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
5547 changed files with 383411 additions and 335493 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
+32 -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
@@ -82,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
@@ -93,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 ./...
@@ -157,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
@@ -275,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
+2 -7
View File
@@ -17,17 +17,14 @@
/reasonix-plugin-example
/reasonix-studio-host
/signpath-contract
/studio-delta
/covergate
/pricecheck
/repolint
/workgroupassay
/write_heartbeat
/catalog-detector
# Website (Astro) build output
/site/dist/
/site/.astro/
/site/_preview.png
/tool-surface-cache
# Go test/build scratch
*.test
@@ -79,7 +76,6 @@ desktop/**/.reasonix/
Thumbs.db
.idea/
.vscode/
/site/public/av/
benchmarks/context-maintenance-e2e/run/
@@ -89,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
-385
View File
@@ -1,385 +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
- **Remote SSH: model credentials stay on your machine.** A remote session now
resolves its providers over a reverse (`-R`) forward back to a broker Reasonix
runs on this machine's loopback, so the remote host needs no API key of its
own and no egress to the model API. The key is never written to the remote
disk — only a per-connection broker token, 0600. The provider runs here, so
every endpoint-dependent judgement (DeepSeek's beta prefix endpoint, Kimi's K3
contract, Gemini model resource names, MiniMax's reasoning split) reads a real
vendor URL, and typed provider errors keep their identity across the tunnel:
a remote turn rejected for context overflow still classifies as one and still
compacts. Per-host `provider = "local" | "remote"` picks which side answers,
defaulting to `local`; `reasonix remote serve start` never uses a broker,
since that serve outlives the command that published it.
- **Remote SSH: the host fetches its own kernel.** `serve_install = "auto"` now
tries, in order: the remote downloading the release over its own connection
(Reasonix resolves `SHA256SUMS` here and hands over the URL and digest, so the
host is not verifying two things the same network gave it), uploading this
machine's binary, downloading here and pushing over SFTP, and finally npm.
npm was previously first and required Node ≥18 on the far side to deliver the
identical static Go binary. A first connect to a Linux/macOS host with only
sshd now transfers roughly 21MB over that host's own link instead of pushing
62MB over the user's uplink. `reasonix remote test` reports which routes are
open and names what closes each one that is not.
- 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
+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和开场视频。
+60 -32
View File
@@ -9,25 +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.
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
@@ -36,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
@@ -48,7 +51,7 @@ 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
@@ -134,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
@@ -143,13 +167,13 @@ 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
@@ -175,7 +199,7 @@ prefix was never where these belonged wrongly; freshness ownership was missing.
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/boot/context_projection_test.go` holds all five at the provider
`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
@@ -210,11 +234,11 @@ 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/billing/rates.go`, oldest generation first, the last being what a
`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 `billing.CurrentRate` and
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.
@@ -227,7 +251,7 @@ 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/config/preset_upgrade.go`: one declared
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.
@@ -253,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`;
@@ -305,18 +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/control/approval_orchestration.go
- sensitive: internal/providerbroker/**
- 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
@@ -352,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
```
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"sort"
"strings"
"reasonix/internal/skill"
"reasonix/internal/ext/skill"
)
// detector answers one question: has the thing the model would be sent changed?
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"path/filepath"
"strings"
"reasonix/internal/skill"
"reasonix/internal/ext/skill"
)
type verdict struct {
+21 -41
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)
@@ -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})
+11 -10
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
@@ -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.
+3 -3
View File
@@ -12,7 +12,7 @@ phase here is a child process that exits before the next one starts.
```
process A (construct) process B (resume)
boot a real runtime boot a real runtime
drive turns (scripted) agent.LoadSession(path)
drive turns (scripted) sessionstore.LoadSession(path)
set a durable Goal ctrl.Resume(session, path)
fold the context read host state
record what it sees record what it sees
@@ -137,7 +137,7 @@ it, and its parent call stays empty. `SubagentMeta` carries both answers, and
this is the first place they differ — until a host-started run existed, every
durable execution came from a tool call and one field could hold both.
`TestDelegationClassesStayApart` in `internal/boot` holds the classification, and
`TestDelegationClassesStayApart` in `internal/assembly/boot` holds the classification, and
holds it where a completed run cannot: what the store answers at the one instant
a child is inside the provider. A check made afterwards cannot tell a recorded
execution from an unrecorded one that happened to finish, because completion
@@ -1105,7 +1105,7 @@ released.
## Rebuilding the graph
`internal/execgraph` folds the journal, the store's child terminals, and this
`internal/state/execgraph` folds the journal, the store's child terminals, and this
process's live executions into a graph. It recomputes rather than restores:
nothing reads a file or emits an event, and the same inputs always give the
same picture.
+3 -3
View File
@@ -8,9 +8,9 @@ import (
"strings"
"time"
"reasonix/internal/agentgraph"
"reasonix/internal/control"
"reasonix/internal/execjournal"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/session/control"
"reasonix/internal/state/execjournal"
)
// armFleetActiveStore asks whether the sub-agent store can be asked about a
+6 -6
View File
@@ -5,12 +5,12 @@ import (
"os"
"os/exec"
"path/filepath"
"reasonix/internal/state/sessionstore"
"strings"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/memory"
"reasonix/internal/provider"
"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
@@ -176,7 +176,7 @@ func swapSystemPrefix(root armRoot, _ Observation) error {
// 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 := agent.LoadSession(before.SessionPath)
session, err := sessionstore.LoadSession(before.SessionPath)
if err != nil {
return err
}
@@ -222,7 +222,7 @@ func truncateTail(_ armRoot, before Observation) error {
// 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 := agent.LoadSession(before.SessionPath)
session, err := sessionstore.LoadSession(before.SessionPath)
if err != nil {
return err
}
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"time"
"reasonix/internal/control"
"reasonix/internal/provider"
"reasonix/internal/contract/provider"
"reasonix/internal/session/control"
)
// Where a stop goes — and, first, whose stop it is. A controller owns the turns
+5 -5
View File
@@ -3,14 +3,14 @@ package main
import (
"context"
"os"
"reasonix/internal/state/sessionstore"
"sort"
"strings"
"time"
"reasonix/internal/agent"
"reasonix/internal/agentgraph"
"reasonix/internal/control"
"reasonix/internal/execgraph"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/session/control"
"reasonix/internal/state/execgraph"
)
// The derivability arms. Everything before them asked what survives; these ask
@@ -127,7 +127,7 @@ func answeredFromDurable(o Observation) map[string]bool {
}
out := map[string]bool{}
for _, e := range o.Executions {
out[e.ID] = e.Disposition == "adopted" || terminal[e.ID] == string(agent.SubagentCompleted)
out[e.ID] = e.Disposition == "adopted" || terminal[e.ID] == string(sessionstore.SubagentCompleted)
}
return out
}
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"strings"
"time"
"reasonix/internal/agentgraph"
"reasonix/internal/control"
"reasonix/internal/provider"
"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
+3 -3
View File
@@ -10,9 +10,9 @@ import (
"strings"
"time"
"reasonix/internal/agentgraph"
"reasonix/internal/control"
"reasonix/internal/provider"
"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,
+10 -9
View File
@@ -2,14 +2,15 @@ package main
import (
"fmt"
"reasonix/internal/state/sessionstore"
"slices"
"sort"
"strings"
"reasonix/internal/agent"
"reasonix/internal/agentgraph"
"reasonix/internal/execgraph"
"reasonix/internal/execjournal"
"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
@@ -87,7 +88,7 @@ func executedWorkers(o Observation) []string {
// 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(agent.SubagentRunning))...)
ghosts := append(runningWorkers(after), childrenInState(after, string(sessionstore.SubagentRunning))...)
verdict := verdictHolds
if len(ghosts) > 0 {
verdict = verdictViolated
@@ -177,11 +178,11 @@ 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(agent.SubagentRunning))) > 0:
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(agent.SubagentInterrupted))) > 0:
case len(childrenInState(after, string(sessionstore.SubagentInterrupted))) > 0:
return graphInterrupted, verdictLossy
case len(after.Obligation.UnansweredCalls) > 0 || after.Obligation.InterruptionMarked > 0:
return graphInterrupted, verdictLossy
@@ -258,8 +259,8 @@ func rebuiltGraph(o Observation) execgraph.Result {
}
children := make([]execgraph.ChildOutcome, 0, len(o.Children.Facts))
for _, f := range o.Children.Facts {
identity := agent.ResolveExecutionIdentity(
agent.SubagentMeta{ExecutionID: f.ExecutionID, ParentToolCallID: f.ParentToolCallID},
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,
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"sync/atomic"
"time"
"reasonix/internal/agentgraph"
"reasonix/internal/control"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/session/control"
)
// The interactive host-initiated arms, named for the one surface they measure: a
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings"
"time"
"reasonix/internal/control"
"reasonix/internal/session/control"
)
// Two trees, read at once. A stop aimed at a turn must close the turn's own
+4 -4
View File
@@ -8,10 +8,10 @@ import (
"slices"
"time"
"reasonix/internal/agentgraph"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
"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
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"sort"
"strings"
"reasonix/internal/agentgraph"
"reasonix/internal/execgraph"
"reasonix/internal/execjournal"
"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
+13 -12
View File
@@ -6,17 +6,18 @@ import (
"encoding/json"
"os"
"path/filepath"
"reasonix/internal/state/sessionstore"
"regexp"
"sort"
"strings"
"reasonix/internal/agent"
"reasonix/internal/agentgraph"
"reasonix/internal/control"
"reasonix/internal/evidence"
"reasonix/internal/execjournal"
"reasonix/internal/provider"
"reasonix/internal/store"
"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
@@ -272,7 +273,7 @@ func childrenObs(root armRoot, sessionPath string) ChildrenObs {
return ChildrenObs{}
}
parent := strings.TrimSuffix(filepath.Base(sessionPath), ".jsonl")
artifacts, err := agent.ListSubagentsByParent(root.Sessions, parent)
artifacts, err := sessionstore.ListSubagentsByParent(root.Sessions, parent)
if err != nil {
return ChildrenObs{Err: err.Error()}
}
@@ -312,7 +313,7 @@ func listingObs(root armRoot, sessionPath string) ListingObs {
if strings.TrimSpace(sessionPath) == "" {
return ListingObs{}
}
store := agent.NewSubagentStore(filepath.Join(root.Sessions, "subagents"))
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 {
@@ -337,7 +338,7 @@ func capture(phase, arm, bootSystem string, ctrl *control.Controller, sink *grap
history := ctrl.History()
system := systemText(history)
snap := ctrl.ContextMaintenanceSnapshot()
st, loaded, loadErr := agent.LoadCompactionState(path)
st, loaded, loadErr := sessionstore.LoadCompactionState(path)
view, viewMsgs := viewObs(st, loaded, history)
graph, deltas := sink.snapshot()
return Observation{
@@ -420,7 +421,7 @@ func shortSum(s string) string {
return hex.EncodeToString(sum[:8])
}
func sidecarObs(st agent.CompactionState, ok bool, err error) SidecarObs {
func sidecarObs(st sessionstore.CompactionState, ok bool, err error) SidecarObs {
out := SidecarObs{Present: ok}
if err != nil {
out.Err = err.Error()
@@ -440,7 +441,7 @@ func sidecarObs(st agent.CompactionState, ok bool, err error) SidecarObs {
// 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 agent.CompactionState, ok bool, canonical []provider.Message) (ViewObs, []provider.Message) {
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)
+7 -6
View File
@@ -4,11 +4,12 @@ import (
"context"
"fmt"
"os"
"reasonix/internal/state/sessionstore"
"time"
"reasonix/internal/agent"
"reasonix/internal/checkpoint"
"reasonix/internal/control"
"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:
@@ -102,7 +103,7 @@ func rewindTurn(root armRoot, arm, bootSystem string, ctrl *control.Controller,
// 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, _ := agent.LoadCompactionState(ctrl.SessionPath()); ok {
if st, ok, _ := sessionstore.LoadCompactionState(ctrl.SessionPath()); ok {
covered = st.Projection.CoveredCount
}
target := -1
@@ -286,7 +287,7 @@ func runSuccessor(dir, arm string) error {
}
defer ctrl.Close()
bootSystem := bootSystemText(ctrl)
session, err := agent.LoadSession(before.SessionPath)
session, err := sessionstore.LoadSession(before.SessionPath)
if err != nil {
return fmt.Errorf("load session: %w", err)
}
@@ -321,7 +322,7 @@ func runResume(dir, arm string) error {
defer ctrl.Close()
bootSystem := bootSystemText(ctrl)
session, err := agent.LoadSession(before.SessionPath)
session, err := sessionstore.LoadSession(before.SessionPath)
if err != nil {
return fmt.Errorf("load session: %w", err)
}
+4 -4
View File
@@ -10,10 +10,10 @@ import (
"sync"
"time"
"reasonix/internal/agentgraph"
"reasonix/internal/boot"
"reasonix/internal/control"
"reasonix/internal/event"
"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
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"time"
"reasonix/internal/agentgraph"
"reasonix/internal/control"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/session/control"
)
// The scheduler-wait arms. Each dies with one item ready and refused admission,
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"sync"
"sync/atomic"
"reasonix/internal/provider"
"reasonix/internal/contract/provider"
)
// newScripted builds the provider with its synchronisation channels open. The
+5 -5
View File
@@ -6,14 +6,14 @@ import (
"fmt"
"os"
"path/filepath"
"reasonix/internal/state/sessionstore"
"sort"
"strings"
"time"
"reasonix/internal/agent"
"reasonix/internal/control"
"reasonix/internal/provider"
"reasonix/internal/skill"
"reasonix/internal/contract/provider"
"reasonix/internal/ext/skill"
"reasonix/internal/session/control"
)
// The skill-runner classification arms. They ask what kind of execution a
@@ -453,7 +453,7 @@ func skillCancelVerdict(before Observation) string {
switch {
case terminal == "":
return verdictNotMeasured
case cancelled && terminal != string(agent.SubagentCancelled):
case cancelled && terminal != string(sessionstore.SubagentCancelled):
return verdictViolated
default:
return verdictHolds
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"time"
"reasonix/internal/agentgraph"
"reasonix/internal/control"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/session/control"
)
// The terminal-disposition arms. SETTLED says an opening is no longer active
+7 -7
View File
@@ -12,13 +12,13 @@ import (
"sync"
"time"
"reasonix/internal/agentgraph"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/execjournal"
"reasonix/internal/provider"
"reasonix/internal/serve"
"reasonix/internal/contract/agentgraph"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/frontend/serve"
"reasonix/internal/session/control"
"reasonix/internal/state/execjournal"
)
// armUIGraphMixed is the only arm that reads the frontend rather than the host.
+39
View File
@@ -0,0 +1,39 @@
# tool-surface-cache
May the provider-visible tool set change between turns?
The tool schema was held still on the reasoning that any change churns the
cache-stable prefix. That is true of a change anywhere, and false of a change at
the end. Which one applies decides whether a tool can appear only while it is
callable, or has to ship on every turn and be refused on most of them.
```
DEEPSEEK_API_KEY=... go run ./benchmarks/tool-surface-cache
```
It reaches the network and spends a few thousand input tokens, so it is not part
of `make lint` or CI. Run it when a provider changes its caching, or before
moving anything in the tool surface.
**The cost is the position, not the change.** Measured against
`deepseek-chat`, 10 tools behind a 1278-token system prompt:
| change | hit tokens | against a 1920 baseline |
| --- | --- | --- |
| append at the tail | 2048 | more, not less |
| insert at the head | 1024 | −896 |
| edit the fifth tool's text | 1536 | −384 |
Appending costs nothing because every token before the new one is unchanged and
the request is longer, so more of it is cached. Inserting at the head moves
every tool after it. Editing in place costs whatever follows the edit.
**A tool that comes and goes at the tail is free after the first turn.** The
second block alternates one tail tool between absent and present: from the
second round on, absent holds 1920 hit tokens and present 2048, unchanged
across three switches, with no miss spike at either. The absent surface is a byte prefix of the present one, so a single
cache entry serves both.
This is what `Registry.ProviderSchemas` is built on: stable tools first,
contextual ones after, each sorted. `internal/contract/tool/provider_surface_test.go`
holds the shape — the segment boundary is the property this benchmark priced.
+108
View File
@@ -0,0 +1,108 @@
// Command tool-surface-cache measures what moving a tool inside the schema
// costs against a provider's prefix cache. It answers one question with real
// requests: may the provider-visible tool set change between turns?
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"strings"
"time"
)
type usage struct {
PromptTokens int `json:"prompt_tokens"`
Hit int `json:"prompt_cache_hit_tokens"`
Miss int `json:"prompt_cache_miss_tokens"`
}
func probe(n int, extra string) map[string]any {
return map[string]any{"type": "function", "function": map[string]any{
"name": fmt.Sprintf("probe_%d", n),
"description": fmt.Sprintf("Probe tool number %d used to occupy schema tokens in a cache "+
"experiment. It accepts a path and a mode and returns a string. %s", n, extra),
"parameters": map[string]any{"type": "object", "properties": map[string]any{
"path": map[string]any{"type": "string", "description": fmt.Sprintf("filesystem path for probe %d", n)},
"mode": map[string]any{"type": "string", "description": fmt.Sprintf("operating mode for probe %d", n)},
}, "required": []string{"path"}},
}}
}
func main() {
endpoint := flag.String("endpoint", "https://api.deepseek.com/chat/completions", "chat completions endpoint")
model := flag.String("model", "deepseek-chat", "model to sample")
keyEnv := flag.String("key-env", "DEEPSEEK_API_KEY", "environment variable holding the key")
flag.Parse()
key := os.Getenv(*keyEnv)
if key == "" {
fmt.Fprintf(os.Stderr, "%s is empty; export it before running\n", *keyEnv)
os.Exit(2)
}
system := strings.Repeat("You are a coding agent. Principles: understand the request before acting; "+
"verify with tools instead of guessing; keep edits minimal and reversible; report what was "+
"actually observed rather than what was expected. ", 24)
base := make([]map[string]any, 0, 10)
for i := range 10 {
base = append(base, probe(i, ""))
}
call := func(label string, tools []map[string]any) {
payload, _ := json.Marshal(map[string]any{
"model": *model,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": "say ok"},
},
"tools": tools, "max_tokens": 1, "temperature": 0,
})
req, _ := http.NewRequest(http.MethodPost, *endpoint, bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", label, err)
return
}
defer resp.Body.Close()
var out struct {
Usage usage `json:"usage"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", label, err)
return
}
u := out.Usage
rate := 0.0
if u.PromptTokens > 0 {
rate = 100 * float64(u.Hit) / float64(u.PromptTokens)
}
fmt.Printf("%-34s prompt=%5d hit=%5d miss=%4d %5.1f%%\n", label, u.PromptTokens, u.Hit, u.Miss, rate)
time.Sleep(2 * time.Second)
}
with := func(extra ...map[string]any) []map[string]any {
out := append([]map[string]any(nil), base...)
return append(out, extra...)
}
fmt.Println("== where a tool sits decides what a change costs ==")
call("1. baseline (warm the cache)", base)
call("2. baseline again", base)
call("3. one tool appended at the tail", with(probe(99, "")))
call("4. one tool inserted at the head", append([]map[string]any{probe(98, "")}, base...))
mid := append(append(append([]map[string]any(nil), base[:5]...), probe(5, "CHANGED DESCRIPTION HERE.")), base[6:]...)
call("5. the fifth tool's text edited", mid)
call("6. baseline once more", base)
fmt.Println("\n== a tail tool coming and going, which is what a contextual tool does ==")
tail := with(probe(99, ""))
for i := range 3 {
call(fmt.Sprintf("%d. absent", 2*i+1), base)
call(fmt.Sprintf("%d. present", 2*i+2), tail)
}
}
@@ -0,0 +1,13 @@
import normalize
def merge_files(paths):
rows = []
header = None
for p in paths:
lines = [l.rstrip("\n") for l in open(p)]
if not lines:
continue
if header is None:
header = normalize.header(lines[0])
rows.extend(normalize.row(l) for l in lines[1:])
return [header] + rows
@@ -0,0 +1,5 @@
def header(line):
return ",".join(part.strip().lower() for part in line.split(","))
def row(line):
return ",".join(part.strip() for part in line.split(","))
@@ -0,0 +1,5 @@
import datetime
def parse(s):
y, m, d = s.split("-")
return datetime.date(int(y), int(m), int(d))
@@ -0,0 +1,5 @@
import parse_date
def in_window(day, start, end):
d = parse_date.parse(day)
return parse_date.parse(start) <= d <= parse_date.parse(end)
@@ -0,0 +1,11 @@
import tokenizer
def build(docs):
idx = {}
for doc_id, text in docs.items():
for tok in tokenizer.tokens(text):
idx.setdefault(tok, []).append(doc_id)
return idx
def lookup(idx, word):
return sorted(idx.get(word.lower(), []))
@@ -0,0 +1,5 @@
import re
def tokens(text):
return re.findall(r"[a-z0-9]+", text.lower())
@@ -0,0 +1,11 @@
import parse
def balance(lines):
total = 0.0
for line in lines:
kind, amount = parse.parse_line(line)
if kind == "deposit":
total += amount
elif kind == "withdraw":
total -= amount
return total
@@ -0,0 +1,3 @@
def parse_line(line):
kind, amount = line.strip().split()
return kind, float(amount.replace(",", ""))
@@ -0,0 +1,4 @@
import paginate
def next_link(items, size, page):
return f"?page={page + 1}" if page < len(paginate.pages(items, size)) else None
@@ -0,0 +1,2 @@
def pages(items, size):
return [items[i:i + size] for i in range(0, len(items), size)]
Loaded 100 of 5547 files, more files were not shown because too many files have changed in this diff. Show more