Compare commits

..
Author SHA1 Message Date
esengine 5207d6f89b docs: draft code signing policy page (not in force) 2026-09-30 01:46:32 -07:00
YHH 20354e388f Merge pull request #11395 from esengine/ci/studio-cli-channels-hardening
ci(release): attest and gate the CLI channels job
2026-09-30 01:07:04 -07:00
esengine 4111108fc4 ci(release): attest and gate the CLI channels job
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.
2026-09-30 00:49:28 -07:00
YHH fd3c7f06aa Merge pull request #11394 from esengine/fix/dns-not-conn-reset
fix(errmsg): report DNS resolution failures as such, not as a connection reset
2026-09-30 00:22:15 -07:00
YHH 9e8fd7b7f7 Merge pull request #11389 from esengine/fix/storage-survey-11385
fix(storage): bound each root's size walk and show partial results
2026-09-30 00:20:09 -07:00
esengine bfbde4dea7 fix(errmsg): report DNS resolution failures as such, not as a connection reset
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
2026-09-30 00:14:09 -07:00
esengine 31f54d8cc7 fix(storage): bound each root's size walk and show partial results
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.
2026-09-30 00:10:47 -07:00
YHH 578f55aac2 Merge pull request #11390 from esengine/fix/interrupted-recovery-precedence-studio
fix(agent): new user message outranks the interrupted-turn recovery block
2026-09-29 23:51:14 -07:00
esengine f2b9ef6561 fix(agent): new user message outranks the interrupted-turn recovery block
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
2026-09-29 23:38:13 -07:00
YHH c4ed71f9ae Merge pull request #11387 from esengine/fix/workbench-files-tab-11382
fix(studio): picking a browser tab steps the docked file list aside
2026-09-29 23:14:53 -07:00
esengine 3cdf8dbd91 fix(studio): picking a browser tab steps the docked file list aside
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.
2026-09-29 23:06:48 -07:00
YHH 7ead478adf Merge pull request #11380 from chuiwenwei/test/studio-verification-stress-solutions
test(e2ebench): give the ten vs-pos tasks the reference solutions the guard needs
2026-09-29 21:48:01 -07:00
Will 01fddd508b test(e2ebench): give the ten vs-pos tasks the reference solutions the guard needs
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.
2026-09-30 12:26:39 +08:00
YHH d5272e17c8 Merge pull request #11367 from chuiwenwei/fix/studio-memorybench-grader-witness
test(e2ebench): assert every memorybench grader accepts its task's correct answer
2026-09-29 20:18:30 -07:00
YHH fea401fa6f Merge pull request #11359 from BuGlessRB/open/diff-fence-scoped-notify
fix(cli): stop the diff formatter repainting every block when a run lands
2026-09-29 20:02:14 -07:00
Will 9eca20c760 test(e2ebench): assert every memorybench grader accepts its task's correct answer
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.
2026-09-30 11:01:59 +08:00
YHH 013caa0859 Merge pull request #11369 from chuiwenwei/fix/studio-policy-flag-row
docs(bench): add the missing -policy row to the e2ebench flag table
2026-09-29 20:01:04 -07:00
YHH c9446388ef Merge pull request #11370 from chuiwenwei/docs/studio-11362-index-corpus-bar
docs(context-retrieval): correct the Index corpus v2 qualification bar
2026-09-29 20:00:13 -07:00
YHH 3dd2b82bf0 Merge pull request #11364 from chuiwenwei/fix/studio-ablate-doc-list
fix(cli): list every ablatable module in the --ablate surfaces
2026-09-29 19:58:13 -07:00
YHH 1793d22f00 Merge pull request #11371 from chuiwenwei/fix/studio-cli-ablate-recall-search
docs(cli): list recall-search in the --ablate module list
2026-09-29 19:50:53 -07:00
Will eabc0a2660 docs(bench): add the missing -policy row to the e2ebench flag table
`-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.
2026-09-30 10:33:04 +08:00
Will 89962c4e98 docs(cli): list recall-search in the --ablate module list
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.
2026-09-30 10:30:03 +08:00
Will 7fde39d38f docs(context-retrieval): correct the Index corpus v2 qualification bar
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.
2026-09-30 10:27:48 +08:00
YHH d30147226a Merge pull request #11365 from esengine/fix/cloud-desktop-post-content-type
fix(serve): give bodyless remote-desktop writes a JSON content type
2026-09-29 19:21:42 -07:00
YHH 96f223e61d Merge pull request #11360 from chuiwenwei/fix/studio-11358-retire-fullfold-arm
fix(bench): retire the FullFold ablation and the inert compaction -arm flag
2026-09-29 19:21:33 -07:00
YHH 0d526b6c4e Merge pull request #11363 from chuiwenwei/fix/studio-cm-e2e-selfcheck
fix(bench): make context-maintenance-e2e resume providerless and pin continue to one summary
2026-09-29 19:19:35 -07:00
esengine 03d5655717 fix(serve): give bodyless remote-desktop writes a JSON content type
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.
2026-09-29 19:12:35 -07:00
Will 2711e55524 fix(cli): list every ablatable module in the --ablate surfaces
Shell completion, the Chinese CLI doc and the benchmarks flag table all stopped
at compaction, omitting upstream and recall-search.
2026-09-30 10:05:01 +08:00
Will 142b79f19a fix(bench): make context-maintenance-e2e resume providerless and pin continue to one summary
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.
2026-09-30 09:55:23 +08:00
Stephen R. van den Berg 90cf3f3f1f fix(cli): stop the diff formatter repainting every block when a run lands
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.
2026-09-30 03:31:34 +02:00
Will a170f2158a fix(bench): retire the FullFold ablation and the inert compaction -arm flag
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.
2026-09-30 09:20:25 +08:00
esengine d04a260c69 docs(release): Studio 2.23.0 release notes 2026-09-29 17:11:16 -07:00
YHH e7d11ed91f Merge pull request #11201 from BuGlessRB/open/diff-fences
feat(cli): render diff fences, an external diff formatter, and shell results that are whole diffs / feat(cli):渲染 diff 围栏、外部 diff 格式化器,以及整体为 diff 的 shell 结果
2026-09-29 16:47:13 -07:00
Stephen R. van den Berg c3e4c641fb refactor(config): move CLIConfig into its own file
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.
2026-09-29 22:49:16 +02:00
Stephen R. van den Berg 519a4acc11 feat(cli): render a shell result whose whole output is a diff
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.
2026-09-29 22:45:17 +02:00
Stephen R. van den Berg 4dfab0faf5 feat(cli): format diffs with an external command via [cli].diff_formatter
[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.
2026-09-29 22:45:17 +02:00
Stephen R. van den Berg 4622fd295a feat(cli): render ``diff / ``patch fences as a diff
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.
2026-09-29 22:45:17 +02:00
YHH 14c3585759 Merge pull request #11346 from KHG420/codex/studio-memorybench-trajectory-11323
fix(memorybench): reject answer reads found in trajectories
2026-09-29 12:10:31 -07:00
xiexian 324147354e fix(memorybench): fail runs that read answer files in trajectories 2026-09-30 02:32:08 +08:00
YHH 8c196cc72e Merge pull request #11351 from esengine/fix/npm-pinned-source
fix(installsource): accept an exact semver pin on npm package sources
2026-09-29 10:51:27 -07:00
esengine c6031016b1 fix(installsource): accept an exact semver pin on npm package sources
`@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.
2026-09-29 10:41:52 -07:00
YHH 7c9c4ddb2c Merge pull request #11344 from esengine/docs/standard-contracts-2
docs: bring the contract documents up to the docs standard (batch 2)
2026-09-29 10:11:46 -07:00
YHH 69999fe72e Merge pull request #11335 from KHG420/codex/studio-market-trust-badge
fix(studio): clarify market trust badge
2026-09-29 10:09:29 -07:00
YHH 8479846661 Merge branch 'studio' into docs/standard-contracts-2 2026-09-29 10:02:28 -07:00
YHH 26ba59a21c Merge pull request #11258 from BuGlessRB/open/expose-stream-idle-timeout
feat(provider): per-provider stream idle timeout (idle_timeout_seconds) / 功能(provider):按 provider 设置流空闲超时(idle_timeout_seconds)
2026-09-29 09:40:21 -07:00
esengine 0e03a900b5 docs: bring the contract documents up to the docs standard
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.
2026-09-29 09:40:04 -07:00
YHH 7d068b874c Merge pull request #11341 from KHG420/codex/studio-installed-sidecar-effect
test(extension): verify installed SDK sidecar lifecycle
2026-09-29 09:28:30 -07:00
YHH 56b843150c Merge pull request #11340 from KHG420/codex/studio-memorybench-answer-isolation-11323
fix(memorybench): isolate committed answers from graded runs
2026-09-29 09:25:58 -07:00
YHH 5c69bbac54 Merge pull request #11337 from KHG420/codex/studio-market-page-overlap
fix(studio): dedupe overlapping market pages
2026-09-29 09:25:03 -07:00
Stephen R. van den Berg 1a0f26d06d feat(provider): per-provider stream idle timeout (idle_timeout_seconds)
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.
2026-09-29 17:52:35 +02:00
xiexian 61f8c4e106 test(extension): verify installed SDK sidecar lifecycle 2026-09-29 23:40:08 +08:00
YHH c3f0838207 Merge pull request #11332 from KHG420/codex/studio-multifile-skill-example
docs(studio): add multi-file skill package example
2026-09-29 08:35:58 -07:00
YHH 5e838e4342 Merge pull request #11325 from esengine/fix/studio-update-delta-robust
fix(update): keep Studio's delta on another volume and make full downloads resumable
2026-09-29 08:27:26 -07:00
YHH 07d3ffdd00 Merge pull request #11331 from Guan0923/fix/studio-composer-send-visibility
fix(studio): hide send arrow for empty live drafts
2026-09-29 08:26:26 -07:00
YHH 0dee47f5f3 Merge pull request #11321 from Guan0923/fix/studio-project-path-marquee
fix(studio): wrap long workspace paths inside project menus
2026-09-29 08:25:17 -07:00
xiexian e4b673621e fix(memorybench): deny answer checkout during graded runs 2026-09-29 23:23:45 +08:00
YHH f11b121a50 Merge pull request #11330 from Guan0923/fix/studio-stale-ask-after-rewrite
fix(studio): clear stale ask cards after rewriting a message
2026-09-29 08:20:26 -07:00
YHH 8b083a107c Merge pull request #11333 from esengine/fix/memorybench-contradiction-grader
fix(memorybench): mb-contradiction grader can never pass
2026-09-29 08:16:17 -07:00
xiexian 46baa69def fix(studio): dedupe overlapping market pages 2026-09-29 23:16:15 +08:00
YHH 5b9b54596a Merge pull request #11327 from KHG420/codex/studio-shell-progress-codepage-11311
fix(studio): decode code-page shell progress before TUI display
2026-09-29 08:09:29 -07:00
xiexian f4564fd810 fix(studio): clarify market trust badge 2026-09-29 23:03:01 +08:00
esengine cb220f64e5 fix(update): keep Studio's delta on another volume and make full downloads resumable
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.
2026-09-29 07:54:23 -07:00
YHH a2015d29ca Merge pull request #11328 from KHG420/codex/studio-mock-theme-installed-location
fix(studio): show installed theme location in market demo
2026-09-29 07:53:34 -07:00
YHH 29beca2922 Merge pull request #11324 from KHG420/codex/studio-market-skill-assets-guide
docs(studio): clarify market skill asset packaging
2026-09-29 07:52:28 -07:00
esengine c55bcc1f8d fix(memorybench): stop mb-contradiction from rejecting its own required answer
"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
2026-09-29 07:52:06 -07:00
xiexian 2abcc7637e docs(studio): add multi-file skill package example 2026-09-29 22:51:14 +08:00
Guan09 78a67947ec fix(studio): hide send arrow for empty live drafts 2026-09-29 22:43:59 +08:00
Guan09 465d7e7dd9 fix(studio): clear expired ask cards before history restore 2026-09-29 22:43:00 +08:00
YHH ffc0bec51b Merge pull request #11326 from KHG420/codex/studio-installed-mcp-direct-effect
test(studio): exercise installed standalone MCP through boot
2026-09-29 07:42:07 -07:00
xiexian 9882d1349a docs(studio): clarify market skill asset packaging 2026-09-29 22:26:04 +08:00
xiexian 18c8fba59f fix(studio): show installed theme location in market demo 2026-09-29 22:22:49 +08:00
xiexian 6075673d9e test(studio): exercise installed standalone MCP through boot 2026-09-29 22:13:32 +08:00
YHH 3b60665854 Merge pull request #11322 from esengine/fix/studio-update-mirror-first
fix(update): fetch Studio artifacts from the mirror first, GitHub as fallback
2026-09-29 07:09:04 -07:00
YHH d59ed66dbc Merge pull request #11315 from KHG420/codex/studio-package-mcp-effect
test(studio): exercise installed plugin MCP through boot
2026-09-29 07:08:47 -07:00
YHH 35dadd8767 Merge pull request #11320 from KHG420/codex/studio-installed-http-mcp-effect
test(studio): exercise installed HTTP MCP package through boot
2026-09-29 07:06:40 -07:00
xiexian 1b7ea17021 fix(shellrun): decode code-page bytes in live progress 2026-09-29 22:03:20 +08:00
YHH a9332b6954 Merge pull request #11318 from KHG420/codex/studio-installed-skill-effect
test(studio): verify installed skill reaches provider
2026-09-29 06:53:58 -07:00
Guan09 d1a27868e2 fix(studio): wrap long workspace paths in project menus 2026-09-29 21:52:46 +08:00
esengine d1ad88853c fix(update): fetch Studio artifacts from the mirror first, GitHub as fallback
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.
2026-09-29 06:52:12 -07:00
YHH 25e41cd046 Merge pull request #11305 from KHG420/codex/studio-compat-fixtures
test(studio): cover theme-only plugin install lifecycle
2026-09-29 06:45:45 -07:00
YHH 87907cb2a2 Merge pull request #11313 from par73e/fix/studio-config-followups-11310
fix(studio): handle missing config and 1.x desktop approval modes
2026-09-29 06:40:49 -07:00
xiexian 9bb3435e62 test(studio): exercise installed HTTP MCP package through boot 2026-09-29 21:40:04 +08:00
YHH 59cc24b3ab Merge pull request #11317 from esengine/fix/market-trust-unpinned
feat(market): install an unpinned approved package on explicit trust
2026-09-29 06:38:13 -07:00
xiexian 50bdcd6088 test(studio): verify installed skill reaches provider 2026-09-29 21:33:30 +08:00
YHH 250b19c052 Merge pull request #11289 from BuGlessRB/open/extract-provider-entry-render
refactor(config): split provider entry types and table rendering into their own files / 重构(config):将 provider 条目类型与表格渲染拆分到独立文件
2026-09-29 06:31:40 -07:00
YHH cd1d559097 Merge pull request #11100 from esengine/fix/plan-check-own-effects
fix(agent): a plan check's own residue does not void it
2026-09-29 06:26:46 -07:00
YHH d821485c19 Merge pull request #11287 from esengine/fix/studio-tui-scrollbar-align
fix(tui): draw every row whole and in its column on Terminal.app, conhost and Windows Terminal
2026-09-29 06:22:55 -07:00
par73e 89d987fb2d fix(studio): avoid missing-config marker writes and read 1.x approval modes 2026-09-29 21:21:22 +08:00
esengine be3fd8a425 feat(market): install an unpinned approved package on explicit trust
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.
2026-09-29 06:20:53 -07:00
esengine 1641c3584d fix(agent): a plan check's own residue does not void it
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.
2026-09-29 06:17:42 -07:00
Stephen R. van den Berg 1356b14d2f refactor(config): split provider entry types and table rendering into their own files
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.
2026-09-29 15:17:09 +02:00
esengine 34ef4b0338 fix(tui): draw every row whole at the width the terminal counts
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.
2026-09-29 06:13:14 -07:00
xiexian e0cec93a9e test(studio): exercise installed plugin MCP through boot 2026-09-29 21:03:48 +08:00
YHH 6808c5a9e0 Merge pull request #11314 from KHG420/codex/studio-scope-refresh
fix(studio): ignore stale extension scope reads
2026-09-29 05:54:31 -07:00
YHH 65ff7dab74 Merge pull request #11308 from KHG420/codex/studio-startup-notice-replay-11300
fix(serve): replay startup notices to first SSE window
2026-09-29 05:52:18 -07:00
xiexian 32a6365d87 fix(studio): ignore stale extension scope reads 2026-09-29 20:35:14 +08:00
xiexian 7d069d44dc fix(serve): replay startup notices only once per session 2026-09-29 20:35:02 +08:00
YHH 8f3ee06fd3 Merge pull request #11307 from KHG420/codex/studio-native-compat-fixture
test(studio): cover declarative plugin install lifecycle
2026-09-29 05:33:21 -07:00
YHH 4579185478 Merge pull request #11275 from KHG420/codex/studio-project-provider-save-11245
fix(config): avoid persisting built-in providers in project config
2026-09-29 05:30:21 -07:00
YHH 11876a0d61 Merge pull request #11298 from esengine/feat/studio-desktop-default-posture
feat(studio): default new window sessions from the sandbox claim and folder trust
2026-09-29 05:10:21 -07:00
xiexian eafc8177e9 test(studio): cover declarative plugin install lifecycle 2026-09-29 19:55:10 +08:00
esengine 337f2d2cb2 feat(studio): default new window sessions from the sandbox claim and folder trust
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.
2026-09-29 04:55:03 -07:00
xiexian 9666c96f3a Merge remote-tracking branch 'fork/studio' into codex/studio-project-provider-save-11245-followup 2026-09-29 19:51:18 +08:00
xiexian 7e271b1164 fix(serve): replay startup notices to first SSE window 2026-09-29 19:49:01 +08:00
xiexian 8d03609fa1 test(studio): cover theme-only plugin install lifecycle 2026-09-29 19:43:56 +08:00
YHH d3fa67857c Merge pull request #11292 from KHG420/codex/studio-ecosystem
feat(studio): find installable market entries and installed capabilities
2026-09-29 04:33:22 -07:00
YHH f3b5febe90 Merge pull request #11295 from KHG420/codex/studio-ecosystem-docs
docs(studio): provide a tested community author workflow
2026-09-29 04:31:38 -07:00
YHH a63c2b92e4 Merge pull request #11288 from esengine/fix/studio-cli-compat-quiet-stderr
fix(cli): keep a headless run's stderr clean; diagnostics behind run --debug
2026-09-29 04:19:13 -07:00
YHH 8467e1acb2 Merge pull request #11280 from Guan0923/fix/reply-regenerate-turn
fix(studio): 修复旧回复重新生成错误地重跑最后一轮 (#11279)
2026-09-29 04:16:17 -07:00
YHH 856d899bc9 Merge pull request #11302 from esengine/fix/studio-tui-unknown-slash-e2e
fix(tui): return from Subscribe only once the stream is attached
2026-09-29 04:10:39 -07:00
xiexian c5310be184 docs(studio): add tested community author workflow 2026-09-29 19:06:43 +08:00
xiexian 21980b4234 feat(studio): prioritize installable market entries and surface installed capabilities 2026-09-29 19:03:00 +08:00
esengine a062240d9f fix(cli): keep a headless run's stderr clean; diagnostics behind run --debug
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.
2026-09-29 03:56:18 -07:00
YHH d56d29f6fc Merge pull request #11235 from esengine/fix/studio-config-preserve-keys
fix(config): rewrite only the keys a user-config save changed
2026-09-29 03:55:29 -07:00
esengine 543633f143 fix(tui): return from Subscribe only once the stream is attached
/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.
2026-09-29 03:53:56 -07:00
xiexian 162ee3edbe fix(config): preserve project provider provenance on save 2026-09-29 18:51:38 +08:00
YHH 19f8d5b001 Merge pull request #11285 from esengine/fix/studio-cli-compat-headless-verdict
fix(cli): headless run exits 0 when the model finishes; verdicts go in the result
2026-09-29 03:17:25 -07:00
YHH e209cf37c8 Merge pull request #11242 from esengine/fix/studio-cli-compat-entries
fix(cli): restore 1.x entry points (chat/code, run --takeover, sessions maintenance, leading --preset)
2026-09-29 03:16:42 -07:00
YHH a1d7886609 Merge pull request #11239 from esengine/fix/studio-cli-compat-setup-help
fix(cli): reasonix setup --help prints usage instead of writing a file
2026-09-29 03:16:00 -07:00
esengine 3e5607272c fix(config): rewrite only the keys a user-config save changed
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.
2026-09-29 03:14:21 -07:00
Guan09 0efb371b9c fix(studio): regenerate replies from their own turn (#11279) 2026-09-29 18:09:35 +08:00
YHH cd360b9d84 Merge pull request #11278 from esengine/fix/studio-cli-compat-fast-fail
fix(cli): fail fast on an unreachable model service, quiet doctor, bare reasonix exits 0
2026-09-29 02:41:07 -07:00
YHH 9683f50271 Merge pull request #11277 from esengine/fix/studio-cli-compat-stream-json
fix(cli): restore 1.x's stream-json and events-jsonl line contract
2026-09-29 02:40:24 -07:00
YHH d305f13d78 Merge pull request #11284 from esengine/fix/studio-11255-short-bubble-align
fix(studio): keep short user bubbles flush right and fold their controls to icons
2026-09-29 02:37:06 -07:00
YHH 8f4ece4453 Merge pull request #11281 from esengine/fix/studio-11267-reading-width
fix(studio): lay transcript cards and the composer on one reading column
2026-09-29 02:36:21 -07:00
esengine 17d681fa2d fix(studio): keep short user bubbles flush right and fold their controls to icons
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
2026-09-29 02:12:00 -07:00
esengine 68ec3c90e1 fix(studio): lay transcript cards and the composer on one reading column
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
2026-09-29 02:09:55 -07:00
YHH 0b80ee3106 Merge pull request #11262 from esengine/fix/studio-tui-shell-local
fix(tui): a ! command runs locally and does not start a model turn
2026-09-29 02:09:27 -07:00
YHH 57a1a66db8 Merge pull request #11177 from KHG420/codex/studio-config-no-unprompted-file-11174
feat(studio): show remembered project rules and default skills to user scope
2026-09-29 01:58:38 -07:00
esengine e19a65af23 fix(tui): a ! command runs locally and does not start a model turn
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.
2026-09-29 01:40:49 -07:00
esengine 4d733ecfda fix(cli): bare reasonix without a terminal exits 0 as in 1.x
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.
2026-09-29 01:40:09 -07:00
esengine fb7e7c381e fix(doctor): stop warning that use_capability is missing
`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.
2026-09-29 01:40:09 -07:00
esengine c906f3cd45 fix(provider): fail at once when the model service refuses the connection
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.
2026-09-29 01:40:09 -07:00
esengine bdf3f96684 fix(cli): headless run exits 0 when the model finishes; verdicts go in the result
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.
2026-09-29 01:40:08 -07:00
esengine fb6a82c8e3 fix(cli): restore 1.x's stream-json and events-jsonl line contract
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.
2026-09-29 01:40:08 -07:00
YHH 4eeb23bfce Merge pull request #11244 from SivanCola/codex/fix-studio-grep-root-globs
fix(grep): preserve search roots across symlinks and sandboxes / 修复符号链接与沙箱下的 grep 搜索范围
2026-09-29 01:39:34 -07:00
YHH 8016f8be98 Merge pull request #11264 from esengine/fix/studio-tui-transcript-noise
fix(tui): keep task bookkeeping and host notes out of the transcript
2026-09-29 01:37:52 -07:00
YHH 917061e58b Merge pull request #11263 from esengine/fix/studio-tui-help-compact-context
fix(tui): /help, /compact's card and /context's breakdown as 1.x shows them
2026-09-29 01:37:09 -07:00
YHH 7be6fee2d5 Merge pull request #11261 from esengine/fix/studio-tui-rewind-clear
fix(tui): /rewind picks a turn, /clear asks first, Esc clears and Esc Esc rewinds
2026-09-29 01:36:27 -07:00
YHH 3a97d8b439 Merge pull request #11259 from esengine/fix/studio-tui-unknown-slash
fix(tui): refuse slash commands nothing answers instead of sending them to the model
2026-09-29 01:35:45 -07:00
YHH 1a3aa44cb3 Merge pull request #11266 from esengine/fix/studio-11250-reply-menu-clip
fix(studio): keep reply action menus whole near the transcript's edges
2026-09-29 01:34:59 -07:00
YHH 1a684d216c Merge pull request #11251 from esengine/fix/studio-11247-memorybench-isolation
fix(e2ebench): keep MemoryBench's memory-off arm free of the seeded store
2026-09-29 01:33:10 -07:00
YHH f8853733d3 Merge pull request #11252 from esengine/fix/studio-powershell-prelude
fix(shell): defensive PowerShell prologue, UTF-8 redirects on 5.1, Store pwsh detection
2026-09-29 01:32:26 -07:00
xiexian 006b361d71 fix(config): omit normalized built-in provider from project saves 2026-09-29 16:32:03 +08:00
YHH 61e1e03bf9 Merge pull request #11256 from esengine/feat/studio-cli-permission-defaults
feat(cli): default the terminal posture from the sandbox claim and folder trust
2026-09-29 01:31:43 -07:00
YHH 5636fed560 Merge pull request #11265 from esengine/fix/studio-tui-i18n
fix(tui): the terminal's own words go through i18n
2026-09-29 01:30:57 -07:00
YHH cf7bbae959 Merge pull request #11260 from esengine/fix/studio-tui-completion-enter
fix(tui): Enter runs a command typed in full instead of completing it
2026-09-29 01:30:05 -07:00
YHH 06805ca7fc Merge pull request #11248 from esengine/fix/studio-relay-lease
fix(remote): reconnect the phone silently across relay leases and desktop reconnects
2026-09-29 01:17:11 -07:00
SivanCola 37bcff7fb9 test(goal): cover whitespace validation without schema changes / 补充目标参数空白校验测试 (#11240)
* 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.
2026-09-29 01:10:18 -07:00
xiexian 50277a2fce fix: revoke matching session grant with remembered rule 2026-09-29 16:06:33 +08:00
esengine a548df7f55 fix(permission): refuse the remaining read-only escapes and trust no home directory 2026-09-29 01:02:46 -07:00
esengine fec7b1eebb fix(studio): keep reply action menus whole near the transcript's edges
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
2026-09-29 01:01:27 -07:00
esengine b77c9fac60 fix(tui): /help, /compact's card and /context's breakdown as 1.x shows them
/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.
2026-09-29 01:00:14 -07:00
SivanCola 75abe61e97 fix(grep): keep the physical search root explicit inside sandboxes
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.
2026-09-29 15:54:41 +08:00
esengine f91bad3ca1 fix(shell): make the PowerShell prologue defensive and fix 5.1 redirect encoding
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.
2026-09-29 00:51:43 -07:00
esengine b7df42b3cd fix(tui): the terminal's own words go through i18n
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.
2026-09-29 00:40:08 -07:00
esengine a32fbea44d fix(e2ebench): keep MemoryBench's memory-off arm free of the seeded store
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
2026-09-29 00:35:28 -07:00
esengine 72c81815bc fix(tui): /rewind picks a turn, /clear asks first, Esc clears and Esc Esc rewinds
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.
2026-09-29 00:32:35 -07:00
esengine 188c373baa fix(tui): keep task bookkeeping and host notes out of the transcript
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).
2026-09-29 00:31:18 -07:00
esengine 527eee4b4c fix(permission): close the read-only escapes and keep the posture's sensitive code declared
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.
2026-09-29 00:30:16 -07:00
esengine d736354248 fix(remote): bind controller commands to their session and resend only to the same desktop
- 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().
2026-09-29 00:15:58 -07:00
esengine 980008de88 feat(cli): default the terminal posture from the sandbox claim and folder trust
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.
2026-09-29 00:00:47 -07:00
YHH 1115efbc9f Merge pull request #11243 from esengine/feat/studio-11237-provider-display-name
feat(providers): rename a model service without changing its identity
2026-09-28 23:54:24 -07:00
esengine 04a8b3542a fix(tui): Enter runs a command typed in full instead of completing it
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.
2026-09-28 23:48:08 -07:00
SivanCola 14aae2c4c0 fix(grep): anchor directory searches across symlink aliases
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.
2026-09-29 14:47:32 +08:00
esengine d8ba78d183 fix(remote): keep phone control alive across relay leases and reconnects
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
2026-09-28 23:45:51 -07:00
xiexian c255459219 Register remembered-rule revoke action 2026-09-29 14:45:05 +08:00
esengine 653439a3b4 fix(tui): refuse slash commands nothing answers instead of sending them to the model
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.
2026-09-28 23:43:35 -07:00
esengine 35bdacd24b fix(cli): restore 1.x entry points: chat/code, run --takeover, sessions maintenance, leading --preset
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.
2026-09-28 23:36:51 -07:00
esengine 023f20e974 feat(providers): rename a model service without changing its identity
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
2026-09-28 23:33:15 -07:00
xiexian 76afa1a77c Merge remote-tracking branch 'origin/studio' into codex/studio-config-no-unprompted-file-11174 2026-09-29 14:31:13 +08:00
xiexian 28067f69ff fix(studio): let users revoke remembered project rules 2026-09-29 14:31:03 +08:00
esengine f90b50ec0a fix(cli): stop reasonix setup from writing its flags as config files
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.
2026-09-28 23:24:44 -07:00
YHH d2da65b8c4 Merge pull request #11233 from esengine/fix/studio-11229-windows-command-line-limit
fix(bash): name the Windows command-line limit instead of "filename too long" (studio)
2026-09-28 23:07:21 -07:00
YHH 13b87dd4e9 Merge pull request #11231 from esengine/fix/studio-1x-context-sidecar
fix(session): keep 1.x context sidecars and carry their summary forward
2026-09-28 23:06:39 -07:00
YHH 4a6e55ff08 Merge pull request #11234 from esengine/fix/studio-import-marker-test-windows
test(sessionstore): encode the import-marker fixture's workspace as JSON
2026-09-28 23:05:56 -07:00
YHH c57193ea4f Merge pull request #11236 from esengine/feat/studio-11230-find-in-session
feat(studio): find in a conversation that Ctrl+F actually reaches
2026-09-28 23:05:47 -07:00
YHH af12885528 Merge pull request #11225 from KHG420/codex/studio-ask-markdown-11219
fix(studio): format ask prompt text
2026-09-28 23:05:04 -07:00
YHH 56b68e2701 Merge pull request #11206 from KHG420/codex/1x-unicode-skill-names-4038
feat(studio): support Unicode skill names
2026-09-28 23:04:13 -07:00
xiexian 6bf8c576eb Merge remote-tracking branch 'origin/studio' into codex/studio-unicode-skill-names-4038 2026-09-29 13:39:16 +08:00
esengine b35f38962f feat(studio): find in a conversation that Ctrl+F actually reaches
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.
2026-09-28 22:36:59 -07:00
xiexian ecf1e37a79 fix(studio): reject invisible Unicode skill identifiers 2026-09-29 13:28:07 +08:00
esengine 607025713e test(sessionstore): encode the import-marker fixture's workspace as JSON
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.
2026-09-28 22:26:21 -07:00
esengine 9f789f9abd fix(bash): name the Windows command-line limit instead of "filename too long"
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
2026-09-28 22:20:10 -07:00
esengine ac30722a7c fix(session): keep 1.x context sidecars and carry their summary forward
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.
2026-09-28 22:19:05 -07:00
xiexian c4cf1388e4 Merge remote-tracking branch 'origin/studio' into codex/studio-config-no-unprompted-file-11174
# Conflicts:
#	internal/assembly/boot/boot.go
#	internal/assembly/boot/boot_test.go
2026-09-29 13:19:01 +08:00
xiexian 1f2bf0b93f fix(studio): match ask prompt body weight to replies 2026-09-29 13:06:11 +08:00
xiexian 422fd16d9c fix(studio): distinguish bold ask prompt text 2026-09-29 12:48:26 +08:00
YHH 38106c9200 Merge pull request #11227 from chuiwenwei/fix/studio-session-import-marker-guard
fix(sessionstore): keep the legacy import marker unstamped when a copy fails
2026-09-28 21:47:32 -07:00
YHH 2917b96263 Merge pull request #11228 from esengine/fix/studio-11226-remote
fix(studio): keep the relayed event stream alive and the tab on its pane
2026-09-28 21:42:48 -07:00
SivanCola 9a9323f3be fix(studio): restore window on macOS Dock activation (#11221)
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.
2026-09-29 12:03:42 +08:00
esengine 90f201ee25 fix(studio): keep the relayed event stream alive and the tab on its pane
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.
2026-09-28 20:58:41 -07:00
Will 641991840d fix(sessionstore): keep the legacy import marker unstamped when a copy fails
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.
2026-09-29 11:57:05 +08:00
YHH 106fe593fd Merge pull request #11223 from esengine/fix/studio-11220-awaiting-user-ask
fix(agent): send choices through ask, keep await_user's need to one line
2026-09-28 20:37:59 -07:00
YHH 944321e2b6 Merge pull request #11224 from esengine/fix/studio-11219-await-notice-markdown
feat(studio): render the await_user notice's need as markdown
2026-09-28 20:35:24 -07:00
xiexian 8b2ca911a1 test(studio): await pending ask interactions before cleanup 2026-09-29 11:31:53 +08:00
esengine 020b388126 fix(agent): send choices through ask, keep await_user's need to one line
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
2026-09-28 20:27:18 -07:00
esengine c5ef5ed10f feat(studio): render the await_user notice's need as markdown
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
2026-09-28 20:27:14 -07:00
xiexian 69ede811cc fix(studio): keep ask Markdown layout consistent 2026-09-29 11:19:50 +08:00
YHH 712fcf10f0 Merge pull request #11222 from esengine/fix/studio-perseveration-approve
test: approve the workspace and pin perseveration fixture bytes
2026-09-28 20:02:28 -07:00
esengine 5c7f6f0801 test: approve the workspace and pin perseveration fixture bytes
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.
2026-09-28 19:54:36 -07:00
xiexian 96465dde21 fix(studio): render ask prompts as Markdown 2026-09-29 10:54:22 +08:00
YHH eaf665edff Merge pull request #11218 from esengine/docs/studio-2.22.0-notes-2
docs(release): add the mobile and workbench fixes to the Studio 2.22.0 notes
2026-09-28 19:15:20 -07:00
YHH 735a6e5998 Merge pull request #11216 from esengine/fix/studio-11207-stream-watchdog
fix(studio): replace an event stream that has gone silent
2026-09-28 19:09:57 -07:00
YHH 6817f58e52 Merge pull request #11215 from esengine/fix/studio-11202-speed-gauge
fix(studio): drop the gauge icon in front of the speed meter
2026-09-28 19:09:13 -07:00
YHH 3dea01d441 Merge pull request #11214 from esengine/fix/studio-11203-hidden-toggle
feat(studio): let the explorer show hidden files on request
2026-09-28 19:08:28 -07:00
YHH 2206a95f12 Merge pull request #11213 from esengine/fix/studio-11204-workbench-close
fix(studio): keep the workbench open while its explorer is showing
2026-09-28 19:07:43 -07:00
esengine dbacae668a docs(release): add the mobile and workbench fixes to the Studio 2.22.0 notes 2026-09-28 19:07:30 -07:00
YHH d23e03b20a Merge pull request #11212 from esengine/fix/studio-11205-lan-explorer
fix(studio): let the files toggle open the explorer on narrow screens
2026-09-28 19:06:58 -07:00
xiexian 42e9c7ce2e feat(studio): support Unicode skill names without changing MCP IDs 2026-09-29 09:49:34 +08:00
esengine 07f25c1ca4 fix(studio): replace an event stream that has gone silent
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.
2026-09-28 18:31:21 -07:00
YHH 6038a6fbf6 Merge pull request #11183 from BuGlessRB/open/perseveration-guard
fix(agent): detect perseveration loops and retry once with a nudge / 修复(agent):检测重复输出循环并在提醒后重试一次
2026-09-28 18:30:26 -07:00
YHH a5f772b718 Merge pull request #11211 from esengine/docs/studio-2.22.0-notes
docs(release): prepare Studio 2.22.0 notes
2026-09-28 18:22:01 -07:00
YHH 23f0a011c4 fix(config): workspace configuration only narrows the user's settings (#11208)
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.
2026-09-28 18:21:34 -07:00
esengine 6c291e8c3e feat(studio): let the explorer show hidden files on request
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.
2026-09-28 18:12:27 -07:00
esengine 395c5eace2 docs(release): prepare Studio 2.22.0 notes 2026-09-28 18:10:58 -07:00
esengine 2f7c0613fd fix(studio): keep the workbench open while its explorer is showing
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.
2026-09-28 18:06:17 -07:00
esengine dd381070f4 fix(studio): drop the gauge icon in front of the speed meter
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.
2026-09-28 18:03:14 -07:00
esengine b1c22c9cfe fix(studio): let the files toggle open the explorer on narrow screens
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.
2026-09-28 18:01:46 -07:00
Stephen R. van den Berg 291a03a9ca fix(agent): detect perseveration by default, reported via progress watch
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.
2026-09-29 02:55:50 +02:00
YHH 0dd3e5574a Merge pull request #11074 from BuGlessRB/open/vi-command-mode
feat(cli): add vi command mode to the composer prompt / 功能(cli):为输入框加入 vi 命令模式
2026-09-28 16:58:38 -07:00
YHH 07291a5f04 Merge pull request #11196 from esengine/fix/studio-updater-ux
fix(studio): ask before restarting into an update, and pin only on rollback
2026-09-28 16:56:48 -07:00
YHH d58f25580e Merge pull request #11198 from esengine/fix/studio-browser-panel-cover
fix(studio): the browser panel pauses only for a layer that is really over it
2026-09-28 16:53:15 -07:00
YHH 77d92a8eb9 Merge pull request #11197 from esengine/feat/studio-11192-per-model-effort
feat(effort): per-model reasoning levels on a relay
2026-09-28 16:52:30 -07:00
esengine d1ab26d6f4 fix(studio): ask before restarting into an update, and pin only on rollback
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.
2026-09-28 16:48:55 -07:00
Stephen R. van den Berg ee786a1ca6 feat(cli): add vi command mode to the composer prompt
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.
2026-09-29 01:01:58 +02:00
YHH 562cdd3f7e Merge pull request #10945 from BuGlessRB/open/continue-copy-fresh-session
fix(cli): start fresh on --continue --copy with no session / 修复(cli):--continue --copy 无会话时改为新开会话
2026-09-28 13:18:55 -07:00
Stephen R. van den Berg 422caf9595 fix(cli): skip --copy only on a fresh --continue, not the picker
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.
2026-09-28 21:47:47 +02:00
Stephen R. van den Berg 1fb3f55396 fix(cli): start fresh on --continue --copy with no session
`--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.
2026-09-28 21:20:53 +02:00
YHH d158d19aef Merge pull request #11200 from esengine/fix/studio-11199-openai-preset-responses
fix(config): serve the OpenAI curated preset on the Responses API
2026-09-28 12:12:04 -07:00
esengine ebabe620e1 fix(config): serve the OpenAI curated preset on the Responses API
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/reasoning

Closes #11199
2026-09-28 12:02:25 -07:00
esengine 24f11e8ecc fix(studio): the browser panel pauses only for a layer that is really over it
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.
2026-09-28 10:27:16 -07:00
esengine 40c87d94c6 feat(effort): let each model on a relay declare its own reasoning levels
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
2026-09-28 10:21:04 -07:00
YHH 54b7bc20bb Merge pull request #11195 from esengine/fix/studio-browser-false-occlusion
fix(browser): judge a click by where the pointer would really land
2026-09-28 10:06:12 -07:00
YHH 4d486b4829 Merge pull request #11194 from esengine/feat/studio-model-modes
feat(model-modes): declare optional model modes; OpenAI Pro on GPT-5.6/GPT-6
2026-09-28 09:58:24 -07:00
esengine 2c85351b91 fix(browser): judge a click by where the pointer would really land
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.
2026-09-28 09:56:57 -07:00
YHH b127b607fa Merge pull request #11193 from esengine/feat/global-provider-presets
feat(config): curated presets for OpenRouter, OpenAI, Gemini and Volcengine Ark
2026-09-28 09:53:19 -07:00
YHH 40891fdc66 Merge pull request #11191 from esengine/docs/skills-guide
docs: add a Skills guide
2026-09-28 09:51:56 -07:00
esengine 6e5d404ddd feat(config): curated presets for OpenRouter, OpenAI, Gemini and Volcengine Ark
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.
2026-09-28 09:43:04 -07:00
esengine c0f4d5ea15 feat(model-modes): declare optional model modes and offer OpenAI Pro on GPT-5.6/GPT-6
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.
2026-09-28 09:39:17 -07:00
esengine 3bca7e6fbe docs: add a Skills guide
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.
2026-09-28 09:24:50 -07:00
xiexian 1bd84d9fdd fix(studio): keep remembered permissions out of workspaces
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.
2026-09-29 00:16:38 +08:00
YHH 025bd17b2f Merge pull request #11189 from esengine/feat/studio-11166-reasoning-levels
feat(effort): GPT-5.6/GPT-6 and Qwen3.8 reasoning ladders, and send what the menu offers
2026-09-28 08:50:11 -07:00
esengine 211d9e8ed8 feat(effort): declare GPT-5.6/GPT-6 and Qwen3.8 reasoning ladders and send what the menu offers
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.
2026-09-28 08:34:47 -07:00
YHH 8c9bbd2557 Merge pull request #11180 from Robertoarce/fix/studio-rewind-menu-labels
fix(studio): translate the rewind menu's scope labels
2026-09-28 07:01:30 -07:00
YHH 908b0aa4b9 Merge pull request #10950 from scrappylabsai/feat/studio-ask-notify-origin
fix(control): name the MCP server and clip the prompt in ask notifications
2026-09-28 06:50:46 -07:00
YHH 572206843f Merge pull request #11176 from KHG420/codex/studio-persist-drafts-9580
fix(studio): restore unsent text drafts per session
2026-09-28 06:37:55 -07:00
Brian ConnellyandClaude Opus 5.5 05723cf928 refactor(control): move askNotificationText into ask_notification.go
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]>
2026-09-28 09:34:38 -04:00
Roberto 9269f039df fix(studio): translate the rewind menu's scope labels
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).
2026-09-28 15:32:38 +02:00
xiexian 82377396a5 fix(studio): restore unsent text drafts per session 2026-09-28 21:02:40 +08:00
YHH 85a3db0114 Merge pull request #11173 from BuGlessRB/open/project-config-path-resolver
refactor(config): one resolver for the project config path / 重构(config):为项目配置路径提供单一解析器
2026-09-28 04:02:25 -07:00
Stephen R. van den Berg 922efd90a3 refactor(config): one resolver for the project config path
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.
2026-09-28 12:51:12 +02:00
YHH 25bbd84e1b Merge pull request #11172 from chuiwenwei/fix/studio-migrate-index-guard
fix(memory): check the index is readable before migrating facts
2026-09-28 03:46:58 -07:00
Will 3e81444615 fix(memory): check the index is readable before migrating facts
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.
2026-09-28 18:22:44 +08:00
YHH 3f86a93bdd Merge pull request #11170 from chuiwenwei/feat/studio-recall-omitted-split
feat(memory): split omitted recall facts by limit and budget
2026-09-28 03:01:04 -07:00
YHH 867cf2752c Merge pull request #11163 from BuGlessRB/open/shell-tool-env
feat(tools): preset environment variables for the bash tool / 工具(tools):为 bash 工具预设环境变量
2026-09-28 02:49:19 -07:00
YHH 129513bde1 Merge pull request #11165 from chuiwenwei/test/studio-restore-budget-assertion
test(memory): assert the pinned-budget refusal in the recovery test
2026-09-28 02:48:32 -07:00
YHH 0feed1a569 Merge pull request #11160 from esengine/feat/studio-no-progress-notice
feat(studio): say when a run stops producing observable progress
2026-09-28 02:45:04 -07:00
Stephen R. van den Berg 88cb1507ec feat(tools): preset environment variables for the bash tool
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.
2026-09-28 11:37:06 +02:00
YHH ad7cb47518 Merge pull request #11167 from kate1128/fix/restore-archived-readable-index-probe
fix(memory): probe index readability before first write in RestoreArc…
2026-09-28 02:31:45 -07:00
Will b59c001ed9 feat(memory): split omitted recall facts by limit and budget
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."
2026-09-28 17:23:57 +08:00
Will 5f25195610 test(memory): assert the pinned-budget refusal by identity
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.
2026-09-28 17:16:29 +08:00
kate1128 04d203959b fix(memory): probe index readability before first write in RestoreArchived 2026-09-28 17:12:44 +08:00
YHH 4160c3307e Merge pull request #11162 from esengine/fix/studio-11159-composer-overflow
fix(studio): keep the composer toolbar inside the composer at every width
2026-09-28 02:02:05 -07:00
YHH fa69e53bdc Merge branch 'studio' into feat/studio-no-progress-notice 2026-09-28 01:55:03 -07:00
YHH 19e77f9313 Merge branch 'studio' into fix/studio-11159-composer-overflow 2026-09-28 01:54:28 -07:00
YHH 9f8ad33f9d Merge pull request #11161 from esengine/docs/studio-2.21.0-notes-wrap
docs(release): split the long backup item in the Studio 2.21.0 notes
2026-09-28 01:41:34 -07:00
esengine e7a9c3f1a7 docs(release): split the long backup item in the Studio 2.21.0 notes 2026-09-28 01:34:45 -07:00
esengine 10276daf1e fix(studio): keep the composer toolbar inside the composer at every width
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
2026-09-28 01:33:59 -07:00
esengine f229559c8f feat(studio): say when a run stops producing observable progress
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
2026-09-28 01:28:33 -07:00
YHH 38f7a5f600 Merge pull request #11129 from esengine/feat/studio-market-votes
feat(studio): vote on market packages, recommended sort, report installs
2026-09-28 01:23:45 -07:00
YHH ee1352964d Merge pull request #11157 from esengine/docs/studio-2.21.0-notes
docs(release): prepare Studio 2.21.0 notes
2026-09-28 01:16:46 -07:00
esengine de30166094 feat(studio): vote on market packages, recommended sort, report installs
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.
2026-09-28 01:14:53 -07:00
esengine 460ff51eb3 docs(release): prepare Studio 2.21.0 notes 2026-09-28 00:47:28 -07:00
YHH 9b0cc7a38e Merge pull request #11154 from esengine/fix/studio-11151-usage-currency
fix(usage): the usage panel reads money in the currency the session does
2026-09-28 00:37:39 -07:00
esengine 6b0a66b3a2 fix(usage): the usage panel reads money in the currency the session does
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.
2026-09-28 00:05:43 -07:00
YHH a98a77b493 Merge pull request #11149 from esengine/feat/studio-11145-inline-skill-chips
feat(studio): skill chips anywhere in the composer line
2026-09-27 23:24:49 -07:00
esengine dac78f1bbd docs(studio): split the chip mirror note to fit the list-item width 2026-09-27 23:05:00 -07:00
esengine ee07b51489 feat(studio): skill chips anywhere in the composer line
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
2026-09-27 22:55:54 -07:00
YHH 4ff9414f4a Merge pull request #11148 from chuiwenwei/fix/studio-restore-subject-guard
fix(memory): enforce save-time invariants when recovering an archived fact
2026-09-27 22:37:00 -07:00
YHH 8da9cac34d Merge pull request #11061 from esengine/fix/studio-8938-deepseek-empty-thinking
fix(anthropic): replay an empty thinking block on DeepSeek tool-call turns
2026-09-27 22:26:46 -07:00
Will a63994d0b9 fix(memory): enforce save-time invariants when recovering an archived fact
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.
2026-09-28 13:25:47 +08:00
YHH 5a4a21bab7 Merge pull request #11143 from chuiwenwei/fix/studio-index-flush-read-error
fix(memory): preserve the MEMORY.md index when reads fail
2026-09-27 22:24:36 -07:00
YHH 6423d8df85 Merge branch 'studio' into fix/studio-8938-deepseek-empty-thinking 2026-09-27 22:19:53 -07:00
YHH df659ea523 Merge pull request #11135 from esengine/feat/studio-market-private
feat(studio): install your own market packages, and publish privately
2026-09-27 22:09:18 -07:00
YHH 00ba31d044 Merge branch 'studio' into feat/studio-market-private 2026-09-27 21:55:50 -07:00
YHH 510676e625 Merge pull request #11121 from esengine/fix/studio-11115-delta-swap-preflight
fix(update): only take a Windows chunked update where the swap can write
2026-09-27 21:54:56 -07:00
YHH 4f0faa4afe Merge pull request #11133 from KHG420/codex/studio-shared-running-status
fix(studio): reflect running turns on newly joined devices
2026-09-27 21:54:38 -07:00
Will e2e514710d fix(memory): preserve the MEMORY.md index when reads fail
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.
2026-09-28 12:44:31 +08:00
YHH 9539b02d41 Merge pull request #11142 from chuiwenwei/fix/studio-quickadd-read-error
fix(memory): surface read errors in quick-add instead of overwriting doc files
2026-09-27 21:20:11 -07:00
YHH 84974cb155 Merge pull request #11141 from esengine/ci/replies-discussion-close
ci(replies): close a discussion after answering it
2026-09-27 20:57:14 -07:00
Will f369bb7c4d fix(memory): surface read errors in quick-add instead of overwriting doc files
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.
2026-09-28 11:56:27 +08:00
YHH 04213f2cd7 Merge pull request #11139 from esengine/chore/update-acknowledgments
Update acknowledgments contributors / 更新致谢贡献者
2026-09-27 20:49:32 -07:00
esengine 560de562bc ci(replies): close a discussion after answering it
A discussion entry may carry close: resolved, outdated or duplicate; the bot
closes it with that reason after posting the reply.
2026-09-27 20:49:23 -07:00
YHH 6ce8bcb460 Merge pull request #11138 from esengine/ci/replies-discussions
ci(replies): answer discussions as github-actions too
2026-09-27 20:46:43 -07:00
SivanCola a99ed416b0 docs: update acknowledgments contributors 2026-09-28 03:40:38 +00:00
esengine e8a0e2aad3 ci(replies): answer discussions as github-actions too
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.
2026-09-27 20:38:59 -07:00
YHH d6a6c2855d Merge pull request #11126 from esengine/feat/studio-market-compact
feat(studio): compact market rows and an installable-only filter
2026-09-27 20:33:26 -07:00
esengine 0614529acc test(studio): give the market row fixture the status field publishing added 2026-09-27 20:25:59 -07:00
YHH eb82ea896d Merge branch 'studio' into feat/studio-market-compact 2026-09-27 20:13:51 -07:00
YHH dfeeace5ab Merge pull request #11112 from esengine/feat/studio-market-publish
feat(studio): publish skills, plugins, MCP servers and themes to the community market
2026-09-27 20:12:54 -07:00
xiexian cfc25dc8f1 fix(studio): refresh hidden pane status at turn boundaries 2026-09-28 10:57:58 +08:00
esengine 30ac7c3fe6 feat(studio): install your own market packages, and publish privately
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.
2026-09-27 19:48:21 -07:00
xiexian 0040a38ce9 fix(studio): show running state when a device joins mid-turn 2026-09-28 10:29:19 +08:00
YHH ae27da71e2 Merge pull request #11120 from esengine/fix/studio-11116-mobile-model-picker
fix(studio): keep the send cluster from covering the model and effort pickers on phones
2026-09-27 19:04:04 -07:00
esengine b6a2e78aea feat(studio): compact market rows and an installable-only filter
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.
2026-09-27 18:57:32 -07:00
esengine 51c5ce1c86 fix(update): only take a Windows chunked update where the swap can write
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
2026-09-27 18:47:22 -07:00
esengine 9221be649e fix(studio): keep the send cluster from covering the model and effort pickers on phones
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
2026-09-27 18:39:53 -07:00
YHH f89cdcc0c4 Merge pull request #11110 from esengine/fix/studio-hardening-0928
fix: harden serve authentication, host git and sandboxed git metadata (studio)
2026-09-27 18:30:36 -07:00
esengine dfc9d1ee28 fix(sandbox): collapse over-budget git groups to one bwrap mount
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.
2026-09-27 18:16:21 -07:00
esengine 9feb5188a7 feat(studio): publish to the community market from Studio, and theme packages
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.
2026-09-27 18:06:24 -07:00
esengine 8a2d067dd0 fix(control): keep controller.go within its line budget 2026-09-27 17:48:08 -07:00
esengine 9ae9bc4bc7 fix(sandbox): keep repository git config and hooks read-only inside the bash sandbox
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.
2026-09-27 17:36:26 -07:00
esengine 5bcba146d8 fix(gitcmd): host git never runs repository-configured programs (studio)
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.
2026-09-27 17:36:26 -07:00
esengine 7a562055e5 fix(serve): authenticate by default; approvals require the launch token
`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.
2026-09-27 17:35:46 -07:00
YHH fba2fbad2a Merge pull request #11107 from esengine/feat/studio-community-market
feat(studio): community market tab, installed pinned to the reviewed content
2026-09-27 17:33:09 -07:00
esengine cf2c2dcdaa feat(studio): community market tab, installed pinned to the reviewed content
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, ...).
2026-09-27 17:25:41 -07:00
YHH 5b707c00ee Merge pull request #11109 from esengine/feat/studio-config-backup
feat(studio): back up configuration to the signed-in account
2026-09-27 17:02:20 -07:00
esengine b328f4de7f feat(studio): back up configuration to the signed-in account
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.
2026-09-27 16:33:55 -07:00
YHH 093e5e1b3b Merge pull request #11102 from esengine/fix/studio-11101-ask-reason-label
fix(studio): translate the ask card's reason label
2026-09-27 11:32:58 -07:00
esengine f71fb49285 fix(studio): translate the ask card's reason label 2026-09-27 11:26:21 -07:00
YHH 85020e081b Merge pull request #11095 from esengine/feat/plan-deliverable-gate
feat(agent): the readiness gate holds the plan deliverable
2026-09-27 10:34:32 -07:00
YHH 9e137c4c51 Merge pull request #11098 from esengine/fix/studio-web-bare-entry
fix(web-studio): send a visit without a device to the device picker
2026-09-27 10:19:20 -07:00
esengine 4afe8aba93 fix(web-studio): send a visit without a device to the device picker
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.
2026-09-27 10:15:53 -07:00
YHH 1b0d257c5b Merge pull request #11097 from esengine/docs/studio-2.20.4-notes-complete
docs(release): list the fixes and changes Studio 2.20.4 ships
2026-09-27 09:54:49 -07:00
esengine 9a5854f6b6 docs(release): list the fixes and changes Studio 2.20.4 ships 2026-09-27 09:54:30 -07:00
YHH 32f24869c9 Merge pull request #11096 from esengine/docs/studio-2.20.4-notes-restore
docs(release): restore the Studio 2.20.4 notes the release was tagged with
2026-09-27 09:46:03 -07:00
esengine 78e68ef9ea docs(release): restore the Studio 2.20.4 notes the release was tagged with 2026-09-27 09:45:45 -07:00
YHH 46aa95c3ae Merge pull request #11094 from esengine/docs/studio-2.20.4-notes
docs(release): Studio 2.20.4 notes
2026-09-27 09:44:50 -07:00
esengine e70cdeaecc feat(agent): the readiness gate holds the plan deliverable
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.
2026-09-27 09:43:11 -07:00
esengine d7df48e18a docs(release): Studio 2.20.4 notes 2026-09-27 09:42:18 -07:00
YHH bc092a8bd4 Merge pull request #11091 from esengine/fix/studio-publish-hash-check
ci(release-studio): publish only the Windows packages the signing jobs recorded
2026-09-27 09:28:15 -07:00
YHH 6ef0be5699 Merge pull request #11090 from esengine/fix/plan-check-baseline
fix(agent): a plan check the host cannot classify does not void itself
2026-09-27 09:23:01 -07:00
esengine 8efaa51368 ci(release-studio): publish only the Windows packages the signing jobs recorded
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.
2026-09-27 09:17:49 -07:00
YHH da53ed147a Merge pull request #11080 from esengine/fix/studio-sign-all-pe
ci(release-studio): sign and verify every PE file in the Windows tree
2026-09-27 09:17:06 -07:00
YHH e78ec4bdb5 fix(studio): tolerate slow mobile relay handshakes (#11088) 2026-09-27 09:16:22 -07:00
esengine d1bbd6d013 fix(agent): a plan check the host cannot classify does not void itself
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.
2026-09-27 09:15:45 -07:00
esengine 579c59da70 ci(release-studio): sign and verify every PE file in the Windows tree
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.
2026-09-27 09:09:45 -07:00
YHH 5226d8426c Merge pull request #11087 from esengine/fix/studio-8900-web-search-result-delta
fix(anthropic): surface web search results delivered in a result delta
2026-09-27 09:08:54 -07:00
esengine 8835d41f52 fix(anthropic): surface web search results delivered in a result delta
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
2026-09-27 09:01:04 -07:00
YHH e269fb5dfe feat(studio): add account-gated remote QR access (#11081)
* feat(studio): add account-gated remote QR access

* test(studio): register cloud QR actions
2026-09-27 09:00:53 -07:00
YHH cc62872d88 Merge pull request #11041 from esengine/dependabot/npm_and_yarn/desktop/frontend-next/vitest-5.0.1
build(deps-dev): bump vitest from 4.1.11 to 5.0.1 in /desktop/frontend-next
2026-09-27 08:58:17 -07:00
YHH 34b1c90599 Merge pull request #11083 from esengine/fix/studio-8635-acp-usage-update
feat(acp): report context size and session cost with usage_update
2026-09-27 08:57:12 -07:00
YHH 31b9f862ad Merge pull request #11084 from esengine/fix/studio-11079-explorer-path
fix(serve): open the theme folder when PATH lacks %SystemRoot%
2026-09-27 08:56:40 -07:00
YHH 21b1fc4e68 Merge pull request #11078 from esengine/fix/studio-11072-ask-history
fix(studio): keep an answered ask readable after a session is reopened
2026-09-27 08:47:39 -07:00
YHH dbac88ee4f Merge pull request #11064 from esengine/fix/studio-6337-blank-line-cause
fix(tools): name a blank-line difference when old_string is not found
2026-09-27 08:47:00 -07:00
esengine c2544fd826 fix(serve): open the theme folder when PATH lacks %SystemRoot%
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
2026-09-27 08:45:17 -07:00
dependabot[bot] a5ee283391 build(deps-dev): bump vitest in /desktop/frontend-next
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.11 to 5.0.1.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.1/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 5.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-27 15:44:07 +00:00
YHH 40f4ae414d Merge pull request #11042 from esengine/dependabot/github_actions/actions-1be3298642
build(deps): bump the actions group with 6 updates
2026-09-27 08:41:43 -07:00
esengine c3d72bcde3 feat(acp): report context size and session cost with usage_update
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.
2026-09-27 08:41:19 -07:00
YHH c8ae7c5d18 Merge pull request #11069 from esengine/fix/studio-shell-startup-log
fix(studio): log shell and host startup, and say why a launch failed
2026-09-27 08:38:19 -07:00
YHH acc58dfb71 Merge pull request #11075 from esengine/fix/studio-8327-deterministic-reset-fixture
test(openai): pin the body-phase reset fixture to the body phase
2026-09-27 08:37:41 -07:00
YHH 3f52cbfd9f Merge pull request #11039 from esengine/dependabot/npm_and_yarn/desktop/electron/npm-afe829c948
build(deps-dev): bump the npm group in /desktop/electron with 2 updates
2026-09-27 08:33:38 -07:00
YHH bd8ffe1e92 Merge pull request #11040 from esengine/dependabot/npm_and_yarn/desktop/frontend-next/npm-6bd16fb67e
build(deps): bump the npm group in /desktop/frontend-next with 10 updates
2026-09-27 08:32:56 -07:00
esengine 7b8a2b5901 fix(studio): keep an answered ask readable after a session is reopened
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.
2026-09-27 08:32:40 -07:00
YHH 7f6213aaa7 fix(studio): skip replayed history on remote connect (#11073) 2026-09-27 08:29:18 -07:00
esengine d9b76f038e test(openai): pin the body-phase reset fixture to the body phase
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.
2026-09-27 08:25:21 -07:00
YHH 163c813f40 Merge branch 'studio' into dependabot/github_actions/actions-1be3298642 2026-09-27 08:25:17 -07:00
YHH 302c846fd2 Merge branch 'studio' into dependabot/npm_and_yarn/desktop/electron/npm-afe829c948 2026-09-27 08:24:39 -07:00
YHH 0c4f686301 Merge branch 'studio' into dependabot/npm_and_yarn/desktop/frontend-next/npm-6bd16fb67e 2026-09-27 08:24:00 -07:00
esengine debf38663a fix(studio): log shell and host startup, and say why a launch failed
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.
2026-09-27 08:21:34 -07:00
YHH 27ef752d05 Merge pull request #11034 from esengine/dependabot/go_modules/go-ca2f16057d
build(deps): bump the go group with 13 updates
2026-09-27 08:21:29 -07:00
YHH eb70520cfa Merge pull request #11070 from esengine/fix/studio-7867-inline-code-links
fix(studio): open a code span that holds one web address as a link
2026-09-27 08:20:40 -07:00
YHH 5f627edac4 perf(studio): cache fingerprinted web assets (#11071) 2026-09-27 08:19:57 -07:00
esengine 3fa681eb77 fix(studio): open a code span that holds one web address as a link
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.
2026-09-27 08:11:46 -07:00
YHH f02c9f2338 Merge pull request #11067 from esengine/codex/studio-remote-fast-connect
perf(studio): overlap remote authorization with boot
2026-09-27 08:05:32 -07:00
YHH 04ea28361a Merge pull request #11068 from esengine/fix/studio-7338-tree-refresh
fix(studio): refresh the explorer when a writing tool call returns
2026-09-27 08:02:31 -07:00
esengine 6eaae7d27f fix(studio): refresh the explorer when a writing tool call returns
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
2026-09-27 07:51:51 -07:00
esengine 4cba7d0975 perf(studio): overlap remote authorization with boot 2026-09-27 07:51:19 -07:00
YHH 337b8233bd Merge pull request #11063 from esengine/fix/studio-5226-user-agent
feat(provider): identify model requests as Reasonix/<version>
2026-09-27 07:47:45 -07:00
YHH e383f3febc Merge pull request #11060 from esengine/feat/plan-deliverable
feat(contract): a plan that names files owes a proven change
2026-09-27 07:37:13 -07:00
esengine 131400b9b3 fix(tools): name a blank-line difference when old_string is not found
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
2026-09-27 07:27:56 -07:00
esengine 3a51cb1e42 build(deps): tidy the desktop module for the go group bumps
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.
2026-09-27 07:20:11 -07:00
esengine d9d436dd70 feat(provider): identify model requests as Reasonix/<version>
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.
2026-09-27 07:19:02 -07:00
YHH c077f7caac Merge pull request #11058 from esengine/codex/studio-remote-presence
feat(studio): show and control Web Studio sessions
2026-09-27 07:17:24 -07:00
esengine f37f943f78 docs(studio): conform 2.20.4 release notes 2026-09-27 07:12:23 -07:00
esengine c3591b1599 fix(anthropic): replay an empty thinking block on DeepSeek tool-call turns
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.
2026-09-27 07:12:20 -07:00
YHH 3ea34bf5d5 Merge pull request #10946 from boscocp/fix/studio-blank-line-runs
fix(tools): match an old_string whose blank-line run differs in length / 匹配空行数量不同的 old_string
2026-09-27 07:12:12 -07:00
esengine a9b84b453b feat(contract): a plan that names files owes a proven change
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.
2026-09-27 07:11:37 -07:00
esengine 90d22e697c fix(studio): certify remote disconnect state 2026-09-27 07:09:09 -07:00
esengine 7d31478a6d docs(studio): add 2.20.4 release notes 2026-09-27 07:03:58 -07:00
esengine ac7119aa3b fix(studio): retain early remote disconnect state 2026-09-27 07:02:24 -07:00
esengine 3eb10d28ed fix(studio): clarify remote connection failures 2026-09-27 07:01:06 -07:00
YHH 5151f0fef3 Merge pull request #11056 from esengine/fix/verdict-no-evidence-no-outcome
fix(verdict): a turn that owed nothing and changed nothing is not done
2026-09-27 06:52:34 -07:00
esengine e7cb60d9d5 feat(studio): surface and control web sessions 2026-09-27 06:52:25 -07:00
Adalberto Pereira 24a8800de0 fix(tools): require an anchoring line and keep the final newline / 空行匹配需要锚点并保留行尾换行
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.
2026-09-27 10:51:53 -03:00
esengine c8fde2a577 fix(verdict): a turn that owed nothing and changed nothing is not done
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).
2026-09-27 06:43:08 -07:00
YHH 112372c7d5 Merge pull request #11055 from esengine/feat/rollout-gate
feat(e2ebench): decide whether a candidate capability set may be promoted
2026-09-27 06:30:44 -07:00
YHH bdb8196a84 Merge pull request #11054 from esengine/codex/studio-remote-controls
feat(studio): serve the full Studio UI over encrypted remote transport
2026-09-27 06:19:26 -07:00
esengine 2d2c576cdf feat(e2ebench): decide whether a candidate capability set may be promoted
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.
2026-09-27 06:17:29 -07:00
YHH 33df03a329 Merge pull request #11052 from esengine/feat/eval-reproduce
feat(e2ebench): say whether two reports reproduce one version
2026-09-27 06:11:59 -07:00
esengine 62fe7d4e01 feat(studio): add encrypted Web Studio transport 2026-09-27 06:09:31 -07:00
YHH d25c446a2b Merge pull request #11045 from KHG420/codex/studio-acp-skill-raw-after-11020
fix(acp): preserve typed skill input in Studio turns
2026-09-27 06:01:22 -07:00
esengine 098534c6bf feat(e2ebench): say whether two reports reproduce one version
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.
2026-09-27 06:00:38 -07:00
YHH 956ba1ed51 Merge pull request #11049 from esengine/feat/capability-digests
feat(trajectory): record the capability versions every run had
2026-09-27 05:41:32 -07:00
esengine 90c2c69716 feat(trajectory): record the capability versions every run had
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.
2026-09-27 05:33:21 -07:00
xiexian da3a77e764 fix(acp): exclude resolved Studio skills from routing 2026-09-27 20:24:01 +08:00
YHH ce720dd122 Merge pull request #11046 from esengine/feat/gate-reads-plan-checks
feat(readiness): hold plan criteria by their step's check, not a citation
2026-09-27 05:20:56 -07:00
esengine 29364f534f feat(readiness): hold plan criteria by their step's check, not a citation
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.
2026-09-27 05:12:40 -07:00
YHH 068367ef1e Merge pull request #11044 from esengine/codex/studio-remote-tasks
feat(studio): add encrypted remote task messaging
2026-09-27 05:10:37 -07:00
esengine 3fd0e093f8 feat(studio): add encrypted remote task messaging 2026-09-27 04:59:53 -07:00
dependabot[bot] 96871f15d4 build(deps): bump the actions group with 6 updates
Bumps the actions group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/setup-go](https://github.com/actions/setup-go) | `6.5.0` | `7.0.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `5.0.0` | `7.0.1` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.37.4` | `4.38.2` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.37.4` | `4.38.2` |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `6.0.9` | `6.1.0` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `6.0.0` | `8.0.1` |


Updates `actions/setup-go` from 6.5.0 to 7.0.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6.5.0...b7ad1dad31e06c5925ef5d2fc7ad053ef454303e)

Updates `actions/upload-artifact` from 5.0.0 to 7.0.1
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a)

Updates `github/codeql-action/init` from 4.37.4 to 4.38.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2)

Updates `github/codeql-action/analyze` from 4.37.4 to 4.38.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2)

Updates `pnpm/action-setup` from 6.0.9 to 6.1.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/0ebf47130e4866e96fce0953f49152a61190b271...ea17c68df8912ef543352723c149a84f56e3d413)

Updates `actions/download-artifact` from 6.0.0 to 8.0.1
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/018cc2cf5baa6db3ef3c5f8a56943fffe632ef53...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: pnpm/action-setup
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-27 11:55:55 +00:00
dependabot[bot] 2d2552a7b9 build(deps): bump the npm group
Bumps the npm group in /desktop/frontend-next with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@lezer/highlight](https://github.com/lezer-parser/highlight) | `1.2.3` | `1.2.4` |
| [katex](https://github.com/KaTeX/KaTeX) | `0.18.4` | `0.18.9` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.8` | `19.3.0` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.18` | `19.3.0` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.8` | `19.3.0` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.5` | `19.3.0` |
| [@babel/parser](https://github.com/babel/babel/tree/HEAD/packages/babel-parser) | `8.0.4` | `8.0.6` |
| [jsdom](https://github.com/jsdom/jsdom) | `30.0.1` | `30.1.1` |
| [playwright](https://github.com/microsoft/playwright) | `1.62.1` | `1.63.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.2` | `8.3.0` |


Updates `@lezer/highlight` from 1.2.3 to 1.2.4
- [Changelog](https://github.com/lezer-parser/highlight/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lezer-parser/highlight/commits)

Updates `katex` from 0.18.4 to 0.18.9
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.18.4...v0.18.9)

Updates `react` from 19.2.8 to 19.3.0
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.0/packages/react)

Updates `@types/react` from 19.2.18 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `react-dom` from 19.2.8 to 19.3.0
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.0/packages/react-dom)

Updates `@types/react-dom` from 19.2.5 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `@babel/parser` from 8.0.4 to 8.0.6
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v8.0.6/packages/babel-parser)

Updates `@types/react` from 19.2.18 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@types/react-dom` from 19.2.5 to 19.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `jsdom` from 30.0.1 to 30.1.1
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v30.0.1...v30.1.1)

Updates `playwright` from 1.62.1 to 1.63.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.62.1...v1.63.0)

Updates `vite` from 8.2.2 to 8.3.0
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/[email protected]/packages/vite)

---
updated-dependencies:
- dependency-name: "@lezer/highlight"
  dependency-version: 1.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: katex
  dependency-version: 0.18.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: react
  dependency-version: 19.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@types/react"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: react-dom
  dependency-version: 19.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@types/react-dom"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@babel/parser"
  dependency-version: 8.0.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@types/react"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@types/react-dom"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: jsdom
  dependency-version: 30.1.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: playwright
  dependency-version: 1.63.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: vite
  dependency-version: 8.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-27 11:55:31 +00:00
dependabot[bot] 0708dc650b build(deps-dev): bump the npm group in /desktop/electron with 2 updates
Bumps the npm group in /desktop/electron with 2 updates: [electron](https://github.com/electron/electron) and [playwright](https://github.com/microsoft/playwright).


Updates `electron` from 44.1.0 to 44.4.5
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v44.1.0...v44.4.5)

Updates `playwright` from 1.62.1 to 1.63.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.62.1...v1.63.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 44.4.5
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: playwright
  dependency-version: 1.63.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-27 11:54:21 +00:00
dependabot[bot] d222a50302 build(deps): bump the go group with 13 updates
Bumps the go group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [charm.land/bubbletea/v2](https://github.com/charmbracelet/bubbletea) | `2.0.9` | `2.0.10` |
| [github.com/bmatcuk/doublestar/v4](https://github.com/bmatcuk/doublestar) | `4.10.0` | `4.10.2` |
| [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) | `0.0.28` | `0.0.30` |
| [github.com/yuin/goldmark](https://github.com/yuin/goldmark) | `1.8.5` | `1.8.6` |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.56.0` | `0.57.0` |
| [golang.org/x/image](https://github.com/golang/image) | `0.45.0` | `0.46.0` |
| [golang.org/x/mod](https://github.com/golang/mod) | `0.40.0` | `0.41.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.58.0` | `0.59.0` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.47.0` | `0.48.0` |
| [golang.org/x/term](https://github.com/golang/term) | `0.45.0` | `0.46.0` |
| [golang.org/x/text](https://github.com/golang/text) | `0.41.0` | `0.42.0` |
| [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | `1.58.0` | `1.59.0` |
| [mvdan.cc/sh/v3](https://github.com/mvdan/sh) | `3.14.0` | `3.14.1` |


Updates `charm.land/bubbletea/v2` from 2.0.9 to 2.0.10
- [Release notes](https://github.com/charmbracelet/bubbletea/releases)
- [Commits](https://github.com/charmbracelet/bubbletea/compare/v2.0.9...v2.0.10)

Updates `github.com/bmatcuk/doublestar/v4` from 4.10.0 to 4.10.2
- [Release notes](https://github.com/bmatcuk/doublestar/releases)
- [Commits](https://github.com/bmatcuk/doublestar/compare/v4.10.0...v4.10.2)

Updates `github.com/mattn/go-runewidth` from 0.0.28 to 0.0.30
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.28...v0.0.30)

Updates `github.com/yuin/goldmark` from 1.8.5 to 1.8.6
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.8.5...v1.8.6)

Updates `golang.org/x/crypto` from 0.56.0 to 0.57.0
- [Commits](https://github.com/golang/crypto/compare/v0.56.0...v0.57.0)

Updates `golang.org/x/image` from 0.45.0 to 0.46.0
- [Commits](https://github.com/golang/image/compare/v0.45.0...v0.46.0)

Updates `golang.org/x/mod` from 0.40.0 to 0.41.0
- [Commits](https://github.com/golang/mod/compare/v0.40.0...v0.41.0)

Updates `golang.org/x/net` from 0.58.0 to 0.59.0
- [Commits](https://github.com/golang/net/compare/v0.58.0...v0.59.0)

Updates `golang.org/x/sys` from 0.47.0 to 0.48.0
- [Commits](https://github.com/golang/sys/compare/v0.47.0...v0.48.0)

Updates `golang.org/x/term` from 0.45.0 to 0.46.0
- [Commits](https://github.com/golang/term/compare/v0.45.0...v0.46.0)

Updates `golang.org/x/text` from 0.41.0 to 0.42.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.41.0...v0.42.0)

Updates `modernc.org/sqlite` from 1.58.0 to 1.59.0
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.58.0...v1.59.0)

Updates `mvdan.cc/sh/v3` from 3.14.0 to 3.14.1
- [Release notes](https://github.com/mvdan/sh/releases)
- [Changelog](https://github.com/mvdan/sh/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mvdan/sh/compare/v3.14.0...v3.14.1)

---
updated-dependencies:
- dependency-name: charm.land/bubbletea/v2
  dependency-version: 2.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: github.com/bmatcuk/doublestar/v4
  dependency-version: 4.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: github.com/yuin/goldmark
  dependency-version: 1.8.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: golang.org/x/crypto
  dependency-version: 0.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
- dependency-name: golang.org/x/image
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
- dependency-name: golang.org/x/mod
  dependency-version: 0.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
- dependency-name: golang.org/x/net
  dependency-version: 0.59.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
- dependency-name: golang.org/x/sys
  dependency-version: 0.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
- dependency-name: golang.org/x/term
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
- dependency-name: golang.org/x/text
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
- dependency-name: modernc.org/sqlite
  dependency-version: 1.59.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
- dependency-name: mvdan.cc/sh/v3
  dependency-version: 3.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-27 11:53:16 +00:00
YHH 4627950697 Merge pull request #11029 from esengine/codex/studio-account-relay
feat(studio): keep signed-in devices reachable
2026-09-27 04:45:55 -07:00
YHH a86854303f Merge pull request #11032 from esengine/feat/receipt-reads-verdict
feat(receipt): answer plan criteria from the sealed verdict, not a citation
2026-09-27 04:45:11 -07:00
YHH 6c1b8c726a Merge pull request #11030 from esengine/docs/studio-2.20.3-notes
docs(release-notes): add studio 2.20.3 notes
2026-09-27 04:43:04 -07:00
esengine 90559e61a1 feat(receipt): answer plan criteria from the sealed verdict, not a citation
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.
2026-09-27 04:38:02 -07:00
xiexian 419146cceb refactor(acp): keep raw turn method on editor port 2026-09-27 19:36:11 +08:00
xiexian 2a44e1e415 fix(acp): preserve raw text across slash prompt expansion 2026-09-27 19:36:11 +08:00
esengine b10577f508 docs(release-notes): add studio 2.20.3 notes 2026-09-27 04:35:45 -07:00
YHH c196e0879d Merge pull request #11020 from KHG420/codex/studio-inline-skill-raw
fix(skill): route Studio inline skills from raw user input
2026-09-27 04:32:36 -07:00
YHH 01770cc2d3 Merge pull request #11024 from esengine/feat/plan-revision-authority
fix(contract): freeze plan checks by command, and let only the user drop them
2026-09-27 04:28:42 -07:00
esengine 61f5f1c02e feat(studio): keep signed-in devices reachable 2026-09-27 04:27:34 -07:00
YHH 57647afaaf Merge pull request #11027 from esengine/docs/studio-shared-certum
docs(studio-release): describe Windows signing with the shared Certum certificate
2026-09-27 04:21:22 -07:00
esengine 554a9f79c3 docs(studio-release): describe Windows signing with the shared Certum certificate 2026-09-27 04:11:11 -07:00
YHH c017be3312 feat: add remote account client contracts (#11021) 2026-09-27 03:50:35 -07:00
esengine 95c497ba90 fix(contract): freeze plan checks by command, and let only the user drop them
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.
2026-09-27 03:49:51 -07:00
xiexian d9c6e82409 test(skill): assert inline pins do not create routes 2026-09-27 18:39:29 +08:00
esengine dded69678d ci: share Certum signing credentials across release lines 2026-09-27 03:30:29 -07:00
esengine d84bc0c625 ci(release-studio): sign Windows with a Studio-owned Certum certificate
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).
2026-09-27 03:30:29 -07:00
YHH 380683c5d0 Merge pull request #11017 from esengine/feat/plan-criterion-verifier
feat(contract): verify plan criteria by the commands their step names
2026-09-27 03:05:28 -07:00
esengine 8c190bc471 feat(contract): verify plan criteria by the commands their step names
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.
2026-09-27 02:58:14 -07:00
xiexian cbea244c51 fix(skill): route inline invocations from raw user input 2026-09-27 17:48:42 +08:00
YHH af0bb1129d Merge pull request #11014 from esengine/feat/todo-claim-edge
fix(contract): a todo marked done is not an acceptance criterion satisfied
2026-09-27 02:44:11 -07:00
esengine 224fb26c5b fix(contract): a todo marked done is not an acceptance criterion satisfied
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.
2026-09-27 02:32:55 -07:00
esengine 5f960c776c docs: identify project maintainer 2026-09-27 02:27:09 -07:00
YHH e5488f9a4e Merge pull request #11013 from esengine/feat/grant-scoped-attribution
feat(agent): attribute a delegated run's observed writes by its declared paths
2026-09-27 02:20:40 -07:00
YHH f4a72e4eda Merge pull request #11008 from esengine/fix/studio-11004-inline-skill-pin
fix(skill): send a user-typed inline skill as the pinned invocation run_skill returns
2026-09-27 02:18:16 -07:00
esengine fb73260c2c feat(agent): attribute a delegated run's observed writes by its declared paths
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.
2026-09-27 02:12:59 -07:00
esengine cecf61fe78 fix(skill): send a user-typed inline skill as the pinned invocation run_skill returns
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
2026-09-27 02:09:43 -07:00
YHH f860f7566b Merge pull request #11009 from esengine/feat/child-write-attribution
fix(agent): let a foreground child observe its own unclassified writes
2026-09-27 02:05:59 -07:00
esengine 84daa12aff fix(agent): let a foreground child observe its own unclassified writes
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.
2026-09-27 01:56:10 -07:00
YHH f3d1e845fb Merge pull request #10898 from esengine/feat/studio-browser-toggle
feat(studio): give the built-in browser tools their own switch
2026-09-27 01:29:32 -07:00
YHH b3df388a51 Merge pull request #11001 from esengine/feat/unrecognized-check-notice
feat(agent): say why a command that ran code did not verify the work
2026-09-27 01:28:57 -07:00
esengine eb387cec84 feat(agent): say why a command that ran code did not verify the work
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.
2026-09-27 01:21:14 -07:00
YHH ef7dd9994e Merge pull request #10998 from esengine/fix/studio-forbidread-test-separator
test(config): compare the forbid-read root after cleaning
2026-09-27 01:09:04 -07:00
esengine 38daa912b1 test(config): compare the forbid-read root after cleaning
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.
2026-09-27 01:01:31 -07:00
esengine eca86cf1f7 feat(studio): give the built-in browser tools their own switch
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.
2026-09-27 00:52:53 -07:00
YHH dfdd3b8ed8 Merge pull request #10990 from KHG420/codex/studio-wide-panel-resize
fix(studio): let the workbench use wide displays
2026-09-27 00:47:10 -07:00
YHH b6f50be5f9 Merge pull request #10996 from esengine/feat/canonical-contract
feat(contract): accept the task contract as a revision in trusted state
2026-09-27 00:42:28 -07:00
esengine 1079ee6276 feat(contract): accept the task contract as a revision in trusted state
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.
2026-09-27 00:33:48 -07:00
YHH fc12067a95 Merge pull request #10864 from par73e/feat/studio-model-provider-order
feat(studio): allow reordering model providers
2026-09-27 00:32:56 -07:00
YHH 91339352f7 Merge pull request #10985 from esengine/docs/studio-2.20.1-notes
docs(release-notes): add studio 2.20.1 notes
2026-09-27 00:32:00 -07:00
esengine fafec2c650 docs(release-notes): add studio 2.20.1 notes
The tag shipped without a notes file. This records what it contained, under
the release-note rule: every change cites its pull request or commits.
2026-09-27 00:25:31 -07:00
xiexian d605ad57b6 Merge remote-tracking branch 'origin/studio' into codex/studio-wide-panel-resize 2026-09-27 15:07:24 +08:00
xiexian 8379d61dc9 fix(studio): reuse viewport observer for dock limit 2026-09-27 15:03:52 +08:00
YHH 32982fd6d7 Merge pull request #10984 from esengine/fix/studio-release-notes-authors
ci(release-studio): credit pull request authors and issue fixers in published notes
2026-09-27 00:01:22 -07:00
YHH 9a80ceb5aa Merge branch 'studio' into feat/studio-model-provider-order 2026-09-26 23:54:27 -07:00
YHH c5afcc27a1 Merge branch 'studio' into fix/studio-release-notes-authors 2026-09-26 23:47:56 -07:00
YHH 14682bbd67 Merge pull request #10991 from esengine/fix/studio-2.20.2-notes-lint
fix(studio): turn studio CI green again (2.20.2 notes lint, Windows config test)
2026-09-26 23:46:42 -07:00
esengine 4f2c1fe6ba test(config): pin the forbid-read variable to a real environment value
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.
2026-09-26 23:38:27 -07:00
esengine 54a4d92963 docs(release-notes): bring studio 2.20.2 notes under the release-note rule
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.
2026-09-26 23:36:48 -07:00
par73e 61d1de4374 feat(studio): allow reordering model providers 2026-09-27 14:34:51 +08:00
esengine 11920eb387 ci(release-studio): credit pull request authors and issue fixers in published notes
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.
2026-09-26 23:28:25 -07:00
xiexian 3a071b4501 fix(studio): let workbench use wide displays 2026-09-27 14:16:20 +08:00
YHH 57e3c6da36 Merge pull request #10986 from esengine/codex/studio-release-notes-required
ci(studio): require auditable release notes
2026-09-26 23:14:03 -07:00
esengine d6d4bd2a50 ci(studio): require auditable release notes 2026-09-26 23:10:21 -07:00
YHH f753f860ed Merge pull request #10982 from esengine/codex/studio-telemetry
feat(studio): report Studio telemetry separately
2026-09-26 22:49:45 -07:00
esengine e766145571 feat(studio): report Studio telemetry separately 2026-09-26 22:41:37 -07:00
YHH 011c2738f8 Merge pull request #10970 from esengine/fix/studio-10965-session-image-blobs
fix(sessionstore): keep image payloads out of the replayed event log
2026-09-26 22:33:22 -07:00
YHH a4120eaa32 Merge pull request #10980 from esengine/fix/verdict-obligation-kind
fix(verdict): name the host obligation kind in divergence reasons
2026-09-26 22:23:06 -07:00
YHH 0758401871 Merge branch 'studio' into fix/studio-10965-session-image-blobs 2026-09-26 22:20:51 -07:00
esengine fe29c01702 fix(verdict): name the host obligation kind in divergence reasons
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.
2026-09-26 22:09:00 -07:00
esengine 2c619312b3 fix(sessionstore): keep image payloads out of the replayed event log
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
2026-09-26 22:05:46 -07:00
YHH be4d04c9cd Merge pull request #10976 from esengine/codex/studio-unified-login
fix(studio): open account approval in system browser
2026-09-26 22:01:22 -07:00
YHH c9003d4718 Merge pull request #10963 from esengine/fix/studio-10957-sidebar-loading
fix(studio): show the folder list as loading until the first tree read settles
2026-09-26 21:54:48 -07:00
YHH ffaffeaeee Merge pull request #10974 from esengine/feat/bundle-verdict
feat(verdict): decide each sealed turn from host evidence alone
2026-09-26 21:51:05 -07:00
esengine f516ab81ec fix(studio): open account approval in system browser 2026-09-26 21:44:32 -07:00
esengine 4e16aca0ba fix(studio): show the folder list as loading until the first tree read settles
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.
2026-09-26 21:39:51 -07:00
esengine 63952c3e8d feat(verdict): decide each sealed turn from host evidence alone
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.
2026-09-26 21:29:24 -07:00
YHH b31cf3bd74 Merge pull request #10971 from esengine/feat/workspace-snapshot
feat(observation): snapshot the workspace around every turn
2026-09-26 21:22:13 -07:00
esengine d475a2fb6d feat(observation): snapshot the workspace around every turn
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).
2026-09-26 21:14:18 -07:00
YHH 6ef1a5fa34 Merge pull request #10969 from esengine/feat/evidence-bundle
feat(trusted-state): seal every turn's shadow evidence bundle
2026-09-26 21:14:08 -07:00
esengine d41b8824f7 feat(trusted-state): seal every turn's shadow evidence bundle
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).
2026-09-26 21:03:21 -07:00
YHH 6267aaa64c Merge pull request #10952 from KHG420/codex/fix-typesafe-model-check
fix(studio): verify TypeSafe models through System One
2026-09-26 20:48:16 -07:00
YHH 7451f575bf Merge pull request #10966 from esengine/docs/trusted-execution
docs(design): trusted execution layer
2026-09-26 20:35:06 -07:00
esengine c6ad2294db docs(design): trusted execution layer
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.
2026-09-26 20:26:03 -07:00
YHH 5b764405a6 Merge pull request #10907 from esengine/fix/studio-windows-sandbox-attribution
fix(tools): attribute out-of-workspace writes to the write scope, not a sandbox
2026-09-26 20:19:54 -07:00
xiexian f54bf03787 fix(studio): verify TypeSafe models via System One 2026-09-27 11:18:39 +08:00
YHH bf7e0e96b8 Merge pull request #10960 from esengine/fix/studio-3220-delete-key
feat(studio): Delete on a focused sidebar session asks to delete it
2026-09-26 20:13:17 -07:00
YHH 044942e9e0 Merge pull request #10959 from esengine/fix/studio-4135-hide-amounts
feat(studio): hide the wallet balance and session cost behind an eye toggle
2026-09-26 20:13:10 -07:00
YHH 903f9e1c8c Merge pull request #10958 from esengine/fix/studio-8136-ask-pick-note
fix(ask): let a single-choice pick carry a typed note
2026-09-26 20:07:16 -07:00
esengine 6b4a684eac feat(studio): Delete on a focused sidebar session asks to delete it
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
2026-09-26 19:58:44 -07:00
esengine cb7261a920 feat(studio): hide the wallet balance and session cost behind an eye toggle
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
2026-09-26 19:58:29 -07:00
esengine 61630d5313 fix(tools): attribute out-of-workspace writes to the write scope, not a sandbox
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.
2026-09-26 19:57:44 -07:00
esengine f652aafa00 fix(ask): let a single-choice pick carry a typed note
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
2026-09-26 19:50:40 -07:00
YHH 9d02251b26 Merge pull request #10944 from esengine/fix/studio-10188-strong-colour-token
feat(theme): let a pack colour bold transcript text
2026-09-26 19:49:49 -07:00
YHH 26920430bb Merge pull request #10869 from KHG420/codex/studio-slash-loading
fix(studio): show slash completion loading state
2026-09-26 19:49:07 -07:00
YHH 5d4ec7c65c Merge pull request #10926 from esengine/fix/studio-8568-copy-label
fix(studio): name what a copy button copies in its tooltip and text
2026-09-26 19:48:25 -07:00
YHH 7b9b7a679d Merge pull request #10917 from esengine/fix/studio-7934-thinking-click-fold
feat(tui): open an answer's thinking with a click or Ctrl+O in full screen
2026-09-26 19:46:58 -07:00
YHH 96b7129f0d Merge pull request #10956 from esengine/fix/studio-9107-write-paths-guidance
fix(delegation): steer write_paths toward file paths over directories
2026-09-26 19:46:30 -07:00
YHH c91658d452 Merge pull request #10916 from esengine/fix/studio-7904-ask-other-keyboard
fix(studio): let a typed "other" answer be given from the keyboard
2026-09-26 19:46:17 -07:00
YHH 22730e87d6 Merge pull request #10954 from esengine/fix/studio-10921-opencode-session
fix(model): send x-opencode-session to OpenCode Go
2026-09-26 19:42:26 -07:00
esengine 0cbcbbf5b9 fix(delegation): steer write_paths toward file paths over directories
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.
2026-09-26 19:37:59 -07:00
esengine 3cabe4c4e7 fix(model): send x-opencode-session to OpenCode Go
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
2026-09-26 19:31:01 -07:00
Brian ConnellyandClaude Opus 5.5 baf8db1156 fix(control): name the MCP server and clip the prompt in ask notifications
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]>
2026-09-26 17:48:45 -04:00
YHH 0b9376a173 Merge pull request #10911 from esengine/fix/studio-7857-mcp-stderr-codepage
fix(plugin): decode an MCP server's code-page stderr before reporting it
2026-09-26 14:01:21 -07:00
esengine c7498ce9d3 fix(plugin): decode an MCP server's code-page stderr before reporting it
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
2026-09-26 13:54:33 -07:00
YHH 219a7bbf75 Merge pull request #10947 from esengine/fix/studio-6488-utf8-partial-tail
fix(encoding): read uncut UTF-8 that ends in half a character as UTF-8
2026-09-26 13:39:03 -07:00
esengine 0f29e498ea fix(encoding): read uncut UTF-8 that ends in half a character as UTF-8
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
2026-09-26 13:23:40 -07:00
YHH 1343c62350 Merge pull request #10933 from esengine/fix/studio-windows-flaky-effect-steer
test: stop the restart-review and steer tests racing Windows fsync latency
2026-09-26 13:19:20 -07:00
YHH 4908566b41 Merge pull request #10943 from esengine/fix/studio-10201-acp-goal-resume
feat(acp,serve): expose explicit Goal pause and resume
2026-09-26 13:15:41 -07:00
YHH eda07b74d8 Merge pull request #10940 from esengine/fix/studio-10319-reload-key
feat(studio): recover a blank window by reload key or on renderer crash
2026-09-26 13:09:33 -07:00
YHH d88859b695 Merge pull request #10941 from esengine/fix/studio-10114-ignore-runtime-state
fix(workspace): keep host-written .reasonix state out of git status
2026-09-26 13:08:53 -07:00
Adalberto Pereira 846ad2159f fix(tools): match an old_string whose blank-line run differs in length / 匹配空行数量不同的 old_string
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.
2026-09-26 17:04:17 -03:00
YHH d45fadd011 Merge pull request #10932 from esengine/fix/studio-9178-key-slot-collision
fix(serve): give a new provider a key slot nothing else holds
2026-09-26 12:53:02 -07:00
YHH e1c46175e3 Merge pull request #10939 from esengine/chore/studio-drop-workgroupassay-binary
chore: drop the committed workgroupassay binary
2026-09-26 12:46:37 -07:00
YHH 9bbeec16d9 Merge pull request #10829 from KHG420/codex/auto-refresh-markdown-preview
fix(studio): auto-refresh open Markdown previews
2026-09-26 12:44:37 -07:00
esengine 12ea68bfb3 feat(acp,serve): expose explicit Goal pause and resume
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.
2026-09-26 12:43:30 -07:00
YHH ef5c5296d3 Merge pull request #10889 from esengine/fix/studio-6488-gb18030-lossless
fix(encoding): a file is GB18030 only if a write can restore its bytes
2026-09-26 12:39:43 -07:00
YHH 75562bc60b Merge pull request #10937 from esengine/fix/studio-9658-request-url-repeats-base
fix(provider): ignore an endpoint override that only repeats base_url
2026-09-26 12:38:43 -07:00
esengine b864219ba7 feat(studio): recover a blank window by reload key or on renderer crash
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
2026-09-26 12:32:14 -07:00
esengine 74558c98d7 fix(workspace): keep host-written .reasonix state out of git status
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.
2026-09-26 12:27:57 -07:00
esengine 635774f5ef feat(theme): let a pack colour bold transcript text
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.
2026-09-26 12:26:15 -07:00
YHH cd0e4733ac Merge pull request #10935 from esengine/fix/studio-9442-openrouter-balance
feat(billing): read OpenRouter's credits endpoint as a wallet
2026-09-26 12:23:41 -07:00
YHH b246172185 Merge pull request #10936 from esengine/fix/studio-9274-tui-statusline
fix(tui): honour a user-level [statusline].command in the terminal footer
2026-09-26 12:22:42 -07:00
YHH eb622d7484 Merge pull request #10931 from esengine/fix/studio-9005-gemini-enum
fix(tools): bound todo_write's level instead of an integer enum
2026-09-26 12:18:15 -07:00
esengine 1d0a421f65 chore: drop the committed workgroupassay build output
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.
2026-09-26 12:14:46 -07:00
esengine 3116ccfa91 test: stop the restart-review and steer tests racing Windows fsync latency
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).
2026-09-26 12:13:49 -07:00
YHH 1e5a50de14 Merge pull request #10923 from esengine/fix/studio-8272-responses-output-calls
fix(responses): close tool calls the completed response lists
2026-09-26 12:09:11 -07:00
esengine 6c00620aa0 fix(provider): ignore an endpoint override that only repeats base_url
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
2026-09-26 12:08:31 -07:00
YHH 15aacdeb18 Merge pull request #10927 from esengine/fix/studio-8486-host-nudges
fix(agent): mark loop-composed instructions as host-authored
2026-09-26 12:06:49 -07:00
esengine 1158e53c03 fix(tui): honour a user-level [statusline].command in the terminal footer
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.
2026-09-26 12:05:10 -07:00
esengine 8a00d0a562 fix(serve): give a new provider a key slot nothing else holds
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.
2026-09-26 12:04:13 -07:00
xiexian 106c79d139 Merge studio into Markdown preview fix 2026-09-27 03:03:49 +08:00
YHH dbb7adefeb Merge pull request #10922 from esengine/fix/studio-8355-run-flag-surface
fix(cli): approval flags mean the same thing on either side of run
2026-09-26 12:03:15 -07:00
YHH 1ed55fd4df Merge pull request #10901 from esengine/fix/studio-6990-stream-idle-timeout
fix(model): wait five minutes for a silent model before calling the stream dropped
2026-09-26 12:00:23 -07:00
YHH be4336025b Merge pull request #10928 from esengine/fix/studio-8385-subagent-bare-model
fix(subagent): a bare model name stays on the parent's provider
2026-09-26 11:54:36 -07:00
xiexian 68860de492 Merge remote-tracking branch 'origin/studio' into codex/studio-slash-loading 2026-09-27 02:54:18 +08:00
xiexian 9f4d12ea45 fix(studio): retain completion menus during lookups 2026-09-27 02:54:11 +08:00
esengine f28d4f3c94 fix(encoding): a file is GB18030 only if a write can restore its bytes
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.
2026-09-26 11:51:40 -07:00
esengine 0eaf4bd72b feat(billing): read OpenRouter's credits endpoint as a wallet
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.
2026-09-26 11:50:59 -07:00
xiexian 7ef5ddb557 fix(studio): retain Markdown preview on transient poll errors 2026-09-27 02:46:58 +08:00
esengine adb5f3f9c0 fix(cli): approval flags mean the same thing on either side of run
`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
2026-09-26 11:40:10 -07:00
YHH 20fcba1fc4 Merge pull request #10920 from esengine/fix/studio-8271-acp-image-blocks
fix(acp): save prompt images and blob resources as attachments
2026-09-26 11:40:04 -07:00
YHH 97622c7d59 Merge pull request #10915 from esengine/fix/studio-8079-provider-edit-rebuild
fix(serve): a source edit reaches the conversation running on it
2026-09-26 11:37:20 -07:00
esengine 71aceb9e84 fix(tools): bound todo_write's level instead of an integer enum
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
2026-09-26 11:35:11 -07:00
esengine ef28ba7d06 fix(responses): close tool calls the completed response lists
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.
2026-09-26 11:35:00 -07:00
YHH 8e34b62ad3 Merge pull request #10919 from esengine/fix/studio-8326-shell-neutral-tests
test(shell): stop requiring a POSIX shell where the subject is not POSIX
2026-09-26 11:29:18 -07:00
YHH 9bfe900453 Merge pull request #10913 from esengine/fix/studio-8143-reasoning-guard
fix(agent): stop aborting turns at the client reasoning byte guard
2026-09-26 11:15:33 -07:00
YHH 4624fbd025 Merge pull request #10905 from esengine/fix/studio-7088-subagent-start-hook
feat(hook): bracket a foreground task sub-agent with SubagentStart/Stop
2026-09-26 11:15:08 -07:00
YHH 82c295c695 Merge pull request #10910 from esengine/fix/studio-7739-fuzzy-at-search
feat(fileref): reach an @ reference by its letters in order
2026-09-26 11:13:30 -07:00
YHH 5d332eb804 Merge pull request #10908 from esengine/fix/studio-7853-fakeip-dual-stack
fix(web_fetch): dial an allowed address when a host also resolves to a blocked one
2026-09-26 11:12:45 -07:00
esengine 607d9c0f64 feat(tui): open an answer's thinking with a click or Ctrl+O in full screen
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
2026-09-26 11:12:11 -07:00
esengine cc03ad8949 fix(serve): a source edit reaches the conversation running on it
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.
2026-09-26 11:10:49 -07:00
esengine f8fffdffa1 fix(subagent): a bare model name stays on the parent's provider
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.
2026-09-26 11:10:13 -07:00
esengine 686b97dc4b fix(agent): mark loop-composed instructions as host-authored
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
2026-09-26 11:09:09 -07:00
esengine 04b99a751c fix(studio): name what a copy button copies in its tooltip and text
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.
2026-09-26 11:07:15 -07:00
esengine d7259a1793 fix(studio): let a typed "other" answer be given from the keyboard
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
2026-09-26 11:06:10 -07:00
YHH 0ca2587b3e Merge pull request #10900 from esengine/fix/studio-7352-cloud-mount-config-path
fix(config): load a project config under a cloud-drive mount
2026-09-26 11:01:26 -07:00
YHH a61f73de6c Merge pull request #10903 from esengine/fix/studio-7035-mcp-workspace-var
feat(config): expand the workspace root in MCP server entries
2026-09-26 10:58:33 -07:00
esengine ba12e4f2e6 fix(acp): save prompt images and blob resources as attachments
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.
2026-09-26 10:53:49 -07:00
esengine 8eb3764aec test(shell): stop requiring a POSIX shell where the subject is not POSIX
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.
2026-09-26 10:52:03 -07:00
YHH ccc2714a96 Merge pull request #10881 from esengine/fix/studio-5914-symlink-explorer
fix(serve): list a workspace link as what it resolves to
2026-09-26 10:44:57 -07:00
esengine 3ad87f17ae fix(agent): stop aborting turns at the client reasoning byte guard
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.
2026-09-26 10:37:40 -07:00
esengine e12b31b1fc feat(hook): bracket a foreground task sub-agent with SubagentStart/Stop
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.
2026-09-26 10:31:41 -07:00
YHH cc79c726a6 Merge pull request #10906 from esengine/fix/studio-7444-windows-remote-posix-shell
fix(remote): identify a Windows host whatever its OpenSSH login shell
2026-09-26 10:28:02 -07:00
esengine 0a0e1989a7 feat(config): expand the workspace root in MCP server entries
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.
2026-09-26 10:25:06 -07:00
esengine ef6fe03c97 feat(fileref): reach an @ reference by its letters in order
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.
2026-09-26 10:23:58 -07:00
YHH c15d7c83f2 Merge branch 'studio' into fix/studio-7352-cloud-mount-config-path 2026-09-26 10:23:55 -07:00
YHH 8006530d36 Merge branch 'studio' into fix/studio-5914-symlink-explorer 2026-09-26 10:23:46 -07:00
esengine df053a976d fix(web_fetch): dial an allowed address when a host also resolves to a blocked one
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.
2026-09-26 10:22:17 -07:00
esengine 64abf5af72 fix(model): wait five minutes for a silent model before calling the stream dropped
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
2026-09-26 10:20:05 -07:00
YHH d921615a0c Merge pull request #10897 from esengine/fix/studio-6860-planner-sees-executor-outcome
feat(coordinator): show the planner what the executor did since it last planned
2026-09-26 10:17:30 -07:00
YHH 7795a5f2f4 Merge pull request #10896 from esengine/fix/studio-6887-relative-evidence-paths
fix(evidence): credit a workspace-relative citation of an absolute write
2026-09-26 10:16:49 -07:00
YHH 4e8a6b47a3 Merge pull request #10899 from esengine/fix/studio-7135-f11-fullscreen
fix(studio): bind F11 to full screen on Linux and Windows
2026-09-26 10:15:34 -07:00
esengine 818fc803c8 fix(remote): identify a Windows host whatever its OpenSSH login shell
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.
2026-09-26 10:11:37 -07:00
esengine 286e682c31 fix(serve): list a workspace link as what it resolves to
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
2026-09-26 10:07:23 -07:00
YHH 9f5ed774b7 Merge pull request #10882 from esengine/fix/studio-6354-header-timeout-retry
fix(provider): retry a response-header timeout instead of ending the turn
2026-09-26 10:04:25 -07:00
YHH 7a3038d501 Merge pull request #10887 from esengine/fix/studio-6401-copy-without-rail
fix(tui): copying a selection leaves the code rail behind
2026-09-26 10:02:40 -07:00
esengine 99fcccdfc0 feat(coordinator): show the planner what the executor did since it last planned
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.
2026-09-26 09:59:46 -07:00
YHH 84155ce239 Merge pull request #10895 from esengine/fix/studio-6823-shell-label
fix(environment): attribute the reported shell to the bash tool
2026-09-26 09:56:00 -07:00
esengine a15348f9b7 fix(evidence): credit a workspace-relative citation of an absolute write
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.
2026-09-26 09:55:32 -07:00
esengine 2bf6da8968 fix(config): load a project config under a cloud-drive mount
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.
2026-09-26 09:53:16 -07:00
YHH 3e5238675a Merge pull request #10894 from esengine/fix/studio-6785-show-turn-usage
fix(tui): honor ui.show_turn_usage in the terminal UI
2026-09-26 09:50:34 -07:00
esengine 7b28e8cf4f fix(studio): bind F11 to full screen on Linux and Windows
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.
2026-09-26 09:49:53 -07:00
esengine d856a08493 fix(environment): attribute the reported shell to the bash tool
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
2026-09-26 09:23:31 -07:00
esengine 11882ecd19 fix(tui): honor ui.show_turn_usage in the terminal UI
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.
2026-09-26 09:22:13 -07:00
YHH fd26c6c704 Merge pull request #10879 from esengine/fix/studio-5693-live-file-preview
fix(studio): refresh an open file preview when the file changes on disk
2026-09-26 08:55:05 -07:00
YHH 2079470588 Merge pull request #10885 from esengine/fix/studio-6369-single-tilde
fix(studio): a single tilde reads as a range, not strikethrough
2026-09-26 08:54:07 -07:00
YHH 0228994111 Merge pull request #10875 from esengine/fix/studio-5235-open-file-changes
feat(studio): a folding mode that opens file changes only
2026-09-26 08:53:25 -07:00
YHH 6a21efb0c3 Merge pull request #10874 from esengine/fix/studio-5058-carriage-return-progress
fix(studio): a progress meter redraws its line instead of growing it
2026-09-26 08:52:42 -07:00
esengine ebd527487e fix(studio): refresh an open file preview when the file changes on disk
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
2026-09-26 08:46:00 -07:00
YHH 67a83685e6 Merge pull request #10876 from esengine/fix/studio-5496-blockquote-wrap
fix(termrender): wrap blockquote text to the width beside its rail
2026-09-26 08:45:31 -07:00
esengine bde08cebf7 fix(tui): copying a selection leaves the code rail behind
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.
2026-09-26 08:30:54 -07:00
YHH 8301d7719d Merge pull request #10862 from esengine/feat/studio-explorer-reveal
feat(studio): reveal explorer entries in the system file manager
2026-09-26 08:30:29 -07:00
YHH 7124dc45e2 Merge pull request #10880 from esengine/fix/studio-5799-compact-blocks-model-switch
fix(control): a model switch waits for an in-flight /compact
2026-09-26 08:26:44 -07:00
YHH 7a09e7461c Merge branch 'studio' into feat/studio-explorer-reveal 2026-09-26 08:20:33 -07:00
YHH 00f1fcea20 Merge pull request #10878 from esengine/fix/ci-windows-test-cache
ci: run the Windows Go legs without the test result cache
2026-09-26 08:19:48 -07:00
esengine 8008423086 fix(studio): a single tilde reads as a range, not strikethrough
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.
2026-09-26 08:15:44 -07:00
esengine ae678f2f1d fix(provider): retry a response-header timeout instead of ending the turn
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
2026-09-26 08:15:00 -07:00
YHH 6e177b0047 Merge pull request #10873 from esengine/fix/studio-4912-tui-keys
fix(tui): page the transcript with Shift+PgUp/PgDn and paste with Shift+Insert
2026-09-26 08:12:57 -07:00
esengine c3dd47eb5d fix(control): count a session rotation as active work so a model switch waits for /compact
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.
2026-09-26 08:02:53 -07:00
esengine 9d899e8dc5 ci: run the Windows Go legs without the test result cache
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.
2026-09-26 08:00:52 -07:00
esengine a843729e38 fix(termrender): wrap blockquote text to the width beside its rail
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.
2026-09-26 07:58:35 -07:00
esengine 275d3b918c feat(studio): a folding mode that opens file changes only
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.
2026-09-26 07:49:44 -07:00
esengine 3dc0707686 fix(studio): a progress meter redraws its line instead of growing it
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%.
2026-09-26 07:49:43 -07:00
esengine 3343286b35 fix(tui): page the transcript with Shift+PgUp/PgDn and paste with Shift+Insert
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
2026-09-26 07:46:15 -07:00
esengine fda5c751f4 feat(studio): reveal explorer entries in the system file manager
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.
2026-09-26 05:54:07 -07:00
YHH 21722a28a3 Merge pull request #10867 from esengine/fix/studio-provider-protocol-hint-wrap
fix(studio): put the protocol hint below its switcher in a service's detail
2026-09-26 05:35:45 -07:00
xiexian 7ee0bf5b35 fix(studio): show slash completion loading state
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.
2026-09-26 20:34:49 +08:00
esengine 19dee62303 fix(studio): put the protocol hint below its switcher in a service's detail
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.
2026-09-26 05:27:32 -07:00
YHH d96ca544ab Merge pull request #10861 from esengine/fix/studio-explorer-refresh
fix(studio): re-read the explorer tree when the window is looked at again
2026-09-26 04:45:35 -07:00
YHH 1674279493 Merge pull request #10859 from esengine/fix/studio-running-step-visible
fix(studio): a running step moves on its own row, and step rows lose their blank column
2026-09-26 04:32:57 -07:00
esengine 39d531704d fix(studio): a running step moves on its own row, and step rows lose their blank column
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.
2026-09-26 04:24:10 -07:00
esengine 0b99a615a7 fix(studio): re-read the explorer tree when the window is looked at again
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.
2026-09-26 04:18:46 -07:00
YHH 61953d1c65 Merge pull request #10855 from esengine/fix/studio-10851-cjk-step-label
fix(studio): keep the receipt label on one line beside a long command
2026-09-26 04:05:58 -07:00
esengine c2ce043cae fix(studio): keep the receipt label on one line beside a long command
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
2026-09-26 03:59:00 -07:00
YHH 53c5683090 Merge pull request #10854 from esengine/fix/studio-10792-multi-edit-all-failures
feat(tools): multi_edit reports every failing step in one call
2026-09-26 03:54:08 -07:00
esengine f0a04741dc feat(tools): multi_edit reports every failing step in one call
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
2026-09-26 03:27:55 -07:00
YHH 76ae0ee554 Merge pull request #10850 from esengine/fix/studio-review-untrusted-checkout
fix(cli): reasonix review ignores tools and skills the reviewed checkout configures
2026-09-26 03:24:44 -07:00
esengine ae4ab517f8 fix(cli): reasonix review ignores tools and skills the reviewed checkout configures
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.
2026-09-26 03:11:36 -07:00
YHH 144e6e9797 Merge pull request #10846 from esengine/fix/studio-hooks-planner-guardian-review
fix(hooks): run configured tool hooks in subagents, the planner, the guardian and reasonix review
2026-09-26 03:11:01 -07:00
esengine c4ea3251a8 fix(hooks): run configured tool hooks in subagents, the planner, the guardian and reasonix review
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
2026-09-26 02:32:11 -07:00
YHH 40de19b6e5 Merge pull request #10838 from c020627/docs-studio-wails-residue
docs: port the retired-Wails wording cleanup to studio
2026-09-26 00:50:48 -07:00
esengine 7ee4bbb77e ci(release): studio publishes the CLI to npm and Homebrew only when told to
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.
2026-09-26 00:47:56 -07:00
YHH 040a4b587c Merge pull request #10841 from esengine/chore/ack-token-studio
ci(acknowledgments): open the weekly PR with a token that triggers CI
2026-09-26 00:32:45 -07:00
esengine ac2bc077b2 ci(acknowledgments): open the weekly PR with a token that triggers CI
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.
2026-09-26 00:13:00 -07:00
YHH 578c1347ad Merge pull request #10828 from onionviolet/codex/studio-version-empty-fallback
fix(tui): show dev for missing build version
2026-09-26 00:10:55 -07:00
c020627 9a999f7b7b docs: port the retired-Wails wording cleanup to studio
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.
2026-09-26 14:08:56 +08:00
esengine 3f58372f40 ci(issues): allow a one-second gap between replies 2026-09-25 20:34:00 -07:00
onionviolet 729ef1a6f2 fix(tui): show dev for missing build version 2026-09-25 23:29:48 -04:00
esengine 5d4e7e710e ci(issues): a batch of replies can set the gap between entries 2026-09-25 20:26:25 -07:00
YHH 199f948463 Merge pull request #10820 from esengine/fix/studio-9758-reasoning-only-after-tools
fix(agent): retry once when a tool round ends in a reasoning-only stop
2026-09-25 20:10:27 -07:00
YHH 4bd331641d Merge pull request #10823 from esengine/fix/studio-10369-wayland-x11-default
fix(studio): relaunch the shell on X11 under Wayland when XWayland is there
2026-09-25 20:07:58 -07:00
esengine d2d6b4070c ci(issues): a reply can close its issue as not planned
A cleanup of reports on old versions is not a fix, and closing those as
completed tells the reporter and the tracker otherwise.
2026-09-25 20:06:22 -07:00
xiexian 4c3a33c891 fix(studio): auto-refresh open Markdown previews 2026-09-26 11:02:28 +08:00
esengine cf66d68c9c fix(studio): relaunch the shell on X11 under Wayland when XWayland is there
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.
2026-09-25 20:00:23 -07:00
esengine 285272440f fix(agent): retry once when a tool round ends in a reasoning-only stop
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.
2026-09-25 19:59:02 -07:00
YHH eb284e84ee Merge pull request #10827 from esengine/fix/studio-10540-conhost-width
fix(tui): fit glyphs a conhost CJK font draws double-width
2026-09-25 19:58:23 -07:00
YHH 30fa424456 Merge pull request #10824 from esengine/fix/studio-9719-kill-durable
fix(session): save the transcript once a turn's prompt is admitted
2026-09-25 19:57:11 -07:00
YHH c9bdc1e0c2 Merge pull request #10810 from esengine/fix/studio-10372-linux-static-host
fix(studio): build the Linux kernel and update helper without cgo
2026-09-25 19:52:30 -07:00
esengine 628163e0f0 fix(tools): an image read_file refuses says who can attach it
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.
2026-09-25 19:47:32 -07:00
esengine 66e73724f0 fix(studio): build the Linux kernel and update helper without cgo
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
2026-09-25 19:38:13 -07:00
esengine 2c80057f08 fix(tui): fit glyphs a conhost CJK font draws double-width
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
2026-09-25 19:12:18 -07:00
esengine e4fc2b0e13 fix(control): sandbox settings still save on a host with no sandbox
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.
2026-09-25 18:52:40 -07:00
esengine 9ddcf2eca3 fix(session): save the transcript once a turn's prompt is admitted
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.
2026-09-25 18:52:19 -07:00
esengine a099f92d25 test(control): pin bash off where the changed-file switch is saved
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.
2026-09-25 18:47:06 -07:00
YHH f371ec07b9 Merge pull request #10816 from BuGlessRB/open/scrollbar-native-mouse
feat(tui): drop the scrollbar and code gutter in native mouse mode / 功能(tui):原生鼠标模式下去除滚动条与代码块竖线
2026-09-25 18:39:29 -07:00
YHH 6c44fbaa3e Merge pull request #10815 from BuGlessRB/open/continue-fresh-session
feat(cli): start a fresh session when --continue finds nothing / 功能(cli):--continue 找不到会话时改为开启新会话
2026-09-25 18:39:26 -07:00
YHH 2fbfb16d08 Merge pull request #10812 from scrappylabsai/feat/studio-ask-notification-hook
feat(control): fire the Notification hook for pending ask questions
2026-09-25 18:39:24 -07:00
YHH 366c493a6e Merge pull request #10793 from onionviolet/codex/studio-tui-version
feat(tui): add /version to studio
2026-09-25 18:39:21 -07:00
Stephen R. van den Berg 5b5ee2d161 feat(tui): drop the scrollbar and code gutter in native mouse mode
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.
2026-09-26 02:18:45 +02:00
Stephen R. van den Berg c4f0483223 feat(cli): start a fresh session when --continue finds nothing
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.
2026-09-26 02:18:45 +02:00
onionviolet 99a93a9453 feat(tui): show build version with /version 2026-09-25 16:50:44 -04:00
Brian ConnellyandClaude Opus 5.5 c1e78ca0e0 feat(control): fire the Notification hook for pending ask questions
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]>
2026-09-25 15:18:57 -04:00
YHH ebc7ff9e22 Merge pull request #10807 from esengine/fix/studio-10458-missing-key-message
fix(cli): point a missing model key at the credentials file, not the shell
2026-09-25 11:05:12 -07:00
esengine f9a5b39161 fix(tui): a streamed answer keeps its layout, and the end card says what it found
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%".
2026-09-25 11:04:57 -07:00
esengine 79c984b32a feat(tools): refuse a whole-file write over a change made since the agent saw the file
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.
2026-09-25 10:32:58 -07:00
esengine 1584a67453 fix(cli): point a missing model key at the credentials file, not the shell
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.
2026-09-25 10:24:20 -07:00
esengine a992bd98f9 ci(issues): close the issues a merged 1.x pull request fixes
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.
2026-09-25 09:12:26 -07:00
Weibao Chen 13f689b5c1 feat(skill): allow quiet startup skill warnings (#10795)
Adds [skills].suppress_warnings (default false), which quiets the non-fatal skill warnings printed during startup discovery. doctor diagnostics are unchanged. Implements #6483.
2026-09-25 08:51:52 -07:00
esengineandBuGlessRB f1a584a9ef fix(tui): start with mouse capture off where 1.x did
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]>
2026-09-25 08:47:13 -07:00
esengine 806e916d3c ci(studio): pin the self-test workflow's actions to full-length commit SHAs 2026-09-25 08:45:07 -07:00
YHH d0cfc7db6d ci: pin every third-party action to a full-length commit SHA (#10779)
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.
2026-09-25 08:44:45 -07:00
YHH 49585e9147 fix(goaleval): read the verdict a thinking model leaves in reasoning (#10785)
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
2026-09-25 08:43:45 -07:00
YHH 5154389dd7 fix(cli): an ambiguous --resume query names its candidates (#10787)
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.
2026-09-25 08:41:54 -07:00
YHH 23c82fc149 fix(tui): history recall keeps the draft it replaced (#10789)
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
2026-09-25 08:34:14 -07:00
YHH a5981dd096 fix(cli): keep fatal crash output of the CLI for the next start (#10790)
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.
2026-09-25 08:33:24 -07:00
esengine 626871ea50 ci(studio): retry the self-test's module download, which the intranet route drops 2026-09-25 07:51:28 -07:00
esengine acedea659b ci(studio): set the self-test PATH from cmd, which the runner's execution policy allows 2026-09-25 07:45:04 -07:00
esengine 06a58e54e4 ci(studio): build an unsigned Windows self-test installer on the intranet runner
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.
2026-09-25 07:44:07 -07:00
esengine 7a52c69746 ci(issues): reply on pull requests, and wait out only a rate limit
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.
2026-09-25 07:40:30 -07:00
esengineandBuGlessRB 3047105ea4 feat(cli): --permission-mode reads 1.x's names
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]>
2026-09-25 07:22:29 -07:00
esengine a7ffbd48ea feat(cli): a bare reasonix opens the terminal UI, with the options 1.x took
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.
2026-09-25 07:17:00 -07:00
esengine c7fccad57d docs: 2.x opens sessions-v4 conversations
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.
2026-09-25 07:09:03 -07:00
esengine 454d1da76f feat(session): list and open the conversations 1.x keeps in sessions-v4
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.
2026-09-25 07:09:03 -07:00
yhh 216550cd2b ci(issues): run issue replies on the self-hosted win-gpu runner
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.
2026-09-25 06:46:41 -07:00
yhh 631c9fc71e ci(issues): post maintainer replies as github-actions[bot]
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.
2026-09-25 06:27:53 -07:00
esengine 1a274370df docs: sessions by 1.x version, and the 2.x release gates
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.
2026-09-25 06:16:50 -07:00
esengine 2c63162b6b feat(session): open 1.x schema 2 sessions, and continue them in a session of their own
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.
2026-09-25 06:15:24 -07:00
esengine 0ed7b4b5a1 docs: a 1.x to 2.x migration guide and the 2.x roadmap
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.
2026-09-25 05:32:37 -07:00
esengine 4391487d8f fix(store): a 1.x turns sidecar is not listed as a conversation
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.
2026-09-25 05:28:44 -07:00
yhh dae42027cc fix(anthropic): a stream error before any output is a recoverable interrupt
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.
2026-09-25 05:04:13 -07:00
yhh a50a6b5c44 fix(projectiondb): cap the WAL with journal_size_limit on every connection
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.
2026-09-25 05:04:13 -07:00
yhh d318232358 fix(provider): root object tool schemas always carry "required": []
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.
2026-09-25 05:04:13 -07:00
esengine 4b9ec92981 feat(tui): paste a screenshot with Ctrl+V, as 1.x did
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.
2026-09-25 04:42:45 -07:00
esengine e83e2b156e fix(tui): keep inline history in order, and say the remaining notices in the user's language
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.
2026-09-25 04:42:45 -07:00
esengine 3579dbbd39 feat(tui): open long shell output with Ctrl+B, and scroll a selection held at the edge
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.
2026-09-25 04:42:45 -07:00
esengine 4ad09c6656 feat(tui): pick a session to resume, and notify when the turn needs you
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.
2026-09-25 04:42:45 -07:00
esengine 6cd9508902 feat(tui): take the full screen by default, with 1.x's scrollbar and mouse
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.
2026-09-25 04:42:45 -07:00
esengine 7519957fbf feat(tui): look and behave like the 1.x chat screen
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.
2026-09-25 04:42:45 -07:00
esengine 6146fb5086 feat(tui): a welcome card, a framed composer, a spinner, and cards for prompts
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.
2026-09-25 04:42:45 -07:00
yhh 186e75abb6 feat(studio): 模型偏好 is one table of uses, the models doing them and their services
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.
2026-09-25 02:58:19 -07:00
yhh d8bd8c763c fix(studio): the selected model service drops the accent line that bent around its corner 2026-09-25 02:47:38 -07:00
yhh a11c9bf956 feat(studio): model services get their own page, a list beside the one being edited
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 模型服务.
2026-09-25 02:43:49 -07:00
yhh eab809e16f fix(studio): one name per approval mode, and the permission menu fits a phone
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.
2026-09-25 02:19:45 -07:00
esengine 5ff9d7a0f5 feat(tui): questions, completion, the task list, and fixes from real runs
- 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.
2026-09-25 00:27:00 -07:00
esengine ed499c4cac feat(control): the model answers a command the user ran
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.
2026-09-25 00:27:00 -07:00
yhh 27f247bc39 fix(workbench): opening a folder reads that folder, and a failure says so in the file list
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.
2026-09-25 00:22:48 -07:00
esengine c5b651bea7 feat(tui): reasonix tui, a terminal UI on the in-process kernel
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.
2026-09-24 23:45:53 -07:00
esengine bf2984ae8c refactor(termrender): the terminal visual layer is its own package
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.
2026-09-24 23:45:52 -07:00
yhh 8a3c25322b fix(sandbox): a backtick escapes only inside double quotes when null redirects are normalized
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.
2026-09-24 23:27:50 -07:00
yhh 4c38a9e639 ci: the pull-request race sweep names jobs where it now lives
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.
2026-09-24 23:27:49 -07:00
64253ebe73 fix(pricing): bill Chinese public holidays as DeepSeek off-peak (#10758)
* 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]>
2026-09-24 23:09:17 -07:00
esengine d58a02719f feat(tui): the terminal UI's client, event stream and transcript
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.
2026-09-24 23:05:31 -07:00
esengine e86fd8f887 feat(acp): a ! prompt runs the user's own command
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.
2026-09-24 23:05:31 -07:00
esengine 72e5d8ddb9 feat(serve): an in-process client, and ! commands for the frontend that holds it
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.
2026-09-24 23:05:31 -07:00
esengine 34da60673d fix(studio): a stopped background job reads as stopped, not as a success
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.
2026-09-24 22:50:49 -07:00
esengine 74ae5a3373 fix(studio): background jobs can be stopped, and the deck works on a phone
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.
2026-09-24 22:50:48 -07:00
yhh bf4edd280d fix(sysproxy): honour WinINET bypass wildcards and the implicit loopback bypass
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.
2026-09-24 22:32:30 -07:00
esengine 7ac19b90c5 feat(release): npm and Homebrew follow the Studio line
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.
2026-09-24 22:20:04 -07:00
esengine 2da273ba7d fix(cli): a double-clicked reasonix.exe says where Studio is instead of vanishing
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.
2026-09-24 22:06:48 -07:00
esengine 0099490a73 test(goal): the 24-round goal test waits as long as that work takes
It ran into the default five-second wait on a slow Windows runner.
2026-09-24 21:46:32 -07:00
esengine c9ebc58155 fix(studio): a warm pane is not a selection, and removing a project is not a close
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.
2026-09-24 21:39:56 -07:00
esengine 4179e1e524 test(jobs): the restart attribution test runs without an OS sandbox
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.
2026-09-24 21:35:05 -07:00
esengine 1ec1f1949e fix(studio): the rest of what the perf guards found once they reached the real layout
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.
2026-09-24 21:30:30 -07:00
esengine 01e04ea614 test(isolation): compare the applied file without the platform's line endings
On Windows, git applies the isolated change under core.autocrlf, so the
file lands with CRLF; the best-of-n effect test already compares that way.
2026-09-24 21:17:35 -07:00
esengine 26294ae811 fix(control): the headless Run holds the turn gate
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.
2026-09-24 21:17:35 -07:00
esengine 91faa67c1d fix(studio): what a guard reaching the real layout found wrong with it
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%.
2026-09-24 21:07:32 -07:00
esengine de71e8df4b test(egress): keep the guard's comment on its test, skip the socket test on Windows
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.
2026-09-24 20:46:51 -07:00
esengine 3683d0a215 style(sessionstore): gofmt save.go
The trailing blank line failed the gofmt step, which stops the Linux and
macOS test jobs before a single test runs.
2026-09-24 20:46:51 -07:00
yhh ade58d1610 docs(readme): drop the embedded logo animation video 2026-09-24 20:31:41 -07:00
esengine ebb278cec1 feat(sandbox): allowed_domains is enforced on Linux too
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).
2026-09-24 20:26:16 -07:00
esengine a250e31399 feat(sandbox): a host outside allowed_domains is put to the user
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.
2026-09-24 20:26:16 -07:00
esengine cb5ce6adf3 test(perf): the idle guard watches studio's run signal, and judges a stroke by its drawing
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.
2026-09-24 20:26:16 -07:00
esengine fb115ebeb7 fix(studio): the sidebar's filled controls read as a layer in light, and its row menu is quiet rather than absent
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.
2026-09-24 20:26:16 -07:00
esengine bc599b2d39 fix(studio): a narrow question card leads with its answer, and a disabled mode button takes no pointer
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.
2026-09-24 20:26:16 -07:00
esengine 45f28b6778 feat(sandbox): bash egress can be limited to named domains
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.
2026-09-24 20:26:16 -07:00
esengine f3274a3247 feat(studio): a failed request floats above the composer instead of pushing it
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.
2026-09-24 20:26:16 -07:00
esengine 0c02f53d68 fix(perf): the bench loads studio.css, as the window does
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.
2026-09-24 20:26:16 -07:00
esengine e07df2ed87 fix(config): a repo can no longer grant itself host authorities
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.
2026-09-24 20:26:16 -07:00
esengine 881dc68289 feat(evidence): a rewritten pytest case is reported like a rewritten Go test
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.
2026-09-24 20:26:15 -07:00
esengine 9a78e92169 fix(studio): the phone card no longer asks a shut door for a code
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.
2026-09-24 20:26:15 -07:00
esengine 8814848fb4 feat(bestof): the judge reads each attempt's host record, not only its diff
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.
2026-09-24 20:26:15 -07:00
esengine 2d23da04fb test(editor): editor discovery tests no longer depend on what the Mac has installed
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.
2026-09-24 20:26:15 -07:00
esengine e1b8d5542f test(studio): an earlier version folds under its conversation and opens
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.
2026-09-24 20:26:15 -07:00
esengine 25414f3abe test(serve): race resume against /new, not the /fork that no longer exists
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.
2026-09-24 20:26:15 -07:00
esengine a7986e6eae feat(session): an edit, regenerate or rewind keeps the conversation it cut
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.
2026-09-24 20:26:15 -07:00
esengine 56b24652d0 fix(jobs): a job the session closed under reads as interrupted, not killed
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.
2026-09-24 20:26:15 -07:00
esengine 09d2811609 fix(isolation): a pending isolated result outlives the process that made it
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.
2026-09-24 20:26:15 -07:00
esengine 5521459d27 fix(delivery): a restart keeps the proof a pending change already earned
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.
2026-09-24 20:26:15 -07:00
yhh 4fe5caf8ce docs(repo): studio is the 2.x line, ready to be the default branch
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.
2026-09-24 20:21:38 -07:00
esengine 8aa9cb02ae fix(repolint): directories sharing a layer rank may not import each other
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.
2026-09-24 10:37:52 -07:00
esengine acc273a31c fix(delivery): the review a high-risk change owes survives a restart
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.
2026-09-24 10:33:33 -07:00
esengine 6d43ffa476 test(boot): a restart between goal turns lets a high-risk change skip its review
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.
2026-09-24 10:27:10 -07:00
esengine b2ddd0c91f feat(skill): an oversized catalog names every skill instead of clipping the tail
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.
2026-09-24 10:19:17 -07:00
esengine 88ba715b86 test(agent): restart equivalence harness, pinning the review a restart drops
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.
2026-09-24 10:15:51 -07:00
esengine 9da268b769 feat(delegation): task can run a writer in its own git worktree
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.
2026-09-24 10:10:45 -07:00
esengine d51d652ef2 feat(agent): a writer that declares its write set is observed like write_file
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.
2026-09-24 09:47:14 -07:00
esengine 0695ac4d6a feat(worktree): apply a candidate path by path against a workspace that moved on
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.
2026-09-24 09:43:11 -07:00
esengine 29ecacc743 fix(mcp): list methods follow nextCursor instead of reading one page
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.
2026-09-24 09:32:07 -07:00
esengine 7a8b720bc7 fix(readiness): a rewritten declaration no longer retires the check a goal began under
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.
2026-09-24 09:32:07 -07:00
esengine 2cec05088e ci(release-studio): package Windows in parallel and mirror chunks 64 at a time
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.
2026-09-24 09:06:52 -07:00
esengine d37410aca0 test(serve): a device-attribution submit waits for the session, not only for TurnDone
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.
2026-09-24 08:14:08 -07:00
esengine a0f3162a1a docs(release): notes for studio 2.20.0 2026-09-24 08:05:42 -07:00
esengine 1333c04e79 feat(mcp): a server can be always loaded instead of searched for
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.
2026-09-24 07:57:49 -07:00
yhh d6e288dba9 fix(evidence): an MCP call is a workspace change only when the workspace shows one
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.
2026-09-24 07:00:48 -07:00
yhh 3b646e87bf fix(boot): a runtime outlives the request that built it
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".
2026-09-24 06:51:34 -07:00
yhh 38f0e33719 feat(mcp): a server can ask the person for a form mid tool call
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".
2026-09-24 06:42:21 -07:00
yhh c600656618 perf(agent): the workspace walk reads directories in parallel
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.
2026-09-24 05:44:26 -07:00
yhh 31ecf6114c perf(proc): resume a suspended launch through its own thread list
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.
2026-09-24 05:44:25 -07:00
yhh a3ff25eb79 refactor(studio): useFoldAway moves out of App.tsx
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.
2026-09-24 05:27:33 -07:00
yhh fc6d22b1d3 fix(plugin): a legacy server using a modern error code keeps its handshake
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.
2026-09-24 05:18:42 -07:00
yhh 35da16d61d perf(tools): a walk confines each entry from the root's real path, not a fresh resolve
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.
2026-09-24 05:14:52 -07:00
yhh 1f708adfa2 test(secrets): the long concurrent redaction runs a lighter load under -race
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.
2026-09-24 05:13:58 -07:00
yhh 337bb95d99 fix(studio): the four inputs that write the host name their action
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.
2026-09-24 04:59:41 -07:00
yhh c6d1d9132e fix(e2ebench): stopping the meter waits for handlers still recording their tape
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.
2026-09-24 04:54:57 -07:00
yhh c163eed8df chore(desktop): tidy the desktop module
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.
2026-09-24 04:54:31 -07:00
yhh 9c2759e467 test(computer): the live-session helpers build only where the live tests use them
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.
2026-09-24 04:54:31 -07:00
yhh 84629cb7f6 test(serve): the image route's absolute-path case uses a path absolute on the platform running it
"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.
2026-09-24 04:54:30 -07:00
yhh 7dcfebbcfe feat(plugin): speak MCP 2026-07-28 to servers that have dropped the handshake
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.
2026-09-24 04:48:24 -07:00
yhh f39a0f8683 feat(studio): guidance said into a running turn reaches every screen, with its device
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.
2026-09-24 04:36:07 -07:00
yhh d3049d69a6 feat(studio): a queued line and a question's answer keep the device they came from
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.
2026-09-24 04:28:14 -07:00
yhh 3b5aef0779 feat(studio): every screen sees what another one said, and which device said it
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.
2026-09-24 04:19:52 -07:00
yhh 21e8036bc1 fix(studio): a reply that failed to render once gets another try, and falls back readably
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.
2026-09-24 04:07:57 -07:00
yhh 633b954a48 feat(update): Windows Studio updates by chunks, swapped in after exit
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.
2026-09-24 04:03:58 -07:00
yhh e511914817 feat(release): publish a Windows delta beside the installer
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.
2026-09-24 03:53:21 -07:00
yhh d5e10fb991 feat(studio): a paired device says whose machine it drives, and the window sees it come and go
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.
2026-09-24 03:49:33 -07:00
yhh 887c17e4bf feat(delta): update an install by the chunks it lacks, not a whole installer
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.
2026-09-24 03:47:15 -07:00
yhh 073eba26c4 feat(studio): the phone card is laid out as a chrome card, a third shorter
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.
2026-09-24 03:36:06 -07:00
yhh d5e28fe2b6 feat(studio): the docked workbench floats as a rounded card
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.
2026-09-24 03:31:55 -07:00
yhh 6ba8b226dc fix(studio): a paired device's long name no longer widens the phone card
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.
2026-09-24 03:30:13 -07:00
yhh 45b0ea470c feat(studio): the phone card copies its pairing link for a device that cannot scan
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.
2026-09-24 03:27:27 -07:00
yhh dd6e9436cd feat(studio): a code block heads itself, numbers its lines, and folds when long
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.
2026-09-24 03:25:48 -07:00
yhh 5c66d8fa11 fix(studio): the phone card's network picker works while sharing is on
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.
2026-09-24 03:24:49 -07:00
yhh 48d753fae3 fix(studio): a press inside the phone card no longer closes it
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.
2026-09-24 03:20:21 -07:00
yhh 84ab2569e6 feat(thinking): the host times a reply's thinking and keeps it with the turn
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.
2026-09-24 03:18:23 -07:00
yhh bb47cd4a76 feat(studio): a QR button on the top bar opens a card a phone scans
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.
2026-09-24 03:11:03 -07:00
yhh 55d885341e fix(studio): declare ask.step, the ask card's step between questions
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.
2026-09-24 03:06:50 -07:00
yhh 4205f44e29 feat(serve): a server prints a QR code a phone scans to open it, and refuses one for a public plain-HTTP address
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.
2026-09-24 03:00:07 -07:00
yhh 2b504d43f6 fix(studio): an ask card with several questions walks them in order
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.
2026-09-24 02:57:30 -07:00
yhh 96ed3492f0 feat(studio): a phone-width window gets a drawer, a full-width workbench and no sideways scroll
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.
2026-09-24 02:53:29 -07:00
yhh b42e15d3ac feat(approval): a card says what approving authorizes beyond the call
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.
2026-09-24 02:52:30 -07:00
yhh 74f76057fe feat(studio): a phone on the same network pairs by QR code and drives the window's panes
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.
2026-09-24 02:14:55 -07:00
yhh fc902b846c feat(script): run_script lets the model call tools from a short Starlark program
[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.
2026-09-24 01:42:34 -07:00
yhh e0afcfbd65 fix(studio): the show-all row stays inside the rail
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.
2026-09-24 01:34:38 -07:00
yhh fe1af1bb9f fix(cli): a session started in a repository's subdirectory is filed with the repository
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.
2026-09-24 01:34:38 -07:00
yhh a6a83a9ed8 test(studio): the meter row under the composer is held to carrying no frame
9346c2ebd removed the row's border, background and shadow on purpose; the
theme test still demanded the background, and failed on every run since.
2026-09-24 01:30:35 -07:00
yhh bba7bcf51e feat(bestof): best_of_n runs parallel attempts in worktrees and lands the judged winner
[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.
2026-09-24 01:29:55 -07:00
yhh 375abff0bb fix(studio): a running conversation keeps its row, its pane, and a way to close it
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.
2026-09-24 01:15:17 -07:00
yhh c76dd51824 feat(advisor): an advise tool that consults a stronger model mid-task
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.
2026-09-24 01:03:30 -07:00
yhh 12b5ba6279 refactor(usecap): the use_capability proxy leaves the agent package
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).
2026-09-24 00:51:59 -07:00
yhh d5ad1a8e5c feat(agent): screen external tool results for prompt injection, off by default
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.
2026-09-24 00:41:58 -07:00
yhh 9ca5da9aa5 refactor(agent): the context window owns its state and says what it needs
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.
2026-09-23 23:38:19 -07:00
yhh 3b0570dea7 feat(agent): label tool results whose content came from outside the workspace
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).
2026-09-23 23:37:47 -07:00
yhh 902091b0ac fix(mcp): refuse an OAuth server that does not advertise PKCE, unless the user allows it
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.
2026-09-23 23:17:48 -07:00
yhh 7fddafb314 fix(studio): the transcript sits on the pane, not in a card around all of it
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.
2026-09-23 23:07:47 -07:00
yhh c59a80ceaa refactor(langpref): turn language preferences leave the agent
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).
2026-09-23 22:43:51 -07:00
yhh a534ead0ab refactor(delegation): sub-agent orchestration leaves the agent package
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).
2026-09-23 22:36:35 -07:00
yhh c7a85ffe51 feat(subagent): locate finds where code lives in four parallel rounds
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.
2026-09-23 22:20:16 -07:00
yhh 2d18ef994d refactor(agent): the loop stops reaching into delegation
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.
2026-09-23 22:05:25 -07:00
yhh 88b0fc139a feat(mcp): negotiate protocol 2025-11-25 and keep older servers working
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.
2026-09-23 21:56:42 -07:00
yhh 1ebd662f34 refactor(writeclaim): write claims and the sub-agent scheduler leave agent
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).
2026-09-23 21:52:24 -07:00
yhh c7301f56a1 refactor(coordinator): the two-model turn leaves the agent package
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.
2026-09-23 21:41:57 -07:00
yhh 917746ce85 fix(studio): the rule between turns reads as a boundary, and belongs to the turn it opens
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.
2026-09-23 21:41:35 -07:00
yhh b2190c8d22 feat(e2ebench): record a run's provider exchanges and replay them, naming where requests diverge
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".
2026-09-23 21:36:54 -07:00
yhh 24ab05fd2f fix(studio): a rule falls between turns, and a mounting block never splits one
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.
2026-09-23 21:35:01 -07:00
yhh 48e02a1915 feat(e2ebench): -trials N reports pass^N, not only whether a task can be solved
-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.
2026-09-23 21:25:38 -07:00
yhh 02e4ab8b65 fix(guardian): the reviewer sees the user's words and the agent's calls, nothing it was told
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.
2026-09-23 21:07:53 -07:00
yhh fbe813584b refactor(boot): build is four stages over one builder
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.
2026-09-23 21:05:13 -07:00
yhh 9346c2ebd5 fix(studio): the meter row under the composer carries no frame
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.
2026-09-23 21:01:37 -07:00
yhh f16172f3b8 feat(workbench): a rendered document's links and images resolve in the workspace
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.
2026-09-23 20:58:00 -07:00
yhh 0f7bf93247 refactor(boot): build reads as its phases
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.
2026-09-23 20:55:25 -07:00
yhh 0136f04278 feat(workbench): a Markdown file opens rendered, with read and edit modes
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.
2026-09-23 20:51:18 -07:00
yhh 86428ba0ec fix(studio): settings open with no conversation open
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.
2026-09-23 20:45:23 -07:00
yhh 55312636c7 feat(browser): proceed past an intranet certificate for this run, by the person's hand
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.
2026-09-23 20:39:49 -07:00
yhh 674f746ca3 test(pluginpkg): the configuration projection of installed packages is covered
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.
2026-09-23 20:39:39 -07:00
yhh 27fa18d7ca fix(browser): a page the person opens answers when it is drawn, not when it loads
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.
2026-09-23 20:28:37 -07:00
yhh fc3a44dbcc refactor(layers): the last upward edges close
- 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.
2026-09-23 20:26:33 -07:00
yhh 493504224a refactor(layers): contracts stop importing the evidence ledger
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.
2026-09-23 20:17:32 -07:00
yhh 9be873730c fix(browser): intranet pages open in the built-in browser, and a failed load says why
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.
2026-09-23 20:10:22 -07:00
yhh 2a62e704d3 refactor(sessionstore): a transcript's durable record leaves the agent
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.
2026-09-23 20:08:36 -07:00
yhh 762aa10b4e fix(studio): the scheme toggle shows a sun or a moon, and the wordmark breathes
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.
2026-09-23 19:44:42 -07:00
yhh 96e27227fd fix(studio): the transcript card draws its own scrollbar, clear of its corners
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.
2026-09-23 19:20:12 -07:00
yhh ed18012649 refactor(catalog): asking an endpoint what it serves leaves config
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.
2026-09-23 19:14:11 -07:00
yhh f32f01223c refactor(provider): which vendor an endpoint belongs to is contract
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.
2026-09-23 19:01:52 -07:00
yhh 79ee58ff70 refactor(billing): pricing is data, asking for a balance is not
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.
2026-09-23 18:58:40 -07:00
yhh 817d6806ec feat(studio): import a theme pack, and open the folder packs live in
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.
2026-09-23 18:53:08 -07:00
yhh 11edcd175b refactor(config): configuration stops reaching into the subsystems
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.
2026-09-23 18:49:10 -07:00
yhh 03e0cb8942 fix(studio): a project's name reads above its conversations
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.
2026-09-23 18:33:02 -07:00
yhh 1a49443f97 fix(studio): the transcript card holds still and scrolls inside
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.
2026-09-23 18:30:04 -07:00
yhh 394da0a313 refactor: the kernel's packages live in the layer they belong to
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.
2026-09-23 18:28:21 -07:00
yhh 68bf9f7410 fix(studio): every project under a machine sits at one indent
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.
2026-09-23 18:20:35 -07:00
yhh d8a758140c fix(studio): paint the chosen wallpaper, centre the mark, sharpen the tray
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.
2026-09-23 18:17:44 -07:00
yhh d318d24050 feat(extension): a plugin runtime can give the agent new tools
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.
2026-09-23 17:43:26 -07:00
yhh 2c7bde8710 feat(plugin): a package's prompts and themes reach Studio
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.
2026-09-23 17:23:35 -07:00
yhh de375d26a5 feat(composer): refine a draft with the session's model before sending it
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.
2026-09-23 17:06:30 -07:00
yhh fd63a02de2 feat(computer): press any letter, digit, function key or punctuation key
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.
2026-09-23 09:11:59 -07:00
yhh 69fb12a25c feat(computer): operate applications on Windows
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.
2026-09-23 09:05:43 -07:00
yhh fac44ef250 fix(studio): a wait for the workspace names the session that is writing
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.
2026-09-23 07:42:28 -07:00
yhh d1fed2b458 fix(remote): a remote kernel follows the broker instead of being replaced
A remote serve resolves every model call through the provider broker on
the machine that started it, reached over an -R forward. The serve read
that broker's address and token once, at launch, so the reuse check
refused any connect that published the broker elsewhere, and the
bootstrap then stopped the running kernel and started another. Every
other process that connected to the same host did that: a second Studio
window, a restarted one, another computer on the same account. The
conversation open in the first window lost its kernel mid-turn and read
"the kernel on this host is not answering". A reconnect that could not
get its old remote port back left the kernel dialling a port that no
longer existed.

The serve now takes --provider-broker-file, one 0600 file holding the
broker's address and token, and reads it on every request, validating
the address as loopback each time. The broker client asks an Endpoint per
request, so providers resolved before a move follow it. A kernel launched
this way records broker_file; reuse rewrites the file under the serve
lock instead of replacing the kernel, and a link that comes back on a
different port re-points every kernel on it, one reconnect at a time.
Kernels launched the old way are still replaced as before, and the flag
probe upgrades a binary that lacks the new flag.

Address and token share one file written by a single rename, so no read
pairs one connect's token with another's port, where a squatter on a
released port would receive both. The remote state directory is made
0700 and an atomic write narrows its temporary file before writing, not
after. The broker client no longer follows redirects.

The most recent connect's broker serves every conversation on that
kernel. That is no more than the SSH account could already do, since it
used to relaunch the kernel onto the new broker anyway; a window naming
which machine it resolves through is left for later.
2026-09-23 07:18:51 -07:00
yhh 7d51c005e6 docs(release): studio 2.19.0 notes 2026-09-23 05:14:04 -07:00
yhh 1da292d945 fix(studio): a delete waits only for work in progress, not for an open pane
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.
2026-09-23 05:02:28 -07:00
yhh 23d9d75f61 fix(studio): a window with no folder or no pane still has a way forward
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.
2026-09-23 05:02:13 -07:00
yhh 6560d50cd9 fix(studio): three inputs that write the host say which action they are
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.
2026-09-23 05:01:17 -07:00
yhh 7776d07f7c feat(studio): a conversation on another machine can be deleted from its row
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.
2026-09-23 04:20:57 -07:00
yhh a84c67dcf6 fix(usage): a reindexed stats file is counted once
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.
2026-09-23 04:04:09 -07:00
yhh 65cf85322c refactor(agent): the steer queue's methods live with the steer inbox
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.
2026-09-23 04:03:57 -07:00
yhh 5690c3989a fix(bash): a NUL file Git Bash created is named in the result
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.
2026-09-23 03:54:09 -07:00
yhh 0168102614 feat(studio): a relay's effort levels are declared where the menu sends you
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.
2026-09-23 03:53:59 -07:00
yhh ee9f06f7de fix(studio): a saved approval, a source build and Git Bash each read as what they are
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.
2026-09-23 03:53:45 -07:00
yhh 0e8a034acf feat(studio): a remote machine's sessions are read without opening a pane on it
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.
2026-09-23 03:53:34 -07:00
yhh 9d34b96a9a feat(studio): a launch plays its intro until the app is ready, and keeps the window's choices
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.
2026-09-23 03:53:22 -07:00
yhh 12855f3bf5 style(studio): the stylesheet paints from theme tokens, and host cards share one frame
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.
2026-09-23 03:53:10 -07:00
yhh 73b88cde95 feat(studio): the workbench edits a file in place and follows the page the agent is on
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.
2026-09-23 03:52:57 -07:00
yhh 3b717c40bb feat(studio): how the transcript folds is a setting, and find lands on what it found
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.
2026-09-23 03:52:43 -07:00
yhh ae68096fa0 fix(agent): a compaction is announced before it spends a model call
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.
2026-09-23 03:52:05 -07:00
yhh 0670f6b9e1 feat(agent): a page the agent wrote is looked at before the turn is called done
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.
2026-09-23 03:51:54 -07:00
yhh e4fcfede82 fix(serve): a missing runtime is refused by its name
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.
2026-09-22 19:25:05 -07:00
yhh c723094722 fix(browser): a page closed outside the agent says so
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.
2026-09-22 19:20:11 -07:00
yhh da8cd34ba3 fix(studio): work the model did before it spoke is drawn, under who did it
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.
2026-09-22 19:15:48 -07:00
yhh 5af3cb3800 fix: restore CI, and two Windows defects it was hiding
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()
2026-09-22 18:51:05 -07:00
yhh 42fff96b55 refactor: remove the interactive terminal session
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.
2026-09-22 17:44:28 -07:00
esengine 123bad2a23 fix(studio): the page loads, the lights clear it, and a menu waits to be reached
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.
2026-09-22 17:07:24 -07:00
yhh d3ef177fa1 fix(config): a desktop setting the renderer never learned is one the window forgets
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.
2026-09-22 09:07:20 -07:00
yhh cd5656f6bb fix(studio): say what an empty SSH auth form actually does
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.
2026-09-22 08:45:26 -07:00
yhh b56a3d839b fix(studio): a link opens in the browser this window draws
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.
2026-09-22 08:36:47 -07:00
yhh 2807d121b9 feat(studio): open the workspace in the machine's code editor
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.
2026-09-22 08:31:49 -07:00
yhh a0570267df feat(studio): deck readings move right and open on hover
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.
2026-09-22 07:37:25 -07:00
yhh 71a63d325d feat(studio): notify when a turn ends or the model stops to ask
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.
2026-09-22 07:27:52 -07:00
yhh 6b391c1cff feat(studio): quote a selection as a chip, live token readings, faster suite
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.
2026-09-22 06:55:04 -07:00
yhh 7367688dcd refactor: pay the standards ratchet down to clean
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.
2026-09-22 06:23:41 -07:00
yhh d191b73fca feat(studio): seat queued lines, restore built panels, and open the browser through the controller
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.
2026-09-22 06:00:30 -07:00
yhh 9a4ead5cd1 feat(studio): complete runtime workspace experience 2026-09-21 07:16:22 -07:00
yhh b76b055a1f feat: add System One decision backends 2026-09-21 06:49:44 -07:00
yhh 5b31c7322f feat(studio): align prototype UI and interactions 2026-09-21 00:26:25 -07:00
yhh fd413aaf76 feat(studio): refine onboarding and model settings 2026-09-21 00:31:16 +08:00
yhh 31e56c1b2e merge: integrate latest studio updates 2026-09-20 07:41:02 -07:00
yhh 8a6a4f20d6 feat: modernize studio interface and session recovery 2026-09-20 07:20:45 -07:00
esengine 4954ca0b0d fix(studio): the enlarged screenshot closes without a mouse
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.
2026-09-18 04:12:11 -07:00
esengine cdd1cb5c30 fix(computer,cli): two of my own from a self-review
`/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.
2026-09-18 04:09:37 -07:00
esengine 3c31656424 fix(tools): a screenshot says that the person sees it too
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.
2026-09-18 03:25:30 -07:00
esengine a1aca7027d test(studio): the window is asked whether it drew the picture
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.
2026-09-18 02:31:58 -07:00
esengine 87817a5faa fix(docs): the tool contract is generated, as it says it is
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.
2026-09-18 02:29:55 -07:00
esengine 3e75ad8f88 feat(permissions): a grant given for one session can be taken back
"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.
2026-09-18 02:27:27 -07:00
esengine 1aefe8cf75 feat(studio): a person sees what the agent was shown
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.
2026-09-18 02:12:16 -07:00
esengine 2361ed85b8 fix(studio): a grant given on an approval card is named where it is listed
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.
2026-09-18 00:04:40 -07:00
esengine f1a69128c5 feat(computer): text goes in at once, and the clipboard is given back
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.
2026-09-18 00:04:40 -07:00
esengine 623b3cf379 feat(browser): a page wider than its viewport is read across
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).
2026-09-17 23:31:45 -07:00
esengine d1ee4135f0 fix(browser): a request is read against the page that made it
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.
2026-09-17 23:26:36 -07:00
esengine 74149825cd test(browser): the window relays a viewport and a request list too
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.
2026-09-17 23:21:15 -07:00
esengine 946584b890 fix(boot): a configuration that could not be read says so
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.
2026-09-17 22:03:04 -07:00
esengine 79326455b0 feat(browser): what the page asked the network for, and the viewport it is laid out in
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.
2026-09-17 21:47:19 -07:00
esengine 87bc9075dc refactor(textutil): one way to say how big something is
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.
2026-09-17 21:47:05 -07:00
esengine 0db339f0f4 test(computer): the pointer is asked for in the shell a person actually uses
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.
2026-09-17 21:19:56 -07:00
esengine 75a7f0ba75 fix(computer): a screenshot answers, and says what it found when it cannot
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.
2026-09-17 21:04:30 -07:00
esengine 591326b98b feat(computer): the pointer the person is holding, borrowed and given back
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%.
2026-09-17 20:45:57 -07:00
esengine 9cb61447e4 fix(boot,serve): the recorded prefix and readings carry what the last two rounds added
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.
2026-09-17 20:45:38 -07:00
esengine 88ff8f623d fix(computer): bringing an element into view is solved from where it is
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.
2026-09-17 20:09:47 -07:00
esengine 2c6f74d1ec feat(browser,computer): the actions the other implementations have and these lacked
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.
2026-09-17 19:50:52 -07:00
esengine 71195f4210 fix(computer): say when an application exposes no window, and name the keys
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.
2026-09-17 19:15:56 -07:00
esengine e54381d4f2 feat(studio): an approval offers the answers this host will actually honour
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.
2026-09-17 19:15:41 -07:00
esengine 634e9842ff fix(computer): an application's tree is walked once, and the pages fill the pane
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.
2026-09-17 18:37:24 -07:00
esengine 8e922ab311 fix(browser): the page's console is the page's, not this window's
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.
2026-09-17 18:17:00 -07:00
esengine c24306bd60 feat(studio): the agent's browser sits beside the conversation, not instead of it
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.
2026-09-17 17:58:24 -07:00
esengine 700541cf1a fix(browser): acting on a page this workspace serves is the check the task has
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.
2026-09-17 17:44:23 -07:00
esengine c2fe835ae8 fix(studio): a session opened again is not waiting on you
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.
2026-09-17 17:27:14 -07:00
esengine 2f9944e794 fix(agent): a finished task list stops following the session into the next task
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.
2026-09-17 17:27:14 -07:00
esengine 4bb019ead4 fix(agent): the tail the host re-derives is not the conversation being rewritten
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.
2026-09-17 17:17:45 -07:00
esengine 660fc9b94d fix(agent): a turn is told which check it has to make pass
"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.
2026-09-17 17:17:45 -07:00
esengine c2d643fe43 fix(control): an approval says why in the reader's language, not the model's
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.
2026-09-17 17:01:44 -07:00
esengine fc50ddfb15 fix(studio): the agent's browser keeps taking input when nobody is looking at it
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.
2026-09-17 10:59:06 -07:00
esengine 8486a20932 fix(studio): the browser panel draws the page again once a view transition ends
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.
2026-09-17 10:59:06 -07:00
esengine d1b44c3a96 feat(studio): the macOS shell hands the kernel its computer-use helper
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.
2026-09-17 09:42:49 -07:00
esengine 049ee1a75d feat(computer): the agent can read and operate this Mac's other applications
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.
2026-09-17 09:42:38 -07:00
esengine 05f3796470 fix(browser): the gate reads the site the host projected, not a field the model added
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.
2026-09-17 09:40:35 -07:00
esengine edd9ce180c feat(studio): the agent's browser is drawn in the window, beside the conversation
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.
2026-09-17 09:05:21 -07:00
esengine 3fd2dbb42b feat(browser): a pane can read the agent's tabs, and hears when they move
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.
2026-09-17 08:42:14 -07:00
esengine 762c26332e feat(browser): a window can host the agent's browser, and a rebuild keeps it
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.
2026-09-17 08:36:47 -07:00
esengine b1b2990db9 feat(browser): typing a secret into a site needs a person, even in auto
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.
2026-09-17 08:18:26 -07:00
esengine d39197ca1a fix(evidence): a browser call is not a workspace change
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.
2026-09-17 07:52:57 -07:00
esengine 64c6baa65e fix(browser): the agent's browser downloads nothing
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.
2026-09-17 07:48:48 -07:00
esengine d1ec7b727e feat(tool): browser_open, browser_read and browser_act, granted per site
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.
2026-09-17 07:47:37 -07:00
esengine 20a7a0cf6b feat(browser): drive an installed Chromium over its debugging protocol
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.
2026-09-17 07:47:37 -07:00
esengine f7f473a433 fix(agent): host context reaches each request once
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.
2026-09-17 06:58:40 -07:00
esengine d4a87e9f2c fix(responses): tool-result images reach vision models
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.
2026-09-17 06:55:47 -07:00
esengine 46efefe4ce fix(agent): a failed tool call keeps its images
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.
2026-09-17 06:55:47 -07:00
esengine 2a0695bf78 fix(agent): screenshots age out of the request and stop training calibration
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.
2026-09-17 06:55:47 -07:00
esengine 4c85c21826 docs: release notes for Studio 2.18.1 2026-09-17 02:13:14 -07:00
esengine 437b982b59 fix(remote): a brokered pane replaces a kernel that predates the broker's model surface
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.
2026-09-17 02:12:06 -07:00
esengine f352dfbecc chore: remove the IM bot gateway
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.
2026-09-17 02:02:48 -07:00
esengine 8b2a05c514 fix(remote): a brokered pane takes its models from the broker, not the host's config
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.
2026-09-17 01:59:46 -07:00
esengine c80385d663 docs: release notes for Studio 2.18.1
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.
2026-09-17 01:25:09 -07:00
esengine 2835cd5a58 fix(control): a turn without a runner reports ErrNoRunner, not a nil fault
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.
2026-09-17 01:24:20 -07:00
esengine 62a6870065 fix(bot): a panic while claiming a session no longer wedges the gateway
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.
2026-09-17 01:24:03 -07:00
esengine 3d5b0b68db fix(bot): stop recovering nil dereferences that corrupt the heap on Windows
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.
2026-09-17 00:56:18 -07:00
esengine 2964f483d6 fix(agent): stream attempt ids stop depending on the clock advancing
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.
2026-09-16 23:59:50 -07:00
esengine d636e93d5b docs: release notes for Studio 2.18.0
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.
2026-09-16 23:38:14 -07:00
esengine 04e9bbe4a0 chore: remove site/, the main-v2 website, from the studio line
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.
2026-09-16 23:05:14 -07:00
esengine 921a04bda0 fix(control): a rebuilt controller never reissues a question's id
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.
2026-09-16 22:13:21 -07:00
esengine 99c5360fd0 docs: the references and code the deletion commit left behind
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.
2026-09-16 21:50:24 -07:00
esengine 68b031ae5d docs: delete stale documents, untranslated copies and main-v2 release remnants
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.
2026-09-16 21:49:47 -07:00
esengine c37dac3a40 fix(productdocs): the ownership header is not a documentation section
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.
2026-09-16 21:01:33 -07:00
esengine 6b6a41d367 docs: a documentation standard with named owners, held by repolint
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.
2026-09-16 20:57:15 -07:00
esengine 44150b0aa4 fix(control): a model that reads images is told the images are there
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.
2026-09-16 20:06:38 -07:00
esengine db4da6e887 fix(studio): a package grant on the install tree no longer kills the window on Windows
#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.
2026-09-16 19:19:58 -07:00
esengine a1e693ceaf test(control): the ask test learns the user was asked before it answers
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.
2026-09-16 10:07:27 -07:00
esengine 1125d994e2 test(neterr): the reset case uses the identity Windows actually reports
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.
2026-09-16 09:48:41 -07:00
esengine 07c797f243 ci: the Windows desktop leg stops entering a directory the retirement deleted
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.
2026-09-16 09:24:41 -07:00
esengine 618142a1cd docs: release notes for Studio 2.17.0 2026-09-16 09:15:12 -07:00
esengine c75187408e test(studio): two sweeps that answered a question they were not asked
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.
2026-09-16 09:13:12 -07:00
esengine 9dfa2ee4de fix(serve): the completion menu speaks the window's language, not the process's
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.
2026-09-16 09:13:02 -07:00
esengine 7f5a8e3044 fix(studio): one Chinese word, one English reading
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.
2026-09-16 09:12:53 -07:00
esengine 2b6e9b8d8b fix(openai): stop failing a whole session over a token counter
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.
2026-09-16 05:17:15 -07:00
esengine 709d829feb fix(serve): a model check that never sent tools called a gateway available
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.
2026-09-16 03:53:57 -07:00
esengine 39050be5e3 refactor(evidence): the ledger, its questions, and what it judges
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.
2026-09-16 03:40:02 -07:00
esengine 0f15057acf refactor(bot): the gateway keeps the connection, not everything on it
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.
2026-09-16 03:34:57 -07:00
esengine 67d43e60c8 refactor(acp): service.go splits along the surface it serves
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.
2026-09-16 03:30:16 -07:00
esengine 2dd399c6a6 refactor(control): the turn entry points and five smaller groups follow
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.
2026-09-16 03:22:20 -07:00
esengine 24599c2fca refactor(control): controller.go loses what already had a home
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.
2026-09-16 03:16:19 -07:00
esengine 95712d4fc7 docs(repolint): the parity table explains an absence that no longer exists
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.
2026-09-16 01:07:53 -07:00
esengine e6dc52f9fe refactor(control): dropped folders become their own collaborator
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.
2026-09-16 01:06:37 -07:00
esengine 953642cd9f fix(studio): build the host from the module that owns it
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.
2026-09-15 23:44:29 -07:00
esengine bbc14d1e6e docs: three references the retirement left pointing at nothing
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.
2026-09-15 23:42:55 -07:00
esengine 393ec8eed8 feat(studio): retire the Wails shell
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.
2026-09-15 23:41:31 -07:00
esengine 8e3c9ebd72 chore(config): drop the update-channel setter no client can reach
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.
2026-09-15 23:12:40 -07:00
esengine 83735b46cf chore: remove what the retired Wails desktop left behind
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.
2026-09-15 23:10:08 -07:00
esengine 3db55a87e6 refactor: sorted map keys are one call
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.
2026-09-15 22:11:24 -07:00
esengine badbc0dd7d refactor: one spelling for sorting, and math/rand/v2
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.
2026-09-15 22:05:50 -07:00
esengine e3198d49bb refactor(fileutil): containment is two questions, each asked one way
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.
2026-09-15 21:51:51 -07:00
esengine e239a40689 refactor: a component id is built by whatever reads one
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.
2026-09-15 21:19:50 -07:00
esengine cb3037d1d8 docs: retire the stage numbers, and three claims that outlived them
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.
2026-09-15 21:17:01 -07:00
esengine 3deba0e288 refactor(extensioncontract): the provider-ref grammar is written once
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.
2026-09-15 21:10:34 -07:00
esengine 759a94d951 refactor: a convenience entrance belongs to the test that wants it
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.
2026-09-15 21:03:29 -07:00
esengine e42d96a0ef refactor: four more wrappers and a glob type nothing constructed
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.
2026-09-15 20:41:53 -07:00
esengine a2f477de70 refactor(neterr): a transport error is read by its identity on both platforms
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.
2026-09-15 20:34:02 -07:00
esengine 50caefc779 refactor: retire exported wrappers no caller outside a test ever used
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.
2026-09-15 20:26:44 -07:00
esengine 26f992350e refactor(extension): the kernel keeps one way to receive a contribution
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.
2026-09-15 20:12:49 -07:00
esengine 884ac5a4bb fix(studio): a window opens in the posture its config names
`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`.
2026-09-15 19:53:11 -07:00
esengine b9088283f3 feat(studio): the transcript stops being one column wide
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.
2026-09-15 19:36:38 -07:00
esengine dbf77fc8e3 fix(studio): Windows refuses to install into a folder holding anything else
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.
2026-09-15 18:43:56 -07:00
esengine 616ded27d4 fix(studio): a palette that will not activate says so
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.
2026-09-15 17:26:56 -07:00
esengine 4b294d8eb5 fix(studio): the slash menu speaks the language the window is set to
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.
2026-09-15 09:40:02 -07:00
esengine deb9030210 test(studio): all three sweeps take the same walk, and say what they walked
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.
2026-09-15 09:21:48 -07:00
esengine c950c08780 fix(studio): the first screen is legible, and the sweep stops crying wolf
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.
2026-09-15 05:37:17 -07:00
esengine 93b0c5d26d fix(studio): a narrow window shortens what is read, not what is there
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.
2026-09-15 05:32:52 -07:00
esengine 403b2952f9 fix(theme): a pack's text is legible, and answers the reader's contrast
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.
2026-09-15 05:08:37 -07:00
esengine ade04ce285 test(studio): the sweep reaches the workbench it was written to walk
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.
2026-09-15 05:08:26 -07:00
esengine a4a2d63a4d fix(studio): the pack's picture reaches the transcript again
`.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.
2026-09-15 04:19:54 -07:00
esengine 734888d8e1 ci: the Windows full-suite step is budgeted for runner variance
`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.
2026-09-15 02:20:39 -07:00
esengine 7ddf8652ef docs: the Studio 2.16.0 count takes in the markdown fix 2026-09-15 02:18:59 -07:00
esengine e0bb898c79 fix(studio): a deferred markdown plugin is read through a thunk
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.
2026-09-15 02:18:28 -07:00
esengine a0eb0b2526 docs: release notes for Studio 2.16.0 2026-09-15 01:04:51 -07:00
esengine 02303b330b chore(studio): read the TS record table with strings.Cut
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.
2026-09-15 00:56:14 -07:00
esengine f0f659c902 test(workspacelease): an instant hold reads as zero on Windows
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.
2026-09-15 00:56:09 -07:00
esengine b08618e500 fix(serve): a trace fixture records a shape, not a measured duration
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.
2026-09-15 00:56:03 -07:00
esengine b8277dc1c2 fix(census): an open cause names the effects that carry it
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.
2026-09-15 00:55:54 -07:00
esengine 7e7444508d feat(telemetry): report what the workspace write lease costs
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.
2026-09-15 00:01:31 -07:00
esengine f83d80de27 feat(tools): read the lease accounts back out of the session logs
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.
2026-09-14 23:44:57 -07:00
esengine 30e553839e feat(kernel): count what serialising writers actually costs
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.
2026-09-14 23:26:51 -07:00
esengine e35612abf8 feat(studio): a turn ends on its receipt
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.
2026-09-14 22:34:18 -07:00
esengine fd5b0e4724 fix(studio): a row names the call, and names it once
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.
2026-09-14 22:33:46 -07:00
esengine 8a73938f22 fix(studio): a row costs what it has to say
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.
2026-09-14 21:59:51 -07:00
esengine fbeaf194f6 fix(studio): a found row lands clear of the find bar
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.
2026-09-14 20:57:16 -07:00
esengine 577213b919 refactor(studio): stylesheets carry no commentary
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.
2026-09-14 20:46:53 -07:00
esengine 7019e7114c fix(studio): six browser guards that had stopped parsing, and what the sweep never saw
`"[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.
2026-09-14 20:46:28 -07:00
esengine bc50137b2d feat(studio): four things the workbench could not do, and the task list's real state
**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.
2026-09-14 20:46:15 -07:00
esengine 2083687116 fix(studio): a card names what the kernel actually ran
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.
2026-09-14 20:45:24 -07:00
esengine a00d5dcd13 fix(agent): a refused tool result carries the host's identity for it
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.
2026-09-14 18:13:17 -07:00
esengine 85e869ad5a fix(studio): a reopened transcript names the capability a proxy reached
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.
2026-09-14 17:39:29 -07:00
esengine 58842c450d fix(agent): a token estimate measures the request that went out
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.
2026-09-14 16:27:04 -07:00
esengine 3a8c52264b fix(studio): an MCP step's line says what its mark says
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.
2026-09-14 10:35:09 -07:00
esengine 0b8b28e3c3 chore: ignore the tool-surface-cache build artifact
The benchmark is a command, so building it writes a binary at the repo root.
repolint refuses one that nothing ignores.
2026-09-14 09:42:23 -07:00
esengine 76d3806f25 refactor(agent): a tool is in the schema while it is callable
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.
2026-09-14 09:38:36 -07:00
esengine c40ce02166 bench(tool-surface-cache): what moving a tool in the schema costs
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.
2026-09-14 09:38:14 -07:00
esengine d83052b0f5 feat(agent): the capability listing names the arguments a call is held to
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.
2026-09-14 08:55:38 -07:00
esengine 60b0288566 fix(studio): each pane's transcript has an id of its own
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.
2026-09-14 08:08:57 -07:00
esengine e7bb095e67 fix(studio): output that runs off to the right says so
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.
2026-09-14 08:00:54 -07:00
esengine 3874085608 style(studio): a block title in Chinese outranks what it titles
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.
2026-09-14 07:27:40 -07:00
esengine 56fe93ad21 fix(studio): the contrast sweep reads the colour that was painted
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.
2026-09-14 07:09:54 -07:00
esengine 4a491d0599 style(studio): the light scheme's greys are warm
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.
2026-09-14 07:05:50 -07:00
esengine 2a1a26bdc1 fix(studio): small-caps typography stops being applied to Han
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.
2026-09-14 06:42:38 -07:00
esengine f013550a51 style(studio): the monospaced face goes back to being the machine's
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.
2026-09-14 06:04:59 -07:00
esengine 2e076e34cd style(studio): the answer is set at a reading size, on a reading measure
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.
2026-09-14 04:54:04 -07:00
esengine a23fb484de fix(studio): the sidebar stops counting nothing
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.
2026-09-14 04:08:27 -07:00
esengine 4a05e99b88 style(studio): the answer reads as the answer, and the rail has somewhere to look
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.
2026-09-14 04:08:16 -07:00
esengine 01eda36380 docs: the comment rule says what register it means and who holds it
"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.
2026-09-14 01:16:39 -07:00
esengine 6011a3b49d fix(serve): a conversation held elsewhere says which process holds 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.
2026-09-14 01:16:39 -07:00
esengine 2fa496409a fix(studio): the task panel reads the kernel's list instead of deriving one
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.
2026-09-14 01:16:39 -07:00
esengine edf7cec0f2 fix(agent): the task-list projection is owed when a status moves, not only an id
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.
2026-09-14 01:16:21 -07:00
esengine 9e84088b22 perf(studio): a pane nobody is looking at stops polling and stops redrawing
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.
2026-09-14 01:16:16 -07:00
esengine 94d8982444 feat(agent): a turn can hand its list back to the user instead of being driven
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.
2026-09-14 01:05:43 -07:00
esengine f4b100742b docs(studio): the notes count the guard fix that landed with them 2026-09-13 19:18:20 -07:00
esengine 98786d587d test(plugin): the cancellation guard runs the command the platform has
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.
2026-09-13 18:58:22 -07:00
esengine 9c8af2ae21 docs(studio): release notes for 2.15.0
Sixty-six commits since 2.14.1, read off their own messages: what the
change was, what it was measured against, and what it does not touch.
2026-09-13 18:53:12 -07:00
esengine 076f8c54e6 fix(studio): the sweep stops calling a scrolled row offscreen and a hidden twist unnamed
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.
2026-09-13 18:48:40 -07:00
esengine 8f72ce16bb fix(repolint): the translation-table exemption names the catalogues that exist
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.
2026-09-13 18:48:40 -07:00
esengine 41e4711543 fix(studio): the Chinese an English window still rendered goes through the catalogue
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.
2026-09-13 18:48:28 -07:00
esengine 61d374f77d fix(studio): a label paired with a segmented control keeps its width
`.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.
2026-09-13 18:48:08 -07:00
esengine e214551579 fix(studio): the two POSTs the CSRF guard was refusing carry the type it asks for
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.
2026-09-13 18:47:58 -07:00
esengine 811d02ebc7 fix(studio): a total that is not final says which of the four reasons it is
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.
2026-09-13 18:47:58 -07:00
esengine 15425a0e45 feat(control): an @ reference offers what the repository has not said to ignore
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.
2026-09-13 18:47:48 -07:00
esengine e6db271d9f feat(config): the DeepSeek catalog is one declaration, and its rates are one table
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.
2026-09-13 18:47:33 -07:00
esengine d3ab94b4dc fix(plugin): a lazily-started MCP server says who cancelled it
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
2026-09-13 03:14:02 -07:00
esengine 33efdbf8e9 fix(studio): the three guards that went red after their own assertions were written
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.
2026-09-12 23:46:59 -07:00
esengine 72a46ceeb4 fix(studio): the transcript stops reaching under the locator rail
.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.
2026-09-12 22:47:10 -07:00
esengine 64a379df9d fix(studio): the weakest ink stops being discounted below its own floor
--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.
2026-09-12 22:06:33 -07:00
esengine 485e2cc4bd fix(studio): a long project name stops running under the title bar's controls
.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.
2026-09-12 21:23:15 -07:00
esengine 894bc8c855 feat: the number of sessions open at once is not capped
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.
2026-09-12 21:12:26 -07:00
esengine db40928c7c feat(studio): how an endpoint carries context is a declaration, not a probe
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.
2026-09-12 20:31:28 -07:00
esengine 234579ed97 test(studio): a generic's closing bracket is not a tag opening
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().
2026-09-12 20:31:05 -07:00
esengine dc4f72fe6e fix(studio): the title bar is draggable along its whole length again
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.
2026-09-12 19:48:42 -07:00
esengine 18ebc953c9 fix(studio): a source that may not be edited here is not a source that is down
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.
2026-09-12 19:40:49 -07:00
esengine 76ccfc65a0 feat(studio): the plan is drawn once, by whichever surface has the room
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.
2026-09-12 19:02:45 -07:00
esengine 189b592f05 style(studio): remove and new stop carrying the same weight
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.
2026-09-12 18:55:44 -07:00
esengine 2d1cd8bfdb style(studio): the card that is blocking you is the one that is lifted
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.
2026-09-12 18:46:22 -07:00
esengine 1fb0d7060d fix(studio): a field and its wrapper stop sharing one name
.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.
2026-09-12 18:41:59 -07:00
esengine 8d0a311666 style(studio): a tool headline reads as three things, not five
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.
2026-09-12 17:29:10 -07:00
esengine 01a18c47eb style(studio): the session rail groups by machine, and its weights stop
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.
2026-09-12 17:21:53 -07:00
esengine aa59729fd2 style(studio): the rail groups by space, and its biggest number follows position
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.
2026-09-12 10:33:03 -07:00
esengine bb5c0b5816 feat(studio): seven changes the running interface asked for
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.
2026-09-12 10:22:16 -07:00
esengine 4d622c7048 feat(studio): the sidebar tree can be walked with the keyboard
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.
2026-09-12 08:05:44 -07:00
esengine 2ca748eecc style(studio): the composer stops one notch wider than the transcript
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.
2026-09-12 07:47:45 -07:00
esengine b2cc880d89 fix(studio): the window title names the pane in front, not the last one to speak
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.
2026-09-12 07:38:24 -07:00
esengine 51048cc4cc feat(studio): the mark on the capacity bar is the handle
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.
2026-09-12 07:20:35 -07:00
esengine 0944d0175e feat(studio): the fold point is changed where it is read
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.
2026-09-12 04:57:42 -07:00
esengine 88394bc00f feat(studio): the fold point says which of the two bounds put it there
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.
2026-09-12 04:41:45 -07:00
esengine 9d6b2c208c fix(studio): a kernel that outlives its window stops holding that window's sessions
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.
2026-09-12 03:35:34 -07:00
esengine be12e7aa39 fix(studio): in English, three labels were being cut to keep their footnotes
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.
2026-09-12 01:31:29 -07:00
esengine 36038c6fb8 style(studio): one corner, drawn one way
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.
2026-09-12 01:13:55 -07:00
esengine f618d8fa0e fix(studio): the warning beside a fully-permitted turn is drawn, not typed
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.
2026-09-12 01:03:44 -07:00
esengine 2ec5557160 style(studio): what you said is as wide as what you said
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.
2026-09-12 00:59:04 -07:00
esengine 06aaa4ae37 style(studio): the context bar gets a palette of its own, and the status hues back
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.
2026-09-12 00:59:04 -07:00
esengine 02c536021b fix(studio): the composer's floor reaches the card's edge
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.
2026-09-12 00:59:04 -07:00
esengine 279141dd5c style(studio): a table in an answer is set, and code in one is not delegated work
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.
2026-09-12 00:59:04 -07:00
esengine cf8a6be338 style(studio): the task board is a page with an answer, not four equal cards
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.
2026-09-12 00:59:04 -07:00
esengine ff4e192dcb fix(studio): the veil over a clipped output stops covering the way out of it
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.
2026-09-12 00:59:04 -07:00
esengine 0bedecb12c style(studio): the line under the box says what Enter does only when it changes
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.
2026-09-12 00:59:04 -07:00
esengine 8324feed53 style(studio): the light scheme's accent is the hue, not a brown that holds white
#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.
2026-09-12 00:59:04 -07:00
esengine 7327af8380 fix(studio): the sentence saying --bg never existed is what proved it did
`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.
2026-09-12 00:59:04 -07:00
esengine 308b7fdd0b feat(studio): the contrast of every line is measured, not looked at
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.
2026-09-12 00:59:04 -07:00
esengine 9f3f994122 style(studio): a menu's current row stops spending the waiting colour
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.
2026-09-12 00:59:04 -07:00
esengine 29e06ffdc4 fix(studio): the picker guard read a token's text where it meant its colour
`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.
2026-09-12 00:59:04 -07:00
esengine 8b495e1286 style(studio): writing a file is not a decision waiting for you
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.
2026-09-12 00:59:04 -07:00
esengine be75cd9565 style(studio): the settings sheet marks where you are the way the rail does
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.
2026-09-12 00:59:04 -07:00
esengine e11847ae8a style(studio): the column you read and the column you type in are one column
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).
2026-09-12 00:59:04 -07:00
esengine dcd7ae905f fix(studio): three marks in one rail column, drawn by three hands
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.
2026-09-12 00:59:04 -07:00
esengine 20a6c937bd style(studio): a heading in an answer is a step, not half a pixel
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.
2026-09-11 21:48:28 -07:00
esengine 1eb809d3a4 style(studio): where you are is not the colour that means you are needed
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.
2026-09-11 21:47:11 -07:00
esengine 1f032beb5b fix(studio): the panel's largest number was wearing a list row's slab
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.
2026-09-11 21:46:23 -07:00
esengine 2d5e5bf192 fix(studio): the launch that booted from an update never said it was working
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.
2026-09-11 21:10:41 -07:00
esengine ce8d41e6c7 test(studio): four guards that had stopped watching the product
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.
2026-09-11 20:42:14 -07:00
esengine 2ca40d0403 fix(permission): what only a person may decide is one declaration, not three
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.
2026-09-11 19:36:49 -07:00
esengine 22398cbdcc fix(permission): a grant recorded before grants named subjects covers nothing
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.
2026-09-11 19:25:25 -07:00
esengine 9aaa3abe00 fix(permission): "don't ask again" answers for the subject it was asked about
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.
2026-09-11 18:51:14 -07:00
esengine c720aa0dbf feat(agent): a delegated run can ask to widen its write fence, and only a person may answer
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.
2026-09-11 18:07:04 -07:00
esengine 4247751438 feat(studio): a conversation another window is writing opens read-only
"占用了我打开又如何呢" — 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.
2026-09-11 09:19:32 -07:00
esengine d213e36da1 style(studio): the message rail is a fisheye, not a map
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.
2026-09-11 08:48:59 -07:00
esengine f307ce19e2 fix(studio): the scroll indicator stops sliding backwards as you scroll up
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.
2026-09-11 08:48:40 -07:00
esengine 1d5bd6e603 fix(serve): a session that is gone says so, instead of being held elsewhere
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.
2026-09-11 08:02:50 -07:00
esengine 0c8e864c1a style(studio): an answer and the evidence under it are two faces
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.
2026-09-11 07:29:07 -07:00
yhh 1b2365fff2 docs(release): 2.14.1 covers what it actually carries
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.
2026-09-11 05:53:25 -07:00
yhh bd82e4c53f feat(studio): a call that is still running says how long it has been
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.
2026-09-11 05:24:31 -07:00
yhh 1f8661e482 fix(studio): a picker's menu is rendered where no ancestor can hold it down
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.
2026-09-11 04:44:28 -07:00
yhh 8f3b8d4682 docs(release): the 2.14.x notes state findings rather than paraphrase commits
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.
2026-09-11 04:06:53 -07:00
yhh 15f5cb8523 docs(release): 2.14.1's own notes
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.
2026-09-11 04:02:10 -07:00
yhh b25a503e6c fix(studio): a settings chunk that never arrives loses the panel, not the window
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.
2026-09-11 03:50:44 -07:00
yhh b0093ef8e9 docs(release): 2.14.0's own notes
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.
2026-09-11 03:07:27 -07:00
yhh 735a7ff5d1 test(workgroup): a temp root's second name is asserted only where it exists
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.
2026-09-11 03:01:10 -07:00
yhh d305fb7481 fix(studio): pasting a file into the composer is the attach it performs
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.
2026-09-11 03:01:10 -07:00
yhh a7775df65b fix(studio): a deferred route is still production code
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.
2026-09-11 03:01:10 -07:00
yhh 1b399469b1 feat(studio): the rail reads as three levels, and the leaf keeps its title
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.
2026-09-11 03:01:10 -07:00
yhh 9bb2bb2d3d style(studio): the guards report in written Chinese
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.
2026-09-11 03:01:10 -07:00
yhh 180c578d13 fix(studio): a remote session's title arrives when the far kernel writes it
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.
2026-09-11 03:01:10 -07:00
yhh 7b24f5c45e fix(studio): a session row's actions are quiet rather than absent
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.
2026-09-11 03:01:10 -07:00
yhh 7da36e705c fix(studio): a menu marks its current row the way the rail does
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.
2026-09-11 03:01:10 -07:00
yhh 8d32b06a6c fix(studio): the transcript stops lurching when a card arrives mid-turn
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).
2026-09-11 03:01:09 -07:00
yhh 24e068a02f fix(studio): the kernel inside the window speaks the configured language
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.
2026-09-11 03:01:09 -07:00
yhh 6914f4c4bc fix(studio): the title bar's controls are out of the window drag region again
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.
2026-09-11 03:01:09 -07:00
yhh e90f3fa52c fix(studio): while a turn runs, the filled button is the one that stops it
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.
2026-09-11 03:01:09 -07:00
yhh 2df71318a1 style(studio): the comments this branch added are written in English
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.
2026-09-11 03:01:09 -07:00
yhh 49eb9e51fa feat(studio): the rail's search asks the whole list
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.
2026-09-11 03:01:09 -07:00
yhh 29ce4daa60 feat(studio): one list of machines, and this one is the first of them
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.
2026-09-11 03:01:09 -07:00
yhh 46e22a3c7e feat(studio): a box in the flow says it is a box once
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.
2026-09-11 03:01:09 -07:00
yhh 1904ba37b8 feat(studio): six type steps, and a floor the interface never goes under
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.
2026-09-11 03:01:09 -07:00
yhh a2330d82a0 feat(studio): a press answers, and what answers it is what the markup says
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.
2026-09-11 03:01:09 -07:00
yhh 883f8e058d refactor(studio): a duration comes from a weight, not from the call site
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.
2026-09-11 03:01:09 -07:00
yhh 71ef3a3083 refactor(studio): the settings search matcher lives with the catalogue it reads
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.
2026-09-11 03:01:09 -07:00
yhh 9e955ca2cc style(studio): the strings this branch added state things rather than chat
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.
2026-09-11 03:01:09 -07:00
yhh a1adff18c5 feat(studio): refine motion and interaction feedback 2026-09-11 03:01:09 -07:00
yhh 72376e0377 feat(studio): refine transcript card hierarchy 2026-09-11 03:01:09 -07:00
yhh 3520d6bc80 feat(studio): polish responsive composer interactions 2026-09-11 03:01:09 -07:00
yhh 19162ffdcb feat(studio): refine the conversation editor 2026-09-11 03:01:08 -07:00
yhh bd399eb0b1 feat(studio): verify unlisted provider models 2026-09-11 03:01:08 -07:00
yhh dd5ddfb963 test(openai): bound canceled stream cleanup 2026-09-11 03:01:08 -07:00
yhh 64c6c78f51 feat(deepseek): recognize flash vision across Studio 2026-09-11 03:01:08 -07:00
yhh 1ab092b662 feat(studio): refine settings layout and hierarchy 2026-09-11 03:01:08 -07:00
yhh 3ea97e40e7 feat(studio): polish the workspace and defer optional UI 2026-09-11 03:00:15 -07:00
yhh e58164cebd fix(grep): slash globs are rooted at the search target 2026-09-11 03:00:14 -07:00
esengine b015ba7ccd fix(studio): a refusal is said on the page the click was made, and no branch reads a sentence
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.
2026-09-10 18:39:09 -07:00
esengine 154fcb60ee style(studio): the rest of the interface states things rather than chats
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.
2026-09-10 17:56:09 -07:00
esengine 3940d4c225 style(studio): the settings screen states things rather than chats
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.
2026-09-10 17:32:06 -07:00
esengine 723eb4c177 fix(studio): an answer the kernel already gave reaches the settings reader
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.
2026-09-10 17:14:52 -07:00
esengine 4b6149be5b fix(agent): a digest that dropped a fact is completed, not bought again
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.
2026-09-09 18:02:21 -07:00
esengine 027f6bc0ac feat(agent): asking to tidy the context is not asking to pay for it
/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.
2026-09-09 17:31:25 -07:00
esengine e986d673cb feat(agent): a compaction receipt accounts for every summary call
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.
2026-09-09 09:39:55 -07:00
esengine 1960266b62 fix(agent): compaction never promotes local raw tool output
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.
2026-09-09 09:23:06 -07:00
esengine 53bec27d25 feat(billing): a session says how much of what it billed carries a price
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.
2026-09-09 09:02:15 -07:00
yhh bc345df0f1 fix(studio): a known amount is never printed as the string a zero prints
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.
2026-09-09 04:41:05 -07:00
yhh b5f342ad57 feat(studio): context and cache show the decision, not duplicate telemetry
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.
2026-09-09 04:21:48 -07:00
yhh f02c445860 feat(studio): runtime facts appear when they have something to say
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.
2026-09-09 04:21:48 -07:00
yhh 39068aab48 feat(studio): the composer shows deviations, not defaults
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.
2026-09-09 03:29:26 -07:00
yhh abae56ab8a fix(studio): tool-derived runtime facts come from executions, not from cards
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.
2026-09-09 02:38:49 -07:00
yhh d0bc2e2ecf fix(studio): throughput expires on wall time, it is not re-derived by 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.
2026-09-09 01:07:17 -07:00
yhh dea993b0c0 feat(studio): appearance opens on what is looked at, and folds the rest
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.
2026-09-08 20:54:38 -07:00
yhh 87aee90dd2 feat(studio): the theme belongs to the appearance page, not to the chrome
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.
2026-09-08 20:54:38 -07:00
yhh d6a2d5fb88 test(agent): an opt-in fork for measuring whether a refusal identity is acted on
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.
2026-09-08 20:54:38 -07:00
yhh 872c906403 feat(tool): a host refusal carries an identity, not only a sentence
`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.
2026-09-08 20:54:38 -07:00
yhh 3bd382c365 fix(repolint): make check opens files git has not been told about
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.
2026-09-08 20:54:38 -07:00
esengineandClaude Opus 5 c0897426c9 feat(studio): a setting says who owns it, not only when it lands
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
2026-09-08 20:54:38 -07:00
esengineandClaude Opus 5 e4d57027ec feat(studio): the shelf says what is unusual about this turn
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
2026-09-08 20:54:38 -07:00
esengine 3ae82f64ad feat(studio): the pane's bar says how to read a run, not how many ways there are
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.
2026-09-08 20:54:14 -07:00
esengineandClaude Opus 5 9c82690f65 feat(studio): the gauge counts down to the fold point, not to the window
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
2026-09-08 09:03:27 -07:00
esengine b11a4cb503 fix(studio): a write needs a gesture someone made on purpose
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.
2026-09-08 08:17:45 -07:00
esengine 97a0c07db1 feat(studio): name the four that reach the host, leave the six that do not
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.
2026-09-08 08:12:38 -07:00
esengine bcbbf85cd7 feat(studio): adding a workspace is one action however you name the folder
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.
2026-09-08 08:02:11 -07:00
esengine fbba4418d2 feat(studio): appearance actions name the intent, not the control
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.
2026-09-08 07:54:16 -07:00
esengine ba82314ff0 docs: what the studio round settled, as rules rather than as history
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.
2026-09-08 07:28:34 -07:00
esengine 2ebfff638b feat(studio): the inspector leads with what this part of the run is about
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.
2026-09-08 07:22:10 -07:00
esengine b0af1d1b04 fix(studio): motion has a cause, and mount is not one
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.
2026-09-08 06:59:30 -07:00
esengine 6272d2ebd8 docs(studio): only the model switch was ever the rebuild
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.
2026-09-08 06:47:15 -07:00
esengine 3966791c6f feat(studio): settings say where they are and what changing them costs
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.
2026-09-08 05:31:50 -07:00
esengine 47e518f943 feat(studio): keep pane close confirmation anchored to its tab
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.
2026-09-08 05:05:14 -07:00
esengine 637c0ff6e1 fix(studio): a primitive's callbacks are declared, not spelled
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.
2026-09-08 04:19:42 -07:00
esengine 49b574687c feat(studio): the composer owns how the next turn runs
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.
2026-09-08 02:09:57 -07:00
esengine 38c1477679 refactor(studio): give transcript facts their proper visual weight
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.
2026-09-08 01:50:14 -07:00
esengine 491016f8c9 fix(workgroup): the natural sample excludes experiments by construction
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.
2026-09-07 22:12:01 -07:00
esengine c2ffdf6d5e feat(workgroup): the designed assay is finished; the natural one is not the same question
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.
2026-09-07 21:57:55 -07:00
esengine 46540ac28f fix(workgroup): a speculative partial dispatch is not a call
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.
2026-09-07 21:46:52 -07:00
esengine babe9be7e1 feat(workgroup): freeze the natural-value protocol before its sample exists
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.
2026-09-07 19:55:24 -07:00
esengine 3f2809c334 test(workgroup): what a legal execution partition may and may not do
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.
2026-09-07 19:27:25 -07:00
esengine 43e1e93c5e test(trajectory): a cold reload reads the same turn a live client saw
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.
2026-09-07 18:58:05 -07:00
esengine 4d44b6fb69 feat(tool): who asked for a call is a fact the call carries
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.
2026-09-07 18:28:18 -07:00
esengine 5b05497a0a feat(trajectory): a replayed trajectory says how much of the session it covers
/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.
2026-09-07 17:31:22 -07:00
esengine fea552a914 feat(events): which model wrote a frame is a fact the frame carries
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.
2026-09-07 10:32:08 -07:00
esengine ba4cc43968 feat(transcript): the settled message frame is enough to rebuild the card
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.
2026-09-07 10:18:58 -07:00
esengine 92ed1ab73f fix(history): a line the host composed is not a thing the user said
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.
2026-09-07 10:13:07 -07:00
esengine 15efab5476 feat(turn): control owns the authored turn boundary across planner and executor runs
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.
2026-09-07 09:41:16 -07:00
esengine d9e9e4328f feat(turn): live turn start names the authored message it is about
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.
2026-09-07 08:19:18 -07:00
esengine 8e9b0eeb64 feat(studio): a continuation on a proven promise is an execution edge
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.
2026-09-06 19:04:00 -07:00
esengine 4369cf12e3 feat(studio): what proves a value is a promise, before .then means anything
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.
2026-09-06 19:03:14 -07:00
esengine a4d5e3a2bf feat(studio): the census that holds the action set, and the gate over it
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.
2026-09-06 17:17:13 -07:00
esengine f492357989 feat(studio): what a person can do here is a set with names
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.
2026-09-06 17:16:32 -07:00
esengine b22096e7f0 feat(studio): one reader for what a user input is, and a place to name one
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.
2026-09-06 17:15:48 -07:00
esengine 95399c92df docs(release): 2.13.0's own notes
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.
2026-09-06 16:46:22 -07:00
esengine 546c53bd63 fix(release): collect both artifact sets from the job that produced them
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.
2026-09-06 16:44:34 -07:00
esengine d039c64d80 fix(release): the release notes' Markdown backticks stop reading as a substitution
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.
2026-09-06 10:23:58 -07:00
esengine 6837222193 fix(proc): the Job Object handle gets an owner, because two paths were closing it
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.
2026-09-06 10:21:24 -07:00
esengine 6e7f189e76 fix(ci): the heap corruption's two upgradable causes, and a floor set on the wrong platform
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.
2026-09-06 09:47:46 -07:00
esengine 85cba5b991 fix(ci): the desktop module carries the same advisory bump
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.
2026-09-06 09:23:08 -07:00
esengine a7521c51ef fix(ci): four red gates, each with the reason it was red
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.
2026-09-06 09:19:19 -07:00
esengine 9ed7b4a20c feat(studio): the permission, sandbox and endpoint switches get names
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.
2026-09-05 20:26:25 -07:00
esengine f46dda045b feat(studio): which clicks write, decided by the verb behind them
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.
2026-09-05 20:19:11 -07:00
esengine 6a4cfa3849 fix(plugin): a server's refusal outranks this machine's clock
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.
2026-09-05 20:11:11 -07:00
esengine acdf2c09f1 test(plugin): what a forced refresh is actually being asked to do
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.
2026-09-05 20:00:01 -07:00
esengine 4f2863da8b fix(studio): a server's own words stop deciding what the row offers to do
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.
2026-09-05 19:53:41 -07:00
esengine 04cbc322c6 fix(plugin): an HTTP status stops being a number the moment it is a sentence
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.
2026-09-05 19:40:04 -07:00
esengine 260390efe6 fix(serve): a disk that would not write is not a bad request
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.
2026-09-05 19:25:15 -07:00
esengine 653bf42a1c feat(studio): what a person can do here is a set with names
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.
2026-09-05 18:59:40 -07:00
esengine 11a087649d fix(studio): a refusal reaches the reader through one boundary, not twenty-five
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.
2026-09-05 18:41:46 -07:00
esengine 6566aecc09 test(studio): a layer that can click, between static render and a real browser
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.
2026-09-05 18:29:13 -07:00
esengine 27c16a0d86 fix(serve): every inbox refusal reaches a frontend as a code, not a sentence
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.
2026-09-05 18:22:53 -07:00
esengine 33faf28053 fix(studio): changing a preset shows whether the change was accepted
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.
2026-09-05 18:12:17 -07:00
esengine ac73292378 fix(studio): live skill changes say when they really take effect
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.
2026-09-05 18:12:04 -07:00
esengine 3c870cfa6a fix(studio): a queued message is not empty when its body cannot be read
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.
2026-09-05 18:11:44 -07:00
esengine 1bb4272145 feat(evidence): why a report does not count is its own answer
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.
2026-09-05 09:50:33 -07:00
esengine 1b172cc91e fix(studio): how a turn ended is four judgements, and only one is a delivery
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.
2026-09-05 09:31:38 -07:00
esengine a9d2876c6b fix(studio): the context labels are read when they are drawn, not when the file loads
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 "=".
2026-09-05 09:16:45 -07:00
esengine 5e8e4d7407 test(studio): a guard that cannot find its subject says so
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.
2026-09-05 09:07:56 -07:00
esengine a68ee722ac refactor(studio): pending decisions, not a label, decide who is waited on
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.
2026-09-05 09:02:45 -07:00
esengine 7307a26d86 feat(studio): focus takes the chrome away and gives it back
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.
2026-09-05 08:22:09 -07:00
esengine 07aa12ce84 test(studio): two guards that could not have caught anything
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.
2026-09-05 08:09:57 -07:00
esengine 54850908a1 fix(studio): the input box has one budget, not one per child
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.
2026-09-05 07:56:35 -07:00
esengine b3ab4c1f46 fix(control): a host continuation is not a message the user typed
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.
2026-09-05 07:27:51 -07:00
esengine e8b6fbcc8e feat(control): standing instructions are asked for, not announced
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.
2026-09-05 06:55:57 -07:00
esengine 48ce86cb12 docs: freshness may not rest on being told
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.
2026-09-05 06:37:53 -07:00
esengine 0f9625d7cc feat(skill): the durable switch answers in the session that flipped it
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.
2026-09-05 06:36:55 -07:00
esengine f0830665cb refactor(control): name the fact a once-delivered block is owed against
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.
2026-09-05 05:59:50 -07:00
esengine e9c96c3d6a docs: four rules for who owns the model's view of dynamic state
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.
2026-09-05 05:48:41 -07:00
esengine 970c565de4 fix(boot): the skill switch reaches discovery, not only the panel that shows it
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.
2026-09-05 05:46:57 -07:00
esengine c955626d0b refactor(control): the registry owns when its listing is owed again
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.
2026-09-05 05:46:34 -07:00
esengine 37fdd2ac17 bench(catalog-detector): who owns "the listing is owed again"
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.
2026-09-05 05:35:01 -07:00
esengine 3cee1a5068 test(boot): what the model sees is a projection, measured at the boundary
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.
2026-09-05 05:04:22 -07:00
esengine 9d6a8b80b4 build: a command's binary is not something git can notice
`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.
2026-09-05 04:43:54 -07:00
esengine 1c7cb8d680 feat(evidence): what a report may close is the host's grant, not its own label
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.
2026-09-05 04:39:48 -07:00
esengine bd800b49dc refactor(frontmatter): the document is canonical, the flat map an adapter
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.
2026-09-05 04:38:54 -07:00
esengine 36d2391299 test(agent): classify the compaction noop family by reachability
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.
2026-09-05 01:37:04 -07:00
esengine 274a78fc8e test(agent): a noop verdict nobody has reached is not a dead one
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.
2026-09-05 01:25:56 -07:00
esengine d19db0eaa8 feat(skill): a worker declares what it owes when it finishes
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.
2026-09-05 01:15:23 -07:00
esengine c3762f8939 test(boot): the typed-review obligation has no owner to inherit from
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.
2026-09-05 01:01:30 -07:00
esengine c296b69f39 bench(runtime-resume): the two host surfaces, compared rather than assumed
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.
2026-09-05 00:54:05 -07:00
esengine 9e49590cf3 feat(agent): execution identity stops being the parent call's
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.
2026-09-05 00:35:58 -07:00
esengine 22bff74de6 bench(runtime-resume): ask what names a host-started execution
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.
2026-09-05 00:08:56 -07:00
esengine ac2af756f8 test(boot): freeze the delegation matrix, and seal Step 8
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.
2026-09-04 23:53:47 -07:00
esengine fdc696e55a fix(agent): an ephemeral run claims nothing the snapshot cannot justify
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.
2026-09-04 23:46:07 -07:00
esengine 189d106fb9 fix(boot): a subagent skill stops owning an execution loop of its own
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.
2026-09-04 23:21:29 -07:00
esengine 6dac296ad6 bench(runtime-resume): ask what kind of execution a subagent skill is
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".
2026-09-04 22:52:44 -07:00
esengine dea79d09b5 bench(runtime-resume): a sentinel inside another dispatches a fan-out nobody asked for
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.
2026-09-04 22:34:13 -07:00
esengine 85640c4a90 fix(agent): the store admits every execution that really started
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.
2026-09-04 22:34:13 -07:00
esengine ae8b2bc27b bench(runtime-resume): ask whether the store admits an execution in flight
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.
2026-09-04 21:55:32 -07:00
esengine baccc9828c bench(runtime-resume): read both trees, and retract the handoff finding
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.
2026-09-04 21:28:14 -07:00
esengine 1a9076abf8 bench(runtime-resume): stop each surface with the stop that owns it
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.
2026-09-04 21:13:10 -07:00
esengine c6313e58d2 bench(runtime-resume): ask where a stop goes
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.
2026-09-04 21:02:39 -07:00
esengine a50ca8075e fix(agent): a delegation the scheduler never admitted owes the store nothing
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.
2026-09-04 20:48:44 -07:00
esengine 9b80052f95 feat(agent): a lone delegation is recorded before it is drawn, and drawn as what it is
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.
2026-09-04 20:27:31 -07:00
esengine ffced139d2 bench(runtime-resume): ask what a held-back delegation is drawn as
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.
2026-09-04 20:00:20 -07:00
esengine efcb091541 bench(runtime-resume): ask where a lone delegation stops being recorded
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.
2026-09-04 19:52:27 -07:00
esengine cb0e3f7a9e bench(runtime-resume): ask which door each fact reaches the view through
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.
2026-09-04 19:21:37 -07:00
esengine 3d703d4296 feat(studio): the run graph is read from its authority, not accumulated
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.
2026-09-04 18:47:45 -07:00
esengine 9a0b4dad9e feat(studio): a snapshot joins the numbered stream instead of forking it
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.
2026-09-04 18:19:34 -07:00
esengine 0a96c45987 feat(control): read the run graph back instead of replaying it
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.
2026-09-04 10:45:30 -07:00
esengine 6f78a7ebfa feat(agent): an opening records what the worker layer resolved
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.
2026-09-04 10:25:03 -07:00
esengine 108aac1ad3 bench(runtime-resume): ask what a node's worker identity reports
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.
2026-09-04 10:16:24 -07:00
esengine 3aded57b05 feat(execgraph): rebuild the run graph from durable facts
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.
2026-09-04 10:08:59 -07:00
esengine 011d5316b2 bench(runtime-resume): ask what still has to be written down
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.
2026-09-04 09:44:29 -07:00
esengine 12bd453caa feat(agent): an adoption records whose answer it reused
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.
2026-09-04 09:35:39 -07:00
esengine 01536b0ce0 fix(agent): a run the caller stopped is not a run that failed
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.
2026-09-04 09:17:47 -07:00
esengine ad15120150 bench(runtime-resume): ask whether the store keeps every terminal
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.
2026-09-04 09:05:59 -07:00
esengine aa9ef03608 bench(runtime-resume): ask what a settling still means
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.
2026-09-04 08:58:59 -07:00
esengine 90ab0ff9d7 feat(agent): the refusal that queued an item outlives the scheduler
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.
2026-09-04 08:31:51 -07:00
esengine 1359f63a43 bench(runtime-resume): ask why a ready item never started
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.
2026-09-04 08:18:34 -07:00
esengine dd2e1a22fb feat(agent): the ordering a fan-out declared outlives it
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.
2026-09-04 07:57:18 -07:00
esengine 3f98faef60 feat(agent): a delegation is durable before it can act
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.
2026-09-04 07:41:44 -07:00
esengine e4f757003c feat(agent): a delegation is durable before it runs
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.
2026-09-04 07:32:00 -07:00
esengine f45fa814ef fix(store): a .jsonl sidecar is not a conversation
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.
2026-09-04 07:25:44 -07:00
esengine 014177bff9 bench(runtime-resume): ask what a dying fan-out leaves behind
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.
2026-09-04 07:00:39 -07:00
yhh ddeed9c2ad feat(serve): expose adjudication provenance
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.
2026-09-04 06:07:15 -07:00
yhh f309bc2c92 feat(control): a successor turn inherits the interruption it was given
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.
2026-09-04 05:45:51 -07:00
yhh b8810f930c bench(runtime-resume): ask when a recorded interruption stops being current
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.
2026-09-04 05:20:00 -07:00
yhh 3945ede3b6 feat(control): tell the next request what a dead run was waiting on
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.
2026-09-04 04:47:41 -07:00
yhh 7d3f5bb891 feat(control): record the adjudication barrier before anyone can be asked
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.
2026-09-04 04:24:06 -07:00
yhh 63afb442e7 bench(runtime-resume): ask what a restart inherits from an open decision
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.
2026-09-04 03:46:01 -07:00
yhh 584bf5cdb1 fix(agent): derive the host's step identity, stop storing it
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.
2026-09-03 22:34:45 -07:00
yhh 7fdbf353a2 bench(runtime-resume): hold the host's step-identity note to what it claims
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.
2026-09-03 21:25:48 -07:00
yhh 6169e884c7 feat(agent): the frozen body stops holding the live tail
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.
2026-09-03 21:02:05 -07:00
yhh c01338fee2 refactor(agent): the fold decides its coverage, persistence records it
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.
2026-09-03 20:36:13 -07:00
yhh d3b0975fb3 feat(agent): translate a fold boundary into canonical coordinates
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.
2026-09-03 20:22:56 -07:00
yhh 782411ace7 fix(control): preserve a valid projection across a tail rewind
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.
2026-09-03 20:11:33 -07:00
yhh 6b34022825 bench(runtime-resume): separate the two ways a live tail moves
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)
2026-09-03 19:57:33 -07:00
yhh 667173b5a0 bench(runtime-resume): ask what a second fold does to the body the first stored
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.
2026-09-03 19:47:49 -07:00
yhh c9b7c3389b fix(agent): make covered-prefix identity authoritative across restart
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.
2026-09-03 19:20:20 -07:00
yhh eba5806846 bench(runtime-resume): establish process-boundary resurrection matrix
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.
2026-09-03 19:12:06 -07:00
esengine 6ca60dbeb1 fix(studio): the Electron host answers the macOS update handoff
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.
2026-09-03 18:07:28 -07:00
esengine 0d65aa193a fix(release): stage the CLI archives by origin, and let a local build name its release
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.
2026-09-03 17:51:13 -07:00
esengine 6751d3f31c docs(release): notes for Studio 2.12.0 2026-09-03 17:28:54 -07:00
esengine 78fee8afb0 test(remote): the install scripts are run, not only written
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.
2026-09-03 17:23:57 -07:00
esengine dc2adfcd40 fix(serve): a brokered kernel stops filing an error against itself
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.
2026-09-03 17:07:44 -07:00
esengine c712f694d3 feat(serve): the kernel can serve the Studio page, not only the window
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.
2026-09-03 09:54:53 -07:00
esengine 45af9437a2 feat(remote): a release fetched for one machine is not fetched again for the next
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.
2026-09-03 09:42:40 -07:00
esengine 9f4776c19b fix(remote): a kernel that cannot be launched is not called usable
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.
2026-09-03 09:37:16 -07:00
yhh 0ed1c6edab feat(desktop): the context window is declared where its absence is noticed
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.
2026-09-03 06:09:51 -07:00
yhh 69ae62a00c fix(desktop): the prefix-cache caption was the row's crumple zone
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.
2026-09-03 06:08:10 -07:00
yhh c2bd45eac5 fix(i18n): eight keys the interface asked for and the catalogue did not carry
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.
2026-09-03 06:07:37 -07:00
yhh 0c81969a28 fix(mcp): the session's MCP lifetime is not the request that built it
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.
2026-09-03 05:04:30 -07:00
yhh 572d4761a1 fix(capability): the catalog is an index, not every description
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.
2026-09-03 04:47:24 -07:00
yhh 32bbf2f029 fix(agent): a call that never ran did not fail a modification
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.
2026-09-03 04:26:01 -07:00
yhh f40da7d068 fix(mcp): connecting a pending server is the decision, and is recorded
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.
2026-09-03 03:49:58 -07:00
yhh 3594238f87 fix(mcp): a server the repository declared waits for an answer
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".
2026-09-03 03:12:40 -07:00
yhh 344f07ac20 fix(mcp): read the scopes Claude Code actually writes to
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.
2026-09-03 02:22:43 -07:00
yhh a872a15651 fix(bash): read a command's output in the code page it was written in
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.
2026-09-03 01:52:21 -07:00
yhh 98d751178d feat(metrics): report what a tool call spent outside the command
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.
2026-09-03 01:52:21 -07:00
yhh b44e8fd520 perf(agent): hold the criteria walk to the state it answers for
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.
2026-09-03 01:52:20 -07:00
yhh efb3e03911 fix(workspace): a project opened at a drive root is not called "\"
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:\.
2026-09-03 01:51:57 -07:00
yhh 680cfcdff2 fix(desktop): a long filename stops painting over its own +N -N
.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.
2026-09-03 00:26:09 -07:00
yhh 36298580dc fix(capability): name the batch dispatcher where the model reads
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.
2026-09-03 00:05:40 -07:00
yhh 0a1231529c fix(graph): a lone sub-agent is a delegation the run graph draws
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.
2026-09-03 00:05:40 -07:00
yhh 1f48317164 feat(cache): say whether the body a request carried is the one it sent before
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.
2026-09-03 00:05:21 -07:00
yhh 2f9ad2527e fix(grep): a CJK file larger than the peek is not GB18030
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.
2026-09-03 00:05:04 -07:00
yhh 8b7aa646f0 fix(glob): a pattern rooted at a drive walks the drive, not its current directory
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.
2026-09-03 00:05:04 -07:00
yhh 5c8bc018b0 fix(agent): stop reading the workspace to find out which files are tests
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.
2026-09-03 00:04:47 -07:00
esengine 34373ca1a0 fix(capability): a tool's own name resolves to the tool
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.
2026-09-02 18:13:29 -07:00
esengine 7208ea9f25 fix(agent): a call cut mid-argument is dropped even when the terminal reads clean
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.
2026-09-02 18:13:29 -07:00
esengine 9649ccc152 fix(evidence): a rejected completion says which claim failed and what was missing
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.
2026-09-02 18:02:20 -07:00
esengine 639ccd26af fix(agent): namespace a sub-agent's tool ids once, not twice
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.
2026-09-02 17:43:10 -07:00
esengine e661902104 fix(agent): stop the dependency barrier from dropping delegation
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.
2026-09-02 17:43:10 -07:00
esengine dd6603e0d0 refactor(control): a turn's cancel belongs to the gate that admits it
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.
2026-09-02 17:12:58 -07:00
esengine 72b462fa61 refactor(control): move the two command appliers to the dispatch that calls them
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.
2026-09-02 17:01:58 -07:00
esengine 51712a6011 refactor(control): put cancelling a turn next to cleaning up after one
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.
2026-09-02 16:58:42 -07:00
esengine 8954115033 refactor(control): separate turning input into a turn from running one
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.
2026-09-02 16:55:34 -07:00
esengine cd5d82ff6b refactor(control): move the approval orchestration, and the gates that guard it
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.
2026-09-02 16:48:53 -07:00
esengine 2f65a1fc84 refactor(control): give the MCP server orchestration its own file
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.
2026-09-02 09:14:59 -07:00
esengine 7bd8045df9 refactor(control): split the session's lifecycle and its persistence out of the Controller
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.
2026-09-02 09:11:31 -07:00
esengine 73168c084f test(boot): hold the tool schemas to the prefix boundary too
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.
2026-09-02 08:58:18 -07:00
esengine 4e662b3643 feat(doctor): the host records what a session billed, and what its cold start cost
`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.
2026-09-02 08:52:06 -07:00
esengine 324d99e8f6 refactor(control): one projection for the turn, instead of eight prepend sites
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.
2026-09-02 08:32:22 -07:00
esengine 20d981f57b fix(compaction): let the newest instructions supersede the copies before them
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.
2026-09-02 08:22:03 -07:00
esengine 796d16271e perf(prompt): owe the project's own rules to the turn, not the prefix
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.
2026-09-02 08:19:41 -07:00
esengine 5ea0b16700 test(bootstrap): assert the probe's routes by name, not by index
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.
2026-09-02 08:01:38 -07:00
esengine 9f312a65f0 test(acp): compare the expanded command against the turn, not the whole prompt
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.
2026-09-02 07:59:53 -07:00
esengine 26f0891afb test(boot): pin the instructions as the last project state in the prefix
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.
2026-09-02 07:56:18 -07:00
esengine b8ddbadd42 test(boot): share the request helpers each effect test wrote for itself
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.
2026-09-02 07:52:00 -07:00
yhh 101f2ca12b perf(memory): stop composing the saved-fact index into the cached prefix
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.
2026-09-02 07:49:24 -07:00
yhh ff6d74e4dc perf(skills): owe the catalog to the turn instead of pinning it in the prefix
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.
2026-09-02 07:48:12 -07:00
yhh 857d2fb8c1 perf(prompt): keep version control out of the prefix every project shares
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.
2026-09-02 05:31:44 -07:00
yhh bb9823743e feat(remote): make a first connect stop being a setup task
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.
2026-09-02 00:06:48 -07:00
yhh 07adbaf92e fix(remote): keep an ephemeral -R forward on its port across reconnects
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.
2026-09-02 00:06:07 -07:00
esengine b1502e8561 feat(studio): offer to remove the install this shell replaces
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.
2026-09-01 17:30:46 -07:00
esengine 8e237eea36 perf(release): build macOS per architecture instead of one universal bundle
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.
2026-09-01 17:21:39 -07:00
esengine dc136baa4f feat(compaction): make the fold threshold a setting, and say which bound fires
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.
2026-09-01 17:10:18 -07:00
esengine b24f2c5386 perf(studio): stop shipping what nothing in the bundle reads
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.
2026-09-01 16:51:10 -07:00
esengine f776e30b6c fix(release): name the macOS artifacts for the bundle inside them
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.
2026-09-01 09:01:31 -07:00
5771 changed files with 457814 additions and 343661 deletions

No files matched your search

+1
View File
@@ -27,3 +27,4 @@
*.yml text eol=lf
.githooks/** text eol=lf
benchmarks/**/*.sh text eol=lf
internal/runtime/agent/testdata/perseveration/** -text
+42 -15
View File
@@ -1,16 +1,43 @@
# Every pull request requests review from the maintainers below.
# Either maintainer can review and merge any change.
* @SivanCola @esengine
# Ownership for the studio line. Standard: docs/DOCS_STANDARD.md.
#
# Each rule lists exactly two owners: primary first, backup second.
# GitHub requests review from both. The last matching rule wins.
# repolint (codeowners, doc-owner) fails a rule for a missing path, a rule
# without a backup, and a document header that names different owners.
# Keep the provider-side signing policy and its GitHub control plane under the
# same explicit review boundary as the machine-readable contract.
/.signpath/ @SivanCola @esengine
/.github/workflows/release-stable.yml @SivanCola @esengine
/.github/workflows/release-stable-trigger.yml @SivanCola @esengine
/.github/workflows/release-desktop.yml @SivanCola @esengine
/.github/workflows/release-studio.yml @SivanCola @esengine
/scripts/complete-signpath-request.ps1 @SivanCola @esengine
/scripts/package-windows-desktop.sh @SivanCola @esengine
/scripts/verify-windows-authenticode.ps1 @SivanCola @esengine
/scripts/studio-build.sh @SivanCola @esengine
/cmd/signpath-contract/ @SivanCola @esengine
# Default: kernel, Studio shell, tooling, standing instructions.
* @esengine @SivanCola
# User-facing guides and references.
/docs/ @SivanCola @esengine
/sdk/ @SivanCola @esengine
/README.md @SivanCola @esengine
/README.zh-CN.md @SivanCola @esengine
/SECURITY.md @SivanCola @esengine
# Contracts and processes the Studio line runs on.
/docs/DOCS_STANDARD.md @esengine @SivanCola
/docs/STUDIO_*.md @esengine @SivanCola
/docs/SESSION_MEMORY_RETRIEVAL.md @esengine @SivanCola
/docs/*_CONTRACT.md @esengine @SivanCola
/docs/TOOL_APPROVAL_MODES.md @esengine @SivanCola
/docs/CAPABILITY_DIAGNOSTICS.md @esengine @SivanCola
/docs/CODE_SIGNING_POLICY.md @esengine @SivanCola
/docs/CHECKPOINTS.md @esengine @SivanCola
/docs/EXTENSION_*.md @esengine @SivanCola
/docs/SUBAGENT_*.md @esengine @SivanCola
/docs/ROADMAP.md @esengine @SivanCola
/docs/MIGRATING.md @esengine @SivanCola
/docs/design/ @esengine @SivanCola
/release-notes/ @esengine @SivanCola
# Release signing: provider policy, its contract and the workflow that uses it.
/.signpath/ @esengine @SivanCola
/.github/workflows/release-studio.yml @esengine @SivanCola
/.github/workflows/studio-certum-signing-smoke.yml @esengine @SivanCola
/.github/actions/setup-certum/ @esengine @SivanCola
/cmd/signpath-contract/ @esengine @SivanCola
/scripts/sign-certum.ps1 @esengine @SivanCola
/scripts/verify-windows-authenticode.ps1 @esengine @SivanCola
/scripts/verify-windows-package.ps1 @esengine @SivanCola
/scripts/windows-signing-lib.ps1 @esengine @SivanCola
+7 -5
View File
@@ -7,23 +7,25 @@ body:
attributes:
value: |
Thanks for filing a bug. Pick which version line you're on below — it gets
labeled (`v1`/`v2`) automatically, no maintainer action needed.
labeled automatically, no maintainer action needed. Not sure which line
you run? See the [version roadmap](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748).
- type: dropdown
id: version-line
attributes:
label: Version line
description: Which line are you running?
options:
- "v2 — Go rewrite (1.x), main-v2 (active development)"
- "v1 — Legacy TypeScript (0.x), maintenance only"
- "2.x — Reasonix Studio, studio branch (active development)"
- "1.x — main-v2 branch (maintenance / stable)"
- "0.x — legacy TypeScript (maintenance only)"
validations:
required: true
- type: input
id: version
attributes:
label: Exact version
description: Output of `reasonix --version`.
placeholder: e.g. 1.0.0
description: Output of `reasonix --version`, or the version shown in Studio's settings.
placeholder: e.g. 2.20.0 or 1.39.0
validations:
required: true
- type: textarea
+3
View File
@@ -1,5 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Version roadmap (1.x / 2.x)
url: https://github.com/esengine/DeepSeek-Reasonix/discussions/10748
about: Which line to use, which branch takes which changes, and where Reasonix is heading.
- name: Questions & discussions
url: https://github.com/esengine/DeepSeek-Reasonix/discussions
about: For usage questions, ideas, and general discussion — please use Discussions instead of opening an issue.
+3 -2
View File
@@ -9,8 +9,9 @@ body:
label: Version line
description: Which line is this for?
options:
- "v2 — Go rewrite (1.x), main-v2 (active development)"
- "v1 — Legacy TypeScript (0.x), maintenance only"
- "2.x — Reasonix Studio, studio branch (active development)"
- "1.x — main-v2 branch (maintenance / stable)"
- "0.x — legacy TypeScript (maintenance only)"
validations:
required: true
- type: textarea
+35
View File
@@ -0,0 +1,35 @@
name: Connect to Certum cloud signing
description: Verify the pinned SimplySign installer and connect to the configured certificate.
inputs:
username:
required: true
description: SimplySign account
otp-uri:
required: true
description: TOTP provisioning URI
thumbprint:
required: true
description: Expected SHA-1 certificate thumbprint
runs:
using: composite
steps:
- name: Verify SimplySign installer
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Invoke-WebRequest 'https://files.certum.eu/software/SimplySignDesktop/Windows/9.4.3.90/SimplySignDesktop-9.4.3.90-64-bit-en.msi' -OutFile SimplySignDesktop.msi
$hash = (Get-FileHash -Algorithm SHA256 SimplySignDesktop.msi).Hash
if ($hash -ne 'CE2B38EF0124A574BC08E5558726099CAAC79A7C58B8D3D712F629371FE1BCB7') {
throw "SimplySign installer SHA-256 $hash does not match the pinned 9.4.3.90 build"
}
$signature = Get-AuthenticodeSignature SimplySignDesktop.msi
if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Asseco Data Systems') {
throw 'SimplySign installer signature or publisher verification failed'
}
- name: Authenticate cloud certificate
uses: dismine/windows-app-signing-setup-action@89ae3b032d4bc7a5b98d1a42a34e61ecb6faad64
with:
certum-username: ${{ inputs.username }}
certum-otp-uri: ${{ inputs.otp-uri }}
certum-key-id: ${{ inputs.thumbprint }}
capture-diagnostics: 'false'
+61 -2
View File
@@ -1,5 +1,6 @@
version: 2
updates:
# 2.x (studio): the development line.
- package-ecosystem: "gomod"
directory: "/"
schedule:
@@ -21,7 +22,7 @@ updates:
update-types: ["minor", "patch"]
- package-ecosystem: "npm"
directory: "/desktop/frontend"
directory: "/desktop/frontend-next"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
@@ -31,7 +32,7 @@ updates:
update-types: ["minor", "patch"]
- package-ecosystem: "npm"
directory: "/site"
directory: "/desktop/electron"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
@@ -47,3 +48,61 @@ updates:
groups:
actions:
patterns: ["*"]
# 1.x (main-v2): maintenance. Dependabot reads this file from the default
# branch only, so the 1.x line is named here; it takes patch updates alone.
- package-ecosystem: "gomod"
directory: "/"
target-branch: "main-v2"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
groups:
go-1x:
patterns: ["*"]
update-types: ["patch"]
- package-ecosystem: "gomod"
directory: "/desktop"
target-branch: "main-v2"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
groups:
go-1x:
patterns: ["*"]
update-types: ["patch"]
- package-ecosystem: "npm"
directory: "/desktop/frontend"
target-branch: "main-v2"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
groups:
npm-1x:
patterns: ["*"]
update-types: ["patch"]
- package-ecosystem: "npm"
directory: "/site"
target-branch: "main-v2"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
groups:
npm-1x:
patterns: ["*"]
update-types: ["patch"]
+9 -9
View File
@@ -6,36 +6,36 @@
agent:
- changed-files:
- any-glob-to-any-file:
- 'internal/agent/**'
- 'internal/control/**'
- 'internal/runtime/agent/**'
- 'internal/session/control/**'
mcp:
- changed-files:
- any-glob-to-any-file:
- 'internal/plugin/**'
- 'internal/ext/plugin/**'
- 'internal/codegraph/**'
config:
- changed-files:
- any-glob-to-any-file:
- 'internal/config/**'
- 'internal/boot/**'
- 'internal/contract/config/**'
- 'internal/assembly/boot/**'
provider:
- changed-files:
- any-glob-to-any-file:
- 'internal/provider/**'
- 'internal/contract/provider/**'
skills:
- changed-files:
- any-glob-to-any-file:
- 'internal/skill/**'
- 'internal/tool/**'
- 'internal/ext/skill/**'
- 'internal/contract/tool/**'
tui:
- changed-files:
- any-glob-to-any-file:
- 'internal/cli/**'
- 'internal/frontend/cli/**'
desktop:
- changed-files:
+6
View File
@@ -1,3 +1,9 @@
<!--
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
-
+88 -94
View File
@@ -18,7 +18,7 @@ concurrency:
cancel-in-progress: true
jobs:
# Cheap path gate for pull requests. PRs confined to docs/site/release-notes
# Cheap path gate for pull requests. PRs confined to docs/release-notes
# (or top-level Markdown) skip the heavy Go jobs, and PRs that cannot affect
# the desktop module skip the desktop jobs. Job-level `if` reports skipped,
# which satisfies the required status checks (lint, race, test) — a
@@ -32,15 +32,15 @@ jobs:
outputs:
code: ${{ steps.filter.outputs.code }}
desktop: ${{ steps.filter.outputs.desktop }}
site: ${{ steps.filter.outputs.site }}
sdk: ${{ steps.filter.outputs.sdk }}
scripts: ${{ steps.filter.outputs.scripts }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- id: filter
run: |
code=true; desktop=true; site=true; sdk=true
code=true; desktop=true; sdk=true; scripts=true
base=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
@@ -50,21 +50,37 @@ jobs:
if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
files=$(git diff --name-only "$base" HEAD)
if [ -n "$files" ]; then
code=false; desktop=false; site=false; sdk=false
code=false; desktop=false; sdk=false; scripts=false
# Root-module CI: desktop/ is a separate module, so only the
# clearly unrelated paths below can skip it.
if echo "$files" | grep -qvE '^(docs/|site/|release-notes/|desktop/|workers/|[^/]+\.md$)'; then code=true; fi
if echo "$files" | grep -qvE '^(docs/|release-notes/|desktop/|workers/|[^/]+\.md$)'; then code=true; fi
# desktop/ imports the root kernel via `replace reasonix => ../`,
# so only this clearly-unrelated set is safe to skip.
if echo "$files" | grep -qvE '^(docs/|site/|release-notes/|workers/|benchmarks/|npm/|[^/]+\.md$)'; then desktop=true; fi
if echo "$files" | grep -q '^site/'; then site=true; fi
if echo "$files" | grep -qvE '^(docs/|release-notes/|workers/|benchmarks/|npm/|[^/]+\.md$)'; then desktop=true; fi
# sdk/go is a nested module invisible to root `go test ./...`;
# its DTOs are generated from internal/extension/protocol, so
# its DTOs are generated from internal/ext/extension/protocol, so
# both paths must trigger the sdk job.
if echo "$files" | grep -qE '^(sdk/|internal/extension/)'; then sdk=true; fi
if echo "$files" | grep -qE '^(sdk/|internal/ext/extension/)'; then sdk=true; fi
# The release-note tests render every committed Studio note; npm/ holds the publish tests.
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/)'; then scripts=true; fi
fi
fi
{ echo "code=$code"; echo "desktop=$desktop"; echo "site=$site"; echo "sdk=$sdk"; } >> "$GITHUB_OUTPUT"
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
release-notes-scripts:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.scripts != 'false')
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs npm/publish.test.mjs
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
@@ -85,10 +101,10 @@ jobs:
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' }}
steps:
- if: env.RUN_STEPS == 'true'
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- if: env.RUN_STEPS == 'true'
uses: actions/setup-go@v7
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
@@ -135,6 +151,14 @@ jobs:
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
run: python3 -m unittest discover -s tools/trajectory -t tools/trajectory
# A hardware exception on a Windows host whose CPU saves a large XSTATE
# (Intel AMX) can corrupt the Go heap (golang/go#81238). The runner pool
# mixes hosts, so a runtime crash is only attributable with the CPU on record.
- name: runner CPU
if: env.RUN_STEPS == 'true' && runner.os == 'Windows'
shell: pwsh
run: Get-CimInstance Win32_Processor | Select-Object -ExpandProperty Name
- name: test
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
env:
@@ -152,44 +176,52 @@ jobs:
# remains independently bounded by Go's per-package timeout below.
#
# That timeout is 8m, not 3m: across four consecutive runs of the same
# code, internal/boot measured 133s, 146s, 162s and then hit 180s, and
# internal/agent 82s, 92s, 144s and 180s — a 2.2x spread from runner
# code, internal/assembly/boot measured 133s, 146s, 162s and then hit 180s, and
# internal/runtime/agent 82s, 92s, 144s and 180s — a 2.2x spread from runner
# variance alone, with four heavy packages sharing four vCPUs. At 3m the
# step failed on slow runners without a code change; a hang still trips
# the 15 minute job budget above.
#
# Every root-module `go test` that runs on Windows passes -count=1. With
# the result cache on, go test records each file and environment access
# a test binary makes, and before and after the run resolves every
# recorded path outside the module through filepath.EvalSymlinks.
# internal/assembly/boot records about 1.9M accesses; on a Windows runner
# that bookkeeping costs minutes per pass while nothing is printed.
# internal/base/testenv holds every such step to it.
- name: test (Windows smoke)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 15
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=8m ./internal/agent/... ./internal/appidentity/... ./internal/boot/... ./internal/checkpoint/... ./internal/cli/... ./internal/control/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/instruction/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
run: go test -count=1 -p 4 -timeout=8m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
# The general Windows PR smoke list intentionally omits internal/tool.
# The general Windows PR smoke list intentionally omits internal/contract/tool.
# Keep the session-temp portability contract covered without widening the
# leg to every tool test: two real PowerShell launches must share the
# injected TMPDIR/TMP/TEMP directory.
- name: test (Windows session temp)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 3
run: go test -timeout=2m -run '^TestBashSharesSessionTempAcrossCalls$' ./internal/tool/builtin
run: go test -count=1 -timeout=2m -run '^TestBashSharesSessionTempAcrossCalls$' ./internal/tools/builtin
# Shell execution contract: PowerShell identity, Chinese workspace paths,
# UTF-8 output, and ExitCode retention must gate PRs on native Windows.
# Keep this focused (not full ./internal/tool) so the smoke budget holds.
# Keep this focused (not full ./internal/contract/tool) so the smoke budget holds.
- name: test (Windows shell execution contract)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
run: go test -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tool/builtin
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
# Full push suite still uses the same 8m package budget as Windows smoke:
# agent/boot/control already hit 180s package timeouts under runner load,
# so 3m fails on slow machines without a code regression. Keep the step
# above normal full-suite wall time (~8-12m) so a hang still dies on the
# job budget rather than a false package timeout.
# so 3m fails on slow machines without a code regression. It runs with
# -count=1 for the reason given at the smoke step. A hang still dies here
# rather than on a package.
- name: test (full)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
timeout-minutes: 20
timeout-minutes: 30
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
@@ -198,16 +230,16 @@ jobs:
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=8m ./...
run: go test -count=1 -p 4 -timeout=8m ./...
race:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
@@ -234,7 +266,7 @@ jobs:
if: github.event_name == 'pull_request'
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./internal/agent/... ./internal/plugin/... ./internal/jobs/... ./internal/proc/... ./internal/sandbox/... ./internal/filelock/... ./internal/eventwire/... ./internal/remote/... ./internal/extension/... ./internal/boot/... ./internal/control/... ./internal/tool/...
run: go test -race ./internal/runtime/agent/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
- name: test -race (full)
if: github.event_name != 'pull_request'
@@ -243,7 +275,7 @@ jobs:
run: go test -race ./...
# sdk/go is a nested stdlib-only module invisible to root `go test ./...`.
# Its DTOs are generated from internal/extension/protocol, and the
# Its DTOs are generated from internal/ext/extension/protocol, and the
# host-side conformance tests spawn the SDK example, so the job runs the
# same three-OS matrix as the root tests.
sdk:
@@ -261,10 +293,10 @@ jobs:
working-directory: sdk/go
steps:
- if: env.RUN_STEPS == 'true'
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- if: env.RUN_STEPS == 'true'
uses: actions/setup-go@v6
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: sdk/go/go.mod
@@ -309,9 +341,9 @@ jobs:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: desktop/go.mod
cache: true
@@ -335,34 +367,26 @@ jobs:
exit 1
fi
# WebKitGTK 4.1 is what studio-build.sh links against: 4.0
# (libwebkit2gtk-4.0.so.37) is gone on Ubuntu 24.04+ and Fedora 40+.
- name: Install Linux build deps
run: |
sudo apt-get update
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
- name: vet
run: go vet -tags webkit2_41 ./...
run: go vet ./...
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
with:
version: v2.12.2
working-directory: desktop
args: --timeout=5m
- name: build
run: go build -tags webkit2_41 ./...
run: go build ./...
- name: test
run: go test -tags webkit2_41 ./...
run: go test ./...
# The shell's tab/rebuild fences and extension UI are shared state.
# Race-sweep the module so regressions are CI-blocked, not just
# author-verified locally.
# The update helper replaces a running application under pkexec, so its
# own concurrency is CI-blocked rather than author-verified locally.
- name: test -race
run: go test -race -tags webkit2_41 ./...
run: go test -race ./...
# desktop/ is a separate module, so the root macOS matrix above does not
# compile it against a real macOS toolchain.
@@ -374,9 +398,9 @@ jobs:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: desktop/go.mod
cache: true
@@ -385,11 +409,8 @@ jobs:
- name: test
run: go test -race ./...
# desktop's *_darwin.go is the one build-tag set the lint job cannot
# reach: the dock-icon glue is cgo, so it only type-checks with a real
# macOS toolchain.
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
with:
version: v2.12.2
working-directory: desktop
@@ -406,21 +427,17 @@ jobs:
shell: bash
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
# The vendored WebView2 fork carries the mixed-DPI and proxy-isolation
# patches, and its own tests only compile on Windows.
- name: test (Windows shell, update helper, and the WebView2 fork)
- name: test (update helper and uninstaller)
timeout-minutes: 15
run: |
go test ./...
(cd third_party/go-webview2 && go test ./pkg/edge)
run: go test ./...
# repolint scans desktop/ and sdk/ too, so this job also runs for diffs the
# `code` filter would otherwise skip.
@@ -429,9 +446,9 @@ jobs:
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' || needs.changes.outputs.desktop != 'false' || needs.changes.outputs.sdk != 'false')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
@@ -450,7 +467,7 @@ jobs:
run: echo "version=$(cat .golangci-version)" >> "$GITHUB_OUTPUT"
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
with:
version: ${{ steps.golangci.outputs.version }}
args: --timeout=5m
@@ -474,32 +491,9 @@ jobs:
run: |
go run github.com/rhysd/actionlint/cmd/[email protected] \
-ignore 'label "windows-11-arm" is unknown' \
.github/workflows/prepare-release-notes.yml \
.github/workflows/release-studio.yml \
.github/workflows/release-verify-issues.yml \
.github/workflows/studio-certum-signing-smoke.yml \
.github/workflows/ci.yml
node scripts/check-single-release-public-contract.mjs
# The site/ auth client has security-sensitive redirect-validation logic
# (safeNext) covered by node:test unit tests. Those tests use only Node
# builtins, so no `npm install` is needed — run them directly on every PR so
# a regression in redirect validation fails the build instead of shipping.
site:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.site != 'false')
runs-on: ubuntu-latest
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "24"
- name: test
run: npm test
govulncheck:
needs: changes
@@ -507,9 +501,9 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true # informational — stdlib vulns need a Go patch release
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
@@ -525,9 +519,9 @@ jobs:
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
@@ -536,7 +530,7 @@ jobs:
run: go test -coverprofile=coverage.out -covermode=atomic ./...
- name: upload coverage
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: coverage-report
path: coverage.out
+35
View File
@@ -0,0 +1,35 @@
name: Close linked issues
# GitHub closes an issue named by "Fixes #N" only when the pull request merges
# into the default branch. This does the same for the 1.x line. It reads the
# merged pull request's description and never checks out its code.
on:
pull_request_target:
types: [closed]
branches: [main-v2]
permissions:
issues: write
jobs:
close:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const pr = context.payload.pull_request;
const { owner, repo } = context.repo;
// GitHub's own closing-keyword grammar, applied to this repository's issues only.
const keyword = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+#(\d+)\b/gi;
const numbers = [...new Set([...(pr.body ?? '').matchAll(keyword)].map((m) => Number(m[1])))];
const sha = (pr.merge_commit_sha ?? '').slice(0, 9);
for (const issue_number of numbers) {
const { data } = await github.rest.issues.get({ owner, repo, issue_number });
if (data.pull_request || data.state !== 'open') continue;
await github.rest.issues.createComment({ owner, repo, issue_number,
body: `Fixed on the 1.x line by #${pr.number} (${sha}). It ships in the next 1.x release.` });
await github.rest.issues.update({ owner, repo, issue_number, state: 'closed', state_reason: 'completed' });
core.info(`closed #${issue_number}`);
}
+27 -14
View File
@@ -4,23 +4,25 @@ on:
# Docs/release-notes/root-Markdown changes carry no analyzable code; the
# weekly cron keeps the full scan cadence regardless.
push:
branches: ["main-v2"]
branches: ["main-v2", "studio"]
paths-ignore:
- 'docs/**'
- 'release-notes/**'
- '*.md'
pull_request:
branches: ["main-v2"]
branches: ["main-v2", "studio"]
paths-ignore:
- 'docs/**'
- 'release-notes/**'
- '*.md'
# A schedule runs only from the default branch, so the weekly scan checks out
# both lines itself: 1.x is in maintenance, and security is part of it.
schedule:
- cron: "27 3 * * 1"
jobs:
analyze:
name: Analyze (${{ matrix.language }})
name: Analyze (${{ matrix.language }}${{ matrix.branch && format(', {0}', matrix.branch) || '' }})
runs-on: ubuntu-latest
permissions:
security-events: write
@@ -30,21 +32,32 @@ jobs:
strategy:
fail-fast: false
matrix:
include:
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
- language: actions
build-mode: none
language: [go, javascript-typescript, actions]
# Empty means "the ref this event is about"; a scheduled run names each line.
branch: ${{ fromJSON(github.event_name == 'schedule' && '["main-v2", "studio"]' || '[""]') }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ matrix.branch }}
- id: head
if: matrix.branch != ''
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.37.4
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/[email protected]
if: matrix.branch == ''
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
category: "/language:${{ matrix.language }}"
# Results from a checked-out line are filed under that line's ref, not the
# default branch the schedule happened to run from.
- name: Perform CodeQL Analysis (${{ matrix.branch }})
if: matrix.branch != ''
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
category: "/language:${{ matrix.language }}"
ref: refs/heads/${{ matrix.branch }}
sha: ${{ steps.head.outputs.sha }}
+3 -3
View File
@@ -19,12 +19,12 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6.0.9
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
+2 -2
View File
@@ -19,8 +19,8 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
+3 -3
View File
@@ -19,12 +19,12 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6.0.9
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
+49
View File
@@ -0,0 +1,49 @@
name: Deploy Web Studio
on:
push:
branches: [studio]
paths:
- "desktop/frontend-next/**"
- ".github/workflows/deploy-web-studio.yml"
permissions:
contents: read
concurrency:
group: web-studio-production
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: desktop/frontend-next
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- name: Install
run: pnpm install --frozen-lockfile
- name: Build
env:
VITE_REMOTE_HOME: https://reasonix.io/remote/
run: pnpm build
- name: Deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
run: npx --yes [email protected] deploy --config wrangler.jsonc
+4 -4
View File
@@ -32,7 +32,7 @@ jobs:
environment: e2e-bot
steps:
- name: Acknowledge
uses: actions/github-script@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
await github.rest.reactions.createForIssueComment({
@@ -42,16 +42,16 @@ jobs:
# Default-branch checkout: this is where the harness (cmd/e2ebench), the
# suite, and a run --metrics-capable agent live.
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-python@v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.12'
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
classify:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
with:
@@ -35,7 +35,7 @@ jobs:
const body = (issue.body || '').slice(0, 4000);
const system = [
'You categorize GitHub issues for Reasonix, a Go-based AI coding agent with a Wails desktop app and a terminal UI.',
'You categorize GitHub issues for Reasonix, a Go-based AI coding agent with an Electron desktop app and a terminal UI.',
'Pick labels ONLY from these fixed sets. Never invent labels.',
'area (0-2, the affected subsystem):',
' agent: core agent loop / tool-calling / reasoning',
@@ -43,7 +43,7 @@ jobs:
' config: configuration, setup wizard, .toml/.env',
' updater: auto-update, installer, release packaging',
' provider: model providers, model selection/switching',
' desktop: Wails desktop GUI',
' desktop: Electron desktop GUI',
' tui: terminal UI / CLI',
' skills: skills system',
' rendering: terminal rendering / flicker / repaint',
+116
View File
@@ -0,0 +1,116 @@
name: Issue replies
# Posts maintainer replies as github-actions[bot], so a batch of triage answers
# spends the repository's quota instead of a person's content-creation limit.
on:
workflow_dispatch:
inputs:
replies:
description: 'JSON array of {"issue": 123, "body": "...", "close": true | "not_planned"} or {"discussion": 123, "body": "...", "close": "resolved" | "outdated" | "duplicate"}'
required: true
type: string
interval:
description: Seconds between entries
required: false
default: '20'
type: string
permissions:
issues: write
pull-requests: write
discussions: write
concurrency:
group: issue-replies
cancel-in-progress: false
jobs:
reply:
runs-on: win-gpu
timeout-minutes: 180
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
REPLIES: ${{ inputs.replies }}
INTERVAL: ${{ inputs.interval }}
with:
script: |
const replies = JSON.parse(process.env.REPLIES);
if (!Array.isArray(replies)) throw new Error('replies must be a JSON array');
const { owner, repo } = context.repo;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const failed = [];
// Only a rate limit is worth waiting out; any other refusal is final.
const rateLimited = (error) => {
const headers = error.response?.headers ?? {};
return error.status === 429 ||
(error.status === 403 && (headers['retry-after'] !== undefined || headers['x-ratelimit-remaining'] === '0'));
};
// Discussions have no REST comment API; they are addressed by node id.
const replyToDiscussion = async (number, body) => {
const q = await github.graphql(
`query($owner:String!,$repo:String!,$n:Int!){repository(owner:$owner,name:$repo){discussion(number:$n){id comments(last:100){nodes{body author{login}}}}}}`,
{ owner, repo, n: number });
const d = q.repository.discussion;
if (!d) throw new Error('no such discussion');
if (d.comments.nodes.some((c) => c.author?.login === 'github-actions' && c.body === body)) return false;
await github.graphql(`mutation($id:ID!,$body:String!){addDiscussionComment(input:{discussionId:$id,body:$body}){comment{id}}}`, { id: d.id, body });
return true;
};
const closeReasons = { resolved: 'RESOLVED', outdated: 'OUTDATED', duplicate: 'DUPLICATE' };
const closeDiscussion = async (number, reason) => {
const q = await github.graphql(
`query($owner:String!,$repo:String!,$n:Int!){repository(owner:$owner,name:$repo){discussion(number:$n){id closed}}}`,
{ owner, repo, n: number });
const d = q.repository.discussion;
if (d.closed) return;
await github.graphql(`mutation($id:ID!,$r:DiscussionCloseReason!){closeDiscussion(input:{discussionId:$id,reason:$r}){discussion{id}}}`, { id: d.id, r: reason });
};
for (const { issue, discussion, body, close } of replies) {
if (Number.isInteger(discussion) && typeof body === 'string' && body.trim() !== '') {
try {
const posted = await replyToDiscussion(discussion, body);
if (close !== undefined && close !== false) {
const reason = closeReasons[close];
if (!reason) throw new Error(`unknown close reason ${close}`);
await closeDiscussion(discussion, reason);
}
core.info(`discussion #${discussion}: ${posted ? 'replied' : 'already replied'}${close ? ', closed' : ''}`);
} catch (error) {
failed.push(`discussion ${discussion}: ${error.message}`);
}
await sleep(Math.max(1, Number(process.env.INTERVAL) || 20) * 1000);
continue;
}
if (!Number.isInteger(issue) || typeof body !== 'string' || body.trim() === '') {
failed.push(`${issue}: malformed entry`);
continue;
}
try {
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: issue, per_page: 100 });
const posted = comments.some((c) => c.user?.type === 'Bot' && c.body === body);
if (!posted) {
for (let attempt = 0; ; attempt++) {
try {
await github.rest.issues.createComment({ owner, repo, issue_number: issue, body });
break;
} catch (error) {
if (attempt >= 5 || !rateLimited(error)) throw error;
core.warning(`#${issue}: ${error.message}; retrying in 2 minutes`);
await sleep(120_000);
}
}
}
if (close === true || close === 'not_planned') {
const { data } = await github.rest.issues.get({ owner, repo, issue_number: issue });
if (data.state === 'open') {
await github.rest.issues.update({ owner, repo, issue_number: issue, state: 'closed', state_reason: close === 'not_planned' ? 'not_planned' : 'completed' });
}
}
core.info(`#${issue}: ${posted ? 'already replied' : 'replied'}${close ? ', closed' : ''}`);
} catch (error) {
failed.push(`${issue}: ${error.message}`);
}
await sleep(Math.max(1, Number(process.env.INTERVAL) || 20) * 1000);
}
if (failed.length) core.setFailed(`not done:\n${failed.join('\n')}`);
+26 -25
View File
@@ -2,7 +2,8 @@ name: Label issue version
# Issue forms can't let a reporter set a label directly (that needs triage rights),
# so the bug/feature forms carry a "Version line" dropdown and this workflow reads
# the submitted value and applies the matching v1/v2 label on their behalf.
# the submitted value and applies the matching label on their behalf:
# 0.x -> v1 (TypeScript), 1.x -> v2 (main-v2), 2.x -> v3 (studio).
on:
issues:
types: [opened, edited]
@@ -18,46 +19,46 @@ jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const body = context.payload.issue.body || '';
const sectionOf = (name) =>
body.split(/^###\s+/m).find((s) => new RegExp(`^${name}`, 'i').test(s)) || '';
const labelOf = (major) => ({ 0: 'v1', 1: 'v2', 2: 'v3' })[major];
const m = sectionOf('Version line').match(/\bv([12])\b/i);
const m = sectionOf('Version line').match(/\b([012])\.x\b/);
if (!m) {
core.info('No version line found; nothing to label.');
return;
}
let choice = 'v' + m[1];
let choice = labelOf(Number(m[1]));
// The dropdown is a choice, and v2 sits first, so it gets picked by
// reporters who are actually on 0.x. `Exact version` is pasted from
// `reasonix --version`, so when it parses it is the better evidence:
// 0.x is the TypeScript line, 1.x the Go rewrite. Anything that does
// not parse (a commit sha, "main-v2") leaves the dropdown to decide.
// The dropdown is a choice, and its first entry gets picked by
// reporters who never looked. `Exact version` is pasted from
// `reasonix --version` or Studio, so when it parses it is the better
// evidence. Anything that does not parse (a commit sha, a branch
// name) or names no known line leaves the dropdown to decide.
const exact = sectionOf('Exact version').match(/\b(\d+)\.(\d+)\.(\d+)/);
if (exact) {
const fromVersion = Number(exact[1]) === 0 ? 'v1' : 'v2';
if (fromVersion !== choice) {
core.info(`Version line says ${choice} but ${exact[0]} says ${fromVersion}; trusting the version.`);
choice = fromVersion;
}
const fromVersion = exact && labelOf(Number(exact[1]));
if (fromVersion && fromVersion !== choice) {
core.info(`Version line says ${choice} but ${exact[0]} says ${fromVersion}; trusting the version.`);
choice = fromVersion;
}
const other = choice === 'v2' ? 'v1' : 'v2';
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.issue.number,
labels: [choice],
});
// Keep v1/v2 mutually exclusive in case an edit flipped the choice.
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: context.issue.number,
name: other,
});
} catch (e) {
core.info(`No ${other} label to remove.`);
// Keep the line labels mutually exclusive in case an edit flipped the choice.
for (const other of ['v1', 'v2', 'v3'].filter((l) => l !== choice)) {
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: context.issue.number,
name: other,
});
} catch (e) {
core.info(`No ${other} label to remove.`);
}
}
-47
View File
@@ -1,47 +0,0 @@
name: Deploy site
on:
push:
branches: [main-v2]
paths: ['site/**', 'release-notes/**', '.github/workflows/pages.yml']
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
cache-dependency-path: site/package-lock.json
- name: Build
working-directory: site
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm ci
npm run build
- uses: actions/upload-pages-artifact@v5
with:
path: site/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- id: deploy
uses: actions/deploy-pages@v5
+1 -1
View File
@@ -23,6 +23,6 @@ jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v7
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7
with:
sync-labels: false
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const base = context.payload.pull_request.base.ref;
-101
View File
@@ -1,101 +0,0 @@
name: Prepare release
on:
workflow_dispatch:
inputs:
version:
description: "Official version without a tag prefix (for example 1.20.0)"
required: true
type: string
permissions:
contents: write
pull-requests: write
jobs:
draft:
name: generate reviewable bilingual draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Select the review branch
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin main-v2
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "::error::version must be MAJOR.MINOR.PATCH"
exit 1
fi
branch="release-notes/v${VERSION}"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
git checkout -b "$branch" --track "origin/$branch"
git merge --no-edit origin/main-v2
else
git checkout -b "$branch"
fi
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
- name: Generate and validate release notes
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
DEEPSEEK_MODEL: deepseek-v4-pro
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
node scripts/generate-release-notes.mjs --version "$VERSION"
node scripts/release-notes.mjs validate
node --test scripts/release-notes.test.mjs
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
{
echo "## Review draft for v${VERSION#v}"
echo
cat /tmp/release-notes.md
} >> "$GITHUB_STEP_SUMMARY"
- name: Open or update the review PR
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
git add release-notes/releases.json
if ! git diff --cached --quiet; then
git commit -m "docs(release): prepare v${VERSION#v} notes" \
-m "Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.
Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing."
fi
git push -u origin "$RELEASE_NOTES_BRANCH"
if ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
if ! gh pr create \
--base main-v2 \
--head "$RELEASE_NOTES_BRANCH" \
--title "docs(release): 准备 v${VERSION#v} 中英更新日志" \
--body-file /tmp/release-notes.md; then
echo "::warning::GitHub Actions could not open the PR; the reviewed branch is preserved."
{
echo "## Manual PR handoff"
echo
echo '```sh'
echo "gh pr create --repo ${{ github.repository }} --base main-v2 --head $RELEASE_NOTES_BRANCH --fill"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
fi
fi
-26
View File
@@ -1,26 +0,0 @@
name: Release notes
on:
pull_request:
paths:
- "release-notes/**"
- "scripts/release-notes*.mjs"
- "site/src/pages/changelog/**"
- "site/src/components/ChangelogPage.astro"
- "desktop/frontend/src/App.tsx"
- "desktop/frontend/src/components/SettingsPanel.tsx"
- "desktop/frontend/src/components/UpdateBanner.tsx"
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "22"
- run: node scripts/release-notes.mjs validate
- run: node --test scripts/release-notes.test.mjs
File diff suppressed because it is too large. Load diff
@@ -1,62 +0,0 @@
name: Request verification on released issues
# A PR that writes "Fixes #N" auto-closes its issue on merge. One that writes
# "Refs"/"Related"/"Addresses" does not, so the report stays open waiting for a
# follow-up nobody remembers to send after the release. This sends it.
on:
workflow_dispatch:
inputs:
tag:
description: Release tag to announce (e.g. desktop-v1.18.0)
required: true
type: string
dry_run:
description: Print the targets without commenting
required: false
default: true
type: boolean
release:
types: [published]
push:
branches:
- main-v2
paths:
- '.github/workflows/release-verify-issues.yml'
- 'scripts/release-verify-issues.mjs'
- 'scripts/release-verify-issues.test.mjs'
permissions:
contents: read
issues: write
concurrency:
group: release-verify-issues-${{ github.event.inputs.tag || github.event.release.tag_name || github.ref }}
cancel-in-progress: false
jobs:
notify:
if: github.repository == 'esengine/DeepSeek-Reasonix'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: Test notifier
run: node --test scripts/release-verify-issues.test.mjs
# A push that only touched the script has no release to announce; the test
# above is the whole point of that trigger.
- name: Request verification
if: github.event_name != 'push'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.inputs.tag || github.event.release.tag_name }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
run: |
set -euo pipefail
args=(--tag "$TAG")
if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run); fi
node scripts/release-verify-issues.mjs "${args[@]}"
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
if: github.event.comment.user.type != 'Bot' && github.event.comment.author_association == 'NONE'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const EMOJI = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}]/gu;
+2 -2
View File
@@ -40,10 +40,10 @@ jobs:
if: github.repository == 'esengine/DeepSeek-Reasonix'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
- name: Test sweeper
@@ -0,0 +1,117 @@
name: Studio Certum signing smoke test
# Signs two throwaway probes with the certificate release-studio.yml would use,
# and publishes nothing. It reports the signer's subject, which is the value
# STUDIO_SIGNING_SUBJECT must hold.
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: certum-signing
cancel-in-progress: false
jobs:
sign:
if: github.repository == 'esengine/DeepSeek-Reasonix' && github.ref == 'refs/heads/studio'
runs-on: windows-2022
environment: studio-release
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: false
- name: Check signing configuration
shell: pwsh
env:
CERTUM_USERNAME: ${{ secrets.CERTUM_USERNAME }}
CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }}
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
run: |
$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($env:CERTUM_USERNAME)) { throw 'Missing secret CERTUM_USERNAME' }
if ($env:CERTUM_OTP_URI -notmatch '^otpauth://totp/') { throw 'Secret CERTUM_OTP_URI must be a complete TOTP URI' }
if ($env:CERTUM_KEY_ID -notmatch '^[0-9a-fA-F]{40}$') { throw 'Secret CERTUM_KEY_ID must be the 40-character SHA-1 certificate thumbprint' }
- name: Build open-source signing probes
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# This minimal probe is covered by the repository's MIT license.
New-Item -ItemType Directory payload | Out-Null
'package main; func main() { println("Reasonix Studio Certum signing probe") }' | Set-Content probe.go
$env:GOOS = 'windows'
$env:GOARCH = 'amd64'
$env:CGO_ENABLED = '0'
go build -trimpath -o payload/probe.exe probe.go
if ($LASTEXITCODE -ne 0) { throw 'Probe compilation failed' }
'probe.exe' | Set-Content payload/signing-files.txt
Copy-Item payload/probe.exe installer-probe.exe
- name: Connect to SimplySign cloud certificate
uses: ./.github/actions/setup-certum
with:
username: ${{ secrets.CERTUM_USERNAME }}
otp-uri: ${{ secrets.CERTUM_OTP_URI }}
thumbprint: ${{ secrets.CERTUM_KEY_ID }}
- name: Sign and verify payload and installer probes
shell: pwsh
env:
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
run: |
./scripts/sign-certum.ps1 -PayloadDirectory payload
./scripts/sign-certum.ps1 -FilePath installer-probe.exe
# The session signs for any process on the runner while it is up.
- name: Disconnect from Certum
if: always()
shell: pwsh
run: Get-Process -Name '*SimplySign*' -ErrorAction SilentlyContinue | Stop-Process -Force
# With the switch on, an unset subject fails here as it would in a
# release; with it off, the value is reported so it can be recorded.
- name: Report and check the signer
shell: pwsh
env:
SIGNING_ENABLED: ${{ vars.STUDIO_SIGNING_ENABLED }}
EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }}
run: |
$ErrorActionPreference = 'Stop'
$failed = $false
foreach ($file in @('payload/probe.exe', 'installer-probe.exe')) {
$signature = Get-AuthenticodeSignature -LiteralPath $file
$certificate = $signature.SignerCertificate
$stamp = if ($signature.TimeStamperCertificate) { $signature.TimeStamperCertificate.Subject } else { '(none)' }
@("### $file", '', "- subject: ``$($certificate.Subject)``", "- issuer: ``$($certificate.Issuer)``",
"- thumbprint: ``$($certificate.Thumbprint)``", "- timestamp: ``$stamp``", '') >> $env:GITHUB_STEP_SUMMARY
if (-not $signature.TimeStamperCertificate) {
Write-Output "::error title=studio-signing.timestamp-missing::$file carries no timestamp"
$failed = $true
}
if ([string]::IsNullOrWhiteSpace($certificate.Issuer)) {
Write-Output "::error title=studio-signing.issuer-missing::$file has a signer certificate without an issuer"
$failed = $true
}
if ([string]::IsNullOrWhiteSpace($env:EXPECTED_SUBJECT)) {
if ($env:SIGNING_ENABLED -eq 'true') {
Write-Output "::error title=studio-signing.subject-unset::STUDIO_SIGNING_SUBJECT is empty while STUDIO_SIGNING_ENABLED=true"
$failed = $true
} else {
Write-Output "::warning::STUDIO_SIGNING_SUBJECT is not set; record '$($certificate.Subject)'"
}
} elseif ($certificate.Subject -cne $env:EXPECTED_SUBJECT) {
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
@@ -0,0 +1,107 @@
name: Studio Windows self-test
# An unsigned Windows installer for trying a branch on a real machine before it
# is released. It runs on the intranet runner, so it is dispatch-only: the
# repository is public, and nothing a pull request writes may run there.
on:
workflow_dispatch:
inputs:
ref:
description: Branch, tag or commit to build
required: false
default: studio
type: string
permissions:
contents: read
concurrency:
group: studio-selftest-windows
cancel-in-progress: true
jobs:
build:
runs-on: win-gpu
timeout-minutes: 90
steps:
# The machine's PATH finds WSL's bash before Git's, and a bash step run in
# WSL sees neither this checkout's toolchain nor Windows paths. cmd, since
# the machine's execution policy refuses the script a powershell step is.
- name: Put Git Bash first on PATH
shell: cmd
run: '>>"%GITHUB_PATH%" echo C:\Program Files\Git\bin'
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ inputs.ref }}
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
cache-dependency-path: go.sum
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: |
desktop/frontend-next/pnpm-lock.yaml
desktop/electron/pnpm-lock.yaml
# A patch above the newest release, as a prerelease: it ranks ahead of
# what is published, so the build does not offer itself that release as
# an update, and behind the next one, which it does offer.
- name: Choose the version
id: version
shell: bash
run: |
latest="$(git ls-remote --tags origin 'refs/tags/studio-v*' | sed -n 's#.*refs/tags/studio-v\([0-9.]*\)$#\1#p' | sort -V | tail -1)"
test -n "$latest" || { echo "::error::no studio-v* release tag"; exit 1; }
IFS=. read -r major minor patch <<< "$latest"
version="$major.$minor.$((patch + 1))-selftest.${{ github.run_number }}"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Version: \`$version\` from \`$(git rev-parse --short HEAD)\`" >> "$GITHUB_STEP_SUMMARY"
# The intranet's route to the module proxy drops connections now and then.
# What arrives stays in this machine's module cache, so a retry resumes.
- name: Fetch Go modules
shell: bash
run: for i in 1 2 3 4 5; do go mod download && exit 0; sleep 10; done; exit 1
- name: Build the SPA
working-directory: desktop/frontend-next
shell: bash
run: pnpm install --frozen-lockfile && pnpm build
- name: Build the kernel
working-directory: desktop/electron
shell: bash
env:
REASONIX_VERSION: v${{ steps.version.outputs.version }}
run: pnpm install --frozen-lockfile && pnpm run build:host
- name: Build the bundle
working-directory: desktop/electron
shell: bash
run: pnpm exec electron-builder --dir -c.extraMetadata.version="${{ steps.version.outputs.version }}"
- name: Package the installer
working-directory: desktop/electron
shell: bash
run: |
pnpm exec electron-builder --prepackaged ../../dist/win-unpacked --win nsis \
-c.extraMetadata.version="${{ steps.version.outputs.version }}"
sha256sum ../../dist/ReasonixStudio-windows-amd64-installer.exe | tee -a "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: studio-selftest-windows-${{ steps.version.outputs.version }}
path: dist/ReasonixStudio-windows-amd64-installer.exe
if-no-files-found: error
retention-days: 7
+46 -32
View File
@@ -1,11 +1,10 @@
name: Studio
# Studio's own surface — desktop/next (the Wails shell) and desktop/frontend-next
# (the SPA it serves) — exists only on the studio branch, so it cannot live in
# ci.yml without failing on main-v2 where neither directory is present. ci.yml
# covers the kernel and the desktop module's Go side; this file covers the
# frontend build and proves the shell still compiles on every platform it ships
# to, which is the same command release-studio.yml runs.
# Studio's own surface — desktop/frontend-next (the SPA) and the Electron shell
# that serves it — exists only on the studio branch, so it cannot live in ci.yml
# without failing on main-v2 where neither directory is present. ci.yml covers
# the kernel; this file covers the frontend build, the desktop module's Go side,
# and the packaging contract on real artifacts.
on:
push:
branches: [studio]
@@ -27,14 +26,14 @@ jobs:
run:
working-directory: desktop/frontend-next
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@v6.0.9
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
@@ -52,6 +51,20 @@ jobs:
- name: test
run: pnpm test
# What the UI census holds. The gate reads structure — which inputs reach
# a mutating endpoint, and which of those the product names an action for
# — so UNDECLARED_MUTATION == 0 is a fact about the tree, not a lint over
# how anything is spelled.
- name: census invariants
run: pnpm census > /dev/null
# And that the analyzer still judges the way it did. _fx is a corpus
# written to exercise the rules, so its reports move only when a judgement
# moves; the product's name every root by file and line and are stale the
# moment a button moves, which is why they are not frozen.
- name: census fixture reports have not drifted
run: pnpm census:verify
# `build` runs tsc -b before vite, so this also proves the project
# references resolve — typecheck alone uses --noEmit and does not.
- name: build
@@ -68,8 +81,8 @@ jobs:
exit 1
fi
shell:
name: shell (${{ matrix.os }})
desktop:
name: desktop (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -79,29 +92,19 @@ jobs:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
# WebKitGTK 4.1, matching what release-studio.yml links against: 4.0
# (libwebkit2gtk-4.0.so.37) is gone on Ubuntu 24.04+ and Fedora 40+.
- name: Install Linux build deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
- name: build shell
run: bash ../scripts/studio-build.sh --shell-only
# The desktop module's Go tests, which no job ran until now: the elevated
# update helper, the release-side tools and the Wails shell were green
# only where a maintainer happened to run them. The code this gate covers
# is what replaces a running application on a user's disk.
# The desktop module's Go tests: the elevated update helper, the
# release-side tools and the Windows uninstall planner. The code this
# gate covers is what replaces a running application on a user's disk.
# The module is CGO-free since the Wails shell retired, so no platform
# carries build dependencies of its own.
# Same command as `make studio-test`; this job is already in desktop/.
- name: go test
run: go test ./...
@@ -143,20 +146,20 @@ jobs:
run:
shell: bash
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/action-setup@v6.0.9
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
@@ -261,6 +264,17 @@ jobs:
exit 1
fi
# 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')
test -n "$host"
node desktop/electron/packaging/elf.js "$host" "$RUNNER_TEMP/deb/usr/lib/reasonix-studio/reasonix-studio-update-helper"
# Recorded, not asserted: the real bundle layout, so later work argues
# from what electron-builder produced rather than from its documentation.
- name: Record the macOS bundle layout
+5 -4
View File
@@ -13,8 +13,8 @@ jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
- name: Update README contributor tables
@@ -22,9 +22,10 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/update-acknowledgments.mjs
- name: Open pull request
uses: peter-evans/create-pull-request@v8
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
# GITHUB_TOKEN-authored pull requests never trigger CI, so required checks would never report.
token: ${{ secrets.ACKNOWLEDGMENTS_TOKEN || secrets.GITHUB_TOKEN }}
branch: chore/update-acknowledgments
delete-branch: true
title: 'Update acknowledgments contributors / 更新致谢贡献者'
+2 -2
View File
@@ -24,10 +24,10 @@ jobs:
if: github.repository == 'esengine/DeepSeek-Reasonix'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
- name: Test chart generator
@@ -0,0 +1,29 @@
name: 1.x nightly monitoring
# A schedule runs only from the default branch. These monitors measure the 1.x
# desktop app, which lives on main-v2 alone, so their nightly runs are started
# from here on that branch. Their push and pull_request gates run on main-v2 as
# before.
on:
schedule:
- cron: "17 3 * * *"
- cron: "43 4 * * *"
workflow_dispatch:
permissions:
actions: write
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: App memory screening
if: github.event_name == 'workflow_dispatch' || github.event.schedule == '17 3 * * *'
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run app-memory.yml --repo "$GITHUB_REPOSITORY" --ref main-v2
- name: Diagnostic overhead monitoring
if: github.event_name == 'workflow_dispatch' || github.event.schedule == '43 4 * * *'
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run diagnostic-overhead.yml --repo "$GITHUB_REPOSITORY" --ref main-v2
+18 -8
View File
@@ -1,4 +1,6 @@
# Build artifacts
# Build artifacts. A `go build` of one command writes its binary into the
# working directory, so every main package in the root module can drop its own
# name here; repolint's build-artifact rule holds the two lists together.
/bin/
/dist/
/stage/
@@ -8,11 +10,21 @@
/e2ebench
/e2ebench.exe
/context-retrieval
# Website (Astro) build output
/site/dist/
/site/.astro/
/site/_preview.png
/compaction
/context-maintenance-e2e
/runtime-resume
/extension-protocol-gen
/reasonix-plugin-example
/reasonix-studio-host
/signpath-contract
/studio-delta
/covergate
/pricecheck
/repolint
/workgroupassay
/write_heartbeat
/catalog-detector
/tool-surface-cache
# Go test/build scratch
*.test
@@ -64,7 +76,6 @@ desktop/**/.reasonix/
Thumbs.db
.idea/
.vscode/
/site/public/av/
benchmarks/context-maintenance-e2e/run/
@@ -74,7 +85,6 @@ temp/
# Official theme art intermediate outputs / caches
scripts/official-theme-art/out/
scripts/official-theme-art/__pycache__/
site/.generated/
# Committed pointer at REASONIX.md; overrides a global CLAUDE.md ignore rule.
!/CLAUDE.md
+1 -1
View File
@@ -16,7 +16,7 @@ builds:
ldflags:
# Inject version identity for `reasonix version --verbose/--json` (Integration D/E).
# buildTimeUTC is the goreleaser run time (UTC), not vcs.time.
- -s -w -X main.version={{ .Tag }} -X main.gitCommit={{ .ShortCommit }} -X main.buildTimeUTC={{ .Date }} -X reasonix/internal/productdocs.linkedVersion={{ .Tag }} -X reasonix/internal/productdocs.linkedRevision={{ .Commit }}
- -s -w -X main.version={{ .Tag }} -X main.gitCommit={{ .ShortCommit }} -X main.buildTimeUTC={{ .Date }} -X reasonix/internal/tools/productdocs.linkedVersion={{ .Tag }} -X reasonix/internal/tools/productdocs.linkedRevision={{ .Commit }}
goos: [darwin, linux, windows]
goarch: [amd64, arm64]
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Stage two: sign the finished NSIS container, and only when the exact
executables it was built around are present and already signed. The
authenticode-verify entries are what make the two stages one contract rather
than two independent requests — an installer rebuilt from an unsigned binary
is refused here rather than discovered by a user after it is installed.
Both of the shell's own executables are verified. Listing only one would let
the other be swapped between the stages and still pass.
GitHub Actions wraps this flat bundle in a zip before SignPath receives it. -->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="*installer*.exe">
<authenticode-sign />
</pe-file>
<pe-file path="Reasonix Studio.exe">
<authenticode-verify />
</pe-file>
<pe-file path="reasonix-studio-host.exe">
<authenticode-verify />
</pe-file>
</zip-file>
</artifact-configuration>
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Stage one: sign the executables a user actually runs, before anything is
packaged around them. Signing only the finished NSIS container would leave
them unsigned on disk after installation, which is what reputation and ML
heuristics quarantine.
The Electron shell ships two of its own where the Wails one was a single
binary: the window, and the kernel it spawns beside itself. Everything else
the bundle carries is Chromium's or electron-builder's — resources/elevate.exe
is the other PE file — and arrives from its own publisher, so it is not
listed here: this configuration must not re-sign somebody else's binary.
GitHub Actions wraps the flat payload directory in a zip before SignPath
receives it. -->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="Reasonix Studio.exe">
<authenticode-sign />
</pe-file>
<pe-file path="reasonix-studio-host.exe">
<authenticode-sign />
</pe-file>
</zip-file>
</artifact-configuration>
+6 -3
View File
@@ -6,11 +6,14 @@ allowed_branch_names:
- studio
allowed_build_definitions:
- .github/workflows/release-studio.yml
- .github/workflows/studio-certum-signing-smoke.yml
fingerprint_files:
- .github/workflows/release-studio.yml
- .signpath/artifact-configurations/studio-windows-payload.xml
- .signpath/artifact-configurations/studio-windows-installer.xml
- scripts/complete-signpath-request.ps1
- .github/workflows/studio-certum-signing-smoke.yml
- .github/actions/setup-certum/action.yml
- scripts/sign-certum.ps1
- scripts/verify-windows-authenticode.ps1
- scripts/verify-windows-package.ps1
- scripts/windows-signing-lib.ps1
- desktop/electron/electron-builder.yml
- cmd/signpath-contract/main.go
-1
View File
@@ -1 +0,0 @@
v2.13.0
-362
View File
@@ -1,362 +0,0 @@
# Changelog
All notable changes to the Go line (Reasonix 1.0+) are recorded here. The legacy
`0.x` TypeScript history lives on the [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1)
branch.
## Unreleased
### Fixed
- **v1.24.2 session snapshot & recovery root fix:** Keep PR #7982's WAL/CAS/lease
safety foundation, but replace process-level "I hold a lease" ownership with a
generation-bound `SessionWriteAuthority`. Same-revision tool-preview/load
reshapes no longer false-diverge; recovery files are bounded to one path per
writer/lineage; empty checkpoints heal from their own WAL; projection lineage
rebinds across upgrade/model switch and inherits across recovery forks without
changing provider-visible prompt bytes. Catalog upgrades to disposable
`session-catalog/v3.sqlite` with recovery lineage roles
(`normal|covered_copy|adopted|diverged`); covered idle copies move to the
recoverable `.trash` using a 15-minute idle threshold applied on two early
sweeps (at startup and ~20 minutes later), then a 24-hour threshold on the
6-hour background ticker; independent diverged branches stay and are listed
for user choice. v1/v2 catalogs are
left byte-unchanged for coexistence/downgrade.
**v1.24.1** only hid/reclaimed already-created covered copies and fixed Windows
flash-window startup; **v1.24.2** stops the misclassification source and repairs
existing user directories without rewriting authoritative JSONL/WAL/sidecar data.
- Goal now runs continuously by default: the former 16-round per-Run boundary,
10/20/40 cross-Run quotas, default wall-clock budget, and numeric
no-progress/Todo-stall pauses no longer stop valid work. Progress guards still
detect repeated host outcomes and zero-evidence work, but redirect the model
to re-plan instead of producing `goal_run_budget` or `goal_stuck`. Explicit
`[agent].goal_token_budget`, `--max-steps`, positive time/cost budgets, manual
pause/stop, genuine user/external blockers, and evaluator fail-closed behavior
remain available. The Goal token budget defaults to `0` (off); resuming its
`budget_spend` pause grants a fresh slice without clearing cumulative usage.
Goal status reports turns, provider requests, tokens, the optional configured
token threshold, and cumulative active work time. Bot `max_steps` also
defaults to `0` (continuous), while positive user configuration is enforced.
- Removed numeric Goal pauses in existing sidecars automatically normalize to
`running` without sending a model request. Active Goal sidecars write
`turnsLimit: -1` as a downgrade-safe unlimited sentinel while public runtime
APIs retain deprecated limit fields as `0`. The migration preserves unknown
fields, todos, checkpoints, usage, evidence, and historical metadata.
- Goal is now the sole long-task runtime. Historical AutoResearch sidecars
migrate transactionally into Goals with research compatibility metadata. Invalid archives block
fail closed and remain read-only, retaining the task id and compatibility mode
for a restart or `/goal resume` retry; successful Goal-only sidecars omit the
old task id and write an explicit downgrade fence so previous readers cannot
reactivate the removed runtime.
- Context-dependent workflow tools now share one host-side execution boundary.
Goal, Plan sign-off, and background-job calls cannot reach permissions,
hooks, leases, or Execute outside their owning context; mixed batches execute
valid calls once and stop safely after one repair. Child agents also isolate
inherited Goal, Jobs, and live memory queues, while persisted tool identity
records the effective child schema projection.
- **Issue #7575:** Linux Bash under bubblewrap no longer mounts a fresh empty
`--tmpfs /tmp` on every call. Consecutive commands in the same logical session
now share a private temporary directory (bound at `/tmp` on Linux, exported via
`TMPDIR`/`TMP`/`TEMP` on all platforms) without exposing the host public
temporary root. `/new`, `/clear`, resume of another session, and branch
switches rotate the directory; model/settings hot rebuilds keep it. Sub-agent
runs get independent directories. Temporary files are not durable across process
restarts.
### Added
- Added `[ui].show_turn_usage` so CLI/TUI users can hide per-request token and
cost receipts from transcript scrollback without disabling usage accounting.
## [1.20.0] — 2026-08-05
Extension kernel, Task Monitor, and safer Goal completion.
Compact decision surfaces, local decision receipts, unified extension kernel,
native Task Monitor, bounded sub-agent progress, Goal fail-closed completion,
MiMo and DashScope Responses fixes, SSH remote access simplification, and
multiple Desktop stability improvements.
### Highlights
- **Unified Extension Kernel and Extension Protocol v1**: Immutable runtime
snapshots, fail-atomic reload, Plugin Manifest v1 (prompts, themes, full-trust
code runtimes), stable JSON-RPC sidecar protocol, interceptor dispatch,
streaming provider adapter, structured UI, and Go SDK.
- **Native Task Monitor**: Monitor agent tasks natively in CLI and Desktop with
lifecycle semantics and session-scoped summary view.
- **Bounded Sub-agent Progress Forwarding**: Forward structured progress for
`task`, `parallel_tasks`, and `fleet` without flooding the parent stream.
Renders nested lifecycle cards in Desktop and stable per-child transcript
slots in CLI.
- **Goal Completion Fail-Closed**: Replace free-form Goal footer markers with a
stable `update_goal` tool and epoch-scoped per-turn reports. Centralized
completion logic with bounded evaluator, progress-aware budgets, and
pause/resume controls.
- **Ablation Subsystem Switches**: Switch subsystems off behind one shared
vocabulary for controlled experiments. Includes planner, subagent, retrieval,
evidence, and compaction.
- **Benchmark Cost per Solved Task**: Report cost per solved task, tokens per
solved, median wall time, and failure-class breakdown in e2e reports.
- **Compact Decision Surfaces and Local Receipts**: Compact footer decision-card
layout with bounded scroll, dense action rows, and overflow disclosure.
Record bounded Ask, approval, and recovery decisions as local transcript
receipts.
- **Simplified SSH Remote Access**: Remove Remote Workbench protocol and
stacks; reuse CLI/Serve remote model. Desktop opens per-host native web child
windows via SSH. Keyless remote Serve setup with loopback-only page.
- **Model Usage Charts with Primer Palette**: Replace monochrome accent ramp
with GitHub Primer data-viz two-set categorical palette. Fix donut overflow
on hover and keyboard accessibility.
- **Cross-platform Extension and Task Monitor Reliability**: Make
content-reference eviction deterministic, reject Unix and Windows absolute
plugin paths consistently, stabilize parallel-task cancellation, and restore
reliable Windows validation for Task Monitor and remote provider setup.
- **MiMo and DashScope Responses Wire Alignment**: Fix multi-turn tool loops,
reasoning round-trip, JSON output for MiMo; fix DashScope second-turn 400
error, all-zero usage suppression, and vendor-aware cache TTL.
- **Desktop Stability Fixes**: Recover stuck updates and legacy WebKit, contain
macOS alias repair startup crashes, keep composer overflow stacks readable,
and harden account verification and community flows.
- **Remote Web Recovery After SSH Drops**: Add integration regression test for
SSH drop, forward recovery, and window reload. Document transient outage
behavior.
- **CI: Auto-minimize Activity-Farming Spam Comments**: Detect and minimize
template spam comments from non-contributor accounts based on structural
signals.
### Added
- Added Extension Protocol v1 and the unified extension kernel: installed or
linked sidecars can contribute tools, skills, commands, hooks, MCP servers,
providers, interceptors, and structured UI surfaces through a versioned
NDJSON contract and the public Go SDK. CLI, Desktop, ACP, and Serve support
fail-atomic runtime reloads; Serve also renders extension surfaces and lists
extension-hosted providers without exposing credentials.
- Added the structured Goal completion protocol: the always-registered
`update_goal` tool (continue/complete/blocked with reason and next_action)
replaces the `[goal:*]` footer markers. The Goal FSM is now the exclusive
cross-turn decision point and validates every complete claim against Delivery
readiness; when the model submits no report, an independent bounded evaluator
(recovery_model → guardian_model → main model, no tools/history, usage
attributed to `goal-evaluator`) judges the turn once, and any evaluator
failure pauses the goal instead of continuing silently.
- Added Goal budget classes with safe pauses: simple 10 turns / 200k tokens,
write 20 turns / 400k tokens, AutoResearch 40 turns / 800k tokens, and a
4-turn no-host-verifiable-progress gate. Pauses keep all Goal state; `/goal
resume` continues and adds one slice of the current class when the pause was
budget-related. `/goal status` shows the full turn/token/no-progress runtime,
and `/goal pause` manually suspends a running Goal.
- Added the `goalRuntime` nested view to the desktop Meta, the remote protocol
(`session/goal/pause` operation, `goalRuntime` DTO on session meta), and the
ACP status payload; the desktop Composer goal menu shows the runtime summary
with distinct pause/end/resume actions.
### Changed
- Delivery no longer retries final-answer readiness with hidden model messages:
a plain Delivery run ends on the first unsatisfied final answer and surfaces
the recovery card, while a Goal + Delivery run has the Goal FSM absorb the
failure and continue under budget with the missing requirements as the next
turn's prompt. Historical `[goal:*]` footers are stripped from old transcripts
for display only and never participate in state decisions.
- Added a **Remote SSH** module (VS Code Remote-SSH style): a user-global
`[remote]` host config, `reasonix remote` CLI (add/list/remove/import/test/
connect/status/forward/serve/fs) and `/remote` slash command, an SSH transport
with trust-on-first-use host-key verification, keepalive + exponential-backoff
reconnect, `-L`/`-R` port forwarding, and SFTP file access. `connect`
bootstraps a persistent `reasonix serve` on the remote host and tunnels its
loopback port so the full agent runs remotely. The desktop app adds a
**Settings -> Remote SSH** host manager, a remote file browser/editor, a
port-forwarding panel, and a status-bar connection chip. Linux/macOS remotes.
- Added `reasonix serve --port-file/--token-file/--pid-file` so a supervised
headless serve can bind an ephemeral port and read its auth token from a file
(keeping it out of `ps`).
- Added an authenticated, loopback-only Provider setup page for `reasonix
serve`. A Serve whose selected Provider is missing its API key now remains
reachable, stores the submitted key in that host's Reasonix credential file,
and rebuilds the active controller in place without restarting Serve.
- Added Claude Code-style searchable CLI pickers for models, providers, and
sessions, with arrow, Vim, and `Ctrl+P` / `Ctrl+N` navigation.
- Added `-p` / `--print`, `text`, `json`, and `stream-json` output modes for
one-shot use and automation.
- Added session-scoped `--allowed-tools`, repeatable `--add-dir`, Claude-compatible
permission modes, flexible `--resume [QUERY]`, and the `--copy` resume escape
hatch.
- Added `/status` details for the active model, effort, cache, Git state,
background jobs, work profile, and provider balance where available.
- Remote SSH workspaces now open as a standalone remote web window again.
Opening a workspace from the status bar or the Remote Server tab starts or
reuses the remote `reasonix serve`, tunnels its loopback port, and opens the
Serve web client in a dedicated per-host window. The remote web page uses
the provider configuration and API keys on the **remote** host; the desktop
no longer exposes its local providers to remote hosts. If the selected remote
Provider is missing its API key, the window opens a setup page that saves the
key only on that host and then opens the normal Serve UI. The Remote Workbench
protocol, its Provider Broker, and the same-window remote projection were
removed. Legacy mirror and provider-trust files are not deleted
automatically; Settings -> Remote SSH shows a cleanup card when they exist.
The hidden `remote attach-workspace`, `remote runtime-workbench`, and
`remote workbench-build-id` commands now fail with a pointer to
`reasonix remote connect <host> --open`.
- Automatic Plan Mode has been retired. Plan Mode is now always entered through
an explicit user choice, and the one-time config v5 upgrade removes legacy
`agent.auto_plan` and `agent.auto_plan_classifier` values so upgraded users
receive the same behavior as new users.
- `Shift+Tab` now cycles CLI safe modes from Ask to Auto to Plan, while YOLO
remains an independent `Ctrl+Y` toggle.
- Model, provider, resume, and approval menus now use consistent row selection;
slash completion, help, aliases, and dispatch share one command registry.
- The full-screen CLI composer now uses theme-accented borders and a slim bar
cursor by default, grows within the available terminal height, scrolls long
drafts independently, and preserves selections across explicit image paste.
- The persistent CLI footer now uses a responsive, theme-aware layout for
interaction state, model, effort, localized work mode, Git identity, cache,
context, compaction headroom, jobs, and balance. Narrow terminals move or
compact complete groups instead of clipping labels.
- CLI clipboard actions now separate terminal-native text paste from explicit
image paste: `Ctrl+V` on macOS/Linux, `Alt+V` on Windows, or `/paste-image`.
Local transcript copy verifies the native clipboard write, while SSH uses a
clearly labelled OSC 52 fallback.
- Runtime rebuilds after model, effort, or work-mode changes now preserve the
conversation, session permission overrides, additional directories, and
session lease ownership.
- Agent execution now monitors host-observed Todo progress automatically. A
stalled current item receives a recovery nudge after 8 tool-call rounds with
no new completion, unique read, command, or mutation, and pauses with saved
work after 16. Exact repeats do not renew the progress lease; real work does.
Two-level task lists keep the single in_progress contract: the active
sub-step is the only current item while its phase stays pending, and the
phase becomes in_progress to sign off only after all of its sub-steps are
completed. A level-1 sub-step with no phase header above it is rejected.
Executor and planner rounds now use automatic progress management. Retired
`[agent].max_steps` and `planner_max_steps` keys remain parseable for upgrades,
but are ignored and removed by a one-time migration so stale hidden limits
cannot truncate new behavior. One-off CLI and unattended bot limits remain.
### Fixed
- Fixed long parallel sub-agent research being silently lost when combined
`parallel_tasks` or `fleet` answers exceeded the single-tool output limit.
Persisted sessions now keep each child transcript independently, return a
bounded fair preview plus stable reference for every result, and page full
answers through the conversation-scoped `read_subagent_result` tool.
- Fixed Remote Workbench failing with only `initialize: workbench-desktop:
connection closed` on fresh or cross-platform SSH hosts. Desktop now proves
the exact Host CLI Build ID, provisions the matching verified release without
requiring remote npm, runs the managed binary explicitly, and preserves a
safe structured bootstrap error when the remote command exits early.
- Hardened Bash permission reuse for dynamic and indirect execution. Parameter/arithmetic expansions,
assignments, redirects, heredocs, and globs can only be remembered as exact
`Bash=<literal>` rules, while still using Auto's normal fallback. Nested or
indirect execution now requires a human in interactive Ask/Auto and fails
closed in headless Ask/Auto/DontAsk. Broad Bash rules, Guardian/hook allows,
and the approved-plan window can no longer silently authorize that stricter
class; YOLO remains the explicit full-access bypass and sandbox enforcement
is unchanged.
- Fixed Desktop sessions incorrectly locking themselves during Goal + Delivery
mode changes, controller rebuilds, duplicate-tab restore, and background
reattachment. Desktop now keeps one process-local runtime owner per canonical
session, fences stale controller events by runtime epoch, blocks sends until
that runtime is ready, and scopes single-instance ownership to
`REASONIX_HOME` instead of the executable path. Switching saved sessions is
now transactional: a target build, restore, or lease failure leaves the
current controller, lease, path, mode profile, and runtime epoch untouched.
- Stabilized the desktop rich composer caret after skill and plugin invocation
tags. DOM→model and model→DOM selection mapping now treat invocation chips as
zero-length atoms while still counting user text that lands inside the NBSP
caret anchor (common on Windows WebView2), restore both selection ends, and
recover the insertion point from a `beforeinput` snapshot when the browser
temporarily loses selection — so mid-text edits no longer jump to the end.
- Isolated the Windows desktop WebView2 shell from stale system proxies, so an
exited proxy client cannot leave the embedded UI hidden during startup. If
WebView2 still does not reach DOM-ready within 15 seconds, Reasonix now shows
the native window with a recovery prompt instead of appearing not to launch.
Remote Markdown images are fetched by the backend with Reasonix's proxy
configuration instead of bypassing that proxy through the isolated WebView.
- Restored captured-mouse right-click text paste, made composer drag selection
copy through the verified native clipboard path, and kept non-Git footer
telemetry left-aligned without reserving an empty data band.
- Restored stateful MCP behavior after the v1.17.13 regression: user-added
servers work without extra trust settings (including delivery-mode on-demand
calls), repository-provided servers use one exact launch confirmation, and
stdio tools reuse one persistent process so browser sessions survive across
calls without repeated startup latency. The former trust/reverify/catalog
management UI and CLI are removed.
- Localized persistent-footer labels and displayed work-mode values in English,
Simplified Chinese, and Traditional Chinese, while keeping command arguments
stable.
- Restored the `0.53` content boundary: model output, tool output, session
transcripts, recovery branches, and background-job artifacts retain their
original text instead of being rewritten by heuristic secret redaction.
Credential masking remains in key-entry summaries and explicit diagnostic or
session-cleanup paths. Transcript-bearing session/job sidecars are kept
private (`0600`, with private job directories), and the retired
`redact_tool_output` setting is removed with a one-time upgrade notice.
### Notes
- Full bilingual release notes:
<https://reasonix.io/changelog/v1.20.0/> ·
[GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases/tag/desktop-v1.20.0).
- The detailed entries below accumulated on `main-v2` after 1.0.0 and shipped
across 1.1.0–1.20.0; per-version attribution lives in the per-version release
notes linked above.
## 1.1.0 – 1.19.7
Per-version entries for the intermediate releases are published in the
[bilingual release notes](https://reasonix.io/changelog/) and on the
[GitHub releases page](https://github.com/esengine/DeepSeek-Reasonix/releases).
## [1.0.0] — 2026-06-03
First stable release — a **ground-up rewrite in Go**. Not an upgrade of the `0.x`
TypeScript line; a new codebase that becomes the default (`main-v2`).
### Highlights
- **Go kernel**: a single static binary (CGO-free), cross-compiled for
darwin/linux/windows on amd64 + arm64. Distributed via npm (the package wraps
the native binary), Homebrew (`esengine/reasonix` tap), and release archives;
no Node runtime needed to run it.
- **Agent core**: the loop, built-in tools (read/write/edit/multi_edit/glob/grep/
ls/bash/web_fetch/todo_write), permission gate, sandboxed bash, and the
DeepSeek prefix-cache–oriented design.
- **Subagents**: `task` plus explore/research/review/security_review skill agents.
- **Skills & hooks**: Claude-Code-style skills (`internal/skill`) and hooks
(`internal/hook`), symlink-aware and slash-integrated.
- **MCP client**: connect external servers over stdio / Streamable HTTP; reads
`[[plugins]]` and a Claude-Code `.mcp.json`.
- **Code intelligence via CodeGraph**: a tree-sitter symbol/call graph
(`codegraph_*` tools) replaces embedding semantic search — no embedding service
or API cost. Fetched into a local cache on first use (or `reasonix codegraph
install`) and indexed in the background, so installs and startup stay fast.
- **Plan mode** with evidence-backed step sign-off (`complete_step`).
- **Memory**: `REASONIX.md` hierarchy + auto-memory, folded into the cache-stable
prefix.
- **ACP** (`reasonix acp`) and an HTTP/SSE server frontend; desktop app (Wails).
### Fixed
- **File encoding support restored** — GBK/GB18030 (and other non-UTF-8) files
can now be read, edited, and grepped correctly. The v2 rewrite had dropped
v1's encoding detection; files in CJK Windows charsets were silently misread
or rejected as binary. The read/edit/write round-trip now preserves the
original file encoding. (#2637)
### Notes
- Versions: the legacy TypeScript line stays in `0.x`; the Go line starts at
`1.0.0`. See [docs/MIGRATING.md](docs/MIGRATING.md).
- Release archives ship a bare binary; CodeGraph is fetched on first use. Windows
support for the fetched runtime is unverified — install `codegraph` on PATH if
the auto-fetch doesn't resolve there.
[1.20.0]: https://github.com/esengine/DeepSeek-Reasonix/releases/tag/desktop-v1.20.0
[1.0.0]: https://github.com/esengine/DeepSeek-Reasonix/releases/tag/v1.0.0
+44 -33
View File
@@ -5,11 +5,10 @@ everything you need to get started.
## Prerequisites
- **Go 1.25+** — the project targets the latest stable Go release
- **Go 1.26+** — the version `go.mod` requires
- **Git** — for version control
- **Node.js** (optional) — only if you work on the desktop app (`desktop/`)
- **Wails CLI** (desktop only) — run `make wails-install` so the CLI matches
`desktop/go.mod`
- **Node.js 24+ and pnpm 10** (optional) — only if you work on Studio
(`desktop/`)
## Getting started
@@ -25,22 +24,22 @@ go test ./... # runs the full test suite
| Directory | Purpose |
|-----------|---------|
| `cmd/reasonix` | CLI entry point |
| `internal/agent` | Agent loop, session, coordinator |
| `internal/cli` | TUI, subcommands, setup wizard |
| `internal/control` | Transport-agnostic controller |
| `internal/config` | TOML configuration loading |
| `internal/tool/builtin` | Built-in tools (bash, read_file, …) |
| `internal/provider` | Model-backend abstraction |
| `internal/provider/openai` | OpenAI-compatible provider |
| `internal/plugin` | MCP client (stdio + HTTP) |
| `internal/event` | Typed event stream |
| `internal/hook` | Shell hooks (PreToolUse, …) |
| `internal/memory` | REASONIX.md hierarchy + auto-memory |
| `internal/skill` | Skill discovery from Markdown |
| `internal/sandbox` | OS-level sandboxing |
| `internal/serve` | HTTP/SSE server frontend |
| `internal/checkpoint` | Snapshot-based rewind |
| `desktop/` | Wails-based desktop app (separate Go module) |
| `internal/runtime/agent` | Agent loop, session, coordinator |
| `internal/frontend/cli` | TUI, subcommands, setup wizard |
| `internal/session/control` | Transport-agnostic controller |
| `internal/contract/config` | TOML configuration loading |
| `internal/tools/builtin` | Built-in tools (bash, read_file, …) |
| `internal/contract/provider` | Model-backend abstraction |
| `internal/model/openai` | OpenAI-compatible provider |
| `internal/ext/plugin` | MCP client (stdio + HTTP) |
| `internal/contract/event` | Typed event stream |
| `internal/ext/hook` | Shell hooks (PreToolUse, …) |
| `internal/state/memory` | REASONIX.md hierarchy + auto-memory |
| `internal/ext/skill` | Skill discovery from Markdown |
| `internal/safety/sandbox` | OS-level sandboxing |
| `internal/frontend/serve` | HTTP/SSE server frontend |
| `internal/state/checkpoint` | Snapshot-based rewind |
| `desktop/` | Studio: the Electron shell, the SPA it serves (separate Go module) |
| `docs/` | Engineering spec, migration guide |
### Dependency direction
@@ -80,11 +79,10 @@ REASONIX_HOME=/tmp/reasonix-dev go run ./cmd/reasonix
# REASONIX_HOME=/tmp/reasonix-dev ./bin/reasonix
```
**Desktop**
**Studio**
```bash
cd desktop && wails build
REASONIX_HOME=/tmp/reasonix-dev-isolated build/bin/reasonix-desktop
REASONIX_HOME=/tmp/reasonix-dev-isolated make studio
```
On Windows, use `$env:REASONIX_HOME` in PowerShell or `set REASONIX_HOME=` in
@@ -115,8 +113,8 @@ For these changes:
```bash
go test ./... # all tests
go test ./internal/agent/ -v # verbose, one package
go test ./internal/tool/builtin/ -run TestGrep # one test
go test ./internal/runtime/agent/ -v # verbose, one package
go test ./internal/tools/builtin/ -run TestGrep # one test
```
### Code style
@@ -141,39 +139,52 @@ ci: add golangci-lint and govulncheck
## Adding a new built-in tool
1. Create `internal/tool/builtin/mytool.go`
1. Create `internal/tools/builtin/mytool.go`
2. Implement the `tool.Tool` interface: `Name()`, `Description()`, `Schema()`, `ReadOnly()`, `Execute()`
3. Register via `func init() { tool.RegisterBuiltin(myTool{}) }`
4. Add tests in `internal/tool/builtin/builtin_test.go` or a separate `mytool_test.go`
4. Add tests in `internal/tools/builtin/builtin_test.go` or a separate `mytool_test.go`
5. The tool is automatically available — `main` blank-imports `builtin`
## Adding a new model provider
(For MCP tool servers see `internal/plugin` instead — that's a different layer.)
(For MCP tool servers see `internal/ext/plugin` instead — that's a different layer.)
1. Create `internal/provider/myprovider/`
1. Create `internal/contract/provider/myprovider/`
2. Implement `provider.Provider`: `Name()`, `Stream()`
3. Register via `func init() { provider.Register("mykind", New) }`
4. The provider is available from config with `kind = "mykind"`
## Adding i18n strings
1. Add the field to `internal/i18n/i18n.go` (`Messages` struct)
2. Add the value in `internal/i18n/messages_en.go` and `messages_zh.go`
1. Add the field to `internal/base/i18n/i18n.go` (`Messages` struct)
2. Add the value in `internal/base/i18n/messages_en.go` and `messages_zh.go`
3. The `TestCatalogsComplete` test will fail if you miss a locale
## Submitting changes
Reasonix ships on two lines (see the
[version roadmap](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748)),
and each takes different changes:
| Line | Branch | Takes |
| --- | --- | --- |
| 2.x | `studio` | Features and fixes: active development |
| 1.x | `main-v2` | Bug fixes, provider/API compatibility, release/updater, security, platform stability |
A fix that matters on both lines lands on `main-v2` and is reviewed for
porting to `studio`; do not open the same PR against both.
1. Fork the repository
2. Create a feature branch from `main-v2`
2. Create a feature branch from the branch your change targets
3. Make your changes with tests
4. Ensure `go test ./...` passes
5. Ensure `gofmt -l .` shows no changes
6. Submit a pull request to `main-v2`
6. Submit a pull request to that same branch
## Reporting issues
Open an issue on GitHub with:
- Which line (1.x or 2.x) and the exact version
- Steps to reproduce
- Expected vs actual behavior
- Go version and OS
+10 -3
View File
@@ -1,4 +1,4 @@
VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
VERSION := $(shell git describe --tags --always 2>/dev/null | sed 's/^studio-//' || echo dev)
BUILD_TIME_UTC := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
GIT_COMMIT := $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)
LDFLAGS := -s -w \
@@ -9,7 +9,7 @@ GOEXE := $(shell go env GOEXE)
# One pin for the Makefile and the CI lint job; see .github/workflows/ci.yml.
GOLANGCI_VERSION := $(shell cat .golangci-version)
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update check test studio-test sdk-test sdk-test-race hooks cross clean studio
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck check test studio-test sdk-test sdk-test-race hooks cross clean studio
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
@@ -51,7 +51,7 @@ lint-install:
# unknown program it must assume writes — so only this form can be cited as
# evidence that the standards gate passed.
check:
go run ./tools/repolint -only "$$(git diff --name-only HEAD | paste -sd, -)"
go run ./tools/repolint -changed
lint-update:
go run ./tools/repolint -update
@@ -66,6 +66,13 @@ coverage-gate:
coverage-gate-update:
go run ./tools/covergate -update
# The vendors publish their rates on pages this reads and compares the table
# against. It reaches the network, so it stays out of lint and out of CI: run it
# before touching a rate and when a vendor announces a change. A page it cannot
# read exits non-zero — unread never counts as agreement.
pricecheck:
go run ./tools/pricecheck
# Linting one GOOS leaves every //go:build windows and darwin file unchecked.
lint-cross:
@for t in "linux ." "darwin ." "windows ." "linux desktop" "windows desktop"; do \
+77 -58
View File
@@ -21,6 +21,7 @@
</p>
<p align="center">
<a href="https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true"><img src="https://img.shields.io/github/v/release/esengine/DeepSeek-Reasonix.svg?filter=studio-v*&include_prereleases&style=flat-square&color=8250df&labelColor=161b22&label=studio%202.x" alt="Reasonix Studio 2.x"/></a>
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/DeepSeek-Reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=8b949e&labelColor=161b22" alt="license"/></a>
@@ -42,18 +43,36 @@
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
<h3 align="center">A coding agent you can leave running.</h3>
<p align="center">One local engine, four ways in — terminal, desktop app, browser, or your editor over ACP. Plan mode, permissions, a workspace sandbox and per-turn checkpoints keep a long autonomous run something you can still read and undo.</p>
<div align="center">
<video src="https://github.com/user-attachments/assets/ab2f3878-e224-4931-8254-060e7695cfb9" controls preload="metadata" width="560"></video>
</div>
<br/>
<p align="center">Maintained by <strong>Huahui Yu</strong>.</p>
> [!IMPORTANT]
> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **<https://discord.gg/XF78rEME2D>**
<br/>
## Versions
Reasonix ships on two lines. See the [version roadmap announcement](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748) for the reasoning behind the split.
| Line | Branch | Status | Get it |
| --- | --- | --- | --- |
| **Reasonix 2.x** | `studio` (this branch) | Active development, pre-release | [Studio releases](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
| **Reasonix 1.x** | [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) | Maintenance / stable | `npm i -g reasonix` · [desktop download](https://reasonix.io/?download=desktop#start) |
- **Want something stable?** Stay on 1.x. It keeps receiving bug fixes,
provider/API compatibility, updater and security fixes; its core architecture
is no longer being extended.
- **Want the new architecture?** Use 2.x Reasonix Studio. It is still moving
quickly; reports are welcome.
Valuable fixes, tests and behaviour from 1.x are reviewed one by one and ported
or reimplemented on 2.x where they fit the new architecture.
- [Moving from 1.x to 2.x](./docs/MIGRATING.md): what the two lines share, and
how to run both on one machine without losing sessions.
- [2.x roadmap](./docs/ROADMAP.md): what 2.x still has to deliver, how it ships,
and the decisions still open.
## Features
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
@@ -73,53 +92,60 @@
## Install
Choose the path that matches how you want to use Reasonix. The CLI/TUI,
desktop app, and VS Code extension all use the same local Reasonix engine.
This branch is **Reasonix 2.x**. Its desktop app is Reasonix Studio; the CLI,
Studio, and editor integrations all run the same local Reasonix engine.
### Path A: CLI / TUI
### Reasonix Studio (2.x)
Install the native binary through npm on any supported platform, or use
Homebrew on macOS:
Download the package for your platform from the latest
[Studio release](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)
(tagged `studio-v2.*`, published as pre-releases while 2.x is in active
development):
| Platform | Package | Architecture |
| --- | --- | --- |
| macOS | `.dmg` or `.zip` | Apple Silicon (`arm64`) / Intel (`amd64`) |
| Windows | Installer `-installer.exe` or portable `.zip` | x64 |
| Linux | `.deb` | x64 |
Every package ships with a `.minisig` signature, and the release carries
`SHA256SUMS`. Once installed, Studio updates itself in place.
The same release also carries the 2.x `reasonix` CLI as archives for
`darwin|linux|windows × amd64|arm64`.
### Reasonix 1.x (stable)
The 1.x CLI installs through npm on any supported platform, or Homebrew on
macOS:
```sh
npm i -g reasonix # any OS; pulls the prebuilt native binary
brew install esengine/reasonix/reasonix # macOS
```
Prebuilt archives (`darwin|linux|windows × amd64|arm64`) and `SHA256SUMS` are on
every [GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases).
The 1.x desktop app is on the
[official download page](https://reasonix.io/?download=desktop#start). Windows
installers are code-signed with a Certum code-signing certificate.
### Path B: Desktop app
### VS Code extension
Use the [official download page](https://reasonix.io/?download=desktop#start)
for the latest desktop build.
| Platform | Package | Architecture |
| --- | --- | --- |
| macOS | Universal `.dmg` or `.zip` | Apple Silicon / Intel |
| Windows | Installer `.exe` or portable `.zip` | x64 / ARM64 |
| Linux | `.deb` or `.tar.gz` | x64 |
Windows installers are code-signed through [SignPath.io](https://signpath.io/)
with a free certificate provided by the [SignPath Foundation](https://signpath.org/).
### Path C: VS Code extension
Complete Path A first. The extension does not bundle the CLI; it starts your
local `reasonix acp` backend and adds native chat, editor context, tool-call
approvals, model selection, and workspace sessions.
The extension does not bundle the CLI; it starts your local `reasonix acp`
backend and adds native chat, editor context, tool-call approvals, model
selection, and workspace sessions. Install the 1.x CLI first.
- **VS Code:** [install from Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=SivanLiu.reasonix-agent)
- **VSCodium / Eclipse Theia:** [install from Open VSX Registry](https://open-vsx.org/extension/SivanLiu/reasonix-agent)
- **Extension ID:** `SivanLiu.reasonix-agent` · [source and usage guide](https://github.com/SivanCola/reasonix-vscode)
### Path D: Build from source
### Build from source
Clone the repository first:
Clone the repository; `studio` builds 2.x and `main-v2` builds 1.x:
```sh
git clone https://github.com/esengine/DeepSeek-Reasonix.git
cd DeepSeek-Reasonix
git switch studio
```
#### CLI
@@ -132,27 +158,26 @@ make build # -> bin/reasonix(.exe)
make cross # -> dist/ (darwin|linux|windows × amd64|arm64)
```
#### Desktop
#### Studio
The desktop build additionally requires:
- **Node 24+ and pnpm 10** (`npm install -g pnpm@10`) for the frontend
- **Wails CLI** matching the shared `.wails-version` pin
Studio additionally requires **Node 24+ and pnpm 10**
(`npm install -g pnpm@10`) for the frontend.
```sh
make wails-install
cd desktop
wails build
make studio
```
See the [desktop build guide](desktop/README.md#prerequisites) for platform
See the [Studio build guide](desktop/README.md#prerequisites) for platform
webview dependencies and Linux build tags.
## Quick start
### CLI / TUI
### Reasonix Studio
These commands are for the CLI/TUI installed through Path A:
Install and launch Studio, then connect a provider and model in the app. No CLI
setup is needed.
### CLI / TUI
```sh
reasonix setup # configure a provider and model
@@ -163,13 +188,6 @@ reasonix run "implement the TODOs in main.go"
In an interactive session, run `/init` when you want Reasonix to create project
instructions.
### Desktop app
Download the installer for your platform from the
[official download page](https://reasonix.io/?download=desktop#start), install
and launch Reasonix, then configure a provider and model in the app. The CLI
commands above are not required for the desktop app.
For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.md)**,
**[Guide](./docs/GUIDE.md)**, and
**[configuration paths](./docs/CONFIG_PATHS.md)**.
@@ -181,12 +199,13 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[Recovery and updates](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
[Recovery and updates](./docs/RECOVERY.md) ·
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
[Tool contract](./docs/TOOL_CONTRACT.md) · [Migrating from 0.x](./docs/MIGRATING.md)
- **Extension development:** [Extensions](./docs/EXTENSIONS.md) ·
[Tool contract](./docs/TOOL_CONTRACT.md) · [Moving from 1.x to 2.x](./docs/MIGRATING.md) ·
[2.x roadmap](./docs/ROADMAP.md)
- **Extension development:** [Skills](./docs/SKILLS.md) · [Extensions](./docs/EXTENSIONS.md) ·
[Plugin packages and Manifest v1](./docs/PLUGIN_PACKAGES.md) ·
[Extension Protocol](./docs/EXTENSION_PROTOCOL.md) ·
[Go SDK and starter](./sdk/go/README.md)
@@ -213,10 +232,10 @@ A small list of folks whose work has shaped Reasonix the most — the current to
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) |
| **merge-order-check** | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) |
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | **merge-order-check** | [**light-front-theory**](https://github.com/light-front-theory) | **Yan Li** |
| [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** | [**Bernardxu123**](https://github.com/Bernardxu123) | [**HaoyueQin**](https://github.com/HaoyueQin) |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**KHG420**](https://github.com/KHG420) | [**JesonChou**](https://github.com/JesonChou) | [**SuMuxi66**](https://github.com/SuMuxi66) |
<!-- reasonix-top-contributors:end -->
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
+95 -60
View File
@@ -9,11 +9,11 @@
&nbsp;·&nbsp;
<a href="./docs/GUIDE.zh-CN.md">指南</a>
&nbsp;·&nbsp;
<a href="./docs/ACP.zh-CN.md">ACP</a>
<a href="./docs/ACP.md">ACP</a>
&nbsp;·&nbsp;
<a href="./docs/EXTENSIONS.zh-CN.md">扩展开发</a>
<a href="./docs/EXTENSIONS.md">扩展开发</a>
&nbsp;·&nbsp;
<a href="./docs/SPEC.zh-CN.md">规格</a>
<a href="./docs/SPEC.md">规格</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
&nbsp;·&nbsp;
@@ -21,6 +21,7 @@
</p>
<p align="center">
<a href="https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true"><img src="https://img.shields.io/github/v/release/esengine/DeepSeek-Reasonix.svg?filter=studio-v*&include_prereleases&style=flat-square&color=8250df&labelColor=161b22&label=studio%202.x" alt="Reasonix Studio 2.x"/></a>
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/DeepSeek-Reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=8b949e&labelColor=161b22" alt="license"/></a>
@@ -42,16 +43,30 @@
<p align="center"><strong>开源 · MIT · 单个 Go 二进制</strong></p>
<h3 align="center">可以一直开着跑的编码 Agent。</h3>
<p align="center">一套本地引擎,四个入口——终端、桌面端、浏览器,或通过 ACP 接入你的编辑器。计划模式、权限、工作区沙箱与逐轮 checkpoint,让长时间自治运行始终可读、可撤销。</p>
<div align="center">
<video src="https://github.com/user-attachments/assets/ab2f3878-e224-4931-8254-060e7695cfb9" controls preload="metadata" width="560"></video>
</div>
<br/>
<p align="center">维护者:<strong>Huahui Yu</strong>。</p>
> [!IMPORTANT]
> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **<https://discord.gg/XF78rEME2D>**
## 版本
Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748)。
| 版本线 | 分支 | 状态 | 获取方式 |
| --- | --- | --- | --- |
| **Reasonix 2.x** | `studio`(当前分支) | 活跃开发,预发布 | [Studio 发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
| **Reasonix 1.x** | [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) | 维护 / 稳定 | `npm i -g reasonix` · [桌面端下载](https://reasonix.io/?download=desktop#start) |
- **想要稳定**:继续使用 1.x。它会持续收到 Bug 修复、Provider / API 兼容、
更新器和安全修复,但不再扩展核心架构。
- **想体验新架构**:使用 2.x Reasonix Studio。它仍在快速迭代,欢迎反馈问题。
1.x 中有价值的修复、测试和行为会逐项 review,适合新架构的会迁移或重新实现到 2.x。
- [从 1.x 迁移到 2.x](./docs/MIGRATING.md)(英文):两条线共用哪些数据,以及如何在同一台
机器上同时使用而不丢会话。
- [2.x 路线图](./docs/ROADMAP.md)(英文):2.x 还要交付什么、如何发布、哪些决定尚未做出。
## 特性
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
@@ -67,57 +82,83 @@
## 安装
选择适合你的使用路径。CLI/TUI、桌面端和 VS Code 扩展都使用同一套本地
Reasonix 引擎。
当前分支是 **Reasonix 2.x**,桌面端是 Reasonix Studio;CLI、Studio 和编辑器集成
都运行同一套本地 Reasonix 引擎。
### 路径 A:CLI / TUI
### Reasonix Studio(2.x)
任意支持的平台都可以通过 npm 安装原生二进制;macOS 也可以使用 Homebrew:
从最新的 [Studio 发布](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)
下载对应平台的安装包(标签为 `studio-v2.*`,2.x 活跃开发期间以预发布形式发布):
| 平台 | 安装包 | 架构 |
| --- | --- | --- |
| macOS | `.dmg` 或 `.zip` | Apple Silicon(`arm64`)/ Intel(`amd64`) |
| Windows | 安装器 `-installer.exe` 或便携 `.zip` | x64 |
| Linux | `.deb` | x64 |
每个安装包都附带 `.minisig` 签名,发布页另有 `SHA256SUMS`。安装后 Studio 会在
应用内自行更新。
同一个发布页也提供 2.x 的 `reasonix` CLI 归档(`darwin|linux|windows × amd64|arm64`)。
### Reasonix 1.x(稳定版)
1.x CLI 在任意支持的平台上都可以通过 npm 安装,macOS 也可以使用 Homebrew:
```sh
npm i -g reasonix # 任意系统;自动拉取对应平台的原生二进制
brew install esengine/reasonix/reasonix # macOS
```
预编译归档(`darwin|linux|windows × amd64|arm64`)和 `SHA256SUMS` 见每个
[GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases)。
1.x 桌面端请前往[官方下载页](https://reasonix.io/?download=desktop#start)。
Windows 安装器使用 Certum 代码签名证书签名。
### 路径 B:桌面端
### VS Code 扩展
前往[官方下载页](https://reasonix.io/?download=desktop#start)获取最新桌面版本。
| 平台 | 安装包 | 架构 |
| --- | --- | --- |
| macOS | 通用 `.dmg` 或 `.zip` | Apple Silicon / Intel |
| Windows | 安装器 `.exe` 或便携 `.zip` | x64 / ARM64 |
| Linux | `.deb` 或 `.tar.gz` | x64 |
Windows 安装器通过 [SignPath.io](https://signpath.io/) 完成代码签名,证书由
[SignPath 基金会](https://signpath.org/) 免费提供。
### 路径 C:VS Code 扩展
请先完成路径 A。扩展不内置 CLI,而是启动本机的 `reasonix acp` 后端,
并提供原生聊天、编辑器上下文、工具调用审批、模型选择和工作区会话。
扩展不内置 CLI,而是启动本机的 `reasonix acp` 后端,并提供原生聊天、编辑器
上下文、工具调用审批、模型选择和工作区会话。请先安装 1.x CLI。
- **VS Code:** [从 Visual Studio Marketplace 安装](https://marketplace.visualstudio.com/items?itemName=SivanLiu.reasonix-agent)
- **VSCodium / Eclipse Theia:** [从 Open VSX Registry 安装](https://open-vsx.org/extension/SivanLiu/reasonix-agent)
- **扩展 ID:** `SivanLiu.reasonix-agent` · [源码与使用说明](https://github.com/SivanCola/reasonix-vscode)
### 路径 D:从源码构建
### 从源码构建
克隆仓库;`studio` 构建 2.x,`main-v2` 构建 1.x:
```sh
git clone https://github.com/esengine/DeepSeek-Reasonix.git
cd DeepSeek-Reasonix
git switch studio
```
#### CLI
CLI 构建需要 **Go 1.25+**。模块固定了 `toolchain`,保持 `GOTOOLCHAIN=auto`
即可让 Go 自动下载对应工具链,也可以手动安装。
```sh
make build # -> bin/reasonix(.exe)
make cross # -> dist/(darwin|linux|windows × amd64|arm64)
```
#### Studio
Studio 的前端还需要 **Node 24+ 与 pnpm 10**(`npm install -g pnpm@10`)。
```sh
make studio
```
各平台的 webview 依赖和 Linux 构建标签见 [Studio 构建指南](desktop/README.md#prerequisites)。
## 快速开始
### CLI / TUI
### Reasonix Studio
以下命令仅适用于通过路径 A 安装的 CLI/TUI:
安装并启动 Studio,然后在应用内连接 provider 和模型即可使用,无需配置 CLI。
### CLI / TUI
```sh
reasonix setup # 配置 provider 和模型
@@ -127,34 +168,28 @@ reasonix run "把 main.go 里的 TODO 实现掉"
需要项目指令时,可在交互式会话中运行 `/init`。
### 桌面端
从[官方下载页](https://reasonix.io/?download=desktop#start)下载对应系统的安装包,
安装并启动 Reasonix,然后在应用内配置 provider 和模型即可使用。桌面端无需执行
上面的 CLI 命令。
CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**、
**[指南](./docs/GUIDE.zh-CN.md)** 和
**[配置路径](./docs/CONFIG_PATHS.zh-CN.md)**。
**[配置路径](./docs/CONFIG_PATHS.md)**。
## 文档
- **开始使用:** [指南](./docs/GUIDE.zh-CN.md) ·
[CLI 命令参考](./docs/CLI.zh-CN.md) · [配置路径](./docs/CONFIG_PATHS.zh-CN.md) ·
[ACP 编辑器接入](./docs/ACP.zh-CN.md)
- **功能与排障:** [子智能体 Profile](./docs/SUBAGENT_PROFILES.zh-CN.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.zh-CN.md) ·
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.zh-CN.md) ·
[恢复与安全模式](./docs/RECOVERY.zh-CN.md) ·
[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md) ·
[Checkpoints 与 rewind](./docs/CHECKPOINTS.zh-CN.md)
- **工程与迁移:** [规格](./docs/SPEC.zh-CN.md) ·
[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md) ·
[工具合约](./docs/TOOL_CONTRACT.zh-CN.md) ·
[从 0.x 迁移](./docs/MIGRATING.zh-CN.md)
- **扩展开发:** [扩展概览](./docs/EXTENSIONS.zh-CN.md) ·
[插件包与 Manifest v1](./docs/PLUGIN_PACKAGES.zh-CN.md) ·
[Extension Protocol](./docs/EXTENSION_PROTOCOL.zh-CN.md) ·
[CLI 命令参考](./docs/CLI.zh-CN.md) · [配置路径](./docs/CONFIG_PATHS.md) ·
[ACP 编辑器接入](./docs/ACP.md)
- **功能与排障:** [子智能体 Profile](./docs/SUBAGENT_PROFILES.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[恢复与安全模式](./docs/RECOVERY.md) ·
[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)
- **工程与迁移:** [规格](./docs/SPEC.md) ·
[任务合约与暂停策略](./docs/TASK_CONTRACT.md) ·
[工具合约](./docs/TOOL_CONTRACT.md) ·
[从 1.x 迁移到 2.x](./docs/MIGRATING.md) ·
[2.x 路线图](./docs/ROADMAP.md)
- **扩展开发:** [扩展概览](./docs/EXTENSIONS.md) ·
[插件包与 Manifest v1](./docs/PLUGIN_PACKAGES.md) ·
[Extension Protocol](./docs/EXTENSION_PROTOCOL.md) ·
[Go SDK 与 starter](./sdk/go/README.md)
## Star 趋势
@@ -179,10 +214,10 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) |
| **merge-order-check** | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) |
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | **merge-order-check** | [**light-front-theory**](https://github.com/light-front-theory) | **Yan Li** |
| [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** | [**Bernardxu123**](https://github.com/Bernardxu123) | [**HaoyueQin**](https://github.com/HaoyueQin) |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**KHG420**](https://github.com/KHG420) | [**JesonChou**](https://github.com/JesonChou) | [**SuMuxi66**](https://github.com/SuMuxi66) |
<!-- reasonix-top-contributors:end -->
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
+162 -35
View File
@@ -9,24 +9,25 @@ agent. It is the Reasonix analog of Claude Code's CLAUDE.md.
- Go kernel under `internal/`; each package owns one concern. A package's long
explanation belongs in its `doc.go`, not spread across implementation files.
- One transport-agnostic `control.Controller` sits behind every frontend (chat
TUI, HTTP/SSE serve, Wails desktop). Add behavior to the controller, not a
TUI, HTTP/SSE serve, Electron desktop). Add behavior to the controller, not a
frontend, so all three inherit it.
- Layering (enforced): utility packages import nothing under `reasonix/` except
one another — that layer is closed, so a leaf reaching a leaf drags no graph
along, while refusing it leaves one judgement copied into several of them; only
the frontends `cli`, `serve`, `acp`, `bot`, `botruntime`, `boot` and the hosts
`cmd/`, `desktop/` may import `control`; nothing below a frontend may import
one. The declared sets live in `tools/repolint/layers.go`.
the frontends `cli`, `serve`, `acp`, `boot` and the hosts `cmd/`, `desktop/`
may import `control`; nothing below a frontend may import one. The declared
sets live in `tools/repolint/layers.go`.
- Subagent delegation keeps five concepts apart: a profile says how a worker
thinks, `TaskSpec` what this call wants, `CapabilityGrant` what it may touch,
`ContextRequest` what it starts from, `SchedulerPolicy` when it runs. Put a
field in whichever member decides its value — profiles carry ceilings, never
per-call values. `internal/agent/profile_boundary_test.go` enforces it.
- Cache-first: the system-prompt prefix (base prompt + tools + memory) must stay
byte-stable across turns so DeepSeek's automatic prefix cache stays warm. Never
mutate it mid-session — ride the turn tail instead (see `control.Compose`).
per-call values. `internal/runtime/delegation/profile_boundary_test.go` enforces it.
- Cache-first: the system-prompt prefix (base prompt, tools, declared prefix
configuration) must stay byte-stable across turns so DeepSeek's automatic
prefix cache stays warm. Never mutate it mid-session — ride the turn tail
instead (see `control.Compose`), under the rules in *Context projection*.
- Performance features land with an effect test at their final boundary
(`internal/boot/effect_test.go` pattern): assert what actually reaches the
(`internal/assembly/boot/effect_test.go` pattern): assert what actually reaches the
provider request, frontend sink, or trajectory through the real `boot.Build`
assembly. Component correctness is not system effectiveness.
- A mutex- or atomic-guarded struct is ratcheted on its **scalar** field count
@@ -35,6 +36,9 @@ agent. It is the Reasonix analog of Claude Code's CLAUDE.md.
the move this blocks — group by lifetime into a named sub-state instead
(`agent.perTurnState` is the pattern), which costs one field and removes the
whole product.
- The context window (projection, compaction, estimation) owns `windowState`;
the loop reaches it via `a.window()`, it reaches the loop via `windowHost`.
`window-state` fails any other read of `sess.win`.
- Judgements read structure, never wording: shell ASTs (`shellparse`), types,
contracts, tool schemas. Phrase tables and message sniffing have been retired
in batches (task-policy prose, the planner's approval phrases, the executor
@@ -47,11 +51,22 @@ agent. It is the Reasonix analog of Claude Code's CLAUDE.md.
knows a deadline expired rather than a socket closed, and a message is where
that knowledge goes to die: the reader is left matching words, or with
nothing. Across HTTP the identity is a dotted code through `refuse`;
`refusal-path` fails a new `http.Error` in `internal/serve`, and the parity
`refusal-path` fails a new `http.Error` in `internal/frontend/serve`, and the parity
test fails a code the frontend cannot say. Inside Go it is `errors.Is` on a
sentinel the wrapping preserves; `error-text` fails a new match against an
error message, following it one hop into a local because storing the text
first is what the direct form turns into.
- **Whoever first knows why an operation failed owns its class.** A transport
status is not a domain identity, and a display string is not a classification;
past that boundary a layer may project the class, never re-derive it. Both
directions were live: `catalog.go` answered 400 for a name nobody declared and
for a disk that would not write, so a failed store told the user their request
was wrong; `ServerRow` decided a connection was an authorisation problem by
matching `/401|403|unauthorized/` against prose an external MCP server wrote,
which the host had sanitised and truncated on the way through. Domain,
internal and dependency are three answers with three different things to do
next, and a caller that cannot tell them apart reports the last two as the
first.
- Failure attribution is host-owned state. When the host can determine why an
operation failed or stopped — truncation, timeout, unavailable context,
dependency skipping, policy refusal, output spill, argument parsing — that
@@ -104,6 +119,17 @@ agent. It is the Reasonix analog of Claude Code's CLAUDE.md.
records `os.File.Close` as a frontend driving `Lifecycle.Close`, a false
"wired" that hides real debt. How a verb is spelled — `/branch`,
`POST /branches` — is presentation and stays out of it.
- The desktop's presentation layer answers to canonical state and never
redefines it. Six rules, each held by a gate rather than by memory:
presentation reorders and reweights what the kernel owns but never restates
it; **mount is not a motion cause** — a fact enters once, and replay or
virtualization bringing its element back is not that fact happening again;
a search alias buys discoverability and never becomes a thing's identity;
when a setting takes effect is declared per block, from what its endpoint
does, never inferred from wording or handler names; ordering panels by
lifecycle moves them and must not remount them; and a control's identity
survives a change of where it is drawn — the settings sheet and the composer
render `chrome.preset` because that is one intent, not two.
## Comments
@@ -111,6 +137,27 @@ Default is none — the code is the truth. Write one only when the **why** is
non-obvious: a hidden constraint, a workaround anchored to something verifiable,
an invariant the type system cannot express, or an external-protocol quirk.
A comment states the constraint that holds now. It does not narrate how the code
got here: a reader who has never seen an earlier version must lose nothing, and
past tense about this code's own behaviour is the tell — an external protocol's
history is not. "Ids alone do not answer it" is the rule; "ids alone were the
wrong question, so the list went on reading as the model wrote it" is the
incident behind the rule, and the incident belongs in the commit message. This
outranks the surrounding style: much of the tree reads as prose and none of it
is the standard. No gate reads register — telling narrative from a statement of
constraint needs a phrase table, which is the one thing this repo does not
build — so this rule is held by review.
- **Stylesheets carry none.** A rule's selector and its declarations are the
whole statement. What a `.css` comment would have said goes to whichever of
two places can act on it: a constraint a component has to honour belongs in
that component, and a decision about the medium itself — a measured colour, a
layout mechanic, why the obvious rule is the wrong one — belongs in the
stylesheet's companion document (`src/styles/TOKENS.md`, `LAYOUT.md`), which
is read end to end rather than met 600 times as an aside.
- **English, always** — every comment, in every other language the tree is
written in. A codebase whose commentary is half one language reads as two
codebases, and the half a given reader cannot read is the half that rots.
- Declaration doc: ≤5 lines. Package comment: ≤8 lines, or ≤40 in a `doc.go`.
- Every other comment: ≤3 lines. Struct-field and trailing `//`: 1 line.
- Never: restatements of the code, phase/stage narrative, incident or
@@ -120,13 +167,45 @@ an invariant the type system cannot express, or an external-protocol quirk.
their length tracks how many cases they cover, not how many concerns they
carry, so splitting one only scatters a subject's table across files.
`go run ./tools/repolint` enforces all of it against a ratchet baseline: recorded
debt is tolerated, anything new fails CI. Never widen the baseline to land a
change — fix the code. `-update` lowers budgets freely and refuses to raise one
without `-allow-widen`, so carrying debt through a rename or an extraction is
asked for in the command and justified in the PR. A clean run also reports
budget the tree stopped using: it is only reclaimed by an `-update`, and until
then it is room a file can grow back into.
`go run ./tools/repolint` enforces the budgets above against a ratchet baseline:
recorded debt is tolerated, anything new fails CI. Never widen the baseline to
land a change — fix the code. `-update` lowers budgets freely and refuses to
raise one without `-allow-widen`, so carrying debt through a rename or an
extraction is asked for in the command and justified in the PR. A clean run also
reports budget the tree stopped using: it is only reclaimed by an `-update`, and
until then it is room a file can grow back into.
## Context projection
Canonical dynamic state — the skill registry, the memory store — lives in its own
owner, and what the model sees is a projection of it onto each request. The
prefix was never where these belonged wrongly; freshness ownership was missing.
- **Prefix ownership.** Only declared prefix configuration may move the
cache-stable prefix; ordinary memory writes, skill writes and runtime facts
ride the turn. Pinned memory bodies are the one declared exception, because
pinning is a request to pay one cold start.
- **Projection freshness.** When canonical state changes, the next eligible turn
carries the new projection or can say why it is not owed. "Right after a fold,
or in the next session" is not an answer.
- **Projection ownership.** Whoever owns a dynamic fact owns the invalidation
that makes its projection owed again. A projection kept fresh because every
writer remembered to announce itself is one forgotten call from stale —
`skillSet.owedCatalog` asks the registry rather than waiting to be told.
- **Determinism.** The same canonical state and the same projection state
produce byte-identical model-visible bytes.
- **Detection is semantic.** Freshness may not rest on writer notification, on
mtime, or on which call path made the change. The registry answers from
canonical state, so a switch flipped and a file edited past every API are the
same question, and a change no projection renders is not a change.
`internal/assembly/boot/context_projection_test.go` holds all five at the provider
boundary, with the skills listing and the standing instructions as the two
worked examples. A block is settled only once the turn carrying it reaches the
runner: a turn blocked by a hook or refused by an extension after composing put
those bytes nowhere, and clearing the debt there would leave the model holding
state nobody sent it. A projection that has become empty is still owed once —
otherwise the block the model already has goes on reading as current.
## Memory
@@ -152,6 +231,44 @@ after the edit that enables it. Several edits to the *same* file are one
`multi_edit` — it applies them against each other in memory and rewrites the
file only if all of them land, so a failure midway leaves nothing half-edited.
## Vendor rates
Official list prices live in one place: `officialRates` in
`internal/contract/pricing/rates.go`, oldest generation first, the last being what a
model charges today. A price change is an **append, never an edit** — 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 these through `pricing.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 reports how long ago a person last confirmed each one. It reaches
the network, so it stays out of `make lint` and out of CI: run it before
touching a rate and when a vendor announces a change. A page it cannot read
exits non-zero — unread is its own verdict and never counts as agreement.
## Curated presets
A preset changing shape reaches the installs that already have the old one
through `shippedPresets` in `internal/contract/config/preset_upgrade.go`: one declared
shape per generation, and one upgrade that brings a matching entry forward. The
guard is the whole shape — catalog, window, default — because an entry holding
what we shipped is ours to move and anything else its user curated.
A preset is also what settles which of its models read images. Probing an
address a preset covers answers from that declaration, because reading a name
misses every Kimi, Qwen, MiniMax and Claude model they declare and `deepseek-
flash` spells nothing at all; the spelling is the last resort for an address
nothing has declared, and stays a correctable suggestion rather than a claim.
Three distinctions are load-bearing, and each was a separate migration function
before they were written down. A window the shape did not name is the user's,
so only a shape identified by its window moves one. `vision_models` **narrows**,
so an upgrade marks only the models it adds and only where the preset says they
read images. And an explicitly empty list is a choice — nothing here reads
images — which is not the same as the nil that means nobody has said.
## Pre-push CI simulation
Run these **before every commit** to catch the fastest CI failures locally:
@@ -160,7 +277,7 @@ Run these **before every commit** to catch the fastest CI failures locally:
gofmt -w . # catches gofmt (saves ~13s CI)
go vet ./... # catches vet warnings (saves ~52s CI/lint)
make lint # golangci-lint at CI's pin + repolint
go test ./internal/tool/builtin/ ./internal/boot/ # catches tool/boot test breaks
go test ./internal/tools/builtin/ ./internal/assembly/boot/ # catches tool/boot test breaks
```
`make lint` runs both gates CI runs, at the version in `.golangci-version`;
@@ -175,13 +292,17 @@ never touched. To see only what your own edits owe:
make check
```
Repo-wide ceilings still report under `-only`, because a file can push the tree
past one without exceeding its own budget.
Repo-wide ceilings still report under a narrowed run, because a file can push
the tree past one without exceeding its own budget.
Run it through `make`, not as `go run ./tools/repolint -only ...`. The host
recognizes a `make` target as a check whose result it can read, while an
arbitrary `go run ./tools/...` is a program it has to assume writes — so only
the `make` form can be cited as evidence that this gate passed.
`make check` owns the evidence-bearing changed-file universe — everything the
worktree altered *and* everything it added that git has not been told about.
Never substitute a direct `go run ./tools/repolint` invocation for it when
recording verification evidence: the host recognizes a `make` target as a check
whose result it can read, while an arbitrary `go run ./tools/...` is a program
it has to assume writes, and the flags behind the target are its implementation
rather than the contract. Building that universe from `git diff` alone once let
a new file reach the gate unopened and be reported clean.
## Import cycle rule
@@ -208,16 +329,22 @@ can finish. Sensitivity is declared, never inferred: the host does not read it
out of how a path is spelled, which cannot tell `internal/auth` from
`session_write_authority.go`, or a trace file from a data race.
- sensitive: internal/permission/**
- sensitive: internal/sandbox/**
- sensitive: internal/shellsafe/**
- sensitive: internal/shellparse/**
- sensitive: internal/control/approval.go
- sensitive: internal/installsource/**
- sensitive: internal/plugin/**
- sensitive: internal/pluginpkg/**
- sensitive: internal/netclient/**
- sensitive: internal/redirectguard/**
- sensitive: internal/safety/permission/**
- sensitive: internal/safety/sandbox/**
- sensitive: internal/safety/shellsafe/**
- sensitive: internal/base/shellparse/**
- sensitive: internal/session/control/approval.go
- sensitive: internal/session/control/approval_bridge.go
- sensitive: internal/session/control/approval_orchestration.go
- sensitive: internal/model/providerbroker/**
- sensitive: internal/ext/installsource/**
- sensitive: internal/ext/plugin/**
- sensitive: internal/ext/pluginpkg/**
- sensitive: internal/base/netclient/**
- sensitive: internal/safety/redirectguard/**
- sensitive: internal/safety/egress/**
- sensitive: internal/platform/browser/**
- sensitive: internal/platform/computer/**
One declaration, two effects: the same list is also the coverage gate's subject.
`make coverage-gate` holds each path to the coverage it already has, because
@@ -253,7 +380,7 @@ moving a number here must not move the promise.
establishes nothing and the mutation stays unproven.
- Symlinks are recorded by `Lstat`, so a write through one lands outside what
the walk compares. Confining the resolved target is the sandbox's job.
- `internal/sandbox` confines writes to the workspace, configured extras, temp
- `internal/safety/sandbox` confines writes to the workspace, configured extras, temp
and toolchain caches — on macOS and Linux. Windows has no OS-level bash
sandbox, so there nothing the host enforces bounds `Writable`.
- The governed host authorities are a *named* set — ssh-agent, docker, podman.
+57 -6
View File
@@ -56,9 +56,10 @@ Supported boundaries include:
- Sandbox behavior for built-in shell execution where the platform supports it.
- Secret handling for provider keys, bot credentials, OAuth tokens, plugin
headers, and credential-store fallback files.
- 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 |
| `permissions.mode`, `permissions.allow`, `permissions.allow_dynamic_bash` | Nothing |
| `desktop.default_tool_approval_mode` | Nothing |
| `[network]` proxy settings | Nothing |
| `[[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
+44 -36
View File
@@ -7,9 +7,9 @@ SWE-bench Verified mode:
[`cmd/e2ebench`](../cmd/e2ebench/main.go). It runs each task against a real
provider and emits a markdown + JSON report (accuracy, cache-hit rate, token
use, cost) suitable for pasting into a PR.
- `context-maintenance-e2e/` — a standalone seed → resume → comprehension
harness that A/B-compares cold-restart cache behavior with and without
context pruning.
- `context-maintenance-e2e/` — a standalone seed → resume → continue smoke that
checks a session seeds below the compaction trigger, resumes without paying
for a summary, and folds exactly once past it.
- `compaction/` — CompactionBench: grows a session one generation at a time and
folds it after each, measuring what repeated compaction costs and what it
loses. See [CompactionBench](#compactionbench) below.
@@ -420,16 +420,20 @@ own outcome).
| `-suite` | `benchmarks/e2e` | Suite root (must contain `tasks/<id>/`). |
| `-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. |
| `-model` | *(config default)* | Provider/model name. |
| `-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. |
| `-ablate` | *(none)* | Ablation arm: comma-separated subsystems to switch off — `evidence`, `planner`, `subagent`, `retrieval`, `compaction`; `none` \| `all`. |
| `-ablate` | *(none)* | Ablation arm: comma-separated subsystems to switch off — `evidence`, `planner`, `subagent`, `retrieval`, `compaction`, `upstream` and `recall-search`; `none` \| `all`. |
| `-out` | *(stdout)* | Write the markdown report here. |
| `-json` | *(none)* | Write the JSON report here (optional). |
| `-trajectories` | *(none)* | Suite mode: write one `<task-id>.trajectory.jsonl` per task into this directory (the agent's full event stream with timestamps — see `reasonix run --trajectory`). The report gains a time-attribution line (tools vs. model) and each JSON result a `trajectory` digest. |
| `-force-planner` | `false` | Suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate. Use for the "with planner" arm of an A/B; results carry `plan_forced` so arms are only comparable with equal forcing. |
| `-anchor` | `blind` | Suite mode: which hypothesis the agent holds before it looks at anything — `blind` (none, the control) \| `correct` \| `wrong`. The seeded arms prefix each prompt with the task's authored seed and **skip** tasks that have none, so an unseeded control run never lands in a seeded denominator. Results carry `anchor`. See [Anchor resistance](#anchor-resistance). |
| `-cache` | `cold` | Suite mode: `cold` runs each task as a fresh session (the fair cross-agent comparison arm); `warm` primes the provider prefix cache with a one-step run in the same workdir first, measuring the long-lived-session steady state. Never mix arms in one report — compare them with `-mode compare cold.json warm.json`. |
| `-policy` | *(none)* | Suite mode: experiment arm. `memory-off` hides the memory store, which is the MemoryBench counterfactual arm; the empty default runs the baseline. |
| `-budget` | `800000` | Abort once total tokens cross this (`0` = no cap). Remaining tasks are reported as skipped. |
| `-meter` | *(off)* | Suite mode: route the benchmarked provider through the neutral measuring proxy, using this `config.toml` as the source. Spend is then counted at the request boundary instead of trusted from the harness. See [Neutral metering](#neutral-metering). |
| `-faults` | *(none)* | Suite mode: inject provider failures through the meter — absolute indices (`3:429`) and/or a cadence that scales with the run (`every:5:500`). Requires `-meter`. See [Fault recovery](#fault-recovery). |
@@ -538,33 +542,32 @@ writer.
## context-maintenance-e2e
This harness measures what happens when a long session goes idle past the
provider's cache TTL and then resumes: it A/B-compares cold-restart miss tokens
with and without pruning, and checks that the agent re-reads a file behind a
prune placeholder instead of hallucinating.
A cost-capped smoke for content-driven maintenance, in three legs over one state
directory: seed a session just below the compaction trigger, load it back and
check nothing is re-derived, then grow it past the trigger and check exactly one
summary lands.
It is hardcoded to the `deepseek-v4-flash` model at `https://api.deepseek.com`
and requires the `DEEPSEEK_API_KEY` environment variable.
Seed and resume take no provider; `continue` samples and needs
`DEEPSEEK_API_KEY`. The model is hardcoded to `deepseek-v4-flash` at
`https://api.deepseek.com`.
```sh
export DEEPSEEK_API_KEY=...
# Seed below the trigger: no checkpoint installed, no summary started
go run ./benchmarks/context-maintenance-e2e -offline seed
# Seed both arms (pruned + control) with a large session and warm the cache
go run ./benchmarks/context-maintenance-e2e seed
# Wait past the provider's cache TTL, then resume: prune the "pruned" arm and
# compare cold-restart miss tokens
# Reload: the projection version must not move and no summary may run
go run ./benchmarks/context-maintenance-e2e resume
# Run the comprehension trials (agent must re-read a pruned file and answer
# from it); exits non-zero unless every trial passes
go run ./benchmarks/context-maintenance-e2e comprehension
# Grow past the trigger: one summary, version +1, then no further summary
export DEEPSEEK_API_KEY=...
go run ./benchmarks/context-maintenance-e2e continue
```
| Flag | Default | Purpose |
| --- | --- | --- |
| `-dir` | `benchmarks/context-maintenance-e2e/run` | State directory for `seed`/`resume` (sessions + `meta.json`, `resume-<ts>.json`). |
| `-trials` | `5` | Number of comprehension trials. |
| `-dir` | `benchmarks/context-maintenance-e2e/run` | State directory shared by the three legs (session + `meta.json`). |
| `-max-usd` | `0.50` | Hard cost cap for the live legs. |
| `-offline` | off | Skip the live provider; `seed` only. |
## See also
@@ -590,6 +593,24 @@ e2ebench -suite benchmarks/memorybench -budget 0 -policy memory-off -trajectorie
e2ebench -mode compare on.json off.json # Memory utility section
```
MemoryBench requires an OS read sandbox. It runs on macOS and Linux and
refuses to start on Windows, where the benchmark cannot isolate the committed
`tasks/<id>/memory/` answers from a graded agent. Each run also audits every
recorded trajectory leg for a successful read of those answers; an answer read
invalidates the result and makes the suite command exit nonzero. A missing or
unreadable trajectory marks only that run incomplete; other tasks still run.
Trajectories are captured temporarily when `-trajectories` is omitted.
The audit treats a completed `read_file` call naming a task-memory path as a
read. For `bash` and `grep`, a task-memory path in the arguments plus an authored
fact line in the result is **evidence** of a read, not a proof of how that text
was obtained. `glob` and directory listings expose names but not fact bodies;
the audit does not count them as answer reads. Commands that reach the answer
through an indirect path, such as `cd` followed by a relative filename, may
not be attributed by the trajectory check. The OS read sandbox is the primary
boundary; this audit is a second check, not a proof that an unflagged run never
accessed an answer.
Utility delta = paired Pass(on) − Pass(off). Harmful attribution is paired,
never judged: the same task passed without memory and failed with it while
recall fired. Scenario classes: exact, paraphrase, cjk, symbol, distractor
@@ -603,8 +624,8 @@ update (revised value wins), pinned (prefix channel end to end).
`benchmarks/compaction/` drives the real agent compaction path over a session
that grows one generation at a time. Each generation appends a round of work
and then folds, so generation N folds everything generations 1..N produced —
which is the growth that matters, because a fold re-derives its digest from the
whole canonical transcript rather than from the previous digest.
which is the growth that matters, because a fold reads the model-visible
projection, so the previous digest feeds the next one.
```bash
go run ./benchmarks/compaction -mode=cost # offline, no API key
@@ -631,16 +652,3 @@ probe, not a compaction loss.
Probe answers are scored on whole words, and a wanted answer does not count if a
rejected one appears anywhere in the same reply — "yes, but it has not been
re-run since" is the shape a drifting digest produces, and it is not a pass.
### Fold arms
`-arm=full` (default) re-derives every digest from the canonical transcript, so
digests never chain. `-arm=incremental` folds the model-visible view instead,
feeding the previous digest back through the summarizer. The arms exist to price
that trade: run the cost arm for what chaining saves, and the fidelity arm for
what it costs.
```bash
go run ./benchmarks/compaction -mode=cost -arm=incremental
DEEPSEEK_API_KEY=… go run ./benchmarks/compaction -mode=fidelity -arm=incremental
```
+24
View File
@@ -0,0 +1,24 @@
# catalog-detector
Who owns "the skills listing is owed again"?
The listing is delivered once. Something has to decide when the model's copy
stopped matching the registry, and there are three candidates: rebuild the
projection and compare it, fingerprint the files' metadata, or trust that every
write announced itself.
```
go run ./benchmarks/catalog-detector -real "$PWD"
```
**Owed is measured, not declared.** The probe renders the block before and after
each transition; owed means those differ. That definition is what makes a
touched mtime and a body-only edit *not* owed — neither changes what the model
would be sent — and it is why the projection detector is the definition rather
than an estimate of it. What the arms measure is where the two cheaper
detectors diverge from it.
Cost is timed without the instrument attached: files and bytes are counted in a
separate walk, because timing a detector with the accounting inside it measures
the accounting. Numbers are macOS/APFS with a warm cache; the ratio between
detectors is what transfers, not the absolute.
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// arm is one transition a workspace can undergo, with what it did to the thing
// the model would be sent. Truth is measured, not declared: the block is
// rendered before and after, and owed means those two differ.
type arm struct {
name string
apply func(e env) error
comment string
}
var arms = []arm{
{"api-create", func(e env) error { *e.apiWrites++; return writeSkill(e, "added", "a skill this process wrote") }, "a writer this process controls"},
{"api-describe", func(e env) error {
*e.apiWrites++
return writeSkill(e, "probe-000", "a description this process rewrote")
}, "same writer, different field"},
{"api-delete", func(e env) error { *e.apiWrites++; return os.RemoveAll(skillDir(e, "probe-000")) }, "same writer, removal"},
{"external-create", func(e env) error { return writeSkill(e, "outside", "a skill written past the API") }, "an editor, a script, the write tool"},
{"external-describe", func(e env) error { return writeSkill(e, "probe-000", "a description rewritten past the API") }, "the common out-of-band edit"},
{"external-delete", func(e env) error { return os.RemoveAll(skillDir(e, "probe-000")) }, "removal past the API"},
{"mtime-restored", restoreMtimeChange, "same length, older mtime: git checkout, rsync, touch -r"},
{"noop-touch", touchOnly, "mtime moved, nothing the model sees changed"},
{"body-only", bodyOnlyChange, "the body changed; the index does not render it"},
}
func skillDir(e env, name string) string {
return filepath.Join(e.projectRoot, ".reasonix", "skills", name)
}
func writeSkill(e env, name, description string) error {
dir := skillDir(e, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "SKILL.md"),
fmt.Appendf(nil, "---\nname: %s\ndescription: %s\n---\n\nBody paragraph the index never renders.\n", name, description), 0o644)
}
// restoreMtimeChange rewrites the rendered description without moving either
// size or mtime. Nothing exotic produces this: a branch switch restores content
// and stamps it with the mtime the file had.
func restoreMtimeChange(e env) error {
path := filepath.Join(skillDir(e, "probe-000"), "SKILL.md")
before, err := os.Stat(path)
if err != nil {
return err
}
old, err := os.ReadFile(path)
if err != nil {
return err
}
swapped := strings.Replace(string(old), "probe skill 0, one line of index text.", "PROBE SKILL 0, ONE LINE OF INDEX TEXT.", 1)
if swapped == string(old) {
return fmt.Errorf("arm mtime-restored did not change the description; the fixture moved")
}
if len(swapped) != len(old) {
return fmt.Errorf("arm mtime-restored changed the file length (%d -> %d); it must not", len(old), len(swapped))
}
if err := os.WriteFile(path, []byte(swapped), 0o644); err != nil {
return err
}
return os.Chtimes(path, before.ModTime(), before.ModTime())
}
func touchOnly(e env) error {
path := filepath.Join(skillDir(e, "probe-000"), "SKILL.md")
later := time.Now().Add(time.Hour)
return os.Chtimes(path, later, later)
}
func bodyOnlyChange(e env) error {
path := filepath.Join(skillDir(e, "probe-000"), "SKILL.md")
old, err := os.ReadFile(path)
if err != nil {
return err
}
return os.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)
}
+90
View File
@@ -0,0 +1,90 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type costRow struct {
scale string
detector string
p50, p95 time.Duration
files int
bytes int64
}
// measureCost times each detector over synthetic workspaces of a given size and,
// when asked, over a real one. The synthetic skills are shaped like real ones —
// a directory per skill holding SKILL.md — because the walk is what is being
// measured and a flat directory would not have it.
func measureCost(scales []int, samples int, realRoot string) []costRow {
var rows []costRow
for _, n := range scales {
root, err := os.MkdirTemp("", "catalog-detector-*")
fatal(err)
defer os.RemoveAll(root)
fatal(writeSkills(filepath.Join(root, "project", ".reasonix", "skills"), n))
// A separate home: pointing both at one directory walks it twice and
// reports double the cost a real workspace pays.
rows = append(rows, timeDetectors(fmt.Sprintf("synthetic n=%d", n),
env{projectRoot: filepath.Join(root, "project"), homeDir: filepath.Join(root, "home"), apiWrites: new(int)}, samples)...)
}
if realRoot != "" {
home, err := os.UserHomeDir()
fatal(err)
rows = append(rows, timeDetectors("real workspace", env{projectRoot: realRoot, homeDir: home, apiWrites: new(int)}, samples)...)
}
return rows
}
func timeDetectors(scale string, e env, samples int) []costRow {
var rows []costRow
files, bytes := walkCost(e.roots())
for _, d := range detectors {
var durations []time.Duration
for range samples {
start := time.Now()
d.read(e)
durations = append(durations, time.Since(start))
}
row := costRow{scale: scale, detector: d.name, p50: percentile(durations, 0.5), p95: percentile(durations, 0.95)}
if d.walks {
row.files, row.bytes = files, bytes
}
rows = append(rows, row)
}
return rows
}
func writeSkills(dir string, n int) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for i := range n {
sub := filepath.Join(dir, fmt.Sprintf("probe-%03d", i))
if err := os.MkdirAll(sub, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte(skillFile(i, fmt.Sprintf("probe skill %d, one line of index text.", i))), 0o644); err != nil {
return err
}
}
return nil
}
func skillFile(i int, description string) string {
return fmt.Sprintf("---\nname: probe-%03d\ndescription: %s\n---\n\nBody paragraph the index never renders.\n", i, description)
}
func renderCost(rows []costRow) string {
var b strings.Builder
b.WriteString("cost — one reading, as a turn would take it\n\n")
fmt.Fprintf(&b, "%-16s %-15s %10s %10s %7s %9s\n", "scale", "detector", "p50", "p95", "files", "bytes")
for _, r := range rows {
fmt.Fprintf(&b, "%-16s %-15s %10s %10s %7d %9d\n", r.scale, r.detector, r.p50.Round(time.Microsecond), r.p95.Round(time.Microsecond), r.files, r.bytes)
}
return b.String()
}
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"reasonix/internal/ext/skill"
)
// detector answers one question: has the thing the model would be sent changed?
// Each returns an opaque token — equal tokens mean "not owed". Nothing here
// counts files or bytes: accounting is a second walk, and timing a detector
// with the instrument attached measures the instrument.
type detector struct {
name string
read func(env) string
walks bool // whether this detector's cost is a directory walk
}
// env is one workspace under measurement.
type env struct {
projectRoot string
homeDir string
// apiWrites is the observed-write detector's whole input: whether the last
// mutation went through a writer this process controls.
apiWrites *int
}
func (e env) store() *skill.Store {
return skill.New(skill.Options{
ProjectRoot: e.projectRoot, ReasonixHomeDir: filepath.Join(e.homeDir, ".reasonix"), Stderr: io.Discard,
})
}
func (e env) roots() []string {
var out []string
for _, r := range e.store().Roots() {
out = append(out, r.Dir)
}
return out
}
// projectionDetector is the truth, not an estimate of it: it renders the block
// the model would receive and compares that. A change no listing shows — a body
// edit, a touched mtime — is by construction not owed.
var projectionDetector = detector{
name: "projection",
walks: true,
read: func(e env) string {
return digest(skill.IndexBlock(e.store().List()))
},
}
// metadataDetector is the cheap approximation: path, size and mtime, no reads.
var metadataDetector = detector{
name: "metadata",
walks: true,
read: func(e env) string {
var lines []string
for _, dir := range e.roots() {
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
lines = append(lines, fmt.Sprintf("%s\x00%d\x00%d", p, info.Size(), info.ModTime().UnixNano()))
return nil
})
}
sort.Strings(lines)
return digest(strings.Join(lines, "\n"))
},
}
// observedWriteDetector sees only what this process was told about. It reads
// nothing, so its cost is zero and its blindness is total: a write it was not
// told about leaves the token where it was.
var observedWriteDetector = detector{
name: "observed-write",
read: func(e env) string {
return fmt.Sprintf("writes=%d", *e.apiWrites)
},
}
var detectors = []detector{projectionDetector, metadataDetector, observedWriteDetector}
// walkCost reports the shape of what a walking detector touches. It runs
// outside the timed section for exactly the reason this file has two functions
// instead of one.
func walkCost(dirs []string) (files int, bytes int64) {
for _, dir := range dirs {
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
files++
if info, err := os.Stat(p); err == nil {
bytes += info.Size()
}
return nil
})
}
return files, bytes
}
func digest(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])[:12]
}
+77
View File
@@ -0,0 +1,77 @@
// Which detector can own "the skills listing is owed again"? The answer decides
// whether a projection debt is a flag every writer must remember to set, or a
// question the host answers for itself. Owed means one thing here: the rendered
// available-skills block differs from the one the model last received, so a
// touched mtime and a body edit are not owed. This probe writes only inside its
// own temporary workspaces.
package main
import (
"flag"
"fmt"
"os"
"slices"
"sort"
"time"
)
func main() {
scales := flag.String("scales", "0,10,50,200", "synthetic skill counts to measure")
samples := flag.Int("samples", 51, "cost samples per detector per scale")
real := flag.String("real", "", "also measure this workspace as it is on disk")
flag.Parse()
costs := measureCost(parseScales(*scales), *samples, *real)
verdicts := measureVerdicts()
fmt.Println(renderCost(costs))
fmt.Println()
fmt.Println(renderVerdicts(verdicts))
if failed(verdicts) {
// Not an error: a detector that misses is the finding, and the exit
// code stays zero so a study can report one.
fmt.Println("\nA detector with a miss cannot own correctness; one with a false positive cannot own cache stability.")
}
}
func parseScales(s string) []int {
var out []int
for _, f := range splitComma(s) {
var n int
if _, err := fmt.Sscanf(f, "%d", &n); err == nil {
out = append(out, n)
}
}
sort.Ints(out)
return out
}
func splitComma(s string) []string {
var out []string
cur := ""
for _, r := range s {
if r == ',' {
out = append(out, cur)
cur = ""
continue
}
cur += string(r)
}
return append(out, cur)
}
func percentile(samples []time.Duration, p float64) time.Duration {
if len(samples) == 0 {
return 0
}
slices.Sort(samples)
idx := int(float64(len(samples)-1) * p)
return samples[idx]
}
func fatal(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "catalog-detector:", err)
os.Exit(1)
}
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/ext/skill"
)
type verdict struct {
arm string
comment string
owed bool
detector string
changed bool
}
func (v verdict) label() string {
switch {
case v.owed == v.changed:
return "correct"
case v.owed:
return "MISS"
default:
return "false-positive"
}
}
// measureVerdicts runs every arm against a fresh workspace and records, for each
// detector, whether it saw the transition. Truth is the rendered block itself:
// the arm is owed when the listing the model would receive is not the listing it
// already has.
func measureVerdicts() []verdict {
var out []verdict
for _, a := range arms {
root, err := os.MkdirTemp("", "catalog-detector-arm-*")
fatal(err)
defer os.RemoveAll(root)
fatal(writeSkills(filepath.Join(root, "project", ".reasonix", "skills"), 3))
e := env{projectRoot: filepath.Join(root, "project"), homeDir: filepath.Join(root, "home"), apiWrites: new(int)}
before := map[string]string{}
for _, d := range detectors {
before[d.name] = d.read(e)
}
blockBefore := skill.IndexBlock(e.store().List())
fatal(a.apply(e))
owed := skill.IndexBlock(e.store().List()) != blockBefore
for _, d := range detectors {
out = append(out, verdict{a.name, a.comment, owed, d.name, d.read(e) != before[d.name]})
}
}
return out
}
func renderVerdicts(vs []verdict) string {
var b strings.Builder
b.WriteString("verdicts — owed is measured, not declared: the rendered block differs\n\n")
fmt.Fprintf(&b, "%-18s %-6s %-15s %-15s %s\n", "arm", "owed", "detector", "verdict", "what the arm is")
for _, v := range vs {
fmt.Fprintf(&b, "%-18s %-6v %-15s %-15s %s\n", v.arm, v.owed, v.detector, v.label(), v.comment)
}
return b.String()
}
func failed(vs []verdict) bool {
for _, v := range vs {
if v.label() != "correct" {
return true
}
}
return false
}
+22 -42
View File
@@ -15,16 +15,16 @@ import (
"fmt"
"os"
"path/filepath"
"reasonix/internal/state/sessionstore"
"strings"
"time"
"reasonix/internal/ablation"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/provider"
_ "reasonix/internal/provider/openai"
"reasonix/internal/tokencount"
"reasonix/internal/tool"
"reasonix/internal/base/tokencount"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
_ "reasonix/internal/model/openai"
"reasonix/internal/runtime/agent"
)
const (
@@ -41,7 +41,6 @@ func main() {
report := flag.String("report", "1,2,4,8", "generations to report on")
window := flag.Int("window", 128_000, "context window in tokens")
control := flag.Bool("control", true, "fidelity: also score probes against full history")
arm := flag.String("arm", "full", "full | incremental: re-derive each digest from canonical, or fold the previous projection")
_ = flag.Bool("snip", false, "accepted and ignored: automatic snip projections are gone")
out := flag.String("out", "", "write the JSON report here")
flag.Parse()
@@ -50,14 +49,11 @@ func main() {
res []genResult
err error
)
a := arms{incremental: *arm == "incremental"}
switch {
case *arm != "full" && *arm != "incremental":
err = fmt.Errorf("unknown arm %q", *arm)
case *mode == "cost":
res, err = runCost(*gens, *window, a)
case *mode == "fidelity":
res, err = runFidelity(*gens, *window, *control, a)
switch *mode {
case "cost":
res, err = runCost(*gens, *window)
case "fidelity":
res, err = runFidelity(*gens, *window, *control)
default:
err = fmt.Errorf("unknown mode %q", *mode)
}
@@ -65,9 +61,9 @@ func main() {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
printReport(*mode+" / "+*arm, res, reportAt(*report))
printReport(*mode, res, reportAt(*report))
if *out != "" {
b, _ := json.MarshalIndent(map[string]any{"mode": *mode, "arm": *arm, "window": *window, "generations": res}, "", " ")
b, _ := json.MarshalIndent(map[string]any{"mode": *mode, "window": *window, "generations": res}, "", " ")
if werr := os.WriteFile(*out, append(b, '\n'), 0o644); werr != nil {
fmt.Fprintln(os.Stderr, werr)
os.Exit(1)
@@ -94,13 +90,13 @@ type genResult struct {
}
type harness struct {
sess *agent.Session
sess *sessionstore.Session
agentA *agent.Agent
path string
calls *callRecorder
}
func newHarness(t *testingDir, p provider.Provider, window int, rec *callRecorder, arm arms) *harness {
func newHarness(t *testingDir, p provider.Provider, window int, rec *callRecorder) *harness {
sess := newSession()
path := filepath.Join(t.dir, "session.jsonl")
a := agent.New(p, tool.NewRegistry(), sess, agent.Options{
@@ -111,26 +107,10 @@ func newHarness(t *testingDir, p provider.Provider, window int, rec *callRecorde
// boot's default when cfg.Agent.Keep is unset; without it the bench
// would measure a configuration no real session runs.
KeepPolicy: agent.KeepErrors,
Ablation: foldArm(arm.incremental),
}, rec.sink())
return &harness{sess: sess, agentA: a, path: path, calls: rec}
}
// foldArm switches full re-derivation off, which is what makes a fold read the
// previous projection instead of the canonical transcript.
// arms selects the maintenance behaviour under test. Snipping is off by default
// so a run stays comparable with baselines recorded before it existed.
type arms struct {
incremental bool
}
func foldArm(incremental bool) ablation.Set {
if incremental {
return ablation.New(ablation.FullFold)
}
return ablation.Set{}
}
// runGeneration grows the session and folds it, returning what that fold cost.
func (h *harness) runGeneration(ctx context.Context, gen int, probes []probe) genResult {
growSession(h.sess, gen, probes)
@@ -138,7 +118,7 @@ func (h *harness) runGeneration(ctx context.Context, gen int, probes []probe) ge
h.calls.reset()
start := time.Now()
err := h.agentA.CompactNow(ctx, "")
_, err := h.agentA.CompactNow(ctx, agent.CompactRequest{})
r.Seconds = time.Since(start).Seconds()
if err != nil {
r.Error = err.Error()
@@ -148,7 +128,7 @@ func (h *harness) runGeneration(ctx context.Context, gen int, probes []probe) ge
r.SummarizerInput += c.tokens
r.LargestCall = max(r.LargestCall, c.tokens)
}
if st, ok, sterr := agent.LoadCompactionState(h.path); sterr == nil && ok {
if st, ok, sterr := sessionstore.LoadCompactionState(h.path); sterr == nil && ok {
r.ProjectionTokens = st.Projection.ProjectionTokens
if st.LastReceipt != nil && st.LastReceipt.Action == "summary" {
r.Mode = agent.CompactionModeSummarized
@@ -159,7 +139,7 @@ func (h *harness) runGeneration(ctx context.Context, gen int, probes []probe) ge
return r
}
func runCost(gens, window int, a arms) ([]genResult, error) {
func runCost(gens, window int) ([]genResult, error) {
dir, cleanup, err := tempDir()
if err != nil {
return nil, err
@@ -168,7 +148,7 @@ func runCost(gens, window int, a arms) ([]genResult, error) {
rec := &callRecorder{}
p := &scriptedProvider{rec: rec, reply: syntheticDigest, window: window}
h := newHarness(dir, p, window, rec, a)
h := newHarness(dir, p, window, rec)
var out []genResult
for gen := range gens {
@@ -177,7 +157,7 @@ func runCost(gens, window int, a arms) ([]genResult, error) {
return out, nil
}
func runFidelity(gens, window int, control bool, a arms) ([]genResult, error) {
func runFidelity(gens, window int, control bool) ([]genResult, error) {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
return nil, fmt.Errorf("fidelity mode needs DEEPSEEK_API_KEY")
@@ -193,7 +173,7 @@ func runFidelity(gens, window int, control bool, a arms) ([]genResult, error) {
defer cleanup()
rec := &callRecorder{}
h := newHarness(dir, &recordingProvider{inner: p, rec: rec}, window, rec, a)
h := newHarness(dir, &recordingProvider{inner: p, rec: rec}, window, rec)
probes := probeSuite()
ctx := context.Background()
+1 -1
View File
@@ -12,7 +12,7 @@ func TestRepeatedFoldsStayBoundedAndKeepSucceeding(t *testing.T) {
reserve = 8192 // summaryOutputReserve: the digest the call must still return
maxCalls = 7 // maxSummarySpans + the merge pass
)
res, err := runCost(gens, window, arms{})
res, err := runCost(gens, window)
if err != nil {
t.Fatalf("runCost: %v", err)
}
+17 -17
View File
@@ -2,13 +2,13 @@ package main
import (
"fmt"
"reasonix/internal/state/sessionstore"
"slices"
"strings"
"unicode"
"reasonix/internal/agent"
"reasonix/internal/provider"
"reasonix/internal/tool"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
)
// probeAnswerContract rides with every probe question. A context full of tool
@@ -36,22 +36,22 @@ func invalidAnswer(s string) bool {
type probe struct {
class string
plantAt int
plant func(*agent.Session)
plant func(*sessionstore.Session)
// later plants more of the same fact in later generations, so a decision
// that changes across folds is tested the way it actually happens: each
// revision lands on the far side of a compaction, not next to the last one.
later map[int]func(*agent.Session)
later map[int]func(*sessionstore.Session)
question string
want []string // answer must contain one of these, lowercased
reject []string // ...and none of these: the pre-correction answer
}
func userTurn(text string) func(*agent.Session) {
return func(s *agent.Session) { s.Add(provider.Message{Role: provider.RoleUser, Content: text}) }
func userTurn(text string) func(*sessionstore.Session) {
return func(s *sessionstore.Session) { s.Add(provider.Message{Role: provider.RoleUser, Content: text}) }
}
func toolRound(id, name, args, result string) func(*agent.Session) {
return func(s *agent.Session) {
func toolRound(id, name, args, result string) func(*sessionstore.Session) {
return func(s *sessionstore.Session) {
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: name, Arguments: args}}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: name, Content: result})
}
@@ -60,8 +60,8 @@ func toolRound(id, name, args, result string) func(*agent.Session) {
// failedToolRound is a tool round the host recorded as failed, the way a real
// non-zero bash run arrives. Without the execution record the keep policy sees
// only text, which is exactly the gap the buried-evidence probe measures.
func failedToolRound(id, name, args, result string, code int) func(*agent.Session) {
return func(s *agent.Session) {
func failedToolRound(id, name, args, result string, code int) func(*sessionstore.Session) {
return func(s *sessionstore.Session) {
exit := code
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: name, Arguments: args}}})
s.Add(provider.Message{
@@ -75,8 +75,8 @@ func failedToolRound(id, name, args, result string, code int) func(*agent.Sessio
}
}
func seq(fns ...func(*agent.Session)) func(*agent.Session) {
return func(s *agent.Session) {
func seq(fns ...func(*sessionstore.Session)) func(*sessionstore.Session) {
return func(s *sessionstore.Session) {
for _, fn := range fns {
fn(s)
}
@@ -240,8 +240,8 @@ func revisionProbes() []probe {
{
class: "late-constraint",
plantAt: 3,
plant: userTurn("New hard rule from here on: never edit anything under internal/store/."),
question: "Are you allowed to edit files under internal/store/? Answer yes or no.",
plant: userTurn("New hard rule from here on: never edit anything under internal/state/store/."),
question: "Are you allowed to edit files under internal/state/store/? Answer yes or no.",
want: []string{"no"},
reject: []string{"yes"},
},
@@ -249,7 +249,7 @@ func revisionProbes() []probe {
class: "reversal-chain",
plantAt: 0,
plant: userTurn("Use PostgreSQL for the datastore."),
later: map[int]func(*agent.Session){
later: map[int]func(*sessionstore.Session){
1: userTurn("Change of plan: drop PostgreSQL, use SQLite instead."),
2: userTurn("Final call: back to PostgreSQL after all."),
},
@@ -261,7 +261,7 @@ func revisionProbes() []probe {
class: "distractor-file",
plantAt: 1,
plant: userTurn("Draft note: the fix might belong in config/legacy_parser.go."),
later: map[int]func(*agent.Session){
later: map[int]func(*sessionstore.Session){
3: userTurn("Confirmed: the fix belongs in config/emitter.go; the legacy parser is not involved."),
},
question: "Which file does the fix belong in? Reply with just the filename, no path.",
+8 -8
View File
@@ -2,10 +2,10 @@ package main
import (
"fmt"
"reasonix/internal/state/sessionstore"
"strings"
"reasonix/internal/agent"
"reasonix/internal/provider"
"reasonix/internal/contract/provider"
)
// A generation is one round of work followed by one compaction. Compaction
@@ -19,7 +19,7 @@ const (
// workUnit is one read_file round: the shape that dominates a real coding
// session's token weight.
func workUnit(sess *agent.Session, gen, i int) {
func workUnit(sess *sessionstore.Session, gen, i int) {
id := fmt.Sprintf("g%02dc%02d", gen, i)
path := fmt.Sprintf("internal/pkg%02d/file%02d.go", gen, i)
sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
@@ -41,7 +41,7 @@ func fakeGoFile(gen, i, size int) string {
// growSession appends one generation of work, planting whatever probes belong
// to that generation so a fact's age is exactly the number of folds it has
// survived.
func growSession(sess *agent.Session, gen int, probes []probe) {
func growSession(sess *sessionstore.Session, gen int, probes []probe) {
for _, p := range probes {
if p.plantAt == gen {
p.plant(sess)
@@ -55,16 +55,16 @@ func growSession(sess *agent.Session, gen int, probes []probe) {
}
}
func newSession() *agent.Session {
sess := agent.NewSession("You are a terse coding agent. Answer questions from what you know; do not guess.")
func newSession() *sessionstore.Session {
sess := sessionstore.NewSession("You are a terse coding agent. Answer questions from what you know; do not guess.")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "Fix the config round-trip formatting bug in this repo."})
return sess
}
// visibleContext splices the stored projection with the canonical messages
// appended after it — the same model-visible view the agent would send.
func visibleContext(sessionPath string, sess *agent.Session) ([]provider.Message, error) {
st, ok, err := agent.LoadCompactionState(sessionPath)
func visibleContext(sessionPath string, sess *sessionstore.Session) ([]provider.Message, error) {
st, ok, err := sessionstore.LoadCompactionState(sessionPath)
if err != nil {
return nil, err
}
+25 -23
View File
@@ -1,6 +1,6 @@
// Cost-capped seed → resume → continue smoke for content-driven maintenance.
// Offline covers seed/resume; live continue needs DEEPSEEK_API_KEY and allows
// at most one summary after growing past compact_ratio=0.85.
// Seed and resume run without a provider; live continue needs DEEPSEEK_API_KEY
// and starts exactly one summary after growing past compact_ratio=0.85.
package main
import (
@@ -10,14 +10,15 @@ import (
"fmt"
"os"
"path/filepath"
"reasonix/internal/state/sessionstore"
"strings"
"time"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/provider"
_ "reasonix/internal/provider/openai"
"reasonix/internal/tool"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
_ "reasonix/internal/model/openai"
"reasonix/internal/runtime/agent"
)
const (
@@ -69,7 +70,7 @@ func (s *recordSink) Emit(e event.Event) {
func prov() (provider.Provider, *provider.Pricing) {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set (use -offline for seed/resume)")
fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set (use -offline for seed)")
os.Exit(1)
}
p, err := provider.New("openai", provider.Config{
@@ -103,7 +104,7 @@ func fatHistory(n int) []provider.Message {
)
}
func newAgent(p provider.Provider, sess *agent.Session, path string, sink event.Sink) *agent.Agent {
func newAgent(p provider.Provider, sess *sessionstore.Session, path string, sink event.Sink) *agent.Agent {
return agent.New(p, tool.NewRegistry(), sess, agent.Options{
ContextWindow: windowTokens,
CompactRatio: compactRatio,
@@ -144,7 +145,7 @@ func seed(dir string, offline bool, cap *costCap) {
}
path := filepath.Join(dir, "session.jsonl")
// ~80×32KB tool results: well under 85% of 1M for a cold estimate.
sess := &agent.Session{Messages: fatHistory(80)}
sess := &sessionstore.Session{Messages: fatHistory(80)}
sink := &recordSink{}
var p provider.Provider
if !offline {
@@ -192,20 +193,19 @@ func seed(dir string, offline bool, cap *costCap) {
m.ProjectedTokens, m.TriggerTokens, m.ProjectionVersion, m.SummaryCalls, m.SpentUSD)
}
func resume(dir string, offline bool) {
// resume reads the seeded session back and checks that loading it neither
// advances the projection version nor pays for a summary. It never samples, so
// it takes no provider and needs no API key.
func resume(dir string) {
path := filepath.Join(dir, "session.jsonl")
m := readMeta(dir)
sess, err := agent.LoadSession(path)
sess, err := sessionstore.LoadSession(path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
sink := &recordSink{}
var p provider.Provider
if !offline {
p, _ = prov()
}
a := newAgent(p, sess, path, sink)
a := newAgent(nil, sess, path, sink)
snap := a.ContextMaintenanceSnapshot()
if snap.ProjectionVersion != m.ProjectionVersion {
fmt.Fprintf(os.Stderr, "resume version = %d, want %d\n", snap.ProjectionVersion, m.ProjectionVersion)
@@ -235,7 +235,7 @@ func cont(dir string, offline bool, cap *costCap) {
}
path := filepath.Join(dir, "session.jsonl")
m := readMeta(dir)
sess, err := agent.LoadSession(path)
sess, err := sessionstore.LoadSession(path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
@@ -257,12 +257,14 @@ func cont(dir string, offline bool, cap *costCap) {
fmt.Fprintln(os.Stderr, "continue prepare:", err)
os.Exit(1)
}
if sink.summaryStarts > 1 {
fmt.Fprintf(os.Stderr, "continue started %d summaries, want ≤1\n", sink.summaryStarts)
// The fixture is already above the trigger it was grown past, so a leg that
// starts no summary has not observed the fold it exists to test.
if sink.summaryStarts != 1 {
fmt.Fprintf(os.Stderr, "continue started %d summaries, want exactly 1\n", sink.summaryStarts)
os.Exit(1)
}
after := a.ContextMaintenanceSnapshot()
if sink.summaryStarts == 1 && after.ProjectionVersion != m.ProjectionVersion+1 {
if after.ProjectionVersion != m.ProjectionVersion+1 {
fmt.Fprintf(os.Stderr, "continue version = %d, want %d after one summary\n",
after.ProjectionVersion, m.ProjectionVersion+1)
os.Exit(1)
@@ -305,14 +307,14 @@ func cont(dir string, offline bool, cap *costCap) {
func main() {
dir := flag.String("dir", "benchmarks/context-maintenance-e2e/run", "state directory")
maxUSD := flag.Float64("max-usd", defaultMaxUSD, "hard cost cap for live API legs")
offline := flag.Bool("offline", false, "skip live provider (seed/resume only)")
offline := flag.Bool("offline", false, "skip live provider (seed only)")
flag.Parse()
cap := &costCap{maxUSD: *maxUSD}
switch flag.Arg(0) {
case "seed":
seed(*dir, *offline, cap)
case "resume":
resume(*dir, *offline)
resume(*dir)
case "continue":
cont(*dir, *offline, cap)
default:
+15 -5
View File
@@ -77,11 +77,21 @@ marks the run contaminated and takes it out of every statistic.
## Index corpus v2, if this is reopened
The present index tasks are not a fit substrate: two of six carry stopping
behaviour that dominates the signal. A task should qualify only if a calibration
run shows `PostSufficientSearches == 0`, alongside the existing requirements
(successful tool call, answer in the output rather than the cue, rank 1,
`FirstHitCoverage` complete, one or two simple values, nothing in the
workspace). Keep i03 and i04 — as a stopping corpus, not an efficiency one.
behaviour that dominates the signal. A task should qualify only if a run-index
run reports a `stopping_class` other than `PostSufficientRetrieval` or
`PostSufficientRunaway`: `SnippetStop`, `SnippetThenRead` and `DefensiveRead`
all qualify, and a defensive read is deliberately not counted as waste. The
class comes from the three counts in that report — `searches_after_sufficient`,
`reads_after_sufficient` and `escapes_after_sufficient` — together with how
sufficiency was reached and how many rounds followed it. The remaining
requirements still apply — successful tool call, answer in the output rather
than the cue, rank 1, `FirstHitCoverage` complete, one or two simple values,
nothing in the workspace. Keep i03 and i04 — as a stopping corpus, not an
efficiency one.
`-mode=calibrate` is not a screening tool: it builds and reads back the
fixtures, and reads nothing else, so its cue-visibility profile is the same for
every task.
Then repeats rather than levels: cue-present against cue-absent on each task's
own boundary, three runs per cell. What is unknown is the variance, and four
+9 -8
View File
@@ -5,15 +5,16 @@ import (
"context"
"encoding/json"
"fmt"
"reasonix/internal/state/sessionstore"
"strings"
"reasonix/internal/ablation"
"reasonix/internal/agent"
"reasonix/internal/boot"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
_ "reasonix/internal/tool/builtin"
"reasonix/internal/assembly/boot"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/runtime/agent"
_ "reasonix/internal/tools/builtin"
)
// A projection is what the agent believes; a provider.Request is what the model
@@ -49,7 +50,7 @@ func cloneRequest(req provider.Request) provider.Request {
// disk, projection from its sidecar, the shipped tool surface at this arm —
// and returns the request the first round hands the provider.
func captureRequest(f builtFixture, arm ablation.Set, prompt string) (provider.Request, error) {
sess, err := agent.LoadSession(f.Path)
sess, err := sessionstore.LoadSession(f.Path)
if err != nil {
return provider.Request{}, fmt.Errorf("load session: %w", err)
}
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"strconv"
"strings"
"reasonix/internal/provider"
"reasonix/internal/tokencount"
"reasonix/internal/base/tokencount"
"reasonix/internal/contract/provider"
)
// Every judgement here reads a tool call's name and its JSON arguments, or a
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
"reasonix/internal/provider"
"reasonix/internal/contract/provider"
)
// The scorer decides what the pilot concludes, so it is held to the same rule
+3 -3
View File
@@ -3,9 +3,9 @@ package main
import (
"fmt"
"math/rand"
"reasonix/internal/state/sessionstore"
"reasonix/internal/agent"
"reasonix/internal/provider"
"reasonix/internal/contract/provider"
)
// Synthetic history on purpose: an answer that exists in the workspace is
@@ -102,7 +102,7 @@ func instantiateTask(t contextTask, rng *rand.Rand) (fixtureInstance, error) {
}
// plant appends the instantiated target and returns its canonical position.
func (f fixtureInstance) plant(s *agent.Session) int {
func (f fixtureInstance) plant(s *sessionstore.Session) int {
at := len(s.Messages)
if f.Task.Plant == plantAssistant {
s.Add(provider.Message{Role: provider.RoleAssistant, Content: f.body})
+12 -11
View File
@@ -6,13 +6,14 @@ import (
"io/fs"
"os"
"path/filepath"
"reasonix/internal/state/sessionstore"
"strings"
"reasonix/internal/ablation"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/runtime/agent"
)
// A fixture is a real session that has really been compacted: no bench-only
@@ -43,7 +44,7 @@ func (p *digestProvider) Stream(_ context.Context, _ provider.Request) (<-chan p
// fillerCall is one ordinary read whose only job is to take up room — in the
// window, and in the fold index ahead of a cue.
func fillerCall(sess *agent.Session, n int) {
func fillerCall(sess *sessionstore.Session, n int) {
id := fmt.Sprintf("f%04d", n)
sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":"internal/mod%03d/handler%03d.go"}`, n%37, n)},
@@ -63,8 +64,8 @@ func fillerBody(n int) string {
// builtFixture is one task's history after real folds, plus where its target
// ended up.
type builtFixture struct {
Session *agent.Session
State agent.CompactionState
Session *sessionstore.Session
State sessionstore.CompactionState
Target int
Path string
Instance fixtureInstance
@@ -75,7 +76,7 @@ type builtFixture struct {
// itself would.
func buildFixture(f fixtureInstance, arm ablation.Set, sessionPath string) (builtFixture, error) {
t := f.Task
sess := agent.NewSession("You are a coding agent.")
sess := sessionstore.NewSession("You are a coding agent.")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "Work through the transport and scheduler backlog."})
n := 0
@@ -94,7 +95,7 @@ func buildFixture(f fixtureInstance, arm ablation.Set, sessionPath string) (buil
fillerCall(sess, n)
n++
}
if err := a.CompactNow(context.Background(), ""); err != nil {
if _, err := a.CompactNow(context.Background(), agent.CompactRequest{}); err != nil {
return builtFixture{}, fmt.Errorf("%s: generation %d fold: %w", t.ID, gen, err)
}
}
@@ -108,7 +109,7 @@ func buildFixture(f fixtureInstance, arm ablation.Set, sessionPath string) (buil
if err := sess.Save(sessionPath); err != nil {
return builtFixture{}, fmt.Errorf("%s: save session: %w", t.ID, err)
}
state, ok, err := agent.LoadCompactionState(sessionPath)
state, ok, err := sessionstore.LoadCompactionState(sessionPath)
if err != nil {
return builtFixture{}, fmt.Errorf("%s: load context state: %w", t.ID, err)
}
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"slices"
"strings"
"reasonix/internal/ablation"
"reasonix/internal/provider"
"reasonix/internal/retrieval"
"reasonix/internal/base/retrieval"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/provider"
)
// Preflight is what makes a real run worth paying for. An answer the model can
@@ -7,7 +7,7 @@ import (
"sort"
"strings"
"reasonix/internal/retrieval"
"reasonix/internal/base/retrieval"
)
// The host probe ranks every target first, so the engine is not what makes a
+10 -9
View File
@@ -6,19 +6,20 @@ import (
"os"
"os/exec"
"path/filepath"
"reasonix/internal/state/sessionstore"
"slices"
"sort"
"strings"
"time"
"reasonix/internal/ablation"
"reasonix/internal/agent"
"reasonix/internal/boot"
"reasonix/internal/event"
"reasonix/internal/provider"
_ "reasonix/internal/provider/openai"
"reasonix/internal/tool"
_ "reasonix/internal/tool/builtin"
"reasonix/internal/assembly/boot"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
_ "reasonix/internal/model/openai"
"reasonix/internal/runtime/agent"
_ "reasonix/internal/tools/builtin"
)
// A run is one recall question against a fixture the preflight has already
@@ -121,7 +122,7 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
if err != nil {
return contextMetrics{}, err
}
sess, err := agent.LoadSession(f.Path)
sess, err := sessionstore.LoadSession(f.Path)
if err != nil {
return contextMetrics{}, fmt.Errorf("load session: %w", err)
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"strconv"
"strings"
"reasonix/internal/provider"
"reasonix/internal/contract/provider"
)
// scriptedProvider walks the chain a competent model would: search, read the
+5 -5
View File
@@ -8,11 +8,11 @@ import (
"path/filepath"
"strings"
"reasonix/internal/ablation"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/retrieval"
"reasonix/internal/tool"
"reasonix/internal/base/retrieval"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/event"
"reasonix/internal/contract/tool"
"reasonix/internal/runtime/agent"
)
// TargetHit is not EvidenceSufficient: a rank-1 hit can still hand over a
@@ -1,3 +1,3 @@
#!/usr/bin/env bash
set -e
grep -q "pnpm install" answer.txt && ! grep -q "npm install" answer.txt
grep -q "pnpm install" answer.txt && ! grep -qE '(^|[^p])npm install' answer.txt
+14 -11
View File
@@ -11,7 +11,7 @@ to this gate and no amount of running it accumulates evidence about it.
Two different claims need two different kinds of evidence.
That the legacy derivation can be bypassed is already proven deterministically
by `internal/agent/project_check_probe_test.go`: a delivery turn that rewrites
by `internal/runtime/agent/project_check_probe_test.go`: a delivery turn that rewrites
its own `- verify:` line and runs only the replacement finalizes, never having
run the criterion the task began under. That test needs no corpus.
@@ -76,7 +76,7 @@ So the chain this corpus set out to pin breaks at its first link. The restored
baseline was not A, and every claim after that fails with it.
One path is left and is not tested here. `DeliveryCheckpoint` carries
`BaselineChecks` and is persisted with goal state (`internal/control/goal.go`,
`BaselineChecks` and is persisted with goal state (`internal/session/control/goal.go`,
json-tagged); `RestoreDeliveryCheckpoint` seeds a rebuilt executor, and the turn
loop only clears the checkpoint when the scope id changes. A Goal that survives
a restart across a changed declaration would therefore keep the old baseline
@@ -87,14 +87,17 @@ controller-level test rather than a corpus.
Until it is run, the bypass is a conditional mechanism with no demonstrated
production path, and the cutover below has no defect to justify it.
## The cutover this is evidence for
## What the gate enforces now
The gate keeps enforcement until this corpus shows, across runs:
The Goal resume path was shown reachable by
`internal/session/control/goal_resume_project_check_test.go`, and the trust-domain
rule in `REASONIX.md` settles its authority question: the workspace cannot
supersede a captured criterion. The gate therefore owes every baseline criterion
the current declaration no longer names, inside the delivery scope that
captured it, anchored where the gate already anchors. The rest of the obligation
derivation is not switched in: a resumed process starts from an empty ledger, and
the obligations would owe nothing there.
- the known bypass reproduces under the legacy path while the obligation path
preserves the baseline;
- no unexplained `candidate_only` and no unexplained `legacy_only`;
- the only divergences are `baseline_preservation`, `identity_normalization`,
and a `mutation_index` difference the two after-indices explain;
- and, once every baseline and current obligation is genuinely satisfied, the
candidate permits finalization — a bypass fixed into a deadlock is not fixed.
The probe still runs. The full cutover it is evidence for needs, across runs,
no unexplained `candidate_only` or `legacy_only`, and a candidate that permits
finalization once every baseline and current obligation is satisfied.
File diff suppressed because it is too large. Load diff
+452
View File
@@ -0,0 +1,452 @@
package main
import (
"context"
"fmt"
"os"
"sort"
"strings"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/session/control"
"reasonix/internal/state/execjournal"
)
// armFleetActiveStore asks whether the sub-agent store can be asked about a
// fan-out item whose slot was granted and whose child reached the provider. It
// is not a parity arm: the store is what every recovery path reads, so what is
// measured is one store answering differently about executions it holds the
// same evidence for.
const armFleetActiveStore = "fleet-active-store"
// activeFleetCallID is the fan-out under measurement. The lone delegation in
// the same turn keeps taskCallID, so the two entry points stay separable in
// every artifact without a naming scheme of their own.
const activeFleetCallID = "probe_fleet_active"
// probeFleetChildEntered is the fan-out's half of the entry evidence. The lone
// delegation's rides probeChildEntered, which already means exactly this for
// the delegation under measurement.
const probeFleetChildEntered = "probe:fleet-child-entered"
// The four populations one death holds. They are read off the journal rather
// than off the item ids the dispatch declared: which of the two held-back items
// wins the freed slot is the scheduler's to decide, and an arm that named them
// in advance would be reporting a race as a premise.
type population string
const (
popSettled population = "fan-out settled"
popRunning population = "fan-out executing"
popRefused population = "fan-out refused"
popLone population = "lone executing"
popNone population = ""
)
// measured are the four in the order they are reported, which is also the order
// they become true: an item settles, the slot it frees admits one of the two
// behind it, and the delegation holding the other slot outlives all of them.
func measured() []population { return []population{popSettled, popRunning, popRefused, popLone} }
// populationOf places one journal entry in its population, or in none. Started
// and open is executing; started and settled is done; queued without a start is an
// item the scheduler held back and never admitted.
func populationOf(e execjournal.Entry) population {
switch {
case ofLoneTask(e.ID):
if e.Started() && e.Open() {
return popLone
}
case ofActiveFleet(e.ID):
switch {
case e.Started() && !e.Open():
return popSettled
case e.Started() && e.Open():
return popRunning
case !e.Started() && e.Queued():
return popRefused
}
}
return popNone
}
func ofActiveFleet(id string) bool { return strings.HasPrefix(id, activeFleetCallID+"/") }
// executionsIn are the ids this observation's journal places in a population,
// in a stable order.
func executionsIn(o Observation, pop population) []string {
var out []string
for _, e := range o.Executions {
if populationOf(e) == pop {
out = append(out, e.ID)
}
}
sort.Strings(out)
return out
}
// storedStatus is what the store says about one execution, empty when it has
// never heard of it. The join is the execution id the child recorded as its
// parent call, which is the same identity the journal opened it under.
func storedStatus(o Observation, id string) string {
for _, f := range o.Children.Facts {
if f.ParentToolCallID == id {
return f.Status
}
}
return ""
}
// noRecord is what the store says about an execution it never recorded. It is
// spelled out rather than left blank because a blank column reads as a row that
// was not measured, which is the opposite of what an absent record means here.
const noRecord = "no record"
func storeReading(o Observation, ids []string) string {
out := make([]string, 0, len(ids))
for _, id := range ids {
status := storedStatus(o, id)
if status == "" {
status = noRecord
}
out = append(out, id+"→"+status)
}
return orNone(join(out))
}
// acknowledged reports whether the store holds a record for every one of these
// executions. An empty set is not acknowledgement: a population the death never
// produced is a premise that failed, and its row says not-measured.
func acknowledged(o Observation, ids []string) bool {
for _, id := range ids {
if storedStatus(o, id) == "" {
return false
}
}
return len(ids) > 0
}
func activeStoreArm(name string) bool { return name == armFleetActiveStore }
// activeStoreRows read one store from four sides. Each population has the same
// two columns — what the dying process's store said, and what the next one's
// does — so the answers can only differ by which execution is being asked about.
func activeStoreRows(before, after Observation) []row {
return []row{
populationsRow(before),
childEntryRow(before),
settledStoreRow(before, after),
executingStoreRow(before, after),
loneStoreRow(before, after),
refusedStoreRow(before, after),
listingRow(before, after),
rebuildUnmovedRow(before, after),
}
}
// populationsRow is the arm's premise, not a durability claim: one death has to
// hold all four states at once, or the comparisons below are between different
// runs rather than between entry points.
func populationsRow(before Observation) row {
var got []string
for _, pop := range measured() {
got = append(got, fmt.Sprintf("%s=%d", pop, len(executionsIn(before, pop))))
}
return row{
Semantic: "the four populations this death holds", Authority: journalAuthority,
Artifact: "<stem>.execution.jsonl", Reconstruction: "in-process only",
Before: join(got), After: "—", Verdict: activePremiseVerdict(before),
}
}
func activePremiseVerdict(before Observation) string {
if activeStoreInvalid(before) != "" {
return verdictNotMeasured
}
return verdictHolds
}
// childEntryRow is the half of the evidence no artifact carries. A slot grant
// says the scheduler admitted the item; only the child's own arrival at the
// provider says the execution happened, and a store row read without it would
// be asking about work that may never have run.
func childEntryRow(before Observation) row {
fleet := firstProgress(before, probeFleetChildEntered)
lone := firstProgress(before, probeChildEntered)
return row{
Semantic: "the children that reached the provider", Authority: "the scripted provider, in-process",
Artifact: "none observed", Reconstruction: "in-process only",
Before: fmt.Sprintf("fan-out=%s lone=%s", fleet, lone),
After: "—",
Verdict: enteredVerdict(fleet == "true" && lone == "true"),
}
}
func enteredVerdict(both bool) string {
if both {
return verdictHolds
}
return verdictNotMeasured
}
func firstProgress(o Observation, key string) string {
if vals := o.Progress[key]; len(vals) > 0 {
return vals[0]
}
return "unrecorded"
}
// settledStoreRow is the control that makes the rest readable. It is the same
// entry point, the same fan-out and the same store, differing only in that this
// item finished — so if it too were absent, the finding would be about
// persistence in general rather than about executions in flight.
func settledStoreRow(before, after Observation) row {
ids := executionsIn(before, popSettled)
return row{
Semantic: "a settled fan-out item, in the store", Authority: storeAuthority,
Artifact: "subagents/<ref>.meta.json", Reconstruction: "ListSubagentsByParent, joined by execution id",
Before: storeReading(before, ids),
After: storeReading(after, ids),
Verdict: controlVerdict(acknowledged(before, ids) && acknowledged(after, ids), len(ids) > 0),
}
}
// executingStoreRow is the arm. The conjunction is stated in the row rather
// than left to the reader: the journal proves the slot was granted, the child
// proves the execution happened, and this column is what the store can say
// about it. All three, or the row is not the finding it looks like.
func executingStoreRow(before, after Observation) row {
ids := executionsIn(before, popRunning)
entered := firstProgress(before, probeFleetChildEntered) == "true"
return row{
Semantic: "an executing fan-out item, in the store", Authority: storeAuthority,
Artifact: "subagents/<ref>.meta.json",
Reconstruction: "STARTED in the journal, entered at the provider, and asked of the store",
Before: storeReading(before, ids),
After: storeReading(after, ids),
Verdict: executingVerdict(before, after, ids, entered),
}
}
// executingVerdict refuses the two readings that would overstate the row. An
// execution nothing proves entered is not a silent store, and a population the
// death never produced is not evidence of anything.
func executingVerdict(before, after Observation, ids []string, entered bool) string {
switch {
case len(ids) == 0 || !entered:
return verdictNotMeasured
case acknowledged(before, ids) || acknowledged(after, ids):
return verdictHolds
default:
return verdictViolated
}
}
// loneStoreRow is the other entry point, in the same death and the same store.
// It is what makes "selectively blind" a statement about the store rather than
// a comparison between two probe runs.
func loneStoreRow(before, after Observation) row {
ids := executionsIn(before, popLone)
return row{
Semantic: "an executing lone delegation, in the store", Authority: storeAuthority,
Artifact: "subagents/<ref>.meta.json", Reconstruction: "the same read, one entry point over",
Before: storeReading(before, ids),
After: storeReading(after, ids),
Verdict: controlVerdict(acknowledged(before, ids) && acknowledged(after, ids), len(ids) > 0),
}
}
// refusedStoreRow is the negative control, and it bounds any fix this arm might
// support. An item the scheduler never admitted produced nothing, so the store
// is owed no record for it — and a change that recorded every opening would
// satisfy the row above by reintroducing exactly that.
func refusedStoreRow(before, after Observation) row {
ids := executionsIn(before, popRefused)
silent := !acknowledged(before, ids) && !acknowledged(after, ids)
return row{
Semantic: "a refused fan-out item, in the store", Authority: crossAuthority,
Artifact: "subagents/<ref>.meta.json, <stem>.execution.jsonl",
Reconstruction: "queued, never started: the store must stay silent",
Before: storeReading(before, ids),
After: storeReading(after, ids),
Verdict: controlVerdict(silent, len(ids) > 0),
}
}
func controlVerdict(ok bool, measured bool) string {
switch {
case !measured:
return verdictNotMeasured
case ok:
return verdictHolds
default:
return verdictViolated
}
}
// listingRow is the surface a person or a model reaches for. It reads through
// the workspace filter and the lineage walk the tool applies, which the store
// read above does not — so it is checked against that read first: a listing
// that cannot even see the settled item is a filter answering, not a store.
func listingRow(before, after Observation) row {
settled := executionsIn(before, popSettled)
return row{
Semantic: "what list_subagents would show", Authority: "SubagentStore.ListForParent",
Artifact: "subagents/<ref>.meta.json",
Reconstruction: "the read behind the tool, filtered by workspace and lineage",
Before: listingReading(before),
After: listingReading(after),
Verdict: listingVerdict(before, after, settled),
}
}
func listingReading(o Observation) string {
if o.Listing.Err != "" {
return "error: " + o.Listing.Err
}
out := make([]string, 0, len(o.Listing.Rows))
for _, r := range o.Listing.Rows {
out = append(out, orDash(r.From)+"="+r.Status)
}
sort.Strings(out)
return orNone(join(out))
}
// listingVerdict answers only once the listing has been shown to work. Its
// filters can drop every row for reasons that have nothing to do with this arm,
// and an empty listing read as an invisible execution is the substitution the
// probe exists to refuse.
func listingVerdict(before, after Observation, settled []string) string {
if len(settled) == 0 || !listingHas(after, settled) {
return verdictNotMeasured
}
if listingHas(before, executionsIn(before, popRunning)) {
return verdictHolds
}
return verdictViolated
}
func listingHas(o Observation, ids []string) bool {
for _, id := range ids {
found := false
for _, r := range o.Listing.Rows {
if r.From == id {
found = true
break
}
}
if !found {
return false
}
}
return len(ids) > 0
}
// rebuildUnmovedRow pins what a fix must not move. A store that learned to
// record executions in flight would leave stale records behind for a restart to
// fold, and the one outcome this whole line of work exists to prevent is a
// rebuild that answers with a node the dead process was running.
func rebuildUnmovedRow(before, after Observation) row {
rebuilt := rebuiltGraph(after)
var ghosts []string
for _, n := range rebuilt.Graph.Nodes {
if n.State == agentgraph.StateRunning {
ghosts = append(ghosts, n.ID)
}
}
var cut []string
for _, i := range rebuilt.Interrupted {
if ofActiveFleet(i.Execution) || ofLoneTask(i.Execution) {
cut = append(cut, i.Execution+":"+interruptionKind(i))
}
}
sort.Strings(ghosts)
sort.Strings(cut)
return row{
Semantic: "no ghost, and how the rebuild names the cut", Authority: rebuildAuthority,
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
Reconstruction: "execgraph.Rebuild over both, after the death",
Before: fmt.Sprintf("%d executing at death", len(executionsIn(before, popRunning))+len(executionsIn(before, popLone))),
After: fmt.Sprintf("ghosts=%s cut=%s", orNone(join(ghosts)), orNone(join(cut))),
Verdict: controlVerdict(len(ghosts) == 0 && len(cut) > 0, len(before.Executions) > 0),
}
}
// activeStoreInvalid reports the premise this arm could not establish. All four
// populations in one death is the whole design: without them the rows compare
// different runs, and a row that compares nothing must not be read as green.
func activeStoreInvalid(before Observation) string {
for _, pop := range measured() {
if len(executionsIn(before, pop)) == 0 {
return "the death held nothing in population " + string(pop)
}
}
if firstProgress(before, probeFleetChildEntered) != "true" {
return "no fan-out child reached the provider, so no execution is proven to have happened"
}
return ""
}
// runActiveStoreConstruct drives one delegation and one fan-out under a ceiling
// they share, and dies with all four populations standing. The delegation goes
// first and hangs: it is what keeps the ceiling from emptying under the fan-out,
// so the item refused admission stays refused rather than starting a moment
// later and taking the arm's negative control with it.
func runActiveStoreConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
prompt := fanOutTurn(turn+1, taskSentinel+" "+fleetActiveSentinel)
go func() { _ = ctrl.Run(ctx, prompt) }()
if err := waitForPopulations(ctrl, prov); err != nil {
return err
}
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
// Whether each child reached the provider is known only here, and it is half
// of what makes a silent store a finding rather than a missing record for
// work that never ran.
obs.Progress[probeFleetChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(childHang)))}
obs.Progress[probeChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(childHold)))}
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
// waitForPopulations blocks until the journal holds all four states, then holds
// still and asks again. A refusal is a state nothing announces, so the second
// reading is what separates an item the scheduler is holding back from one that
// was merely slow to be admitted.
func waitForPopulations(ctrl *control.Controller, prov *scripted) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
if populationsStanding(ctrl, prov) {
time.Sleep(2 * time.Second)
if !populationsStanding(ctrl, prov) {
return errUnexpected("a refusal that was still standing",
journalStates(control.ExecutionHistory(ctrl.SessionPath())))
}
return nil
}
time.Sleep(50 * time.Millisecond)
}
return errUnexpected("one death holding all four populations",
journalStates(control.ExecutionHistory(ctrl.SessionPath())))
}
func populationsStanding(ctrl *control.Controller, prov *scripted) bool {
held := map[population]bool{}
for _, e := range control.ExecutionHistory(ctrl.SessionPath()) {
held[populationOf(e)] = true
}
return held[popSettled] && held[popRunning] && held[popRefused] && held[popLone] &&
prov.fleets.held(childEntered(childHang)) && prov.fleets.held(childEntered(childHold))
}
func journalStates(entries []execjournal.Entry) string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, fmt.Sprintf("%s:started=%t,queued=%t,open=%t", e.ID, e.Started(), e.Queued(), e.Open()))
}
sort.Strings(out)
return orNone(join(out))
}
+345
View File
@@ -0,0 +1,345 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"reasonix/internal/state/sessionstore"
"strings"
"reasonix/internal/contract/config"
"reasonix/internal/contract/provider"
"reasonix/internal/state/memory"
)
// arm is one death-and-resume run. Lever runs between the two processes and is
// what separates the three compaction questions: nothing changed, only the
// stable prefix changed, the covered conversation changed.
type arm struct {
name string
asks string
lever func(armRoot, Observation) error
}
// appendsAfterFold names the arms whose construct phase adds one turn after
// the fold. Every arm carrying a lever needs it: without it the projection is
// already gone at the boundary for an unrelated reason, and the lever's own
// effect cannot be read out of the result.
func appendsAfterFold(name string) bool {
return name != "exact" && name != armRefoldIntoBody && !graphArm(name) && !uiArm(name) &&
!schedulerWaitArm(name) && !terminalArm(name) && !deriveArm(name) &&
!loneTaskArm(name) && !cancelRoutingArm(name) && !lineageArm(name) && !activeStoreArm(name) &&
!skillArm(name) && !ephemeralArm(name) && !hostExecArm(name)
}
// armAppendAfterFold separates the two ways a projection can fail validation
// after a restart: a covered prefix that no longer matches, and a projection
// that covers the whole transcript, whose only cross-process validation is a
// transcript version the reload does not carry.
const armAppendAfterFold = "append-after-fold"
// armRefoldIntoBody folds twice. The second fold reads the provider-visible
// view — stored body plus live tail — and must record a boundary in canonical
// terms, which the body has no counterpart for.
const armRefoldIntoBody = "refold-into-body"
// The tail arms separate two ways the live tail can move. tail-edit and
// tail-truncate change the transcript past the fold, where the covered hash
// cannot notice; tail-rewind takes the path a person actually drives, which
// throws the projection away without asking whether coverage still holds.
const (
armTailEdit = "tail-edit"
armTailTruncate = "tail-truncate"
armTailRewind = "tail-rewind"
// armCoveredRewind is the negative control the rewind path will need before
// its invalidation can become conditional: rewinding below the fold removes
// history the digest folded, and no coverage check may excuse that.
armCoveredRewind = "covered-rewind"
// armTodoIdentity moves host step identity while a fold is between the
// model and it: identity A, fold, live tail, identity B, fold again.
armTodoIdentity = "todo-identity"
// armOpenDecision dies while a question is open. It asks what the host
// believes afterwards, not whether one particular card comes back.
armOpenDecision = "open-decision"
// The three successors to an interrupted barrier. Each starts where
// open-decision ends and differs only in what the next process does, which
// is the whole question: when does a recorded interruption stop being the
// context every later request carries?
armIdleRestart = "interrupted-idle"
armUnrelatedTurn = "interrupted-unrelated"
armDirectAnswer = "interrupted-answered"
)
// successorTurn is what the middle process says, empty when it only restarts.
func successorTurn(arm string) (string, bool) {
switch arm {
case armIdleRestart:
return "", true
case armUnrelatedTurn:
return marker(120) + " List the files you would look at first.", true
case armDirectAnswer:
return marker(121) + " Yes, go ahead with that — take the side below the fold.", true
}
return "", false
}
func arms() []arm {
return []arm{
{name: "exact", asks: "nothing changed between the processes"},
{name: armAppendAfterFold, asks: "nothing changed, and one turn was appended after the fold"},
{name: armRefoldIntoBody, asks: "a second fold reaches into the body the first one stored"},
{name: "system-swap", asks: "only the stable prefix changed, against the surviving baseline", lever: swapSystemPrefix},
{name: armTailEdit, asks: "a message past the fold changed", lever: editTailRow},
{name: armTailTruncate, asks: "the messages past the fold were dropped", lever: truncateTail},
{name: armTailRewind, asks: "the same truncation, driven through the rewind a person uses"},
{name: armCoveredRewind, asks: "a rewind that lands below the fold boundary"},
{name: armTodoIdentity, asks: "host step identity moved across two folds and a live tail"},
{name: armOpenDecision, asks: "the process died while a decision was open"},
{name: armIdleRestart, asks: "an interrupted barrier, and nobody does anything"},
{name: armUnrelatedTurn, asks: "an interrupted barrier, then a turn about something else"},
{name: armDirectAnswer, asks: "an interrupted barrier, then a turn that reads like an answer to it"},
{name: armGraphCompleted, asks: "a fan-out whose items had all settled when the process died"},
{name: armGraphRunning, asks: "a fan-out with a child still executing when the process died"},
{name: armGraphMixed, asks: "a fan-out holding completed, failed, adopted and running at once"},
{name: armUIGraphMixed, asks: "which door each fact reaches the frontend through, after a death"},
{name: armTaskCompleted, asks: "a lone delegation that finished, in a turn that closed"},
{name: armTaskRunning, asks: "a lone delegation still executing when the process died"},
{name: armTaskFgQueued, asks: "what a lone delegation is drawn as while the scheduler holds it back"},
{name: armTaskBgQueued, asks: "a backgrounded delegation the ceiling refused, with its job already handed over"},
{name: armTaskBgRunning, asks: "a backgrounded delegation executing inside its job"},
{name: armCancelTool, asks: "whether a stop reaches an ordinary tool the turn is waiting on"},
{name: armCancelBackground, asks: "whether a stop leaves work already handed to a job alone"},
{name: armCancelHeadlessOwner, asks: "who owns the cancellation of a turn the controller never admitted"},
{name: armLineageForeground, asks: "control: does a stop reach the tree the turn owns?"},
{name: armLineageBackground, asks: "does it also reach the tree it handed away?"},
{name: armLineageJobKill, asks: "control: does a job's own stop reach its tree?"},
{name: armTaskFgQueuedCancel, asks: "a delegation stopped while the scheduler still held it back"},
{name: armTaskBgQueuedCancel, asks: "the same, backgrounded: a job killed before it was ever admitted"},
{name: armTaskFgRunningCancel, asks: "a delegation stopped while its child was executing"},
{name: armTaskBgRunningCancel, asks: "the same, backgrounded: a job killed while its child ran"},
{name: armWaitSlots, asks: "an item the session's total ceiling refused"},
{name: armWaitWriters, asks: "an item the writer ceiling refused, with total capacity free"},
{name: armWaitClaim, asks: "an item a path conflict refused, with both ceilings free"},
{name: armWaitTransition, asks: "what a reported wait cause does when the thing it named stops holding the item"},
{name: armTerminalAdopted, asks: "an item that never ran and still holds an answer"},
{name: armTerminalSkippedDep, asks: "an item that never ran and holds none, because its dependency failed"},
{name: armTerminalCancelled, asks: "an item a cancellation ended before it was admitted"},
{name: armTerminalContext, asks: "whether a delivered answer's edge follows from what is already durable"},
{name: armChildTerminal, asks: "whether the store keeps every terminal the graph draws"},
{name: armDeriveSkipBoth, asks: "a skip whose two upstreams both ended without an answer"},
{name: armDeriveSkipFlip, asks: "the same, with the two failures in the other order"},
{name: armIdentitySemantics, asks: "what a node's model and effort report, across two producers and the store"},
{name: armDeriveAnswered, asks: "a dependent whose upstreams answered, one by completing and one by adopting"},
{name: armFleetActiveStore, asks: "whether the store can be asked about a fan-out item that is executing"},
{name: armSkillQueued, asks: "whether a subagent skill asks the same scheduler as every other delegation"},
{name: armSkillRunning, asks: "what names a subagent skill while its child is executing"},
{name: armSkillCompleted, asks: "what a finished subagent skill leaves, through both entry points"},
{name: armSkillCancel, asks: "what a stop reaches when a subagent skill is running"},
{name: armReadOnlySkillQueued, asks: "whether the read-only skill entry point asks the same scheduler"},
{name: armReadOnlySkillRunning, asks: "what names a read-only skill while its child is executing"},
{name: armReadOnlySkillCompleted, asks: "what a finished read-only skill leaves behind, if anything"},
{name: armEphemeralTask, asks: "can the execution authority account for what read_only_task draws?"},
{name: armEphemeralSkill, asks: "the same of read_only_skill, the other ephemeral entry point"},
{name: armHostRunning, asks: "what a slash-started subagent leaves when the process dies mid-execution"},
{name: armHostQueued, asks: "what names slash-started work the scheduler is holding back"},
{name: armHostCompleted, asks: "what a finished slash invocation leaves besides its answer"},
{name: armHostCancel, asks: "what a stop reaches, and what history it leaves"},
{name: armHeadlessCompleted, asks: "does the headless host surface leave the same provenance?"},
{name: armHeadlessRunningCancel, asks: "the same, stopped through the context its caller holds"},
{name: armHeadlessQueuedCancel, asks: "the same while the scheduler is still holding it back"},
{name: "covered-mutation", asks: "the covered conversation changed, against the surviving baseline", lever: mutateCoveredRow},
}
}
// swapSystemPrefix saves a memory fact into this arm's home. The lever is a
// real user action rather than a poked field: the saved-fact index folds into
// the boot-time system prompt, so the next process composes a different one.
func swapSystemPrefix(root armRoot, _ Observation) error {
store := memory.StoreFor(config.RootsForHome(root.Home).MemoryUserDir(), root.Workspace)
_, err := store.Save(memory.Memory{
Name: "probe-prefix-lever",
Title: "Probe prefix lever",
Description: "A fact saved between the two processes so the composed system prompt differs.",
Type: memory.TypeProject,
Scope: memory.FactScopeProject,
// Pinned, not relevant: only a pinned body rides the stable prefix. The
// saved-fact index is retrieval-only and would move nothing.
Activation: memory.ActivationPinned,
Body: "The runtime-resume probe wrote this to move the stable prefix.",
})
return err
}
// mutateCoveredRow rewrites one provider-visible message inside the prefix the
// projection folded, through the same rewrite save a rewind or a recovery
// branch uses. This is the control arm: the digest's material changed, so the
// projection must not be reused.
func mutateCoveredRow(_ armRoot, before Observation) error {
session, err := sessionstore.LoadSession(before.SessionPath)
if err != nil {
return err
}
msgs := session.Snapshot()
covered := min(before.Sidecar.CoveredCount, len(msgs))
target := -1
for i := range covered {
if msgs[i].Role == provider.RoleUser || msgs[i].Role == provider.RoleAssistant {
target = i
break
}
}
if target < 0 {
return errUnexpected("a covered conversation row to mutate", covered)
}
msgs[target].Content += "\n[probe: covered row mutated]"
session.Replace(msgs)
return session.SaveRewrite(before.SessionPath)
}
// editTailRow rewrites one message the projection does not claim to cover. The
// covered hash cannot see it, so the projection should keep serving.
func editTailRow(_ armRoot, before Observation) error {
return rewriteSession(before, func(msgs []provider.Message, covered int) ([]provider.Message, error) {
for i := covered; i < len(msgs); i++ {
if msgs[i].Role == provider.RoleUser || msgs[i].Role == provider.RoleAssistant {
msgs[i].Content += "\n[probe: tail row edited]"
return msgs, nil
}
}
return nil, errUnexpected("a live tail row to edit", covered)
})
}
// truncateTail drops everything past the fold boundary, leaving the covered
// prefix exactly as the projection folded it.
func truncateTail(_ armRoot, before Observation) error {
return rewriteSession(before, func(msgs []provider.Message, covered int) ([]provider.Message, error) {
return msgs[:covered], nil
})
}
// rewriteSession applies a transcript rewrite through the save a rewind or a
// recovery branch uses, refusing an arm with no live tail to act on.
func rewriteSession(before Observation, edit func([]provider.Message, int) ([]provider.Message, error)) error {
session, err := sessionstore.LoadSession(before.SessionPath)
if err != nil {
return err
}
msgs := session.Snapshot()
covered := before.Sidecar.CoveredCount
if covered <= 0 || covered >= len(msgs) {
return errUnexpected("a live tail past the fold", covered)
}
next, err := edit(msgs, covered)
if err != nil {
return err
}
session.Replace(next)
return session.SaveRewrite(before.SessionPath)
}
func orchestrate(work, only, jsonOut string, keep bool) error {
self, err := os.Executable()
if err != nil {
return err
}
if work == "" {
work, err = os.MkdirTemp("", "runtime-resume-")
if err != nil {
return err
}
if !keep {
defer os.RemoveAll(work)
}
}
var results []armResult
for _, a := range arms() {
if only != "" && a.name != only {
continue
}
result, err := runArm(self, work, a)
if err != nil {
return fmt.Errorf("arm %s: %w", a.name, err)
}
results = append(results, result)
}
if len(results) == 0 {
return fmt.Errorf("no arm matched %q", only)
}
fmt.Print(renderMatrix(results, work))
return writeJSON(jsonOut, results)
}
func runArm(self, work string, a arm) (armResult, error) {
root := rootFor(filepath.Join(work, a.name))
if err := root.requireFresh(); err != nil {
return armResult{}, err
}
if err := root.create(); err != nil {
return armResult{}, err
}
if err := spawn(self, "construct", root, a.name); err != nil {
return armResult{}, fmt.Errorf("construct process: %w", err)
}
before, err := readObservation(root, "construct")
if err != nil {
return armResult{}, err
}
if a.lever != nil {
if err := a.lever(root, before); err != nil {
return armResult{}, fmt.Errorf("lever: %w", err)
}
}
if _, ok := successorTurn(a.name); ok {
if err := spawn(self, "successor", root, a.name); err != nil {
return armResult{}, fmt.Errorf("successor process: %w", err)
}
}
if err := spawn(self, "resume", root, a.name); err != nil {
return armResult{}, fmt.Errorf("resume process: %w", err)
}
after, err := readObservation(root, "resume")
if err != nil {
return armResult{}, err
}
var extra *Observation
if _, ok := successorTurn(a.name); ok {
obs, err := readObservation(root, "successor")
if err != nil {
return armResult{}, err
}
extra = &obs
} else if phase := extraPhase(a.name); phase != "" {
obs, err := readObservation(root, phase)
if err != nil {
return armResult{}, err
}
extra = &obs
}
return classify(a, extra, before, after), nil
}
// spawn runs one phase as a real child process and waits for it to exit. The
// OS process is the measurement boundary: a nil-ed object can still be revived
// by a package-level singleton, a live sink, or a cache, and none of those
// survive here.
func spawn(self, phase string, root armRoot, armName string) error {
cmd := exec.Command(self, "-phase="+phase, "-root="+root.Dir, "-arm="+armName)
cmd.Env = childEnv(root)
cmd.Dir = root.Workspace
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
return cmd.Run()
}
func writeJSON(path string, results []armResult) error {
if strings.TrimSpace(path) == "" {
return nil
}
b, err := marshalResults(results)
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
+230
View File
@@ -0,0 +1,230 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
)
// Where a stop goes — and, first, whose stop it is. A controller owns the turns
// it admits itself and cancels those; a synchronous Run belongs to the caller
// that passed the context. Measuring the second with the first says nothing
// about either.
const (
// armCancelTool is the control that says where a defect lives. If an
// ordinary tool's context is cancelled and a delegation's is not, the fault
// is in how a delegation derives its context; if neither is, it is upstream
// of both.
armCancelTool = "cancel-ordinary-tool"
// armCancelBackground is the negative control, and the one that decides what
// a fix may do: work already handed to a job has an owner of its own, and a
// cancel that reached into it would take back the handoff.
armCancelBackground = "cancel-background-handoff"
// armCancelHeadlessOwner reads the other surface. A synchronous Run is never
// admitted through the gate, so the controller holds no cancel for it and
// the caller's own context holds the only one. Both halves are asserted: a
// controller that started claiming this would be taking ownership from the
// only holder that has it.
armCancelHeadlessOwner = "cancel-headless-owner"
)
func cancelRoutingArm(name string) bool {
switch name {
case armCancelTool, armCancelBackground, armCancelHeadlessOwner:
return true
}
return false
}
// guardedArm names the arms driven through a turn the controller admits itself,
// which is the surface a person's Stop reaches.
func guardedArm(name string) bool {
return cancelTaskArm(name) || name == armCancelTool || name == armCancelBackground ||
name == armSkillCancel || name == armHostCancel
}
const (
sleepSentinel = "PROBE-SLEEP"
sleepCallID = "probe_sleep"
// sleepSeconds outlasts the settling this probe waits through, so a shell
// that ends promptly ended because something ended it.
sleepSeconds = 30
)
// sleepCall is an ordinary foreground tool that does nothing but wait. It is
// the shell every turn can call, which is the point: it derives its context the
// way every other tool does.
func sleepCall() []provider.Chunk {
args, _ := json.Marshal(map[string]any{
"command": fmt.Sprintf("sleep %d", sleepSeconds),
"description": "waits until something stops it",
})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: sleepCallID, Name: "bash", Arguments: string(args)},
}}
}
// runCancelRoutingConstruct drives the arm's work, stops it the way that
// surface's owner stops it, and records what the stop reached. Nothing waits
// for the work to end: whether it ends at all is the measurement.
func runCancelRoutingConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
sentinel := sleepSentinel
if arm == armCancelBackground {
sentinel = taskSentinel
}
prompt := fanOutTurn(turn+1, sentinel)
ctx, cancelCaller := context.WithCancel(ctx)
defer cancelCaller()
if guardedArm(arm) {
ctrl.Send(prompt)
} else {
go func() { _ = ctrl.Run(ctx, prompt) }()
}
if err := waitForCancelSubject(sink, prov, arm); err != nil {
return err
}
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
obs.Progress[probeStopReached] = stopReadings(ctrl, sink, prov, arm, cancelCaller)
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
// probeStopReached carries what each stop reached into the observation. The key
// names the probe so no row reads it as a status the kernel emitted.
const probeStopReached = "probe:stop-reached"
// stopReadings issues this surface's stop and reports what it reached. The
// headless arm issues both in order — the controller's, which owns nothing
// there, and then the caller's, which owns everything.
func stopReadings(ctrl *control.Controller, sink *graphSink, prov *scripted, arm string, cancelCaller context.CancelFunc) []string {
// What the stop found before it was issued. Work already ended would make
// every answer below meaningless, and this probe has made that mistake once.
before := "live"
if !workLive(sink, prov, arm) {
before = "already gone"
}
ctrl.Cancel()
first := "controller-cancel before=" + before + " " + reachedAfter(ctrl, sink, prov, arm)
if arm != armCancelHeadlessOwner {
return []string{first}
}
cancelCaller()
return []string{first, "caller-cancel " + reachedAfter(ctrl, sink, prov, arm)}
}
// reachedAfter waits out the settling this probe allows and reports what is
// still live. The wait stays well below the shell's own sleep, so work that has
// ended was ended by the stop rather than by finishing.
func reachedAfter(ctrl *control.Controller, sink *graphSink, prov *scripted, arm string) string {
stopped := time.Now()
deadline := stopped.Add(8 * time.Second)
for time.Now().Before(deadline) && workLive(sink, prov, arm) {
time.Sleep(100 * time.Millisecond)
}
live := "gone"
if workLive(sink, prov, arm) {
live = "still running"
}
return fmt.Sprintf("turn=%s work=%s after=%s",
turnStanding(ctrl, arm), live, time.Since(stopped).Round(time.Second))
}
func workLive(sink *graphSink, prov *scripted, arm string) bool {
if arm == armCancelBackground {
return prov.fleets.held(childEntered(childHang)) && !prov.fleets.held(childLeft(childHang))
}
_, ended := sink.toolResult(sleepCallID)
return !ended
}
// turnStanding reports the gate only where the gate is the authority. A
// synchronous Run is never admitted through it, so reading it there would say
// "ended" about a turn it was never told about — which is how this probe first
// mistook an ownership boundary for a cancellation defect.
func turnStanding(ctrl *control.Controller, arm string) string {
if !guardedArm(arm) {
return "not admitted (caller-owned)"
}
if ctrl.Running() {
return "still active"
}
return "ended"
}
// waitForCancelSubject blocks until the work this arm is about to stop is
// really under way: a stop that arrived before it would measure nothing.
func waitForCancelSubject(sink *graphSink, prov *scripted, arm string) error {
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
if arm == armCancelBackground {
if prov.fleets.held(childEntered(childHang)) {
return nil
}
} else if _, seen := sink.toolDispatched(sleepCallID); seen {
// The shell starts with the dispatch, not with the event; a moment's
// grace keeps the stop from racing its own subject.
time.Sleep(500 * time.Millisecond)
return nil
}
time.Sleep(50 * time.Millisecond)
}
return errUnexpected("work under way to stop", "nothing started")
}
// cancelRoutingRows read the arm's own readings. The three arms want different
// answers to the same question, which is why they run together: a fix that
// cancels everything a session knows about passes one and fails two.
func cancelRoutingRows(arm string, before Observation) []row {
readings := before.Progress[probeStopReached]
first, second := reading(readings, 0), reading(readings, 1)
switch arm {
case armCancelHeadlessOwner:
return []row{
ownershipRow("a controller stop on a turn it never admitted", first,
"leaves the caller's work alone", stillRunning(first)),
ownershipRow("the caller's own stop", second,
"ends the work it owns", !stillRunning(second)),
}
case armCancelBackground:
return []row{
ownershipRow("the turn after a stop", first, "a cancelled turn ends",
strings.Contains(first, "turn=ended")),
ownershipRow("work already handed to a job", first,
"a stop aimed at the turn leaves work the turn no longer owns", stillRunning(first)),
}
}
return []row{
ownershipRow("the turn after a stop", first, "a cancelled turn ends",
strings.Contains(first, "turn=ended")),
ownershipRow("the tool the turn was waiting on", first,
"a stop reaches the work its turn is waiting on", !stillRunning(first)),
}
}
func ownershipRow(semantic, got, want string, ok bool) row {
return row{
Semantic: semantic, Authority: "the surface that owns this cancellation",
Artifact: "none: in-process state", Reconstruction: want,
Before: want, After: orNone(got), Verdict: held(ok),
}
}
func reading(readings []string, at int) string {
if at < len(readings) {
return readings[at]
}
return ""
}
func stillRunning(reading string) bool { return strings.Contains(reading, "work=still running") }
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"fmt"
"strings"
)
// How a host can stand after dying with a question open. The names are the
// outcomes, not a pass and its failures: two of them are designs a host may
// legitimately choose, and only measurement says which one this is.
const (
outcomeResumable = "resumable" // the question is still there to answer
outcomeInterrupted = "interrupted-explicit" // it is gone, and the host says a turn was cut
outcomeLostSilent = "LOST-SILENT" // it is gone and nothing records that it existed
)
// decisionRows classify what a restart inherited. The safety row comes first
// because it is the only one with a single acceptable answer: whether the
// decision survives is a design question, whether the effect it blocked stayed
// blocked is not.
func decisionRows(before, after Observation) []row {
return []row{
deferredRow(before, after),
outcomeRow(before, after),
barrierRow(before, after),
modelContextRow(before, after),
obligationRow(before, after),
}
}
// deferredRow asserts the write the question held back never ran — not while
// the answer was pending, and not because a later process loaded the session.
func deferredRow(before, after Observation) row {
verdict := verdictHolds
if before.Deferred.Executed || after.Deferred.Executed {
verdict = verdictViolated
}
return row{
Semantic: "the effect the decision held back", Authority: "the workspace",
Artifact: before.Deferred.MarkerPath, Reconstruction: "the blocked write never runs, before or after the boundary",
Before: ranOrNot(before), After: ranOrNot(after), Verdict: verdict,
}
}
func ranOrNot(o Observation) string {
if o.Deferred.Executed {
return "RAN"
}
return "did not run"
}
// outcomeRow names which of the three standings the restart produced. It is
// stated, not judged: a host that drops the question and records the
// interruption is a defensible design, and one that drops both is not.
func outcomeRow(before, after Observation) row {
verdict := verdictHolds
var outcome string
switch {
case len(after.Decisions) > 0:
outcome = outcomeResumable
case len(after.Interrupted) > 0:
outcome = outcomeInterrupted
case after.Obligation.InterruptionMarked > 0 || len(after.Obligation.UnansweredCalls) > 0:
outcome = outcomeInterrupted
default:
outcome = outcomeLostSilent
verdict = verdictViolated
}
return row{
Semantic: "what the restart inherited", Authority: "approvalManager and the transcript",
Artifact: "none for the question; the transcript for the obligation",
Reconstruction: "resumable, interrupted-explicit, or nothing at all",
Before: decisionSummary(before), After: outcome, Verdict: verdict,
}
}
func decisionSummary(o Observation) string {
if len(o.Decisions) == 0 {
return "no decision open"
}
var kinds []string
for _, d := range o.Decisions {
kinds = append(kinds, string(d.Kind))
}
return strings.Join(kinds, ", ") + " open"
}
// barrierRow is the evidence behind an interrupted classification: a barrier
// the host recorded before anyone could be asked, still open, with no owner in
// this process waiting on it. It is deliberately not offered as answerable.
func barrierRow(before, after Observation) row {
return row{
Semantic: "durable adjudication barriers", Authority: "the adjudication log",
Artifact: "<stem>.adjudication.jsonl", Reconstruction: "open records with no live owner in this process",
Before: barrierSummary(before), After: barrierSummary(after), Verdict: verdictStable,
}
}
func barrierSummary(o Observation) string {
if len(o.Interrupted) == 0 {
return "none"
}
var out []string
for _, b := range o.Interrupted {
out = append(out, b.Kind+":"+b.ID)
}
return join(out) + " (not answerable)"
}
// modelContextRow is the other read surface: what the next request tells the
// model. One block when an interruption stands, none when it does not, and it
// must never arrive as something answerable.
func modelContextRow(before, after Observation) row {
verdict := verdictHolds
if before.ModelSeesInterruption != expectedBlocks(before) || after.ModelSeesInterruption != expectedBlocks(after) {
verdict = verdictViolated
}
return row{
Semantic: "what the next request tells the model", Authority: "control, projected per request",
Artifact: "none: derived, never stored", Reconstruction: "one block while an interruption stands, none otherwise",
Before: modelContextSummary(before), After: modelContextSummary(after), Verdict: verdict,
}
}
func expectedBlocks(o Observation) int {
if len(o.Interrupted) > 0 {
return 1
}
return 0
}
func modelContextSummary(o Observation) string {
return fmt.Sprintf("%d block(s) for %d interruption(s)", o.ModelSeesInterruption, len(o.Interrupted))
}
// obligationRow shows the evidence behind that classification, so a reader can
// see what the host still says rather than trusting the label.
func obligationRow(before, after Observation) row {
return row{
Semantic: "what the transcript still owes", Authority: "the canonical transcript",
Artifact: "<stem>.jsonl", Reconstruction: "tool calls with no result, and interruption records",
Before: obligationSummary(before), After: obligationSummary(after), Verdict: verdictStable,
}
}
func obligationSummary(o Observation) string {
return fmt.Sprintf("unanswered %s, interruption records %d",
orNone(join(o.Obligation.UnansweredCalls)), o.Obligation.InterruptionMarked)
}
func orNone(s string) string {
if s == "" {
return "none"
}
return s
}
+354
View File
@@ -0,0 +1,354 @@
package main
import (
"context"
"os"
"reasonix/internal/state/sessionstore"
"sort"
"strings"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/session/control"
"reasonix/internal/state/execgraph"
)
// The derivability arms. Everything before them asked what survives; these ask
// what still has to be written down. A fact a restart can compute from what is
// already durable needs no record of its own, and adding one would give it a
// second owner for nothing.
const (
// armDeriveSkipBoth cuts a branch with two upstreams that both end without
// an answer. Which of them the graph names is the question: the durable
// facts show two, and the picture shows one.
armDeriveSkipBoth = "derive-skip-both"
// armDeriveSkipFlip is the same graph with the two failures in the other
// order. If the named cause moves while the durable facts do not, the cause
// is history rather than a consequence of the end state.
armDeriveSkipFlip = "derive-skip-flip"
// armDeriveAnswered is the negative control and the context sample at once:
// two upstreams that both answer, one by completing and one by adopting, so
// the dependent must run and both deliveries must be accounted for.
armDeriveAnswered = "derive-answered"
)
func deriveArm(name string) bool {
switch name {
case armDeriveSkipBoth, armDeriveSkipFlip, armDeriveAnswered:
return true
}
return false
}
func deriveSentinels(arm string) string {
if arm == armDeriveAnswered {
return fleetWarmSentinel + " " + fleetDeriveSentinel
}
return fleetDeriveSentinel
}
// runDeriveConstruct lets the fan-out settle and dies before the turn ends, the
// way every terminal arm does: a skip is published only in the closing delta.
func runDeriveConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
runCtx, cancelRun := context.WithCancel(ctx)
defer cancelRun()
go func() { _ = ctrl.Run(runCtx, fanOutTurn(turn+1, deriveSentinels(arm))) }()
if err := waitForDerive(sink, arm, prov); err != nil {
return err
}
if err := writeObservation(root, capture("construct", arm, bootSystem, ctrl, sink, root)); err != nil {
return err
}
os.Exit(0)
return nil
}
// waitForDerive holds until the arm's shape is on the graph. The skip arms
// release their second failure once the first has been recorded, so which
// upstream the fan-out names is decided rather than raced.
func waitForDerive(sink *graphSink, arm string, prov *scripted) error {
if arm == armDeriveAnswered {
// Waiting on a count would be satisfied by the warm-up alone. The
// delivery edge is what the arm compares, so it waits for that.
return waitForShape(sink, func(g agentgraph.Graph) bool {
return len(contextTargets(g)) > 0 && len(nodesInState(g, agentgraph.StateAdopted)) > 0
}, "an adopted upstream and a delivered answer")
}
// The first failure decides which upstream is named — the fan-out cuts the
// branch when it processes that result. Only then is the second released,
// so the order is constructed rather than raced.
if err := waitForShape(sink, func(g agentgraph.Graph) bool {
return len(nodesInState(g, agentgraph.StateFailed)) > 0
}, "the first upstream to fail"); err != nil {
return err
}
prov.releaseOnce()
// A skip is published only in the closing delta, so the group has to end
// before the state the arm compares exists at all.
return waitForShape(sink, func(g agentgraph.Graph) bool {
return len(nodesInState(g, agentgraph.StateSkipped)) > 0 &&
len(nodesInState(g, agentgraph.StateFailed)) >= 2
}, "both upstreams failed and the dependent skipped")
}
// contextTargets are the items the graph shows receiving an upstream answer.
func contextTargets(g agentgraph.Graph) []string {
var out []string
for _, e := range g.Edges {
if e.Kind == agentgraph.Context {
out = append(out, e.To)
}
}
return out
}
func waitForShape(sink *graphSink, ok func(agentgraph.Graph) bool, want string) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
graph, _ := sink.snapshot()
if ok(graph) {
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected(want, waitStates(graph))
}
// answeredFromDurable is Answered() computed the way a restart would have to:
// an adoption says so on its own opening, and everything else is the terminal
// the sub-agent store kept, joined by the execution id the child records.
func answeredFromDurable(o Observation) map[string]bool {
terminal := map[string]string{}
for _, f := range o.Children.Facts {
if f.ParentToolCallID != "" {
terminal[f.ParentToolCallID] = f.Status
}
}
out := map[string]bool{}
for _, e := range o.Executions {
out[e.ID] = e.Disposition == "adopted" || terminal[e.ID] == string(sessionstore.SubagentCompleted)
}
return out
}
// impliedSkipped is the state a restart would derive: an item the orchestration
// released without ever starting it, ordered behind something that did not
// answer. Nothing here reads a disposition, because that is what is missing.
func impliedSkipped(o Observation) string {
answered := answeredFromDurable(o)
var out []string
for _, e := range o.Executions {
if e.Started() || e.Queued() || e.SettledAt.IsZero() || len(e.DependsOn) == 0 {
continue
}
for _, up := range e.DependsOn {
if !answered[up] {
out = append(out, e.ID)
break
}
}
}
sort.Strings(out)
return join(out)
}
// impliedSkipCauses are every upstream that could have been named, and the two
// rules that would pick one: declaration order, and the earliest release. A
// cause is derivable only if some rule always agrees with the picture.
func impliedSkipCauses(o Observation) (all, byOrder, bySettle string) {
answered := answeredFromDurable(o)
byID := map[string]int{}
for i, e := range o.Executions {
byID[e.ID] = i
}
var every, first, earliest []string
for _, e := range o.Executions {
if e.Started() || e.Queued() || e.SettledAt.IsZero() || len(e.DependsOn) == 0 {
continue
}
var unanswered []string
for _, up := range e.DependsOn {
if !answered[up] {
unanswered = append(unanswered, up)
}
}
if len(unanswered) == 0 {
continue
}
every = append(every, e.ID+"<-{"+strings.Join(unanswered, ",")+"}")
first = append(first, e.ID+"<-"+unanswered[0])
pick := unanswered[0]
for _, up := range unanswered[1:] {
if i, j := byID[up], byID[pick]; o.Executions[i].SettledAt.Before(o.Executions[j].SettledAt) {
pick = up
}
}
earliest = append(earliest, e.ID+"<-"+pick)
}
sort.Strings(every)
sort.Strings(first)
sort.Strings(earliest)
return join(every), join(first), join(earliest)
}
// actualSkipCause is the upstream the fan-out actually named, read off the node
// the graph settled. It is the picture's own answer, not a derivation.
func actualSkipCause(o Observation) string {
var out []string
for _, n := range o.Graph.Nodes {
if n.State == agentgraph.StateSkipped && n.Err != "" {
out = append(out, n.ID+": "+n.Err)
}
}
sort.Strings(out)
return orNone(join(out))
}
// impliedContextFromDurable predicts the delivery edges from durable facts
// alone: a started item receives an answer from every upstream that answered.
func impliedContextFromDurable(o Observation) string {
answered := answeredFromDurable(o)
var out []string
for _, e := range o.Executions {
if !e.Started() {
continue
}
for _, up := range e.DependsOn {
if answered[up] {
out = append(out, e.ID+"<-"+up)
}
}
}
sort.Strings(out)
return join(out)
}
// deriveRows are the three questions, kept apart. A state that derives says
// nothing about whether the cause does, and one arm's agreement says nothing
// about a rule that has only ever seen one sample.
func deriveRows(arm string, before, after Observation) []row {
rows := []row{
joinRow(before, after),
skipStateRow(before, after),
contextDeriveRow(before, after),
}
if arm != armDeriveAnswered {
rows = append(rows, skipCauseRow(before, after))
}
return rows
}
// joinRow is what every derivation below stands on: the child records name the
// execution they ran for, so a restart can ask the store what each item ended
// as. Without it none of the other rows could be computed at all.
func joinRow(before, after Observation) row {
return valueRow("executions the store can be asked about", "child metadata's parent tool-call id",
"subagents/<ref>.meta.json", "joined to the journal by execution id",
joinedExecutions(before), joinedExecutions(after), true)
}
func joinedExecutions(o Observation) string {
var out []string
for _, f := range o.Children.Facts {
if f.ParentToolCallID != "" {
out = append(out, f.ParentToolCallID+"="+f.Status)
}
}
sort.Strings(out)
return join(out)
}
// skipStateRow compares the state a restart would derive against the one the
// picture drew. Agreement means StateSkipped needs no record of its own.
func skipStateRow(before, after Observation) row {
actual := join(nodesInState(graphOf(before), agentgraph.StateSkipped))
implied := impliedSkipped(after)
verdict := verdictHolds
if implied != actual {
verdict = verdictViolated
}
return row{
Semantic: "skipped state: derived against drawn", Authority: "the journal joined to the store",
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
Reconstruction: "settled, never started, ordered behind something that did not answer",
Before: "drawn " + orNone(actual), After: "derived " + orNone(implied), Verdict: verdict,
}
}
// skipCauseRow keeps the harder question separate, and reads it three ways: the
// upstream the picture named, the two rules this file works out on its own, and
// what the production fold concludes. The independence matters — a probe that
// only asked the implementation would agree with it by construction.
func skipCauseRow(before, after Observation) row {
all, byOrder, bySettle := impliedSkipCauses(after)
return row{
Semantic: "skipped cause: what the picture named, what the facts permit",
Authority: "the fan-out's own choice against the durable upstream set",
Artifact: "none: the reason text is not recorded",
Reconstruction: "declaration order, or earliest released, over the upstreams that did not answer",
Before: actualSkipCause(before),
After: "candidates " + orNone(all) + " | by-order " + orNone(byOrder) +
" | by-settle " + orNone(bySettle) + " | rebuild " + orNone(rebuiltSkipCauses(after)),
Verdict: verdictStable,
}
}
// rebuiltSkipCauses is what the production fold names, asked the same question
// through its own code path.
func rebuiltSkipCauses(o Observation) string {
children := make([]execgraph.ChildOutcome, 0, len(o.Children.Facts))
for _, f := range o.Children.Facts {
children = append(children, execgraph.ChildOutcome{
Execution: f.ParentToolCallID, Ref: f.Ref, Status: f.Status,
})
}
rebuilt := execgraph.Rebuild(o.Executions, children, nil)
var out []string
for _, n := range rebuilt.Graph.Nodes {
if n.State != agentgraph.StateSkipped {
continue
}
if cause := execgraph.SkipCause(o.Executions, children, n.ID); cause != "" {
out = append(out, n.ID+"<-"+cause)
}
}
sort.Strings(out)
return join(out)
}
// contextDeriveRow asks the same question of the delivery edge, now that a
// restart can tell which upstreams answered.
func contextDeriveRow(before, after Observation) row {
actual, implied := contextEdges(before), impliedContextFromDurable(after)
verdict := verdictHolds
if implied != actual {
verdict = verdictViolated
}
return row{
Semantic: "context edges: derived against drawn", Authority: "the journal joined to the store",
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
Reconstruction: "a started item receives from every upstream that answered",
Before: "drawn " + orNone(actual), After: "derived " + orNone(implied), Verdict: verdict,
}
}
// deriveArmInvalid refuses an arm that did not reach the shape it compares.
func deriveArmInvalid(arm string, before Observation) string {
if arm == armDeriveAnswered {
if len(nodesInState(graphOf(before), agentgraph.StateAdopted)) == 0 {
return "no item was adopted, so the arm never had an answered upstream that did not run"
}
if contextEdges(before) == "" {
return "no answer was delivered, so there is no context edge to derive"
}
return ""
}
if len(nodesInState(graphOf(before), agentgraph.StateSkipped)) == 0 {
return "no dependent was skipped; the fan-out ended as " + orNone(terminalSummary(before))
}
if len(nodesInState(graphOf(before), agentgraph.StateFailed)) < 2 {
return "only one upstream ended without an answer, so the cause was never ambiguous"
}
return ""
}
+239
View File
@@ -0,0 +1,239 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
)
// The two ephemeral entry points, read against the execution read model's own
// authority: the snapshot is what a reader starts from and what a gap restores,
// and the stream is a projection of the same facts. A node only the stream has
// is therefore not a lighter node but one the authority cannot account for, and
// these arms ask whether either entry point produces one.
const (
armEphemeralTask = "ephemeral-task-authority"
armEphemeralSkill = "ephemeral-skill-authority"
)
func ephemeralArm(name string) bool {
return name == armEphemeralTask || name == armEphemeralSkill
}
const (
ephemeralSentinel = "PROBE-EPHEMERAL"
// ephemeralCallID is the call the model makes. Both entry points use it, so
// the two arms differ by which tool ran and by nothing else.
ephemeralCallID = "probe_ephemeral"
)
// ephemeralCall dispatches the entry point this arm measures. Its child hangs,
// because every row here is about a run that is still happening: an ephemeral
// execution that has ended owes no reader anything.
func ephemeralCall(arm string) []provider.Chunk {
name, args := "read_only_task", map[string]any{
"prompt": childHang + " ephemeral research", "description": "still executing at death",
}
if arm == armEphemeralSkill {
name, args = "read_only_skill", map[string]any{
"name": probeSkillName, "arguments": childHang + " ephemeral research",
}
}
raw, _ := json.Marshal(args)
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: ephemeralCallID, Name: name, Arguments: string(raw)},
}}
}
// runEphemeralConstruct drives one ephemeral run and reads three surfaces at the
// same instant, while its child is inside the provider: what the stream drew,
// what the authority holds, and what a reader is left with once a gap makes it
// start from the authority again.
func runEphemeralConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
go func() { _ = ctrl.Run(ctx, fanOutTurn(turn+1, ephemeralSentinel)) }()
if err := waitForEphemeral(prov); err != nil {
return err
}
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
obs.Progress[probeChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(childHang)))}
// One instant, three readings. Taken here because two of them cannot be
// taken later at all: the authority is recomputed per call, and what a gap
// leaves is a claim about the moment the gap happens.
live, _ := sink.snapshot()
authority := ctrl.ExecutionGraph()
obs.Progress[probeEphemeralLive] = []string{ephemeralNodes(live)}
obs.Progress[probeEphemeralAuthority] = []string{ephemeralNodes(authority.Graph)}
obs.Progress[probeEphemeralAfterGap] = []string{ephemeralNodes(afterGap(authority.Graph))}
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
// The three readings one instant produces. They ride the observation because
// none of them survives the process, and the arm's whole question is what a
// reader could see while the run was happening.
const (
probeEphemeralLive = "probe:ephemeral-live"
probeEphemeralAuthority = "probe:ephemeral-authority"
probeEphemeralAfterGap = "probe:ephemeral-after-gap"
)
// afterGap is what a view holds once a dropped stream makes it start from the
// authority again: the snapshot, and only the snapshot. A resync replaces the
// folded state rather than merging into it — that is what makes the snapshot the
// authority — so whatever the fold held and it does not has left the screen.
func afterGap(authority agentgraph.Graph) agentgraph.Graph { return authority }
func waitForEphemeral(prov *scripted) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
if prov.fleets.held(childEntered(childHang)) {
// Let the picture settle: a reading taken the instant the child
// arrives could miss a delta that was already on its way.
time.Sleep(time.Second)
return nil
}
time.Sleep(50 * time.Millisecond)
}
return errUnexpected("an ephemeral child inside the provider", "it never reached one")
}
// ephemeralNodes renders the nodes belonging to this arm's run, worker and group
// alike: a group card drawn for work the authority cannot account for is the
// same defect as a worker one.
func ephemeralNodes(g agentgraph.Graph) string {
var out []string
for _, n := range g.Nodes {
if strings.HasPrefix(n.ID, ephemeralCallID) {
out = append(out, n.ID+":"+orDash(string(n.State)))
}
}
sort.Strings(out)
return orNone(join(out))
}
func ephemeralRows(arm string, before, after Observation) []row {
return []row{
ephemeralLiveRow(before),
ephemeralAuthorityRow(before),
ephemeralGapRow(before),
ephemeralRestartRow(before, after),
ephemeralDurableRow(before, after),
ephemeralContractRow(arm, before),
}
}
const authorityName = "Controller.ExecutionGraph (the snapshot a reader starts from)"
func ephemeralLiveRow(before Observation) row {
drawn := firstProgress(before, probeEphemeralLive)
return row{
Semantic: "what the stream drew while it ran", Authority: liveAuthority,
Artifact: "none observed", Reconstruction: "in-process only",
Before: drawn, After: "—", Verdict: presenceVerdict(drawn != "none"),
}
}
func ephemeralAuthorityRow(before Observation) row {
held := firstProgress(before, probeEphemeralAuthority)
return row{
Semantic: "what the authority held at the same instant", Authority: authorityName,
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
Reconstruction: "execgraph.Rebuild over durable facts, recomputed on demand",
Before: held, After: "—", Verdict: presenceVerdict(held != "none"),
}
}
// ephemeralGapRow is the one that decides. A reader that loses the stream starts
// from the authority again, so whatever the fold held and the authority does not
// leaves the screen — while the child it named is still holding a slot.
func ephemeralGapRow(before Observation) row {
left := firstProgress(before, probeEphemeralAfterGap)
return row{
Semantic: "what a reader is left with after a gap", Authority: authorityName,
Artifact: "none: the fold is replaced, never merged",
Reconstruction: "a resync starts from the authority",
Before: left, After: "—", Verdict: presenceVerdict(left != "none"),
}
}
func ephemeralRestartRow(before, after Observation) row {
return row{
Semantic: "what a restart can say about it", Authority: rebuildAuthority,
Artifact: "<stem>.execution.jsonl",
Reconstruction: "execgraph.Rebuild in the next process",
Before: "executing inside the provider",
After: ephemeralNodes(rebuiltGraph(after).Graph),
Verdict: presenceVerdict(ephemeralNodes(rebuiltGraph(after).Graph) != "none"),
}
}
// ephemeralDurableRow is the contract both entry points already promise. It is
// read rather than assumed, because a convergence that made one of them visible
// by recording it would satisfy the rows above and break this one.
func ephemeralDurableRow(before, after Observation) row {
return presenceRow("journal and store", crossAuthority,
"<stem>.execution.jsonl, subagents/<ref>.meta.json",
"what an ephemeral entry point promises to leave: nothing",
ephemeralDurable(before), ephemeralDurable(after))
}
func ephemeralDurable(o Observation) []string {
var out []string
for _, e := range o.Executions {
if strings.HasPrefix(e.ID, ephemeralCallID) {
out = append(out, "journal:"+e.ID)
}
}
for _, f := range o.Children.Facts {
if strings.HasPrefix(f.ParentToolCallID, ephemeralCallID) {
out = append(out, "store:"+f.ParentToolCallID+"="+f.Status)
}
}
sort.Strings(out)
return out
}
// ephemeralContractRow states the authority contract as a conjunction. A node
// the stream drew, an authority that cannot account for it, and a child still
// executing: that is not a lighter kind of node, it is one that leaves the
// screen at the next gap while the work it named goes on holding a slot.
func ephemeralContractRow(arm string, before Observation) row {
live := firstProgress(before, probeEphemeralLive) != "none"
authority := firstProgress(before, probeEphemeralAuthority) != "none"
running := firstProgress(before, probeChildEntered) == "true"
answer, verdict := "no node was drawn: nothing claims what the authority cannot hold", verdictHolds
switch {
case !running:
answer, verdict = "the child never reached the provider", verdictNotMeasured
case live && !authority:
answer, verdict = "drawn by the stream, absent from the authority, and still executing", verdictViolated
case live && authority:
answer, verdict = "drawn, and the authority accounts for it", verdictHolds
}
return row{
Semantic: "a node the authority cannot account for", Authority: crossAuthority,
Artifact: "none, which is the point",
Reconstruction: "the stream and the snapshot, read at one instant",
Before: answer, After: "—", Verdict: verdict,
}
}
// ephemeralArmInvalid reports the premise this arm could not establish.
func ephemeralArmInvalid(before Observation) string {
if firstProgress(before, probeChildEntered) != "true" {
return "the ephemeral child never reached the provider, so nothing was executing at the reading"
}
return ""
}
+641
View File
@@ -0,0 +1,641 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"regexp"
"slices"
"strings"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
)
// The three fan-out arms. Each dies at a different point in one fleet's life,
// because "what survives" has no single answer: an item that finished, one
// still executing, and one the graph knows about without ever running are
// carried by different host state, and only separating them says which.
const (
// armGraphCompleted lets the fleet finish and the turn close before the
// process exits. Nothing is interrupted, so a loss here is not about
// interruption at all — it says execution provenance itself is ephemeral.
armGraphCompleted = "graph-completed"
// armGraphRunning kills the process with a child mid-execution. It asks the
// one question a completed fleet cannot: what does the new process believe
// about work whose owner is gone?
armGraphRunning = "graph-running"
// armGraphMixed carries every state at once — completed, running, adopted,
// failed, skipped — plus a read grant, a write grant, and a dependency edge.
// It is the density arm: one death, every semantic the graph claims to hold.
armGraphMixed = "graph-mixed"
)
func graphArm(name string) bool {
return name == armGraphCompleted || name == armGraphRunning || name == armGraphMixed
}
// Child sentinels. A child's prompt decides what its run does, and the probe
// reads the prompt off the request's last user message rather than scanning the
// whole conversation: the parent's own transcript quotes every fleet argument,
// so a scan would make the parent answer as its own children.
const (
childDone = "PROBE-CHILD-DONE"
childHang = "PROBE-CHILD-HANG"
childFail = "PROBE-CHILD-FAIL"
)
// Parent sentinels, one per fleet the arm dispatches.
const (
fleetWarmSentinel = "PROBE-FLEET-WARM"
fleetMixedSentinel = "PROBE-FLEET-MIXED"
fleetPairSentinel = "PROBE-FLEET-PAIR"
// The scheduler arms. Holder fills a ceiling and never ends; refused asks
// for admission the scheduler must deny. Two sentinels rather than one
// because the claim and transition arms need the holder to be already
// admitted before the second fleet is dispatched.
fleetHolderSentinel = "PROBE-FLEET-HOLDER"
fleetRefusedSentinel = "PROBE-FLEET-REFUSED"
// The terminal arms. Their fan-outs run to completion, because a skip is
// published only in the closing delta; the turn is then held open so the
// process still dies mid-turn.
fleetTerminalSentinel = "PROBE-FLEET-TERMINAL"
// Named so it is not contained in PROBE-PARALLEL-IDENTITY. Sentinels are
// matched by substring, so one inside another dispatches a fan-out its arm
// never asked for — this pair silently ran cancelParallel in the identity
// arm, whose rows then read a scenario nobody wrote.
parallelSentinel = "PROBE-PARALLEL-CANCEL"
fleetOutcomesSentinel = "PROBE-FLEET-OUTCOMES"
fleetDeriveSentinel = "PROBE-FLEET-DERIVE"
fleetIdentitySentinel = "PROBE-FLEET-IDENTITY"
parallelIdentity = "PROBE-PARALLEL-IDENTITY"
// The active-store arm. Its fan-out shares one ceiling with a lone
// delegation, so the four populations it needs are reached in one death.
fleetActiveSentinel = "PROBE-FLEET-ACTIVE"
)
// childHold is a child that reports holding its slot before it blocks, so a
// later fleet can be dispatched knowing the ceiling is already occupied. Timing
// it any other way races the scheduler.
const (
childHold = "PROBE-CHILD-HOLD"
// childRelease finishes only when the arm frees it, which is how capacity
// is returned while a refusal is still standing.
childRelease = "PROBE-CHILD-RELEASE"
// childFailLate fails only once the arm frees it, so two failures in one
// fan-out have a decided order instead of a raced one. Its wording shares no
// prefix with childFail: the sentinels are matched by substring, so a name
// containing another's makes the shorter one win and the delay disappear.
childFailLate = "PROBE-CHILD-DELAYEDFAIL"
)
// probeClaimPath is the write path the claim arm overlaps. The refused writer
// asks for a path inside it, which is a conflict no ceiling change releases.
const (
probeClaimPath = "probe-claim"
probeClaimInner = "probe-claim/inner"
)
// subagentRefPattern finds the references an earlier fleet's aggregate reported.
// The mixed arm adopts one of them, which is the only way to reach StateAdopted
// through the tool's own contract rather than by constructing the state.
var subagentRefPattern = regexp.MustCompile(`sa_[A-Za-z0-9_-]+`)
// childScript answers one delegated run. Hanging is expressed as a context read
// so the child holds its scheduler slot until the process dies — a sleep long
// enough to look the same would still be a race against the probe's deadline.
func (s *scripted) childScript(ctx context.Context, sentinel string) []provider.Chunk {
// Reaching here is the child's own proof that it started, and returning is
// its proof that something let it go: an arm asking whether a cancellation
// reached a running child has no other way to know.
s.fleets.first(childEntered(sentinel))
defer s.fleets.first(childLeft(sentinel))
switch sentinel {
case childHold:
// Reaching a provider call means the slot is already held, which is the
// only moment a later fleet can be dispatched against a full ceiling
// without racing it.
s.holding.Do(func() { close(s.held) })
<-ctx.Done()
return nil
case childRelease:
<-s.release
return append(text("Child released."), done())
case childFailLate:
<-s.release
return []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf("probe child failed on release")}}
case childHang:
<-ctx.Done()
// The context closed, and this is the only place that can say so: a
// caller that stopped waiting leaves the same absence behind.
s.fleets.first(childCtxDone(sentinel))
return nil
case childFail:
return []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf("probe child failed on purpose")}}
default:
return append(text("Child answered."), done())
}
}
// askedInPrompt reports whether this request's own user turns carry a sentinel.
// The user side only: a fleet's arguments live in an assistant message, so a
// whole-conversation scan makes a parent answer as one of its own children.
// Every user turn, not the last: the host appends its own notes after the
// prompt, and the last user message in a request is frequently one of those.
func askedInPrompt(req provider.Request, sentinel string) bool {
for _, m := range req.Messages {
if m.Role == provider.RoleUser && strings.Contains(m.Content, sentinel) {
return true
}
}
return false
}
// childSentinel reports which child script this request is, empty when the
// request is a parent turn.
func childSentinel(req provider.Request) string {
for _, sentinel := range []string{childDone, childHang, childFail, childHold, childRelease, childFailLate} {
if askedInPrompt(req, sentinel) {
return sentinel
}
}
return ""
}
// fanOutTurnRecorded reports whether the dispatching turn is in these messages.
// The probe reads its own sentinel rather than counting rows: a turn appended
// at some later point would still be there, and only the prompt says whether
// this is the turn the fan-out was opened in.
func fanOutTurnRecorded(msgs []provider.Message) bool {
for _, m := range msgs {
if strings.Contains(m.Content, fleetPairSentinel) || strings.Contains(m.Content, fleetMixedSentinel) {
return true
}
}
return false
}
// fleetCall renders one fleet dispatch. Nothing closes the round it opens: all
// three arms die inside the turn, so the host's final-answer readiness check
// never runs and the arms measure a process boundary rather than a task list.
func fleetCall(id string, tasks []map[string]any) []provider.Chunk {
args, _ := json.Marshal(map[string]any{"tasks": tasks})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: id, Name: "fleet", Arguments: string(args)},
}}
}
// warmFleet finishes two children so a later fleet has a completed reference to
// adopt. Both are read-only: nothing about the warm-up is under measurement.
func warmFleet() []provider.Chunk {
return fleetCall("probe_fleet_warm", []map[string]any{
{"id": "w1", "prompt": childDone + " warm one", "description": "warm one", "read_only": true},
{"id": "w2", "prompt": childDone + " warm two", "description": "warm two", "read_only": true},
})
}
// pairFleet is the two-item shape both single-state arms use. The running arm
// dies with the second item still executing; the completed arm lets both
// finish, so the two differ by when the process exits and nothing else.
func pairFleet(second string) []provider.Chunk {
return fleetCall("probe_fleet_pair", []map[string]any{
{"id": "p1", "prompt": childDone + " pair one", "description": "pair one", "read_only": true},
{"id": "p2", "prompt": second + " pair two", "description": "pair two", "read_only": true},
})
}
// mixedFleet is the density arm's dispatch: one item per terminal the graph can
// record, plus one still running, and the two grants told apart. The write item
// declares a path so preflight admits it beside the readers; it never writes,
// because the grant is the envelope under measurement, not the effect.
func mixedFleet(adoptRef string) []provider.Chunk {
return fleetCall("probe_fleet_mixed", []map[string]any{
// Model and effort are named so the identity row measures something: two
// nodes agreeing that a field is empty proves nothing about recovering it.
{"id": "m1", "prompt": childDone + " mixed one", "description": "completes", "read_only": true,
"model": probeModelRef, "effort": "high"},
{"id": "m2", "prompt": childFail + " mixed two", "description": "fails", "read_only": true},
{"id": "m3", "prompt": childDone + " mixed three", "description": "skipped by m2", "read_only": true, "depends_on": []string{"m2"}},
{"id": "m4", "adopt_ref": adoptRef, "description": "adopted"},
{"id": "m5", "prompt": childHang + " mixed five", "description": "running at death", "write_paths": []string{"probe-fanout-out"}},
})
}
// opens reports whether this round is the one that dispatches that fleet.
func (s *scripted) opens(req provider.Request, sentinel string) bool {
return askedInPrompt(req, sentinel) && s.fleets.first(sentinel)
}
// opensRun is the same for the fan-out an arm is built around: the terminal and
// derive arms hold the turn open once theirs has gone out.
func (s *scripted) opensRun(req provider.Request, sentinel string) bool {
if !s.opens(req, sentinel) {
return false
}
s.opened.Store(true)
return true
}
// fanOut answers a parent turn that opens a fleet, at most once per sentinel.
// The sentinel is read off the current user turn, and the latch is what stops
// the round that receives the aggregate from dispatching the same fleet again.
func (s *scripted) fanOut(req provider.Request) ([]provider.Chunk, bool) {
switch {
case s.opens(req, fleetWarmSentinel):
return append(warmFleet(), done()), true
case s.opens(req, fleetPairSentinel):
if s.arm == armWaitSlots {
return append(slotsFleet(), done()), true
}
second := childDone
if s.arm == armGraphRunning {
second = childHang
}
return append(pairFleet(second), done()), true
case s.opens(req, fleetSettledSentinel):
return append(settledFleet(), done()), true
case s.opens(req, fleetLiveSentinel):
return append(liveFleet(), done()), true
case s.opens(req, fleetMixedSentinel):
if s.arm == armTerminalAdopted {
return append(adoptOnlyFleet(adoptableRef(req)), done()), true
}
if s.arm == armUIGraphMixed {
return append(uiMixedFleet(adoptableRef(req)), done()), true
}
return append(mixedFleet(adoptableRef(req)), done()), true
case s.opensRun(req, fleetIdentitySentinel):
return append(identityFleet(adoptableRef(req)), done()), true
case s.opens(req, parallelIdentity):
return append(identityParallel(), done()), true
case s.opensRun(req, fleetDeriveSentinel):
return append(deriveFleet(s.arm, adoptableRef(req)), done()), true
case s.opensRun(req, fleetOutcomesSentinel):
return append(outcomesFleet(), done()), true
case s.opensRun(req, fleetTerminalSentinel):
return append(terminalFleet(s.arm), done()), true
case s.opensRun(req, parallelSentinel):
return append(cancelParallel(), done()), true
case s.fleets.held(taskSentinel) && s.opens(req, fleetActiveSentinel):
// The delegation goes first and its own child says when the slot is
// taken. Dispatching before that lets the fan-out take the whole
// ceiling, and the arm loses the entry point it exists to compare.
<-s.held
return append(activeStoreFleet(), done()), true
case s.opens(req, fleetHolderSentinel):
return append(holderFleet(s.arm), done()), true
case s.opens(req, fleetRefusedSentinel):
// The holder's own child says when its ceiling is occupied. Dispatching
// before that lets the refused item win the race and start.
<-s.held
return append(refusedFleet(s.arm), done()), true
}
return nil, false
}
// holderFleet occupies the ceiling this arm measures. The slots arm needs no
// holder — its refused item is in the same fleet as the runs that fill the
// ceiling — so only the arms that need one dispatch it in the background.
func holderFleet(arm string) []provider.Chunk {
tasks := []map[string]any{
{"id": "h1", "prompt": childHold + " holder", "description": "holds the ceiling", "write_paths": []string{probeClaimPath}},
{"id": "h2", "prompt": childHang + " filler", "description": "filler", "read_only": true},
}
if arm == armWaitTransition {
// The second run is what the arm later releases: freeing total capacity
// while the writer ceiling stays full is how the blocker changes.
tasks[1] = map[string]any{"id": "h2", "prompt": childRelease + " releasable", "description": "released mid-wait", "read_only": true}
}
if bothHoldersReport(arm) {
tasks[1] = map[string]any{"id": "h2", "prompt": childHold + " second holder", "description": "holds the ceiling", "read_only": true}
}
return backgroundFleetCall("probe_fleet_holder", tasks)
}
// bothHoldersReport names the arms that leave one slot free. Either holder may
// win it, so both report holding, and the refused delegation's own child is then
// the only arrival that means anything — a filler that hung under the same
// sentinel would be read as the subject having started.
func bothHoldersReport(arm string) bool {
return queuedTaskArm(arm) || queuedSkillArm(arm) || queuedHostArm(arm)
}
// refusedFleet asks for admission the scheduler must deny, with every check
// above this arm's cause already cleared.
func refusedFleet(arm string) []provider.Chunk {
refused := map[string]any{"id": "r1", "prompt": childHang + " refused", "description": "refused admission"}
switch arm {
case armWaitWriters:
// Disjoint from the holder's path, so only the writer ceiling can
// refuse it.
refused["write_paths"] = []string{"probe-writers-own"}
case armWaitClaim:
refused["write_paths"] = []string{probeClaimInner}
default:
refused["write_paths"] = []string{"probe-transition-own"}
}
return fleetCall("probe_fleet_refused", []map[string]any{
refused,
{"id": "r2", "prompt": childHang + " sibling", "description": "sibling", "read_only": true},
})
}
// terminalFleet runs to completion holding the disposition its arm measures.
// The skipped arm cuts a branch with a failure; the context arm delivers a real
// answer across an ordering edge, which is the only way a context edge appears.
func terminalFleet(arm string) []provider.Chunk {
upstream := map[string]any{"id": "up", "prompt": childDone + " upstream", "description": "upstream", "read_only": true}
if arm == armTerminalSkippedDep {
upstream["prompt"] = childFail + " upstream"
}
return fleetCall("probe_fleet_terminal", []map[string]any{
upstream,
{"id": "down", "prompt": childDone + " downstream", "description": "downstream", "read_only": true, "depends_on": []string{"up"}},
})
}
// cancelParallel opens more items than the session admits at once, so the arm
// can interrupt the group while one has started and another has not. That split
// is what parallel_tasks reports as cancelled against skipped.
func cancelParallel() []provider.Chunk {
args, _ := json.Marshal(map[string]any{"tasks": []map[string]any{
{"prompt": childHang + " first", "description": "started"},
{"prompt": childHang + " second", "description": "never started"},
{"prompt": childHang + " third", "description": "never started"},
}})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: "probe_parallel", Name: "parallel_tasks", Arguments: string(args)},
}}
}
// adoptOnlyFleet runs to completion with one item adopted and one that ran.
// Nothing hangs: the arm compares two settled items whose only difference is
// that one executed, which a fan-out still in flight could not show.
func adoptOnlyFleet(adoptRef string) []provider.Chunk {
return fleetCall("probe_fleet_terminal", []map[string]any{
{"id": "a1", "adopt_ref": adoptRef, "description": "adopted"},
{"id": "a2", "prompt": childDone + " ran", "description": "ran", "read_only": true},
})
}
// outcomesFleet reaches all three executed terminals in one group: one item
// completes, one fails, and two are still running when the arm interrupts. The
// store's record of each is then compared under identical conditions.
func outcomesFleet() []provider.Chunk {
return fleetCall("probe_fleet_outcomes", []map[string]any{
{"id": "o1", "prompt": childDone + " completes", "description": "completes", "read_only": true},
{"id": "o2", "prompt": childFail + " fails", "description": "fails", "read_only": true},
{"id": "o3", "prompt": childHang + " cancelled", "description": "cancelled", "read_only": true},
{"id": "o4", "prompt": childHang + " cancelled too", "description": "cancelled too", "read_only": true},
})
}
// identityFleet names a worker identity four ways: not at all, by model, by
// effort, and not at all because nothing runs. What the graph then shows for
// each is what says which layer of the resolution it is reporting.
func identityFleet(adoptRef string) []provider.Chunk {
return fleetCall("probe_fleet_identity", []map[string]any{
{"id": "i1", "prompt": childDone + " default", "description": "inherits", "read_only": true},
{"id": "i2", "prompt": childDone + " override", "description": "names a model", "read_only": true,
"model": probeAltModelRef, "effort": "high"},
{"id": "i3", "prompt": childDone + " effort only", "description": "names an effort", "read_only": true,
"effort": "low"},
{"id": "i4", "adopt_ref": adoptRef, "description": "runs nothing"},
})
}
// identityParallel asks the same of the other producer, which resolves through
// a shorter chain: it has no profile layer to consult.
func identityParallel() []provider.Chunk {
args, _ := json.Marshal(map[string]any{"tasks": []map[string]any{
{"prompt": childDone + " p default", "description": "inherits"},
{"prompt": childDone + " p override", "description": "names a model",
"model": probeAltModelRef, "effort": "high"},
}})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: "probe_parallel_identity", Name: "parallel_tasks", Arguments: string(args)},
}}
}
// deriveFleet orders one dependent behind two upstreams. The skip arms end both
// upstreams without an answer, one immediately and one on release, so which of
// them the fan-out names is decided by the order rather than by a race; the
// answered arm gives the dependent one completed and one adopted upstream, so
// it must run and both deliveries must be accounted for.
func deriveFleet(arm, adoptRef string) []provider.Chunk {
first, second := childFail+" first", childFailLate+" second"
if arm == armDeriveSkipFlip {
first, second = childFailLate+" first", childFail+" second"
}
up := []map[string]any{
{"id": "a", "prompt": first, "description": "upstream a", "read_only": true},
{"id": "b", "prompt": second, "description": "upstream b", "read_only": true},
}
if arm == armDeriveAnswered {
up = []map[string]any{
{"id": "a", "prompt": childDone + " first", "description": "upstream a", "read_only": true},
{"id": "b", "adopt_ref": adoptRef, "description": "upstream b"},
}
}
return fleetCall("probe_fleet_derive", append(up, map[string]any{
"id": "c", "prompt": childDone + " dependent", "description": "dependent",
"read_only": true, "depends_on": []string{"a", "b"},
}))
}
// activeStoreFleet reaches three fan-out populations under one ceiling. The
// first item settles and frees the slot the other two then contend for, so one
// executes and one is refused — which of them is the scheduler's to decide, and
// the arm reads the populations off the journal rather than naming them here.
func activeStoreFleet() []provider.Chunk {
return fleetCall(activeFleetCallID, []map[string]any{
{"id": "a1", "prompt": childDone + " settles", "description": "settles", "read_only": true},
{"id": "a2", "prompt": childHang + " holds", "description": "contends for the freed slot",
"read_only": true, "depends_on": []string{"a1"}},
{"id": "a3", "prompt": childHang + " contends", "description": "contends for the freed slot",
"read_only": true, "depends_on": []string{"a1"}},
})
}
// slotsFleet fills the total ceiling and asks for one more, all read-only: a
// reader clears every writer check by not being a writer.
func slotsFleet() []provider.Chunk {
return fleetCall("probe_fleet_refused", []map[string]any{
{"id": "s1", "prompt": childHang + " one", "description": "fills a slot", "read_only": true},
{"id": "s2", "prompt": childHang + " two", "description": "fills a slot", "read_only": true},
{"id": "s3", "prompt": childHang + " three", "description": "refused admission", "read_only": true},
})
}
// backgroundFleetCall dispatches a fleet that returns a job id at once. The
// holder has to keep holding while a later fleet is dispatched, which a
// foreground call cannot do: it does not return until every item has settled.
func backgroundFleetCall(id string, tasks []map[string]any) []provider.Chunk {
args, _ := json.Marshal(map[string]any{"tasks": tasks, "run_in_background": true})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: id, Name: "fleet", Arguments: string(args)},
}}
}
// adoptableRef picks a reference the warm-up fleet reported. It reads the
// conversation the model reads: the aggregate names each child's reference, and
// an arm that cannot find one has not established its premise.
func adoptableRef(req provider.Request) string {
for _, m := range slices.Backward(req.Messages) {
if found := subagentRefPattern.FindAllString(m.Content, -1); len(found) > 0 {
return found[len(found)-1]
}
}
return ""
}
// fanOutTurn is the prompt that opens one fleet.
func fanOutTurn(n int, sentinel string) string {
return fmt.Sprintf("%s %s: dispatch the fan-out.", marker(n), sentinel)
}
// fanOutSentinels is what this arm's one prompt carries. The mixed arm names
// both of its fleets in the same turn: the warm-up has to finish before a
// reference can be adopted, and a fleet's aggregate returns into the same turn
// that dispatched it, so the second round dispatches the mixed fleet without
// the turn ever closing.
func fanOutSentinels(arm string) string {
if arm == armUIGraphMixed {
return fleetWarmSentinel + " " + fleetSettledSentinel + " " + fleetMixedSentinel
}
if arm == armGraphMixed {
return fleetWarmSentinel + " " + fleetMixedSentinel
}
return fleetPairSentinel
}
// runFanOutConstruct drives the arm's fan-out and exits while the turn is still
// open. Every arm dies mid-turn, including the completed one: what separates
// them is which states the graph holds at that moment, and a clean shutdown
// would cancel a running child and settle its node — the one thing a process
// that dies mid-fan-out does not do.
func runFanOutConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, turn int) error {
go func() { _ = ctrl.Run(ctx, fanOutTurn(turn+1, fanOutSentinels(arm))) }()
if err := waitForFanOut(sink, arm); err != nil {
return err
}
if err := writeObservation(root, capture("construct", arm, bootSystem, ctrl, sink, root)); err != nil {
return err
}
os.Exit(0)
return nil
}
// waitForFanOut blocks until the graph shows the shape this arm was built to
// die with. Waiting on the shape rather than on a duration is what keeps the
// arm honest: a timer that fired early would report a smaller fan-out as if
// that were what the restart inherited.
func waitForFanOut(sink *graphSink, arm string) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
graph, _ := sink.snapshot()
if fanOutReady(graph, arm) {
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected("a fan-out holding "+wantedText(arm), nodeStates(graph))
}
// fanOutReady is the arm's premise expressed as a graph shape. The completed
// arm waits for every worker to settle, which is the opposite condition from
// the other two rather than a weaker version of it.
func fanOutReady(g agentgraph.Graph, arm string) bool {
if arm == armGraphCompleted {
return allWorkersSettled(g)
}
return holdsAll(g, wantedStates(arm))
}
// wantedStates are the states a mid-flight arm must hold at once. Skipped is
// not among them and cannot be: a fan-out publishes a skip only in its closing
// outcome delta, so nothing reads skipped until the group has ended and nothing
// is running. An item whose dependency already failed sits at pending until
// then, which is what the mixed arm dies holding.
func wantedStates(arm string) []agentgraph.NodeState {
want := []agentgraph.NodeState{agentgraph.StateCompleted, agentgraph.StateRunning}
if arm == armGraphMixed || arm == armUIGraphMixed {
want = append(want, agentgraph.StateFailed, agentgraph.StateAdopted)
}
// The UI arm dies holding a settled fan-out beside a live one, so it also
// reaches the two states the mixed arm cannot: a skip, which is published
// only when a group closes, and an item a ceiling never admitted.
if arm == armUIGraphMixed {
want = append(want, agentgraph.StateSkipped, agentgraph.StatePending, agentgraph.StateQueued)
}
return want
}
func wantedText(arm string) string {
if arm == armGraphCompleted {
return "every worker settled"
}
return statesText(wantedStates(arm))
}
// allWorkersSettled reports whether every worker reached a terminal state. An
// empty state is not terminal here even though NodeState.Terminal calls it one:
// a node declared before it ran carries no state at all, and reading that as
// settled would let the arm die before the fan-out started.
func allWorkersSettled(g agentgraph.Graph) bool {
seen := false
for _, n := range g.Nodes {
if n.Kind != agentgraph.KindWorker {
continue
}
seen = true
if n.State == "" || !n.State.Terminal() {
return false
}
}
return seen
}
// holdsAll reports whether the graph shows at least one worker in each state.
// Group nodes are excluded: a group is the call that owns the fan-out, and its
// own running state would satisfy the running requirement without any child
// being in flight.
func holdsAll(g agentgraph.Graph, want []agentgraph.NodeState) bool {
for _, state := range want {
found := false
for _, n := range g.Nodes {
if n.Kind == agentgraph.KindWorker && n.State == state {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func statesText(states []agentgraph.NodeState) string {
out := make([]string, 0, len(states))
for _, s := range states {
out = append(out, string(s))
}
return join(out)
}
func nodeStates(g agentgraph.Graph) string {
out := make([]string, 0, len(g.Nodes))
for _, n := range g.Nodes {
out = append(out, n.ID+":"+string(n.State))
}
return orNone(join(out))
}
+646
View File
@@ -0,0 +1,646 @@
package main
import (
"fmt"
"reasonix/internal/state/sessionstore"
"slices"
"sort"
"strings"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/runtime/delegation"
"reasonix/internal/state/execgraph"
"reasonix/internal/state/execjournal"
)
// How a run graph can stand after the process that built it exits. Three are
// designs a host may hold; two are defects, and not the same defect. Losing the
// graph costs a reader an explanation; a node still drawn as running with no
// owner costs them the truth — reviving actionable state is more dangerous than
// dropping it, and a running node is actionable state.
const (
graphExact = "reconstructed-exact" // nodes, edges, grants, waits, states all back
graphInterrupted = "interrupted-explicit" // the fan-out is legible and the live child is marked cut
graphLossy = "reconstructed-lossy" // it is back, minus semantics nothing can re-derive
graphLostSilent = "LOST-SILENT" // gone, with no provenance that it ever existed
graphGhost = "GHOST-RUNNING" // still says running, with no live owner anywhere
graphNotMeasured = "not-measured" // the construct phase never established the shape
)
// graphRows classify what a restart inherited from a fan-out. There are two
// outcome rows rather than one because a fan-out that died mid-flight holds two
// populations with different answers: the items that had already settled, whose
// answers the store owns, and the one still executing, which nothing on disk
// has ever been told about. One label over both reports the better half.
func graphRows(before, after Observation) []row {
return []row{
ghostRow(before, after),
dispatchTurnRow(before, after),
settledOutcomeRow(before, after),
interruptedOutcomeRow(before, after),
graphStructureRow(before, after),
graphSemanticsRow(before, after),
childProvenanceRow(before, after),
executionJournalRow(before, after),
rebuildStateRow(before, after),
rebuildTopologyRow(before, after),
rebuildEnvelopeRow(before, after),
rebuildInterruptedRow(before, after),
rebuildIdentityRow(before, after),
executionTopologyRow(before, after),
executionKindRow(before, after),
executionContextRow(before, after),
graphObligationRow(before, after),
}
}
// runningWorkers are the nodes that were executing when the process died. They
// are the arm's premise: an arm that establishes none has measured a fan-out
// nobody interrupted, whatever else it recorded.
func runningWorkers(o Observation) []string {
var out []string
for _, n := range o.Graph.Nodes {
if n.Kind == agentgraph.KindWorker && n.State == agentgraph.StateRunning {
out = append(out, n.ID)
}
}
return out
}
// executedWorkers are the items that ran to a terminal state. Adopted nodes are
// excluded on purpose: nothing executed under them, so the store owes them no
// record and counting them would read a correct store as incomplete.
func executedWorkers(o Observation) []string {
var out []string
for _, n := range o.Graph.Nodes {
if n.Kind != agentgraph.KindWorker || n.State == "" || n.State == agentgraph.StateAdopted {
continue
}
if n.State.Terminal() {
out = append(out, n.ID)
}
}
return out
}
// ghostRow asks the one question with a single acceptable answer: does anything
// the new process can read still present this work as running? A graph node and
// a store record are both such a claim, and neither has an owner behind it —
// the goroutine that would have settled them died with the first process.
func ghostRow(before, after Observation) row {
ghosts := append(runningWorkers(after), childrenInState(after, string(sessionstore.SubagentRunning))...)
verdict := verdictHolds
if len(ghosts) > 0 {
verdict = verdictViolated
}
return row{
Semantic: "work still presented as running", Authority: "the graph and the sub-agent store",
Artifact: "subagents/<ref>.meta.json",
Reconstruction: "nothing may read as running once its owner is gone",
Before: orNone(join(runningWorkers(before))) + " running at death",
After: orNone(join(ghosts)), Verdict: verdict,
}
}
// dispatchTurnRow is the row underneath every other loss here: whether the turn
// that opened the fan-out reached the transcript at all. A turn is appended
// when it ends, so a process that dies inside one takes the request with it —
// and a graph reconstruction has nothing to attach to even in principle.
func dispatchTurnRow(before, after Observation) row {
verdict := verdictPersisted
switch {
case !before.FanOutTurn:
verdict = verdictNotMeasured
case !after.FanOutTurn:
verdict = verdictLost
}
return row{
Semantic: "the turn that dispatched the fan-out", Authority: "session transcript + event log",
Artifact: "<stem>.jsonl, <stem>.events.jsonl", Reconstruction: "appended when the turn ends",
Before: recordedText(before), After: recordedText(after), Verdict: verdict,
}
}
func recordedText(o Observation) string {
if o.FanOutTurn {
return fmt.Sprintf("in the transcript (%d rows)", o.Transcript.Messages)
}
return fmt.Sprintf("absent (%d rows)", o.Transcript.Messages)
}
// settledOutcomeRow classifies the items that had already finished. Their
// answers are what a later turn reads back, so this is the row that decides
// whether a fan-out's history is durable at all.
func settledOutcomeRow(before, after Observation) row {
outcome, verdict := settledOutcome(before, after)
return row{
Semantic: "items that had settled before the death", Authority: "the graph fold and the sub-agent store",
Artifact: "subagents/<ref>.meta.json",
Reconstruction: "one durable child record per item that executed",
Before: fmt.Sprintf("%d executed: %s", len(executedWorkers(before)), graphSummary(before)),
After: outcome, Verdict: verdict,
}
}
func settledOutcome(before, after Observation) (string, string) {
executed := len(executedWorkers(before))
switch {
case len(before.Graph.Nodes) == 0:
return graphNotMeasured, verdictNotMeasured
case graphIdentical(before, after):
return graphExact, verdictExact
case executed == 0:
return graphNotMeasured, verdictNotMeasured
case len(after.Children.Facts) == 0:
return graphLostSilent, verdictViolated
case len(after.Children.Facts) < executed:
return fmt.Sprintf("%s (%d of %d children)", graphLossy, len(after.Children.Facts), executed), verdictLossy
default:
return graphLossy, verdictLossy
}
}
// interruptedOutcomeRow classifies the item that was still executing. This is
// the row the arm exists for: the graph is gone either way, and what separates
// a defensible design from a durable gap is whether anything the new process
// can read still says that work was opened and cut.
func interruptedOutcomeRow(before, after Observation) row {
outcome, verdict := interruptedOutcome(before, after)
return row{
Semantic: "the item still executing at the death", Authority: "the sub-agent store and the transcript",
Artifact: "subagents/<ref>.meta.json, <stem>.jsonl",
Reconstruction: "an interrupted child record, or an unsettled call the transcript still owes",
Before: orNone(join(runningWorkers(before))), After: outcome, Verdict: verdict,
}
}
func interruptedOutcome(before, after Observation) (string, string) {
switch {
case len(before.Graph.Nodes) == 0 || len(runningWorkers(before)) == 0:
return graphNotMeasured, verdictNotMeasured
case len(runningWorkers(after)) > 0 || len(childrenInState(after, string(sessionstore.SubagentRunning))) > 0:
return graphGhost, verdictViolated
case len(after.InterruptedExecutions) > 0:
return graphInterrupted, verdictLossy
case len(childrenInState(after, string(sessionstore.SubagentInterrupted))) > 0:
return graphInterrupted, verdictLossy
case len(after.Obligation.UnansweredCalls) > 0 || after.Obligation.InterruptionMarked > 0:
return graphInterrupted, verdictLossy
default:
return graphLostSilent, verdictViolated
}
}
// graphIdentical compares the whole graph, not its node count. Two graphs with
// the same nodes and different grants are not the same graph, and a comparison
// that only counted would have called that exact.
func graphIdentical(before, after Observation) bool {
return graphNodes(before) == graphNodes(after) &&
graphEdges(before) == graphEdges(after) &&
graphMap(before.Graph.Grants) == graphMap(after.Graph.Grants) &&
graphMap(before.Graph.Waits) == graphMap(after.Graph.Waits)
}
// graphStructureRow is the shape a reader asks for: which nodes, and the typed
// edges between them. Spawn, depends, context and adopt are separate facts, and
// a restart that kept the nodes while losing the edges kept a list, not a graph.
func graphStructureRow(before, after Observation) row {
return valueRow("run graph edges", "event sink (GraphDelta)",
"none observed", "fold GraphDelta events, if any survive",
graphEdges(before), graphEdges(after), false)
}
// graphSemanticsRow is the authority envelope and the reason a node waited.
// They are called out apart from the structure because nothing else in the
// system records them: a child's persisted metadata carries its model and its
// profile, and says nothing about what it was allowed to touch.
func graphSemanticsRow(before, after Observation) row {
return valueRow("run graph grants and waits", "event sink (GraphDelta)",
"none observed", "no durable source carries an authority envelope",
graphEnvelope(before), graphEnvelope(after), false)
}
func graphEnvelope(o Observation) string {
grants, waits := graphMap(o.Graph.Grants), graphMap(o.Graph.Waits)
if grants == "" && waits == "" {
return ""
}
return fmt.Sprintf("grants[%s] waits[%s]", orNone(grants), orNone(waits))
}
// childProvenanceRow is what the store still owns, which is the evidence behind
// an interrupted classification. It is reported as its own row so a reader can
// see the reconstruction the label stands on rather than trusting the label.
func childProvenanceRow(before, after Observation) row {
return valueRow("delegated runs the store owns", "SubagentStore, swept at boot",
"subagents/<ref>.meta.json", "ListSubagentsByParent; CleanupStaleRunning marks stale running children interrupted",
childSummary(before), childSummary(after), true)
}
// graphObligationRow is the last fallback a reader has: a fleet call the
// transcript records with no result is the host saying a fan-out was opened and
// never settled, even when nothing else remembers it.
func graphObligationRow(before, after Observation) row {
return row{
Semantic: "fan-out calls the transcript never settled", Authority: "the canonical transcript",
Artifact: "<stem>.jsonl", Reconstruction: "tool calls with no result",
Before: orNone(join(before.Obligation.UnansweredCalls)),
After: orNone(join(after.Obligation.UnansweredCalls)), Verdict: verdictStable,
}
}
// rebuiltGraph is what the production fold makes of this session's durable
// facts, join included: a record names its own execution, and an older one is
// aliased to its parent call only where the journal opened one by that id.
func rebuiltGraph(o Observation) execgraph.Result {
opened := make(map[string]bool, len(o.Executions))
for _, e := range o.Executions {
opened[e.ID] = true
}
children := make([]execgraph.ChildOutcome, 0, len(o.Children.Facts))
for _, f := range o.Children.Facts {
identity := delegation.ResolveExecutionIdentity(
sessionstore.SubagentMeta{ExecutionID: f.ExecutionID, ParentToolCallID: f.ParentToolCallID},
func(id string) bool { return opened[id] })
children = append(children, execgraph.ChildOutcome{
Execution: identity.Execution, Ref: f.Ref, Status: f.Status,
})
}
return execgraph.Rebuild(o.Executions, children, nil)
}
// rebuildStateRow compares the states the fold produces against the ones the
// dying process drew. Nodes whose owner disappeared are excluded and reported
// by their own row: putting those back as running is the one outcome this
// whole line of work exists to prevent.
func rebuildStateRow(before, after Observation) row {
rebuilt := rebuiltGraph(after)
cut := map[string]bool{}
for _, i := range rebuilt.Interrupted {
cut[i.Execution] = true
}
return valueRow("rebuilt node states", "execgraph.Rebuild over journal + store",
"<stem>.execution.jsonl + subagents/<ref>.meta.json",
"recomputed from durable facts, never restored",
statesExcept(graphOf(before), cut), statesExcept(rebuilt.Graph, cut), false)
}
// rebuildTopologyRow compares every typed edge. Spawn and depends are stated by
// the openings, adopt names its source, and context is derived — so a mismatch
// here says which of those four is not yet recoverable.
func rebuildTopologyRow(before, after Observation) row {
return valueRow("rebuilt topology", "execgraph.Rebuild over journal + store",
"<stem>.execution.jsonl", "spawn and depends stated, adopt named, context derived",
edgeSummary(graphOf(before)), edgeSummary(rebuiltGraph(after).Graph), false)
}
// rebuildEnvelopeRow is the authority and the wait provenance together: what
// each item was allowed to touch, and why admission was first refused.
func rebuildEnvelopeRow(before, after Observation) row {
return valueRow("rebuilt grants and wait causes", "execgraph.Rebuild over journal + store",
"<stem>.execution.jsonl", "recorded at the opening and at the first refusal",
envelopeSummary(graphOf(before)), envelopeSummary(rebuiltGraph(after).Graph), false)
}
// rebuildInterruptedRow accounts for the nodes the state row leaves out. They
// are not dropped: the fold names them as interruptions, split by whether they
// had reached a slot. A node the dying process showed live and the rebuild
// mentions nowhere would be a loss the state row could not see.
func rebuildInterruptedRow(before, after Observation) row {
rebuilt := rebuiltGraph(after)
var got []string
for _, i := range rebuilt.Interrupted {
kind := execjournal.InterruptedBeforeStart
if i.Started {
kind = execjournal.InterruptedDuringExecution
}
got = append(got, i.Execution+":"+kind)
}
sort.Strings(got)
return valueRow("nodes the rebuild will not call live", "execgraph.Rebuild over journal + store",
"<stem>.execution.jsonl", "an execution with no owner is named, never redrawn as running",
liveAtDeath(before), join(got), false)
}
// liveAtDeath is what the dying process still showed as someone's work.
func liveAtDeath(o Observation) string {
var out []string
for _, n := range o.Graph.Nodes {
if n.Kind == agentgraph.KindGroup {
continue
}
switch n.State {
case agentgraph.StateRunning:
out = append(out, n.ID+":"+execjournal.InterruptedDuringExecution)
case agentgraph.StateQueued, agentgraph.StatePending:
out = append(out, n.ID+":"+execjournal.InterruptedBeforeStart)
}
}
sort.Strings(out)
return join(out)
}
// rebuildIdentityRow is what a reader sees on a node beyond its state: the
// label it was dispatched under, and which worker ran it. Only one of those is
// recorded, so this row is where a remaining gap shows up rather than hiding
// inside an exact verdict about states.
func rebuildIdentityRow(before, after Observation) row {
return valueRow("rebuilt node identity", "execgraph.Rebuild over journal + store",
"<stem>.execution.jsonl", "label from the opening; the worker override is recorded nowhere",
identitySummary(graphOf(before)), identitySummary(rebuiltGraph(after).Graph), false)
}
func identitySummary(g agentgraph.Graph) string {
var out []string
for _, n := range g.Nodes {
if n.Kind != agentgraph.KindWorker {
continue
}
out = append(out, fmt.Sprintf("%s[label=%s profile=%s model=%s effort=%s]",
n.ID, orNone(n.Label), orNone(n.Profile), orNone(n.Model), orNone(n.Effort)))
}
sort.Strings(out)
return join(out)
}
// statesExcept renders worker states, skipping the executions whose owner is
// gone. Those are not a state the rebuild may assert, and comparing them would
// demand exactly the ghost this refuses to draw.
func statesExcept(g agentgraph.Graph, skip map[string]bool) string {
var out []string
for _, n := range g.Nodes {
if n.Kind == agentgraph.KindGroup || skip[n.ID] {
continue
}
out = append(out, n.ID+":"+string(n.State))
}
sort.Strings(out)
return join(out)
}
func edgeSummary(g agentgraph.Graph) string {
out := make([]string, 0, len(g.Edges))
for _, e := range g.Edges {
out = append(out, fmt.Sprintf("%s-%s->%s", e.From, e.Kind, e.To))
}
sort.Strings(out)
return join(out)
}
func envelopeSummary(g agentgraph.Graph) string {
var out []string
for _, n := range g.Nodes {
if n.Grant != "" {
out = append(out, n.ID+"="+string(n.Grant))
}
if n.Wait != "" {
out = append(out, n.ID+"@"+string(n.Wait))
}
}
sort.Strings(out)
return join(out)
}
// executionJournalRow is the record written before the work started, which is
// the only artifact here that can outlive an unfinished turn. It is what a
// restart reads to say a delegation existed at all.
func executionJournalRow(before, after Observation) row {
return valueRow("delegations the journal recorded", "execution journal, written before dispatch",
"<stem>.execution.jsonl", "folded from openings and settlings",
executionSummary(before), executionSummary(after), true)
}
// executionTopologyRow is the ordering a restart inherits. An item that never
// started is explained by what it was held behind, so the topology has to
// survive the process that knew it — and survive unchanged, or the explanation
// is a different graph's.
func executionTopologyRow(before, after Observation) row {
return valueRow("declared ordering between items", "the fan-out plan, recorded at the opening",
"<stem>.execution.jsonl", "read back with the openings, never re-derived",
topologySummary(before), topologySummary(after), true)
}
func topologySummary(o Observation) string {
var out []string
for _, e := range o.Executions {
if len(e.DependsOn) == 0 {
continue
}
up := append([]string(nil), e.DependsOn...)
sort.Strings(up)
out = append(out, e.ID+"<-"+strings.Join(up, "+"))
}
if len(out) == 0 {
return ""
}
sort.Strings(out)
return join(out)
}
// executionKindRow holds the two interruptions apart against what the dying
// process actually showed. The item the graph had running must come back as one
// that reached a slot; the one its dependency blocked must come back as one
// that did not. A classification agreeing with neither is a label, not a fact.
func executionKindRow(before, after Observation) row {
running := runningWorkers(before)
verdict := verdictHolds
if len(after.InterruptedExecutions) == 0 {
verdict = verdictNotMeasured
}
for _, e := range after.InterruptedExecutions {
want := execjournal.InterruptedBeforeStart
if slices.Contains(running, e.ID) {
want = execjournal.InterruptedDuringExecution
}
if e.Interruption() != want {
verdict = verdictViolated
}
}
return row{
Semantic: "how each interruption is classified", Authority: "the execution journal's start records",
Artifact: "<stem>.execution.jsonl", Reconstruction: "started at death -> during-execution, never started -> before-start",
Before: orNone(join(running)) + " running at death", After: interruptionKinds(after), Verdict: verdict,
}
}
func interruptionKinds(o Observation) string {
if len(o.InterruptedExecutions) == 0 {
return "none"
}
out := make([]string, 0, len(o.InterruptedExecutions))
for _, e := range o.InterruptedExecutions {
out = append(out, e.ID+":"+e.Interruption())
}
sort.Strings(out)
return join(out)
}
// executionContextRow is the other side of that record: what the next request
// is told. It must state provenance and never offer a continuation, so the
// count is checked against the interruptions rather than against a fixed
// number — a block with nothing behind it is a ghost in prose.
func executionContextRow(before, after Observation) row {
verdict := verdictHolds
if blocksOwed(before) != before.ModelSeesInterruptedExecution ||
blocksOwed(after) != after.ModelSeesInterruptedExecution {
verdict = verdictViolated
}
return row{
Semantic: "what the next request says about them", Authority: "control, projected per request",
Artifact: "none: derived, never stored", Reconstruction: "one block while an interrupted execution stands, none otherwise",
Before: executionBlocks(before), After: executionBlocks(after), Verdict: verdict,
}
}
func blocksOwed(o Observation) int {
if len(o.InterruptedExecutions) > 0 {
return 1
}
return 0
}
func executionBlocks(o Observation) string {
return fmt.Sprintf("%d block(s) for %d interrupted", o.ModelSeesInterruptedExecution, len(o.InterruptedExecutions))
}
func executionSummary(o Observation) string {
if len(o.Executions) == 0 {
return ""
}
open, started := 0, 0
for _, e := range o.Executions {
if e.Open() {
open++
}
if e.Started() {
started++
}
}
return fmt.Sprintf("%d opened, %d started, %d still open, %d with no owner here",
len(o.Executions), started, open, len(o.InterruptedExecutions))
}
func childrenInState(o Observation, status string) []string {
var out []string
for _, f := range o.Children.Facts {
if f.Status == status {
out = append(out, f.Ref)
}
}
return out
}
func childSummary(o Observation) string {
if len(o.Children.Facts) == 0 {
if o.Children.Err != "" {
return "error: " + o.Children.Err
}
return ""
}
counts := map[string]int{}
for _, f := range o.Children.Facts {
counts[f.Status]++
}
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
}
return fmt.Sprintf("%d child(ren): %s", len(o.Children.Facts), strings.Join(parts, " "))
}
// graphSummary is the fan-out as the dying process last saw it, which is the
// "before" every graph row is compared against.
func graphSummary(o Observation) string {
if len(o.Graph.Nodes) == 0 {
return "no fan-out"
}
counts := map[string]int{}
for _, n := range o.Graph.Nodes {
if n.Kind == agentgraph.KindWorker {
counts[string(n.State)]++
}
}
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
}
return fmt.Sprintf("%d node(s), %d edge(s): %s",
len(o.Graph.Nodes), len(o.Graph.Edges), strings.Join(parts, " "))
}
// graphArmInvalid refuses an arm that never reached the state it names. The
// running arms need a child in flight at death; every fan-out arm needs a
// fan-out at all. Reporting a smaller shape as the thing that was interrupted
// is how a probe answers a question nobody asked.
func graphArmInvalid(arm string, before Observation) string {
if len(before.Graph.Nodes) == 0 {
return "no fan-out was published before the process exited, so nothing was measured"
}
if arm == armGraphCompleted {
if len(runningWorkers(before)) > 0 {
return "the fan-out was still running at exit, so this is not the completed arm"
}
return ""
}
if len(runningWorkers(before)) == 0 {
return "no child was executing when the process died, so nothing was interrupted"
}
if arm == armGraphMixed {
return mixedArmInvalid(before)
}
return ""
}
// mixedArmInvalid holds the density arm to its own premise. Its whole claim is
// that one death carried every reachable state at once, and an arm that reached
// three of them measures a different scenario than the one it reports.
func mixedArmInvalid(before Observation) string {
var missing []string
for _, state := range wantedStates(armGraphMixed) {
if !holdsAll(agentgraph.Graph{Nodes: before.Graph.Nodes}, []agentgraph.NodeState{state}) {
missing = append(missing, string(state))
}
}
if len(missing) > 0 {
return "the fan-out never reached " + join(missing) + ", so the arm is not the mixed shape it reports"
}
if !carriesGrant(before, agentgraph.GrantRead) || !carriesGrant(before, agentgraph.GrantWrite) {
return "the fan-out carried only one kind of grant, so the envelope row measures one side of it"
}
return ""
}
func carriesGrant(o Observation, want agentgraph.Grant) bool {
for _, got := range o.Graph.Grants {
if got == string(want) {
return true
}
}
return false
}
// graphEdges renders the typed relations in a stable order. Sorting is not
// cosmetic: the fold keeps first-seen order, which two processes reach by
// different routes, and an unsorted comparison would report a reordering as a
// changed graph.
func graphEdges(o Observation) string {
if len(o.Graph.Edges) == 0 {
return ""
}
out := make([]string, 0, len(o.Graph.Edges))
for _, e := range o.Graph.Edges {
out = append(out, fmt.Sprintf("%s-%s->%s", e.From, e.Kind, e.To))
}
sort.Strings(out)
return join(out)
}
+602
View File
@@ -0,0 +1,602 @@
package main
import (
"context"
"fmt"
"os"
"sort"
"strings"
"sync/atomic"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/session/control"
)
// The interactive host-initiated arms, named for the one surface they measure: a
// person typing /<skill> into a live session. The headless counterpart is not
// measured here — it is caller-owned and its cancellation need not match, and
// reading one for the other is how a real number gets the wrong subject.
const (
armHostCompleted = "interactive-host-skill-completed"
armHostQueued = "interactive-host-skill-queued-crash"
armHostRunning = "interactive-host-skill-running-crash"
armHostCancel = "interactive-host-skill-cancel"
// The headless counterpart: the controller's own synchronous entry point,
// whose caller owns both the context and the cancellation. It is measured
// apart from the four above and compared to them, never read for them.
armHeadlessCompleted = "headless-host-skill-completed"
armHeadlessRunningCancel = "headless-host-skill-running-cancel"
armHeadlessQueuedCancel = "headless-host-skill-queued-cancel"
)
func headlessHostArm(name string) bool {
switch name {
case armHeadlessCompleted, armHeadlessRunningCancel, armHeadlessQueuedCancel:
return true
}
return false
}
// callerStopArm names the arms a caller stops through its own context. The
// controller never admitted these turns, so its own cancel holds nothing for
// them — reading one surface with the other's stop is a mistake this probe has
// already made once.
func callerStopArm(name string) bool {
return name == armHeadlessRunningCancel || name == armHeadlessQueuedCancel
}
func hostExecArm(name string) bool {
switch name {
case armHostCompleted, armHostQueued, armHostRunning, armHostCancel:
return true
}
return headlessHostArm(name)
}
// queuedHostArm names the arms whose ceiling is filled before the invocation.
func queuedHostArm(name string) bool {
return name == armHostQueued || name == armHeadlessQueuedCancel
}
// The four identities one host-started run can be known by. They are printed
// apart because this is the first surface where they may not coincide: for a
// model-delegated run the execution and the parent call are one string, and
// that has only ever been true because a tool call produced every durable
// execution there was.
const (
probeHostDispatch = "probe:host-dispatch-id"
probeHostChildRef = "probe:host-child-ref"
probeHostParentID = "probe:host-parent-lineage"
probeHostExecution = "probe:host-execution-id"
// probeHostStanding is what the call itself was doing at the reading:
// admitted or held back, its child in the provider or not.
probeHostStanding = "probe:host-standing"
// probeHostAuthority is the snapshot at the same instant: what a reader
// starting from the authority would hold, on either surface.
probeHostAuthority = "probe:host-authority"
)
// hostSlashInput is what a person types. The task carries the child sentinel so
// the scripted provider knows how this arm's child behaves.
func hostSlashInput(arm string) string {
return "/" + probeSkillName + " " + hostChildSentinel(arm) + " host-started work"
}
// runHostExecConstruct drives one interactive slash invocation and dies where
// this arm is about. The queued arm fills the ceiling first with a background
// fleet, which is dispatched through Run rather than Submit: Run is never
// admitted through the turn gate, so the slash the arm measures can still be.
func runHostExecConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
if queuedHostArm(arm) {
go func() { _ = ctrl.Run(ctx, fanOutTurn(turn+1, fleetHolderSentinel)) }()
if err := waitForCeiling(prov); err != nil {
return err
}
}
// Each surface is entered and stopped by the owner it has: the controller's
// gate for a turn it admitted, and the caller's own context for a
// synchronous call it never did.
callerCtx, stopCaller := context.WithCancel(ctx)
defer stopCaller()
var answered atomic.Bool
if headlessHostArm(arm) {
go func() {
_, _ = ctrl.RunSubagentProfile(callerCtx, probeSkillName, hostHeadlessTask(arm), false)
answered.Store(true)
}()
} else {
go ctrl.Submit(hostSlashInput(arm))
}
if err := waitForHostExec(ctrl, prov, arm, &answered); err != nil {
return err
}
stop := ""
switch {
case arm == armHostCancel:
stop = stopHostExec(ctrl, prov)
case callerStopArm(arm):
stop = stopHostCaller(ctrl, prov, arm, stopCaller)
}
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
recordHostIdentities(&obs, ctrl, sink, prov, arm)
if stop != "" {
obs.Progress[probeStopReached] = []string{stop}
}
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
// recordHostIdentities writes down all four names this run could be known by,
// at one instant. A single "identity: none" would hide which of them exists and
// which does not, and that difference is the whole question.
func recordHostIdentities(obs *Observation, ctrl *control.Controller, sink *graphSink, prov *scripted, arm string) {
obs.Progress[probeChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(hostChildSentinel(arm))))}
obs.Progress[probeHostDispatch] = []string{orNone(join(hostDispatchIDs(sink)))}
obs.Progress[probeHostChildRef] = []string{orNone(join(hostChildRefs(*obs)))}
obs.Progress[probeHostParentID] = []string{orNone(join(hostParentLineage(*obs)))}
obs.Progress[probeHostExecution] = []string{orNone(join(hostExecutions(*obs)))}
obs.Progress[probeHostStanding] = []string{hostStanding(ctrl, prov, arm)}
// The authority, read at the same instant. It is what a reader starts from
// on either surface, so it is where the two are compared — the delta stream
// exists only where a sink does, and a synchronous caller has none.
obs.Progress[probeHostAuthority] = []string{hostAuthorityNodes(ctrl)}
}
// hostHeadlessTask is what the controller's own entry point is asked to do.
func hostHeadlessTask(arm string) string {
return hostChildSentinel(arm) + " host-started work"
}
func hostAuthorityNodes(ctrl *control.Controller) string {
var out []string
for _, n := range ctrl.ExecutionGraph().Graph.Nodes {
if ofHostExecution(n.ID) {
out = append(out, n.ID+":"+orDash(string(n.State)))
}
}
sort.Strings(out)
return orNone(join(out))
}
// stopHostCaller stops work through the only owner a synchronous call has: the
// context its caller passed in. The controller's gate never admitted this turn,
// so asking it to stop one would be reading a surface with another's stop.
func stopHostCaller(ctrl *control.Controller, prov *scripted, arm string, stop func()) string {
stopped := time.Now()
stop()
child := hostChildSentinel(arm)
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) && !prov.fleets.held(childLeft(child)) {
time.Sleep(50 * time.Millisecond)
}
live := "gone"
if prov.fleets.held(childEntered(child)) && !prov.fleets.held(childLeft(child)) {
live = "still running"
}
return fmt.Sprintf("turn=%s work=%s ctx=%t after=%s", turnStanding(ctrl, arm), live,
prov.fleets.held(childCtxDone(child)), time.Since(stopped).Round(time.Second))
}
func hostChildSentinel(arm string) string {
if arm == armHostCompleted || arm == armHeadlessCompleted {
return childDone
}
return childHang
}
// hostDispatchIDs are the synthetic event ids the host minted so the child's
// activity could nest under something on screen. They are UI identity, not a
// call the model made, which is why no persisted lineage may carry one.
func hostDispatchIDs(sink *graphSink) []string {
var out []string
for id := range sink.toolIDs() {
if strings.HasPrefix(id, "slash-skill-") {
out = append(out, id)
}
}
sort.Strings(out)
return out
}
// hostChildRefs are the store's own artifact ids. They exist only once a child
// transcript has been prepared, which is why they cannot serve as the identity
// of work the scheduler is still holding back.
func hostChildRefs(o Observation) []string {
var out []string
for _, f := range o.Children.Facts {
if f.Kind == "skill" {
out = append(out, f.Ref+"="+f.Status)
}
}
sort.Strings(out)
return out
}
func hostParentLineage(o Observation) []string {
var out []string
for _, f := range o.Children.Facts {
if f.Kind == "skill" {
out = append(out, f.Ref+"→"+orDash(f.ParentToolCallID))
}
}
sort.Strings(out)
return out
}
func hostExecutions(o Observation) []string {
var out []string
for _, e := range o.Executions {
if ofHostExecution(e.ID) || strings.Contains(e.Name, probeSkillName) {
out = append(out, e.ID)
}
}
sort.Strings(out)
return out
}
// ofHostExecution matches both names this work can go by: the execution the host
// owns, and the synthetic event id it mints for the screen. Reading only the
// second is how an arm reports absence when what changed is the identity.
func ofHostExecution(id string) bool {
return strings.HasPrefix(id, "host-") || strings.HasPrefix(id, "slash-skill-")
}
func hostStanding(ctrl *control.Controller, prov *scripted, arm string) string {
return fmt.Sprintf("turn=%v ceiling=%t child=%t", ctrl.Running(),
prov.fleets.held(childEntered(childHold)),
prov.fleets.held(childEntered(hostChildSentinel(arm))))
}
func waitForCeiling(prov *scripted) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
if prov.fleets.held(childEntered(childHold)) {
return nil
}
time.Sleep(50 * time.Millisecond)
}
return errUnexpected("a fan-out holding the ceiling", "none reached the provider")
}
// waitForHostExec blocks until the invocation stands where this arm dies. The
// queued arm asks a negative — the child never arrived while the ceiling was
// full — so it settles and asks again rather than reading a race.
func waitForHostExec(ctrl *control.Controller, prov *scripted, arm string, answered *atomic.Bool) error {
child := hostChildSentinel(arm)
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
switch {
case queuedHostArm(arm):
// In flight, read through whichever owner this surface has: a turn
// the controller admitted answers its gate, and a synchronous call
// it never admitted answers only by returning.
if hostInFlight(ctrl, arm, answered) && !prov.fleets.held(childEntered(child)) {
time.Sleep(2 * time.Second)
if prov.fleets.held(childEntered(child)) {
return errUnexpected("a host invocation the scheduler was still holding back", "its child ran")
}
return nil
}
case arm == armHostCompleted:
if prov.fleets.held(childLeft(child)) && !ctrl.Running() {
return nil
}
case arm == armHeadlessCompleted:
if prov.fleets.held(childLeft(child)) {
return nil
}
default:
if prov.fleets.held(childEntered(child)) {
return nil
}
}
time.Sleep(50 * time.Millisecond)
}
return errUnexpected("a host invocation "+hostWanted(arm), hostStanding(ctrl, prov, arm))
}
// hostInFlight reports that the invocation has not answered yet, asked of the
// owner the surface actually has.
func hostInFlight(ctrl *control.Controller, arm string, answered *atomic.Bool) bool {
if headlessHostArm(arm) {
return !answered.Load()
}
return ctrl.Running()
}
func hostWanted(arm string) string {
switch {
case queuedHostArm(arm):
return "held back by a full ceiling"
case arm == armHostCompleted || arm == armHeadlessCompleted:
return "that finished"
}
return "with its child inside the provider"
}
// stopHostExec stops the work the way a person does: the turn's own cancel. A
// slash invocation is admitted through the same gate a typed message is, so
// that gate is the owner of its stop.
func stopHostExec(ctrl *control.Controller, prov *scripted) string {
stopped := time.Now()
ctrl.Cancel()
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) && ctrl.Running() {
time.Sleep(50 * time.Millisecond)
}
live := "gone"
if prov.fleets.held(childEntered(childHang)) && !prov.fleets.held(childLeft(childHang)) {
live = "still running"
}
return fmt.Sprintf("turn=%s work=%s ctx=%t after=%s", turnStanding(ctrl, armHostCancel), live,
prov.fleets.held(childCtxDone(childHang)), time.Since(stopped).Round(time.Second))
}
// The verdicts this arm needs. They are separate words because the failure
// modes are separate facts, and one label over all of them would say less than
// any of them does.
const (
// verdictIdentityGap is work the host admitted that no identity outliving
// the process names. It is not about a lost record: none of the candidate
// identities can carry one.
verdictIdentityGap = "IDENTITY-GAP"
// verdictResultDurable is an invocation whose child record survives while
// the execution that produced it does not. A reader can see that a child of
// this name was cut; nothing says which invocation it belonged to.
verdictResultDurable = "record-only"
)
func hostExecRows(arm string, before, after Observation) []row {
return []row{
hostAdmissionRow(arm, before),
hostIdentityRow(before),
hostGraphRow(before),
hostJournalRow(before, after),
hostStoreRow(before, after),
hostLineageOracleRow(before, after),
hostPreStartIdentityRow(arm, before, after),
hostCancelRow(arm, before),
hostRestartRow(arm, before, after),
}
}
func hostAdmissionRow(arm string, before Observation) row {
entered := firstProgress(before, probeChildEntered) == "true"
answer, verdict := "admitted: the child reached the provider", verdictHolds
switch {
case queuedHostArm(arm) && !entered:
answer = "refused: the ceiling was full and the child never arrived"
case queuedHostArm(arm):
answer, verdict = "not held back: the child ran with the ceiling full", verdictViolated
case !entered:
answer, verdict = "the child never arrived", verdictNotMeasured
}
return row{
Semantic: "does it ask the session scheduler", Authority: skillAuthority,
Artifact: "none observed", Reconstruction: "in-process only",
Before: answer + " [" + firstProgress(before, probeHostStanding) + "]",
After: "—", Verdict: verdict,
}
}
// hostIdentityRow prints all four names at once. This is the row the arm exists
// for: for a model-delegated run the execution and the parent call are the same
// string, and that has only ever held because a tool call produced every durable
// execution there was.
func hostIdentityRow(before Observation) row {
return row{
Semantic: "the four names this run could be known by", Authority: crossAuthority,
Artifact: "the event stream, subagents/<ref>.meta.json, <stem>.execution.jsonl",
Reconstruction: "read apart, because they may not coincide here",
Before: fmt.Sprintf("dispatch=%s child=%s lineage=%s execution=%s",
firstProgress(before, probeHostDispatch), firstProgress(before, probeHostChildRef),
firstProgress(before, probeHostParentID), firstProgress(before, probeHostExecution)),
After: "—", Verdict: verdictHolds,
}
}
func hostGraphRow(before Observation) row {
var drawn []string
for _, n := range before.Graph.Nodes {
if ofHostExecution(n.ID) {
drawn = append(drawn, n.ID+":"+string(n.State))
}
}
sort.Strings(drawn)
// The stream and the authority are reported together because only one of
// them is surface-independent: a synchronous caller has no sink to draw on,
// and a reader on either surface starts from the snapshot regardless.
authority := firstProgress(before, probeHostAuthority)
return row{
Semantic: "the graph, drawn and reconstructable",
Authority: liveAuthority + " and " + authorityName,
Artifact: "<stem>.execution.jsonl", Reconstruction: "the stream where a sink exists; the authority always",
Before: fmt.Sprintf("stream=%s authority=%s", orNone(join(drawn)), authority),
After: "—", Verdict: presenceVerdict(authority != "none"),
}
}
func hostJournalRow(before, after Observation) row {
return presenceRow("does the journal record it", journalAuthority, "<stem>.execution.jsonl",
"ExecutionHistory over the whole session", hostExecutions(before), hostExecutions(after))
}
func hostStoreRow(before, after Observation) row {
return presenceRow("what the store holds for it", storeAuthority, "subagents/<ref>.meta.json",
"ListSubagentsByParent over the transcript stem", hostChildRefs(before), hostChildRefs(after))
}
// hostLineageOracleRow is the contract Step 8 respected and this must not
// weaken. A synthetic host event id is UI identity; persisting one as a parent
// would say the model issued a call it never made.
func hostLineageOracleRow(before, after Observation) row {
leaked := hostSyntheticLeaks(before)
leaked = append(leaked, hostSyntheticLeaks(after)...)
empty := hostLineageEmpty(before) && hostLineageEmpty(after)
answer, verdict := "no persisted lineage names a synthetic host id, and every parent is empty", verdictHolds
switch {
case len(leaked) > 0:
answer, verdict = "a synthetic host id was persisted as lineage: "+join(leaked), verdictViolated
case !empty:
answer, verdict = "a host-started run was filed under a parent call: "+
join(hostParentLineage(after)), verdictViolated
case len(hostChildRefs(before))+len(hostChildRefs(after)) == 0:
verdict = verdictNotMeasured
answer = "no child record exists to carry a lineage"
}
return row{
Semantic: "synthetic host id persisted as lineage", Authority: storeAuthority,
Artifact: "subagents/<ref>.meta.json", Reconstruction: "every persisted parent, read for host event ids",
Before: answer, After: "—", Verdict: verdict,
}
}
func hostSyntheticLeaks(o Observation) []string {
var out []string
for _, f := range o.Children.Facts {
if strings.HasPrefix(f.ParentToolCallID, "slash-skill-") {
out = append(out, f.Ref+"→"+f.ParentToolCallID)
}
}
return out
}
func hostLineageEmpty(o Observation) bool {
for _, f := range o.Children.Facts {
if f.Kind == "skill" && strings.TrimSpace(f.ParentToolCallID) != "" {
return false
}
}
return true
}
// hostPreStartIdentityRow is what decides where an identity would have to be
// created. Work the scheduler is holding back has no child artifact yet, so a
// store ref cannot name it; if nothing else survives the process either, no
// candidate identity exists at the moment the work first became real.
func hostPreStartIdentityRow(arm string, before, after Observation) row {
survives := len(hostExecutions(after)) > 0
answer := "nothing that outlives the process names this work"
verdict := verdictIdentityGap
if survives {
answer, verdict = "an execution the journal opened: "+join(hostExecutions(after)), verdictHolds
}
if !queuedHostArm(arm) && firstProgress(before, probeChildEntered) != "true" {
verdict = verdictNotMeasured
}
return row{
Semantic: "an identity that exists before the child does", Authority: crossAuthority,
Artifact: "<stem>.execution.jsonl",
Reconstruction: "the candidates, read against what the work needs",
Before: hostAtDeath(arm), After: answer, Verdict: verdict,
}
}
func hostAtDeath(arm string) string {
switch {
case queuedHostArm(arm):
return "admitted-domain work, held back, no child artifact yet"
case arm == armHostCompleted || arm == armHeadlessCompleted:
return "ran and returned"
}
return "executing inside the provider"
}
func hostCancelRow(arm string, before Observation) row {
if arm != armHostCancel && !callerStopArm(arm) {
return row{
Semantic: "what a stop reaches", Authority: "the turn's own cancel",
Artifact: "none observed", Reconstruction: "in-process only",
Before: "not stopped in this arm", After: "—", Verdict: verdictNotMeasured,
}
}
return row{
Semantic: "what a stop reaches", Authority: "the turn's own cancel",
Artifact: "subagents/<ref>.meta.json",
Reconstruction: "the live stop and the record it left, kept apart",
Before: firstProgress(before, probeStopReached) + " store=" + orNone(join(hostChildRefs(before))),
After: "—", Verdict: verdictHolds,
}
}
// hostRestartRow classifies what the next process inherits. An answer that
// survived while its execution did not is not a loss to the user, so it is not
// called one; work that was executing and left nothing is.
func hostRestartRow(arm string, before, after Observation) row {
exec, children := hostExecutions(after), hostChildRefs(after)
rebuilt := hostRebuilt(after)
answer := fmt.Sprintf("execution=%s store=%s rebuilt=%s",
orNone(join(exec)), orNone(join(children)), orNone(join(rebuilt)))
return row{
Semantic: "what a restart inherits", Authority: rebuildAuthority,
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
Reconstruction: "execgraph.Rebuild, then the store beside it",
Before: hostAtDeath(arm), After: answer,
Verdict: hostRestartVerdict(arm, before, exec, children),
}
}
// hostRestartVerdict is driven by what a next process can actually hold, not by
// what the arm expected. A child record with nothing to place it is not silence:
// a reader can still see that a child of this name was cut. What it cannot do is
// say which invocation that was, and the identity row answers that separately —
// folding the two would report a surviving record as a total loss.
func hostRestartVerdict(arm string, before Observation, exec, children []string) string {
switch {
case len(exec) > 0:
return verdictHolds
case len(children) > 0:
return verdictResultDurable
case firstProgress(before, probeChildEntered) == "true" || queuedHostArm(arm):
return graphLostSilent
default:
return verdictNotMeasured
}
}
// hostRebuilt is what a restart draws for this invocation. The whole rebuilt
// graph is wider than the arm — the queued arm fills its ceiling with a fan-out,
// and a fan-out records everything — so reading its nodes as the invocation's is
// how an arm reports a gap as closed by evidence belonging to something else.
func hostRebuilt(o Observation) []string {
var out []string
for _, n := range rebuiltGraph(o).Graph.Nodes {
if n.Kind == agentgraph.KindGroup {
continue
}
if ofHostExecution(n.ID) || strings.Contains(n.Label, probeSkillName) {
out = append(out, n.ID+":"+string(n.State))
}
}
sort.Strings(out)
return out
}
// hostExecArmInvalid reports the premise this arm could not establish.
func hostExecArmInvalid(arm string, before Observation) string {
entered := firstProgress(before, probeChildEntered) == "true"
switch {
case queuedHostArm(arm) && entered:
return "the invocation ran while the ceiling was meant to be full"
case !queuedHostArm(arm) && !entered:
return "the invocation's child never reached the provider"
case headlessHostArm(arm) && len(hostDispatchIDs2(before)) > 0:
return "a synchronous call minted a dispatch id, so this was not the headless surface"
case !headlessHostArm(arm) && len(hostDispatchIDs2(before)) == 0:
return "the host minted no dispatch id, so this was not the slash surface"
}
return ""
}
// hostDispatchIDs2 reads the dispatch id back off the observation, where the
// construct phase recorded what only a live sink could see.
func hostDispatchIDs2(o Observation) []string {
if got := firstProgress(o, probeHostDispatch); got != "none" && got != "unrecorded" {
return strings.Split(got, ", ")
}
return nil
}
+163
View File
@@ -0,0 +1,163 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"reasonix/internal/session/control"
)
// Two trees, read at once. A stop aimed at a turn must close the turn's own
// tree and leave the job's alone; an explicit job stop does the reverse, and
// reading only one of them is how a probe calls a violated handoff correct. The
// manager's own root needs no separate question: a job still running with its
// child's context open is the root answering directly.
const (
// armLineageForeground is the positive control: it proves the reading can
// see an ordinary cancellation arrive.
armLineageForeground = "lineage-foreground-cancel"
// armLineageBackground is the one under suspicion.
armLineageBackground = "lineage-background-cancel"
// armLineageJobKill is the other positive control: without it, a reading
// that could never observe a job's own cancellation would make the arm
// above pass for the wrong reason.
armLineageJobKill = "lineage-job-kill"
)
func lineageArm(name string) bool {
switch name {
case armLineageForeground, armLineageBackground, armLineageJobKill:
return true
}
return false
}
// runLineageConstruct drives one delegation, stops it the way this arm's owner
// stops it, and reads both trees afterwards. It records six facts, kept apart
// on purpose: a context that closed and a call that returned are different
// events, and only their combination says whether a cancellation propagated or
// something else ended the work.
func runLineageConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
ctrl.Send(fanOutTurn(turn+1, taskSentinel))
if err := waitFor(60*time.Second, func() bool { return prov.fleets.held(childEntered(childHang)) }); err != nil {
return errUnexpected("a delegated child inside the provider", "none arrived")
}
before := lineageReading(ctrl, prov, "before")
switch arm {
case armLineageJobKill:
jobs := ctrl.Jobs()
if len(jobs) == 0 {
return errUnexpected("a background job to stop", "none running")
}
for _, job := range jobs {
ctrl.CancelJob(job.ID)
}
default:
ctrl.Cancel()
}
// Long enough for a cancellation that was going to arrive to have arrived.
time.Sleep(3 * time.Second)
after := lineageReading(ctrl, prov, "after")
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
obs.Progress[probeLineage] = []string{before, after}
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
const probeLineage = "probe:lineage"
// lineageReading is the state of both trees at one instant, in the terms the
// rows judge: the turn's gate, the job's own liveness, the child's context, the
// child's call, and the durable record the orchestration keeps.
func lineageReading(ctrl *control.Controller, prov *scripted, at string) string {
return fmt.Sprintf("%s turn=%s job=%s childCtx=%s childCall=%s record=%s",
at, turnGateStanding(ctrl), jobStanding(ctrl),
yesNo(prov.fleets.held(childCtxDone(childHang)), "cancelled", "open"),
yesNo(prov.fleets.held(childLeft(childHang)), "returned", "inside"),
loneRecordStanding(ctrl))
}
func turnGateStanding(ctrl *control.Controller) string {
if ctrl.Running() {
return "active"
}
return "ended"
}
func jobStanding(ctrl *control.Controller) string {
if len(ctrl.Jobs()) > 0 {
return "running"
}
return "gone"
}
func loneRecordStanding(ctrl *control.Controller) string {
for _, e := range control.ExecutionHistory(ctrl.SessionPath()) {
if e.ID == parallelLoneNodeID {
return lifecycleOf(e)[1:]
}
}
return "absent"
}
func yesNo(ok bool, yes, no string) string {
if ok {
return yes
}
return no
}
func waitFor(within time.Duration, ready func() bool) error {
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
if ready() {
return nil
}
time.Sleep(50 * time.Millisecond)
}
return fmt.Errorf("timed out")
}
// lineageRows read the two trees against the ownership each arm is about. The
// three sets of expectations are deliberately different: a fix that closes
// everything on a stop satisfies the foreground arm and violates both others.
func lineageRows(arm string, before Observation) []row {
at := reading(before.Progress[probeLineage], 1)
turnEnded := strings.Contains(at, "turn=ended")
jobLive := strings.Contains(at, "job=running")
childCancelled := strings.Contains(at, "childCtx=cancelled")
recordOpen := !strings.Contains(at, "settled") && !strings.Contains(at, "record=absent")
rows := []row{lineageRow("the turn the stop was aimed at", at, "ends", turnEnded)}
if arm == armLineageForeground {
return append(rows,
lineageRow("the child's own context", at, "closes with the turn that owned it", childCancelled),
lineageRow("the delegation record", at, "is let go of", !recordOpen))
}
if arm == armLineageJobKill {
return append(rows[:0],
lineageRow("the job the stop was aimed at", at, "ends", !jobLive),
lineageRow("the child's own context", at, "closes with the job that owned it", childCancelled),
lineageRow("the delegation record", at, "is let go of", !recordOpen))
}
return append(rows,
lineageRow("the job the turn no longer owns", at, "keeps running", jobLive),
lineageRow("the child's own context", at, "is not closed by another owner's stop", !childCancelled),
lineageRow("the delegation record", at, "stays open while its child runs", recordOpen))
}
func lineageRow(semantic, got, want string, ok bool) row {
return row{
Semantic: semantic, Authority: "both trees, read at the same instant",
Artifact: "none: in-process state", Reconstruction: want,
Before: want, After: orNone(got), Verdict: held(ok),
}
}
+356
View File
@@ -0,0 +1,356 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"slices"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
)
// The four single-delegation arms. A lone task is not another executor: it
// reaches the same RunProfileSpec a fleet item does and asks the same scheduler
// for a slot. What these ask is whether that shared chain is wired to the
// durable authority the fan-out arms established — and where, exactly, it stops
// being wired, which is a different question from "does it have a graph".
const (
// armTaskCompleted lets the delegation finish and the turn close. Nothing is
// interrupted, so a loss here is not about interruption: it says the run's
// provenance was never durable at all.
armTaskCompleted = "task-completed"
// armTaskRunning kills the process with the child mid-execution, the same
// death the fan-out arms take.
armTaskRunning = "task-running"
// armTaskFgQueued is the live-truth arm: the ceiling is full when the
// delegation asks, so the scheduler is holding it back. What the parent and
// the graph say while that is true is the whole question — a picture that
// says running while nothing has started is wrong before any restart.
armTaskFgQueued = "task-foreground-queued"
// armTaskBgQueued is the one the foreground arms cannot stand in for: a
// background run hands its execution to a job and asks for a slot inside it,
// so the parent tool call has already returned when the refusal happens.
armTaskBgQueued = "task-background-queued"
// armTaskBgRunning is the same handoff with the slot granted, which is where
// ownership of the terminal moves from the caller to the job.
armTaskBgRunning = "task-background-running"
// The cancellation arms. They ask the one question the five before them
// cannot: who owns the ending when work is stopped, on each side of the
// boundary that decides whether it ever ran. A child the scheduler never
// admitted produced nothing, so the store owes it no terminal at all.
armTaskFgQueuedCancel = "task-foreground-queued-cancel"
armTaskBgQueuedCancel = "task-background-queued-cancel"
armTaskFgRunningCancel = "task-foreground-running-cancel"
armTaskBgRunningCancel = "task-background-running-cancel"
)
func cancelTaskArm(name string) bool {
switch name {
case armTaskFgQueuedCancel, armTaskBgQueuedCancel, armTaskFgRunningCancel, armTaskBgRunningCancel:
return true
}
return false
}
// beforeStartArm names the arms cancelled while the scheduler still held the
// delegation, which is the half of the boundary the store must stay out of.
func beforeStartArm(name string) bool {
return name == armTaskFgQueuedCancel || name == armTaskBgQueuedCancel
}
func loneTaskArm(name string) bool {
switch name {
case armTaskCompleted, armTaskRunning, armTaskFgQueued, armTaskBgQueued, armTaskBgRunning:
return true
}
return cancelTaskArm(name)
}
// queuedTaskArm names the arms that fill the ceiling before delegating, so the
// scheduler has to refuse the delegation admission.
func queuedTaskArm(name string) bool {
return name == armTaskFgQueued || name == armTaskBgQueued || beforeStartArm(name)
}
// childEntered names the record a child leaves when it reaches the provider.
// The queued arms ask a negative — that one never did — which nothing else in
// the probe can answer.
func childEntered(sentinel string) string { return "child-entered:" + sentinel }
// childLeft names the record a child leaves when it returns. Entered without
// left is a child still inside the provider — the state a cancellation that
// never arrived leaves behind.
func childLeft(sentinel string) string { return "child-left:" + sentinel }
// childCtxDone names the record a child leaves when its own context closes,
// which is a different fact from having returned: a run whose caller stopped
// waiting returns to nobody with its context still open.
func childCtxDone(sentinel string) string { return "child-ctxdone:" + sentinel }
// probeChildEntered is where that answer rides into the observation. It is not
// a status the kernel emits: the key names the probe so no row reads it as one.
const probeChildEntered = "probe:delegated-child-entered"
func backgroundTaskArm(name string) bool {
switch name {
case armTaskBgQueued, armTaskBgRunning, armTaskBgQueuedCancel, armTaskBgRunningCancel,
armLineageBackground, armLineageJobKill, armCancelBackground, armFleetActiveStore:
return true
}
return false
}
// The sentinels one turn carries. The queued arm names the holder as well: its
// ceiling has to be occupied before the delegation asks for a slot, or the
// refusal it is built to record never happens.
const (
taskSentinel = "PROBE-TASK"
taskCallID = "probe_task"
taskHoldsFirst = fleetHolderSentinel + " " + taskSentinel
)
// loneTaskCall is the delegation itself, dispatched through the tool a model
// calls. Nothing here reaches past the tool's own arguments: the probe measures
// the production path, so it must enter it the way a model does.
func loneTaskCall(arm string) []provider.Chunk {
prompt, description := childDone+" lone task", "completes"
switch {
case arm == armFleetActiveStore:
// This delegation is the other occupant of the ceiling its arm shares
// with a fan-out, and its own child is the only thing that can say the
// slot is taken. Holding is how it hangs and says so at once.
prompt, description = childHold+" lone task", "holds a slot to the death"
case arm != armTaskCompleted:
prompt, description = childHang+" lone task", "still executing at death"
}
args, _ := json.Marshal(map[string]any{
"prompt": prompt, "description": description,
"run_in_background": backgroundTaskArm(arm),
})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: taskCallID, Name: "task", Arguments: string(args)},
}}
}
// probeStepIDs are the host's open steps, in the order they have to be signed.
// The arm whose turn ends has to close them: a delivery is refused while the
// list is open, and a todo_write that marks an item completed on its own is
// refused too — the sign-off is the only door.
func probeStepIDs() []string { return []string{"probe_step_01", "probe_step_02", "probe_step_03"} }
// signStep signs off one step per round, which is what the host accepts: the
// sign-off promotes the next item, so two in one round sign against a list that
// no longer stands.
func signStep(id string) []provider.Chunk {
args, _ := json.Marshal(map[string]any{
"step_id": id,
"result": "the probe drove this step to its end",
"evidence": []map[string]any{
{"kind": "manual", "summary": "the probe established this state directly"},
},
})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: "probe_sign_" + id, Name: "complete_step", Arguments: string(args)},
}}
}
// closesItsTurn names the arms whose turn has to reach its end. A turn that
// called a tool cannot deliver while the host's step list is open, so these
// sign it off one step per round — the sign-off promotes the next item, and two
// in one round would sign against a list that no longer stands.
func closesItsTurn(arm string) bool {
return arm == armTaskCompleted || arm == armSkillCompleted || arm == armReadOnlySkillCompleted
}
// loneTaskSentinels is what this arm's one prompt carries.
func loneTaskSentinels(arm string) string {
if queuedTaskArm(arm) {
return taskHoldsFirst
}
return taskSentinel
}
// runLoneTaskConstruct drives one delegation and dies at the moment this arm is
// about. The completed arm is the exception that proves the rest: it lets the
// turn close, so what it loses was never about dying inside one.
func runLoneTaskConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
// One turn, whatever the arm needs in it: a session admits no second one. The
// queued arm's ceiling is filled by a background fleet named in the same
// prompt, whose own child says when the slot is taken.
prompt := fanOutTurn(turn+1, loneTaskSentinels(arm))
switch {
case arm == armTaskCompleted:
if err := ctrl.Run(ctx, prompt); err != nil {
return fmt.Errorf("delegating turn: %w", err)
}
case guardedArm(arm):
// A stop belongs to whoever admitted the turn, so an arm that is going
// to be stopped is admitted the way a person's input is.
ctrl.Send(prompt)
if err := waitForLoneTask(sink, prov, arm); err != nil {
return err
}
default:
go func() { _ = ctrl.Run(ctx, prompt) }()
if err := waitForLoneTask(sink, prov, arm); err != nil {
return err
}
}
stop := ""
if cancelTaskArm(arm) {
stop = stopDelegation(ctrl, prov, arm)
}
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
if stop != "" {
obs.Progress[probeStopReached] = []string{stop}
}
if queuedTaskArm(arm) {
// Whether the delegation's own child ever ran is the fact the live rows
// are read against, and only the scripted provider knows it.
obs.Progress[probeChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(childHang)))}
}
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
// stopDelegation stops the work the way a person does — the turn's own cancel
// for a foreground run, the job list and a kill for a backgrounded one — and
// waits for the orchestration to let go of it. What each layer holds then is
// this arm's whole question, so nothing is read until the record closes.
func stopDelegation(ctrl *control.Controller, prov *scripted, arm string) string {
stopped := time.Now()
if backgroundTaskArm(arm) {
for _, job := range ctrl.Jobs() {
ctrl.CancelJob(job.ID)
}
} else {
ctrl.Cancel()
}
// Wait for the record to close, but do not demand it: whether a stop reaches
// the work it was aimed at is what this arm measures, and an arm that failed
// when it did not would report the finding as its own premise breaking.
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) && !settledLone(ctrl) {
time.Sleep(50 * time.Millisecond)
}
live := "gone"
if prov.fleets.held(childEntered(childHang)) && !prov.fleets.held(childLeft(childHang)) {
live = "still running"
}
return fmt.Sprintf("turn=%s work=%s after=%s", turnStanding(ctrl, arm), live,
time.Since(stopped).Round(time.Second))
}
func settledLone(ctrl *control.Controller) bool {
for _, e := range control.ExecutionHistory(ctrl.SessionPath()) {
if e.ID == parallelLoneNodeID && !e.SettledAt.IsZero() {
return true
}
}
return false
}
// waitForLoneTask blocks until the delegation stands where this arm dies. The
// foreground arms read the live graph; the background ones cannot — a handed-off
// run publishes no node — so they read the progress the parent was told.
func waitForLoneTask(sink *graphSink, prov *scripted, arm string) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
if loneTaskReady(sink, prov, arm) {
// A refusal is a state nothing announces, so the arm holds still and
// asks again: a child that was merely slow to start would have
// arrived by now, and the death would be measuring a race.
if beforeStartArm(arm) || arm == armTaskFgQueued {
time.Sleep(2 * time.Second)
return refusalHolding(prov, arm)
}
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected("a lone delegation "+loneTaskWanted(arm),
nodeStates(graph)+" progress="+fmt.Sprint(sink.phaseSeries()))
}
// refusalHolding reports the arm's premise once more, after the settle: the
// delegation's child must still not have run.
func refusalHolding(prov *scripted, arm string) error {
if prov.fleets.held(childEntered(childHang)) {
return errUnexpected("a delegation the scheduler was still holding back", "its child ran")
}
return nil
}
func loneTaskReady(sink *graphSink, prov *scripted, arm string) bool {
graph, _ := sink.snapshot()
switch arm {
case armTaskFgQueued, armTaskFgQueuedCancel:
// The node is drawn — the delegation reached the graph — and its child
// has not started, which with the ceiling full is the refusal.
return loneNodeDrawn(graph) && !prov.fleets.held(childEntered(childHang))
case armTaskRunning, armTaskFgRunningCancel, armTaskBgRunningCancel:
return holdsAll(graph, []agentgraph.NodeState{agentgraph.StateRunning})
case armTaskBgQueued, armTaskBgQueuedCancel:
phases := sink.phaseSeries()[taskCallID]
return len(phases) > 0 && phases[len(phases)-1] == string(subagentQueued)
case armTaskBgRunning:
return slices.Contains(sink.phaseSeries()[taskCallID], subagentRunning)
}
return false
}
// loneNodeDrawn reports whether the delegation's own node is on the graph.
func loneNodeDrawn(g agentgraph.Graph) bool {
for _, n := range g.Nodes {
if n.Kind == agentgraph.KindWorker && n.ID == parallelLoneNodeID {
return true
}
}
return false
}
// parallelLoneNodeID is what a lone delegation's node is called: the parent
// call, and the first slot of the fan-out numbering it shares.
const parallelLoneNodeID = taskCallID + "/sub-1"
func loneTaskWanted(arm string) string {
switch arm {
case armTaskRunning, armTaskFgRunningCancel, armTaskBgRunningCancel:
return "running"
case armTaskFgQueued, armTaskFgQueuedCancel, armTaskBgQueuedCancel:
return "drawn while the scheduler holds it back"
case armTaskBgQueued:
return "queued for a slot and not started"
}
return "running in its job"
}
// The two phases the probe waits on. They are the parent-facing statuses a
// background delegation emits, and for a handed-off run they are the only live
// evidence there is.
const (
subagentQueued = "queued"
subagentRunning = "running"
)
// progressPhase reads one subagent status event. The tool id is the parent call
// the delegation was made from, which is also what the graph node hangs under
// and what the sub-agent store records as its parent — the join this arm exists
// to check rather than assume.
func progressPhase(e event.Event) (id, phase string, ok bool) {
if e.Kind != event.ToolProgress || e.Tool.Name != event.SubagentProgressStatusName {
return "", "", false
}
return e.Tool.ID, e.Tool.Output, e.Tool.ID != ""
}
+536
View File
@@ -0,0 +1,536 @@
package main
import (
"fmt"
"sort"
"strings"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/state/execgraph"
"reasonix/internal/state/execjournal"
)
// What a lone delegation leaves behind, layer by layer. The columns are the
// ordinary ones: what the dying process held, and what the next one can still
// say. Nothing here is a fix — the arm exists to find which transition stops
// being recorded, because "single task has no graph" was the roadmap's guess
// and the live chain has since been unified.
func loneTaskRows(arm string, before, after Observation) []row {
return []row{
liveDelegationRow(before),
parentStatusRow(before),
durableOpeningRow(before, after),
durableQueueRow(before, after),
durableStartRow(before, after),
durableSettleRow(before, after),
delegationStoreRow(before, after),
rebuiltDelegationRow(before, after),
rebuiltDelegationIdentityRow(after),
delegationInterruptionRow(before, after),
identityJoinRow(before),
heldBackRow(arm, before),
stoppedOwnershipRow(arm, before),
startedTerminalRow(before, after),
fanOutControlRow(before),
}
}
// ofLoneTask reports whether an id belongs to the delegation under measurement.
// The queued arm fills its ceiling with a fan-out, and a fan-out records
// everything — so without this the rows would read that record as the lone
// task's own and report a gap as closed.
func ofLoneTask(id string) bool {
return id == taskCallID || strings.HasPrefix(id, taskCallID+"/")
}
const (
crossAuthority = "the journal and the store, read against each other"
liveAuthority = "event sink (GraphDelta), in-process"
journalAuthority = "execjournal, written by the orchestration"
storeAuthority = "sub-agent store"
rebuildAuthority = "execgraph.Rebuild over journal + store"
)
// liveDelegationRow is what the dying process actually drew. A backgrounded run
// draws nothing at all — openDelegationGraph returns early for it — so this row
// separates "the graph was lost" from "there was never a node".
func liveDelegationRow(before Observation) row {
var drawn []string
for _, n := range before.Graph.Nodes {
if n.Kind == agentgraph.KindWorker && ofLoneTask(n.ID) {
drawn = append(drawn, n.ID+":"+string(n.State))
}
}
sort.Strings(drawn)
return row{
Semantic: "the delegation the live graph drew", Authority: liveAuthority,
Artifact: "none observed", Reconstruction: "in-process only",
Before: orNone(join(drawn)), After: "—", Verdict: liveVerdict(len(drawn) > 0),
}
}
func liveVerdict(drawn bool) string {
if drawn {
return verdictStable
}
return verdictNotMeasured
}
// parentStatusRow is the other live surface, and the only one a handed-off run
// has: the statuses the parent tool call was told.
func parentStatusRow(before Observation) row {
var told []string
for id, phases := range before.Progress {
if ofLoneTask(id) {
told = append(told, id+":"+strings.Join(phases, "→"))
}
}
sort.Strings(told)
return row{
Semantic: "what the parent call was told", Authority: "subagent progress (ToolProgress)",
Artifact: "none observed", Reconstruction: "in-process only",
Before: orNone(join(told)), After: "—", Verdict: liveVerdict(len(told) > 0),
}
}
func durableOpeningRow(before, after Observation) row {
return valueRow("durable opening", journalAuthority, "<stem>.execution.jsonl",
"read back by ExecutionHistory", executionIDs(before), executionIDs(after), true)
}
func durableQueueRow(before, after Observation) row {
return valueRow("durable queue refusal", journalAuthority, "<stem>.execution.jsonl",
"the scheduler's first refusal, with its cause",
queuedCauses(before), queuedCauses(after), true)
}
func durableStartRow(before, after Observation) row {
return valueRow("durable slot grant", journalAuthority, "<stem>.execution.jsonl",
"written before the child can act", startedIDs(before), startedIDs(after), true)
}
func durableSettleRow(before, after Observation) row {
return valueRow("durable settlement", journalAuthority, "<stem>.execution.jsonl",
"the orchestration letting go of the execution",
settledIDs(before), settledIDs(after), true)
}
// delegationStoreRow is the layer that already survives for a fan-out item. It
// is the reason this arm cannot be read off the graph alone: a store record with
// no opening leaves the run's grant, worker and scheduling unaccounted for.
func delegationStoreRow(before, after Observation) row {
return valueRow("child the store kept", storeAuthority, "subagents/<ref>.meta.json",
"ListSubagentsByParent over the transcript stem",
childFacts(before), childFacts(after), true)
}
func rebuiltDelegationRow(before, after Observation) row {
rebuilt := rebuiltGraph(after)
var nodes []string
for _, n := range rebuilt.Graph.Nodes {
if n.Kind != agentgraph.KindGroup && ofLoneTask(n.ID) {
nodes = append(nodes, n.ID+":"+string(n.State))
}
}
sort.Strings(nodes)
return valueRow("the node a restart rebuilds", rebuildAuthority, "<stem>.execution.jsonl",
"execgraph.Rebuild", loneDelegationAtDeath(before), join(nodes), false)
}
// rebuiltDelegationIdentityRow is what a reader sees on the rebuilt node beyond
// its state. Grant and worker are opening facts: the store cannot answer them,
// so an arm that only compared states would call a hollow node exact.
func rebuiltDelegationIdentityRow(after Observation) row {
var got []string
for _, n := range rebuiltGraph(after).Graph.Nodes {
if n.Kind == agentgraph.KindGroup || !ofLoneTask(n.ID) {
continue
}
got = append(got, fmt.Sprintf("%s grant=%s model=%s effort=%s wait=%s",
n.ID, orDash(string(n.Grant)), orDash(n.Model), orDash(n.Effort), orDash(string(n.Wait))))
}
sort.Strings(got)
return row{
Semantic: "grant, worker and wait on the rebuilt node", Authority: rebuildAuthority,
Artifact: "<stem>.execution.jsonl", Reconstruction: "opening facts, which no store record carries",
Before: "declared at the opening", After: orNone(join(got)),
Verdict: rebuiltIdentityVerdict(got),
}
}
func rebuiltIdentityVerdict(got []string) string {
if len(got) == 0 {
return verdictLost
}
return verdictExact
}
func delegationInterruptionRow(before, after Observation) row {
var got []string
for _, i := range rebuiltGraph(after).Interrupted {
if ofLoneTask(i.Execution) {
got = append(got, i.Execution+":"+interruptionKind(i))
}
}
sort.Strings(got)
return valueRow("the interruption a restart names", rebuildAuthority, "<stem>.execution.jsonl",
"an execution with no owner is named, never redrawn as running",
loneDelegationAtDeath(before), join(got), false)
}
// loneDelegationAtDeath is the delegation as the dying process last knew it,
// from whichever surface knew: a foreground run has a node, a handed-off one
// has only the store's record, and an arm whose rows read the wrong surface
// would report a loss the other one never had.
func loneDelegationAtDeath(o Observation) string {
if live := liveWorkers(o); live != "" {
return live
}
return childFacts(o)
}
func interruptionKind(i execgraph.Interruption) string {
if i.Started {
return execjournal.InterruptedDuringExecution
}
return execjournal.InterruptedBeforeStart
}
// identityJoinRow is the question that decides whether Step 8 needs an identity
// at all: the live node, the call it hung under, the parent the store recorded,
// and the execution the journal opened. Four spellings, or one identity.
func identityJoinRow(before Observation) row {
live, call, stored, opened := identitiesAtDeath(before)
verdict := verdictViolated
switch {
case stored != "" && (stored == opened || stored == call || strings.HasPrefix(live, stored)):
verdict = verdictHolds
case stored == "" && !startedAtDeath(before):
// Nothing executed, so the store owes no record and there is no join to
// make. A missing artifact here is the arm's own premise, not a defect.
verdict = verdictNotMeasured
}
return row{
Semantic: "one identity or four", Authority: "the graph, the call, the store and the journal",
Artifact: "subagents/<ref>.meta.json, <stem>.execution.jsonl",
Reconstruction: "the store's parent call is what a rebuild joins on",
Before: fmt.Sprintf("node=%s call=%s", orDash(live), orDash(call)),
After: fmt.Sprintf("store parent=%s journal=%s", orDash(stored), orDash(opened)),
Verdict: verdict,
}
}
// identitiesAtDeath reads the four spellings out of the dying process's own
// observation, so a join that only holds after a restart cannot be claimed.
func identitiesAtDeath(o Observation) (live, call, stored, opened string) {
for _, n := range o.Graph.Nodes {
if n.Kind == agentgraph.KindWorker && ofLoneTask(n.ID) {
live, call = n.ID, n.ParentID
break
}
}
for id := range o.Progress {
if call == "" && ofLoneTask(id) {
call = id
}
}
for _, f := range o.Children.Facts {
if ofLoneTask(f.ParentToolCallID) {
stored = f.ParentToolCallID
break
}
}
for _, e := range o.Executions {
if ofLoneTask(e.ID) {
opened = e.ID
break
}
}
return live, call, stored, opened
}
// startedAtDeath reports whether the delegation was ever granted a slot.
func startedAtDeath(o Observation) bool {
for _, e := range o.Executions {
if ofLoneTask(e.ID) && e.Started() {
return true
}
}
return false
}
func executionIDs(o Observation) string {
var out []string
for _, e := range o.Executions {
if ofLoneTask(e.ID) {
out = append(out, e.ID)
}
}
sort.Strings(out)
return join(out)
}
func queuedCauses(o Observation) string {
var out []string
for _, e := range o.Executions {
if e.Queued() && ofLoneTask(e.ID) {
out = append(out, e.ID+":"+orDash(e.Cause))
}
}
sort.Strings(out)
return join(out)
}
func startedIDs(o Observation) string {
var out []string
for _, e := range o.Executions {
if e.Started() && ofLoneTask(e.ID) {
out = append(out, e.ID)
}
}
sort.Strings(out)
return join(out)
}
func settledIDs(o Observation) string {
var out []string
for _, e := range o.Executions {
if !e.SettledAt.IsZero() && ofLoneTask(e.ID) {
out = append(out, e.ID)
}
}
sort.Strings(out)
return join(out)
}
func childFacts(o Observation) string {
var out []string
for _, f := range o.Children.Facts {
if ofLoneTask(f.ParentToolCallID) {
out = append(out, f.ParentToolCallID+":"+f.Status)
}
}
sort.Strings(out)
return join(out)
}
func liveWorkers(o Observation) string {
var out []string
for _, n := range o.Graph.Nodes {
if n.Kind == agentgraph.KindWorker && ofLoneTask(n.ID) {
out = append(out, n.ID+":"+string(n.State))
}
}
sort.Strings(out)
return join(out)
}
func orDash(s string) string {
if strings.TrimSpace(s) == "" {
return "-"
}
return s
}
// heldBackRow is the live-truth row: with the ceiling full and the delegation's
// own child never reaching the provider, the scheduler is holding it back — and
// what the picture says while that is true is a claim about now, which no
// restart can correct. Running is the answer that is wrong.
func heldBackRow(arm string, before Observation) row {
if !queuedTaskArm(arm) {
return row{
Semantic: "what the picture says while the scheduler holds it back", Authority: liveAuthority,
Artifact: "none observed", Reconstruction: "this arm never fills the ceiling",
Before: "—", After: "—", Verdict: verdictNotMeasured,
}
}
drawn := orNone(liveWorkers(before))
told := orNone(loneStatus(before))
verdict := verdictHolds
if strings.Contains(drawn, ":running") || told == subagentRunning {
verdict = verdictViolated
}
return row{
Semantic: "what the picture says while the scheduler holds it back", Authority: liveAuthority,
Artifact: "none observed",
Reconstruction: "queued with its cause, or at least not started",
Before: "held back: ceiling full, child never entered",
After: "graph=" + drawn + " parent=" + told, Verdict: verdict,
}
}
func loneStatus(o Observation) string {
for id, phases := range o.Progress {
if ofLoneTask(id) && len(phases) > 0 {
return phases[len(phases)-1]
}
}
return ""
}
// stoppedOwnershipRow is the invariant a stop has to leave behind: once the
// turn is over, nothing it owned may still be open with a live owner. A record
// left open is what a restart reads as an interruption, so a turn that merely
// stopped waiting looks, afterwards, exactly like a process that died.
func stoppedOwnershipRow(arm string, before Observation) row {
if !cancelTaskArm(arm) {
return row{
Semantic: "work the stopped turn still owns", Authority: crossAuthority,
Artifact: "<stem>.execution.jsonl", Reconstruction: "this arm stops nothing",
Before: "—", After: "—", Verdict: verdictNotMeasured,
}
}
reading := ""
if seen := before.Progress[probeStopReached]; len(seen) > 0 {
reading = seen[0]
}
entry, _ := loneEntry(before)
open := entry.SettledAt.IsZero()
turnOver := strings.Contains(reading, "turn=ended")
// An open record implies a live owner: nothing else closes one. The child's
// liveness rides in the reading as evidence, not as the test — a delegation
// still waiting for a slot has no child and is just as much still owned.
return row{
Semantic: "work the stopped turn still owns", Authority: crossAuthority,
Artifact: "<stem>.execution.jsonl",
Reconstruction: "a turn that has ended owns nothing still open",
Before: "the turn owned it when the stop was issued",
After: orNone(reading) + " record=" + entryStanding(entry), Verdict: held(!(turnOver && open)),
}
}
func entryStanding(e execjournal.Entry) string {
if e.SettledAt.IsZero() {
return "still open"
}
return "settled"
}
// startedTerminalRow refuses the combination neither layer may produce: a child
// record for work the journal proves was never admitted. A state comparison
// shows that only as "cancelled came back failed"; this names the cause, which
// is a store asked to speak for an execution that never happened.
func startedTerminalRow(before, after Observation) row {
if startedAtDeath(before) {
return row{
Semantic: "a child record for work that never started", Authority: crossAuthority,
Artifact: "subagents/<ref>.meta.json, <stem>.execution.jsonl",
Reconstruction: "this delegation was granted a slot, so the store owes a record",
Before: "—", After: "—", Verdict: verdictNotMeasured,
}
}
kept := orNone(join(compact(childFacts(before), childFacts(after))))
return row{
Semantic: "a child record for work that never started", Authority: crossAuthority,
Artifact: "subagents/<ref>.meta.json, <stem>.execution.jsonl",
Reconstruction: "no slot was ever granted, so nothing ran and nothing owes a terminal",
Before: "none", After: kept, Verdict: held(kept == "none"),
}
}
func compact(parts ...string) []string {
var out []string
for _, p := range parts {
if strings.TrimSpace(p) != "" {
out = append(out, p)
}
}
return out
}
// fanOutControlRow is the positive control the queued arm carries with it: the
// fleet that fills its ceiling runs through the same scheduler in the same
// session, and records everything. What this arm reports missing is missing at
// the entry point, not in the machinery.
func fanOutControlRow(before Observation) row {
var recorded []string
for _, e := range before.Executions {
if !ofLoneTask(e.ID) {
recorded = append(recorded, e.ID+lifecycleOf(e))
}
}
sort.Strings(recorded)
verdict := verdictNotMeasured
if len(recorded) > 0 {
verdict = verdictPersisted
}
return row{
Semantic: "a fan-out in the same session, same scheduler", Authority: journalAuthority,
Artifact: "<stem>.execution.jsonl", Reconstruction: "control: the machinery this arm reports missing",
Before: orNone(join(recorded)), After: "—", Verdict: verdict,
}
}
func lifecycleOf(e execjournal.Entry) string {
marks := ""
if e.Queued() {
marks += " queued=" + orDash(e.Cause)
}
if e.Started() {
marks += " started"
}
if !e.SettledAt.IsZero() {
marks += " settled"
}
return marks
}
// loneTaskArmInvalid guards each arm's premise: the state it is built to die in
// has to have been reached, or its rows describe a different death.
func loneTaskArmInvalid(arm string, before Observation) string {
phases := strings.Join(before.Progress[taskCallID], "→")
switch arm {
case armTaskCompleted:
if !strings.Contains(phases, "completed") && len(before.Children.Facts) == 0 {
return "the delegation never completed, so this arm measures a different death"
}
case armTaskRunning:
if !holdsState(before, string(agentgraph.StateRunning)) {
return "nothing was executing when the process died"
}
case armTaskFgQueued:
if entered := before.Progress[probeChildEntered]; len(entered) == 0 || entered[0] != "false" {
return "the delegation's child ran, so the scheduler was not holding it back"
}
if !strings.Contains(fanOutControlRow(before).Before, "queued=slots") {
return "no fan-out filled the ceiling, so nothing refused the delegation"
}
case armTaskBgQueued:
if !strings.HasSuffix(phases, subagentQueued) {
return "the delegation was not left queued for a slot: " + orNone(phases)
}
case armTaskBgRunning:
if !strings.Contains(phases, subagentRunning) {
return "the delegation never entered its job: " + orNone(phases)
}
}
if cancelTaskArm(arm) {
return stoppedArmInvalid(arm, before)
}
return ""
}
// stoppedArmInvalid holds a cancellation arm to the side of the boundary it was
// built for. An arm that stopped the work somewhere else measures a different
// ownership question and must say so rather than reporting the answer.
func stoppedArmInvalid(arm string, before Observation) string {
entry, ok := loneEntry(before)
switch {
case !ok:
return "the delegation was never recorded, so there is no ownership to read"
case beforeStartArm(arm) && entry.Started():
return "the delegation was granted a slot before the stop, so this is not a pre-start cancellation"
case !beforeStartArm(arm) && !entry.Started():
return "the delegation never held a slot, so this is not a cancellation of work that ran"
}
return ""
}
func loneEntry(o Observation) (execjournal.Entry, bool) {
for _, e := range o.Executions {
if ofLoneTask(e.ID) {
return e, true
}
}
return execjournal.Entry{}, false
}
+48
View File
@@ -0,0 +1,48 @@
// Process-boundary resurrection probe. It answers one question and modifies
// nothing: after the process that built a session exits, what can a new process
// still prove about that session's runtime semantics without asking a model?
package main
import (
"flag"
"fmt"
"os"
)
func main() {
phase := flag.String("phase", "", "internal: construct|successor|resume, spawned by the orchestrator")
root := flag.String("root", "", "internal: this arm's root directory")
armName := flag.String("arm", "", "internal: this arm's name")
work := flag.String("work", "", "directory to hold arm roots; default is a temp dir")
only := flag.String("only", "", "run a single arm by name")
jsonOut := flag.String("json", "", "also write the machine-readable matrix here")
keep := flag.Bool("keep", false, "keep arm roots on disk after the run")
sabotage := flag.String("sabotage", "", "run under a deliberate defect the arm must fail on (ui-graph-mixed: publish|trajectory)")
flag.Parse()
if *sabotage != "" {
// Carried to the children through the environment they already inherit,
// so no phase has to learn about a flag it does not read.
if err := os.Setenv(envProbeSabotage, *sabotage); err != nil {
fmt.Fprintln(os.Stderr, "runtime-resume:", err)
os.Exit(1)
}
}
var err error
switch *phase {
case "construct":
err = runConstruct(*root, *armName)
case "successor":
err = runSuccessor(*root, *armName)
case "resume":
err = runResume(*root, *armName)
case "":
err = orchestrate(*work, *only, *jsonOut, *keep)
default:
err = fmt.Errorf("unknown phase %q", *phase)
}
if err != nil {
fmt.Fprintln(os.Stderr, "runtime-resume:", err)
os.Exit(1)
}
}
+584
View File
@@ -0,0 +1,584 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"reasonix/internal/state/sessionstore"
"regexp"
"sort"
"strings"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/contract/provider"
"reasonix/internal/runtime/delegation"
"reasonix/internal/safety/evidence"
"reasonix/internal/session/control"
"reasonix/internal/state/execjournal"
"reasonix/internal/state/store"
)
// Observation is everything one phase can read about a session without asking a
// model. Both phases capture the same shape so the matrix is a comparison and
// not two differently-shaped reports.
type Observation struct {
Phase string `json:"phase"`
Arm string `json:"arm"`
SessionPath string `json:"session_path"`
SystemHash string `json:"system_hash"`
SystemBytes int `json:"system_bytes"`
// BootSystemHash is what this process's assembly composed, read before any
// resume; SystemHash is the session's own leading row. Different questions.
BootSystemHash string `json:"boot_system_hash"`
BootSystemBytes int `json:"boot_system_bytes"`
Transcript TranscriptObs `json:"transcript"`
Goal GoalObs `json:"goal"`
Todos []evidence.TodoItem `json:"todos"`
Decisions []control.Decision `json:"decisions"`
Context ContextObs `json:"context"`
Sidecar SidecarObs `json:"sidecar"`
View ViewObs `json:"view"`
TodoNotes TodoNoteObs `json:"todo_notes"`
Deferred DeferredObs `json:"deferred"`
Obligation ObligationObs `json:"obligation"`
// Interrupted is what the host derives from a barrier it found open and did
// not open itself: evidence the wait happened, not a question to answer.
Interrupted []control.InterruptedAdjudication `json:"interrupted"`
// ModelSeesInterruption counts the interruption blocks the request carries,
// which is a different surface from the derived fact behind them.
ModelSeesInterruption int `json:"model_sees_interruption"`
// Journal is how each barrier ended. A superseded edge is the durable
// evidence that a turn received the interruption: nothing else writes one.
Journal []control.AdjudicationEntry `json:"journal,omitempty"`
Graph GraphObs `json:"graph"`
// Children is the durable side of the same fan-out the graph draws. The
// graph has no read surface after a restart, so without this a lost graph
// and a lost delegation would be one indistinguishable row.
Children ChildrenObs `json:"children"`
// Listing is the same children through the read list_subagents performs.
// It filters by workspace and walks lineage, so it can answer differently
// from the one above — and it is the answer a model asking would be shown.
Listing ListingObs `json:"listing"`
// FanOutTurn is whether the dispatching turn is in this phase's transcript
// at all. A turn is appended when it ends, so a process that dies inside one
// leaves no record of the request, and a lost graph is the smaller half.
FanOutTurn bool `json:"fan_out_turn"`
// Executions is the durable record of delegations this turn opened, and
// InterruptedExecutions the host's judgement over it: open, with no owner
// here. Written before the work starts, so it outlives an unfinished turn.
Executions []execjournal.Entry `json:"executions,omitempty"`
InterruptedExecutions []execjournal.Entry `json:"interrupted_executions,omitempty"`
// ModelSeesInterruptedExecution counts the blocks the next request carries
// about them, which is a different surface from the fact behind them.
ModelSeesInterruptedExecution int `json:"model_sees_interrupted_execution"`
// WaitCauses counts the refusals the graph carries, and WaitSeries keeps
// each node's causes in publication order. The fold keeps only the latest,
// which cannot say whether a cause was reported once or replaced.
WaitCauses map[string]int `json:"wait_causes,omitempty"`
WaitSeries map[string][]string `json:"wait_series,omitempty"`
// Progress is every parent-facing subagent status, per call. A background
// delegation draws no node, so this is where its live evidence is.
Progress map[string][]string `json:"progress,omitempty"`
// SettledWorkers and QueuedWorkers are the transition arm's evidence that
// capacity was actually freed while a refusal still stood.
SettledWorkers int `json:"settled_workers"`
QueuedWorkers []string `json:"queued_workers,omitempty"`
Artifacts []ArtifactObs `json:"artifacts"`
// UI is what a frontend attached to this process saw, in order. Only the one
// arm that runs a client fills it; a value comparison cannot answer what it
// asks, which is through which door each fact arrived.
UI *UIObs `json:"ui,omitempty"`
}
type TranscriptObs struct {
Messages int `json:"messages"`
Digest string `json:"digest"`
}
type GoalObs struct {
Goal string `json:"goal"`
Status string `json:"status"`
TurnsUsed int `json:"turns_used"`
}
// ContextObs is the live judgement: CheckpointState is "none" whenever the
// projection did not survive validation, which is what the compaction arms are
// actually asking about.
type ContextObs struct {
ProjectionVersion uint64 `json:"projection_version"`
CheckpointState string `json:"checkpoint_state"`
CanonicalTokens int `json:"canonical_tokens"`
ProjectedTokens int `json:"projected_tokens"`
Blocked bool `json:"blocked"`
}
// SidecarObs is the stored claim, read off disk rather than through the agent,
// so a projection the host refuses to use is still visible as present.
type SidecarObs struct {
Present bool `json:"present"`
Messages int `json:"messages"`
CoveredCount int `json:"covered_count"`
CoveredPrefixHash string `json:"covered_prefix_hash"`
ProjectionVersion uint64 `json:"projection_version"`
TranscriptVersion uint64 `json:"transcript_version"`
Generation uint64 `json:"generation"`
Err string `json:"err,omitempty"`
}
// ViewObs is the model-visible context, spliced the way ContextProjection
// declares it: the stored body followed by canonical[CoveredCount:]. Markers
// are what a fold can be held to — the scripted digest carries none, so a
// marker that is gone was dropped, not summarised.
type ViewObs struct {
Messages int `json:"messages"`
// Markers are the user turns still shown, Echoes the assistant replies. A
// fold takes the reply first, so the two answer different questions.
Markers []string `json:"markers"`
Echoes []string `json:"echoes"`
BodyMarkers []string `json:"body_markers"`
BodyEchoes []string `json:"body_echoes"`
SplicedFromTail int `json:"spliced_from_tail"`
}
// TodoNoteObs locates the host's step-identity note inside the model-visible
// view. BodyLen is where the frozen body ends, so an index below it says the
// note was frozen ahead of the live tail rather than riding it.
type TodoNoteObs struct {
Count int `json:"count"`
Indexes []int `json:"indexes"`
IDs []string `json:"ids"`
BodyLen int `json:"body_len"`
ViewLen int `json:"view_len"`
HostIDs []string `json:"host_ids"`
// ReadableInHistory says the conversation still shows every current id, so
// no note is owed. Without it a count of zero cannot be told apart from a
// lost identity, and the gate would demand a note nobody needs.
ReadableInHistory bool `json:"readable_in_history"`
}
// todoNoteMarker is the opening of todoIdentityNote. The probe reads the view
// the way a model would, so it matches the text rather than a type.
const todoNoteMarker = "Host task state."
var stepIDPattern = regexp.MustCompile(`probe_step_\d+`)
func todoNoteObs(view, history []provider.Message, todos []evidence.TodoItem) TodoNoteObs {
out := TodoNoteObs{BodyLen: len(history), ViewLen: len(view)}
for _, t := range todos {
if t.StepID != "" {
out.HostIDs = append(out.HostIDs, t.StepID)
}
}
out.ReadableInHistory = len(out.HostIDs) > 0
for _, id := range out.HostIDs {
if !mentionsID(history, id) {
out.ReadableInHistory = false
}
}
for i, m := range view {
if !strings.Contains(m.Content, todoNoteMarker) {
continue
}
out.Count++
out.Indexes = append(out.Indexes, i)
out.IDs = append(out.IDs, stepIDPattern.FindAllString(m.Content, -1)...)
}
return out
}
// DeferredObs is the effect a pending decision is holding back. Whether the
// decision itself survives is one question; whether what it blocked stayed
// blocked is the one that cannot be answered by a design choice.
type DeferredObs struct {
MarkerPath string `json:"marker_path"`
Executed bool `json:"executed"`
}
func deferredObs(workspace string) DeferredObs {
path := filepath.Join(workspace, deferredEffect)
_, err := os.Stat(path)
return DeferredObs{MarkerPath: deferredEffect, Executed: err == nil}
}
// ObligationObs is what the transcript itself still says about a turn that
// never finished: a tool call with no result is a claim the host made and did
// not settle, and an interruption record is the host saying so outright. Either
// tells a lost decision apart from a silently dropped one.
type ObligationObs struct {
UnansweredCalls []string `json:"unanswered_calls"`
InterruptionMarked int `json:"interruption_marked"`
}
func obligationObs(msgs []provider.Message) ObligationObs {
answered := map[string]bool{}
var out ObligationObs
for _, m := range msgs {
if m.ToolCallID != "" {
answered[m.ToolCallID] = true
}
if m.InterruptedTurn != nil {
out.InterruptionMarked++
}
}
for _, m := range msgs {
for _, call := range m.ToolCalls {
if call.ID != "" && call.ID != provider.LocalOnlyToolID && !answered[call.ID] {
out.UnansweredCalls = append(out.UnansweredCalls, call.Name+":"+call.ID)
}
}
}
return out
}
type GraphObs struct {
Deltas int `json:"deltas"`
Nodes []agentgraph.Node `json:"nodes,omitempty"`
Edges []agentgraph.Edge `json:"edges,omitempty"`
Grants map[string]string `json:"grants,omitempty"`
Waits map[string]string `json:"waits,omitempty"`
}
// ChildFact is one delegated run as the durable store records it, which is a
// different set of facts from the node the graph drew for it. Whatever the
// graph carried and this does not is a semantic no reconstruction can recover.
type ChildFact struct {
Ref string `json:"ref"`
Status string `json:"status"`
Kind string `json:"kind,omitempty"`
Name string `json:"name,omitempty"`
// ExecutionID is which execution the record belongs to; ParentToolCallID is
// the provider-visible call it descends from. They are separate answers, and
// a host-started run has only the first.
ExecutionID string `json:"executionId,omitempty"`
ParentToolCallID string `json:"parentToolCallId,omitempty"`
Model string `json:"model,omitempty"`
Effort string `json:"effort,omitempty"`
}
// ChildrenObs is every child the store still owns for this parent. It is the
// only durable execution evidence a restart has been shown to read, so a graph
// classification that ignores it would report a loss the host can in fact
// still speak to.
type ChildrenObs struct {
Facts []ChildFact `json:"facts,omitempty"`
Err string `json:"err,omitempty"`
}
// childrenObs reads the store the way a restart does: by parent session id,
// which is the transcript's stem. A session with no path owns nothing.
func childrenObs(root armRoot, sessionPath string) ChildrenObs {
if strings.TrimSpace(sessionPath) == "" {
return ChildrenObs{}
}
parent := strings.TrimSuffix(filepath.Base(sessionPath), ".jsonl")
artifacts, err := sessionstore.ListSubagentsByParent(root.Sessions, parent)
if err != nil {
return ChildrenObs{Err: err.Error()}
}
out := ChildrenObs{}
for _, a := range artifacts {
out.Facts = append(out.Facts, ChildFact{
Ref: a.Ref, Status: string(a.Meta.Status), Kind: a.Meta.Kind, Name: a.Meta.Name,
ExecutionID: a.Meta.ExecutionID,
ParentToolCallID: a.Meta.ParentToolCallID, Model: a.Meta.Model, Effort: a.Meta.Effort,
})
}
sort.Slice(out.Facts, func(i, j int) bool { return out.Facts[i].Ref < out.Facts[j].Ref })
return out
}
// ListingRow is one row list_subagents would print. The tool call itself needs
// a registry this probe has no way to build, so the read behind it is what is
// exercised; the formatting adds no fact the row does not already carry.
type ListingRow struct {
Ref string `json:"ref"`
Status string `json:"status"`
From string `json:"from,omitempty"`
}
// ListingObs is what the recovery surface would show for this conversation.
type ListingObs struct {
Rows []ListingRow `json:"rows,omitempty"`
Err string `json:"err,omitempty"`
}
// listingObs reads the store the way the tool does: by the parent session the
// child recorded, filtered to this workspace. The workspace filter is why this
// cannot be folded into childrenObs — a root that does not match drops every
// row, and a reader must be able to see that happen rather than read it as an
// empty store.
func listingObs(root armRoot, sessionPath string) ListingObs {
if strings.TrimSpace(sessionPath) == "" {
return ListingObs{}
}
store := delegation.NewSubagentStore(filepath.Join(root.Sessions, "subagents"))
parent := strings.TrimSuffix(filepath.Base(sessionPath), ".jsonl")
artifacts, err := store.ListForParent(parent, root.Workspace)
if err != nil {
return ListingObs{Err: err.Error()}
}
out := ListingObs{}
for _, a := range artifacts {
out.Rows = append(out.Rows, ListingRow{
Ref: a.Ref, Status: string(a.Meta.Status), From: a.Meta.ParentToolCallID,
})
}
return out
}
type ArtifactObs struct {
Name string `json:"name"`
Bytes int64 `json:"bytes"`
}
func capture(phase, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, root armRoot) Observation {
path := ctrl.SessionPath()
history := ctrl.History()
system := systemText(history)
snap := ctrl.ContextMaintenanceSnapshot()
st, loaded, loadErr := sessionstore.LoadCompactionState(path)
view, viewMsgs := viewObs(st, loaded, history)
graph, deltas := sink.snapshot()
return Observation{
Phase: phase,
Arm: arm,
SessionPath: path,
SystemHash: shortSum(system),
SystemBytes: len(system),
BootSystemHash: shortSum(bootSystem),
BootSystemBytes: len(bootSystem),
Transcript: TranscriptObs{Messages: len(history), Digest: transcriptDigest(history)},
Goal: GoalObs{Goal: ctrl.Goal(), Status: ctrl.GoalStatus(), TurnsUsed: ctrl.GoalRuntime().TurnsUsed},
Todos: ctrl.Todos(),
Decisions: ctrl.Decisions(),
Context: ContextObs{
ProjectionVersion: snap.ProjectionVersion,
CheckpointState: snap.CheckpointState,
CanonicalTokens: snap.CanonicalTokens,
ProjectedTokens: snap.ProjectedTokens,
Blocked: snap.Blocked,
},
Sidecar: sidecarObs(st, loaded, loadErr),
View: view,
// The note is derived for a request and stored nowhere, so the contracts
// about it are read off the request rather than off the stored view.
TodoNotes: todoNoteObs(ctrl.ModelVisibleMessages(), viewMsgs, ctrl.Todos()),
Graph: graphObs(graph, deltas),
WaitCauses: waitCauseCounts(graph),
WaitSeries: sink.waitSeries(),
Progress: sink.phaseSeries(),
SettledWorkers: settledWorkers(graph),
QueuedWorkers: queuedWorkers(graph),
Children: childrenObs(root, path),
Listing: listingObs(root, path),
FanOutTurn: fanOutTurnRecorded(history),
Executions: control.ExecutionHistory(path),
InterruptedExecutions: ctrl.InterruptedExecutions(),
ModelSeesInterruptedExecution: countBlocks(
ctrl.ModelVisibleMessages(), "<interrupted-execution>"),
Deferred: deferredObs(root.Workspace),
Obligation: obligationObs(history),
Interrupted: ctrl.InterruptedAdjudications(),
Journal: control.AdjudicationHistory(path),
ModelSeesInterruption: countBlocks(ctrl.ModelVisibleMessages(), "<interrupted-adjudication>"),
Artifacts: readArtifacts(path),
}
}
// bootSystemText is what the freshly built assembly composed, read before a
// resume replaces the session under it.
func bootSystemText(ctrl *control.Controller) string { return systemText(ctrl.History()) }
func systemText(msgs []provider.Message) string {
var b strings.Builder
for _, m := range msgs {
if m.Role == provider.RoleSystem {
b.WriteString(m.Content)
b.WriteByte('\n')
}
}
return b.String()
}
// transcriptDigest covers only what reaches a provider, so a local-only marker
// appended by either process cannot read as a changed conversation.
func transcriptDigest(msgs []provider.Message) string {
h := sha256.New()
for _, m := range provider.ModelMessages(msgs) {
b, _ := json.Marshal(struct {
R, C, N string
}{string(m.Role), m.Content, m.Name})
h.Write(b)
h.Write([]byte{'\n'})
}
return hex.EncodeToString(h.Sum(nil))[:16]
}
func shortSum(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:8])
}
func sidecarObs(st sessionstore.CompactionState, ok bool, err error) SidecarObs {
out := SidecarObs{Present: ok}
if err != nil {
out.Err = err.Error()
return out
}
if !ok {
return out
}
out.Messages = len(st.Projection.Messages)
out.CoveredCount = st.Projection.CoveredCount
out.CoveredPrefixHash = st.Projection.CoveredPrefixHash
out.ProjectionVersion = st.Projection.ProjectionVersion
out.TranscriptVersion = st.TranscriptVersion
out.Generation = st.Generation
return out
}
// viewObs splices the model-visible context. With no usable projection the
// view is the canonical transcript, which is what the host would send.
func viewObs(st sessionstore.CompactionState, ok bool, canonical []provider.Message) (ViewObs, []provider.Message) {
var out ViewObs
if !ok || len(st.Projection.Messages) == 0 {
out.Messages = len(canonical)
out.Markers = markersIn(contents(canonical), markerPattern)
out.Echoes = markersIn(contents(canonical), echoPattern)
return out, canonical
}
body := st.Projection.Messages
out.BodyMarkers = markersIn(contents(body), markerPattern)
out.BodyEchoes = markersIn(contents(body), echoPattern)
view := append([]provider.Message(nil), body...)
if n := st.Projection.CoveredCount; n >= 0 && n < len(canonical) {
view = append(view, canonical[n:]...)
out.SplicedFromTail = len(canonical) - n
}
out.Messages = len(view)
out.Markers = markersIn(contents(view), markerPattern)
out.Echoes = markersIn(contents(view), echoPattern)
return out, view
}
// mentionsID reads an id the way the host's own visibility check does: text or
// tool arguments, anywhere in the messages the request carries.
func mentionsID(msgs []provider.Message, id string) bool {
for _, m := range msgs {
if strings.Contains(m.Content, id) {
return true
}
for _, call := range m.ToolCalls {
if strings.Contains(call.Arguments, id) {
return true
}
}
}
return false
}
func countBlocks(msgs []provider.Message, marker string) int {
n := 0
for _, m := range msgs {
if strings.Contains(m.Content, marker) {
n++
}
}
return n
}
func contents(msgs []provider.Message) []string {
out := make([]string, 0, len(msgs))
for _, m := range msgs {
out = append(out, m.Content)
}
return out
}
// waitCauseCounts is how many nodes the graph shows under each refusal. It
// counts nodes rather than reports: a node keeps its cause after it starts, so
// this answers what was refused, not what is refused now.
func waitCauseCounts(g agentgraph.Graph) map[string]int {
out := map[string]int{}
for _, n := range g.Nodes {
if n.Wait != "" {
out[string(n.Wait)]++
}
}
if len(out) == 0 {
return nil
}
return out
}
// queuedWorkers are the items still refused admission, in a stable order.
func queuedWorkers(g agentgraph.Graph) []string {
var out []string
for _, n := range g.Nodes {
if n.Kind != agentgraph.KindGroup && n.State == agentgraph.StateQueued {
out = append(out, n.ID)
}
}
sort.Strings(out)
return out
}
func graphObs(g agentgraph.Graph, deltas int) GraphObs {
out := GraphObs{Deltas: deltas}
if len(g.Nodes) == 0 {
return out
}
out.Nodes = g.Nodes
out.Edges = g.Edges
out.Grants = map[string]string{}
out.Waits = map[string]string{}
for _, n := range g.Nodes {
if n.Grant != "" {
out.Grants[n.ID] = string(n.Grant)
}
if n.Wait != "" {
out.Waits[n.ID] = string(n.Wait)
}
}
return out
}
// readArtifacts lists the session sidecars that exist, which is the evidence
// behind every "persisted" claim in the matrix.
func readArtifacts(sessionPath string) []ArtifactObs {
if sessionPath == "" {
return nil
}
paths := append([]string{sessionPath}, store.SessionSidecarFiles(sessionPath)...)
var out []ArtifactObs
for _, p := range paths {
if p == "" {
continue
}
info, err := os.Stat(p)
if err != nil {
continue
}
out = append(out, ArtifactObs{Name: filepath.Base(p), Bytes: info.Size()})
}
return out
}
func writeObservation(root armRoot, obs Observation) error {
b, err := json.MarshalIndent(obs, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(root.Dir, "obs-"+obs.Phase+".json"), b, 0o644)
}
func readObservation(root armRoot, phase string) (Observation, error) {
var obs Observation
b, err := os.ReadFile(filepath.Join(root.Dir, "obs-"+phase+".json"))
if err != nil {
return obs, err
}
return obs, json.Unmarshal(b, &obs)
}
+338
View File
@@ -0,0 +1,338 @@
package main
import (
"context"
"fmt"
"os"
"reasonix/internal/state/sessionstore"
"time"
"reasonix/internal/runtime/agent"
"reasonix/internal/session/control"
"reasonix/internal/state/checkpoint"
)
// probeTurns is enough conversation for a fold to have a region to work on:
// the system row and the first user turn are pinned, and the recent tail is
// kept verbatim, so a short session has nothing foldable between them.
const probeTurns = 8
// refoldTurns grows the transcript past the first fold by less than the fold
// keeps verbatim, so the second fold has to reach into the stored body.
const refoldTurns = 3
// runTurns drives count turns, each carrying its own marker, and returns the
// next turn number.
func runTurns(ctx context.Context, ctrl *control.Controller, from, count int) (int, error) {
for i := range count {
n := from + i + 1
prompt := fmt.Sprintf("%s Probe turn %d: record the task list and keep working.", marker(n), n)
if err := ctrl.Run(ctx, prompt); err != nil {
return n, fmt.Errorf("turn %d: %w", n, err)
}
}
return from + count, nil
}
// openDecisionAndDie leaves a question open and kills the process while the
// turn is still parked on it. It exits rather than returning: a clean shutdown
// would resolve or cancel the question, which is the one thing a process that
// dies mid-decision does not do.
func openDecisionAndDie(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink) error {
// The gate a person answers through, wired the way chat and desktop wire
// it. Without it the ask tool decides for itself and nothing ever blocks.
ctrl.EnableInteractiveApproval()
go func() { _ = ctrl.Run(ctx, marker(99)+" "+askSentinel+": ask before writing.") }()
if err := waitForAsk(ctrl); err != nil {
return err
}
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
if obs.Deferred.Executed {
return errUnexpected("a write still held back by the open question", obs.Deferred.MarkerPath)
}
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
// waitForAsk blocks until the host reports a question waiting on a person.
func waitForAsk(ctrl *control.Controller) error {
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
for _, d := range ctrl.Decisions() {
if d.Kind == control.DecisionAsk {
return nil
}
}
time.Sleep(50 * time.Millisecond)
}
return errUnexpected("a question waiting on a person", ctrl.Decisions())
}
// rewindTurn drives the rewind a person drives, recording the projection
// before it: the tail arm takes back the turn appended after the fold, the
// covered arm lands below the fold boundary and must lose the projection
// whatever coverage says.
func rewindTurn(root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink) error {
points := ctrl.Checkpoints()
if len(points) == 0 {
return errUnexpected("a checkpoint to rewind to", 0)
}
target := points[len(points)-1].Turn
if arm == armCoveredRewind {
var err error
if target, err = turnBelowFold(ctrl, points); err != nil {
return err
}
}
if err := writeObservation(root, capture(extraPhase(arm), arm, bootSystem, ctrl, sink, root)); err != nil {
return err
}
if err := ctrl.Rewind(target, control.RewindConversation); err != nil {
return fmt.Errorf("rewind: %w", err)
}
return nil
}
// turnBelowFold picks the checkpoint whose boundary sits just below the fold,
// read from the sidecar rather than assumed: a control aiming at a fixed turn
// stops removing folded history the moment coverage moves, and says nothing.
// A boundary at the first message is skipped — a session left holding only a
// system row is never persisted, so the restart would read the arm's work back.
func turnBelowFold(ctrl *control.Controller, points []checkpoint.Meta) (int, error) {
covered := 0
if st, ok, _ := sessionstore.LoadCompactionState(ctrl.SessionPath()); ok {
covered = st.Projection.CoveredCount
}
target := -1
for _, p := range points {
plan, err := ctrl.PrepareRewind(p.Turn, control.RewindConversation)
if err != nil || !plan.HasBoundary {
continue
}
if plan.BoundaryIndex > 1 && plan.BoundaryIndex < covered {
target = p.Turn
}
}
if target < 0 {
return 0, errUnexpected("a checkpoint below the fold boundary", covered)
}
return target, nil
}
// extraPhase names the in-process observation an arm records before its
// process exits, empty when it records none.
func extraPhase(arm string) string {
switch arm {
case armRefoldIntoBody:
return "prefold"
case armTailRewind, armCoveredRewind:
return "prerewind"
}
return ""
}
const probeGoal = "Prove which runtime semantics survive an OS process boundary."
// armConstruct hands the arm to the construct path it needs, reporting whether
// one took over. Each of them records its own observation and exits inside the
// turn, so a caller that sees taken has nothing left to do but report the error.
func armConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) (bool, error) {
switch {
case arm == armOpenDecision || successorArm(arm):
return true, openDecisionAndDie(ctx, root, arm, bootSystem, ctrl, sink)
case deriveArm(arm):
return true, runDeriveConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case terminalArm(arm):
return true, runTerminalConstruct(ctx, root, arm, bootSystem, ctrl, sink, turn)
case schedulerWaitArm(arm):
return true, runSchedulerWaitConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case lineageArm(arm):
return true, runLineageConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case cancelRoutingArm(arm):
return true, runCancelRoutingConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case loneTaskArm(arm):
return true, runLoneTaskConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case activeStoreArm(arm):
return true, runActiveStoreConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case skillArm(arm):
return true, runSkillClassConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case ephemeralArm(arm):
return true, runEphemeralConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case hostExecArm(arm):
return true, runHostExecConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn)
case graphArm(arm) || uiArm(arm):
return true, runFanOutConstruct(ctx, root, arm, bootSystem, ctrl, sink, turn)
}
return false, nil
}
// runConstruct establishes host state through the same calls a frontend makes,
// records what this process can see, and returns so the process can exit. It
// never asserts: an arm that failed to establish something is reported as not
// measured, which is the one reading a missing "before" supports.
func runConstruct(dir, arm string) error {
root := rootFor(dir)
if err := root.create(); err != nil {
return err
}
if skillArm(arm) || arm == armEphemeralSkill || hostExecArm(arm) {
if err := writeProbeSkill(root.Workspace); err != nil {
return fmt.Errorf("probe skill: %w", err)
}
}
if total, writers := schedulerLimits(arm); total > 0 {
if err := root.writeProjectConfig(total, writers); err != nil {
return fmt.Errorf("project config: %w", err)
}
}
ctx := context.Background()
sink := &graphSink{}
bus, into := uiBus(arm, sink)
ctrl, prov, err := buildRuntime(ctx, root, arm, into)
if err != nil {
return fmt.Errorf("build: %w", err)
}
defer ctrl.Close()
// The process that dies is a window in production, and a window records what
// it served. Without that the trajectory this session leaves behind holds
// none of its own frames.
recordLikeAWindow(ctrl, bus)
bootSystem := bootSystemText(ctrl)
ctrl.EnsureSessionPath()
if ctrl.SessionPath() == "" {
return errUnexpected("a session path", "")
}
turn, err := runTurns(ctx, ctrl, 0, probeTurns)
if err != nil {
return err
}
if err := ctrl.SetGoalDurable(probeGoal); err != nil {
return fmt.Errorf("goal: %w", err)
}
if _, err := ctrl.Compact(ctx, agent.CompactRequest{Instructions: "Fold the probe turn."}); err != nil {
return fmt.Errorf("compact: %w", err)
}
if taken, err := armConstruct(ctx, root, arm, bootSystem, ctrl, sink, prov, turn); taken {
return err
}
if arm == armTodoIdentity {
// Identity A is already written and folded away. Grow, replace the list
// with identity B, then fold again: the second fold is what decides
// whether the model is left holding one current identity or two.
if turn, err = runTurns(ctx, ctrl, turn, refoldTurns); err != nil {
return err
}
if err := ctrl.Run(ctx, fmt.Sprintf("%s %s: replace the task list.", marker(turn+1), retodoSentinel)); err != nil {
return fmt.Errorf("retodo turn: %w", err)
}
// Grow well past the rewrite so the fold takes the new ids out of view.
// A scenario that leaves them readable asks nothing of the note.
if _, err = runTurns(ctx, ctrl, turn+1, probeTurns); err != nil {
return err
}
if _, err := ctrl.Compact(ctx, agent.CompactRequest{Instructions: "Fold again, after the identity moved."}); err != nil {
return fmt.Errorf("refold: %w", err)
}
} else if arm == armRefoldIntoBody {
// Grow past the fold, record the view a second fold will operate on,
// then fold again: its region reaches back into the stored body, which
// has no canonical counterpart to map a new boundary onto.
if _, err = runTurns(ctx, ctrl, turn, refoldTurns); err != nil {
return err
}
if err := writeObservation(root, capture(extraPhase(arm), arm, bootSystem, ctrl, sink, root)); err != nil {
return err
}
if _, err := ctrl.Compact(ctx, agent.CompactRequest{Instructions: "Fold again, into the stored body."}); err != nil {
return fmt.Errorf("refold: %w", err)
}
} else if appendsAfterFold(arm) {
if _, err = runTurns(ctx, ctrl, turn, 1); err != nil {
return err
}
}
if arm == armTailRewind || arm == armCoveredRewind {
if err := rewindTurn(root, arm, bootSystem, ctrl, sink); err != nil {
return err
}
}
return writeObservation(root, capture("construct", arm, bootSystem, ctrl, sink, root))
}
// successorArm reports whether this arm starts from an interrupted barrier.
func successorArm(arm string) bool {
_, ok := successorTurn(arm)
return ok
}
// runSuccessor is the middle process: it inherits the interruption and does
// what a person would do next — nothing, unrelated work, or something that
// reads like an answer to the dead question. It runs a model on purpose; the
// phase that judges what the host knows still does not.
func runSuccessor(dir, arm string) error {
root := rootFor(dir)
before, err := readObservation(root, "construct")
if err != nil {
return err
}
ctx := context.Background()
sink := &graphSink{}
ctrl, _, err := buildRuntime(ctx, root, arm, sink)
if err != nil {
return fmt.Errorf("build: %w", err)
}
defer ctrl.Close()
bootSystem := bootSystemText(ctrl)
session, err := sessionstore.LoadSession(before.SessionPath)
if err != nil {
return fmt.Errorf("load session: %w", err)
}
if err := ctrl.Resume(session, before.SessionPath); err != nil {
return fmt.Errorf("resume: %w", err)
}
if turn, _ := successorTurn(arm); turn != "" {
if err := ctrl.Run(ctx, turn); err != nil {
return fmt.Errorf("successor turn: %w", err)
}
}
return writeObservation(root, capture("successor", arm, bootSystem, ctrl, sink, root))
}
// runResume boots a second time against the same roots and reads host state.
// It deliberately never calls Run: the question is what the host can prove
// before a provider call, and a model that reconstructs a lost identity from
// the transcript would turn a real loss into a passing row.
func runResume(dir, arm string) error {
root := rootFor(dir)
before, err := readObservation(root, "construct")
if err != nil {
return fmt.Errorf("read construct observation: %w", err)
}
ctx := context.Background()
sink := &graphSink{}
bus, into := uiBus(arm, sink)
ctrl, _, err := buildRuntime(ctx, root, arm, into)
if err != nil {
return fmt.Errorf("build: %w", err)
}
defer ctrl.Close()
bootSystem := bootSystemText(ctrl)
session, err := sessionstore.LoadSession(before.SessionPath)
if err != nil {
return fmt.Errorf("load session: %w", err)
}
if err := ctrl.Resume(session, before.SessionPath); err != nil {
return fmt.Errorf("resume: %w", err)
}
obs := capture("resume", arm, bootSystem, ctrl, sink, root)
if uiArm(arm) {
ui := observeUI(root, ctrl, bus, obs)
obs.UI = &ui
}
return writeObservation(root, obs)
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"fmt"
"sort"
"strconv"
"strings"
)
// A fold may only remove a run of markers from the middle: the pinned head
// stays, the retained tail stays, and everything between them becomes the
// digest. Any other shape means the view lost something no fold folded.
const (
verdictContiguous = "contiguous"
verdictHole = "HOLE"
markPrefix = "PROBE-MARK-"
echoPrefix = "PROBE-ECHO-"
)
// refoldRows report what the second fold did. They are their own set because
// the comparison is inside one process — the view before the refold against
// the view after it — not across the process boundary.
func refoldRows(prefold, postfold, resumed Observation, total int) []row {
return []row{
coverageRow("fold 1: stored body and coverage", prefold),
coverageRow("fold 2: stored body and coverage", postfold),
continuityRow("assistant work visible after fold 1", prefold, total),
continuityRow("assistant work visible after fold 2", postfold, total),
continuityRow("assistant work visible after restart", resumed, total),
turnsRow("user turns visible after fold 2", postfold),
}
}
func coverageRow(semantic string, o Observation) row {
return row{
Semantic: semantic, Authority: "compaction sidecar", Artifact: "<stem>.context.json",
Reconstruction: "projection.Messages + canonical[CoveredCount:]",
Before: fmt.Sprintf("canonical=%d", o.Transcript.Messages),
After: fmt.Sprintf("body=%d covered=%d spliced=+%d view=%d",
o.Sidecar.Messages, o.Sidecar.CoveredCount, o.View.SplicedFromTail, o.View.Messages),
Verdict: verdictStable,
}
}
// continuityRow judges the assistant side: a fold replaces one run of replies
// with a digest, so anything but a single surviving run is a loss no fold made.
func continuityRow(semantic string, o Observation, total int) row {
shape := markerShape(o.View.Echoes, echoPrefix)
verdict := verdictContiguous
if reason := markerHole(o.View.Echoes, echoPrefix, total); reason != "" {
verdict, shape = verdictHole, shape+" — "+reason
}
return row{
Semantic: semantic, Authority: "the spliced model-visible view",
Artifact: "none: derived", Reconstruction: "assistant replies surviving in the view",
Before: fmt.Sprintf("emitted 1-%d", total), After: shape, Verdict: verdict,
}
}
// turnsRow reports the user side beside it: the retention budget keeps those
// verbatim, which is why a one-sided tag could not see a dropped reply.
func turnsRow(semantic string, o Observation) row {
return row{
Semantic: semantic, Authority: "the spliced model-visible view",
Artifact: "none: derived", Reconstruction: "user turns surviving in the view",
Before: "kept by the retention budget",
After: markerShape(o.View.Markers, markPrefix), Verdict: verdictStable,
}
}
// pad renders a tag number the way the scripted turns emit it.
func pad(n int) string { return fmt.Sprintf("%03d", n) }
func markerNumbers(markers []string, prefix string) []int {
seen := map[int]bool{}
var out []int
for _, m := range markers {
n, err := strconv.Atoi(strings.TrimPrefix(m, prefix))
if err != nil || seen[n] {
continue
}
seen[n] = true
out = append(out, n)
}
sort.Ints(out)
return out
}
// markerShape renders the surviving markers as runs, which is the form the
// judgement is about: "1, 9-12" says a fold took 2-8.
func markerShape(markers []string, prefix string) string {
nums := markerNumbers(markers, prefix)
if len(nums) == 0 {
return "none"
}
var runs []string
start := nums[0]
for i := 1; i <= len(nums); i++ {
if i < len(nums) && nums[i] == nums[i-1]+1 {
continue
}
if end := nums[i-1]; end == start {
runs = append(runs, strconv.Itoa(start))
} else {
runs = append(runs, fmt.Sprintf("%d-%d", start, end))
}
if i < len(nums) {
start = nums[i]
}
}
return strings.Join(runs, ", ")
}
// markerHole names the violation, or returns "" when the shape is one a fold
// can produce: an optional pinned head starting at 1, then one run reaching the
// last turn. Two gaps, or a tail that stops short of it, are losses.
func markerHole(markers []string, prefix string, total int) string {
nums := markerNumbers(markers, prefix)
if len(nums) == 0 {
return "the view shows no turn at all"
}
runs := strings.Split(markerShape(markers, prefix), ", ")
if last := nums[len(nums)-1]; last != total {
return fmt.Sprintf("the newest turn %d is missing", total)
}
if len(runs) > 2 {
return fmt.Sprintf("%d separate runs; a fold removes one", len(runs))
}
if len(runs) == 2 && nums[0] != 1 {
return fmt.Sprintf("the kept head starts at %d, not the pinned first turn", nums[0])
}
return ""
}
+45
View File
@@ -0,0 +1,45 @@
package main
import "testing"
// The oracle has to be able to fail, or "no hole" says nothing. These are the
// shapes a fold can produce and the shapes only a loss can.
func TestMarkerHoleDiscriminates(t *testing.T) {
echo := func(ns ...int) []string {
out := make([]string, 0, len(ns))
for _, n := range ns {
out = append(out, echoPrefix+pad(n))
}
return out
}
cases := []struct {
name string
in []string
total int
holed bool
}{
{"nothing folded", echo(1, 2, 3, 4, 5), 5, false},
{"middle folded away", echo(4, 5), 5, false},
{"pinned head kept beside the tail", echo(1, 4, 5), 5, false},
{"a reply dropped inside the kept tail", echo(3, 5), 5, true},
{"two gaps", echo(1, 3, 5), 5, true},
{"newest turn missing", echo(3, 4), 5, true},
{"head kept but not the first turn", echo(2, 4, 5), 5, true},
{"empty view", nil, 5, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
reason := markerHole(tc.in, echoPrefix, tc.total)
if holed := reason != ""; holed != tc.holed {
t.Fatalf("markerHole(%v) = %q, want holed=%v", tc.in, reason, tc.holed)
}
})
}
}
func TestMarkerShapeRendersRuns(t *testing.T) {
in := []string{echoPrefix + pad(1), echoPrefix + pad(9), echoPrefix + pad(10), echoPrefix + pad(11)}
if got := markerShape(in, echoPrefix); got != "1, 9-11" {
t.Fatalf("markerShape = %q, want %q", got, "1, 9-11")
}
}
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"fmt"
"strings"
)
// renderMatrix prints one table per arm. It reports what each row's value was
// on both sides of the process boundary, because a verdict without the two
// readings behind it is an assertion.
func renderMatrix(results []armResult, work string) string {
var b strings.Builder
b.WriteString("# Runtime resurrection matrix\n\n")
b.WriteString("Measurement boundary: OS process. Phase B loads and inspects; it never calls a model.\n\n")
fmt.Fprintf(&b, "Arm roots: %s\n\n", work)
for _, res := range results {
fmt.Fprintf(&b, "## arm: %s\n\n%s\n\n", res.Arm, res.Asks)
if res.Invalid != "" {
fmt.Fprintf(&b, "**ARM INVALID — %s.** Its rows below establish nothing.\n\n", res.Invalid)
}
b.WriteString("| Semantic | Before death | After new process | Verdict | Authority | Artifact |\n")
b.WriteString("| --- | --- | --- | --- | --- | --- |\n")
for _, r := range res.Rows {
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s | %s |\n",
r.Semantic, cell(r.Before), cell(r.After), r.Verdict, r.Authority, r.Artifact)
}
b.WriteString("\n")
b.WriteString(summarize(res))
b.WriteString("\n")
}
return b.String()
}
func summarize(res armResult) string {
counts := map[string]int{}
for _, r := range res.Rows {
counts[r.Verdict]++
}
var parts []string
// Every verdict the matrix can produce. A summary that silently omits one
// is how a VIOLATED row reads as a clean arm.
for _, v := range []string{verdictPersisted, verdictExact, verdictLossy, verdictLost, verdictNotMeasured,
verdictChanged, verdictStable, verdictContiguous, verdictHole, verdictHolds, verdictViolated} {
if counts[v] > 0 {
parts = append(parts, fmt.Sprintf("%s=%d", v, counts[v]))
}
}
return strings.Join(parts, " ") + "\n"
}
func cell(s string) string {
if strings.TrimSpace(s) == "" {
return "—"
}
s = strings.ReplaceAll(s, "|", "\\|")
if len(s) > 64 {
return s[:61] + "..."
}
return s
}
+323
View File
@@ -0,0 +1,323 @@
package main
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// Verdicts. They are deliberately not booleans: "the host holds it verbatim"
// and "a fold over canonical artifacts happens to agree" are different claims
// about durability, and a row that was never established supports neither.
const (
verdictPersisted = "persisted-direct"
verdictExact = "reconstructed-exact"
verdictLossy = "reconstructed-lossy"
verdictLost = "lost"
verdictNotMeasured = "not-measured"
verdictChanged = "changed"
verdictStable = "stable"
)
type row struct {
Semantic string `json:"semantic"`
Authority string `json:"authority"`
Artifact string `json:"artifact"`
Reconstruction string `json:"reconstruction"`
Before string `json:"before"`
After string `json:"after"`
Verdict string `json:"verdict"`
}
type armResult struct {
Arm string `json:"arm"`
Asks string `json:"asks"`
Invalid string `json:"invalid,omitempty"`
Rows []row `json:"rows"`
Extra *Observation `json:"extra,omitempty"`
Before Observation `json:"before"`
After Observation `json:"after"`
}
func classify(a arm, extra *Observation, before, after Observation) armResult {
res := armResult{Arm: a.name, Asks: a.asks, Extra: extra, Before: before, After: after}
prefix := prefixRow(before, after)
res.Rows = append(res.Rows, prefix)
if a.name == "system-swap" && prefix.Verdict != verdictChanged {
res.Invalid = "the lever did not move the stable prefix, so this arm measures nothing"
}
if a.name == "exact" && prefix.Verdict != verdictStable {
res.Invalid = "the stable prefix moved without a lever, so this arm is not the control it claims"
}
res.Rows = append(res.Rows,
valueRow("system row in the session", "the transcript's own leading system message",
"<stem>.jsonl", "loaded with the transcript",
before.SystemHash, after.SystemHash, true),
valueRow("canonical transcript", "session transcript + event log",
"<stem>.jsonl, <stem>.events.jsonl", "LoadSession replays the append log",
before.Transcript.Digest, after.Transcript.Digest, true),
valueRow("active goal identity", "goalMachine (control)",
"<stem>.goal-state.json", "restoreFromState on Resume",
before.Goal.Goal, after.Goal.Goal, true),
valueRow("goal status", "goalMachine (control)",
"<stem>.goal-state.json", "restoreFromState on Resume",
before.Goal.Status, after.Goal.Status, true),
valueRow("todo host identity", "agent todoState (host-owned, never in the prompt)",
"none verbatim; goal-state.json snapshots a terminal goal's list",
"rebuilt from the transcript's todo_write / complete_step receipts",
todoIDs(before), todoIDs(after), false),
valueRow("open decisions", "approvalManager (control, in-memory)",
"none", "none: derived from live pending approvals/asks",
decisionIDs(before), decisionIDs(after), false),
valueRow("compaction projection (stored)", "compaction sidecar",
"<stem>.context.json", "LoadProjectionSidecar on bind",
sidecarClaim(before), sidecarClaim(after), true),
useRow("compaction projection (in use)", before, after),
valueRow("run graph nodes", "event sink (GraphDelta)",
"none observed", "fold GraphDelta events, if any survive",
graphNodes(before), graphNodes(after), false),
valueRow("run graph grants", "event sink (GraphDelta)",
"none observed", "fold GraphDelta events, if any survive",
graphMap(before.Graph.Grants), graphMap(after.Graph.Grants), false),
valueRow("run graph wait causes", "event sink (GraphDelta)",
"none observed", "fold GraphDelta events, if any survive",
graphMap(before.Graph.Waits), graphMap(after.Graph.Waits), false),
)
switch {
case successorArm(a.name) && extra != nil:
res.Rows = append(res.Rows, successorRows(a.name, *extra, after)...)
// The premise is a barrier open at death. The construct process is its
// own waiter, so it reports no interruption of its own.
if len(before.Decisions) == 0 {
res.Invalid = "no decision was open when the first process died"
}
case a.name == armOpenDecision:
res.Rows = append(res.Rows, decisionRows(before, after)...)
if len(before.Decisions) == 0 {
res.Invalid = "no decision was open when the process died, so nothing was interrupted"
}
case deriveArm(a.name):
res.Rows = append(res.Rows, deriveRows(a.name, before, after)...)
res.Invalid = deriveArmInvalid(a.name, before)
case terminalArm(a.name):
res.Rows = append(res.Rows, terminalRows(a.name, before, after)...)
res.Invalid = terminalArmInvalid(a.name, before)
case schedulerWaitArm(a.name):
res.Rows = append(res.Rows, schedulerWaitRows(a.name, before, after)...)
res.Invalid = schedulerArmInvalid(a.name, before)
case graphArm(a.name):
res.Rows = append(res.Rows, graphRows(before, after)...)
res.Invalid = graphArmInvalid(a.name, before)
case uiArm(a.name):
res.Rows = append(res.Rows, uiRows(before, after)...)
res.Invalid = uiArmInvalid(before, after)
case lineageArm(a.name):
res.Rows = append(res.Rows, lineageRows(a.name, before)...)
case cancelRoutingArm(a.name):
res.Rows = append(res.Rows, cancelRoutingRows(a.name, before)...)
case loneTaskArm(a.name):
res.Rows = append(res.Rows, loneTaskRows(a.name, before, after)...)
res.Invalid = loneTaskArmInvalid(a.name, before)
case activeStoreArm(a.name):
res.Rows = append(res.Rows, activeStoreRows(before, after)...)
res.Invalid = activeStoreInvalid(before)
case skillArm(a.name):
res.Rows = append(res.Rows, skillClassRows(a.name, before, after)...)
res.Invalid = skillArmInvalid(a.name, before)
case ephemeralArm(a.name):
res.Rows = append(res.Rows, ephemeralRows(a.name, before, after)...)
res.Invalid = ephemeralArmInvalid(before)
case hostExecArm(a.name):
res.Rows = append(res.Rows, hostExecRows(a.name, before, after)...)
res.Invalid = hostExecArmInvalid(a.name, before)
case a.name == armTodoIdentity:
res.Rows = append(res.Rows, todoRows(before, after)...)
case a.name == armRefoldIntoBody && extra != nil:
res.Rows = append(res.Rows, refoldRows(*extra, before, after, probeTurns+refoldTurns)...)
if extra.Sidecar.Messages == 0 {
res.Invalid = "the first fold stored no body, so the second had none to reach into"
}
case (a.name == armTailRewind || a.name == armCoveredRewind) && extra != nil:
res.Rows = append(res.Rows, useRow("projection across the rewind, same process", *extra, before))
res.Rows = append(res.Rows, rewindLandingRow(a.name, *extra, before))
res.Invalid = rewindLandedWrong(a.name, *extra, before)
}
return res
}
// prefixRow is the arm's own control rather than a durability claim: it reports
// whether the stable prefix moved, which is the thing each arm sets up.
func prefixRow(before, after Observation) row {
verdict := verdictStable
if before.BootSystemHash != after.BootSystemHash {
verdict = verdictChanged
}
return row{
Semantic: "stable prefix identity (composed)", Authority: "boot assembly (recomposed per process)",
Artifact: "none (composed from config, memory and instructions)", Reconstruction: "recomposed at boot",
Before: before.BootSystemHash, After: after.BootSystemHash, Verdict: verdict,
}
}
func valueRow(semantic, authority, artifact, reconstruction, before, after string, persisted bool) row {
r := row{Semantic: semantic, Authority: authority, Artifact: artifact,
Reconstruction: reconstruction, Before: before, After: after}
switch {
case before == "" || before == "-":
r.Verdict = verdictNotMeasured
case after == "" || after == "-":
r.Verdict = verdictLost
case before != after:
r.Verdict = verdictLossy
case persisted:
r.Verdict = verdictPersisted
default:
r.Verdict = verdictExact
}
return r
}
func todoIDs(o Observation) string {
var ids []string
for _, t := range o.Todos {
id := t.StepID
if id == "" {
id = "(no step_id)"
}
ids = append(ids, id+":"+t.Status)
}
return join(ids)
}
func decisionIDs(o Observation) string {
var ids []string
for _, d := range o.Decisions {
ids = append(ids, string(d.Kind)+":"+d.ID)
}
return join(ids)
}
func sidecarClaim(o Observation) string {
if !o.Sidecar.Present {
return ""
}
return fmt.Sprintf("v%d msgs=%d covered=%d hash=%s",
o.Sidecar.ProjectionVersion, o.Sidecar.Messages, o.Sidecar.CoveredCount, short(o.Sidecar.CoveredPrefixHash))
}
// rewindLandingRow says where the rewind actually landed relative to the fold.
// An arm that names a side and does not reach it is not measuring that side.
func rewindLandingRow(arm string, prerewind, after Observation) row {
side := "above the fold boundary"
if after.Transcript.Messages < prerewind.Sidecar.CoveredCount {
side = "below the fold boundary"
}
return row{
Semantic: "where the rewind landed", Authority: "checkpoint boundary against the sidecar",
Artifact: "none: derived", Reconstruction: "canonical length after the rewind against CoveredCount before it",
Before: fmt.Sprintf("covered=%d", prerewind.Sidecar.CoveredCount),
After: fmt.Sprintf("canonical=%d, %s", after.Transcript.Messages, side), Verdict: verdictStable,
}
}
// rewindLandedWrong invalidates an arm that aimed at one side of the fold and
// reached the other. covered-rewind stopped removing folded history the moment
// coverage became a real boundary, and nothing in the matrix said so.
func rewindLandedWrong(arm string, prerewind, after Observation) string {
below := after.Transcript.Messages < prerewind.Sidecar.CoveredCount
switch {
case arm == armCoveredRewind && !below:
return "the rewind landed above the fold boundary, so it removed no folded history"
case arm == armTailRewind && below:
return "the rewind landed below the fold boundary, so it is not a tail-only truncation"
}
return ""
}
// useRow turns on whether the next request would ride the projection, not on
// the token counts beside it: a tail edit legitimately moves those, and letting
// them decide the verdict reported a surviving projection as a loss.
func useRow(semantic string, before, after Observation) row {
r := valueRow(semantic, "projectionValid over the covered prefix",
"none: a judgement, not a stored value",
"revalidated against the canonical prefix on every read",
projectionState(before), projectionState(after), false)
r.Before, r.After = projectionUse(before), projectionUse(after)
return r
}
// projectionState is the compared value: in use, not in use, or never measured.
func projectionState(o Observation) string {
switch o.Context.CheckpointState {
case "":
return ""
case "none":
return "NOT-in-use"
}
return "in-use"
}
// projectionUse reads the host's own judgement rather than the stored claim.
// CheckpointState is "none" whenever validation failed, which is the difference
// between a sidecar that loaded and one the next request will actually ride.
// The label itself is not compared — "applied" and "restored" are the same
// answer reached from different sides — so the row turns on use and size.
func projectionUse(o Observation) string {
if o.Context.CheckpointState == "" {
return ""
}
use := "in-use"
if o.Context.CheckpointState == "none" {
use = "NOT-in-use"
}
return fmt.Sprintf("%s projected=%d canonical=%d", use,
o.Context.ProjectedTokens, o.Context.CanonicalTokens)
}
// graphNodes renders the fold's nodes in a stable order. The fold keeps
// first-seen order, which two processes reach by different routes, so an
// unsorted rendering would report a reordering as a changed graph.
func graphNodes(o Observation) string {
if len(o.Graph.Nodes) == 0 {
return ""
}
ids := make([]string, 0, len(o.Graph.Nodes))
for _, n := range o.Graph.Nodes {
ids = append(ids, n.ID+":"+string(n.State))
}
sort.Strings(ids)
return join(ids)
}
// graphMap renders one per-node attribute set. Map iteration is unordered, so
// without the sort two identical envelopes would compare as different.
func graphMap(m map[string]string) string {
if len(m) == 0 {
return ""
}
out := make([]string, 0, len(m))
for k, v := range m {
out = append(out, k+"="+v)
}
sort.Strings(out)
return join(out)
}
func join(in []string) string {
if len(in) == 0 {
return ""
}
return strings.Join(in, ", ")
}
func short(s string) string {
if len(s) <= 12 {
return s
}
return s[:12]
}
func marshalResults(results []armResult) ([]byte, error) {
return json.MarshalIndent(results, "", " ")
}
+265
View File
@@ -0,0 +1,265 @@
package main
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"reasonix/internal/assembly/boot"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/contract/event"
"reasonix/internal/session/control"
)
// armRoot names the four directories one arm owns. Home and Workspace are
// separate so a lever can change what the prefix folds without touching the
// transcript, and Sessions is explicit so the probe never writes to the
// developer's own session directory.
type armRoot struct{ Home, Workspace, Sessions, Dir string }
func rootFor(dir string) armRoot {
return armRoot{
Dir: dir,
Home: filepath.Join(dir, "home"),
Workspace: filepath.Join(dir, "workspace"),
Sessions: filepath.Join(dir, "sessions"),
}
}
func (r armRoot) create() error {
for _, d := range []string{r.Home, r.Workspace, r.Sessions} {
if err := os.MkdirAll(d, 0o755); err != nil {
return err
}
}
return nil
}
// writeProjectConfig puts a project config beside the workspace, which is where
// a person configures these ceilings. The scheduler arms need them small enough
// to reach a specific refusal, and a poked field would prove nothing about the
// judgement production makes.
func (r armRoot) writeProjectConfig(total, writers int) error {
body := fmt.Sprintf("[agent]\nmax_subagent_concurrency = %d\nmax_parallel_writers = %d\n", total, writers)
return os.WriteFile(filepath.Join(r.Workspace, "reasonix.toml"), []byte(body), 0o644)
}
// requireFresh refuses a root a previous run left behind. A reused root is how
// a lever's effect ends up already present in the construct phase, which reads
// as "the lever changed nothing" — a false negative that looks like a result.
func (r armRoot) requireFresh() error {
entries, err := os.ReadDir(r.Dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if len(entries) > 0 {
return fmt.Errorf("arm root %s is not empty; each run needs a clean root", r.Dir)
}
return nil
}
// graphSink records what the run graph published while this process was alive.
// It is the only "before" the graph row has: nothing else in a session's
// artifacts is known to carry it, and a row with no before is not measured.
type graphSink struct {
mu sync.Mutex
graph agentgraph.Graph
deltas int
// waits is every wait cause published per node, in order. The fold keeps
// only the latest, which cannot say whether a cause was reported once or
// replaced — and that is exactly what the transition arm asks.
waits map[string][]string
// phases is every subagent status the parent was told, per call. A run
// handed to a job publishes no graph node at all, so for those this is the
// only live evidence that anything happened.
phases map[string][]string
// tools is when each call was dispatched and when it answered. A stop that
// reached a running tool is visible as the second arriving early.
tools map[string][2]time.Time
}
func (s *graphSink) Emit(e event.Event) {
if e.Kind == event.ToolDispatch || e.Kind == event.ToolResult {
s.noteTool(e)
return
}
if id, phase, ok := progressPhase(e); ok {
s.mu.Lock()
if s.phases == nil {
s.phases = map[string][]string{}
}
s.phases[id] = append(s.phases[id], phase)
s.mu.Unlock()
return
}
if e.Kind != event.GraphDelta || e.Graph == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.deltas++
for _, n := range e.Graph.Nodes {
if n.Wait == "" {
continue
}
if s.waits == nil {
s.waits = map[string][]string{}
}
s.waits[n.ID] = append(s.waits[n.ID], string(n.Wait))
}
s.graph.Apply(*e.Graph)
}
func (s *graphSink) snapshot() (agentgraph.Graph, int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.graph, s.deltas
}
// noteTool records the two edges of a tool call this probe may be asked about.
func (s *graphSink) noteTool(e event.Event) {
if e.Tool.ID == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.tools == nil {
s.tools = map[string][2]time.Time{}
}
at := s.tools[e.Tool.ID]
if e.Kind == event.ToolDispatch {
at[0] = time.Now()
} else {
at[1] = time.Now()
}
s.tools[e.Tool.ID] = at
}
func (s *graphSink) toolDispatched(id string) (time.Time, bool) {
s.mu.Lock()
defer s.mu.Unlock()
at, ok := s.tools[id]
return at[0], ok && !at[0].IsZero()
}
func (s *graphSink) toolResult(id string) (time.Time, bool) {
s.mu.Lock()
defer s.mu.Unlock()
at, ok := s.tools[id]
return at[1], ok && !at[1].IsZero()
}
// phaseSeries is every status published for one call, in publication order.
func (s *graphSink) phaseSeries() map[string][]string {
s.mu.Lock()
defer s.mu.Unlock()
out := map[string][]string{}
for id, seq := range s.phases {
out[id] = append([]string(nil), seq...)
}
return out
}
// waitSeries is every cause published for one node, in publication order.
func (s *graphSink) waitSeries() map[string][]string {
s.mu.Lock()
defer s.mu.Unlock()
out := map[string][]string{}
for id, seq := range s.waits {
out[id] = append([]string(nil), seq...)
}
return out
}
// buildRuntime assembles a real runtime bound to this arm's roots. Everything a
// frontend would supply is supplied; only the provider is scripted, so the
// probe never depends on a network or a key.
func buildRuntime(ctx context.Context, root armRoot, arm string, sink event.Sink) (*control.Controller, *scripted, error) {
prov := newScripted(arm)
ctrl, err := boot.Build(ctx, boot.Options{
Version: "runtime-resume-probe",
Model: probeModelRef,
Sink: sink,
WorkspaceRoot: root.Workspace,
Home: root.Home,
SessionDir: root.Sessions,
ProviderResolver: resolverFor(prov),
Stderr: io.Discard,
HeadlessApprovalMode: approvalMode(arm),
})
if err != nil {
return nil, nil, err
}
ctrl.ApplyHeadlessApprovalMode(approvalMode(arm))
return ctrl, prov, nil
}
// approvalMode is the gate this arm needs. Everything else runs denied, the
// strictest a headless frontend can be. A fleet is not read-only, so under a
// denied gate the dispatch is refused before any child starts and the arm
// measures a permission decision instead of a process boundary.
func approvalMode(arm string) string {
if graphArm(arm) || uiArm(arm) || loneTaskArm(arm) || cancelRoutingArm(arm) || lineageArm(arm) ||
schedulerWaitArm(arm) || terminalArm(arm) || deriveArm(arm) || activeStoreArm(arm) ||
skillArm(arm) || ephemeralArm(arm) || hostExecArm(arm) {
return control.ToolApprovalAuto
}
return "deny"
}
// childEnv binds the child process to this arm's home through the environment
// as well as through Options.Home: a Controller's later re-reads and the shared
// history index still read the process, not the assembly.
func childEnv(root armRoot) []string {
env := append(os.Environ(),
"REASONIX_HOME="+root.Home,
"REASONIX_STATE_HOME="+root.Home,
)
// A child runs in its own workspace, so the one arm that has to build the
// frontend is told where the checkout is rather than searching for it.
if repo := repoRoot(); repo != "" {
env = append(env, envProbeRepo+"="+repo)
}
return env
}
// repoRoot walks up from the orchestrator's working directory for the module
// this probe lives in. Empty when it is not run from a checkout, which the arm
// reports as not measured rather than as a failure.
func repoRoot() string {
dir, err := os.Getwd()
if err != nil {
return ""
}
for {
if b, err := os.ReadFile(filepath.Join(dir, "go.mod")); err == nil &&
strings.HasPrefix(string(b), "module reasonix") {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
// toolIDs is every call this sink saw dispatched, which is where a host-minted
// event id first appears: it is not in the graph and not in the transcript.
func (s *graphSink) toolIDs() map[string]bool {
s.mu.Lock()
defer s.mu.Unlock()
out := make(map[string]bool, len(s.tools))
for id := range s.tools {
out[id] = true
}
return out
}
+376
View File
@@ -0,0 +1,376 @@
package main
import (
"context"
"fmt"
"os"
"sort"
"strings"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/session/control"
)
// The scheduler-wait arms. Each dies with one item ready and refused admission,
// and they are separate because the scheduler decides in a fixed order —
// slots, then writers, then claim. An arm that let an earlier cause stand would
// report it as the later one, so each has to clear every check above the one it
// is measuring.
const (
// armWaitSlots fills the session's total ceiling with runs that never end.
// The refused item is read-only: it clears every writer check by not being
// a writer at all.
armWaitSlots = "wait-slots"
// armWaitWriters leaves total capacity free and fills the writer ceiling.
// Its two writers declare disjoint paths, so a claim conflict cannot be
// what refuses the second one.
armWaitWriters = "wait-writers"
// armWaitClaim leaves both ceilings free and overlaps two write paths. A
// single fan-out cannot reach this: preflight refuses concurrent writers
// whose claims overlap, so the holder is dispatched as a background fleet
// and the refused writer comes from a later one.
armWaitClaim = "wait-claim"
// armWaitTransition asks what a wait cause means once the thing it named
// stops being what holds the item back. The scheduler reports the cause
// once, when the request queues; the blocker can change afterwards.
armWaitTransition = "wait-transition"
)
func schedulerWaitArm(name string) bool {
switch name {
case armWaitSlots, armWaitWriters, armWaitClaim, armWaitTransition:
return true
}
return false
}
// schedulerLimits are the ceilings an arm needs to reach its refusal. They are
// written to the project config, so the run makes the same judgement a person
// configuring the session would get.
func schedulerLimits(arm string) (total, writers int) {
switch arm {
case armTerminalCancelled:
// One slot, so the group has an item running and items that never got
// one — the split a cancellation is reported against.
return 1, 1
case armLineageBackground, armLineageJobKill:
// Room for a second run: the alive check asks whether the manager can
// still start anything, and a ceiling of one would answer for the
// hanging child instead.
return 4, 4
case armTaskFgQueued, armTaskBgQueued, armTaskFgQueuedCancel, armTaskBgQueuedCancel:
// One slot, held by the fleet dispatched ahead of the delegation, so the
// refusal the arm records is the scheduler's and not a race.
return 1, 1
case armUIGraphMixed:
// One slot, so the item after the one that hangs is refused: the death
// then holds a node that was queued and never admitted.
return 1, 1
case armHostQueued, armHeadlessQueuedCancel:
// One slot, held by the fan-out dispatched ahead of the invocation.
return 1, 1
case armSkillQueued, armReadOnlySkillQueued:
// One slot, held by the fleet dispatched ahead of the skill, so the
// refusal the arm reads is the scheduler's and not a race.
return 1, 1
case armFleetActiveStore:
// Two slots: the delegation holds one to the death, and the fan-out's
// first item frees the other for exactly one of the two behind it.
return 2, 2
case armWaitSlots:
return 2, 1
case armWaitWriters:
return 6, 1
case armWaitClaim:
return 6, 3
case armWaitTransition:
return 3, 1
}
return 0, 0
}
// wantedCause is the refusal this arm exists to produce. The transition arm
// starts at slots and asks what happens to that answer afterwards.
func wantedCause(arm string) agentgraph.WaitCause {
switch arm {
case armWaitWriters:
return agentgraph.WaitWriters
case armWaitClaim:
return agentgraph.WaitClaim
}
return agentgraph.WaitSlots
}
// waitingWorkers are the nodes the scheduler refused and has not admitted: the
// arm's premise. State is checked as well as the cause, because a node that
// went on to run still carries the cause it waited under.
func waitingWorkers(g agentgraph.Graph, cause agentgraph.WaitCause) []string {
var out []string
for _, n := range g.Nodes {
if n.Kind == agentgraph.KindGroup || n.State != agentgraph.StateQueued {
continue
}
if n.Wait == cause {
out = append(out, n.ID)
}
}
sort.Strings(out)
return out
}
// waitForRefusal blocks until one item is queued under this arm's cause.
func waitForRefusal(sink *graphSink, arm string) error {
cause := wantedCause(arm)
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
graph, _ := sink.snapshot()
if len(waitingWorkers(graph, cause)) > 0 {
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected("an item queued under "+string(cause), waitStates(graph))
}
func waitStates(g agentgraph.Graph) string {
out := make([]string, 0, len(g.Nodes))
for _, n := range g.Nodes {
out = append(out, fmt.Sprintf("%s:%s/%s", n.ID, n.State, orNone(string(n.Wait))))
}
sort.Strings(out)
return orNone(join(out))
}
// schedTurn is the prompt that opens this arm's fan-out. The slots arm needs
// only one fleet; the others name both, and the latch order dispatches the
// holder first while the refused fleet waits on the holder's own signal.
func schedTurn(n int, arm string) string {
if arm == armWaitSlots {
return fmt.Sprintf("%s %s: dispatch the fan-out.", marker(n), fleetPairSentinel)
}
return fmt.Sprintf("%s %s %s: dispatch the fan-out.", marker(n), fleetHolderSentinel, fleetRefusedSentinel)
}
// runSchedulerWaitConstruct drives the arm to its refusal and exits while the
// item is still queued. The transition arm goes further: it frees the capacity
// the refusal named and asks what the reported cause does about it.
func runSchedulerWaitConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
go func() { _ = ctrl.Run(ctx, schedTurn(turn+1, arm)) }()
if err := waitForRefusal(sink, arm); err != nil {
return err
}
if arm == armWaitTransition {
if err := releaseAndSettle(sink, prov); err != nil {
return err
}
}
if err := writeObservation(root, capture("construct", arm, bootSystem, ctrl, sink, root)); err != nil {
return err
}
os.Exit(0)
return nil
}
// releaseAndSettle frees the run that filled total capacity, then waits for the
// graph to show it gone. What the refused item's cause does after that is the
// arm's question: the ceiling it named has room, and the writer ceiling that
// now holds it back was never reported.
func releaseAndSettle(sink *graphSink, prov *scripted) error {
prov.releaseOnce()
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
graph, _ := sink.snapshot()
if settledWorkers(graph) > 0 && len(waitingWorkers(graph, agentgraph.WaitSlots)) > 0 {
// Give any second report a chance to arrive before reading.
time.Sleep(500 * time.Millisecond)
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected("capacity freed while a refusal still stands", waitStates(graph))
}
func settledWorkers(g agentgraph.Graph) int {
n := 0
for _, node := range g.Nodes {
if node.Kind != agentgraph.KindGroup && node.State != "" && node.State.Terminal() {
n++
}
}
return n
}
// schedulerWaitRows report what a restart inherits about an item the scheduler
// refused. The classification is already right — STARTED settled that — so the
// question here is narrower: can the host still say why it never started?
func schedulerWaitRows(arm string, before, after Observation) []row {
rows := []row{refusalRow(arm, before, after)}
if arm == armWaitTransition {
rows = append(rows, capacityFreedRow(before))
}
return append(rows,
waitSeriesRow(arm, before),
executionKindRow(before, after),
waitProvenanceRow(before, after),
schedulerGateRow(before, after),
)
}
// capacityFreedRow is the transition arm's evidence. It freed the ceiling the
// refusal named and the item stayed queued, held by a different one — so what
// the reported cause does next is a question about the contract, not a race.
func capacityFreedRow(before Observation) row {
return row{
Semantic: "the ceiling the refusal named, once freed", Authority: "the arm, through a child it releases",
Artifact: "none", Reconstruction: "an item settles while the refused one stays queued",
Before: fmt.Sprintf("%d settled, still queued: %s", before.SettledWorkers, orNone(join(before.QueuedWorkers))),
After: "not applicable: the scheduler is gone", Verdict: verdictStable,
}
}
// refusalRow is the arm's own control: it reached the refusal it names, and no
// earlier one. Reaching a different cause means an upper check intercepted it,
// and the arm measured someone else's question.
func refusalRow(arm string, before, after Observation) row {
want := string(wantedCause(arm))
verdict := verdictHolds
if before.WaitCauses[want] == 0 {
verdict = verdictNotMeasured
}
return row{
Semantic: "the refusal this arm reached", Authority: "SubagentScheduler.canStartLocked",
Artifact: "none: reported once through OnQueued", Reconstruction: "slots, then writers, then claim",
Before: causeSummary(before), After: causeSummary(after), Verdict: verdict,
}
}
// waitSeriesRow is what the transition arm exists for: how many times a cause
// was published for the item that never started. Once means the answer is the
// cause that queued it, not the one that held it last.
func waitSeriesRow(arm string, before Observation) row {
return row{
Semantic: "how often a wait cause was reported", Authority: "OnQueued, which fires once per request",
Artifact: "none", Reconstruction: causeContract(before),
Before: seriesSummary(before), After: "not applicable: the scheduler is gone",
Verdict: verdictStable,
}
}
// causeContract states what the observed reporting says the cause means. It is
// read off the series rather than assumed: one report can only be the cause
// that queued the request, and more than one would track the current blocker.
func causeContract(o Observation) string {
for _, seq := range o.WaitSeries {
if len(seq) > 1 {
return "reported again as the blocker changes: the cause is the current one"
}
}
return "reported once: the cause is the one that queued the request, not the one holding it now"
}
// waitProvenanceRow is what the batch was built to expose and then close. The
// refusal exists only at the moment the scheduler makes it, so it is recorded
// there; this compares what the graph showed against what a later process can
// still read back.
func waitProvenanceRow(before, after Observation) row {
return valueRow("why the item never started", "the execution journal's queue records",
"<stem>.execution.jsonl", "the cause the scheduler gave when it first refused admission",
causeSummary(before), journalCauses(after), true)
}
// journalCauses is the refusal a restart reads back, counted the way the graph
// counts it so the two sides of the row are the same measurement.
func journalCauses(o Observation) string {
counts := map[string]int{}
for _, e := range o.Executions {
if e.Queued() {
counts[e.Cause]++
}
}
if len(counts) == 0 {
return ""
}
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
}
return strings.Join(parts, " ")
}
// schedulerGateRow is the other half of what a queue record proves. An item
// only reaches the scheduler once its dependencies are answered, so a queued
// entry is durable evidence it crossed that gate — the one thing a dependency
// edge alone could never say.
func schedulerGateRow(before, after Observation) row {
return valueRow("items proven to have reached the scheduler", "the execution journal's queue records",
"<stem>.execution.jsonl", "a queue record implies the dependency gate was crossed",
queuedIDs(before), queuedIDs(after), true)
}
func queuedIDs(o Observation) string {
var out []string
for _, e := range o.Executions {
if e.Queued() {
out = append(out, e.ID)
}
}
sort.Strings(out)
return join(out)
}
func causeSummary(o Observation) string {
if len(o.WaitCauses) == 0 {
return ""
}
keys := make([]string, 0, len(o.WaitCauses))
for k := range o.WaitCauses {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, o.WaitCauses[k]))
}
return strings.Join(parts, " ")
}
func seriesSummary(o Observation) string {
if len(o.WaitSeries) == 0 {
return "none"
}
ids := make([]string, 0, len(o.WaitSeries))
for id := range o.WaitSeries {
ids = append(ids, id)
}
sort.Strings(ids)
out := make([]string, 0, len(ids))
for _, id := range ids {
out = append(out, id+":["+strings.Join(o.WaitSeries[id], "->")+"]")
}
return join(out)
}
// schedulerArmInvalid refuses an arm that did not reach its own refusal. Each
// arm exists to isolate one cause, and one that reached a different cause was
// intercepted by a check above it — reporting that as this arm's answer is how
// a probe measures the wrong ceiling and calls it evidence.
func schedulerArmInvalid(arm string, before Observation) string {
want := string(wantedCause(arm))
if before.WaitCauses[want] == 0 {
return "no item was refused under " + want + "; the arm reached " + orNone(causeSummary(before))
}
for cause := range before.WaitCauses {
if cause != want {
return "an item was also refused under " + cause + ", so this arm does not isolate " + want
}
}
return ""
}
+327
View File
@@ -0,0 +1,327 @@
package main
import (
"context"
"encoding/json"
"fmt"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"reasonix/internal/contract/provider"
)
// newScripted builds the provider with its synchronisation channels open. The
// scheduler arms coordinate through them; every other arm never reads them.
func newScripted(arm string) *scripted {
return &scripted{arm: arm, held: make(chan struct{}), release: make(chan struct{})}
}
// probeModelRef is both the catalog ref and the model name boot is given, so
// syntheticEntryFromResolver matches it without touching any config file.
const probeModelRef = "probe/scripted"
// probeAltModelRef is a second identity the same scripted provider answers to.
// Without one, every layer of the model resolution collapses onto the same
// string and a probe cannot tell which layer produced it.
const probeAltModelRef = "probe/alt"
// scripted answers completions from a fixed script so the construct phase can
// establish host state without a network call. A request carrying no tools is a
// host-internal completion (the compaction summary); everything else is a turn.
type scripted struct {
// arm is which run this provider is scripting. Only the fan-out arms read
// it: the pair fleet is the same dispatch either way, and the arm decides
// whether its second item finishes or is still executing at death.
arm string
turnCalls atomic.Int64
// retodo latches the one reply that replaces the task list. The sentinel
// stays in history after the call, so without it every later round would
// rewrite the list again.
retodo atomic.Bool
// asked latches the one reply that opens a question and, in the same round,
// tries to write. Calling ask ends the round, so the write is the effect a
// decision is holding back.
asked atomic.Bool
// Which fleets have been opened. A fleet's aggregate returns into the same
// round that dispatched it, so without a latch the reply that follows reads
// its own sentinel off the still-current user turn and dispatches again.
fleets dispatched
// opened says the arm's own fan-out has gone out, whichever sentinel opened
// it: the terminal and derive arms then hold the turn rather than replying,
// so the process still dies inside one.
opened atomic.Bool
// The scheduler arms dispatch two fleets and must not race them. held is
// closed by the holder's own child, which only runs once its slot is
// granted; release lets the arm free capacity while a refusal stands.
holding sync.Once
released sync.Once
held chan struct{}
release chan struct{}
}
// dispatched is the set of fleets already opened, keyed by the sentinel that
// opened each. One named state rather than a latch per fleet: they share a
// lifetime and a meaning, and every new fan-out was adding another flag.
type dispatched struct {
mu sync.Mutex
seen map[string]bool
}
// held reports whether something already recorded this name, without claiming
// it. The lone-delegation arms read it to ask a negative: that a child the
// scheduler should be holding back never reached the provider at all.
func (d *dispatched) held(name string) bool {
d.mu.Lock()
defer d.mu.Unlock()
return d.seen[name]
}
// first reports whether this is the sentinel's first dispatch, and records it.
func (d *dispatched) first(sentinel string) bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.seen[sentinel] {
return false
}
if d.seen == nil {
d.seen = map[string]bool{}
}
d.seen[sentinel] = true
return true
}
// releaseOnce frees the child waiting on release. Idempotent: an arm may reach
// the moment to release through more than one path.
func (s *scripted) releaseOnce() { s.released.Do(func() { close(s.release) }) }
func (s *scripted) Name() string { return "probe" }
func (s *scripted) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
ch := make(chan provider.Chunk, 8)
go func() {
defer close(ch)
for _, c := range s.script(ctx, req) {
select {
case ch <- c:
case <-ctx.Done():
return
}
}
}()
return ch, nil
}
func (s *scripted) script(ctx context.Context, req provider.Request) []provider.Chunk {
if len(req.Tools) == 0 {
return append(text(summaryBody), done())
}
// A delegated run answers first: the parent's transcript quotes every fleet
// argument, so any later branch that scanned the conversation would make
// the parent answer as one of its own children.
if sentinel := childSentinel(req); sentinel != "" {
return s.childScript(ctx, sentinel)
}
if chunks, ok := s.fanOut(req); ok {
return chunks
}
if s.opens(req, sleepSentinel) {
return append(sleepCall(), done())
}
if s.opensRun(req, taskSentinel) {
if queuedTaskArm(s.arm) {
// The holder's own child says when the ceiling is occupied.
// Dispatching before that lets the delegation win the race and start.
<-s.held
}
return append(loneTaskCall(s.arm), done())
}
if s.opensRun(req, ephemeralSentinel) {
return append(ephemeralCall(s.arm), done())
}
if s.opensRun(req, skillSentinel) {
if queuedSkillArm(s.arm) {
// The holder's own child says when the ceiling is occupied.
// Dispatching before that lets the skill win the race and start.
<-s.held
}
return append(skillCall(s.arm), done())
}
// The one arm whose turn has to close signs the host's steps off first, one
// per round, because a turn that called a tool cannot deliver while the list
// is open. The latch is read last: a round that could not answer keeps it.
if closesItsTurn(s.arm) && s.opened.Load() && hasTool(req.Tools, "complete_step") {
for _, id := range probeStepIDs() {
if s.fleets.first("sign-" + id) {
return append(signStep(id), done())
}
}
}
// A terminal arm needs its fan-out to finish and its turn to stay open: a
// closed turn would settle the marker, and the process would no longer be
// dying inside one.
if (terminalArm(s.arm) || deriveArm(s.arm)) && s.opened.Load() {
<-ctx.Done()
return nil
}
if s.turnCalls.Add(1) == 1 && hasTool(req.Tools, "todo_write") {
return append(todoCall(firstTodos()), done())
}
if hasTool(req.Tools, "todo_write") && requestMentions(req, retodoSentinel) && !s.retodo.Swap(true) {
return append(todoCall(secondTodos()), done())
}
if requestMentions(req, askSentinel) && !s.asked.Swap(true) {
return append(askAndWrite(), done())
}
return append(text(echoMarker(req)+turnBody), done())
}
// A turn is tagged twice: the user side by markerPattern, the assistant reply
// by echoPattern. A fold folds assistant work first, so a one-sided tag would
// let the surviving user turn hide the reply that was dropped beside it.
var (
markerPattern = regexp.MustCompile(`PROBE-MARK-\d+`)
echoPattern = regexp.MustCompile(`PROBE-ECHO-\d+`)
)
func marker(n int) string { return fmt.Sprintf("PROBE-MARK-%03d", n) }
// markersIn returns the tags a message set still shows, in order.
func markersIn(texts []string, pat *regexp.Regexp) []string {
var out []string
for _, t := range texts {
out = append(out, pat.FindAllString(t, -1)...)
}
return out
}
// echoMarker answers a turn with the assistant-side tag for the same number.
func echoMarker(req provider.Request) string {
for _, m := range slices.Backward(req.Messages) {
found := markerPattern.FindAllString(m.Content, -1)
if len(found) == 0 {
continue
}
return "PROBE-ECHO-" + found[len(found)-1][len("PROBE-MARK-"):] + " reply. "
}
return ""
}
func hasTool(schemas []provider.ToolSchema, name string) bool {
for _, t := range schemas {
if t.Name == name {
return true
}
}
return false
}
func text(s string) []provider.Chunk {
return []provider.Chunk{{Type: provider.ChunkText, Text: s}}
}
func done() provider.Chunk { return provider.Chunk{Type: provider.ChunkDone} }
// todoCall writes the list the resume phase looks for. The step ids are the
// point: they are the host identity a completion has to cite. A first write
// may not open with a completed item — the host rejects that shape, and a
// rejected call would leave the row unmeasured rather than answered.
func todoCall(todos []map[string]any) []provider.Chunk {
args, _ := json.Marshal(map[string]any{"todos": todos})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: "probe_todo_" + strconv.Itoa(len(todos)), Name: "todo_write", Arguments: string(args)},
}}
}
// retodoSentinel asks the scripted reply to replace the task list, so a probe
// can move host step identity from one set of ids to another.
const retodoSentinel = "PROBE-RETODO"
// askSentinel asks for a question the host must put to a person, alongside the
// write that question is holding back.
const (
askSentinel = "PROBE-ASK"
deferredEffect = "probe-deferred-effect.txt"
deferredContents = "this write was not held back by the decision"
)
// askAndWrite opens a question and asks to write in the same round. The round
// ends at the question, so the write must not run — not while the answer is
// pending, and not because a later process reloaded the session.
func askAndWrite() []provider.Chunk {
ask, _ := json.Marshal(map[string]any{"questions": []map[string]any{{
"header": "Boundary",
"question": "Which side of the fold should the probe measure?",
"reason": "user_decision",
"options": []map[string]any{
{"label": "Below the fold", "description": "removes folded history"},
{"label": "Above the fold", "description": "leaves coverage intact"},
},
}}})
write, _ := json.Marshal(map[string]string{"path": deferredEffect, "content": deferredContents})
return []provider.Chunk{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "probe_ask_1", Name: "ask", Arguments: string(ask)}},
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "probe_write_1", Name: "write_file", Arguments: string(write)}},
}
}
func requestMentions(req provider.Request, needle string) bool {
for _, m := range slices.Backward(req.Messages) {
if strings.Contains(m.Content, needle) {
return true
}
}
return false
}
func firstTodos() []map[string]any {
return []map[string]any{
{"content": "Establish probe state", "status": "in_progress", "activeForm": "Establishing probe state", "step_id": "probe_step_01"},
{"content": "Cross the process boundary", "status": "pending", "step_id": "probe_step_02"},
{"content": "Report the resurrection matrix", "status": "pending", "step_id": "probe_step_03"},
}
}
// secondTodos replaces the pending work while the in_progress item stays: the
// host refuses to drop an item that is in flight, so this is what a list
// rewrite actually looks like. The two new ids are what a stale note misses.
func secondTodos() []map[string]any {
return []map[string]any{
{"content": "Establish probe state", "status": "in_progress", "activeForm": "Establishing probe state", "step_id": "probe_step_01"},
{"content": "Split the frozen body", "status": "pending", "step_id": "probe_step_21"},
{"content": "Hold the ordering contract", "status": "pending", "step_id": "probe_step_22"},
}
}
// turnBody is fat on purpose: the fold keeps a recent verbatim tail, so thin
// replies leave no region between the pinned head and that tail.
var turnBody = "Probe state established. " + strings.Repeat("The probe records deterministic filler so the fold has material to work on. ", 60)
var summaryBody = strings.Join([]string{
"## Standing facts & constraints",
"The runtime-resume probe is measuring what survives a process boundary.",
"",
"## Goal",
"Establish host state, exit, and observe what a new process can still prove.",
}, "\n")
func resolverFor(prov *scripted) *provider.StaticResolver {
return &provider.StaticResolver{
Descriptors: []provider.Descriptor{{
Ref: probeModelRef, DisplayName: "probe", Model: "scripted",
ContextWindow: 128_000, Tools: true,
}, {
Ref: probeAltModelRef, DisplayName: "probe-alt", Model: "scripted-alt",
ContextWindow: 128_000, Tools: true,
}},
Providers: map[string]provider.Provider{probeModelRef: prov, probeAltModelRef: prov},
}
}
func errUnexpected(what string, got any) error {
return fmt.Errorf("probe could not establish %s (got %v); the arm measures nothing and is reported invalid", what, got)
}
@@ -0,0 +1,38 @@
package main
import (
"strings"
"testing"
)
// Sentinels are matched by substring, so one contained in another dispatches on
// the same prompt: the identity arm silently ran the cancellation fan-out for as
// long as PROBE-PARALLEL sat inside PROBE-PARALLEL-IDENTITY, and its rows read a
// scenario nobody wrote. Care did not catch it — the child sentinels carry a
// comment about exactly this hazard and the parent ones had no guard at all.
func TestNoSentinelContainsAnother(t *testing.T) {
sentinels := map[string]string{
"fleetWarm": fleetWarmSentinel, "fleetMixed": fleetMixedSentinel,
"fleetPair": fleetPairSentinel, "fleetHolder": fleetHolderSentinel,
"fleetRefused": fleetRefusedSentinel, "fleetTerminal": fleetTerminalSentinel,
"fleetOutcomes": fleetOutcomesSentinel, "fleetDerive": fleetDeriveSentinel,
"fleetIdentity": fleetIdentitySentinel, "fleetActive": fleetActiveSentinel,
"fleetSettled": fleetSettledSentinel, "fleetLive": fleetLiveSentinel,
"parallel": parallelSentinel, "parallelIdentity": parallelIdentity,
"task": taskSentinel, "sleep": sleepSentinel,
"ask": askSentinel, "retodo": retodoSentinel,
"childDone": childDone, "childHang": childHang, "childFail": childFail,
"childHold": childHold, "childRelease": childRelease, "childFailLate": childFailLate,
}
for outerName, outer := range sentinels {
for innerName, inner := range sentinels {
if outerName == innerName {
continue
}
if strings.Contains(outer, inner) {
t.Errorf("%s (%q) contains %s (%q): a prompt naming the first dispatches the second too",
outerName, outer, innerName, inner)
}
}
}
}
+694
View File
@@ -0,0 +1,694 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reasonix/internal/state/sessionstore"
"sort"
"strings"
"time"
"reasonix/internal/contract/provider"
"reasonix/internal/ext/skill"
"reasonix/internal/session/control"
)
// The skill-runner classification arms. They ask what kind of execution a
// runAs=subagent skill is, because the answer decides whether it belongs to the
// delegation class the task and fan-out arms established or is an execution
// class of its own. Nothing here is a fix: a classification that guessed would
// be answered by whichever plumbing happened to exist.
const (
// armSkillCompleted lets a skill finish and the turn close, then asks what
// each layer kept. It runs both entry points — the tool a model calls and
// the controller's own — because their lineage differs by design and a row
// that read one for the other would report intent as a defect.
armSkillCompleted = "skill-completed"
// armSkillRunning kills the process with the child mid-execution: the death
// every other delegation arm takes.
armSkillRunning = "skill-running"
// armSkillQueued fills the ceiling first. It is the arm that says whether a
// skill belongs to the same admission domain as everything else, which is a
// different question from whether it leaves the same records.
armSkillQueued = "skill-queued"
// armSkillCancel stops a running skill through the turn that admitted it, so
// the stop has the owner a person's Stop has.
armSkillCancel = "skill-cancel"
// The read-only entry point is a separate surface, not a lighter version of
// the one above: it prepares no transcript and refuses continuation, so
// asking it for durability would be demanding it break its own contract.
// These three ask what it is instead.
armReadOnlySkillCompleted = "readonly-skill-completed"
armReadOnlySkillRunning = "readonly-skill-running"
armReadOnlySkillQueued = "readonly-skill-queued"
)
func skillArm(name string) bool {
switch name {
case armSkillCompleted, armSkillRunning, armSkillQueued, armSkillCancel:
return true
}
return readOnlySkillArm(name)
}
func readOnlySkillArm(name string) bool {
switch name {
case armReadOnlySkillCompleted, armReadOnlySkillRunning, armReadOnlySkillQueued:
return true
}
return false
}
func queuedSkillArm(name string) bool {
return name == armSkillQueued || name == armReadOnlySkillQueued
}
// runningSkillArm names the arms that die or are stopped with the child inside
// the provider, which is the only state a durable record could be missing from.
func runningSkillArm(name string) bool {
return name == armSkillRunning || name == armSkillCancel || name == armReadOnlySkillRunning
}
const (
skillSentinel = "PROBE-SKILL"
// skillCallID is the tool call the model makes. It is the identity the
// store would join to, so the arm names it rather than letting the round
// pick one.
skillCallID = "probe_skill"
// probeSkillName is the skill on disk. Not a built-in: those carry their own
// read-only promises and review contracts, and the arm needs a plain writer
// so the scheduler sees a claim rather than a reader.
probeSkillName = "probe-worker"
// hostSkillTask is what the controller's own entry point is asked to do. It
// completes, because that arm compares two lineages rather than two deaths.
hostSkillTask = childDone + " host-initiated skill"
)
// writeProbeSkill puts a runAs=subagent skill in the workspace, where a project
// skill lives. It is a writer: a read-only one would clear the writer and claim
// checks by not being subject to them, and the arm could not say whether a skill
// is admitted the way every other writer is.
func writeProbeSkill(workspace string) error {
dir := filepath.Join(workspace, ".reasonix", skill.SkillsDirname, probeSkillName)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
body := "---\nname: " + probeSkillName + "\n" +
"description: The probe's own subagent, which answers whatever task it is handed.\n" +
"runAs: subagent\n---\n\nAnswer the task you are given and stop.\n"
return os.WriteFile(filepath.Join(dir, skill.SkillFile), []byte(body), 0o644)
}
// skillCall is the dispatch under measurement, made through the tool a model
// calls. The read-only arms enter through the other tool, which is a different
// runner rather than a flag on this one.
func skillCall(arm string) []provider.Chunk {
name := "run_skill"
if readOnlySkillArm(arm) {
name = "read_only_skill"
}
task := skillChildSentinel(arm) + " skill body"
args, _ := json.Marshal(map[string]any{"name": probeSkillName, "arguments": task})
return []provider.Chunk{{
Type: provider.ChunkToolCall,
ToolCall: &provider.ToolCall{ID: skillCallID, Name: name, Arguments: string(args)},
}}
}
// skillChildSentinel is how this arm's child behaves. The completed arms need one
// that finishes; every other arm needs one that holds until the process dies.
func skillChildSentinel(arm string) string {
if arm == armSkillCompleted || arm == armReadOnlySkillCompleted {
return childDone
}
return childHang
}
// skillSentinels is what this arm's one prompt carries. The queued arms name the
// holder as well: its ceiling has to be occupied before the skill asks for a
// slot, or the refusal they are built to record never happens.
func skillSentinels(arm string) string {
if queuedSkillArm(arm) {
return fleetHolderSentinel + " " + skillSentinel
}
return skillSentinel
}
// runSkillClassConstruct drives one skill and dies where this arm is about. The
// completed arms let the turn close and then run the controller's own entry
// point, so one death holds both lineages.
func runSkillClassConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, prov *scripted, turn int) error {
prompt := fanOutTurn(turn+1, skillSentinels(arm))
completed := arm == armSkillCompleted || arm == armReadOnlySkillCompleted
switch {
case completed:
if err := ctrl.Run(ctx, prompt); err != nil {
return fmt.Errorf("skill turn: %w", err)
}
if err := runHostInitiatedSkill(ctx, ctrl, arm); err != nil {
return err
}
case arm == armSkillCancel:
// A stop belongs to whoever admitted the turn, so the arm that is going
// to be stopped is admitted the way a person's input is.
ctrl.Send(prompt)
if err := waitForSkill(sink, prov, arm); err != nil {
return err
}
default:
go func() { _ = ctrl.Run(ctx, prompt) }()
if err := waitForSkill(sink, prov, arm); err != nil {
return err
}
}
stop := ""
if arm == armSkillCancel {
stop = stopSkill(ctrl, prov, arm)
}
obs := capture("construct", arm, bootSystem, ctrl, sink, root)
child := skillChildSentinel(arm)
obs.Progress[probeChildEntered] = []string{fmt.Sprint(prov.fleets.held(childEntered(child)))}
obs.Progress[probeSkillChildLeft] = []string{fmt.Sprint(prov.fleets.held(childLeft(child)))}
obs.Progress[probeSkillChildCtxDone] = []string{fmt.Sprint(prov.fleets.held(childCtxDone(child)))}
obs.Progress[probeSkillStanding] = []string{skillStanding(sink, prov)}
if stop != "" {
obs.Progress[probeStopReached] = []string{stop}
}
if err := writeObservation(root, obs); err != nil {
return err
}
os.Exit(0)
return nil
}
// runHostInitiatedSkill runs the same skill through the controller's own entry
// point, which is what a slash invocation reaches. Its lineage is deliberately
// different — the runner drops the call id for a host-initiated run — so the
// identity row has both to compare rather than one to judge.
func runHostInitiatedSkill(ctx context.Context, ctrl *control.Controller, arm string) error {
if _, err := ctrl.RunSubagentProfile(ctx, probeSkillName, hostSkillTask, readOnlySkillArm(arm)); err != nil {
return fmt.Errorf("host-initiated skill: %w", err)
}
return nil
}
// The records a skill's child leaves. They ride the observation because only the
// scripted provider knows them, and a store row read without them would be
// asking about work nothing proves ran.
const (
probeSkillChildLeft = "probe:skill-child-left"
probeSkillChildCtxDone = "probe:skill-child-ctxdone"
// probeSkillStanding is where the call itself stood: dispatched, answered,
// and whether the ceiling was occupied. A refusal has no event of its own,
// so this is the only positive evidence the admission row has.
probeSkillStanding = "probe:skill-standing"
)
// waitForSkill blocks until the skill stands where this arm dies. A refusal is a
// state nothing announces, so the queued arms establish it from both sides — the
// call went out and has not answered, the ceiling is occupied, and the skill's
// own child never arrived — then hold still and ask again, because a child that
// was merely slow to start would have reached the provider by then.
func waitForSkill(sink *graphSink, prov *scripted, arm string) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
if !queuedSkillArm(arm) {
if prov.fleets.held(childEntered(childHang)) {
return nil
}
} else if skillHeldBack(sink, prov) {
time.Sleep(2 * time.Second)
if !skillHeldBack(sink, prov) {
return errUnexpected("a skill the scheduler was still holding back",
"its child ran or its call answered")
}
return nil
}
time.Sleep(50 * time.Millisecond)
}
return errUnexpected("a skill child "+skillWanted(arm), skillStanding(sink, prov))
}
// skillHeldBack is the refusal expressed as facts rather than as an absence: the
// ceiling is occupied, the call is out, it has not answered, and no child of its
// own has reached the provider.
func skillHeldBack(sink *graphSink, prov *scripted) bool {
_, dispatched := sink.toolDispatched(skillCallID)
_, answered := sink.toolResult(skillCallID)
return prov.fleets.held(childEntered(childHold)) && dispatched && !answered &&
!prov.fleets.held(childEntered(childHang))
}
func skillStanding(sink *graphSink, prov *scripted) string {
_, dispatched := sink.toolDispatched(skillCallID)
_, answered := sink.toolResult(skillCallID)
return fmt.Sprintf("ceiling=%t dispatched=%t answered=%t child=%t",
prov.fleets.held(childEntered(childHold)), dispatched, answered,
prov.fleets.held(childEntered(childHang)))
}
func skillWanted(arm string) string {
if queuedSkillArm(arm) {
return "held back by a full ceiling"
}
return "inside the provider"
}
// stopSkill stops the work the way a person does — the turn's own cancel — and
// waits to see what that reaches. Nothing is demanded: whether a stop arrives is
// what the arm measures, and an arm that failed when it did not would report its
// own finding as a broken premise.
func stopSkill(ctrl *control.Controller, prov *scripted, arm string) string {
stopped := time.Now()
ctrl.Cancel()
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) && ctrl.Running() {
time.Sleep(50 * time.Millisecond)
}
live := "gone"
if prov.fleets.held(childEntered(childHang)) && !prov.fleets.held(childLeft(childHang)) {
live = "still running"
}
return fmt.Sprintf("turn=%s work=%s ctx=%t after=%s", turnStanding(ctrl, arm), live,
prov.fleets.held(childCtxDone(childHang)), time.Since(stopped).Round(time.Second))
}
// skillClassRows are the seven dimensions, one row each. They are separate
// because a skill can share one domain and not another — the whole reason this
// is a classification and not a comparison.
func skillClassRows(arm string, before, after Observation) []row {
return []row{
skillAdmissionRow(arm, before),
skillGraphRow(before),
skillJournalRow(before, after),
skillStoreRow(before, after),
skillCancelRow(arm, before),
skillRestartRow(arm, before, after),
skillIdentityRow(arm, before, after),
skillClassVerdictRow(arm, before, after),
}
}
const skillAuthority = "the scheduler, read through what the child did"
// skillAdmissionRow is the scheduler dimension. There is no refusal hook on a
// skill's acquire, so the refusal is read the only way it can be: the ceiling was
// proven occupied and the child never arrived, against the sibling arm where the
// ceiling was free and it did.
func skillAdmissionRow(arm string, before Observation) row {
entered := firstProgress(before, probeChildEntered) == "true"
answer := "admitted: the child reached the provider"
verdict := verdictHolds
switch {
case queuedSkillArm(arm) && !entered:
answer = "refused: the ceiling was full and the child never arrived"
case queuedSkillArm(arm):
answer = "not held back: the child ran with the ceiling full"
verdict = verdictViolated
case !entered:
answer = "the child never arrived with capacity free"
verdict = verdictNotMeasured
}
return row{
Semantic: "does it ask the session scheduler", Authority: skillAuthority,
Artifact: "none observed", Reconstruction: "in-process only",
Before: answer + " [" + firstProgress(before, probeSkillStanding) + "]",
After: "—", Verdict: verdict,
}
}
// skillGraphRow is the live picture. A skill that never draws one is invisible
// to every frontend while it runs, which is a product fact rather than a
// durability one and is why it is its own row.
func skillGraphRow(before Observation) row {
var drawn []string
for _, n := range before.Graph.Nodes {
if strings.HasPrefix(n.ID, skillCallID) {
drawn = append(drawn, n.ID+":"+string(n.State))
}
}
sort.Strings(drawn)
// The whole graph's size rides along: "the filter found nothing" and "there
// was nothing to find" are different readings, and only the second is what
// this row means to report.
return row{
Semantic: "does the live graph hold it", Authority: liveAuthority,
Artifact: "none observed", Reconstruction: "in-process only",
Before: fmt.Sprintf("%s (graph held %d node(s))", orNone(join(drawn)), len(before.Graph.Nodes)),
After: "—", Verdict: presenceVerdict(len(drawn) > 0),
}
}
// presenceVerdict reports what was found without calling absence a defect. What
// a skill owes each layer is the question; a row that answered it here would be
// asserting the classification it exists to establish.
func presenceVerdict(present bool) string {
if present {
return verdictHolds
}
return verdictAbsent
}
const verdictAbsent = "absent"
// skillJournalRow reports what the journal holds. It does not go through the
// ordinary value comparison: two empty columns are equal, and calling that
// persisted would report an execution nothing recorded as durably recorded —
// which is the substitution this whole probe exists to refuse.
func skillJournalRow(before, after Observation) row {
return presenceRow("does the journal record it", journalAuthority, "<stem>.execution.jsonl",
"ExecutionHistory over the whole session", skillExecutions(before), skillExecutions(after))
}
// presenceRow answers "is anything here" before it answers "is it the same".
// Absence on both sides is absence, never agreement.
func presenceRow(semantic, authority, artifact, reconstruction string, before, after []string) row {
r := row{Semantic: semantic, Authority: authority, Artifact: artifact,
Reconstruction: reconstruction, Before: orNone(join(before)), After: orNone(join(after))}
switch {
case len(before) == 0 && len(after) == 0:
r.Verdict = verdictAbsent
case len(after) == 0:
r.Verdict = verdictLost
case r.Before == r.After:
r.Verdict = verdictPersisted
default:
r.Verdict = verdictLossy
}
return r
}
// skillExecutions are the journal entries this arm's skill could be recorded
// under. The whole journal is read rather than a prefix: a skill records nothing
// today, so a filter written around an id it never wrote would report absence by
// construction.
func skillExecutions(o Observation) []string {
var out []string
for _, e := range o.Executions {
if strings.HasPrefix(e.ID, skillCallID) || strings.Contains(e.Name, probeSkillName) {
out = append(out, e.ID)
}
}
sort.Strings(out)
return out
}
// skillStoreRow is the durable half. The two entry points differ here by design:
// one prepares a transcript, the other refuses to.
func skillStoreRow(before, after Observation) row {
return presenceRow("what the store holds for it", storeAuthority, "subagents/<ref>.meta.json",
"ListSubagentsByParent over the transcript stem",
skillChildren(before), skillChildren(after))
}
// skillChildren are the store's records of skill children, by the kind the
// runner declared rather than by a name match: kind is what the store was told
// this execution is.
func skillChildren(o Observation) []string {
var out []string
for _, f := range o.Children.Facts {
if f.Kind == "skill" {
out = append(out, f.Name+"/"+orDash(f.ParentToolCallID)+"="+f.Status)
}
}
sort.Strings(out)
return out
}
// skillCancelRow is what a stop reached. Three facts, kept apart: a context that
// closed and a call that returned are different events, and work whose caller
// stopped waiting leaves the same absence behind as work that was cancelled.
func skillCancelRow(arm string, before Observation) row {
if arm != armSkillCancel {
return row{
Semantic: "what a stop reaches", Authority: "the turn's own cancel",
Artifact: "none observed", Reconstruction: "in-process only",
Before: "not stopped in this arm", After: "—", Verdict: verdictNotMeasured,
}
}
return row{
Semantic: "what a stop reaches", Authority: "the turn's own cancel",
Artifact: "subagents/<ref>.meta.json", Reconstruction: "read at one instant, after the record closed",
Before: fmt.Sprintf("%s store=%s", firstProgress(before, probeStopReached),
orNone(join(skillChildren(before)))),
After: "—", Verdict: skillCancelVerdict(before),
}
}
// skillCancelVerdict asks whether the terminal the store kept is the one that
// happened. The cause is observed, not read off a message: the child's own
// context closed, which is what a cancellation does and what a failure does not,
// so a record that files it as failed is naming the wrong ending.
func skillCancelVerdict(before Observation) string {
cancelled := firstProgress(before, probeSkillChildCtxDone) == "true"
terminal := ""
for _, f := range before.Children.Facts {
if f.Kind == "skill" {
terminal = f.Status
}
}
switch {
case terminal == "":
return verdictNotMeasured
case cancelled && terminal != string(sessionstore.SubagentCancelled):
return verdictViolated
default:
return verdictHolds
}
}
// skillRestartRow is what the next process can say. It is separate from the
// store row because "a record survives" and "the execution is explicable" are
// different claims: a completed child on disk explains nothing about a run that
// was cut.
func skillRestartRow(arm string, before, after Observation) row {
stored, rebuilt, cut := skillChildren(after), skillRebuiltNodes(after), skillInterruptions(after)
answer := fmt.Sprintf("store=%s rebuilt=%s cut=%s",
orNone(join(stored)), orNone(join(rebuilt)), orNone(join(cut)))
return row{
Semantic: "what a restart can say about it", Authority: rebuildAuthority,
Artifact: "<stem>.execution.jsonl + subagents/<ref>.meta.json",
Reconstruction: "execgraph.Rebuild over the journal, and the store beside it",
Before: skillAtDeath(arm, before), After: answer,
Verdict: skillRestartVerdict(stored, rebuilt, cut),
}
}
// skillInterruptions are the interruptions that name this skill. The whole list
// is wider than the arm: a queued arm fills its ceiling with a fan-out, and a
// fan-out records everything — reading its interruption as the skill's is how an
// arm reports a gap as closed by evidence belonging to something else.
func skillInterruptions(o Observation) []string {
var out []string
for _, e := range o.InterruptedExecutions {
if strings.HasPrefix(e.ID, skillCallID) || strings.Contains(e.Name, probeSkillName) {
out = append(out, e.ID)
}
}
sort.Strings(out)
return out
}
// skillRestartVerdict keeps two claims apart. That the store remembers a child
// finished is not that a restart can reconstruct the delegated execution: the
// first is a record, the second is provenance, and an arm that folded them would
// report a run nothing can place as recovered.
func skillRestartVerdict(stored, rebuilt, cut []string) string {
switch {
case len(rebuilt) > 0 || len(cut) > 0:
return verdictHolds
case len(stored) > 0:
return verdictRecordOnly
default:
return verdictAbsent
}
}
// verdictRecordOnly is a child the store kept for an execution no reconstruction
// names.
const verdictRecordOnly = "record-only"
// skillRebuiltNodes are the nodes a restart's rebuild draws for this skill.
func skillRebuiltNodes(o Observation) []string {
var out []string
for _, n := range rebuiltGraph(o).Graph.Nodes {
if strings.HasPrefix(n.ID, skillCallID) || strings.Contains(n.Label, probeSkillName) {
out = append(out, n.ID+":"+string(n.State))
}
}
sort.Strings(out)
return out
}
func skillAtDeath(arm string, before Observation) string {
switch {
case queuedSkillArm(arm):
return "held back, never started"
case firstProgress(before, probeChildEntered) != "true":
return "never reached the provider"
case skillFinished(before):
return "ran and returned"
default:
return "executing inside the provider"
}
}
// skillIdentityRow is the seventh dimension, and it reads the two entry points
// apart. A model-invoked skill has a call to join to; the controller's own entry
// point drops that id on purpose, so an empty one there is top-level provenance
// rather than a lost join — reading them together is what stops a correct value
// being reported as a defect.
func skillIdentityRow(arm string, before, after Observation) row {
var got []string
for _, f := range after.Children.Facts {
if f.Kind != "skill" {
continue
}
got = append(got, fmt.Sprintf("%s from=%s model=%s effort=%s",
f.Name, orDash(f.ParentToolCallID), orDash(f.Model), orDash(f.Effort)))
}
sort.Strings(got)
return row{
Semantic: "what identity the record carries", Authority: storeAuthority,
Artifact: "subagents/<ref>.meta.json",
Reconstruction: "the call the model made, against the entry point that made it",
Before: skillLineageExpected(arm), After: orNone(join(got)),
Verdict: skillIdentityVerdict(after),
}
}
func skillLineageExpected(arm string) string {
if arm == armSkillCompleted || arm == armReadOnlySkillCompleted {
return "model-invoked joins the execution under " + skillCallID + "; host-initiated names none"
}
return "model-invoked joins the execution opened under " + skillCallID
}
// skillIdentityVerdict holds when the model-invoked record joins the execution
// the journal opened under the call the model made. The join is checked, never
// one spelling of it: a record's parent is the execution's own identity, so
// comparing it to the bare call id would fail the chain that makes them
// joinable. A host-started run has no call to hang under, so it is not held.
func skillIdentityVerdict(after Observation) string {
opened := map[string]bool{}
for _, id := range skillExecutions(after) {
opened[id] = true
}
joined, records := false, 0
for _, f := range after.Children.Facts {
if f.Kind != "skill" {
continue
}
records++
if opened[f.ParentToolCallID] && strings.HasPrefix(f.ParentToolCallID, skillCallID) {
joined = true
}
}
switch {
case records == 0:
return verdictNotMeasured
case joined:
return verdictHolds
default:
return verdictViolated
}
}
// skillClassVerdictRow is the arm's answer in one line, and it is a conjunction
// rather than a comparison: an execution the scheduler admitted, whose child
// reached the provider, that no live picture, no journal and no active store
// record names, is lost while it is happening — not merely unrecorded.
func skillClassVerdictRow(arm string, before, after Observation) row {
entered := firstProgress(before, probeChildEntered) == "true"
graph := len(skillGraphNodes(before)) > 0
journal := len(skillExecutions(before)) > 0
stored := len(skillChildren(before)) > 0
answer, verdict := "not this arm's question", verdictNotMeasured
switch {
case queuedSkillArm(arm):
answer, verdict = skillQueuedNegative(before)
case readOnlySkillArm(arm) && runningSkillArm(arm) && entered:
answer, verdict = readOnlyClass(graph, journal, stored)
case runningSkillArm(arm) && entered:
if graph || journal || stored {
answer, verdict = fmt.Sprintf("named by graph=%t journal=%t store=%t", graph, journal, stored), verdictHolds
} else {
answer, verdict = "LOST-SILENT: executing, and named by no layer", verdictViolated
}
case runningSkillArm(arm):
answer = "the child never reached the provider"
}
return row{
Semantic: "an execution in flight, named by nothing", Authority: crossAuthority,
Artifact: "none, which is the finding",
Reconstruction: "scheduler admitted + child entered + no graph, journal or store record",
Before: answer, After: "—", Verdict: verdict,
}
}
// readOnlyClass classifies the entry point that promised no durable side
// effects. Absence there is its contract kept, not a gap — but it still holds a
// scheduler slot, so whether anything draws it while it does is a separate
// answer, and a durable record would be the contract broken the other way.
func readOnlyClass(graph, journal, stored bool) (string, string) {
switch {
case journal || stored:
return "an entry point that promised no durable side effects left one", verdictViolated
case graph:
return "ephemeral by contract, and drawn while it holds a slot", verdictEphemeralSeen
default:
return "ephemeral by contract, and unseen while it holds a slot", verdictEphemeralUnseen
}
}
// The two readings of an execution that keeps nothing. Neither is a defect: what
// separates them is whether anyone can see the slot being held.
const (
verdictEphemeralSeen = "ephemeral-seen"
verdictEphemeralUnseen = "ephemeral-unseen"
)
// skillQueuedNegative is the control the running row needs, and it is about the
// store alone. An opening is the orchestration saying work entered it, which a
// refused item did; a store record is a child execution artifact, which it has
// none of. Demanding silence from the journal too would forbid the very record
// that tells a restart the item was held back rather than lost.
func skillQueuedNegative(before Observation) (string, string) {
if len(skillChildren(before)) > 0 {
return "a child record for work the scheduler never admitted", verdictViolated
}
return "held back: opened and queued, with no child execution artifact", verdictHolds
}
func skillGraphNodes(o Observation) []string {
var out []string
for _, n := range o.Graph.Nodes {
if strings.HasPrefix(n.ID, skillCallID) {
out = append(out, n.ID)
}
}
return out
}
// skillArmInvalid reports the premise this arm could not establish. A skill that
// never reached the provider measures nothing about executions in flight, and a
// ceiling that was not occupied measures nothing about admission.
func skillArmInvalid(arm string, before Observation) string {
entered := firstProgress(before, probeChildEntered) == "true"
switch {
case queuedSkillArm(arm) && entered:
return "the skill ran while the ceiling was meant to be full"
case runningSkillArm(arm) && !entered:
return "the skill's child never reached the provider, so nothing was executing at the death"
case (arm == armSkillCompleted || arm == armReadOnlySkillCompleted) && !skillFinished(before):
return "no skill child finished, so the arm compares nothing"
}
return ""
}
func skillFinished(before Observation) bool {
return firstProgress(before, probeSkillChildLeft) == "true"
}
+69
View File
@@ -0,0 +1,69 @@
package main
import "fmt"
// successorRows ask what happens to a recorded interruption once work carries
// on. The derivation has no way out of "interrupted" today, so these rows are
// there to say what that costs rather than to grade it.
func successorRows(arm string, successor, final Observation) []row {
return []row{{
Semantic: "the interruption after work continued", Authority: "the adjudication log",
Artifact: "<stem>.adjudication.jsonl", Reconstruction: "still derived from an open record with no owner",
Before: interruptionState(successor), After: interruptionState(final), Verdict: stickiness(final),
}, {
Semantic: "what that turn's request carried", Authority: "control, projected per request",
Artifact: "none: derived, never stored", Reconstruction: "the interruption block, if one still stands",
Before: fmt.Sprintf("%d block(s)", successor.ModelSeesInterruption),
After: fmt.Sprintf("%d block(s)", final.ModelSeesInterruption), Verdict: verdictStable,
}, {
Semantic: "how the barrier ended", Authority: "the adjudication log",
Artifact: "<stem>.adjudication.jsonl", Reconstruction: "an edge is written only for a barrier a turn received",
Before: journalSummary(successor), After: journalSummary(final), Verdict: verdictStable,
}, {
Semantic: "the effect the dead decision held back", Authority: "the workspace",
Artifact: final.Deferred.MarkerPath, Reconstruction: "no successor turn may release it",
Before: ranOrNot(successor), After: ranOrNot(final), Verdict: heldBack(successor, final),
}}
}
func interruptionState(o Observation) string {
if len(o.Interrupted) == 0 {
return "settled"
}
return fmt.Sprintf("%d still interrupted", len(o.Interrupted))
}
// stickiness is the question these arms were built to ask: does an
// interruption outlive the work that replaced it? A successor that took it over
// leaves nothing behind for later requests to carry.
func stickiness(final Observation) string {
if len(final.Interrupted) == 0 && final.ModelSeesInterruption == 0 {
return "inherited"
}
if len(final.Interrupted) == 0 {
return "sticky context without an owner"
}
return "sticky"
}
func journalSummary(o Observation) string {
var out []string
for _, e := range o.Journal {
switch {
case e.Open():
out = append(out, e.ID+":open")
case e.SupersededBy != "":
out = append(out, e.ID+":"+e.Disposition+" by turn "+e.SupersededBy)
default:
out = append(out, e.ID+":"+e.Disposition)
}
}
return orNone(join(out))
}
func heldBack(successor, final Observation) string {
if successor.Deferred.Executed || final.Deferred.Executed {
return verdictViolated
}
return verdictHolds
}
+533
View File
@@ -0,0 +1,533 @@
package main
import (
"context"
"fmt"
"os"
"sort"
"strings"
"time"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/session/control"
)
// The terminal-disposition arms. SETTLED says an opening is no longer active
// and nothing more, so these ask what that costs: two items can share every
// durable fact and mean opposite things, and a restart that cannot tell them
// apart has lost the answer a dependent needs, not a label a picture wanted.
const (
// armTerminalAdopted reuses an earlier child's answer. It never runs and
// still holds a final answer, which is the one terminal that is answered
// without having executed.
armTerminalAdopted = "terminal-adopted"
// armTerminalSkippedDep is the same lifecycle with the opposite meaning: an
// item whose dependency failed, which never ran and holds no answer.
armTerminalSkippedDep = "terminal-skipped-dep"
// armTerminalCancelled is the other way an item ends without an answer. It
// was built to reach parallel_tasks' skipped branch and found it
// unreachable: running[i] is set when an item is dispatched, not when it is
// admitted, so a cancellation marks every unfinished item cancelled.
armTerminalCancelled = "terminal-cancelled"
// armTerminalContext asks whether a context edge has to be recorded at all,
// or already follows from the ordering and what the upstream answered.
armTerminalContext = "terminal-context"
// armChildTerminal compares the graph's terminal for an executed item
// against what the sub-agent store recorded for the same child. The store
// is the owner of "what happened to this child", so a distinction the graph
// draws and the store does not keep is one no reconstruction can recover.
armChildTerminal = "child-terminal"
// armIdentitySemantics asks what a node's model and effort mean. Two
// producers resolve them through different chains and the store resolves
// them again, so before a rebuild can be held to them, one of those has to
// be the fact the field carries.
armIdentitySemantics = "identity-semantics"
)
func terminalArm(name string) bool {
switch name {
case armTerminalAdopted, armTerminalSkippedDep, armTerminalCancelled, armTerminalContext, armChildTerminal, armIdentitySemantics:
return true
}
return false
}
// terminalSentinels is what this arm's one prompt carries. The adopted arm
// needs a completed reference first, so it names the warm-up fleet too.
func terminalSentinels(arm string) string {
switch arm {
case armTerminalAdopted:
return fleetWarmSentinel + " " + fleetMixedSentinel
case armTerminalCancelled:
return parallelSentinel
case armChildTerminal:
return fleetOutcomesSentinel
case armIdentitySemantics:
return fleetWarmSentinel + " " + fleetIdentitySentinel + " " + parallelIdentity
}
return fleetTerminalSentinel
}
// runTerminalConstruct lets the fan-out settle, then dies before the turn ends.
// The settling is the point: a fan-out publishes an item's skip only in its
// closing delta, so an arm that died mid-flight would never see one.
func runTerminalConstruct(ctx context.Context, root armRoot, arm, bootSystem string, ctrl *control.Controller, sink *graphSink, turn int) error {
runCtx, cancelRun := context.WithCancel(ctx)
defer cancelRun()
go func() { _ = ctrl.Run(runCtx, fanOutTurn(turn+1, terminalSentinels(arm))) }()
if arm == armTerminalCancelled {
if err := cancelBeforeStart(sink, cancelRun); err != nil {
return err
}
}
if arm == armChildTerminal {
if err := cancelAfterOutcomes(sink, cancelRun); err != nil {
return err
}
}
if err := waitForTerminal(sink, arm); err != nil {
return err
}
if err := writeObservation(root, capture("construct", arm, bootSystem, ctrl, sink, root)); err != nil {
return err
}
os.Exit(0)
return nil
}
// cancelBeforeStart interrupts the group once one item is running and another
// is still waiting for a slot, which is the state parallel_tasks splits on. It
// cancels the turn's own context rather than calling Controller.Cancel: the
// synchronous headless Run does not pass through turn admission, so the gate
// that Cancel operates has nothing registered for it.
func cancelBeforeStart(sink *graphSink, cancelRun context.CancelFunc) error {
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
graph, _ := sink.snapshot()
if runningCount(graph) > 0 && len(queuedWorkers(graph)) > 0 {
cancelRun()
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected("one item running and one still queued", waitStates(graph))
}
// cancelAfterOutcomes interrupts once one item has completed and another has
// failed, with a third still running. All three terminals then come from one
// group, so the store's record of them is compared under identical conditions.
func cancelAfterOutcomes(sink *graphSink, cancelRun context.CancelFunc) error {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
graph, _ := sink.snapshot()
if len(nodesInState(graph, agentgraph.StateCompleted)) > 0 &&
len(nodesInState(graph, agentgraph.StateFailed)) > 0 &&
runningCount(graph) > 0 {
cancelRun()
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected("one completed, one failed and one still running", waitStates(graph))
}
func runningCount(g agentgraph.Graph) int {
n := 0
for _, node := range g.Nodes {
if node.Kind != agentgraph.KindGroup && node.State == agentgraph.StateRunning {
n++
}
}
return n
}
// wantedTerminal is the disposition this arm exists to produce.
func wantedTerminal(arm string) agentgraph.NodeState {
if arm == armTerminalAdopted {
return agentgraph.StateAdopted
}
if arm == armTerminalContext {
return agentgraph.StateCompleted
}
if arm == armTerminalCancelled || arm == armChildTerminal {
return agentgraph.StateCancelled
}
if arm == armIdentitySemantics {
return agentgraph.StateAdopted
}
return agentgraph.StateSkipped
}
// waitForTerminal blocks until the graph shows this arm's disposition. The
// identity arm needs more than a state: it compares three producers, so it
// waits until the last of them has settled something rather than the first.
func waitForTerminal(sink *graphSink, arm string) error {
want := wantedTerminal(arm)
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
graph, _ := sink.snapshot()
if len(nodesInState(graph, want)) > 0 && identityReady(graph, arm) {
return nil
}
time.Sleep(50 * time.Millisecond)
}
graph, _ := sink.snapshot()
return errUnexpected("an item in state "+string(want), waitStates(graph))
}
// identityReady reports whether every producer the identity arm compares has
// published a settled item. Waiting on one of them leaves the others unread.
func identityReady(g agentgraph.Graph, arm string) bool {
if arm != armIdentitySemantics {
return true
}
settled := map[string]int{}
for _, n := range g.Nodes {
if n.Kind == agentgraph.KindGroup || n.State == "" || !n.State.Terminal() {
continue
}
settled[n.ParentID]++
}
return settled["probe_fleet_identity"] >= 4 && settled["probe_parallel_identity"] >= 2
}
func nodesInState(g agentgraph.Graph, want agentgraph.NodeState) []string {
var out []string
for _, n := range g.Nodes {
if n.Kind != agentgraph.KindGroup && n.State == want {
out = append(out, n.ID)
}
}
sort.Strings(out)
return out
}
// terminalRows put the two lifecycles side by side. Adopted and skipped reach
// the same durable shape by different routes and mean opposite things to a
// dependent, so every row here compares what the graph knew against what the
// journal can still be asked.
func terminalRows(arm string, before, after Observation) []row {
rows := []row{
terminalStateRow(arm, before, after),
terminalLifecycleRow(before, after),
terminalAnswerRow(before, after),
}
if arm == armTerminalAdopted {
rows = append(rows, adoptSourceRow(before, after))
}
if arm == armTerminalContext || arm == armTerminalSkippedDep {
rows = append(rows, contextDerivabilityRow(before, after))
}
if arm == armChildTerminal {
rows = append(rows, childOutcomeRow(before, after))
}
if arm == armIdentitySemantics {
rows = append(rows, identitySemanticsRow(before), identityRebuildRow(before, after))
}
return rows
}
// terminalStateRow is the arm's own control and the loss in one line: which
// disposition the graph ended on, and whether anything still says so.
func terminalStateRow(arm string, before, after Observation) row {
want := wantedTerminal(arm)
verdict := verdictNotMeasured
if len(nodesInState(graphOf(before), want)) > 0 {
verdict = verdictHolds
}
return row{
Semantic: "the disposition this arm reached", Authority: "the fan-out's closing delta",
Artifact: "none observed", Reconstruction: "published once, when the group ends",
Before: terminalSummary(before), After: terminalSummary(after), Verdict: verdict,
}
}
// terminalLifecycleRow is the comparison that decides whether a disposition has
// to be recorded. Two items with the same durable lifecycle and opposite
// meanings cannot be told apart by anything a restart reads.
func terminalLifecycleRow(before, after Observation) row {
return valueRow("the durable lifecycle each item left", "the execution journal",
"<stem>.execution.jsonl", "opened / queued / started / settled, per item",
lifecycleSummary(before), lifecycleSummary(after), true)
}
// terminalAnswerRow is what a dependent actually needs. Completed and adopted
// carry a final answer a dependent may start from; skipped and cancelled do
// not, and the difference is the whole reason the distinction exists.
func terminalAnswerRow(before, after Observation) row {
return valueRow("items holding an answer a dependent may use", "agentgraph.NodeState.Answered",
"none observed", "derived from the disposition, which is not recorded",
answeredSummary(before), answeredSummary(after), false)
}
// adoptSourceRow is the provenance an adoption carries: whose answer stood in.
// Reuse is only visible if the source is nameable — otherwise the picture shows
// work that cost nothing with no way to say what paid for it. The comparison is
// the graph's edge against the journal's opening, so a record that named a
// different source would read as a loss rather than as agreement.
func adoptSourceRow(before, after Observation) row {
return valueRow("whose answer an adoption reused", "the adopt edge, recorded with the opening",
"<stem>.execution.jsonl", "read back with the openings, never re-derived",
adoptEdges(before), journalAdoptions(after), true)
}
// journalAdoptions renders the openings' recorded sources the way the graph
// renders its edges, so the two sides of the row are the same measurement.
func journalAdoptions(o Observation) string {
var out []string
for _, e := range o.Executions {
if e.AdoptedFrom != "" {
out = append(out, e.ID+"<-"+e.AdoptedFrom)
}
}
sort.Strings(out)
return join(out)
}
// contextDerivabilityRow asks whether the delivery edge follows from facts that
// are already durable. A context edge that always accompanies an ordering edge
// whose upstream answered is derivable; one that does not is a fact of its own.
func contextDerivabilityRow(before, after Observation) row {
implied, actual := impliedContext(before), contextEdges(before)
verdict := verdictHolds
if implied != actual {
verdict = verdictViolated
}
return row{
Semantic: "whether a context edge follows from the durable facts",
Authority: "the ordering edges and what each upstream answered",
Artifact: "<stem>.execution.jsonl for the ordering; the disposition is not recorded",
Reconstruction: "an ordering edge whose upstream answered delivered its answer",
Before: "implied " + orNone(implied) + " / actual " + orNone(actual),
After: "implied " + orNone(impliedContext(after)) + " / actual: not observable",
Verdict: verdict,
}
}
// executedTerminals are the graph's terminals for items that actually ran.
// Adopted and skipped are excluded: nothing executed under them, so the store
// owes them no record and counting them would read a correct store as short.
func executedTerminals(o Observation) map[string]int {
out := map[string]int{}
for _, n := range o.Graph.Nodes {
if n.Kind == agentgraph.KindGroup || n.State == "" || !n.State.Terminal() {
continue
}
if n.State == agentgraph.StateAdopted || n.State == agentgraph.StateSkipped {
continue
}
out[string(n.State)]++
}
return out
}
// childOutcomeRow compares the two owners of the same fact. The graph draws
// completed, failed and cancelled apart; the store is what a restart reads. A
// terminal the graph distinguishes and the store folds into another is gone for
// good — no journal record could recover it, because the owner never kept it.
func childOutcomeRow(before, after Observation) row {
graphTerminals := executedTerminals(before)
verdict := verdictNotMeasured
if len(graphTerminals) > 0 {
verdict = verdictHolds
for state := range graphTerminals {
if !storeKnows(after, state) {
verdict = verdictViolated
}
}
}
return row{
Semantic: "terminal outcome: what the graph drew, what the store kept",
Authority: "the graph's closing delta against SubagentStore.Meta.Status",
Artifact: "subagents/<ref>.meta.json",
Reconstruction: "the store owns what happened to a child; every terminal it draws it must keep",
Before: "graph " + countSummary(graphTerminals) + " / store " + orNone(childStatusCounts(before)),
After: "store " + orNone(childStatusCounts(after)), Verdict: verdict,
}
}
// storeKnows reports whether any child carries this terminal as its status.
func storeKnows(o Observation, state string) bool {
for _, f := range o.Children.Facts {
if f.Status == state {
return true
}
}
return false
}
func childStatusCounts(o Observation) string {
counts := map[string]int{}
for _, f := range o.Children.Facts {
counts[f.Status]++
}
if len(counts) == 0 {
return ""
}
return countSummary(counts)
}
// identitySemanticsRow lays the three answers beside each other: what the
// caller named, what the graph shows, and what the store resolved. They are not
// expected to agree — the point is which of them a node's field is reporting,
// because a rebuild cannot be held to a field whose meaning is unsettled.
func identitySemanticsRow(before Observation) row {
store := map[string]string{}
for _, f := range before.Children.Facts {
store[f.ParentToolCallID] = orNone(f.Model) + "/" + orNone(f.Effort)
}
var out []string
for _, n := range before.Graph.Nodes {
if n.Kind != agentgraph.KindWorker {
continue
}
out = append(out, fmt.Sprintf("%s graph=%s/%s store=%s", n.ID,
orNone(n.Model), orNone(n.Effort), orNone(store[n.ID])))
}
sort.Strings(out)
return row{
Semantic: "worker identity: what each layer reports", Authority: "the fan-out's spec against the store's resolution",
Artifact: "subagents/<ref>.meta.json", Reconstruction: "the graph resolves a subagent chain; the store resolves the final identity",
Before: join(out), After: "not applicable: read before the boundary", Verdict: verdictStable,
}
}
// identityRebuildRow is the same question asked of the rebuild: whichever of
// those layers the field is meant to carry, does a restart get it back?
func identityRebuildRow(before, after Observation) row {
return valueRow("worker identity after a restart", "execgraph.Rebuild over journal + store",
"<stem>.execution.jsonl + subagents/<ref>.meta.json",
"the journal records no worker identity; the store records the one it resolved",
identitySummary(graphOf(before)), identitySummary(rebuiltGraph(after).Graph), false)
}
func graphOf(o Observation) agentgraph.Graph {
return agentgraph.Graph{Nodes: o.Graph.Nodes, Edges: o.Graph.Edges}
}
func terminalSummary(o Observation) string {
if len(o.Graph.Nodes) == 0 {
return ""
}
counts := map[string]int{}
for _, n := range o.Graph.Nodes {
if n.Kind != agentgraph.KindGroup && n.State != "" {
counts[string(n.State)]++
}
}
return countSummary(counts)
}
// lifecycleSummary renders each item's durable transitions, which is what a
// restart actually has. Two items printing the same line here are two items a
// restart cannot tell apart.
func lifecycleSummary(o Observation) string {
if len(o.Executions) == 0 {
return ""
}
out := make([]string, 0, len(o.Executions))
for _, e := range o.Executions {
marks := []string{"open"}
if e.Disposition != "" && e.Disposition != "pending" {
marks = append(marks, e.Disposition)
}
if e.Queued() {
marks = append(marks, "queued("+e.Cause+")")
}
if e.Started() {
marks = append(marks, "started")
}
if !e.SettledAt.IsZero() {
marks = append(marks, "settled")
}
out = append(out, e.ID+":"+strings.Join(marks, "+"))
}
sort.Strings(out)
return join(out)
}
func answeredSummary(o Observation) string {
var out []string
for _, n := range o.Graph.Nodes {
if n.Kind != agentgraph.KindGroup && n.State.Answered() {
out = append(out, n.ID)
}
}
sort.Strings(out)
return join(out)
}
func adoptEdges(o Observation) string {
var out []string
for _, e := range o.Graph.Edges {
if e.Kind == agentgraph.Adopt {
out = append(out, e.To+"<-"+e.From)
}
}
sort.Strings(out)
return join(out)
}
func contextEdges(o Observation) string {
var out []string
for _, e := range o.Graph.Edges {
if e.Kind == agentgraph.Context {
out = append(out, e.To+"<-"+e.From)
}
}
sort.Strings(out)
return join(out)
}
// impliedContext is what the durable facts alone would predict: every ordering
// edge whose upstream ended up answered.
func impliedContext(o Observation) string {
answered := map[string]bool{}
for _, n := range o.Graph.Nodes {
if n.State.Answered() {
answered[n.ID] = true
}
}
var out []string
for _, e := range o.Executions {
for _, up := range e.DependsOn {
if answered[up] {
out = append(out, e.ID+"<-"+up)
}
}
}
sort.Strings(out)
return join(out)
}
func countSummary(counts map[string]int) string {
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
}
return strings.Join(parts, " ")
}
// terminalArmInvalid refuses an arm that never reached its disposition. Every
// row below it compares against a shape that was not established, and reporting
// those as evidence is how a probe answers a question nobody asked.
func terminalArmInvalid(arm string, before Observation) string {
want := wantedTerminal(arm)
if len(nodesInState(graphOf(before), want)) == 0 {
return "no item reached " + string(want) + "; the fan-out ended as " + orNone(terminalSummary(before))
}
if arm == armTerminalContext && contextEdges(before) == "" {
return "the fan-out delivered no upstream answer, so there is no context edge to derive"
}
if arm == armTerminalCancelled && len(nodesInState(graphOf(before), agentgraph.StateSkipped)) > 0 {
return "an item was skipped rather than cancelled; parallel_tasks' skipped branch was thought unreachable"
}
return ""
}
Loaded 100 of 5771 files, more files were not shown because too many files have changed in this diff. Show more