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
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
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
1037 changed files with 60030 additions and 5294 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
+7
View File
@@ -19,6 +19,13 @@
/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
+3 -3
View File
@@ -61,8 +61,8 @@ jobs:
# its DTOs are generated from internal/ext/extension/protocol, so
# both paths must trigger the sdk job.
if echo "$files" | grep -qE '^(sdk/|internal/ext/extension/)'; then sdk=true; fi
# The release-note tests render every committed Studio note.
if echo "$files" | grep -qE '^(scripts/|release-notes/studio/)'; then scripts=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 "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
@@ -80,7 +80,7 @@ jobs:
- 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
- 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
+2
View File
@@ -38,6 +38,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build
env:
VITE_REMOTE_HOME: https://reasonix.io/remote/
run: pnpm build
- name: Deploy
+38 -2
View File
@@ -6,7 +6,7 @@ on:
workflow_dispatch:
inputs:
replies:
description: 'JSON array of {"issue": 123, "body": "...", "close": true | "not_planned"}'
description: 'JSON array of {"issue": 123, "body": "...", "close": true | "not_planned"} or {"discussion": 123, "body": "...", "close": "resolved" | "outdated" | "duplicate"}'
required: true
type: string
interval:
@@ -18,6 +18,7 @@ on:
permissions:
issues: write
pull-requests: write
discussions: write
concurrency:
group: issue-replies
@@ -45,7 +46,42 @@ jobs:
return error.status === 429 ||
(error.status === 403 && (headers['retry-after'] !== undefined || headers['x-ratelimit-remaining'] === '0'));
};
for (const { issue, body, close } of replies) {
// 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;
+33 -2
View File
@@ -979,6 +979,7 @@ jobs:
# Deltas are served from the mirror only, so a release that cannot reach
# it does not offer one: a client would fetch an index nobody uploaded.
# The same gate puts the mirror ahead of GitHub for every full artifact.
- name: Generate manifest
working-directory: desktop
env:
@@ -1117,9 +1118,39 @@ jobs:
needs: [resolve, publish]
if: vars.STUDIO_PUBLISHES_CLI == 'true'
runs-on: ubuntu-latest
# Owner approval before anything reaches npm or the tap: the switch alone
# would otherwise publish on every studio tag.
environment: studio-cli-channels
# id-token lets npm publish --provenance attach a Sigstore attestation; the
# registry token still authenticates the publish itself.
permissions:
contents: read
id-token: write
steps:
# A job naming a missing environment gets an unprotected one, so the
# gate is verified rather than assumed; the tokens below exist only in it.
- name: Require the approval gate on studio-cli-channels
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -uo pipefail
resp="$(gh api -i "repos/$REPO/environments/studio-cli-channels" 2>&1)"
status="$(printf '%s\n' "$resp" | sed -n '1s/^HTTP\/[0-9.]* \([0-9]*\).*/\1/p')"
case "$status" in
200) ;;
404)
echo "::error::environment studio-cli-channels does not exist"
exit 1 ;;
*)
echo "::error::reading environment studio-cli-channels as ${GITHUB_ACTOR} failed with HTTP ${status:-unknown}"
exit 1 ;;
esac
reviewers="$(printf '%s\n' "$resp" | sed '1,/^\r\{0,1\}$/d' | jq '[.protection_rules[] | select(.type == "required_reviewers") | .reviewers | length] | add // 0')"
if ! [[ "$reviewers" =~ ^[1-9][0-9]*$ ]]; then
echo "::error::environment studio-cli-channels has no required reviewers (or its protection rules were unreadable: '${reviewers}'); refusing to publish"
exit 1
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
@@ -1133,7 +1164,7 @@ jobs:
registry-url: https://registry.npmjs.org
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.STUDIO_NPM_TOKEN }}
VERSION: ${{ needs.resolve.outputs.version }}
run: node npm/build.mjs "$VERSION" --publish
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -1144,7 +1175,7 @@ jobs:
- name: Update the Homebrew cask
if: needs.resolve.outputs.candidate != 'true'
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
TAP_TOKEN: ${{ secrets.STUDIO_HOMEBREW_TAP_TOKEN }}
VERSION: ${{ needs.resolve.outputs.version }}
TAG: ${{ needs.resolve.outputs.tag }}
run: |
+5 -5
View File
@@ -205,7 +205,7 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
[Task contracts & pause policy](./docs/TASK_CONTRACT.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:** [Extensions](./docs/EXTENSIONS.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)
@@ -232,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.
+4 -4
View File
@@ -214,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和开场视频。
+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
+41 -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.
@@ -426,13 +426,14 @@ own outcome).
| `-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). |
@@ -541,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
@@ -593,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
@@ -606,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
@@ -634,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
```
+12 -33
View File
@@ -20,7 +20,6 @@ import (
"time"
"reasonix/internal/base/tokencount"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
@@ -42,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()
@@ -51,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)
}
@@ -66,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)
@@ -101,7 +96,7 @@ type harness struct {
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{
@@ -112,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)
@@ -160,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
@@ -169,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 {
@@ -178,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")
@@ -194,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)
}
+15 -14
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 (
@@ -70,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{
@@ -193,7 +193,10 @@ 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 := sessionstore.LoadSession(path)
@@ -202,11 +205,7 @@ func resume(dir string, offline bool) {
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)
@@ -258,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)
@@ -306,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
@@ -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
@@ -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)]
@@ -0,0 +1,5 @@
def apply_pct(price, pct):
return price * (1 - pct / 100.0)
def apply_coupon(price, coupon):
return price - coupon
@@ -0,0 +1,7 @@
import discounts
def final_price(base, pct, coupon):
price = discounts.apply_pct(base, pct)
price = discounts.apply_coupon(price, coupon)
return int(max(price, 0.0) * 100) / 100
@@ -0,0 +1,6 @@
import tiers
def overage_units(used, plan):
included = tiers.included(plan)
return max(0, used - included)
@@ -0,0 +1,9 @@
PLANS = {"basic": 100, "pro": 1000}
def included(plan):
return PLANS[plan]
def tier_for(units):
if units > 100:
return "pro"
return "basic"
@@ -0,0 +1,3 @@
def delay_ms(base, attempt, cap):
delay = base * (2 ** (attempt - 1))
return min(delay, cap)
@@ -0,0 +1,11 @@
import re
def slugify(title):
out = []
for ch in title.lower():
if ch.isalnum():
out.append(ch)
elif ch in " -_":
out.append("-")
return re.sub(r"-+", "-", "".join(out)).strip("-")
@@ -0,0 +1,6 @@
import slug
BASE = "/articles/"
def article_url(title):
return BASE + slug.slugify(title)
@@ -0,0 +1,2 @@
def c_to_f(c):
return c * 9 / 5 + 32
@@ -0,0 +1,9 @@
import convert
def band(c):
f = convert.c_to_f(c)
if f >= 90:
return "hot"
if f >= 60:
return "warm"
return "cold"
+147
View File
@@ -0,0 +1,147 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"reasonix/internal/safety/sandbox"
)
// MemoryBench keeps its seeded facts in the suite checkout. The graded child
// receives only workdir/, but without a read boundary it can still search the
// host for that checkout or read the same facts from Git objects.
func validateAnswerIsolation(tasks []task) error {
for _, t := range tasks {
if t.answerRoot != "" && fileExists(filepath.Join(t.dir, "workdir", "reasonix.toml")) {
return fmt.Errorf("%s already has a project config; answer isolation cannot replace it", t.dir)
}
}
for _, t := range tasks {
if t.answerRoot == "" {
continue
}
if !sandbox.Available() {
return fmt.Errorf("%s contains answer material, but this host has no OS read sandbox", t.answerRoot)
}
roots, err := answerReadRoots(t.answerRoot)
if err != nil {
return err
}
stateBase, err := benchStateBase()
if err != nil {
return err
}
for _, root := range roots {
for _, path := range []string{os.TempDir(), stateBase} {
if within(root, resolvedPath(path)) {
return fmt.Errorf("benchmark runtime path %s lies inside forbidden answer root %s", path, root)
}
}
}
return nil
}
return nil
}
func stageAnswerIsolation(answerRoot, work string) error {
if answerRoot == "" {
return nil
}
roots, err := answerReadRoots(answerRoot)
if err != nil {
return err
}
for _, root := range roots {
if within(root, resolvedPath(work)) {
return fmt.Errorf("workdir %s lies inside forbidden answer root %s", work, root)
}
}
paths, err := json.Marshal(roots) // JSON strings are valid TOML basic strings.
if err != nil {
return err
}
path := filepath.Join(work, "reasonix.toml")
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return fmt.Errorf("create isolated project config: %w", err)
}
_, writeErr := fmt.Fprintf(f, "[sandbox]\nbash = \"enforce\"\nforbid_read = %s\n", paths)
closeErr := f.Close()
if writeErr != nil {
return writeErr
}
return closeErr
}
// The temporary policy is harness state, not a task artifact. Remove it before
// grading and mutation timing so it cannot affect either measurement.
func removeAnswerIsolation(answerRoot, work string) error {
if answerRoot == "" {
return nil
}
return os.Remove(filepath.Join(work, "reasonix.toml"))
}
func answerReadRoots(answerRoot string) ([]string, error) {
tasks, err := filepath.Abs(answerRoot)
if err != nil {
return nil, err
}
tasks = resolvedPath(tasks)
roots := []string{tasks}
// A worktree's .git is a pointer into the common object store. Hide that
// store and every registered checkout: the same answer files may be present
// in the primary checkout as well as in the one running this harness.
cmd := exec.Command("git", "-C", tasks, "worktree", "list", "--porcelain", "-z")
listing, err := cmd.Output()
if err != nil {
if gitMarkerAbove(tasks) {
return nil, fmt.Errorf("list checkout worktrees for answer isolation: %w", err)
}
return roots, nil // Source archive without a Git object store.
}
for line := range strings.SplitSeq(string(listing), "\x00") {
if path, ok := strings.CutPrefix(line, "worktree "); ok {
roots = append(roots, resolvedPath(path))
}
}
common, err := exec.Command("git", "-C", tasks, "rev-parse", "--git-common-dir").Output()
if err != nil {
return nil, fmt.Errorf("locate Git object store for answer isolation: %w", err)
}
gitDir := strings.TrimSuffix(string(common), "\n")
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(tasks, gitDir)
}
roots = append(roots, resolvedPath(gitDir))
return uniqueAnswerRoots(roots), nil
}
func gitMarkerAbove(path string) bool {
for {
if _, err := os.Stat(filepath.Join(path, ".git")); err == nil {
return true
}
parent := filepath.Dir(path)
if parent == path {
return false
}
path = parent
}
}
func uniqueAnswerRoots(paths []string) []string {
seen := make(map[string]bool, len(paths))
var out []string
for _, path := range paths {
if path != "" && !seen[path] {
seen[path] = true
out = append(out, path)
}
}
return out
}
+289
View File
@@ -0,0 +1,289 @@
package main
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"reasonix/internal/contract/config"
"reasonix/internal/safety/sandbox"
)
func TestMemoryBenchRequiresReadSandbox(t *testing.T) {
tasks, err := loadTasks("../../benchmarks/memorybench")
if err != nil {
t.Fatal(err)
}
if len(tasks) == 0 {
t.Fatal("MemoryBench corpus is empty")
}
for _, task := range tasks {
if task.answerRoot == "" {
t.Fatalf("%s can run without the answer boundary", task.ID)
}
}
err = validateAnswerIsolation(tasks)
if sandbox.Available() && err != nil {
t.Fatal(err)
}
if !sandbox.Available() && err == nil {
t.Fatal("MemoryBench accepted a host with no OS read sandbox")
}
if sandbox.Available() {
previous := benchStateBase
benchStateBase = func() (string, error) { return filepath.Join(tasks[0].answerRoot, "cache"), nil }
t.Cleanup(func() { benchStateBase = previous })
if err := validateAnswerIsolation(tasks); err == nil {
t.Fatal("MemoryBench accepted a state root inside the forbidden checkout")
}
}
}
func TestMemoryBenchAnswerIsolationAtSandboxBoundary(t *testing.T) {
if !sandbox.Available() {
t.Skip("this host has no OS read sandbox")
}
const suite = "../../benchmarks/memorybench"
tasks, err := loadTasks(suite)
if err != nil {
t.Fatal(err)
}
if len(tasks) == 0 || tasks[0].answerRoot == "" {
t.Fatal("MemoryBench answer material was not identified")
}
if err := validateAnswerIsolation(tasks); err != nil {
t.Fatal(err)
}
work := t.TempDir()
if err := stageAnswerIsolation(tasks[0].answerRoot, work); err != nil {
t.Fatal(err)
}
home := t.TempDir()
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[sandbox]\nbash = \"off\"\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("REASONIX_HOME", home)
cfg, err := config.LoadForRoot(work)
if err != nil {
t.Fatal(err)
}
if cfg.BashMode() != "enforce" {
t.Fatalf("sandbox mode = %q, want enforce even when the user config disables it", cfg.BashMode())
}
visible := filepath.Join(work, "visible.txt")
if err := os.WriteFile(visible, []byte("visible-control"), 0o600); err != nil {
t.Fatal(err)
}
answer := filepath.Join(suite, "tasks", "mb-contradiction", "memory", "project", "package-manager.md")
answer, err = filepath.Abs(answer)
if err != nil {
t.Fatal(err)
}
spec := sandbox.Spec{Mode: cfg.BashMode(), WriteRoots: []string{work}, ForbidReadRoots: cfg.ForbidReadRootsForRoot(work)}
read := func(path string) (string, error) {
t.Helper()
argv, wrapped := sandbox.CommandArgs(spec, []string{"cat", path})
if !wrapped {
t.Fatal("OS sandbox did not wrap the command")
}
out, err := exec.Command(argv[0], argv[1:]...).CombinedOutput()
return string(out), err
}
if got, err := read(visible); err != nil || !strings.Contains(got, "visible-control") {
t.Fatalf("control file read = %q, %v", got, err)
}
if got, err := read(answer); err == nil || strings.Contains(got, "npm install") {
t.Fatalf("answer file remained readable through the sandbox: %q, %v", got, err)
}
}
func TestAnswerIsolationRefusesUnsafeWorkdirAndExistingConfig(t *testing.T) {
const answerRoot = "../../benchmarks/memorybench/tasks"
if err := stageAnswerIsolation(answerRoot, filepath.Dir(answerRoot)); err == nil {
t.Fatal("workdir inside the checkout was accepted")
}
work := t.TempDir()
path := filepath.Join(work, "reasonix.toml")
if err := os.WriteFile(path, []byte("# task fixture\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := stageAnswerIsolation(answerRoot, work); err == nil {
t.Fatal("existing task config was overwritten")
}
if data, _ := os.ReadFile(path); string(data) != "# task fixture\n" {
t.Fatalf("task config changed: %q", data)
}
}
func TestRunTaskDropsIsolationConfigBeforeGrading(t *testing.T) {
requireShellStub(t)
suite := t.TempDir()
taskDir := filepath.Join(suite, "tasks", "demo")
if err := os.MkdirAll(filepath.Join(taskDir, "memory", "project"), 0o755); err != nil {
t.Fatal(err)
}
verify := "#!/usr/bin/env bash\nset -e\ntest ! -e reasonix.toml\ngrep -q '^ok$' answer.txt\n"
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte(verify), 0o755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(t.TempDir(), "fake-agent")
script := `#!/usr/bin/env bash
set -e
test -f reasonix.toml
printf 'ok\n' > answer.txt
while [ "$#" -gt 0 ]; do
case "$1" in
--metrics) printf '{"complete":true}\n' > "$2"; shift 2 ;;
--trajectory) printf '{"event":{"kind":"text","text":"done"}}\n' > "$2"; shift 2 ;;
*) shift ;;
esac
done
`
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
tk := task{ID: "demo", Prompt: "write the answer", TimeoutSec: 30, dir: taskDir, answerRoot: filepath.Join(suite, "tasks")}
r := runTask(suiteConfig{bin: bin}, tk)
if !r.Passed {
t.Fatalf("isolated task failed grading: %+v", r)
}
}
func TestMemoryBenchRejectsAnswerReadInTrajectory(t *testing.T) {
requireShellStub(t)
suite := t.TempDir()
taskDir := filepath.Join(suite, "tasks", "demo")
answer := filepath.Join(taskDir, "memory", "project", "fact.md")
if err := os.MkdirAll(filepath.Dir(answer), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(answer, []byte("---\nname: fact\n---\nA unique answer that the graded child must not read.\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte("#!/bin/sh\ntest -f answer.txt\n"), 0o755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(t.TempDir(), "fake-agent")
script := `#!/usr/bin/env bash
set -e
printf 'ok\n' > answer.txt
while [ "$#" -gt 0 ]; do
case "$1" in
--metrics) printf '{"complete":true}\n' > "$2"; shift 2 ;;
--trajectory)
printf '{"event":{"kind":"tool_dispatch","tool":{"id":"read-1","name":"read_file","args":"{\\"path\\":\\"%s\\"}"}}}\n' "$ANSWER_FILE" > "$2"
printf '{"event":{"kind":"tool_result","tool":{"id":"read-1","name":"read_file","output":"A unique answer that the graded child must not read."}}}\n' >> "$2"
shift 2 ;;
*) shift ;;
esac
done
`
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("ANSWER_FILE", answer)
tk := task{ID: "demo", Prompt: "write the answer", TimeoutSec: 30, dir: taskDir, answerRoot: filepath.Join(suite, "tasks")}
r := runTask(suiteConfig{bin: bin}, tk)
if r.Passed || !errors.Is(r.answerAuditErr, errAnswerRead) {
t.Fatalf("answer-read trajectory was accepted: %+v", r)
}
}
func TestMissingAnswerTrajectoryKeepsOtherTasksRunning(t *testing.T) {
requireShellStub(t)
root := filepath.Join(t.TempDir(), "tasks")
var tasks []task
for _, id := range []string{"missing", "complete"} {
dir := filepath.Join(root, id)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "verify.sh"), []byte("#!/bin/sh\ntest -f answer.txt\n"), 0o755); err != nil {
t.Fatal(err)
}
tasks = append(tasks, task{ID: id, Prompt: id, TimeoutSec: 30, dir: dir, answerRoot: root})
}
bin := filepath.Join(t.TempDir(), "fake-agent")
script := `#!/usr/bin/env bash
set -e
printf 'ok\n' > answer.txt
traj=''
prompt=''
while [ "$#" -gt 0 ]; do
case "$1" in
--metrics) printf '{"complete":true}\n' > "$2"; shift 2 ;;
--trajectory) traj="$2"; shift 2 ;;
*) prompt="$1"; shift ;;
esac
done
if [ "$prompt" = complete ]; then
printf '{"event":{"kind":"text","text":"done"}}\n' > "$traj"
fi
`
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
results := runSuite(suiteConfig{bin: bin}, tasks)
if len(results) != 2 || !errors.Is(results[0].answerAuditErr, errAnswerAuditIncomplete) || !results[1].Passed {
t.Fatalf("missing trajectory stopped the suite: %+v", results)
}
results[0].Outcome = "renamed display status"
if !answerAuditFailed(results[0]) || answerLeakDetected(results[0]) {
t.Fatal("audit control flow depended on the display outcome")
}
}
func TestAnswerTrajectoryAuditDistinguishesReadsFromListingsAndDenials(t *testing.T) {
const fact = "A unique answer that the graded child must not read."
const dispatch = `{"event":{"kind":"tool_dispatch","tool":{"id":"a","name":"bash","args":"{\"command\":\"cat /repo/tasks/demo/memory/project/fact.md\"}"}}}`
for _, tc := range []struct {
name, data string
want bool
}{
{"successful shell read", dispatch + "\n" + `{"event":{"kind":"tool_result","tool":{"id":"a","name":"bash","output":"` + fact + `","execution":{"exitCode":0}}}}`, true},
{"sandbox denial", dispatch + "\n" + `{"event":{"kind":"tool_result","tool":{"id":"a","name":"bash","output":"` + fact + `","execution":{"exitCode":1}}}}`, false},
{"directory listing", `{"event":{"kind":"tool_dispatch","tool":{"id":"a","name":"bash","args":"{\"command\":\"ls /repo/tasks/demo/memory\"}"}}}` + "\n" + `{"event":{"kind":"tool_result","tool":{"id":"a","name":"bash","output":"fact.md"}}}`, false},
{"native read denial", `{"event":{"kind":"tool_result","tool":{"name":"read_file","args":"{\"path\":\"/repo/tasks/demo/memory/project/fact.md\"}","err":"sandbox denied"}}}`, false},
{"grep reads fact", `{"event":{"kind":"tool_result","tool":{"name":"grep","args":"{\"path\":\"/repo/tasks/demo/memory/project/fact.md\",\"pattern\":\"unique\"}","output":"` + fact + `"}}}`, true},
{"glob lists name only", `{"event":{"kind":"tool_result","tool":{"name":"glob","args":"{\"pattern\":\"/repo/tasks/demo/memory/project/*.md\"}","output":"/repo/tasks/demo/memory/project/fact.md"}}}`, false},
} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "run.trajectory.jsonl")
if err := os.WriteFile(path, []byte(tc.data+"\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := scanAnswerRead(path, []string{fact})
if err != nil || got != tc.want {
t.Fatalf("scanAnswerRead = %t, %v; want %t", got, err, tc.want)
}
})
}
}
func TestAnswerTrajectoryAuditScansEarlierSegments(t *testing.T) {
root := filepath.Join(t.TempDir(), "tasks")
answer := filepath.Join(root, "demo", "memory", "project", "fact.md")
if err := os.MkdirAll(filepath.Dir(answer), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(answer, []byte("---\nname: fact\n---\nA fact body in the first trajectory leg.\n"), 0o600); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
for index, data := range []string{
`{"event":{"kind":"tool_result","tool":{"name":"bash","args":"{\"command\":\"cat /repo/tasks/demo/memory/project/fact.md\"}","output":"A fact body in the first trajectory leg."}}}`,
`{"event":{"kind":"text","text":"done"}}`,
} {
path := segmentTrajectoryPath(dir, "demo", segment{index: index + 1}, 2)
if err := os.WriteFile(path, []byte(data+"\n"), 0o600); err != nil {
t.Fatal(err)
}
}
read, err := auditAnswerTrajectories(dir, task{ID: "demo", answerRoot: root}, 2)
if err != nil || !read {
t.Fatalf("earlier answer read was missed: read=%t err=%v", read, err)
}
}
+210
View File
@@ -0,0 +1,210 @@
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
var answerMemoryPath = regexp.MustCompile(`(?:^|[/\\])tasks[/\\][^/\\]+[/\\]memory[/\\]`)
var (
errAnswerRead = errors.New("graded trajectory read task memory")
errAnswerAuditIncomplete = errors.New("answer trajectory audit incomplete")
)
type answerToolRecord struct {
Event *struct {
Kind string `json:"kind"`
Tool *struct {
ID string `json:"id"`
Name string `json:"name"`
Args string `json:"args"`
Output string `json:"output"`
Err string `json:"err"`
Execution *struct {
State string `json:"state"`
ExitCode *int `json:"exitCode"`
} `json:"execution"`
} `json:"tool"`
} `json:"event"`
}
type answerToolCall struct{ name, args string }
// answerBodyLines are the authored facts, excluding repeated frontmatter.
// A path listing alone is not evidence that the child read a fact body.
func answerBodyLines(root string) ([]string, error) {
var lines []string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || filepath.Ext(path) != ".md" || !strings.Contains(path, string(filepath.Separator)+"memory"+string(filepath.Separator)) {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
body := string(data)
if strings.HasPrefix(body, "---\n") {
if end := strings.Index(body[4:], "\n---\n"); end >= 0 {
body = body[4+end+5:]
}
}
for line := range strings.SplitSeq(body, "\n") {
line = strings.TrimSpace(line)
if len(line) >= 20 {
lines = append(lines, line)
}
}
return nil
})
return lines, err
}
// scanAnswerRead checks completed tool results, not dispatched attempts: the
// OS sandbox may correctly deny a cat of a forbidden path. A successful
// native file read is direct evidence; a shell read needs both the forbidden
// path and an authored fact body in its output.
func scanAnswerRead(path string, bodyLines []string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
calls := map[string]answerToolCall{}
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 1<<20), 64<<20)
for sc.Scan() {
var rec answerToolRecord
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
return false, fmt.Errorf("decode trajectory: %w", err)
}
if rec.Event == nil || rec.Event.Tool == nil {
continue
}
tl := rec.Event.Tool
if rec.Event.Kind == "tool_dispatch" {
if tl.ID != "" && tl.Args != "" {
calls[tl.ID] = answerToolCall{tl.Name, tl.Args}
}
continue
}
if rec.Event.Kind != "tool_result" {
continue
}
call := answerToolCall{tl.Name, tl.Args}
if prior, ok := calls[tl.ID]; ok {
if call.name == "" {
call.name = prior.name
}
if call.args == "" {
call.args = prior.args
}
}
delete(calls, tl.ID)
if tl.Err != "" || tl.Execution != nil && (tl.Execution.State == "not_run" || tl.Execution.ExitCode != nil && *tl.Execution.ExitCode != 0) {
continue
}
if !answerMemoryPath.MatchString(call.args) {
continue
}
if call.name == "read_file" && tl.Output != "" {
return true, nil
}
if call.name == "bash" || call.name == "grep" {
for _, line := range bodyLines {
if strings.Contains(tl.Output, line) {
return true, nil
}
}
}
}
if err := sc.Err(); err != nil {
return false, err
}
return false, nil
}
func auditAnswerTrajectories(dir string, t task, segments int) (bool, error) {
if segments < 1 {
return false, fmt.Errorf("run recorded no segments")
}
lines, err := answerBodyLines(t.answerRoot)
if err != nil {
return false, err
}
for index := 1; index <= segments; index++ {
path := segmentTrajectoryPath(dir, t.ID, segment{index: index}, segments)
read, err := scanAnswerRead(path, lines)
if err != nil || read {
return read, err
}
}
return false, nil
}
func answerTrajectoryDir(dir string, required bool) (string, func(), error) {
cleanup := func() {}
if required && dir == "" {
var err error
dir, err = os.MkdirTemp("", "e2ebench-answer-audit-")
if err != nil {
return "", cleanup, err
}
cleanup = func() { _ = os.RemoveAll(dir) }
}
if dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
cleanup()
return "", func() {}, err
}
}
return dir, cleanup, nil
}
func auditAnswerRun(r *result, dir string, t task) bool {
if t.answerRoot == "" {
return true
}
read, err := auditAnswerTrajectories(dir, t, r.Segments)
if err != nil {
markAnswerAuditIncomplete(r, err)
r.Note = "answer audit: " + err.Error()
return false
}
if read {
r.Outcome = "answer_leak"
r.answerAuditErr = errAnswerRead
r.Note = "answer audit: graded trajectory read a task memory file"
return false
}
return true
}
func markAnswerAuditIncomplete(r *result, err error) {
r.Outcome = "answer_audit_incomplete"
r.answerAuditErr = fmt.Errorf("%w: %w", errAnswerAuditIncomplete, err)
}
func answerAuditFailed(r result) bool {
return r.answerAuditErr != nil
}
func answerLeakDetected(r result) bool {
return errors.Is(r.answerAuditErr, errAnswerRead)
}
func recordTaskTrajectory(r *result, path string, t task) {
if summary, err := summarizeTrajectory(path); err == nil {
r.Trajectory = summary
}
applyMemoryStats(r, path, t)
}
+43 -1
View File
@@ -22,6 +22,12 @@ const verificationStressDir = "../../benchmarks/verification-stress"
// must ship a solution/ proving its grader can be satisfied.
const trainCorpusDir = "../../benchmarks/train"
// memorybenchDir holds the recall-behavior corpus. It ships no solution/
// fixtures (see #11329), so it only gets the pristine-seed half of the
// authoring guard below; the mb-contradiction fix has its own targeted
// grader test instead.
const memorybenchDir = "../../benchmarks/memorybench"
// protectedFiles reads the manifest embedded in a no-solution grader. The
// manifest lives inside verify.sh precisely because e2ebench drops that file
// in only after the run, so the agent never sees which files are watched.
@@ -174,7 +180,7 @@ func TestSolvableCorpusSeedsMustNotGradeClean(t *testing.T) {
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
}
}
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir} {
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir} {
if !dirExists(dir) {
continue
}
@@ -348,3 +354,39 @@ func TestCorpusLetsTheAgentDecideWhenToStop(t *testing.T) {
}
}
}
// mbContradictionDir is the fixture behind #11329: its grader required
// "pnpm install" while also forbidding "npm install", a substring of the very
// answer it demanded, so no answer.txt could ever satisfy both halves.
const mbContradictionDir = "../../benchmarks/memorybench/tasks/mb-contradiction"
// TestContradictionGraderAcceptsPnpmRejectsNpm pins the four cases #11329
// worked through by hand: the intended answer must pass, the stale answer it
// is meant to reject must still fail, and disambiguating the negative check
// must not let a genuine "npm install" line through when both are present.
func TestContradictionGraderAcceptsPnpmRejectsNpm(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash unavailable; the grader needs a POSIX shell")
}
cases := []struct {
name string
content string
want bool
}{
{"pnpm install", "pnpm install\n", true},
{"npm install", "npm install\n", false},
{"both lines", "pnpm install\nnpm install\n", false},
{"pnpm install with flag", "pnpm install --frozen-lockfile\n", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
work := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(work, "answer.txt"), []byte(c.content), 0o644); err != nil {
t.Fatal(err)
}
if got := grade(work, mbContradictionDir); got != c.want {
t.Fatalf("answer.txt = %q: grade() = %v, want %v", c.content, got, c.want)
}
})
}
}
+58 -66
View File
@@ -11,12 +11,9 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/BurntSushi/toml"
fileencoding "reasonix/internal/base/fileutil/encoding"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/config"
@@ -48,6 +45,7 @@ type task struct {
SeedCorrect string `toml:"seed_correct" json:"-"`
SeedWrong string `toml:"seed_wrong" json:"-"`
dir string
answerRoot string
}
type runMetrics struct {
@@ -126,10 +124,11 @@ type result struct {
Profile string `json:"profile"`
// Arm is the ablation arm the harness requested, not the arm the child
// reported, so a run that died before writing metrics is still attributable.
Arm string `json:"arm"`
Passed bool
Skipped bool
Note string
Arm string `json:"arm"`
Passed bool
Skipped bool
Note string
answerAuditErr error
// Memory shadow: recall decisions and point-of-use evidence extracted from
// the trajectory (see memorybench.go). Zero for suites without seeds.
MemoryRecallEvents int `json:"memory_recall_events,omitempty"`
@@ -288,6 +287,7 @@ func main() {
tapeRecord, tapeReplay := registerTapeFlags()
trials := flag.Int("trials", 1, "suite mode: run every task N times and keep every outcome; reports pass^N (all passed) beside mean pass@1")
flag.Parse()
*trajDir = absTrajectoryDir(*trajDir)
if *trials > 1 && *attempts > 1 {
fmt.Fprintln(os.Stderr, "e2ebench: -trials and -attempts count repeats differently; use one")
os.Exit(2)
@@ -358,6 +358,10 @@ func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON string) {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if err := validateAnswerIsolation(tasks); err != nil {
fmt.Fprintln(os.Stderr, "load suite:", err)
os.Exit(2)
}
results := runSuite(cfg, tasks)
@@ -370,17 +374,22 @@ func runSuiteMode(cfg suiteConfig, suite, taskFilter, outMD, outJSON string) {
} else {
fmt.Print(report)
}
if outJSON == "" {
return
if outJSON != "" {
b, err := json.MarshalIndent(results, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, "marshal json:", err)
os.Exit(1)
}
if err := os.WriteFile(outJSON, b, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write json:", err)
os.Exit(1)
}
}
b, err := json.MarshalIndent(results, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, "marshal json:", err)
os.Exit(1)
}
if err := os.WriteFile(outJSON, b, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "write json:", err)
os.Exit(1)
for _, r := range results {
if answerAuditFailed(r) {
fmt.Fprintln(os.Stderr, "MemoryBench answer audit:", r.Note)
os.Exit(1)
}
}
}
@@ -395,37 +404,6 @@ func emit(report, outMD, _ string) {
fmt.Print(report)
}
func loadTasks(suite string) ([]task, error) {
tasksDir := filepath.Join(suite, "tasks")
entries, err := os.ReadDir(tasksDir)
if err != nil {
return nil, err
}
var tasks []task
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(tasksDir, e.Name())
var t task
data, err := fileencoding.ReadFileUTF8(filepath.Join(dir, "task.toml"))
if err != nil {
return nil, fmt.Errorf("%s: %w", e.Name(), err)
}
if _, err := toml.Decode(string(data), &t); err != nil {
return nil, fmt.Errorf("%s: %w", e.Name(), err)
}
t.ID = e.Name()
t.dir = dir
if t.TimeoutSec == 0 {
t.TimeoutSec = 240
}
tasks = append(tasks, t)
}
sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID })
return tasks, nil
}
func exitNoTasks(suite string) {
dir := filepath.Join(suite, "tasks")
if _, statErr := os.Stat(dir); statErr != nil {
@@ -508,7 +486,11 @@ func runSuite(cfg suiteConfig, tasks []task) []result {
continue
}
if cfg.trials > 1 {
results = append(results, runTrials(cfg, t, &total)...)
trials := runTrials(cfg, t, &total)
results = append(results, trials...)
if len(trials) > 0 && answerLeakDetected(trials[len(trials)-1]) {
return results
}
continue
}
var cumWallMs int64
@@ -521,6 +503,9 @@ func runSuite(cfg suiteConfig, tasks []task) []result {
}
total += r.PromptTokens + r.CompletionTokens
results = append(results, r)
if answerLeakDetected(r) {
return results
}
if r.Passed || (cfg.budget > 0 && total >= cfg.budget) {
break
}
@@ -548,12 +533,15 @@ func runTask(cfg suiteConfig, t task) result {
// to exist before the first child starts, and the digest below reads the
// last leg's file.
trajPath := ""
if cfg.trajDir != "" {
if err := os.MkdirAll(cfg.trajDir, 0o755); err != nil {
r.Note = "trajectory dir: " + err.Error()
return r
runTrajDir, dropTraj, err := answerTrajectoryDir(cfg.trajDir, t.answerRoot != "")
if err != nil {
r.Note = "trajectory dir: " + err.Error()
if t.answerRoot != "" {
markAnswerAuditIncomplete(&r, err)
}
return r
}
defer dropTraj()
work, err := taskWorkdir(cfg, t.ID)
if err != nil {
@@ -562,14 +550,14 @@ func runTask(cfg suiteConfig, t task) result {
}
defer os.RemoveAll(work)
if seed := filepath.Join(t.dir, "workdir"); dirExists(seed) {
if err := copyDir(seed, work); err != nil {
r.Note = "copy seed: " + err.Error()
return r
}
pinTapeTimes(cfg, work)
if err := copyTaskSeed(cfg, t, work); err != nil {
r.Note = "copy seed: " + err.Error()
return r
}
if err := stageAnswerIsolation(t.answerRoot, work); err != nil {
r.Note = "isolate answers: " + err.Error()
return r
}
extraEnv, dropState, seedNote := taskExperimentEnv(cfg, t, work)
defer dropState()
if seedNote != "" {
@@ -590,19 +578,23 @@ func runTask(cfg suiteConfig, t task) result {
startedAt := time.Now()
snap, dropSnapshots := attachSnapshotter(cfg, t, work, startedAt)
defer dropSnapshots()
runErr := runSegments(ctx, cfg, t, work, cfg.trajDir, extraEnv, &r)
runErr := runSegments(ctx, cfg, t, work, runTrajDir, extraEnv, &r)
r.WallMs = time.Since(startedAt).Milliseconds()
var taken []checkpoint
if snap != nil {
taken = snap.halt()
}
if err := removeAnswerIsolation(t.answerRoot, work); err != nil {
r.Note = "remove answer isolation config: " + err.Error()
return r
}
mtr.record(&r)
if trajPath = lastSegmentTrajectory(cfg.trajDir, t.ID, r.Segments); trajPath != "" {
if summary, err := summarizeTrajectory(trajPath); err == nil {
r.Trajectory = summary
}
applyMemoryStats(&r, trajPath, t)
if trajPath = lastSegmentTrajectory(runTrajDir, t.ID, r.Segments); trajPath != "" {
recordTaskTrajectory(&r, trajPath, t)
}
if !auditAnswerRun(&r, runTrajDir, t) {
return r
}
// A killed child never writes metrics, so the deadline is the only place
// this failure mode is still observable.
+60 -4
View File
@@ -59,12 +59,11 @@ func seedTaskMemory(taskDir, work, stateHome string) error {
// cleanup drops the root; note reports a failure without aborting the run.
func taskExperimentEnv(cfg suiteConfig, t task, work string) (env []string, cleanup func(), note string) {
cleanup = func() {}
if cfg.policy == "memory-off" {
memoryOff := cfg.policy == "memory-off"
if memoryOff {
env = append(env, "REASONIX_EXPERIMENT_NO_MEMORY=1")
}
// Named without the task id: a run that goes looking for its own name in
// the temp root must not find the directory holding its session either.
stateHome, err := os.MkdirTemp("", "e2ebench-state-")
stateHome, err := makeStateHome(work)
if err != nil {
return env, cleanup, "state root: " + err.Error()
}
@@ -79,12 +78,69 @@ func taskExperimentEnv(cfg suiteConfig, t task, work string) (env []string, clea
}
cleanup = func() { _ = os.RemoveAll(stateHome); _ = os.RemoveAll(tmpDir) }
env = append(env, "TMPDIR="+tmpDir)
// The off arm's treatment is that no memory exists. Hiding only the tool
// leaves the seeded files on disk for a shell command to read.
if memoryOff {
return env, cleanup, ""
}
if err := seedTaskMemory(t.dir, work, stateHome); err != nil {
return env, cleanup, "memory seed: " + err.Error()
}
return env, cleanup, ""
}
// benchStateBase is the directory every run's state root is made in. It is
// outside the temp root holding the workdirs: a sibling of the workdir is one
// `cd ..` and a find away from a run hunting for its seeded answers.
var benchStateBase = func() (string, error) {
cache, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(cache, "reasonix-bench-state"), nil
}
// makeStateHome creates one run's state root. Its name is random and carries
// neither the task id nor any part of the workdir's name, so a run searching
// the disk for its own name does not find the directory holding its session.
func makeStateHome(work string) (string, error) {
base, err := benchStateBase()
if err != nil {
return "", err
}
if err := os.MkdirAll(base, 0o700); err != nil {
return "", err
}
home, err := os.MkdirTemp(base, "")
if err != nil {
return "", err
}
if within(resolvedPath(filepath.Dir(work)), resolvedPath(home)) {
_ = os.RemoveAll(home)
return "", fmt.Errorf("%s is inside the workdir's parent %s", home, filepath.Dir(work))
}
return home, nil
}
func resolvedPath(p string) string {
if abs, err := filepath.Abs(p); err == nil {
p = abs
}
if resolved, err := filepath.EvalSymlinks(p); err == nil {
p = resolved
}
return p
}
// within reports whether path is dir or lies below it.
func within(dir, path string) bool {
rel, err := filepath.Rel(dir, path)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
// applyMemoryStats folds one trajectory's recall behavior into the result row.
func applyMemoryStats(r *result, trajPath string, t task) {
stats := scanMemoryRecall(trajPath, t.MemoryMarkers, t.MemoryMarkersPrefix)
+147 -1
View File
@@ -94,9 +94,10 @@ func TestSeedTaskMemoryBuildsIsolatedStateRoot(t *testing.T) {
// its work dir, and one observed run found exactly that by grepping the store
// for its own task id while looking for an answer it was not meant to have.
func TestTaskExperimentEnvIsolatesEveryRun(t *testing.T) {
isolateStateBase(t)
roots := map[string]bool{}
for _, id := range []string{"nosol-absent-oracle", "fix-add-bug"} {
env, drop, note := taskExperimentEnv(suiteConfig{}, task{ID: id, dir: testenv.TempDir(t)}, testenv.TempDir(t))
env, drop, note := taskExperimentEnv(suiteConfig{}, task{ID: id, dir: testenv.TempDir(t)}, nestedWorkdir(t))
defer drop()
if note != "" {
t.Fatalf("%s: %s", id, note)
@@ -131,3 +132,148 @@ func TestTaskExperimentEnvIsolatesEveryRun(t *testing.T) {
t.Fatalf("two tasks did not get four distinct roots: %v", roots)
}
}
// isolateStateBase points state roots at a directory of the test's own, so the
// suite never writes into the operator's user cache.
func isolateStateBase(t *testing.T) string {
t.Helper()
base := testenv.TempDir(t)
prev := benchStateBase
benchStateBase = func() (string, error) { return base, nil }
t.Cleanup(func() { benchStateBase = prev })
return base
}
// nestedWorkdir is a workdir whose parent is private to the test, so a state
// base made by isolateStateBase is not inside it.
func nestedWorkdir(t *testing.T) string {
t.Helper()
work := filepath.Join(testenv.TempDir(t), "work")
if err := os.MkdirAll(work, 0o755); err != nil {
t.Fatal(err)
}
return work
}
func stateHomeOf(env []string) string {
for _, e := range env {
if after, ok := strings.CutPrefix(e, "REASONIX_STATE_HOME="); ok {
return after
}
}
return ""
}
func seededTask(t *testing.T) task {
t.Helper()
dir := testenv.TempDir(t)
for _, seed := range []string{"project/fact.md", "global/pref.md"} {
p := filepath.Join(dir, "memory", seed)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("---\nname: x\ndescription: y\n---\n\nbody\n"), 0o644); err != nil {
t.Fatal(err)
}
}
return task{ID: "mb-exact", dir: dir}
}
func memoryFilesUnder(t *testing.T, root string) []string {
t.Helper()
var found []string
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.HasSuffix(p, ".md") {
found = append(found, p)
}
return nil
})
if err != nil {
t.Fatal(err)
}
return found
}
// The off arm's treatment is that no memory exists, not that the memory tool
// is hidden: a seeded store left on disk is one shell command away.
func TestMemoryOffArmSeedsNoMemory(t *testing.T) {
isolateStateBase(t)
tk := seededTask(t)
env, drop, note := taskExperimentEnv(suiteConfig{policy: "memory-off"}, tk, nestedWorkdir(t))
defer drop()
if note != "" {
t.Fatal(note)
}
home := stateHomeOf(env)
if home == "" {
t.Fatal("memory-off arm ran against the operator's own store root")
}
if files := memoryFilesUnder(t, home); len(files) != 0 {
t.Fatalf("memory-off state root holds seeded memory: %v", files)
}
onEnv, onDrop, onNote := taskExperimentEnv(suiteConfig{}, tk, nestedWorkdir(t))
defer onDrop()
if onNote != "" {
t.Fatal(onNote)
}
if files := memoryFilesUnder(t, stateHomeOf(onEnv)); len(files) != 2 {
t.Fatalf("memory-on arm must carry both seeds, got %v", files)
}
}
// The state root must not sit beside the workdir: a run that walks up from its
// workspace and searches the temp root would otherwise reach the seeded store.
func TestStateHomeIsOutsideWorkdirParent(t *testing.T) {
work, err := taskWorkdir(suiteConfig{}, "mb-exact")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(work) })
env, drop, note := taskExperimentEnv(suiteConfig{}, seededTask(t), work)
defer drop()
if note != "" {
t.Fatal(note)
}
home := stateHomeOf(env)
if home == "" {
t.Fatal("no state root")
}
if within(resolvedPath(filepath.Dir(work)), resolvedPath(home)) {
t.Fatalf("state root %s is inside the workdir's parent %s", home, filepath.Dir(work))
}
if strings.Contains(filepath.Base(home), "e2ebench") || strings.Contains(home, "mb-exact") {
t.Errorf("state root %s is named after the workdir or task", home)
}
}
func TestMakeStateHomeRefusesWorkdirParent(t *testing.T) {
parent := testenv.TempDir(t)
prev := benchStateBase
benchStateBase = func() (string, error) { return filepath.Join(parent, "state"), nil }
t.Cleanup(func() { benchStateBase = prev })
if home, err := makeStateHome(filepath.Join(parent, "work")); err == nil {
t.Fatalf("state root %s accepted beside the workdir", home)
}
}
func TestAbsTrajectoryDirAnchorsRelativePaths(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if got, want := absTrajectoryDir("t-off"), filepath.Join(cwd, "t-off"); got != want {
t.Fatalf("relative -trajectories resolved to %q, want %q", got, want)
}
abs := filepath.Join(testenv.TempDir(t), "t-on")
if got := absTrajectoryDir(abs); got != abs {
t.Fatalf("absolute -trajectories changed: %q", got)
}
if got := absTrajectoryDir(""); got != "" {
t.Fatalf("unset -trajectories became %q", got)
}
}
+132
View File
@@ -0,0 +1,132 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
// witness is one memorybench task's correct answer, as a set of files to write
// into a staged seed. Each answer is derived from the task's own facts, never
// from verify.sh: an expected value read out of the grader accepts whatever the
// grader accepts, which is the loop this table exists to break.
type witness struct {
// from names the fact the answer comes from, never the grader.
from string
files map[string]string
}
// memorybenchWitness holds the other half of the authoring rule corpus_test.go
// states: "One that can never pass is indistinguishable from a task with no
// solution." These tasks ship no solution/, so the reference-solution test skips
// them and nothing else asserts that their graders accept a correct answer.
var memorybenchWitness = map[string]witness{
"mb-cjk": {
from: "memory/project/migration-naming.md: migrations are `MEMKEY-CJK5T-<description>.sql` at the repo root",
files: map[string]string{"MEMKEY-CJK5T-add-users.sql": ""},
},
"mb-conflict": {
from: "memory/project/deploy-region.md: this project deploys to eu-central-1, overriding the global us-east-1",
files: map[string]string{"answer.txt": "eu-central-1\n"},
},
"mb-contradiction": {
from: "memory/project/dependency-tooling.md (migrated to pnpm) and the seeded pnpm-lock.yaml",
files: map[string]string{"answer.txt": "pnpm install\n"},
},
"mb-distractor": {
from: "memory/project/fast-check-command.md among the distractors",
files: map[string]string{"answer.txt": "make check-fast --tag=MEMKEY-DIST5Q\n"},
},
"mb-exact": {
from: "memory/project/fast-check-command.md",
files: map[string]string{"answer.txt": "make check-fast --tag=MEMKEY-EXACT7Q\n"},
},
"mb-generic": {
from: "the prompt, plus the seeded util.py that already defines sub",
files: map[string]string{"util.py": "def sub(a, b):\n return a - b\n\n\ndef add(a, b):\n return a + b\n"},
},
"mb-history": {
from: "the seeded config/server.conf, whose line the prompt says to copy unchanged",
files: map[string]string{"answer.txt": "timeout_seconds = 47 # MEMKEY-HIST6M tuned for p99\n"},
},
"mb-paraphrase": {
from: "memory/project/package-manager.md",
files: map[string]string{"answer.txt": "pnpm install --tag=MEMKEY-PARA3X\n"},
},
"mb-pin": {
from: "memory/project/file-header-rule.md: a new text file's first line must be the stamp",
files: map[string]string{"notes.txt": "# MEMKEY-PIN4D\nhello\n"},
},
"mb-stale": {
from: "the seeded RELEASE file, which supersedes the branch memory still names",
files: map[string]string{"answer.txt": "release/1.21\n"},
},
"mb-symbol": {
from: "memory/project/retry-config-location.md: the key lives in policy/retry.toml, never .env",
files: map[string]string{"policy/retry.toml": "# MEMKEY-SYM9K policy file\nmax_backoff_ms = 250\n"},
},
"mb-update": {
from: "memory/project/api-base.md: the v2 host replaced the dead v1 host",
files: map[string]string{"answer.txt": "https://api.v2.example\n"},
},
"mb-v1miss-cjk-short": {
from: "memory/project/canary-ratio.md",
files: map[string]string{"answer.txt": "7%\n"},
},
"mb-v1miss-crosslang": {
from: "memory/project/deploy-approver.md",
files: map[string]string{"answer.txt": "release-captain-zhao\n"},
},
"mb-v1miss-symbol": {
from: "memory/project/idle-conn-config.md: the key lives in conf/server.toml, never env",
files: map[string]string{"conf/server.toml": "# MEMKEY-VM1SYM\nmax_idle_conn_secs = 30\n"},
},
}
// A grader that rejects a correct answer scores every honest run as a failure,
// so a corpus of them teaches that nothing is ever accepted. Every memorybench
// task ships memory/ facts instead of a solution/, so the witness table above
// is the only reference answer available.
func TestMemorybenchGradersAcceptTheReferenceAnswer(t *testing.T) {
for _, bin := range []string{"bash", "python3"} {
if _, err := exec.LookPath(bin); err != nil {
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
}
}
tasks, err := loadTasks(memorybenchDir)
if err != nil {
t.Fatalf("load corpus: %v", err)
}
if len(tasks) == 0 {
t.Fatal("no memorybench tasks found")
}
for _, task := range tasks {
w, ok := memorybenchWitness[task.ID]
if !ok {
t.Errorf("%s has no witness: give it a correct answer derived from its own facts", task.ID)
continue
}
t.Run(task.ID, func(t *testing.T) {
t.Parallel()
work := stageSeed(t, task.dir)
for rel, content := range w.files {
path := filepath.Join(work, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir for %s: %v", rel, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
if err := gradeSeed(t, work); err != nil {
t.Fatalf("the grader rejects the answer its own facts imply (%s): %v", w.from, err)
}
})
}
for id := range memorybenchWitness {
if _, err := os.Stat(filepath.Join(memorybenchDir, "tasks", id)); err != nil {
t.Errorf("witness for %s has no task", id)
}
}
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"github.com/BurntSushi/toml"
fileencoding "reasonix/internal/base/fileutil/encoding"
)
func loadTasks(suite string) ([]task, error) {
tasksDir := filepath.Join(suite, "tasks")
entries, err := os.ReadDir(tasksDir)
if err != nil {
return nil, err
}
var tasks []task
hasAnswerMaterial := false
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(tasksDir, e.Name())
var t task
data, err := fileencoding.ReadFileUTF8(filepath.Join(dir, "task.toml"))
if err != nil {
return nil, fmt.Errorf("%s: %w", e.Name(), err)
}
if _, err := toml.Decode(string(data), &t); err != nil {
return nil, fmt.Errorf("%s: %w", e.Name(), err)
}
t.ID = e.Name()
t.dir = dir
if dirExists(filepath.Join(dir, "memory")) {
hasAnswerMaterial = true
}
if t.TimeoutSec == 0 {
t.TimeoutSec = 240
}
tasks = append(tasks, t)
}
if hasAnswerMaterial {
for i := range tasks {
tasks[i].answerRoot = tasksDir
}
}
sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID })
return tasks, nil
}
func copyTaskSeed(cfg suiteConfig, t task, work string) error {
seed := filepath.Join(t.dir, "workdir")
if !dirExists(seed) {
return nil
}
if err := copyDir(seed, work); err != nil {
return err
}
pinTapeTimes(cfg, work)
return nil
}
+13
View File
@@ -89,3 +89,16 @@ func dispatchOfflineMode(mode string, a offlineModeArgs) bool {
}
return true
}
// absTrajectoryDir anchors -trajectories to the operator's cwd. Each task's
// child runs with the task workdir as its cwd, so a relative value would name
// a directory under every workdir instead of the one the operator meant.
func absTrajectoryDir(dir string) string {
if dir == "" {
return ""
}
if abs, err := filepath.Abs(dir); err == nil {
return abs
}
return dir
}
+3
View File
@@ -22,6 +22,9 @@ func runTrials(cfg suiteConfig, t task, total *int) []result {
r.Trial = trial
*total += r.PromptTokens + r.CompletionTokens
out = append(out, r)
if answerLeakDetected(r) {
break
}
}
return out
}
+8
View File
@@ -282,6 +282,11 @@ func resolveKernelLanguage(cfg *config.Config) string {
// assemble builds the hub this host serves: one pane on the workspace it was
// launched in, carrying the capabilities a local window may exercise.
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS) (*serve.Hub, error) {
// The one-time upgrades belong to whichever entry point starts first; a
// person who only ever opens the window would otherwise never get them.
if _, err := config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath()); err != nil {
fmt.Fprintln(logs, "reasonix-studio-host: config upgrade:", err)
}
cfg, err := config.Load()
if err != nil {
return nil, err
@@ -461,4 +466,7 @@ func grantHostCapabilities(srv *serve.Server) {
srv.AllowAccountAuth()
srv.AllowProviderEdit()
srv.AllowLocalDesktop()
if telemetry.ReleaseVersion(version) {
srv.AllowMarketInstallReport()
}
}
+25
View File
@@ -471,3 +471,28 @@ type stubTray struct{}
func (stubTray) IconLive() bool { return false }
func (stubTray) TrayFold() traystate.State { return traystate.State{} }
func (stubTray) ApplyTrayPrefs(serve.TrayPrefs) {}
// A person who only ever opens the window still gets the one-time upgrades:
// the shipped "auto" a v6 file carries is released before the first pane is
// built, so that pane opens in the default the sandbox and trust decide.
func TestAssembleAppliesTheStartupUpgrades(t *testing.T) {
// Its own home: another test's launch already recorded the release in the
// package's shared one, and a recorded release is not run again.
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
path := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("config_version = 6\n[desktop]\ndefault_tool_approval_mode = \"auto\"\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Chdir(testenv.TempDir(t))
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil)
if err != nil {
t.Fatalf("assemble: %v", err)
}
defer hub.Shutdown()
if got := config.LoadForEdit(path).DesktopDefaultToolApprovalMode(); got != "" {
t.Fatalf("after the window started, the desktop posture is %q, want the shipped auto released", got)
}
}
+306
View File
@@ -0,0 +1,306 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
var cliChannelSecrets = []string{"STUDIO_NPM_TOKEN", "STUDIO_HOMEBREW_TAP_TOKEN"}
// The 1.x line reads these repository secrets; studio must never touch them.
var legacyRegistrySecrets = []string{"NPM_TOKEN", "HOMEBREW_TAP_TOKEN"}
func parseWorkflowFile(t *testing.T, path string) *yaml.Node {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var document yaml.Node
if err := yaml.Unmarshal(data, &document); err != nil {
t.Fatalf("%s: %v", path, err)
}
if len(document.Content) != 1 {
t.Fatalf("%s: not a single YAML document", path)
}
return document.Content[0]
}
func jobsOf(t *testing.T, root *yaml.Node) *yaml.Node {
t.Helper()
jobs := mappingValue(root, "jobs")
if jobs == nil || jobs.Kind != yaml.MappingNode {
t.Fatal("workflow has no jobs mapping")
}
return jobs
}
func permissionsIssue(node *yaml.Node) string {
if node == nil {
return ""
}
if node.Kind != yaml.MappingNode {
return "permissions is a " + node.Value + " shorthand, not a mapping"
}
return ""
}
// secretsRefs reports every way a workflow can reach a named secret: dotted
// and subscripted access, and the whole-context forms that expose them all.
var secretsWholeContext = regexp.MustCompile(`\bsecrets\b\s*(?:[^.\[\w\s]|$)|toJSON\(\s*secrets\s*\)|\bsecrets\s*\[\s*[^'"\s]`)
func secretRefPattern(name string) *regexp.Regexp {
return regexp.MustCompile(`\bsecrets\s*(?:\.\s*` + name + `\b|\[\s*['"]` + name + `['"]\s*\])`)
}
func scalarsOf(node *yaml.Node, out *[]string) {
if node == nil {
return
}
if node.Kind == yaml.ScalarNode {
*out = append(*out, node.Value)
}
for _, child := range node.Content {
scalarsOf(child, out)
}
}
func refersTo(node *yaml.Node, names []string) (string, bool) {
var scalars []string
scalarsOf(node, &scalars)
for _, text := range scalars {
for _, name := range names {
if secretRefPattern(name).MatchString(text) {
return name, true
}
}
if secretsWholeContext.MatchString(text) && regexp.MustCompile(`\$\{\{`).MatchString(text) {
return "the whole secrets context", true
}
}
return "", false
}
func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
jobs := jobsOf(t, root)
job := mappingValue(jobs, "cli-channels")
if job == nil {
t.Fatal("cli-channels job is missing")
}
if got := mappingScalar(job, "environment"); got != "studio-cli-channels" {
t.Fatalf("cli-channels environment = %q, want studio-cli-channels", got)
}
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
t.Fatalf("cli-channels if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
}
permissions := mappingValue(job, "permissions")
if permissions == nil || permissions.Kind != yaml.MappingNode {
t.Fatal("cli-channels must declare its permissions as a mapping")
}
want := map[string]string{"contents": "read", "id-token": "write"}
if len(permissions.Content) != 2*len(want) {
t.Fatalf("cli-channels permissions has %d entries, want exactly %v", len(permissions.Content)/2, want)
}
for scope, level := range want {
if got := mappingScalar(permissions, scope); got != level {
t.Fatalf("cli-channels permission %s = %q, want %q", scope, got, level)
}
}
if !nodeContains(job, "environments/studio-cli-channels") || !nodeContains(job, "required_reviewers") {
t.Fatal("cli-channels must verify the environment's required reviewers before publishing")
}
assertGateBeforePublishing(t, job)
for _, name := range cliChannelSecrets {
if !secretRefPattern(name).MatchString(scalarString(job)) {
t.Fatalf("cli-channels does not read secrets.%s", name)
}
}
if name, found := refersTo(job, legacyRegistrySecrets); found {
t.Fatalf("cli-channels reads %s; it must use the environment-only STUDIO_* names", name)
}
}
func scalarString(node *yaml.Node) string {
var scalars []string
scalarsOf(node, &scalars)
return strings.Join(scalars, "\n")
}
func TestNoWorkflowGrantsIDTokenOrBroadPermissionsOutsideCLIChannels(t *testing.T) {
files, err := filepath.Glob("../../.github/workflows/*.yml")
if err != nil || len(files) == 0 {
t.Fatalf("no workflows found: %v", err)
}
for _, file := range files {
root := parseWorkflowFile(t, file)
if top := mappingValue(root, "permissions"); top != nil {
if msg := permissionsIssue(top); msg != "" {
t.Errorf("%s: workflow-level %s", file, msg)
} else if mappingScalar(top, "id-token") != "" {
t.Errorf("%s: id-token at workflow level", file)
}
}
jobs := mappingValue(root, "jobs")
if jobs == nil {
continue
}
for i := 0; i+1 < len(jobs.Content); i += 2 {
name, job := jobs.Content[i].Value, jobs.Content[i+1]
permissions := mappingValue(job, "permissions")
if permissions == nil {
continue
}
if msg := permissionsIssue(permissions); msg != "" {
t.Errorf("%s job %s: %s", file, name, msg)
continue
}
if mappingScalar(permissions, "id-token") != "" && !(filepath.Base(file) == "release-studio.yml" && name == "cli-channels") {
t.Errorf("%s job %s requests id-token; only release-studio.yml cli-channels may", file, name)
}
}
}
}
func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
files, err := filepath.Glob("../../.github/workflows/*.yml")
if err != nil || len(files) == 0 {
t.Fatalf("no workflows found: %v", err)
}
guarded := append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...)
for _, file := range files {
root := parseWorkflowFile(t, file)
jobs := mappingValue(root, "jobs")
if jobs == nil {
continue
}
if filepath.Base(file) == "release-studio.yml" {
for i := 0; i+1 < len(jobs.Content); i += 2 {
name, job := jobs.Content[i].Value, jobs.Content[i+1]
if name == "cli-channels" {
continue
}
if what, found := refersTo(job, guarded); found {
t.Errorf("release-studio.yml job %s reaches %s; only cli-channels may", name, what)
}
}
if what, found := refersTo(mappingValue(root, "env"), guarded); found {
t.Errorf("release-studio.yml workflow env reaches %s", what)
}
continue
}
if name, found := refersTo(root, cliChannelSecrets); found {
t.Errorf("%s reaches %s, which belongs to studio's cli-channels", file, name)
}
}
}
func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
for _, text := range []string{
"${{ secrets.STUDIO_NPM_TOKEN }}",
"${{ secrets['STUDIO_NPM_TOKEN'] }}",
`${{ secrets["STUDIO_NPM_TOKEN"] }}`,
"${{ toJSON(secrets) }}",
"${{ secrets[format('STUDIO_{0}', 'NPM_TOKEN')] }}",
} {
var doc yaml.Node
if err := yaml.Unmarshal([]byte("run: \""+regexp.MustCompile(`"`).ReplaceAllString(text, `\"`)+"\"\n"), &doc); err != nil {
t.Fatal(err)
}
if _, found := refersTo(doc.Content[0], cliChannelSecrets); !found {
t.Errorf("reference form not detected: %s", text)
}
}
var doc yaml.Node
if err := yaml.Unmarshal([]byte("run: echo ${{ secrets.R2_BUCKET }}\n"), &doc); err != nil {
t.Fatal(err)
}
if _, found := refersTo(doc.Content[0], cliChannelSecrets); found {
t.Error("an unrelated secret was reported")
}
}
func assertGateBeforePublishing(t *testing.T, job *yaml.Node) {
t.Helper()
steps := mappingValue(job, "steps")
if steps == nil || steps.Kind != yaml.SequenceNode {
t.Fatal("cli-channels has no steps")
}
gate := -1
for i, step := range steps.Content {
if nodeContains(mappingValue(step, "run"), "environments/studio-cli-channels") {
gate = i
break
}
}
if gate < 0 {
t.Fatal("cli-channels has no environment check step")
}
for i, step := range steps.Content[:gate] {
if run := mappingScalar(step, "run"); run != "" || mappingValue(step, "env") != nil {
t.Errorf("step %d runs or gets credentials before the environment check", i)
}
}
for i, step := range steps.Content {
if i > gate {
continue
}
for _, name := range append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...) {
if nodeContains(step, name) {
t.Errorf("step %d references %s before or at the environment check", i, name)
}
}
}
}
func TestEnvironmentCheckStepFailsClosed(t *testing.T) {
bash, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash not available")
}
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
job := mappingValue(jobsOf(t, root), "cli-channels")
var script string
for _, step := range mappingValue(job, "steps").Content {
if run := mappingScalar(step, "run"); strings.Contains(run, "environments/studio-cli-channels") {
script = run
break
}
}
if script == "" {
t.Fatal("environment check step not found")
}
reviewers := `{"type":"required_reviewers","reviewers":[{"a":1}]}`
cases := []struct {
name, response string
pass bool
}{
{"one reviewer", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[" + reviewers + "]}", true},
{"no rules", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}", false},
{"two JSON documents", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}{\"protection_rules\":[]}", false},
{"empty body", "HTTP/2.0 200 OK\n\n", false},
{"non-numeric body", "HTTP/2.0 200 OK\n\n\"x\"", false},
{"missing environment", "HTTP/2.0 404 Not Found\n\n{\"message\":\"Not Found\"}", false},
{"server error", "HTTP/2.0 500 Error\n\n{}", false},
}
for _, tc := range cases {
dir := t.TempDir()
fake := "#!/bin/sh\ncat <<'EOF'\n" + tc.response + "\nEOF\n"
if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(fake), 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command(bash, "-c", script+"\necho PASSED")
cmd.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "REPO=x/y", "GH_TOKEN=x", "GITHUB_ACTOR=t")
out, err := cmd.CombinedOutput()
passed := err == nil && strings.Contains(string(out), "PASSED")
if passed != tc.pass {
t.Errorf("%s: passed=%v, want %v\n%s", tc.name, passed, tc.pass, out)
}
}
}
+2 -2
View File
@@ -29,13 +29,13 @@ func TestManifestListsEachSignedDeltaFromTheMirror(t *testing.T) {
if err := os.WriteFile(index, []byte("index bytes"), 0o644); err != nil {
t.Fatal(err)
}
if err := run(dir, "v0.1.0", "studio-v0.1.0", deltaDir); err == nil {
if err := run(dir, "v0.1.0", "studio-v0.1.0", deltaDir, true); err == nil {
t.Fatal("an unsigned delta index was published")
}
if err := os.WriteFile(index+".minisig", []byte("sig"), 0o644); err != nil {
t.Fatal(err)
}
if err := run(dir, "v0.1.0", "studio-v0.1.0", deltaDir); err != nil {
if err := run(dir, "v0.1.0", "studio-v0.1.0", deltaDir, true); err != nil {
t.Fatalf("run: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
+22 -4
View File
@@ -31,13 +31,16 @@ func main() {
if len(os.Args) == 5 {
deltaDir = os.Args[4]
}
if err := run(os.Args[1], os.Args[2], os.Args[3], deltaDir); err != nil {
// HAS_R2 is the release workflow's own gate on uploading the mirror, so the
// manifest names mirror addresses exactly when that upload will run.
mirrored := os.Getenv("HAS_R2") == "true"
if err := run(os.Args[1], os.Args[2], os.Args[3], deltaDir, mirrored); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(dir, version, tag, deltaDir string) error {
func run(dir, version, tag, deltaDir string, mirrored bool) error {
repo := os.Getenv("GITHUB_REPOSITORY")
if strings.TrimSpace(repo) == "" {
return fmt.Errorf("studio-manifest: GITHUB_REPOSITORY is unset, so asset URLs cannot be built")
@@ -66,8 +69,8 @@ func run(dir, version, tag, deltaDir string) error {
if err != nil {
return err
}
url := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", repo, tag, name)
asset := update.Asset{URL: url, Sig: url + ".minisig", Size: size, SHA256: sum}
asset := releaseAsset(repo, tag, name, mirrored)
asset.Size, asset.SHA256 = size, sum
// Downloads is what a person is offered, so it lists what installs
// itself. A portable archive stays a release asset, but offering it
// beside the installer only asks the reader to choose blind.
@@ -111,6 +114,21 @@ func run(dir, version, tag, deltaDir string) error {
return os.WriteFile(filepath.Join(dir, "latest.json"), append(b, '\n'), 0o644)
}
// releaseAsset addresses one artifact. The mirror serves it first when there is
// one: GitHub's release CDN is what a CN route reaches slowest, and it stays as
// the fallback for a mirror that is down.
func releaseAsset(repo, tag, name string, mirrored bool) update.Asset {
github := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", repo, tag, name)
if !mirrored {
return update.Asset{URL: github, Sig: github + ".minisig"}
}
mirror := fmt.Sprintf("%s/%s/%s", update.StudioMirror, tag, name)
return update.Asset{
URL: mirror, Sig: mirror + ".minisig",
Fallback: github, FallbackSig: github + ".minisig",
}
}
// installable reports whether an artifact installs itself rather than expecting
// the reader to place it: a disk image to drag from, an installer to run, a
// package for dpkg.
+60 -3
View File
@@ -30,7 +30,7 @@ func TestManifestRecordsEveryArtifactWithItsSignature(t *testing.T) {
}
}
if err := run(dir, "v0.1.0", "studio-v0.1.0", ""); err != nil {
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", false); err != nil {
t.Fatalf("run: %v", err)
}
@@ -85,7 +85,7 @@ func TestManifestResolvesOnlyWhatCanInstallItself(t *testing.T) {
t.Fatal(err)
}
}
if err := run(dir, "v0.1.0", "studio-v0.1.0", ""); err != nil {
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", false); err != nil {
t.Fatalf("run: %v", err)
}
var m update.Manifest
@@ -128,7 +128,64 @@ func TestManifestResolvesOnlyWhatCanInstallItself(t *testing.T) {
func TestEmptyDirIsAFailedRelease(t *testing.T) {
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
if err := run(t.TempDir(), "v0.1.0", "studio-v0.1.0", ""); err == nil {
if err := run(t.TempDir(), "v0.1.0", "studio-v0.1.0", "", false); err == nil {
t.Fatal("a release with no artifacts must fail, not publish an empty manifest")
}
}
// A mirrored release is fetched from the mirror first, with GitHub kept as the
// address a client falls back to; every full artifact and its signature alike.
func TestMirroredManifestPutsTheMirrorFirst(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
for _, name := range []string{
"ReasonixStudio-windows-amd64-installer.exe",
"ReasonixStudio-darwin-universal.zip",
"ReasonixStudio-linux-amd64.deb",
} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", true); err != nil {
t.Fatalf("run: %v", err)
}
var m update.Manifest
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
var all []update.Asset
for _, group := range []map[string]update.Asset{m.Platforms, m.Downloads, m.NativePackages} {
for _, a := range group {
all = append(all, a)
}
}
if len(all) != 6 {
t.Fatalf("assets = %d, want 3 platforms, 2 downloads and 1 package", len(all))
}
for _, a := range all {
name := a.URL[strings.LastIndex(a.URL, "/")+1:]
mirror := update.StudioMirror + "/studio-v0.1.0/" + name
github := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/studio-v0.1.0/" + name
if a.URL != mirror || a.Sig != mirror+".minisig" {
t.Errorf("%s: primary = %q / %q, want the mirror", name, a.URL, a.Sig)
}
if a.Fallback != github || a.FallbackSig != github+".minisig" {
t.Errorf("%s: fallback = %q / %q, want the GitHub release", name, a.Fallback, a.FallbackSig)
}
}
if !strings.HasPrefix(m.DownloadPage, "https://github.com/") {
t.Errorf("download page = %q, want the GitHub release page", m.DownloadPage)
}
}
func TestUnmirroredManifestNamesOnlyGitHub(t *testing.T) {
a := releaseAsset("o/r", "studio-v1", "ReasonixStudio-linux-amd64.deb", false)
if !strings.HasPrefix(a.URL, "https://github.com/o/r/") || a.Fallback != "" || a.FallbackSig != "" {
t.Fatalf("asset = %+v, want GitHub alone when nothing is mirrored", a)
}
}
+1
View File
@@ -15,6 +15,7 @@
"build:dist": "electron-builder",
"smoke": "electron test/smoke.js",
"browser-live": "electron test/browser_live.js",
"freeze-live": "electron test/freeze_live.js",
"computer-live": "electron test/computer_live.js"
},
"devDependencies": {
+28
View File
@@ -29,6 +29,9 @@ class BrowserViews {
// for this run only: a restart, or a different certificate, asks again.
this.trusted = new Set();
this.shown = "";
// Moves with every show and hide, so a freeze that finishes after one does
// not put away a page shown since.
this.placed = 0;
win.on("resize", () => {
for (const [id, entry] of this.entries) if (id !== this.shown) this.putAway(entry.view);
});
@@ -142,6 +145,7 @@ class BrowserViews {
// re-adding the view and setting the bounds it already has is work for
// every one of those.
if (this.shown === targetId && sameRect(entry.at, at)) return;
this.placed++;
for (const [id, other] of this.entries) {
if (id !== targetId) this.putAway(other.view, other);
}
@@ -160,7 +164,31 @@ class BrowserViews {
}
}
// freeze answers with a picture of the page on screen, taken before it is put
// away, for the window to draw in its place while something of its own is
// over it. Only the page already shown to the person is ever taken, and the
// picture goes to this window's page and nowhere else.
async freeze() {
const id = this.shown;
const entry = this.entries.get(id);
if (!entry || entry.view.webContents.isDestroyed()) {
this.hide();
return "";
}
const turn = this.placed;
let picture = "";
try {
const image = await entry.view.webContents.capturePage();
if (!image.isEmpty()) picture = "data:image/jpeg;base64," + image.toJPEG(85).toString("base64");
} catch {
picture = "";
}
if (this.placed === turn && this.shown === id) this.hide();
return picture;
}
hide() {
this.placed++;
this.shown = "";
for (const entry of this.entries.values()) this.putAway(entry.view, entry);
}
+4 -1
View File
@@ -201,7 +201,7 @@ function onWindowClose(event) {
// showWindow brings it back from wherever it went. Show alone is a no-op on a
// window that is merely buried, so the focus is what actually raises it.
function showWindow() {
if (!win || win.isDestroyed()) return;
if (quitting || !win || win.isDestroyed()) return;
if (win.isMinimized()) win.restore();
reload?.revive();
win.show();
@@ -284,6 +284,7 @@ ipcMain.handle("browser:show", (event, targetId, rect) => {
ipcMain.handle("browser:hide", (event) => {
if (fromWindow(event)) browserViews?.hide();
});
ipcMain.handle("browser:freeze", (event) => (fromWindow(event) ? (browserViews?.freeze() ?? "") : ""));
ipcMain.handle("browser:control", (event, targetId, action) => {
if (fromWindow(event)) browserViews?.control(String(targetId), String(action));
});
@@ -363,6 +364,8 @@ if (!primary) {
app.quit();
} else {
app.on("second-instance", showWindow);
// macOS reopens an existing app through activation, without a second process.
app.on("activate", showWindow);
grants = stripPackageGrants(hostBinary, { ...where, execPath: process.execPath });
if (grants?.stripped.length) {
const note = `removed app-package grants that stop sandboxed processes loading: ${grants.stripped.join(", ")}`;
+1
View File
@@ -45,6 +45,7 @@ contextBridge.exposeInMainWorld("reasonixHost", {
// page, and the few controls a person has over a page they are watching.
showBrowserView: (targetId, rect) => ipcRenderer.invoke("browser:show", String(targetId), rectOf(rect)),
hideBrowserView: () => ipcRenderer.invoke("browser:hide"),
freezeBrowserView: () => ipcRenderer.invoke("browser:freeze"),
controlBrowserView: (targetId, action) => ipcRenderer.invoke("browser:control", String(targetId), String(action)),
navigateBrowserView: (targetId, address) => ipcRenderer.invoke("browser:navigate", String(targetId), String(address)),
// Why a page did not load, and a login a page or proxy asks for. The password
+56
View File
@@ -0,0 +1,56 @@
"use strict";
const { app, BrowserWindow } = require("electron");
const { until } = require("./livekit");
async function activationChecks(win, current, check) {
const { client, origin, tray } = current();
const contents = win.webContents;
await contents.executeJavaScript("window.__activationProbe = 'retained'");
for (const source of ["activate", "second-instance", "tray"]) {
app.focus({ steal: true });
const reopen = () => source === "tray" ? tray.icon.emit("click") : app.emit(source);
win.hide();
reopen();
check(`${source} shows the hidden window`, win.isVisible());
// Continue even on a missing handler so each entry point is weighed.
win.show();
win.minimize();
await until("window minimization", () => win.isMinimized());
reopen();
check(`${source} restores the minimized window`, await until("reactivation", () => !win.isMinimized() && win.isVisible(), 5000).then(() => true, () => false));
win.restore();
await until("window restoration", () => !win.isMinimized());
const cover = new BrowserWindow({ width: 300, height: 200, show: true });
try {
await cover.loadURL("data:text/html,<title>Activation test</title>");
app.focus({ steal: true });
cover.focus();
await until("cover window focus", () => cover.isFocused());
reopen();
check(`${source} focuses the existing window`, await until("reactivation focus", () => win.isFocused(), 5000).then(() => true, () => false));
reopen();
reopen();
} finally {
cover.destroy();
}
check(`${source} reuses one window and renderer`,
BrowserWindow.getAllWindows().length === 1 && current().win === win && win.webContents === contents);
check(`${source} preserves in-memory renderer state`,
await contents.executeJavaScript("window.__activationProbe") === "retained");
check(`${source} preserves the running host`,
current().client === client && current().origin === origin && !!(await client.trayPrefs()));
}
await contents.executeJavaScript("delete window.__activationProbe");
app.once("before-quit", () => {
win.hide();
for (const source of ["activate", "second-instance"]) {
app.emit(source);
check(`${source} cannot reopen a quitting window`, !win.isVisible());
}
});
}
module.exports = { activationChecks };
+55
View File
@@ -0,0 +1,55 @@
"use strict";
// Freezes a real page in the real shell: the picture the window draws while its
// own layer covers the page must be that page, taken before it is put away.
// Run with `pnpm freeze-live`; it needs no kernel and no network.
const { app, BrowserWindow, nativeImage } = require("electron");
const { BrowserViews } = require("../src/browserviews");
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const fails = [];
const check = (name, ok, got) => {
console.log((ok ? "ok " : "FAIL ") + name + (ok ? "" : " — " + JSON.stringify(got)));
if (!ok) fails.push(name);
};
app.whenReady().then(async () => {
const win = new BrowserWindow({ width: 900, height: 700, show: true, webPreferences: { sandbox: true } });
await win.loadURL("data:text/html,<body style='margin:0;background:%23eee'><h3>studio page</h3></body>");
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
let loaded;
const ready = new Promise((r) => (loaded = r));
const page = views.create({
partition: "freeze-live",
url: "data:text/html,<body style='margin:0;background:%23c0392b;color:white;font:40px sans-serif'><p>guest page</p></body>",
onEvent: (method) => method === "Page.loadEventFired" && loaded(),
onClosed() {},
onPopup() {},
onDownload() {},
});
await page.send("Page.enable");
await Promise.race([ready, wait(3000)]);
const rect = { x: 100, y: 120, width: 600, height: 400 };
views.show(page.targetId, rect);
await wait(600);
const view = views.entries.get(page.targetId).view;
check("shown at the panel's rectangle", JSON.stringify(view.getBounds()) === JSON.stringify(rect), view.getBounds());
const picture = await views.freeze();
check("freeze answers with a jpeg of the page", picture.startsWith("data:image/jpeg;base64,") && picture.length > 1000, picture.slice(0, 40));
const b = view.getBounds();
check("the page is put away after the picture is taken", b.x < 0 && views.shown === "", { b, shown: views.shown });
const img = nativeImage.createFromDataURL(picture);
const bmp = img.toBitmap();
const size = img.getSize();
const mid = ((size.height >> 1) * size.width + (size.width >> 1)) * 4;
check("the picture is the guest page's own pixels", bmp[mid + 2] > 150 && bmp[mid + 1] < 100, [bmp[mid + 2], bmp[mid + 1], bmp[mid]]);
// A show that lands while a freeze is still taking its picture wins.
views.show(page.targetId, rect);
await wait(300);
const racing = views.freeze();
views.show(page.targetId, { ...rect, width: 601 });
await racing;
check("a freeze overtaken by a show leaves the page shown", views.shown === page.targetId && view.getBounds().x === 100, { shown: views.shown, b: view.getBounds() });
views.hide();
check("with nothing shown, freeze answers with no picture", (await views.freeze()) === "", "");
page.close();
console.log(fails.length ? `${fails.length} failed` : "all checks passed");
app.exit(fails.length ? 1 : 0);
});
+13 -5
View File
@@ -14,6 +14,10 @@ const { app, BrowserWindow, Menu } = require("electron");
// it. A home that already carries a configuration is left exactly as it is.
seedHome(process.env.REASONIX_HOME);
const { current } = require("../src/main.js");
const { activationChecks } = require("./activation.js");
// macOS can activate the app before the host has supplied its window.
if (process.platform === "darwin") app.emit("activate");
function seedHome(home) {
if (!home || fs.existsSync(path.join(home, "config.toml"))) return;
@@ -80,7 +84,7 @@ async function run() {
placed: typeof window.reasonixHost?.pathForFile(new File(['x'], 'x.txt')),
}))()`);
const verbs = ["closeWindow", "controlBrowserView", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "openExternal", "pathForFile", "pickFolder", "platform", "revealPath", "saveBytes", "saveText", "shell", "showBrowserView", "titleBar", "toggleMaximiseWindow"];
const verbs = ["answerBrowserLogin", "closeWindow", "controlBrowserView", "freezeBrowserView", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "pathForFile", "pickFolder", "platform", "revealPath", "saveBytes", "saveText", "shell", "showBrowserView", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
check("the bridge exposes verbs and nothing else", JSON.stringify(seen.bridge) === JSON.stringify(verbs), seen.bridge);
check("the credential never reaches the page", !seen.cookie.includes("reasonix_token"), seen.cookie);
check("the renderer has no node of its own", seen.globals.every((t) => t === "undefined"), seen.globals);
@@ -199,7 +203,8 @@ async function trayChecks(win) {
visible: win.isVisible(),
});
win.show();
if (process.platform === "darwin") await activationChecks(win, current, check);
else win.show();
await wait(200);
const off = await client.setTrayPrefs(true, false);
check("backgrounding can be turned back off", off?.closeToTray === false, off);
@@ -237,7 +242,7 @@ async function instanceChecks(win) {
const { client } = current();
check("the running kernel is undisturbed", !!(await client.trayPrefs()));
const otherHome = path.join(os.tmpdir(), "rx-other-home");
const otherHome = fs.mkdtempSync(path.join(os.tmpdir(), "rx-other-home-"));
const elsewhere = await launch(otherHome);
check("a launch over another home is allowed to run", elsewhere.took === -1, elsewhere.took);
@@ -344,6 +349,9 @@ app.whenReady().then(async () => {
failures.push(String(err && err.message));
process.stdout.write(` FAIL ${String(err && err.message)}\n`);
}
process.stdout.write(failures.length ? `\n${failures.length} failed\n` : "\nall checks passed\n");
process.exit(failures.length ? 1 : 0);
app.once("will-quit", () => {
process.stdout.write(failures.length ? `\n${failures.length} failed\n` : "\nall checks passed\n");
app.exit(failures.length ? 1 : 0);
});
app.quit();
});
+1
View File
@@ -58,6 +58,7 @@ node perf/idle.mjs # 闲置的守卫:看不见的东西不许动,闲
node perf/shipped.mjs # 产物的守卫:dist 里的东西必须比源码新(构建可以成功而编的是别的树)
node perf/reason.mjs # 内核拒绝的双语落地:同一个码,中英各说各的
node perf/budget.mjs # 预算的守卫:输入框上下堆满东西时,转录还剩多少
node perf/find.mjs # 对话内查找:快捷键穿过输入框、跳到没挂载的那条、同一行里的第 n 处落在眼前
node perf/focus.mjs # 专注的守卫:拿走外围之后,退出时世界是否原样
```
+61
View File
@@ -126,6 +126,67 @@ for (const width of [1440, 640, 420]) {
check(`${width}px:回执不超出回答栏宽`, row.dRight <= 1, `超出 ${row.dRight}px`);
}
// In the reading measure the transcript and the composer are one column: every
// card's edges are the composer's. The full measure widens the transcript only.
const col = await browser.newPage({ viewport: { width: 1440, height: 900 }, locale: "zh-CN", colorScheme: "dark" });
col.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await col.goto(PAGE, { waitUntil: "networkidle" });
await col.waitForSelector(".compose");
await col.evaluate(() => {
window.__feed({ kind: "turn_started" });
window.__feed({ kind: "text", text: "先读文件再构建。" });
window.__feed({ kind: "message" });
window.__feed({ kind: "tool_dispatch", tool: { id: "col-read", name: "read_file", args: '{"path":"a.ts"}', readOnly: true } });
window.__feed({ kind: "tool_result", tool: { id: "col-read", name: "read_file", args: '{"path":"a.ts"}', output: "1→ok", readOnly: true } });
window.__feed({ kind: "ask_request", ask: { id: "col-ask", questions: [
{ id: "q1", header: "方案", prompt: "选择一种方案", options: [{ label: "方案 A" }, { label: "方案 B" }] },
] } });
});
await col.waitForSelector('.call[data-k="ask"]');
const edges = () => col.evaluate(() => {
const r = (el) => { const b = el.getBoundingClientRect(); return { l: Math.round(b.left), r: Math.round(b.right) }; };
const all = (sel) => [...document.querySelectorAll(sel)].map(r);
return {
compose: r(document.querySelector(".compose")),
flow: r(document.querySelector(".flow")),
boxed: [...all(".flow .activity-group"), ...all('.flow .call[data-k="ask"]')],
replies: all('.flow .call[data-k="say"] > .c'),
bubbles: all('.flow .call[data-k="me"] .out .txt'),
};
});
const setMeasure = (full) => col.evaluate((f) => {
if (f) document.documentElement.dataset.measure = "full";
else delete document.documentElement.dataset.measure;
}, full);
const composeAt = {};
for (const width of [1440, 1000]) {
await col.setViewportSize({ width, height: 900 });
await setMeasure(false);
await col.waitForTimeout(300);
const e = await edges();
composeAt[width] = e.compose;
const off = (xs, sides) => xs.filter((x) => sides.some((s) => Math.abs(x[s] - e.compose[s]) > 1));
check(`${width}px 适宜阅读:找到了要比的卡片`, e.boxed.length >= 2 && e.replies.length >= 1 && e.bubbles.length >= 1,
`${e.boxed.length} 张卡 / ${e.replies.length} 段回复 / ${e.bubbles.length} 个气泡`);
check(`${width}px 适宜阅读:卡片左右边与输入框对齐`, off(e.boxed, ["l", "r"]).length === 0,
`输入框 ${e.compose.l}–${e.compose.r},卡片 ${JSON.stringify(off(e.boxed, ["l", "r"]))}`);
check(`${width}px 适宜阅读:回复与输入框同宽`, off(e.replies, ["l", "r"]).length === 0,
`输入框 ${e.compose.l}–${e.compose.r},回复 ${JSON.stringify(off(e.replies, ["l", "r"]))}`);
check(`${width}px 适宜阅读:用户气泡右边与输入框对齐`, off(e.bubbles, ["r"]).length === 0,
`输入框右 ${e.compose.r},气泡 ${JSON.stringify(off(e.bubbles, ["r"]))}`);
}
await col.setViewportSize({ width: 1440, height: 900 });
await setMeasure(true);
await col.waitForTimeout(300);
const full = await edges();
await setMeasure(false);
check("全宽:输入框宽度不跟着变", full.compose.l === composeAt[1440].l && full.compose.r === composeAt[1440].r,
`适宜阅读 ${composeAt[1440].l}–${composeAt[1440].r},全宽 ${full.compose.l}–${full.compose.r}`);
check("全宽:转录比输入框宽", full.flow.r - full.flow.l > full.compose.r - full.compose.l + 100,
`转录 ${full.flow.r - full.flow.l}px,输入框 ${full.compose.r - full.compose.l}px`);
check("全宽:卡片跟着转录到边", full.boxed.every((x) => Math.abs(x.l - full.flow.l) <= 1 && Math.abs(x.r - full.flow.r) <= 1),
`转录 ${full.flow.l}–${full.flow.r},卡片 ${JSON.stringify(full.boxed)}`);
await browser.close();
if (fails.length) {
console.error(`\n${fails.length} 项不合格:\n ` + fails.join("\n "));
+48
View File
@@ -98,6 +98,54 @@ await page.waitForSelector(".slashmenu", { state: "detached" });
await page.waitForSelector('.studio-model-control > button[aria-expanded="true"]');
check("补全与模型菜单互斥", await page.locator(".menu:not([hidden])").count() === 1);
// The toolbar shares its line with the send cluster, whose width depends on
// whether a turn runs. Every column width has to hold both: controls inside the
// composer, none under another, and the model name still readable.
const LONG_MODEL = "mimo-v2.6-pro-reasoning-preview";
const toolbar = () => page.evaluate((name) => {
const nm = document.querySelector(".studio-model-control .nm");
if (nm) nm.textContent = name;
const edge = document.querySelector(".compose").getBoundingClientRect();
const shown = [...document.querySelectorAll(".compose .row button")]
.filter((el) => el.getClientRects().length && el.checkVisibility({ visibilityProperty: true }));
const id = (el) => el.getAttribute("data-action") || el.getAttribute("aria-label") || el.className.toString();
const boxes = shown.map((el) => ({ el, id: id(el), r: el.getBoundingClientRect() }));
const outside = boxes.filter(({ r }) => r.left < edge.left - 0.5 || r.right > edge.right + 0.5 || r.bottom > edge.bottom + 0.5).map((b) => b.id);
const overlaps = [];
for (let i = 0; i < boxes.length; i++) for (let j = i + 1; j < boxes.length; j++) {
const a = boxes[i], b = boxes[j];
if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
if (a.r.left < b.r.right - 1 && a.r.right > b.r.left + 1 && a.r.top < b.r.bottom - 1 && a.r.bottom > b.r.top + 1) overlaps.push(`${a.id} × ${b.id}`);
}
return {
pane: Math.round(document.querySelector(".pane").getBoundingClientRect().width),
outside, overlaps, name: nm ? Math.round(nm.getBoundingClientRect().width) : 0,
};
}, LONG_MODEL);
const swept = new Set();
for (const state of ["idle", "running"]) {
await page.setViewportSize({ width: 1010, height: 800 });
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
if (state === "running") {
await page.evaluate(() => window.__feed({ kind: "turn_started" }));
await page.waitForSelector('[data-action="session.stop"]');
}
const bad = [];
for (let width = 340; width <= 760; width += 10) {
await page.setViewportSize({ width, height: 800 });
await frame();
const g = await toolbar();
swept.add(g.pane);
if (g.outside.length) bad.push(`${g.pane}px 越界: ${g.outside.join(" / ")}`);
if (g.overlaps.length) bad.push(`${g.pane}px 重叠: ${g.overlaps.join(" / ")}`);
if (g.name < 40) bad.push(`${g.pane}px 模型名只剩 ${g.name}px`);
}
check(`${state}:各宽度下控件都在编辑器内、互不覆盖、模型名可读`, bad.length === 0, bad.slice(0, 4).join(";"));
}
check("扫到的对话列覆盖 360–700px", [...swept].some((w) => w <= 360) && [...swept].some((w) => w >= 700), [...swept].sort((a, b) => a - b).slice(0, 1).concat([...swept].sort((a, b) => b - a).slice(0, 1)).join("–"));
await browser.close();
if (fails.length) {
console.error(`\n${fails.length} 项不合格:\n ` + fails.join("\n "));
+129
View File
@@ -0,0 +1,129 @@
// 对话内查找:快捷键打开、计数、跳到没挂载的那一条、当前处真的落在眼前。
//
// 记录只挂几十张卡,浏览器自带的查找看不见屏幕外的消息;这里要证的是应用内
// 的查找能把一条从未挂载过的消息带到眼前,并且高亮落在读者看得见的地方 ——
// jsdom 没有布局,这几条只能在真浏览器里问。
import { chromium } from "playwright";
import { fileURLToPath } from "node:url";
import { mkdirSync } from "node:fs";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.SHOTS ?? fileURLToPath(new URL("shots", import.meta.url));
mkdirSync(SHOTS, { recursive: true });
const browser = await chromium.launch();
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
page.on("console", (m) => m.type() === "error" && fails.push("控制台错误: " + m.text()));
// 当前处那一段高亮,量它在滚动容器里的位置;没有就说没有。
const current = () =>
page.evaluate(() => {
const now = CSS.highlights.get("rx-find-now");
const range = now && [...now][0];
const flow = document.querySelector('[data-pane="flow"]');
if (!range || !flow) return null;
const box = range.getBoundingClientRect();
const view = flow.getBoundingClientRect();
// 落在视口里还不够:长输出在自己的框里滚,框外的那一截同样看不见。
const host = range.startContainer.parentElement;
const hit = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2);
const seen = !!hit && (hit === host || host.contains(hit));
return { text: range.toString(), top: box.top, bottom: box.bottom, h: box.height, viewTop: view.top, viewBottom: view.bottom, seen };
});
const inView = (c) => !!c && c.h > 0 && c.seen && c.top >= c.viewTop + 40 && c.bottom <= c.viewBottom;
// 落地要等块挂上、位置稳住,给它一段时间,而不是赌一个固定的等待。
async function settled() {
let c = null;
for (let i = 0; i < 30; i++) {
await page.waitForTimeout(50);
c = await current();
if (inView(c)) return c;
}
return c;
}
async function suite(scheme) {
await page.emulateMedia({ colorScheme: scheme });
await page.goto(`${PAGE}?turns=200`, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 15000 });
await page.waitForTimeout(1200);
await page.evaluate((s) => document.documentElement.setAttribute("data-theme", s), scheme);
const tag = scheme;
const mountedFirst = await page.locator('[data-item] :text("第 3 个问题")').count();
check(`${tag} 第 3 问原本没挂载`, mountedFirst === 0);
// 焦点在输入框里:它没有自己的查找,快捷键要能穿过它。
await page.locator("textarea").first().focus();
await page.keyboard.press("ControlOrMeta+f");
await page.waitForTimeout(250);
const box = page.locator(".tfind input");
check(`${tag} 快捷键从输入框里打开查找条`, (await box.count()) === 1);
check(`${tag} 焦点在查找框`, await box.evaluate((el) => el === document.activeElement).catch(() => false));
await page.screenshot({ path: `${SHOTS}/${tag}-1-打开查找条.png` });
await box.pressSequentially("第 3 个问题", { delay: 20 });
const c1 = await settled();
const n = await page.locator(".tfind .n").innerText();
check(`${tag} 计数`, n === "1 / 1", n);
check(`${tag} 屏幕外那条被带到眼前`, inView(c1), JSON.stringify(c1));
check(`${tag} 高亮的是查的字`, c1?.text === "第 3 个问题", c1?.text);
await page.screenshot({ path: `${SHOTS}/${tag}-2-跳到屏幕外的匹配.png` });
// 一条工具输出里有二十处:每按一次 Enter,当前处要往下走,而且留在眼前。
await box.fill("第 120 次输出");
await page.waitForTimeout(700);
const total = await page.locator(".tfind .n").innerText();
check(`${tag} 同一行里的多处都算进去`, total === "1 / 20", total);
const tops = [];
for (let i = 0; i < 20; i++) {
if (i > 0) await box.press("Enter");
const c = await settled();
check(`${tag} 第 ${i + 1} 处在眼前`, inView(c), JSON.stringify(c));
tops.push(c?.top ?? NaN);
}
const moved = new Set(tops.map((t) => Math.round(t))).size;
check(`${tag} 当前处随步进移动`, moved > 1, `${moved} 个不同位置`);
await page.screenshot({ path: `${SHOTS}/${tag}-3-同一行里的第20处.png` });
await box.press("Shift+Enter");
await page.waitForTimeout(400);
check(`${tag} Shift+Enter 往回`, (await page.locator(".tfind .n").innerText()) === "19 / 20");
await box.press("ArrowUp");
await page.waitForTimeout(400);
check(`${tag} 上箭头往回`, (await page.locator(".tfind .n").innerText()) === "18 / 20");
await box.press("Escape");
await page.waitForTimeout(200);
check(`${tag} Esc 关掉`, (await page.locator(".tfind").count()) === 0);
check(`${tag} 关掉后高亮清空`, await page.evaluate(() => !CSS.highlights.has("rx-find") && !CSS.highlights.has("rx-find-now")));
}
for (const scheme of ["light", "dark"]) await suite(scheme);
// 窄屏:没有键盘,入口得是看得见的按钮。
await page.setViewportSize({ width: 390, height: 844 });
await page.emulateMedia({ colorScheme: "light" });
await page.goto(`${PAGE}?turns=200`, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 15000 });
await page.waitForTimeout(1200);
const button = page.locator(".chrome .find-action");
check("窄屏 顶栏有查找按钮", await button.isVisible().catch(() => false));
if (await button.isVisible().catch(() => false)) {
await button.click();
await page.waitForTimeout(250);
await page.locator(".tfind input").pressSequentially("第 5 个问题", { delay: 20 });
check("窄屏 按钮打开的查找条能跳过去", inView(await settled()));
}
await page.screenshot({ path: `${SHOTS}/narrow-查找.png` });
await browser.close();
console.log(fails.length ? `\n失败 ${fails.length} 条:\n ${fails.join("\n ")}` : "\n全部通过");
process.exit(fails.length ? 1 : 0);
+1 -1
View File
@@ -56,7 +56,7 @@ const undeclared = census.roots.filter((r) => !r.actions.length);
// denominator that grows because the instrument was corrected is not the same
// event as one that grows because debt was added, and numbers either side of
// that change do not compare.
const UNDECLARED_CEILING = 219;
const UNDECLARED_CEILING = 218;
/** One file, for holding the discovery rules themselves. */
const only = (src: string) => certifyRoots(new Map([["src/ui/X.tsx", src]]), { resolve });
+62
View File
@@ -91,6 +91,7 @@ export const ACTIONS: UIAction[] = [
// header ask the same thing of the kernel, and which grant rides on
// data-target, with "all" as the value that names none of them.
{ id: "permissions.revoke-session", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "permissions.revoke-remembered", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "tool.image-open", kind: "view", target: "none", proof: "interaction" },
{ id: "tool.image-close", kind: "view", target: "none", proof: "interaction" },
@@ -150,6 +151,19 @@ export const ACTIONS: UIAction[] = [
{ id: "account.reload", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "account.sign-in", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "account.sign-out", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "backup.category", kind: "view", target: "entity", proof: "interaction" },
{ id: "backup.label", kind: "view", target: "none", proof: "interaction" },
{ id: "backup.passphrase", kind: "view", target: "optional", proof: "interaction" },
{ id: "backup.create", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "backup.restore", kind: "view", target: "entity", proof: "interaction" },
{ id: "backup.delete", kind: "destructive", target: "entity", proof: "interaction" },
{ id: "backup.preview", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "backup.cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "backup.pick", kind: "view", target: "entity", proof: "interaction" },
{ id: "backup.allow", kind: "view", target: "entity", proof: "interaction" },
{ id: "backup.apply", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "backup.install-plugin", kind: "view", target: "entity", proof: "interaction" },
{ id: "backup.done", kind: "view", target: "none", proof: "interaction" },
{ id: "ask.answer", kind: "interaction", target: "none", proof: "authority-effect" },
{ id: "ask.step", kind: "view", target: "none", proof: "interaction" },
{ id: "code.download", kind: "interaction", target: "none", proof: "interaction" },
@@ -174,6 +188,35 @@ export const ACTIONS: UIAction[] = [
{ id: "extensions.export", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "extensions.enabled", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "extensions.remove", kind: "destructive", target: "entity", proof: "authority-effect" },
{ id: "extensions.tab", kind: "view", target: "none", proof: "interaction" },
// The community market: reads are views, and the one write is the install
// its confirmation page answers with the plan the person just read.
{ id: "market.search", kind: "view", target: "none", proof: "interaction" },
{ id: "market.kind", kind: "view", target: "none", proof: "interaction" },
{ id: "market.sort", kind: "view", target: "none", proof: "interaction" },
{ id: "market.pinned", kind: "view", target: "none", proof: "interaction" },
{ id: "market.show-all", kind: "view", target: "none", proof: "interaction" },
{ id: "market.more", kind: "view", target: "none", proof: "interaction" },
{ id: "market.open", kind: "navigation", target: "none", proof: "interaction" },
{ id: "market.back", kind: "navigation", target: "none", proof: "interaction" },
{ id: "market.cancel", kind: "navigation", target: "none", proof: "interaction" },
{ id: "market.inspect", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.trust", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.confirm-many", kind: "view", target: "none", proof: "interaction" },
{ id: "market.install", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "market.view-installed", kind: "navigation", target: "none", proof: "interaction" },
// Publishing: the draft is on screen only, and the submission is the one
// write, spending the account session on the registry.
{ id: "market.view", kind: "view", target: "none", proof: "interaction" },
{ id: "market.signin", kind: "navigation", target: "none", proof: "interaction" },
{ id: "market.draft", kind: "view", target: "none", proof: "interaction" },
{ id: "market.publish", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.publish-again", kind: "view", target: "none", proof: "interaction" },
// The account's own packages: a preview, an install pinned to that preview's
// digest, and sending a private one to review.
{ id: "market.own-inspect", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.submit", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "market.vote", kind: "kernel-mutation", target: "none", proof: "interaction" },
// Taking back the innermost open thing: a popover, an inline form. One
// intent, reached by pressing away and by Escape.
{ id: "layer.dismiss", kind: "navigation", target: "none", proof: "interaction" },
@@ -244,6 +287,8 @@ export const ACTIONS: UIAction[] = [
{ id: "provider.add-start", kind: "view", target: "none", proof: "interaction" },
{ id: "provider.select", kind: "view", target: "entity", proof: "interaction" },
{ id: "provider.move", kind: "view", target: "entity", proof: "interaction" },
{ id: "provider.rename-start", kind: "view", target: "entity", proof: "interaction" },
{ id: "provider.rename", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "provider.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "roles.model", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "permissions.rule-level", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -276,6 +321,8 @@ export const ACTIONS: UIAction[] = [
{ id: "versions.reload", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.pin", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "versions.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "versions.restart", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "versions.later", kind: "view", target: "none", proof: "interaction" },
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "hooks.recipe", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
@@ -336,6 +383,7 @@ export const ACTIONS: UIAction[] = [
{ id: "workbench.folder", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.diff", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.files", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.hidden", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.reveal", kind: "shell-native", target: "optional", proof: "interaction" },
{ id: "workbench.menu", kind: "view", target: "entity", proof: "interaction" },
{ id: "palette.pick", kind: "view", target: "entity", proof: "interaction" },
@@ -352,6 +400,9 @@ export const ACTIONS: UIAction[] = [
{ id: "plan.fold", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.file", kind: "view", target: "entity", proof: "interaction" },
{ id: "tool-approval.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// The folder-trust question and the no-sandbox note beside the composer.
{ id: "workspace.trust", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "posture-note.dismiss", kind: "view", target: "none", proof: "interaction" },
{ id: "storage.move", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "session.import-legacy", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "network.diagnose", kind: "repeatable", target: "none", proof: "interaction" },
@@ -367,6 +418,17 @@ export const ACTIONS: UIAction[] = [
{ id: "sandbox.add-write-root", kind: "kernel-mutation", target: "optional", proof: "authority-effect" },
{ id: "sandbox.remove-write-root", kind: "destructive", target: "entity", proof: "authority-effect" },
{ id: "browser-tools.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// Read by the running turn at its next round, so no rebuild stands between the
// click and the canonical change.
{ id: "progress-watch.pause", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.rounds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.token-multiple", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// The stall strip beside the composer. Stopping is the ordinary interrupt and
// continuing is an ordinary message; muting and dismissing touch this window.
{ id: "stall.stop", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "stall.continue", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "stall.mute", kind: "view", target: "none", proof: "interaction" },
{ id: "stall.dismiss", kind: "view", target: "none", proof: "interaction" },
{ id: "provider.remove", kind: "destructive", target: "entity", proof: "interaction" },
{ id: "provider.protocol", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "provider.web-search", kind: "kernel-mutation", target: "entity", proof: "interaction" },
+30 -10
View File
@@ -15,6 +15,7 @@ import { EN_METRICS } from "./en_metrics";
import { EN_STORAGE } from "./en_storage";
import { EN_USAGE } from "./en_usage";
import { EN_COMPOSER } from "./en_composer";
import { EN_BACKUP } from "./en_backup";
export const EN: Record<string, string> = {
"工作台": "Workbench",
@@ -24,6 +25,7 @@ export const EN: Record<string, string> = {
...EN_STORAGE,
...EN_USAGE,
...EN_COMPOSER,
...EN_BACKUP,
...EN_KERNEL,
...EN_REMOTE,
...EN_WINDOW,
@@ -491,6 +493,7 @@ export const EN: Record<string, string> = {
"留空时按这个顺序尝试:先 ssh-agent 持有的密钥,再 ~/.ssh 下的默认密钥;只有填了下面的环境变量或该机器要求交互时,才会走密码。": "Left blank, it tries in this order: keys held by ssh-agent, then the default keys under ~/.ssh. A password is only used when one of the variables below is set, or the machine asks interactively.",
"在代码编辑器中打开工作区": "Open the workspace in a code editor",
"在系统文件管理器中显示工作区": "Show the workspace in the system file manager",
"显示隐藏文件": "Show hidden files",
"在访达中显示": "Reveal in Finder",
"在文件资源管理器中显示": "Reveal in File Explorer",
"在系统文件管理器中显示": "Show in file manager",
@@ -679,6 +682,10 @@ export const EN: Record<string, string> = {
"本轮有改动无法还原": "Some of this turn's changes cannot be restored",
"部分改动不在快照内": "Some of the changes are outside the snapshot",
"已还原 {n} 个文件": "{n} files restored",
"代码和对话": "Code and conversation",
"只还原代码": "Code only",
"只回退对话": "Conversation only",
"bash 命令所作的修改没有快照": "Changes made by bash commands have no snapshot",
// ── 开场 ─────────────────────────────────────────────────────────
"欢迎使用 Reasonix。": "Welcome to Reasonix.",
@@ -696,15 +703,6 @@ export const EN: Record<string, string> = {
"无法连接版本目录": "Cannot reach the version index",
"已固定": "Pinned",
"固定在这里": "Pin here",
"清除固定": "Clear the pin",
"恢复自动更新": "Resume automatic updates",
"可在下方对应行安装,安装完成后会自动重启。": "Install it from the row below; it restarts itself when done.",
"回退后固定是有意为之:否则下次更新会将你带回刚离开的版本。":
"Pinning after a rollback is deliberate: otherwise the next update would put you back on the version you just left.",
"该固定已与实际情况不符,自动更新按未固定处理。":
"This pin no longer describes anything real, so automatic updates treat it as unpinned.",
"切换版本期间请勿关闭窗口。较新版本写入的会话在旧版本中暂时无法打开,升级回去后即可恢复。":
"Do not close the window while versions are switching. A session written by a newer version will not open on an older one until you upgrade again.",
// ── 账号 ─────────────────────────────────────────────────────────
"正在检查登录状态…": "Checking sign-in status…",
@@ -882,6 +880,14 @@ export const EN: Record<string, string> = {
"{n} 天前": "{n} days ago",
"下载中 {got} / {all}": "Downloading {got} / {all}",
"下载中 {got}": "Downloading {got}",
"下载完整安装包 {got} / {all}": "Downloading the full package {got} / {all}",
"下载完整安装包 {got}": "Downloading the full package {got}",
"安装目录无法就地替换文件,本次改为下载完整安装包。": "The install folder cannot have its files replaced in place, so this update downloads the full package.",
"增量数据下载失败,本次改为下载完整安装包。": "The changed parts could not be downloaded, so this update downloads the full package.",
"增量数据下载太慢,本次改为下载完整安装包。": "The changed parts were downloading too slowly, so this update downloads the full package.",
"增量数据未通过校验,本次改为下载完整安装包。": "The changed parts failed verification, so this update downloads the full package.",
"无法写入增量更新文件,本次改为下载完整安装包。": "The changed parts could not be written to disk, so this update downloads the full package.",
"增量更新不可用,本次改为下载完整安装包。": "Updating only the changed parts is unavailable, so this update downloads the full package.",
"校验签名…": "Verifying the signature…",
"准备安装…": "Preparing to install…",
"等待系统授权…": "Waiting for permission…",
@@ -911,7 +917,7 @@ export const EN: Record<string, string> = {
// ── 卡片 ─────────────────────────────────────────────────────────
"将工作区与对话回退至该消息之前": "Take the workspace and the conversation back to before this message",
"↩ 回到这里": "↩ Back to here",
"回到这里": "Back to here",
"按技能 {name} 的设定运行的子代理": "A subagent running under the {name} skill's settings",
"外部服务 {name} 提供的工具": "A tool provided by the external service {name}",
"这次调用解析到的能力目录条目": "The capability catalogue entry this call resolved to",
@@ -1194,4 +1200,18 @@ export const EN: Record<string, string> = {
"New tools",
"agent 可以调用,每次调用照常经过权限确认":
"The agent can call them; every call still goes through permission checks",
"自上次有进展以来已用约 {n} 倍上下文的输入 token": "About {n}× the context window in input tokens since the last progress",
"已连续 {n} 轮没有可观察的进展": "No observable progress for {n} rounds",
"共 {n} tokens": "{n} tokens in all",
"没有文件改动、检查变化或新读取": "No file changed, no check moved, nothing new read",
"任务已暂停:{s}": "Task paused: {s}",
"继续(计数清零)": "Continue (resets the count)",
"不再提示(本会话)": "Don’t show again (this session)",
"任务已按设置暂停:自上次有进展以来的输入已达上下文窗口的 {n} 倍": "Paused by your setting: input since the last progress reached {n}× the context window",
"任务已按设置暂停:连续 {n} 轮没有可观察的进展": "Paused by your setting: {n} rounds in a row without observable progress",
"模型在重复输出同一段文字": "The model is repeating the same text",
"同一段内容被逐字重复": "The same block is being repeated verbatim",
"任务已按设置暂停:模型卡在重复输出同一段文字": "Paused by your setting: the model is stuck repeating the same text",
"等待你的输入": "Waiting for you",
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
};
@@ -0,0 +1,63 @@
// English for the cloud backup section under Settings > Account.
export const EN_BACKUP: Record<string, string> = {
"云备份": "Cloud backup",
"把本机的模型、扩展、记忆等配置加密保存到账号,换机器或重装后按项恢复。":
"Encrypt this machine's models, extensions, memory and other setup into your account, and restore it item by item on a new machine or after a reinstall.",
"模型与界面设置": "Models and interface",
"技能、插件与 MCP": "Skills, plugins and MCP",
"长期记忆": "Long-term memory",
"Hooks 与状态栏命令": "Hooks and status line command",
"API 密钥": "API keys",
"供应商、默认模型、预设与界面外观;不含审批模式、权限和沙箱":
"Providers, default model, presets and appearance; approval mode, permissions and sandbox are never included",
"插件只记录来源与版本,恢复时仍走插件安装确认":
"Plugins are recorded by source and version only, and restoring one goes through the usual install confirmation",
"用户级指令文件与全局记忆": "User-level instruction files and global memory",
"会在本机运行的命令,恢复时逐项确认": "Commands that run on this machine; each one is confirmed on restore",
"默认不备份。勾选后密钥会随备份一起加密上传": "Off by default. When ticked, keys are encrypted and uploaded with the backup",
"备份在本机用你设置的口令加密后才上传,服务端看不到其中内容。忘记口令就无法恢复。":
"A backup is encrypted here with your passphrase before it is uploaded, so the service cannot read it. A forgotten passphrase cannot be recovered.",
"备份内容": "What to back up",
"备注(可选)": "Label (optional)",
"例如:公司笔记本": "e.g. work laptop",
"加密口令(至少 {n} 个字符)": "Passphrase (at least {n} characters)",
"再输一次": "Repeat it",
"两次输入的口令不一致": "The two passphrases differ",
"正在加密上传…": "Encrypting and uploading…",
"备份到账号": "Back up to account",
"已用 {n}/{max} 份": "{n} of {max} used",
"已备份。": "Backed up.",
"已备份。{n} 个文件过大或无法读取,未包含在内。": "Backed up. {n} files were too large or unreadable and were left out.",
"账号里还没有备份。": "No backups in this account yet.",
"未命名备份": "Untitled backup",
"恢复…": "Restore…",
"确认删除": "Confirm delete",
"正在读取备份…": "Loading backups…",
"本机没有": "Not on this machine",
"与本机不同": "Differs from this machine",
"与本机相同": "Same as this machine",
"需安装": "Needs install",
"我已看过这条命令,允许它在本机运行": "I have read this command and allow it to run on this machine",
"我确认允许把对话和本机保存的密钥发往这个地址": "I allow conversations and the key stored here to be sent to this address",
"我确认用备份里的密钥替换本机已保存的密钥": "I allow the key in the backup to replace the one stored here",
"我已读过这份指令文件,允许它引用其中列出的文件": "I have read this instruction file and allow the files it imports",
"已恢复 {n} 项。": "Restored {n} items.",
"配置已写入,但当前会话未能重新加载:{e}": "The setup was written, but this session could not reload it: {e}",
"以下插件需要走正常的安装确认:": "These plugins go through the usual install confirmation:",
"安装…": "Install…",
"输入备份「{name}」的加密口令,先预览它和本机的差异,不会立即写入。":
"Enter the passphrase for “{name}” to preview how it differs from this machine. Nothing is written yet.",
"加密口令": "Passphrase",
"正在解密…": "Decrypting…",
"预览差异": "Preview differences",
"这份备份来自 {p}": "This backup comes from {p}",
"命令和路径在另一种系统上可能无法运行,这类项目默认不勾选。":
"Commands and paths may not work on another system, so those items start unticked.",
"本机现为:": "Here now: ",
"本机不存在:{p}": "Missing here: {p}",
"来自另一种系统,可能无法运行": "From another system; it may not run here",
"备份时有 {n} 个文件过大或无法读取,未包含在内。": "{n} files were too large or unreadable at backup time and are not included.",
"正在恢复…": "Restoring…",
"恢复所选 {n} 项": "Restore {n} selected",
"还有 {n} 项需要逐项确认": "{n} items still need confirmation",
};
@@ -2,6 +2,14 @@
// that remain available while an agent is live.
export const EN_COMPOSER: Record<string, string> = {
"↓ 回到最新": "↓ Back to latest",
"不思考": "No reasoning",
"超深入": "Extra deep",
"不做推理直接作答,响应最快,适合简单问答": "Answers without reasoning; fastest, for simple questions",
"比深入投入更多推理,适合困难的多步问题": "More reasoning than Deep, for hard multi-step problems",
"Pro 模式": "Pro mode",
"更慢:模型为同一请求做更多推理,消耗的 token 与费用明显更高。仅对当前会话生效。":
"Slower: the model does more reasoning for the same request, using noticeably more tokens and money. Applies to this session only.",
"费用更高": "Costs more",
"停下": "Stop",
"发送": "Send",
"插话": "Steer",
@@ -15,6 +23,16 @@ export const EN_COMPOSER: Record<string, string> = {
"Enter 插话 · Shift+Enter 换行": "Enter to steer · Shift+Enter for a new line",
"{n} 字 · {lines} 行": "{n} characters · {lines} lines",
"知道了": "Got it",
"是否信任此文件夹": "Trust this folder",
"是否信任此文件夹?": "Trust this folder?",
"信任后,在此文件夹里的编辑和命令不再逐条询问(桌面端和终端都是)。系统沙盒把命令的写入限制在此文件夹、你额外允许写入的目录、临时目录和工具链缓存内;网络与拒绝规则照常生效。":
"Once trusted, edits and commands in this folder run without asking each time, here and in the terminal. The OS sandbox limits what commands write to this folder, the directories you allowed writes to, temp and toolchain caches; network and deny rules still apply.",
"信任此文件夹": "Trust this folder",
"暂不信任": "Not now",
"新会话默认每次询问": "New sessions ask first",
"当前没有生效的系统沙盒(Windows 暂不支持,或已在设置中关闭),命令的写入不受限制,所以编辑和命令执行前都会先问你。切换到自动批准后,之后所有文件夹的新会话都会使用它。":
"No OS sandbox is in effect (Windows has none yet, or it is turned off in Settings), so nothing limits what commands write - edits and commands ask you first. Switching to auto-approve applies to new sessions in every folder.",
"切换到自动批准": "Switch to auto-approve",
"本轮执行策略": "How this turn runs",
"工作策略": "Work strategy",
"当前分支 · {branch}": "Current branch · {branch}",
@@ -46,6 +46,30 @@ export const EN_KERNEL: Record<string, string> = {
"未能导出该插件:{detail}": "That plugin could not be exported: {detail}",
"无法解析本次安装请求:{detail}": "That install request could not be read: {detail}",
"安装失败:{detail}": "It could not be installed: {detail}",
"这不是有效的社区包名称": "That is not a community package name",
"社区市场中没有这个已审核的包": "The community market has no approved package by that name",
"无法连接社区市场:{detail}": "The community market could not be reached: {detail}",
"社区市场返回了无法识别的内容": "The community market answered with something unrecognisable",
"社区市场暂不支持只列出已固定内容的包": "The community market cannot list only pinned packages yet",
"该包的审核版本没有固定内容,需要信任发布者后才能安装": "The reviewed version is not pinned to its content; trust the publisher to install it",
"该包的审核版本指向本机或不安全的来源,不从市场安装": "The reviewed version points at a local or unsafe source, which the market does not install",
"审核版本已更新,请重新查看后再安装": "A newer version has been approved - look at it again before installing",
"来源内容已与审核版本不同,已拒绝安装": "The source no longer holds the reviewed content, so nothing was installed",
"该来源的内容无法固定,已拒绝安装": "This source cannot be pinned by content, so nothing was installed",
"来源内容在确认后发生了变化,请重新查看": "The source changed after you confirmed - look at it again",
"这个标为主题的包还会安装主题以外的内容,已拒绝安装": "This package is listed as a theme but would install more than themes, so nothing was installed",
"登录账号后才能发布或评价": "Sign in to publish or vote",
"请先在 id.reasonix.io 验证账号邮箱,再发布或评价": "Verify your account email at id.reasonix.io before publishing or voting",
"这个名称已被其他发布者使用,请换一个名称": "That name belongs to another publisher - pick another one",
"这个版本号已经发布过,请换一个版本号": "That version is already published - use another version",
"请求太频繁,请稍等一分钟再试": "Too many requests - wait a minute and try again",
"这个来源审核通过后也无法从市场安装:插件和主题要指向固定到提交的 GitHub 目录(tree/40 位提交号),技能要用 https 地址,MCP 用 https 地址或 npm 包名": "The market could not install this source even once approved: plugins and themes must point at a GitHub directory pinned to a commit (tree/<40-character commit>), skills at an https address, MCP servers at an https address or an npm package name",
"社区市场拒绝了这次提交:{detail}": "The community market refused the submission: {detail}",
"你的发布里没有这个包": "None of your packages has that name",
"只有私有的包才能提交审核": "Only a private package can be submitted for review",
"未审核的版本要先预览,再按预览时的内容摘要安装": "Preview an unreviewed version first; it installs only against that preview's content digest",
"不能评价自己发布的包": "You cannot vote on a package you published",
"评价只能是赞、踩或撤回": "A vote is up, down or withdrawn",
"无法解析安装器的响应:{detail}": "The installer's own answer could not be read: {detail}",
"无法读取该主题:{detail}": "That theme could not be read: {detail}",
"这不是可安装的主题:{detail}": "That is not an installable theme: {detail}",
@@ -90,6 +114,7 @@ export const EN_KERNEL: Record<string, string> = {
"{provider} names no reasoning-effort levels. To give it some, set reasoning_protocol or supported_efforts in its config block",
"{provider} 不提供「{level}」档位,可选值为:{levels}": "{provider} has no \"{level}\" level. Available: {levels}",
"无法识别当前使用的来源,请先切换一次模型": "Cannot tell which source is in use — switch models once and try again",
"当前模型 {model} 不支持「{mode}」模式": "The current model {model} does not support the “{mode}” mode",
"任务正在运行,请先停止再切换工作区": "A task is running — stop it before changing the workspace",
"任务正在运行,请先停止再重载扩展": "A task is running — stop it before reloading extensions",
"该来源正在使用中,请先切换模型再删除": "This source is in use — switch models before deleting it",
@@ -101,6 +126,8 @@ export const EN_KERNEL: Record<string, string> = {
"无法识别「{kind}」这种接入方式": "“{kind}” is not a protocol this knows",
"请至少选择一个模型": "Pick at least one model",
"默认模型「{model}」不在已选择的模型中": "The default model “{model}” is not among the ones you picked",
"显示名称最多 {max} 个字符": "A display name can be at most {max} characters",
"显示名称不能包含换行或其他控制字符": "A display name cannot contain line breaks or other control characters",
"该协议不发送思考参数,启用后不会生效": "This protocol never sends thinking parameters — turning it on does nothing",
"该协议在轮次之间不保留状态,无需选择续接方式": "This protocol keeps no state between turns, so there is nothing to choose",
"该协议不支持由端点自行搜索": "This protocol has no wire format for a search the endpoint runs itself",
@@ -185,6 +212,22 @@ export const EN_KERNEL: Record<string, string> = {
"没有已配置的模型匹配「{model}」": "No configured model matches “{model}”",
"这台服务器未开放 shell 设置": "This server does not open shell settings",
"这台服务器未开放账号登录": "This server does not open account sign-in",
"登录账号后才能使用云备份": "Sign in to use cloud backups",
"暂时连不上备份服务,稍后再试:{detail}": "The backup service could not be reached; try again later: {detail}",
"备注最多 {max} 个字": "A label can be at most {max} characters",
"读取本机配置时出错:{detail}": "Reading this machine's setup failed: {detail}",
"无法和本机配置比对:{detail}": "Could not compare with this machine's setup: {detail}",
"加密口令至少 {min} 个字符": "The passphrase needs at least {min} characters",
"至少选择一类要备份的内容": "Choose at least one kind of content to back up",
"口令不对,或这份备份已损坏": "Wrong passphrase, or this backup is damaged",
"这份备份来自更新的版本,请先升级 Studio": "This backup comes from a newer version; update Studio first",
"这份备份的内容无法识别": "The contents of this backup could not be read",
"备份超出了大小上限": "The backup is over the size limit",
"预览已失效,请重新打开这份备份": "The preview has expired; open the backup again",
"所选的项目不在这份备份里": "A selected item is not in this backup",
"这份备份已不存在": "That backup no longer exists",
"备份数量已达上限,请先删除一份旧备份": "The backup limit is reached; delete an older backup first",
"有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复": "Some items run programs here or change where a key is sent; confirm each one before restoring",
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
"不存在该预设": "There is no such preset",
"本次拖入 {count} 个,最多允许 {limit} 个": "{count} dropped at once — the limit is {limit}",
@@ -196,6 +239,8 @@ export const EN_KERNEL: Record<string, string> = {
"最大输出 Token 不能是负数": "The maximum output tokens cannot be negative",
"无法识别「{protocol}」这种思考协议": "Unrecognised reasoning protocol “{protocol}”",
"默认档位「{level}」不在填写的档位里": "The default level “{level}” is not one of the levels listed",
"{model} 的默认档位「{level}」不在为它选的档位里": "The default level “{level}” for {model} is not one of the levels picked for it",
"{model} 不在已启用的模型里,不能为它单独设置档位": "{model} is not one of the enabled models, so it cannot have levels of its own",
"这个模型上的对话正在运行,停止后再删除": "A conversation on this model is running. Stop it before removing the model",
"该会话正在运行,停止后再删除": "This conversation is running. Stop it before deleting it",
"这个文件夹里有正在运行的对话,停止后再移除": "A conversation in this folder is running. Stop it before removing the folder",
@@ -223,11 +268,15 @@ export const EN_KERNEL: Record<string, string> = {
"已有一个版本切换正在进行,请等待其完成后重试":
"A version change is already running — wait for it to finish and try again",
"本次版本切换未能启动:{detail}": "This version change did not start: {detail}",
"有 {n} 项任务正在运行,重启会中断它们": "{n} tasks are running, and a restart would stop them",
"没有已下载好的版本可供重启安装,请重新点击安装": "No downloaded version is waiting for a restart; choose Install again",
"本次启动未能记录为健康状态:{detail}": "This launch could not be recorded as healthy: {detail}",
"该请求未发往 Studio 监听的地址,已被拒绝": "That request was not addressed to the address Studio listens on — refused",
"该页面不属于 Studio,无法对其操作": "That page is not Studio itself, so it may not drive it",
"缺少本次启动的凭据,请重新打开 Studio 后重试": "This launch's credential is missing — reopen Studio and try again",
"本机通道未建立,请重新打开 Studio 后重试": "The local channel was never established — reopen Studio and try again",
"该服务未开启认证,只接受发往本机或其监听地址的请求": "This server runs without authentication, so it only accepts requests addressed to this machine or its listening address",
"该服务未开启认证,修改与审批需要本次启动的令牌,请用启动时打印的链接打开": "This server runs without authentication, so changes and approvals need this launch's token — open it with the link printed at startup",
"文件保存请求格式不正确": "The file save request is malformed",
"文件已在打开后被其他操作修改,请重新载入": "The file changed after it was opened; reload it and try again",
"找不到该文件,它可能已被移动或删除": "The file was not found; it may have been moved or deleted",
@@ -236,4 +285,8 @@ export const EN_KERNEL: Record<string, string> = {
"写入目标不在工作区可写范围内。这是文件工具的写入范围,不是操作系统沙箱;要允许写入,请在 设置 → 沙箱 → 额外可写目录 中添加目标文件夹": "The target is outside the workspace write scope. This is the file tools' write scope, not an OS sandbox; to allow the write, add the target folder under Settings → Sandbox → Also writable",
"无法读取工作区文件列表": "The workspace file list could not be read",
"该文件不是可编辑文本或超过大小限制": "The file is not editable text or exceeds the size limit",
"无进展设置未能保存:{detail}": "The progress settings were not saved: {detail}",
"主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder",
"未能记下对此文件夹的信任决定:{detail}": "The trust decision for this folder was not saved: {detail}",
"该数值超出允许范围,未做任何修改:{detail}": "That value is out of range; nothing was changed: {detail}",
};
@@ -133,6 +133,12 @@ export const EN_REMOTE: Record<string, string> = {
"连接远程 Studio 超时,请检查电脑是否在线。": "Connecting to Remote Studio timed out. Check that the computer is online.",
"远程中转服务暂时不可用,请稍后重试。": "The remote relay is temporarily unavailable. Try again shortly.",
"远程 Studio 身份校验失败,请停止连接并检查设备。": "Remote Studio identity verification failed. Stop and check the device.",
"需要重新登录": "Sign in again",
"为了安全,远程控制在登录满 24 小时或退出登录后需要重新登录。": "For security, remote control asks you to sign in again 24 hours after signing in, or after you sign out.",
"重新登录": "Sign in",
"远程控制需要重新登录。": "Remote control needs you to sign in again.",
"这台电脑已从账号中移除。": "This computer was removed from your account.",
"连接中断,这次操作可能没有完成,请刷新后确认。": "The connection dropped; this action may not have finished. Refresh to check.",
"互联网远程": "Internet remote access",
"互联网连接二维码": "Internet connection QR code",
"手机扫码 · 不在同一网络也能连接": "Scan with your phone · works away from this network",
+184 -4
View File
@@ -27,6 +27,9 @@ export const EN_SETTINGS: Record<string, string> = {
"搜索已添加的服务": "Search added services",
"上移 {name}(Alt+上方向键)": "Move {name} up (Alt+Up Arrow)",
"下移 {name}(Alt+下方向键)": "Move {name} down (Alt+Down Arrow)",
"重命名 {name}(F2)": "Rename {name} (F2)",
"重命名 {name}": "Rename {name}",
"回车保存,Esc 取消;留空恢复为「{name}」": "Enter saves, Esc cancels; leave it empty to go back to “{name}”",
"无法保存服务顺序。": "Could not save the service order.",
"没有匹配的服务。": "No matching service.",
"添加一个模型服务后,在这里查看和修改它。": "Add a model service to see and change it here.",
@@ -128,6 +131,13 @@ export const EN_SETTINGS: Record<string, string> = {
"The setting above decides whether you are asked; this one decides what is never allowed and what never needs asking. Changes rebuild the runtime and cannot be made while a task is running.",
"无法读取权限配置。": "Cannot read the permission config.",
"当前项目自带权限配置": "This project ships its own permissions",
"当前项目记住的放行规则": "Remembered allow rules for this project",
"已记住的权限规则无法读取": "Could not read remembered permission rules",
"请检查或修复用户目录中的授权记录文件。": "Check or repair the grant record in your user directory.",
"请选择要收回的授权规则": "Select an approval rule to revoke",
"任务正在运行,请先停止再收回已记住的授权": "Stop the running task before revoking a remembered approval",
"当前会话无法重建,授权未更改": "This session cannot rebuild; the approval was not changed",
"无法更新当前项目的授权记录,请检查用户目录中的授权文件": "Could not update this project's approvals; check the grant record in your user directory",
"{path} 中同样声明了 permissions,实际生效的是该文件。此处的修改会被保存,但需待其不再声明后才会生效。":
"{path} declares permissions too, and that is what is in force. Edits here are saved, but only take effect once it stops declaring them.",
"禁止访问 .env": "Keep .env off limits",
@@ -257,6 +267,22 @@ export const EN_SETTINGS: Record<string, string> = {
"第一个档位": "The first level",
"推理强度选「自动」时使用的档位。": "The level used when reasoning effort is set to Auto.",
"{n} 个档位": "{n} levels",
"{n} 个模型单独设置": "{n} models with their own levels",
"按模型设置档位": "Levels per model",
"同一接入下有多家厂商的模型时,可为单个模型指定档位;「继承接入设置」沿用上面的档位。模型自己的档位优先。":
"When one connection serves several vendors' models, give a model levels of its own. Inherit uses the levels above; a model's own levels win.",
"{model} 的推理档位": "Reasoning levels for {model}",
"继承接入设置": "Inherit from connection",
"单独设置": "Its own levels",
"该模型的思考参数不使用自定义档位。": "This model's thinking parameter does not use custom levels.",
"继承:保存后按接入设置确定": "Inherits: settled by the connection once saved",
"继承:{levels}": "Inherits: {levels}",
"继承:端点未声明档位": "Inherits: the endpoint declares no levels",
"其他档位": "Other level",
"{model} 的其他档位": "Another level for {model}",
"{model} 的默认档位": "Default level for {model}",
"第一个档位({level})": "The first level ({level})",
"未选档位时按继承处理。": "With no level picked, the model inherits.",
"{door} 不提供 {model},请在下方选择该来源支持的模型。": "{door} does not offer {model}. Choose one it does provide below.",
"端点控制思考深度的方式。此项无法自动探测:中转站转发的是第三方模型,只有你知道其后端。选择后才能调整推理强度,选择错误会导致请求被端点拒绝。":
"How this endpoint expresses thinking depth. It cannot be probed: a relay forwards someone else's model, and only you know what is behind it. Choosing one enables the reasoning level; choosing wrong makes the endpoint reject the request.",
@@ -328,8 +354,8 @@ export const EN_SETTINGS: Record<string, string> = {
"已登录": "Signed in",
"未登录": "Not signed in",
"版本": "Version",
"当前安装的版本、可用更新,以及出现问题时如何回退。回退后将固定在所选版本,不会被自动更新覆盖。":
"Which version is installed, whether an update is available, and how to go back if something breaks. A rollback pins the version you chose and is not undone by an automatic update.",
"当前安装的版本、可用更新,以及出现问题时如何回退。更新下载好后由你决定何时重启;回退后会固定在所选版本,不再提示新版本。":
"The installed version, available updates, and how to go back if something breaks. A downloaded update waits for you to choose when to restart; after a rollback the chosen version is pinned and newer ones stop being offered.",
"记忆": "Memory",
"agent 自动记录的内容:未经配置,但会据此执行。此处按触发时机分组,并标出上一轮实际使用的条目。":
"What the agent recorded on its own: you never configured it, but it acts on it. Grouped here by when each entry is recalled, with the ones actually used last turn marked.",
@@ -376,6 +402,8 @@ export const EN_SETTINGS: Record<string, string> = {
"确认(还有 {n} 个没答)": "Confirm ({n} left)",
"确认": "Confirm",
"先不选择,直接回复": "Skip the choice and reply instead",
"需要你决定": "Need your decision",
"需要你补充信息": "Need more information from you",
"MCP 服务器 {name} 请求信息": "MCP server {name} is asking for information",
"这是外部服务器的请求,不是 agent 的提问。请勿填写密码、密钥等敏感信息。": "This request comes from an external server, not the agent. Do not enter passwords, keys or other secrets.",
"上次填写未通过:{note}": "The last answer was not accepted: {note}",
@@ -452,11 +480,44 @@ export const EN_SETTINGS: Record<string, string> = {
"自动触发 · 输入到达 {n}": "Automatic · input reached {n}",
"移除中…": "Removing…",
"{err} —— 本地功能不受影响,请稍后重试。": "{err} — nothing local is affected; try again later.",
"已固定在 {v},不会自动更新": "Pinned to {v}; updates will not move it",
"固定版本为 {pinned},当前运行的是 {current}": "Pinned to {pinned}; what is running is {current}",
"有新版本 {v}": "{v} is out",
"切换到 {v} 失败": "Switching to {v} failed",
"{err} —— 当前版本未被改动,可以重试。": "{err} — the running version was not touched; you can try again.",
"已固定在 {v},不再提示新版本": "Pinned to {v}; newer versions are not offered",
"回退时会固定在所选版本,免得新版本提示把你带回刚离开的版本。": "A rollback pins the version you chose, so an update prompt does not take you back to the one you left.",
"取消固定": "Unpin",
"这个固定没有作用,下次启动时会自动清除。": "This pin holds nothing and is cleared at the next launch.",
"现在清除": "Clear it now",
"可在下方对应行安装。下载完成后会先问你,再重启。": "Install it from the row below. Once it has downloaded, Studio asks before restarting.",
"有 {n} 项任务正在运行": "{n} tasks are running",
"{v} 已下载并通过签名校验": "{v} is downloaded and its signature checked",
"现在重启会中断它们。可以等任务结束后再重启。": "Restarting now would stop them. You can restart once they finish.",
"重启 Studio 后生效。重启会关闭当前窗口,稍后重启也可以。": "It takes effect when Studio restarts. Restarting closes this window; later works too.",
"较新版本写入的会话在旧版本中暂时无法打开,升级回去后即可恢复。": " A session written by a newer version will not open on an older one until you upgrade again.",
"仍然重启": "Restart anyway",
"立即重启": "Restart now",
"稍后": "Later",
"下载完整安装包": "Download the full installer",
"重启以完成安装": "Restart to finish",
"已下载,待重启": "Downloaded, waiting for a restart",
"正在安装…": "Installing…",
"正在重启到新版本…": "Restarting into the new version…",
"安装失败": "Install failed",
"无法读取 {v} 的发布信息": "Could not read the release details for {v}",
"版本目录暂时连不上,可能是网络或代理问题。当前版本未被改动,稍后重试即可。": "The version index cannot be reached right now, likely a network or proxy problem. The running version was not touched; try again later.",
"{v} 下载中断": "Downloading {v} was interrupted",
"网络中断或超时。当前版本未被改动,可以重试。": "The connection dropped or timed out. The running version was not touched; you can try again.",
"{v} 未通过签名校验": "{v} failed its signature check",
"下载到的文件与发布签名不符,已丢弃,没有安装。多为下载损坏或网络被篡改,请重试;反复出现请反馈。": "The downloaded file does not match the release signature, so it was discarded and nothing was installed. This is usually a corrupted download or a tampered network; try again, and report it if it keeps happening.",
"无法写入更新文件": "The update could not be saved",
"磁盘空间不足,或更新缓存目录不可写。清理空间后重试。": "The disk is full, or the update cache folder cannot be written. Free some space and try again.",
"{v} 没有适用于本机的安装包": "{v} has no package for this machine",
"这个版本没有为本机的系统发布可自动安装的包,请下载安装包手动安装。": "This version publishes no automatically installable package for this system; download the installer and install it by hand.",
"{v} 的安装没能开始": "Installing {v} did not start",
"安装程序被系统或安全软件拦下了。当前版本未被改动,可以重试,或下载完整安装包手动安装。": "The system or security software stopped the installer. The running version was not touched; try again, or install the full package by hand.",
"上次更新到 {v} 没有生效": "The last update to {v} did not take effect",
"重启后运行的仍是 {current}:新版本没能替换已安装的文件,已恢复原版本。常见原因是装在 Program Files 或其他磁盘,或文件被安全软件占用。下载完整安装包安装一次即可,之后的更新会恢复正常。": "After the restart {current} is still running: the new version could not replace the installed files, so the previous one was restored. This usually means Studio is installed under Program Files or on another drive, or security software held a file. Install the full package once and updates work again after that.",
"当前版本未被改动,可以重试。": "The running version was not touched; you can try again.",
"正在运行": "Running now",
"更早的版本": "An earlier version",
"更新的版本": "A newer version",
@@ -638,4 +699,123 @@ export const EN_SETTINGS: Record<string, string> = {
"加载方式": "Loading",
"启动时预先连接,工具每轮随请求发送,模型直接调用,省去搜索往返,但占用上下文。": "Connected at startup; the tools go with every request and the model calls them directly: no search round trip, but they take up context.",
"用到时再搜索加载,不占上下文,调用前多一轮往返。": "Searched for and loaded when needed: no context used, one extra round trip before a call.",
// 社区市场
"社区市场": "Community market",
"社区发布、经过审核的技能、插件、MCP 服务与主题。固定了审核内容的版本按审核时的内容安装,未固定的需要你信任发布者;安装前都会列出将写入的全部内容,与粘贴地址安装走同一套确认。": "Skills, plugins, MCP servers and themes published by the community and reviewed. A version pinned to its reviewed content installs exactly that; an unpinned one needs you to trust its publisher. Everything an install would write is listed first - the same confirmation a pasted address goes through.",
"已安装": "Installed",
"发现": "Discover",
"MCP 服务": "MCP servers",
"插件": "Plugins",
"近期热门": "Trending",
"暂无评价": "No votes yet",
"好评 {pct}%({n} 票)": "{pct}% positive ({n} votes)",
"评价": "Vote",
"赞": "Upvote",
"踩": "Downvote",
"撤回赞": "Withdraw upvote",
"撤回踩": "Withdraw downvote",
"登录后评价": "Sign in to vote",
"验证账号邮箱后才能评价": "Verify your account email to vote",
"在这台设备上不能评价": "Voting is not available on this device",
"安装最多": "Most installed",
"最新": "Newest",
"排序": "Sort",
"搜索技能、插件、MCP 服务与主题": "Search skills, plugins, MCP servers and themes",
"搜索社区市场": "Search the community market",
"无法读取社区市场": "Could not read the community market",
"没有找到匹配的包。": "No matching packages.",
"没有找到已固定内容的包。可关闭筛选查看全部包。": "No pinned packages found. Turn off the filter to see all packages.",
"查看全部包": "See all packages",
"只看已固定": "Pinned only",
"未固定": "Not pinned",
"固定状态未知": "Pin status unknown",
"管理员标记可信": "Admin marked trusted",
"可更新": "Update available",
"{n} 次安装": "{n} installs",
"加载更多": "Load more",
"返回列表": "Back to list",
"查看已安装能力": "View installed capabilities",
"{name} {version} 将安装以下内容": "{name} {version} will install the following",
"已按内容摘要核对:与审核时固定的版本一致。": "Checked by content digest: identical to the version pinned at review.",
"这个来源包含 {n} 个技能,会全部安装": "This source holds {n} skills, and all of them will be installed",
"我已看过这 {n} 个技能,全部安装": "I have read these {n} skills - install all of them",
"以下项目需要逐项看过再安装": "Read each of these before installing",
"以下项目会改变可用能力": "These change what is available",
"以下项目仅添加文件": "These only add files",
"无法读取 {name}": "Could not read {name}",
"发布者": "Publisher",
"审核版本": "Reviewed version",
"来源": "Source",
"固定内容": "Pinned content",
"未固定——审核时没有记录内容摘要": "Not pinned - no content digest was recorded at review",
"仓库": "Repository",
"标签": "Tags",
"已安装 {version}": "Installed {version}",
"先列出将安装的全部内容,确认后才会写入": "Everything it would install is listed first; nothing is written until you confirm",
"审核时没有记录内容摘要,需要信任发布者后安装": "No content digest was recorded at review; trust the publisher to install it",
"内容未经审核固定": "Content not pinned by review",
"审核时没有记录内容摘要,无法确认来源现在提供的仍是审核过的内容。只在你信任发布者 @{handle} 时安装。": "No content digest was recorded at review, so there is no way to confirm the source still serves what was reviewed. Install only if you trust the publisher @{handle}.",
"信任并安装": "Trust and install",
"你选择了信任这个发布者": "You chose to trust this publisher",
"以下是来源现在提供的内容,可能与审核时不同。确认后内容若有变化,安装会被拒绝。": "This is what the source serves now, which may differ from what was reviewed. If it changes after you confirm, the install is refused.",
"查看将安装的内容": "See what it installs",
"查看更新内容": "See what the update installs",
"MCP 服务只固定了启动方式与连接地址;它启动后自行下载或远端提供的代码不在固定范围内。": "For an MCP server only how it is started and where it connects are pinned; code it downloads or a remote end serves after that is not",
"技能不会被原地覆盖:先在「已安装」里移除旧版本,再回来安装": "A skill is not overwritten in place: remove the old version under Installed first, then install this one",
// 社区市场:发布
"浏览": "Browse",
"我的发布": "My packages",
"发布": "Publish",
"登录后可以在这里发布技能、插件、MCP 服务和主题,并查看审核进度。": "Sign in to publish skills, plugins, MCP servers and themes here, and to follow their review.",
"去登录": "Sign in",
"已提交 {slug} {version}": "Submitted {slug} {version}",
"审核通过后会在社区市场公开;在「我的发布」里可以看到审核进度。": "It goes public in the community market once approved; My packages shows where review stands.",
"再发布一个": "Publish another",
"查看我的发布": "See my packages",
"以 @{handle} 的名义提交,审核通过后公开。只收来源地址,不上传文件。": "Submitted as @{handle} and made public once approved. Only the source address is sent; no files are uploaded.",
"主题以插件包发布,包里只能有主题;带技能、钩子或 MCP 服务的包请按插件发布。": "A theme is published as a plugin package that holds only themes; a package with skills, hooks or MCP servers is published as a plugin.",
"小写字母、数字、点、下划线、连字符,最多 64 个字符。": "Lowercase letters, digits, dots, underscores and hyphens, up to 64 characters.",
"留空时新包为 0.1.0,更新自动加一个补丁号。": "Left empty, a new package starts at 0.1.0 and an update bumps the patch number.",
"来源地址": "Source address",
"摘要": "Summary",
"描述": "Description",
"用逗号分隔,最多 8 个": "Comma-separated, up to 8",
"没有提交成功": "The submission did not go through",
"提交后进入审核队列;审核员会固定审核时的内容,之后只安装那一份。": "It joins the review queue; the reviewer pins the content they reviewed, and only that is ever installed.",
"提交中…": "Submitting…",
"提交审核": "Submit for review",
"无法读取我的发布": "Could not read your packages",
"还没有发布过。": "Nothing published yet.",
"固定到提交的 GitHub 目录,形如 https://github.com/owner/repo/tree/(40 位提交号)/子目录": "A GitHub directory pinned to a commit, such as https://github.com/owner/repo/tree/(40-character commit)/subdir",
"SKILL.md 的 https 地址,或 GitHub 仓库里某个技能的目录。": "An https address of a SKILL.md, or one skill's directory in a GitHub repository.",
".mcp.json 或仓库的 https 地址,或 npm 包名。": "An https address of a .mcp.json or a repository, or an npm package name.",
"审核中": "In review",
"已公开": "Public",
"未通过": "Rejected",
"已隐藏": "Hidden",
"私有": "Private",
"已保存 {slug} {version},仅自己可见": "Saved {slug} {version}, visible only to you",
"它不会进入审核,也不会在社区市场出现;在「我的发布」里可以安装它,或随时提交审核。": "It skips review and never appears in the community market; install it from My packages, or submit it for review whenever you like.",
"仅自己可见:不提交审核,社区市场里只有你的账号能看到并安装": "Only me: skip review; in the community market only your account can see and install it",
"保存后只有你能看到;要公开时在「我的发布」里提交审核。": "Only you will see it; submit it for review from My packages to make it public.",
"保存为私有": "Save as private",
"仅你可见,未提交审核": "visible only to you, not submitted for review",
"返回我的发布": "Back to my packages",
"无法预览 {name}": "Could not preview {name}",
"正在预览将安装的内容…": "Previewing what would be installed…",
"未审核 · 仅你可见": "Unreviewed · visible only to you",
"这是你自己发布、还没有通过审核的版本。安装会核对与这次预览相同的内容摘要,内容在确认后有变化就拒绝安装。": "This is your own version, not yet approved by review. The install checks it against this preview's content digest and is refused if the content changes after you confirm.",
"内容摘要:{digest}": "Content digest: {digest}",
"长时间无进展": "Long stretches without progress",
"判断只看宿主能观察到的效果,不看命令内容;提示和暂停都不会向模型发送任何内容,也不会拒绝任何调用。": "Judged only from effects the host can observe, never from what a command says. Neither the notice nor the pause sends the model anything or refuses any call.",
"长时间无进展时暂停任务": "Pause a task that stops making progress",
"默认关闭:只在输入框上方提示,由你决定是否停止。开启后达到下面的条件会暂停任务,已完成的工作都会保留,发一条消息即可继续。": "Off by default: a notice appears above the composer and stopping is your call. When on, a task that meets the conditions below is paused; the work so far is kept and one message continues it.",
"连续无进展轮数": "Rounds without progress",
"没有改动文件、没有检查或任务步骤变化、没有读到新内容、也没有子代理返回的工具轮次。默认 {n}。": "Tool rounds in a row that changed no file, moved no check or task step, read nothing new and brought back no sub-agent result. Default {n}.",
"轮": "rounds",
"输入 token 上限": "Input token backstop",
"与模型无关的后备:自上次有进展以来的累计输入达到上下文窗口的这个倍数时同样提示。默认 {n} 倍。": "A model-independent backstop: the same notice once the input spent since the last progress reaches this many context windows. Default {n}×.",
"倍窗口": "× window",
"请输入 1 到 {n} 之间的整数。": "Enter a whole number from 1 to {n}.",
"无法读取无进展设置。": "Could not read the progress settings.",
};
@@ -17,6 +17,8 @@ export const EN_STORAGE: Record<string, string> = {
"Where the data is written and how much space it uses. Sessions and indexes keep growing while configuration and credentials do not, so only the former can be moved. A move takes effect after a restart.",
"无法读取存储占用。": "Cannot read what is on disk.",
"正在统计…": "Measuring…",
"统计超时": "Measuring timed out",
"目录过大,已统计到时间上限,实际占用不小于此数": "This folder is too large to finish measuring in time; the real size is at least this much.",
占用: "Space used",
原位置仍有残留数据: "Data remains in the previous location",
"以下内容仍位于 {dir}:{names}。迁移存储位置时未一并迁移,因此本机的壁纸、主题包或更新回滚备份可能显示为缺失。手动将这些目录复制到当前位置即可恢复。":
@@ -34,7 +34,6 @@ export const EN_WINDOW: Record<string, string> = {
"前进": "Forward",
"重新加载": "Reload",
"网址": "Address",
"页面被遮住时暂停显示": "The page is hidden while something covers it",
"搜索会话 / 项目": "Search sessions or projects",
"没有匹配的会话": "Nothing matches",
"清空": "Clear",
@@ -5,7 +5,7 @@
// function of the error and the installed catalogue.
import { beforeEach, describe, expect, it } from "vitest";
import { boot, STORAGE } from "./index";
import { PROVIDER_EDIT_DISABLED, SAVED_NOT_APPLIED, codes, reason } from "./kernel";
import { ACCOUNT_SIGNIN_DISABLED, PROVIDER_EDIT_DISABLED, SAVED_NOT_APPLIED, codes, reason } from "./kernel";
import { HttpError } from "../port/port";
// Pinned, not defaulted: with nothing stored the window follows the machine, so
@@ -94,6 +94,10 @@ describe("codes a caller branches on", () => {
expect(codes[PROVIDER_EDIT_DISABLED]).toBeTruthy();
});
it("spells the account refusal the same in the constant and the catalogue", () => {
expect(codes[ACCOUNT_SIGNIN_DISABLED]).toBeTruthy();
});
it("spells the saved-but-not-applied answers the same in the constant and the catalogue", () => {
for (const code of SAVED_NOT_APPLIED) expect(codes[code]).toBeTruthy();
});
+59
View File
@@ -15,6 +15,8 @@ import { t } from "./index";
// its literal keys — the kernel's parity guard reads this file as text and can
// only see those — so kernel.test.ts holds the two spellings together.
export const PROVIDER_EDIT_DISABLED = "provider.editing_disabled";
// A paired device answers this for every account route, votes included.
export const ACCOUNT_SIGNIN_DISABLED = "account.signin_disabled";
// The write landed and only applying it to the open conversation did not.
export const SAVED_NOT_APPLIED: readonly string[] = ["provider.saved_while_running", "provider.saved_model_unlisted", "runtime.rebuild_failed"];
@@ -47,6 +49,8 @@ const SAID: Record<string, string> = {
"provider.kind_unsupported": "无法识别「{kind}」这种接入方式",
"provider.no_models_picked": "请至少选择一个模型",
"provider.default_not_selected": "默认模型「{model}」不在已选择的模型中",
"provider.display_name_too_long": "显示名称最多 {max} 个字符",
"provider.display_name_invalid": "显示名称不能包含换行或其他控制字符",
// ── 来源:这个协议做不到 ─────────────────────────────────────────
"provider.no_thinking_param": "该协议不发送思考参数,启用后不会生效",
@@ -67,6 +71,10 @@ const SAID: Record<string, string> = {
"request.method_not_allowed": "该地址不接受此种请求方式",
"request.bad_content_type": "请求体必须是 application/json",
"permissions.editing_disabled": "这台服务器未开放权限编辑",
"permissions.rule_required": "请选择要收回的授权规则",
"permissions.busy": "任务正在运行,请先停止再收回已记住的授权",
"permissions.rebuild_unavailable": "当前会话无法重建,授权未更改",
"project_grants.unavailable": "无法更新当前项目的授权记录,请检查用户目录中的授权文件",
"sandbox.editing_disabled": "这台服务器未开放沙箱编辑",
"browser_tools.editing_disabled": "这台服务器未开放内置浏览器设置",
"roles.editing_disabled": "这台服务器未开放角色编辑",
@@ -80,6 +88,22 @@ const SAID: Record<string, string> = {
"roles.model_unknown": "没有已配置的模型匹配「{model}」",
"shell.editing_disabled": "这台服务器未开放 shell 设置",
"account.signin_disabled": "这台服务器未开放账号登录",
"backup.signed_out": "登录账号后才能使用云备份",
"backup.cloud_unavailable": "暂时连不上备份服务,稍后再试:{detail}",
"backup.label_too_long": "备注最多 {max} 个字",
"backup.collect_failed": "读取本机配置时出错:{detail}",
"backup.preview_failed": "无法和本机配置比对:{detail}",
"backup.weak_passphrase": "加密口令至少 {min} 个字符",
"backup.bad_categories": "至少选择一类要备份的内容",
"backup.cannot_decrypt": "口令不对,或这份备份已损坏",
"backup.unsupported_format": "这份备份来自更新的版本,请先升级 Studio",
"backup.malformed": "这份备份的内容无法识别",
"backup.too_large": "备份超出了大小上限",
"backup.plan_expired": "预览已失效,请重新打开这份备份",
"backup.unknown_item": "所选的项目不在这份备份里",
"backup.not_found": "这份备份已不存在",
"backup.limit_reached": "备份数量已达上限,请先删除一份旧备份",
"backup.consent_required": "有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复",
"workspace.changing_disabled": "这台服务器不支持切换工作区",
"settings.unknown_preset": "不存在该预设",
"drop.too_many_paths": "本次拖入 {count} 个,最多允许 {limit} 个",
@@ -91,6 +115,8 @@ const SAID: Record<string, string> = {
"provider.bad_max_output_tokens": "最大输出 Token 不能是负数",
"provider.bad_reasoning_protocol": "无法识别「{protocol}」这种思考协议",
"provider.default_effort_not_listed": "默认档位「{level}」不在填写的档位里",
"provider.model_default_effort_not_listed": "{model} 的默认档位「{level}」不在为它选的档位里",
"provider.model_effort_unlisted": "{model} 不在已启用的模型里,不能为它单独设置档位",
"provider.running": "这个模型上的对话正在运行,停止后再删除",
"session.running": "该会话正在运行,停止后再删除",
"workspace.running": "这个文件夹里有正在运行的对话,停止后再移除",
@@ -152,6 +178,7 @@ const SAID: Record<string, string> = {
"{provider} 没说自己有哪些推理强度档位。要有,得在它的配置块里写 reasoning_protocol 或 supported_efforts",
"effort.unsupported_level": "{provider} 不提供「{level}」档位,可选值为:{levels}",
"effort.no_provider": "无法识别当前使用的来源,请先切换一次模型",
"model_mode.unsupported": "当前模型 {model} 不支持「{mode}」模式",
"prompt_refine.empty": "输入框是空的,先写点内容再优化",
"prompt_refine.too_long": "内容超过 {max_bytes} 字节,无法优化",
@@ -236,6 +263,10 @@ const SAID: Record<string, string> = {
"compaction.no_soft_limit": "本次请求未包含阈值,未做任何修改",
"browser_tools.save_failed": "内置浏览器设置未能保存:{detail}",
"browser_tools.no_enabled": "本次请求未说明开关状态,未做任何修改",
"progress_watch.save_failed": "无进展设置未能保存:{detail}",
"workspace.untrustable": "主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹",
"workspace.trust_save_failed": "未能记下对此文件夹的信任决定:{detail}",
"progress_watch.out_of_range": "该数值超出允许范围,未做任何修改:{detail}",
"mcp.bad_declaration": "无法解析该服务器声明:{detail}",
"mcp.install_failed": "未能安装该服务器:{detail}",
"mcp.remove_failed": "未能移除该服务器:{detail}",
@@ -252,6 +283,30 @@ const SAID: Record<string, string> = {
"install.request_unreadable": "无法解析本次安装请求:{detail}",
"install.failed": "安装失败:{detail}",
"install.bad_answer": "无法解析安装器的响应:{detail}",
"market.bad_slug": "这不是有效的社区包名称",
"market.not_found": "社区市场中没有这个已审核的包",
"market.unreachable": "无法连接社区市场:{detail}",
"market.bad_response": "社区市场返回了无法识别的内容",
"market.filter_unsupported": "社区市场暂不支持只列出已固定内容的包",
"market.unpinned": "该包的审核版本没有固定内容,需要信任发布者后才能安装",
"market.bad_source": "该包的审核版本指向本机或不安全的来源,不从市场安装",
"market.version_changed": "审核版本已更新,请重新查看后再安装",
"market.content_changed": "来源内容已与审核版本不同,已拒绝安装",
"market.not_pinnable": "该来源的内容无法固定,已拒绝安装",
"market.plan_changed": "来源内容在确认后发生了变化,请重新查看",
"market.not_theme": "这个标为主题的包还会安装主题以外的内容,已拒绝安装",
"market.signed_out": "登录账号后才能发布或评价",
"market.email_unverified": "请先在 id.reasonix.io 验证账号邮箱,再发布或评价",
"market.not_owner": "这个名称已被其他发布者使用,请换一个名称",
"market.version_exists": "这个版本号已经发布过,请换一个版本号",
"market.rate_limited": "请求太频繁,请稍等一分钟再试",
"market.unpublishable": "这个来源审核通过后也无法从市场安装:插件和主题要指向固定到提交的 GitHub 目录(tree/40 位提交号),技能要用 https 地址,MCP 用 https 地址或 npm 包名",
"market.rejected": "社区市场拒绝了这次提交:{detail}",
"market.not_yours": "你的发布里没有这个包",
"market.not_private": "只有私有的包才能提交审核",
"market.unpreviewed": "未审核的版本要先预览,再按预览时的内容摘要安装",
"market.own_package": "不能评价自己发布的包",
"market.bad_vote": "评价只能是赞、踩或撤回",
"theme.unreadable": "无法读取该主题:{detail}",
"theme.not_a_pack": "这不是可安装的主题:{detail}",
"theme.install_failed": "主题未能写入磁盘:{detail}",
@@ -289,11 +344,15 @@ const SAID: Record<string, string> = {
"studio.pin_rejected": "版本固定未能保存:{detail}",
"update.install_running": "已有一个版本切换正在进行,请等待其完成后重试",
"update.install_rejected": "本次版本切换未能启动:{detail}",
"update.restart_busy": "有 {n} 项任务正在运行,重启会中断它们",
"update.nothing_ready": "没有已下载好的版本可供重启安装,请重新点击安装",
"loopback.host_rejected": "该请求未发往 Studio 监听的地址,已被拒绝",
"loopback.origin_rejected": "该页面不属于 Studio,无法对其操作",
"loopback.unauthorized": "缺少本次启动的凭据,请重新打开 Studio 后重试",
"loopback.misconfigured": "本机通道未建立,请重新打开 Studio 后重试",
"serve.host_rejected": "该服务未开启认证,只接受发往本机或其监听地址的请求",
"auth.launch_token_required": "该服务未开启认证,修改与审批需要本次启动的令牌,请用启动时打印的链接打开",
};
/** Reason is what a refused request answers with. `error` is English fallback
@@ -12,5 +12,10 @@ export const NOTICE_TEXT: Record<string, string> = {
permission_saved: "已记住这项授权,以后同样的操作不再询问",
permission_covered: "已有的授权规则覆盖了这项操作,无需另存",
permission_save_failed: "授权没能保存,只在本次会话内有效",
project_programs_awaiting_approval: "这个项目自带的钩子等程序要你批准后才会运行",
project_program_changed: "这个项目的程序在批准后被改动过,本次没有运行,需要重新批准",
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
await_user: "等待你的输入",
approval_mode_unrecognized: "配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换",
perseveration_loop: "模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型",
};
+22 -11
View File
@@ -14,26 +14,30 @@ import { install as installFileDrop } from "./ui/filedrop";
import { host } from "./port/host";
import { play } from "./boot/intro";
import { settled } from "./boot/gate";
import { onRemoteConnectionEnded, remoteConnectionEnded, remoteHub } from "./port/cloud_remote";
import { onRemoteConnectionEnded, remoteConnectionEnded, remoteHub, signInAgain, type RemoteEnd } from "./port/cloud_remote";
function RemoteAwareApp({ hub, remote }: { hub: HubPort; remote: boolean }) {
const [disconnected, setDisconnected] = useState(() => remote && remoteConnectionEnded());
function RemoteAwareApp({ hub, remote }: { hub: HubPort; remote: string | null }) {
const [ended, setEnded] = useState<RemoteEnd | null>(() => (remote ? remoteConnectionEnded() : null));
useEffect(() => {
if (!remote) return;
const closed = () => setDisconnected(true);
return onRemoteConnectionEnded(closed);
return onRemoteConnectionEnded(setEnded);
}, [remote]);
const reauth = ended?.kind === "reauth";
return (
<>
<App hub={hub} />
{disconnected && (
<div className="remote-closed" role="alert" aria-label={t("远程连接已断开")}>
{ended && remote && (
<div className="remote-closed" role="alert" aria-label={reauth ? t("需要重新登录") : t("远程连接已断开")}>
<span aria-hidden="true" />
<div>
<b>{t("远程连接已断开")}</b>
<p>{t("这台电脑已停止网页控制。重新连接需要再次验证设备状态。")}</p>
<b>{reauth ? t("需要重新登录") : t("远程连接已断开")}</b>
<p>{reauth
? t("为了安全,远程控制在登录满 24 小时或退出登录后需要重新登录。")
: t("这台电脑已停止网页控制。重新连接需要再次验证设备状态。")}</p>
</div>
<button onClick={() => location.reload()}>{t("重新连接")}</button>
<button onClick={() => (reauth ? void signInAgain(remote) : location.reload())}>
{reauth ? t("重新登录") : t("重新连接")}
</button>
</div>
)}
</>
@@ -47,6 +51,13 @@ function RemoteAwareApp({ hub, remote }: { hub: HubPort; remote: boolean }) {
async function pick(): Promise<HubPort> {
const remoteDevice = new URLSearchParams(location.search).get("device")?.trim();
if (remoteDevice) return remoteHub(remoteDevice);
// The hosted web build has no kernel behind its origin; without a device it
// can only send the visitor to pick one.
const remoteHome = import.meta.env.VITE_REMOTE_HOME;
if (remoteHome) {
location.replace(remoteHome);
return new Promise<never>(() => {});
}
try {
// The hub's own list rather than a pane's /status: it answers with every
// pane closed, which is when a reload has to find the kernel again.
@@ -134,7 +145,7 @@ pick().then(
installFileDrop();
root.render(
<StrictMode>
<RemoteAwareApp hub={hub} remote={new URLSearchParams(location.search).has("device")} />
<RemoteAwareApp hub={hub} remote={new URLSearchParams(location.search).get("device")?.trim() || null} />
</StrictMode>,
);
intro.progress(2);
+97
View File
@@ -0,0 +1,97 @@
// Configuration backups kept in the signed-in account. The kernel seals each
// one with the person's passphrase before it leaves; the service stores only
// the envelope and the metadata listed here.
export type BackupCategory = "settings" | "extensions" | "memory" | "automation" | "secrets";
export interface BackupCategoryInfo {
id: BackupCategory;
defaultOn: boolean;
consent: boolean;
}
export interface BackupEntry {
id: string;
label: string;
format: number;
appVersion: string;
platform: string;
categories: BackupCategory[];
ciphertextBytes: number;
createdAt: string;
}
export interface BackupCatalog {
categories: BackupCategoryInfo[];
backups: BackupEntry[];
limits: { maxCount: number; maxBytes: number };
minPassphrase: number;
}
export interface BackupOmission {
kind: string;
name: string;
reason: string;
}
export interface BackupCreateRequest {
label: string;
categories: BackupCategory[];
passphrase: string;
appVersion?: string;
}
export interface BackupCreated {
backup: BackupEntry;
omitted?: BackupOmission[];
}
export interface BackupPathRef {
path: string;
exists: boolean;
}
// consent is set when restoring the item runs code on this machine, sends
// conversations or a stored key somewhere new, replaces a stored key, or
// imports other files into the standing instructions. The kernel refuses such an item unless it is
// named again in consented, whatever the selection said.
export interface BackupPlanItem {
id: string;
category: BackupCategory;
kind: string;
name: string;
status: "new" | "changed" | "same" | "install";
consent?: "executes" | "endpoint" | "replaces_secret" | "imports";
summary?: string;
previous?: string;
paths?: BackupPathRef[];
crossPlatform?: boolean;
files?: number;
details?: string[];
content?: string;
recommended: boolean;
}
export interface BackupPlan {
planId: string;
createdAt: string;
appVersion?: string;
platform: string;
samePlatform: boolean;
categories: BackupCategory[];
items: BackupPlanItem[];
omitted?: BackupOmission[];
}
export interface BackupPluginRef {
name: string;
source: string;
version?: string;
commit?: string;
}
export interface BackupApplyResult {
applied: string[];
plugins?: BackupPluginRef[];
failed?: { id: string; error: string }[];
reloadError?: string;
}
+17 -3
View File
@@ -11,14 +11,17 @@ export interface PermissionLists {
deny: string[];
}
// What an editor needs beyond the lists: the file a save lands in, and — only
// when a project config declares its own — the merge actually in force, which
// an edit here cannot move.
// Editable user lists and separate project rules that are in force. shadowedBy
// names a project config that outranks the edited file; effective is the
// merged boundary the controller actually loaded.
export interface PermissionRules extends PermissionLists {
path: string;
// What was allowed on a prompt for this session alone. Nothing wrote it down,
// so the file on screen is less than the agent may currently do.
granted?: string[];
remembered?: string[];
rememberedPath?: string;
rememberedErrorCode?: string;
shadowedBy?: string;
effective?: PermissionLists;
}
@@ -78,3 +81,14 @@ export interface BrowserToolsSettings {
effective: boolean;
path: string;
}
// When a run reads as no longer moving. Only the user file holds it: a
// project file cannot pause the user's runs.
export interface ProgressWatchSettings {
pause: boolean;
rounds: number;
tokenMultiple: number;
defaultRounds: number;
defaultTokenMultiple: number;
path: string;
}
@@ -0,0 +1,257 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
CLOSE_IDLE,
CLOSE_REAUTH_REQUIRED,
CLOSE_REVOKED,
closeAction,
linkCodec,
RemoteLink,
RemoteLinkError,
type RemoteConnection,
type RemoteEnd,
} from "./cloud_link";
class FakeSocket extends EventTarget {
readyState: number = WebSocket.OPEN;
readonly sent: Array<Record<string, unknown>> = [];
send(wire: string) {
this.sent.push(JSON.parse(wire) as Record<string, unknown>);
}
close(code = 1000, reason = "") {
if (this.readyState === WebSocket.CLOSED) return;
this.readyState = WebSocket.CLOSED;
this.dispatchEvent(Object.assign(new Event("close"), { code, reason }));
}
reply(id: string, body: string, extra: Record<string, unknown> = {}) {
this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({
type: "desktop.response", id, index: 0, done: true, status: 200,
body: linkCodec.bytesToBase64(new TextEncoder().encode(body)), ...extra,
}) }));
}
}
function connection(features: string[] = [], instance = "desktop-1"): { conn: RemoteConnection; socket: FakeSocket } {
const socket = new FakeSocket();
return {
socket,
conn: {
socket: socket as unknown as WebSocket,
features,
instance,
listen(onMessage, onClose) {
socket.addEventListener("message", (event) => onMessage(String((event as MessageEvent).data)));
socket.addEventListener("close", (event) => {
const closed = event as Event & { code: number; reason: string };
onClose(closed.code, closed.reason);
});
},
channel: {
seal: async (value) => JSON.stringify(value),
open: async (payload) => JSON.parse(payload) as Record<string, unknown>,
},
},
};
}
function request(method = "GET", path = "/rt/one/events/replay") {
return new Request(`${location.origin}${path}`, { method, body: method === "GET" ? undefined : "{}" });
}
let ends: RemoteEnd[];
beforeEach(() => {
vi.useFakeTimers();
ends = [];
});
afterEach(() => vi.useRealTimers());
describe("relay close codes", () => {
it("reconnects on idle, legacy expiry and dropped sockets, and stops on revocation or sign-in", () => {
expect(closeAction(CLOSE_IDLE)).toBe("reconnect");
expect(closeAction(4003)).toBe("reconnect");
expect(closeAction(1006)).toBe("reconnect");
expect(closeAction(1012)).toBe("reconnect");
expect(closeAction(CLOSE_REAUTH_REQUIRED)).toBe("reauth");
expect(closeAction(CLOSE_REVOKED)).toBe("end");
expect(closeAction(4004)).toBe("end");
});
});
describe("remote link", () => {
it("resends an in-flight read on the next connection and resolves it there", async () => {
const first = connection();
const second = connection();
const dial = vi.fn(async () => second.conn);
const link = new RemoteLink(first.conn, dial, (end) => ends.push(end));
const answer = link.fetch(request());
await vi.advanceTimersByTimeAsync(0);
const id = String(first.socket.sent[0]?.id);
first.socket.close(CLOSE_IDLE, "Idle timeout");
await vi.advanceTimersByTimeAsync(1_000);
expect(dial).toHaveBeenCalledOnce();
expect(second.socket.sent[0]?.id).toBe(id);
second.socket.reply(id, "late but whole");
await expect((await answer).text()).resolves.toBe("late but whole");
expect(ends).toEqual([]);
});
it("queues requests made while reconnecting and sends them once connected", async () => {
const first = connection();
const second = connection();
const link = new RemoteLink(first.conn, async () => second.conn, (end) => ends.push(end));
first.socket.close(4003, "Admission expired");
const answer = link.fetch(request("POST", "/rt/one/send"));
await vi.advanceTimersByTimeAsync(0);
expect(first.socket.sent).toEqual([]);
await vi.advanceTimersByTimeAsync(1_000);
const id = String(second.socket.sent[0]?.id);
second.socket.reply(id, "sent");
await expect((await answer).text()).resolves.toBe("sent");
});
it("never resends a state change to a desktop that cannot recognise the resend", async () => {
const first = connection();
const second = connection();
const link = new RemoteLink(first.conn, async () => second.conn, (end) => ends.push(end));
const answer = link.fetch(request("POST", "/rt/one/send"));
const rejected = answer.catch((error: Error) => error);
await vi.advanceTimersByTimeAsync(0);
first.socket.close(1006, "");
await vi.advanceTimersByTimeAsync(1_000);
expect(await rejected).toBeInstanceOf(Error);
expect(second.socket.sent).toEqual([]);
});
it("resends a state change under the same id when the desktop replays answers", async () => {
const first = connection(["replay"]);
const second = connection(["replay"]);
const link = new RemoteLink(first.conn, async () => second.conn, (end) => ends.push(end));
const answer = link.fetch(request("POST", "/rt/one/send"));
await vi.advanceTimersByTimeAsync(0);
const id = String(first.socket.sent[0]?.id);
first.socket.close(1006, "");
await vi.advanceTimersByTimeAsync(1_000);
expect(second.socket.sent[0]?.id).toBe(id);
second.socket.reply(id, "once");
await expect((await answer).text()).resolves.toBe("once");
});
it("does not resend a state change to a restarted desktop, whose replay cache is empty", async () => {
const first = connection(["replay"], "desktop-1");
const second = connection(["replay"], "desktop-2");
const link = new RemoteLink(first.conn, async () => second.conn, (end) => ends.push(end));
const rejected = link.fetch(request("POST", "/rt/one/send")).catch((error: Error) => error);
const read = link.fetch(request());
await vi.advanceTimersByTimeAsync(0);
first.socket.close(1006, "");
await vi.advanceTimersByTimeAsync(1_000);
expect(await rejected).toBeInstanceOf(Error);
expect(second.socket.sent).toHaveLength(1);
expect(second.socket.sent[0]?.method).toBe("GET");
second.socket.reply(String(second.socket.sent[0]?.id), "read again");
await expect((await read).text()).resolves.toBe("read again");
});
it("sends commands in the order they were sealed, however long sealing takes", async () => {
const { conn, socket } = connection();
let slow = true;
conn.channel.seal = async (value) => {
if (slow) {
slow = false;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return JSON.stringify(value);
};
const link = new RemoteLink(conn, vi.fn(), (end) => ends.push(end));
void link.fetch(request("POST", "/first"));
void link.fetch(request("POST", "/second"));
await vi.advanceTimersByTimeAsync(100);
expect(socket.sent.map((command) => command.path)).toEqual(["/first", "/second"]);
});
it("asks for a sign-in instead of reconnecting when the relay says so", async () => {
const first = connection();
const dial = vi.fn();
const link = new RemoteLink(first.conn, dial, (end) => ends.push(end));
const pending = link.fetch(request()).catch((error: Error) => error);
await vi.advanceTimersByTimeAsync(0);
first.socket.close(CLOSE_REAUTH_REQUIRED, "Sign-in required");
await vi.advanceTimersByTimeAsync(60_000);
expect(dial).not.toHaveBeenCalled();
expect(ends).toEqual([{ kind: "reauth", reason: "Sign-in required" }]);
expect(await pending).toBeInstanceOf(Error);
});
it("stops for good when the device is revoked", async () => {
const first = connection();
const dial = vi.fn();
new RemoteLink(first.conn, dial, (end) => ends.push(end));
first.socket.close(CLOSE_REVOKED, "Access revoked");
await vi.advanceTimersByTimeAsync(60_000);
expect(dial).not.toHaveBeenCalled();
expect(ends).toEqual([{ kind: "ended", reason: "Access revoked" }]);
});
it("ends with the account service's reason when a reconnect is refused", async () => {
const first = connection();
const dial = vi.fn()
.mockRejectedValueOnce(new RemoteLinkError("transient", "relay busy"))
.mockRejectedValueOnce(new RemoteLinkError("reauth", "sign in"));
new RemoteLink(first.conn, dial, (end) => ends.push(end));
first.socket.close(CLOSE_IDLE, "Idle timeout");
await vi.advanceTimersByTimeAsync(5_000);
expect(dial).toHaveBeenCalledTimes(2);
expect(ends).toEqual([{ kind: "reauth", reason: "sign in" }]);
});
it("rebuilds the encrypted session when the desktop goes silent on an open socket", async () => {
const first = connection();
const second = connection();
const dial = vi.fn(async () => second.conn);
const link = new RemoteLink(first.conn, dial, (end) => ends.push(end), { requestTimeoutMs: 30_000 });
const answer = link.fetch(request());
await vi.advanceTimersByTimeAsync(30_000);
await vi.advanceTimersByTimeAsync(1_000);
expect(first.socket.readyState).toBe(WebSocket.CLOSED);
expect(dial).toHaveBeenCalledOnce();
const id = String(second.socket.sent[0]?.id);
second.socket.reply(id, "fresh session");
await expect((await answer).text()).resolves.toBe("fresh session");
});
it("gives up after its retries and says the connection ended", async () => {
const first = connection();
const dial = vi.fn(async () => { throw new RemoteLinkError("transient", "offline"); });
new RemoteLink(first.conn, dial, (end) => ends.push(end), { retryDelaysMs: [10, 10] });
first.socket.close(1006, "");
await vi.advanceTimersByTimeAsync(100);
expect(dial).toHaveBeenCalledTimes(2);
expect(ends[0]?.kind).toBe("ended");
});
});
@@ -0,0 +1,281 @@
import { t } from "../i18n";
// Relay close codes, decided by the relay and read here by number alone.
export const CLOSE_IDLE = 4408;
export const CLOSE_REAUTH_REQUIRED = 4401;
export const CLOSE_REVOKED = 4403;
export const CLOSE_DISCONNECTED_BY_DEVICE = 4004;
const CLOSE_RESYNC = 4000;
export type CloseAction = "reconnect" | "reauth" | "end";
export function closeAction(code: number): CloseAction {
if (code === CLOSE_REAUTH_REQUIRED) return "reauth";
if (code === CLOSE_REVOKED || code === CLOSE_DISCONNECTED_BY_DEVICE) return "end";
// Protocol violations would repeat on a new connection.
if (code === 1003 || code === 1008 || code === 1009) return "end";
return "reconnect";
}
export interface RemoteEnd {
kind: "ended" | "reauth";
reason: string;
}
// Why a (re)connection attempt failed, as the account service or the relay
// said it: "transient" is worth another attempt, the others are not.
export class RemoteLinkError extends Error {
constructor(readonly kind: "reauth" | "ended" | "transient", readonly reason: string) {
super(reason);
}
}
export interface RemoteChannel {
seal(value: unknown): Promise<string>;
open(payload: string): Promise<Record<string, unknown>>;
}
export interface RemoteConnection {
socket: WebSocket;
channel: RemoteChannel;
features: string[];
instance: string;
listen(onMessage: (data: string) => void, onClose: (code: number, reason: string) => void): void;
}
interface Pending {
id: string;
method: string;
command: Record<string, unknown>;
sentTo: RemoteConnection | null;
chunks: Map<number, string>;
status?: number;
contentType?: string;
etag?: string;
timer?: ReturnType<typeof setTimeout>;
resolve: (value: Response) => void;
reject: (reason: unknown) => void;
}
export interface RemoteLinkOptions {
requestTimeoutMs: number;
retryDelaysMs: number[];
}
const DEFAULTS: RemoteLinkOptions = {
requestTimeoutMs: 30_000,
retryDelaysMs: [500, 1_000, 2_000, 4_000, 8_000, 15_000],
};
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
for (let at = 0; at < bytes.length; at += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000));
}
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
}
function base64ToBytes(value: string) {
const padded = value.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - value.length % 4) % 4);
return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
}
function concatChunks(chunks: Map<number, string>) {
const parts = [...chunks.entries()].sort(([a], [b]) => a - b).map(([, value]) => base64ToBytes(value));
const joined = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
let offset = 0;
for (const part of parts) {
joined.set(part, offset);
offset += part.length;
}
return joined;
}
// Carries desktop requests over the relay and keeps doing so across relay
// reconnects. A request that never left, or one the desktop can safely see
// twice, is resent on the next connection under the same id; a state-changing
// one already sent to a desktop that cannot recognise a resend is failed
// instead, so it never runs twice.
export class RemoteLink {
private current: RemoteConnection | null = null;
private readonly pending = new Map<string, Pending>();
private reconnecting = false;
private ended = false;
private lastHeardAt = Date.now();
private sending: Promise<void> = Promise.resolve();
private readonly options: RemoteLinkOptions;
constructor(
first: RemoteConnection,
private readonly dial: () => Promise<RemoteConnection>,
private readonly onEnd: (end: RemoteEnd) => void,
options: Partial<RemoteLinkOptions> = {},
) {
this.options = { ...DEFAULTS, ...options };
this.attach(first);
}
async fetch(request: Request): Promise<Response> {
if (this.ended) throw new Error(t("远程连接已断开,请重新连接。"));
const id = crypto.randomUUID();
const body = new Uint8Array(await request.arrayBuffer());
const command = {
v: 1, type: "desktop.request", id, method: request.method,
path: request.url.slice(location.origin.length), body: bytesToBase64(body),
};
return new Promise<Response>((resolve, reject) => {
const entry: Pending = {
id, method: request.method.toUpperCase(), command, sentTo: null, chunks: new Map(), resolve, reject,
};
this.pending.set(id, entry);
void this.send(entry);
});
}
private attach(connection: RemoteConnection) {
this.current = connection;
this.lastHeardAt = Date.now();
connection.listen((data) => {
if (this.current !== connection) return;
this.lastHeardAt = Date.now();
void this.receive(connection, data);
}, (code, reason) => {
if (this.current === connection) this.lost(code, reason);
});
}
// Commands are numbered as they are sealed, so they leave in sealing order.
private send(entry: Pending): Promise<void> {
this.sending = this.sending.then(() => this.sendNow(entry), () => this.sendNow(entry));
return this.sending;
}
private async sendNow(entry: Pending) {
const connection = this.current;
if (!connection || connection.socket.readyState !== WebSocket.OPEN || !this.pending.has(entry.id)) return;
const wire = await connection.channel.seal(entry.command);
if (this.current !== connection || connection.socket.readyState !== WebSocket.OPEN || !this.pending.has(entry.id)) return;
entry.chunks.clear();
entry.sentTo = connection;
clearTimeout(entry.timer);
entry.timer = setTimeout(() => this.timedOut(entry), this.options.requestTimeoutMs);
connection.socket.send(wire);
}
// A desktop that has said nothing at all for a whole request timeout has lost
// this connection's encrypted session; one that is merely slow has not.
private timedOut(entry: Pending) {
if (!this.pending.has(entry.id)) return;
if (this.current && Date.now() - this.lastHeardAt >= this.options.requestTimeoutMs) {
const stale = this.current;
this.lost(CLOSE_RESYNC, "Desktop stopped answering");
try { stale.socket.close(CLOSE_RESYNC, "Resync"); } catch { /* already closing */ }
return;
}
this.pending.delete(entry.id);
entry.reject(new Error(t("远程 Studio 暂无响应,请检查电脑是否在线后重试。")));
}
private lost(code: number, reason: string) {
if (this.ended) return;
this.current = null;
const action = closeAction(code);
if (action !== "reconnect") {
this.finish({ kind: action === "reauth" ? "reauth" : "ended", reason });
return;
}
for (const entry of this.pending.values()) clearTimeout(entry.timer);
this.settleUnanswered((sentTo) => sentTo.features.includes("replay"));
if (!this.reconnecting) void this.reconnect();
}
private async reconnect() {
this.reconnecting = true;
try {
for (const delay of this.options.retryDelaysMs) {
await new Promise((resolve) => setTimeout(resolve, delay));
if (this.ended) return;
try {
const connection = await this.dial();
if (this.ended) {
connection.socket.close(1000, "Ended");
return;
}
this.attach(connection);
this.settleUnanswered((sentTo) =>
sentTo.features.includes("replay") && sentTo.instance !== "" && sentTo.instance === connection.instance);
for (const entry of this.pending.values()) void this.send(entry);
return;
} catch (error) {
if (error instanceof RemoteLinkError && error.kind !== "transient") {
this.finish({ kind: error.kind, reason: error.reason });
return;
}
}
}
this.finish({ kind: "ended", reason: t("远程 Studio 暂无响应,请检查电脑是否在线后重试。") });
} finally {
this.reconnecting = false;
}
}
// A state change already sent is resent only where it cannot run twice: to
// the same desktop process, which answers a resend from its replay cache.
private settleUnanswered(canResend: (sentTo: RemoteConnection) => boolean) {
for (const entry of [...this.pending.values()]) {
const idempotent = entry.method === "GET" || entry.method === "HEAD";
if (entry.sentTo && !idempotent && !canResend(entry.sentTo)) {
this.pending.delete(entry.id);
entry.reject(new Error(t("连接中断,这次操作可能没有完成,请刷新后确认。")));
}
}
}
private finish(end: RemoteEnd) {
if (this.ended) return;
this.ended = true;
const error = new Error(end.kind === "reauth"
? t("远程控制需要重新登录。")
: t("远程连接已断开,请重新连接。"));
for (const entry of this.pending.values()) {
clearTimeout(entry.timer);
entry.reject(error);
}
this.pending.clear();
this.onEnd(end);
}
private async receive(connection: RemoteConnection, wire: string) {
let message: Record<string, unknown>;
try {
message = await connection.channel.open(wire);
} catch {
return;
}
const id = typeof message.id === "string" ? message.id : "";
const pending = this.pending.get(id);
if (!pending || this.current !== connection) return;
if (message.type === "error") {
clearTimeout(pending.timer);
this.pending.delete(id);
pending.reject(new Error(typeof message.error === "string" ? message.error : t("远程 Studio 拒绝了这次请求。")));
return;
}
if (message.type !== "desktop.response" || typeof message.index !== "number" || typeof message.body !== "string") return;
pending.status = typeof message.status === "number" ? message.status : pending.status;
pending.contentType = typeof message.contentType === "string" ? message.contentType : pending.contentType;
pending.etag = typeof message.etag === "string" ? message.etag : pending.etag;
pending.chunks.set(message.index, message.body);
if (message.done !== true) return;
clearTimeout(pending.timer);
this.pending.delete(id);
const headers = new Headers();
if (pending.contentType) headers.set("content-type", pending.contentType);
if (pending.etag) headers.set("etag", pending.etag);
const status = pending.status ?? 500;
const bytes = concatChunks(pending.chunks);
pending.resolve(new Response(status === 204 || status === 304 ? null : bytes, { status, headers }));
}
}
export const linkCodec = { bytesToBase64, base64ToBytes, concatChunks };
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { onRemoteConnectionEnded, RemoteEventSource, remoteCodec, remoteConnectionEnded } from "./cloud_remote";
import { openLiveStream, STREAM_SILENCE_MS } from "./livestream";
describe("remote Studio binary framing", () => {
it("round-trips binary bodies and joins response chunks in index order", () => {
@@ -44,21 +45,17 @@ describe("remote Studio binary framing", () => {
it("announces a relay disconnect to the Web Studio shell", () => {
let reason = "";
const stop = onRemoteConnectionEnded((value) => { reason = value; });
remoteCodec.announceClosed("Disconnected by device");
const stop = onRemoteConnectionEnded((end) => { reason = end.reason; });
remoteCodec.announceClosed({ kind: "ended", reason: "Disconnected by device" });
stop();
expect(reason).toBe("Disconnected by device");
expect(remoteConnectionEnded()).toBe(true);
expect(remoteConnectionEnded()).toEqual({ kind: "ended", reason: "Disconnected by device" });
});
it("starts polling after the host watermark instead of replaying restored history", async () => {
vi.useFakeTimers();
const fetchMock = vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response(JSON.stringify({
frames: [{ kind: "message", seq: 7, text: "already in history" }],
complete: true,
watermark: 7,
}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ frames: null, complete: true, watermark: 7 }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
frames: [{ kind: "message", seq: 8, text: "new reply" }],
complete: true,
@@ -69,15 +66,72 @@ describe("remote Studio binary framing", () => {
source.onmessage = (event) => seen.push(JSON.parse(event.data) as Record<string, unknown>);
await vi.advanceTimersByTimeAsync(0);
expect(seen).toEqual([]);
expect(String(fetchMock.mock.calls[0][0])).toContain("lastEventId=0");
expect(String(fetchMock.mock.calls[0][0])).toBe(`/rt/one/events/replay?lastEventId=${Number.MAX_SAFE_INTEGER}`);
expect(seen).toEqual([{ kind: "stream_watermark", seq: 7 }]);
await vi.advanceTimersByTimeAsync(200);
expect(seen).toEqual([{ kind: "message", seq: 8, text: "new reply" }]);
expect(String(fetchMock.mock.calls[1][0])).toContain("lastEventId=7");
await vi.advanceTimersByTimeAsync(1500);
expect(String(fetchMock.mock.calls[1][0])).toBe("/rt/one/events/replay?lastEventId=7");
expect(seen.slice(1)).toEqual([{ kind: "message", seq: 8, text: "new reply" }]);
source.close();
fetchMock.mockRestore();
vi.useRealTimers();
});
it("resumes from the cursor its URL carries", async () => {
vi.useFakeTimers();
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
frames: [{ kind: "message", seq: 6, text: "missed while away" }],
complete: true,
watermark: 6,
}), { status: 200 }));
const seen: Array<Record<string, unknown>> = [];
const source = new RemoteEventSource("/rt/one/events?lastEventId=5");
source.onmessage = (event) => seen.push(JSON.parse(event.data) as Record<string, unknown>);
await vi.advanceTimersByTimeAsync(0);
source.close();
expect(String(fetchMock.mock.calls[0][0])).toBe("/rt/one/events/replay?lastEventId=5");
expect(seen).toEqual([{ kind: "message", seq: 6, text: "missed while away" }]);
fetchMock.mockRestore();
vi.useRealTimers();
});
it("says so when the log no longer reaches the cursor", async () => {
vi.useFakeTimers();
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
frames: [{ kind: "message", seq: 40, text: "oldest kept" }],
complete: false,
watermark: 40,
}), { status: 200 }));
const seen: Array<Record<string, unknown>> = [];
const source = new RemoteEventSource("/rt/one/events?lastEventId=5");
source.onmessage = (event) => seen.push(JSON.parse(event.data) as Record<string, unknown>);
await vi.advanceTimersByTimeAsync(0);
source.close();
expect(seen).toEqual([
{ kind: "stream_gap", seq: 40 },
{ kind: "message", seq: 40, text: "oldest kept" },
]);
fetchMock.mockRestore();
vi.useRealTimers();
});
it("keeps an idle pane's stream under the silence watchdog", async () => {
vi.useFakeTimers();
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () =>
new Response(JSON.stringify({ frames: null, complete: true, watermark: 7 }), { status: 200 }));
vi.stubGlobal("EventSource", RemoteEventSource);
let opened = 0;
const stop = openLiveStream(() => `/rt/one/events${opened++ ? "?lastEventId=7" : ""}`, () => {});
await vi.advanceTimersByTimeAsync(STREAM_SILENCE_MS * 3);
stop();
expect(opened).toBe(1);
for (const [input] of fetchMock.mock.calls) expect(String(input)).toMatch(/^\/rt\/one\/events\/replay\?/);
fetchMock.mockRestore();
vi.unstubAllGlobals();
vi.useRealTimers();
});
});
+140 -165
View File
@@ -1,25 +1,43 @@
import type { HubPort } from "./hub";
import { SseHub } from "./hub";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import { linkCodec, RemoteLink, RemoteLinkError, type RemoteConnection, type RemoteEnd } from "./cloud_link";
const ACCOUNT = (import.meta.env.VITE_ACCOUNTS_API || "https://id.reasonix.io").replace(/\/$/, "");
const RELAY = (import.meta.env.VITE_REMOTE_GATEWAY || "wss://remote.reasonix.io").replace(/\/$/, "");
const REMOTE_HOME = import.meta.env.VITE_REMOTE_HOME || "https://reasonix.io/remote/";
const HANDSHAKE_TIMEOUT_MS = 30_000;
const REQUEST_TIMEOUT_MS = 30_000;
let connectionEnded = false;
const connectionEndedListeners = new Set<(reason: string) => void>();
let connectionEnded: RemoteEnd | null = null;
const connectionEndedListeners = new Set<(end: RemoteEnd) => void>();
const { bytesToBase64, base64ToBytes, concatChunks } = linkCodec;
function announceClosed(reason = "") {
connectionEnded = true;
for (const listener of connectionEndedListeners) listener(reason);
function announceClosed(end: RemoteEnd) {
connectionEnded = end;
for (const listener of connectionEndedListeners) listener(end);
}
export type { RemoteEnd };
export const remoteConnectionEnded = () => connectionEnded;
export const onRemoteConnectionEnded = (listener: (reason: string) => void) => {
export const onRemoteConnectionEnded = (listener: (end: RemoteEnd) => void) => {
connectionEndedListeners.add(listener);
return () => { connectionEndedListeners.delete(listener); };
};
// Signing out first is what makes the sign-in page ask: it sends a visitor who
// still holds a session straight on, and that session is the one too old to
// control a computer.
export async function signInAgain(deviceId: string, nativeFetch: typeof fetch = globalThis.fetch.bind(globalThis)) {
try {
await nativeFetch(`${ACCOUNT}/auth/logout`, { method: "POST", credentials: "include" });
} catch {
// The sign-in page still works; it will just offer to continue as the old session.
}
const home = new URL(REMOTE_HOME);
home.searchParams.set("device", deviceId);
location.href = `${home.origin}/login/?next=${encodeURIComponent(home.pathname + home.search)}`;
}
interface RemoteDevice {
id: string;
publicKey: string;
@@ -27,42 +45,6 @@ interface RemoteDevice {
revokedAt?: string | null;
}
interface PendingResponse {
chunks: Map<number, string>;
resolve: (value: Response) => void;
reject: (reason: unknown) => void;
timer: ReturnType<typeof setTimeout>;
status?: number;
contentType?: string;
etag?: string;
}
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
for (let at = 0; at < bytes.length; at += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000));
}
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
}
function base64ToBytes(value: string) {
const padded = value.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - value.length % 4) % 4);
const binary = atob(padded);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
function concatChunks(chunks: Map<number, string>) {
const parts = [...chunks.entries()].sort(([a], [b]) => a - b).map(([, value]) => base64ToBytes(value));
const size = parts.reduce((total, part) => total + part.length, 0);
const joined = new Uint8Array(size);
let offset = 0;
for (const part of parts) {
joined.set(part, offset);
offset += part.length;
}
return joined;
}
async function encryptedChannel(device: RemoteDevice, socket: WebSocket) {
const pair = await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);
const deviceKey = await crypto.subtle.importKey("raw", base64ToBytes(device.publicKey), { name: "X25519" }, false, []);
@@ -81,10 +63,21 @@ async function encryptedChannel(device: RemoteDevice, socket: WebSocket) {
socket.send(JSON.stringify({
v: 1, type: "hello", publicKey: bytesToBase64(publicKey), salt: bytesToBase64(salt),
}));
// Once the desktop names this session, every command carries that name and
// an increasing number, so a relay cannot replay a command into a later
// session or twice into this one.
let bound: string | null = null;
let seq = 0;
return {
bind(session: string) {
bound = session;
},
async seal(value: unknown) {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const plain = new TextEncoder().encode(JSON.stringify(value));
const stamped = bound && value && typeof value === "object"
? { ...(value as Record<string, unknown>), session: bound, seq: ++seq }
: value;
const plain = new TextEncoder().encode(JSON.stringify(stamped));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce, additionalData: info }, key, plain);
return JSON.stringify({ v: 1, nonce: bytesToBase64(nonce), ciphertext: bytesToBase64(new Uint8Array(ciphertext)) });
},
@@ -141,94 +134,28 @@ function waitForSocketOpen(socket: WebSocket, timeout = HANDSHAKE_TIMEOUT_MS): P
});
}
class RemoteTransport {
private readonly pending = new Map<string, PendingResponse>();
constructor(
private readonly socket: WebSocket,
private readonly channel: Awaited<ReturnType<typeof encryptedChannel>>,
) {
socket.addEventListener("message", (event) => void this.receive(String(event.data)));
socket.addEventListener("close", (event) => {
this.failAll(new Error(t("远程连接已断开,请重新连接。")));
announceClosed(event.reason);
});
}
async fetch(request: Request): Promise<Response> {
if (this.socket.readyState !== WebSocket.OPEN) throw new Error(t("远程连接已断开,请重新连接。"));
const id = crypto.randomUUID();
const body = new Uint8Array(await request.arrayBuffer());
const response = new Promise<Response>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(t("远程 Studio 暂无响应,请检查电脑是否在线后重试。")));
}, REQUEST_TIMEOUT_MS);
this.pending.set(id, { chunks: new Map(), resolve, reject, timer });
});
const payload = await this.channel.seal({
v: 1, type: "desktop.request", id, method: request.method,
path: request.url.slice(location.origin.length), body: bytesToBase64(body),
});
if (this.socket.readyState !== WebSocket.OPEN) {
const pending = this.pending.get(id);
if (pending) {
clearTimeout(pending.timer);
this.pending.delete(id);
}
throw new Error(t("远程连接已断开,请重新连接。"));
}
this.socket.send(payload);
return response;
}
private async receive(wire: string) {
let message: Record<string, unknown>;
try {
message = await this.channel.open(wire);
} catch {
return;
}
const id = typeof message.id === "string" ? message.id : "";
const pending = this.pending.get(id);
if (!pending) return;
if (message.type === "error") {
clearTimeout(pending.timer);
this.pending.delete(id);
pending.reject(new Error(typeof message.error === "string" ? message.error : t("远程 Studio 拒绝了这次请求。")));
return;
}
if (message.type !== "desktop.response" || typeof message.index !== "number" || typeof message.body !== "string") return;
pending.status = typeof message.status === "number" ? message.status : pending.status;
pending.contentType = typeof message.contentType === "string" ? message.contentType : pending.contentType;
pending.etag = typeof message.etag === "string" ? message.etag : pending.etag;
pending.chunks.set(message.index, message.body);
if (message.done !== true) return;
clearTimeout(pending.timer);
this.pending.delete(id);
const headers = new Headers();
if (pending.contentType) headers.set("content-type", pending.contentType);
if (pending.etag) headers.set("etag", pending.etag);
const status = pending.status ?? 500;
const bytes = concatChunks(pending.chunks);
pending.resolve(new Response(status === 204 || status === 304 ? null : bytes, { status, headers }));
}
private failAll(error: Error) {
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
interface ReplayAnswer {
frames?: Array<Record<string, unknown>> | null;
complete?: boolean;
watermark?: number;
}
// Stands in for EventSource over the relay by polling /events/replay. A cursor
// the URL carries is where a replacement stream resumes; without one it attaches
// at the watermark, and asking past it returns no frames, not the whole log.
// Frames the SSE transport writes on its own (the watermark, a gap) are said
// here too, or the stream reads as silent to whoever watches it.
export class RemoteEventSource {
onmessage: ((event: MessageEvent<string>) => void) | null = null;
private closed = false;
private after: number | null = null;
private after: number | null;
private readonly replay: string;
constructor(private readonly path: string) {
constructor(url: string) {
const target = new URL(url, location.origin);
const cursor = Number(target.searchParams.get("lastEventId") ?? "");
this.after = target.searchParams.has("lastEventId") && Number.isSafeInteger(cursor) && cursor >= 0 ? cursor : null;
this.replay = `${target.pathname}/replay`;
queueMicrotask(() => void this.poll());
}
@@ -236,28 +163,32 @@ export class RemoteEventSource {
this.closed = true;
}
private say(frame: Record<string, unknown>) {
this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(frame) }));
}
private async poll() {
while (!this.closed) {
let active = false;
try {
const path = this.path.replace(/\/events$/, `/events/replay?lastEventId=${this.after ?? 0}`);
const response = await fetch(path, { credentials: "same-origin" });
if (response.ok) {
const body = await response.json() as { frames?: Array<Record<string, unknown>>; watermark?: number };
active = Boolean(body.frames?.length);
if (this.after === null) {
const frameWatermark = (body.frames ?? []).reduce(
(latest, frame) => typeof frame.seq === "number" ? Math.max(latest, frame.seq) : latest,
0,
);
this.after = typeof body.watermark === "number" ? body.watermark : frameWatermark;
} else {
for (const frame of body.frames ?? []) {
const seq = typeof frame.seq === "number" ? frame.seq : 0;
if (seq > this.after) this.after = seq;
this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(frame) }));
}
const response = await fetch(`${this.replay}?lastEventId=${this.after ?? Number.MAX_SAFE_INTEGER}`, { credentials: "same-origin" });
if (response.ok && !this.closed) {
const body = await response.json() as ReplayAnswer;
const watermark = typeof body.watermark === "number" ? body.watermark : null;
const attaching = this.after === null;
const frames = attaching ? [] : body.frames ?? [];
active = frames.length > 0;
if (this.after === null) this.after = watermark ?? 0;
else if (body.complete === false) {
const from = typeof frames[0]?.seq === "number" ? frames[0].seq : watermark ?? this.after;
this.say({ kind: "stream_gap", seq: from });
}
for (const frame of frames) {
const seq = typeof frame.seq === "number" ? frame.seq : 0;
if (seq > this.after) this.after = seq;
this.say(frame);
}
if (!active && watermark !== null) this.say({ kind: "stream_watermark", seq: watermark });
}
} catch {
// The next poll retries while the encrypted session remains open.
@@ -267,40 +198,84 @@ export class RemoteEventSource {
}
}
async function connect(deviceId: string, nativeFetch: typeof fetch) {
const bootstrap = (globalThis as typeof globalThis & {
__rxRemoteBootstrap?: Promise<Response> | null;
}).__rxRemoteBootstrap;
async function grantFailure(issued: Response): Promise<RemoteLinkError> {
const body = await issued.json().catch(() => null) as { error?: { code?: string } } | null;
const code = body?.error?.code;
if (issued.status === 401 || code === "remote_reauth_required") {
return new RemoteLinkError("reauth", t("远程控制需要重新登录。"));
}
if (issued.status === 404 || code === "device_not_found") {
return new RemoteLinkError("ended", t("这台电脑已从账号中移除。"));
}
return new RemoteLinkError("transient", t("无法授权 Web Studio,请重新登录后再试。"));
}
async function connect(deviceId: string, nativeFetch: typeof fetch, useBootstrap: boolean): Promise<RemoteConnection> {
const scope = globalThis as typeof globalThis & { __rxRemoteBootstrap?: Promise<Response> | null };
const bootstrap = useBootstrap ? scope.__rxRemoteBootstrap : null;
scope.__rxRemoteBootstrap = null;
if (!bootstrap) performance.mark("reasonix:remote:start");
const issued = await (bootstrap ?? nativeFetch(`${ACCOUNT}/me/remote-grants`, {
method: "POST", credentials: "include", headers: { "content-type": "application/json" },
body: JSON.stringify({ targetDeviceId: deviceId, scopes: ["desktop"] }),
}));
if (!issued.ok) throw new Error(t("无法授权 Web Studio,请重新登录后再试。"));
let issued: Response;
try {
issued = await (bootstrap ?? nativeFetch(`${ACCOUNT}/me/remote-grants`, {
method: "POST", credentials: "include", headers: { "content-type": "application/json" },
body: JSON.stringify({ targetDeviceId: deviceId, scopes: ["desktop"] }),
}));
} catch {
throw new RemoteLinkError("transient", t("远程中转服务暂时不可用,请稍后重试。"));
}
if (!issued.ok) throw await grantFailure(issued);
const { grant, device: target } = await issued.json() as { grant: { ticket: string }; device?: RemoteDevice };
if (!target || target.id !== deviceId || target.revokedAt || !target.capabilities.includes("desktop")) {
throw new Error(t("请先更新这台电脑上的 Studio,再连接。"));
throw new RemoteLinkError("ended", t("请先更新这台电脑上的 Studio,再连接。"));
}
performance.mark("reasonix:remote:authorized");
const socket = new WebSocket(`${RELAY}/v1/sessions/connect`, ["reasonix.remote.v1", `reasonix.auth.${grant.ticket}`]);
await waitForSocketOpen(socket);
const readyWire = nextMessage(socket);
const channel = await encryptedChannel(target, socket);
const ready = await channel.open(await readyWire);
if (ready.type !== "ready" || ready.deviceId !== target.id) throw new Error(t("远程 Studio 身份校验失败,请停止连接并检查设备。"));
performance.mark("reasonix:remote:ready");
performance.measure("reasonix:remote:authorize", "reasonix:remote:start", "reasonix:remote:authorized");
performance.measure("reasonix:remote:handshake", "reasonix:remote:authorized", "reasonix:remote:ready");
performance.measure("reasonix:remote:connect", "reasonix:remote:start", "reasonix:remote:ready");
return new RemoteTransport(socket, channel);
try {
await waitForSocketOpen(socket);
const readyWire = nextMessage(socket);
const channel = await encryptedChannel(target, socket);
const ready = await channel.open(await readyWire);
if (ready.type !== "ready" || ready.deviceId !== target.id) {
throw new RemoteLinkError("ended", t("远程 Studio 身份校验失败,请停止连接并检查设备。"));
}
performance.mark("reasonix:remote:ready");
performance.measure("reasonix:remote:authorize", "reasonix:remote:start", "reasonix:remote:authorized");
performance.measure("reasonix:remote:handshake", "reasonix:remote:authorized", "reasonix:remote:ready");
performance.measure("reasonix:remote:connect", "reasonix:remote:start", "reasonix:remote:ready");
const features = Array.isArray(ready.features) ? ready.features.filter((item): item is string => typeof item === "string") : [];
if (typeof ready.session === "string" && ready.session) channel.bind(ready.session);
const instance = typeof ready.instance === "string" ? ready.instance : "";
return {
socket, channel, features, instance,
listen(onMessage, onClose) {
socket.addEventListener("message", (event) => onMessage(String(event.data)));
socket.addEventListener("close", (event) => onClose(event.code, event.reason));
},
};
} catch (error) {
try { socket.close(1000, "Handshake failed"); } catch { /* never opened */ }
if (error instanceof RemoteLinkError) throw error;
throw new RemoteLinkError("transient", reason(error));
}
}
export async function remoteHub(deviceId: string): Promise<HubPort> {
const nativeFetch = globalThis.fetch.bind(globalThis);
const transport = await connect(deviceId, nativeFetch);
let first: RemoteConnection;
try {
first = await connect(deviceId, nativeFetch, true);
} catch (error) {
if (error instanceof RemoteLinkError && error.kind === "reauth") {
await signInAgain(deviceId, nativeFetch);
return new Promise<never>(() => {});
}
throw error;
}
const link = new RemoteLink(first, () => connect(deviceId, nativeFetch, false), announceClosed);
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
return new URL(request.url).origin === location.origin ? transport.fetch(request) : nativeFetch(request);
return new URL(request.url).origin === location.origin ? link.fetch(request) : nativeFetch(request);
}) as typeof fetch;
globalThis.EventSource = RemoteEventSource as unknown as typeof EventSource;
return new SseHub();
+12
View File
@@ -44,6 +44,9 @@ export interface HostPort {
* hide every other; hideBrowserView puts them all away again. */
showBrowserView(target: string, rect: ViewRect): void;
hideBrowserView(): void;
/** Put the page on screen away the way hideBrowserView does, answering with
* a picture of what it showed at that moment, or "" when none was taken. */
freezeBrowserView(): Promise<string>;
controlBrowserView(target: string, action: BrowserControl): void;
/** Load what the person typed. false when the shell refused the address. */
navigateBrowserView(target: string, address: string): Promise<boolean>;
@@ -126,6 +129,7 @@ interface ElectronBridge {
revealPath?(base: string, path: string): Promise<Refusal | null>;
showBrowserView?(target: string, rect: ViewRect): Promise<void>;
hideBrowserView?(): Promise<void>;
freezeBrowserView?(): Promise<string>;
controlBrowserView?(target: string, action: string): Promise<void>;
navigateBrowserView?(target: string, address: string): Promise<boolean>;
onBrowserLoadState?(listener: (state: BrowserLoadState) => void): () => void;
@@ -197,6 +201,11 @@ class ElectronHost implements HostPort {
hideBrowserView() {
void this.api.hideBrowserView?.();
}
freezeBrowserView() {
if (this.api.freezeBrowserView) return this.api.freezeBrowserView().catch(() => "");
this.hideBrowserView();
return Promise.resolve("");
}
controlBrowserView(target: string, action: BrowserControl) {
void this.api.controlBrowserView?.(target, action);
}
@@ -253,6 +262,9 @@ class BrowserHost implements HostPort {
}
showBrowserView() {}
hideBrowserView() {}
freezeBrowserView() {
return Promise.resolve("");
}
controlBrowserView() {}
navigateBrowserView() {
return Promise.resolve(false);
@@ -0,0 +1,84 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { openLiveStream, STREAM_SILENCE_MS } from "./livestream";
import { SsePort } from "./sse";
type Stream = { url: string; closed: boolean; say: (raw: string) => void };
function stubStreams() {
const opened: Stream[] = [];
vi.stubGlobal(
"EventSource",
class {
onmessage: ((m: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
readonly s: Stream;
constructor(url: string) {
this.s = { url, closed: false, say: (raw) => this.onmessage?.({ data: raw }) };
opened.push(this.s);
}
close() {
this.s.closed = true;
}
},
);
return opened;
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("a stream that goes silent", () => {
it("keeps a stream that goes on hearing the kernel's watermark", () => {
const opened = stubStreams();
const stop = openLiveStream(() => "/events", () => {});
for (let i = 0; i < 12; i++) {
vi.advanceTimersByTime(15_000);
opened[0].say('{"kind":"stream_watermark","seq":1}');
}
expect(opened).toHaveLength(1);
stop();
expect(opened[0].closed).toBe(true);
});
it("replaces one that has heard nothing for three watermark intervals", () => {
const opened = stubStreams();
const got: string[] = [];
const stop = openLiveStream(() => `/events?n=${opened.length}`, (raw) => got.push(raw));
vi.advanceTimersByTime(STREAM_SILENCE_MS + 5_000);
expect(opened).toHaveLength(2);
expect(opened[0].closed).toBe(true);
expect(opened[1].url).toBe("/events?n=1");
opened[1].say("x");
expect(got).toEqual(["x"]);
stop();
});
it("checks at once when the page comes back rather than waiting for a tick", () => {
const opened = stubStreams();
const stop = openLiveStream(() => "/events", () => {});
vi.setSystemTime(Date.now() + STREAM_SILENCE_MS + 1);
document.dispatchEvent(new Event("visibilitychange"));
expect(opened).toHaveLength(2);
stop();
});
it("resumes the replacement from the last frame the pane folded", () => {
const opened = stubStreams();
vi.stubGlobal("fetch", async () => ({ ok: true, json: async () => ({ frames: [], complete: true }) }));
const seen: number[] = [];
const stop = new SsePort("", "r1").subscribe((ev) => ev.seq && seen.push(ev.seq));
opened[0].say('{"kind":"stream_watermark","seq":0}');
opened[0].say('{"kind":"turn_started","seq":1}');
opened[0].say('{"kind":"turn_done","seq":2}');
vi.advanceTimersByTime(STREAM_SILENCE_MS + 5_000);
expect(opened[1].url).toBe("/events?lastEventId=2");
opened[1].say('{"kind":"turn_done","seq":2}');
opened[1].say('{"kind":"turn_started","seq":3}');
expect(seen).toEqual([1, 2, 3]);
stop();
});
});
@@ -0,0 +1,49 @@
// The kernel restates its position on every transport once per
// SSEWatermarkInterval (15s, internal/frontend/serve/eventstream.go). Three
// silent intervals is a stream that is gone, whatever readyState says: a phone
// that slept or a Wi-Fi link that dropped leaves the socket looking open.
export const STREAM_SILENCE_MS = 45_000;
const CHECK_MS = 5_000;
/** Holds one EventSource open and replaces it once it has gone silent. `url`
* names where to resume, so a replacement asks for what the dead one missed.
* Timers stop while a phone sleeps, so waking and coming back online check at
* once rather than on the next tick. */
export function openLiveStream(url: () => string, onData: (raw: string) => void, silenceMs = STREAM_SILENCE_MS): () => void {
let es: EventSource;
let heard = 0;
const open = () => {
heard = Date.now();
es = new EventSource(url(), { withCredentials: true });
es.onopen = () => {
heard = Date.now();
};
es.onmessage = (m) => {
heard = Date.now();
onData(m.data);
};
};
const check = () => {
if (Date.now() - heard < silenceMs) return;
es.close();
open();
};
const wake = () => {
if (document.visibilityState === "visible") check();
};
open();
const timer = setInterval(check, CHECK_MS);
const dom = typeof document !== "undefined" && typeof addEventListener === "function";
if (dom) {
document.addEventListener("visibilitychange", wake);
addEventListener("online", check);
}
return () => {
clearInterval(timer);
if (dom) {
document.removeEventListener("visibilitychange", wake);
removeEventListener("online", check);
}
es.close();
};
}
+131
View File
@@ -0,0 +1,131 @@
import type { PluginPlan } from "./plugin";
// The community registry as the market tab reads it. Every text field is the
// publisher's; what an install puts on disk is decided by the approved
// version's pinned digest and the plan the person confirms, never by these.
// A theme is its own category but installs as a plugin package carrying only themes.
export type MarketKind = "skill" | "plugin" | "mcp" | "theme";
// What this machine holds of a listed package, read off the market's own
// install record — and only while the things it installed are still there.
export interface MarketInstalled {
version: string;
contentHash: string;
}
export interface MarketPackage {
kind: MarketKind;
handle: string;
name: string;
slug: string;
summary: string;
description: string;
homepage: string;
repoUrl: string;
tags: string[];
latestVersion: string;
installCount: number;
starCount: number;
upCount: number;
downCount: number;
// up / (up + down); null while nobody has voted, which is not zero approval.
approvalRate: number | null;
// The registry's recommended score, 0..1; the order itself is the registry's.
score: number;
verified: boolean;
status: string;
updatedAt: string;
installed?: MarketInstalled;
// The registry's word that the approved version is pinned; absent when it
// did not say. The install still checks the digest itself.
pinned?: boolean;
}
// The version a reviewer let through. contentHash is the pin: an install is
// refused unless the source still resolves to exactly this.
export interface MarketVersion {
version: string;
source: string;
contentHash: string;
riskLevel: string;
createdAt: string;
}
export interface MarketDetail {
package: MarketPackage;
approved?: MarketVersion;
// False means an install needs the person's trust (market.unpinned without it).
pinned: boolean;
installed?: MarketInstalled;
}
export interface MarketList {
packages: MarketPackage[];
limit: number;
offset: number;
}
export interface MarketQuery {
kind?: MarketKind | "";
q?: string;
sort?: "recommended" | "trending" | "new" | "installs";
offset?: number;
// Filtered by the registry, so paging stays whole.
pinned?: boolean;
}
// version is the approved version the person was shown; the kernel refuses an
// install once a different one is approved (market.version_changed). trust
// accepts an unpinned version; digest is then the preview's contentDigest.
export interface MarketRequest {
slug: string;
version?: string;
planId?: string;
replace?: boolean;
trust?: boolean;
digest?: string;
}
// unreviewed marks an install of a version no reviewer pinned: contentDigest
// is then this preview's, and apply must echo it.
export type MarketPlan = PluginPlan & { slug: string; version: string; contentDigest?: string; unreviewed?: boolean };
// The account's own package, any review state. digest is the previewed
// contentDigest; the kernel refuses an apply without it (market.unpreviewed).
export type MarketOwnRequest = MarketRequest;
// One package offered for review under the signed-in account's handle.
export interface MarketSubmission {
kind: MarketKind;
name: string;
source: string;
summary?: string;
description?: string;
repoUrl?: string;
version?: string;
tags?: string[];
// private keeps it to the publisher and out of review until submitted.
visibility?: "public" | "private";
}
// The registry's receipt: a new submission lands as pending until approved.
export interface MarketPublished {
package: MarketPackage;
created: boolean;
version: string;
}
export type MarketReviewStatus = "pending" | "active" | "rejected" | "hidden" | "private";
// The signed-in person's vote on one package. signedIn false is an answer, not
// an error: the entry offers sign-in instead of the buttons.
export interface MarketVote {
signedIn: boolean;
value: -1 | 0 | 1;
upCount?: number;
downCount?: number;
approvalRate?: number | null;
canVote?: boolean;
own?: boolean;
emailVerified?: boolean;
}
+11 -8
View File
@@ -2,7 +2,7 @@ import type { PlanAction } from "./session";
import { HttpError } from "./port";
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, NotifyPrefs, TrayPrefs } from "./port";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { MockTheme } from "./mock_theme";
import { MockBackup } from "./mock_backup";
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
import { mockStorage, mockStoragePlan } from "./mock_storage";
@@ -11,7 +11,7 @@ import { mockUsage } from "./mock_usage";
export class MockPort extends MockTheme implements AgentPort {
export class MockPort extends MockBackup implements AgentPort {
private listeners = new Set<(ev: WireEvent) => void>();
private log: WireEvent[] = [];
// What the user has sent, so checkpoints() can mirror one per turn.
@@ -204,13 +204,14 @@ export class MockPort extends MockTheme implements AgentPort {
async goToVersion(): Promise<void> {
throw new Error("演示模式不会真的安装版本");
}
async restartToVersion(): Promise<void> { throw new Error("演示模式不会真的安装版本"); }
onUpdateProgress(): () => void {
return () => {};
}
async account(): Promise<AccountState> {
return { signedIn: false };
return localStorage.getItem("reasonix.mock.account") ? { signedIn: true, user: { handle: "demo", email: "[email protected]", label: "demo" } } : { signedIn: false };
}
async accountLogin(): Promise<DeviceGrant> {
@@ -408,12 +409,13 @@ export class MockPort extends MockTheme implements AgentPort {
};
}
async workspaceFiles(path = "", query = "") {
const all = ["README.md", "internal/provider/retry.go"];
async workspaceFiles(path = "", query = "", hidden = false) {
const all = ["README.md", "internal/provider/retry.go", ...(hidden ? [".gitignore", ".reasonix/REASONIX.local.md"] : [])];
if (query) return { files: all.filter((p) => p.toLowerCase().includes(query.toLowerCase())), directories: [] };
if (path === "internal") return { files: [], directories: ["internal/provider"] };
if (path === "internal/provider") return { files: ["internal/provider/retry.go"], directories: [] };
return { files: ["README.md"], directories: ["internal"] };
if (path === ".reasonix") return { files: [".reasonix/REASONIX.local.md"], directories: [] };
return hidden ? { files: [".gitignore", "README.md"], directories: [".reasonix", "internal"] } : { files: ["README.md"], directories: ["internal"] };
}
async workspaceFile(path: string) { return { path, content: `// ${path}\n`, revision: "fixture" }; }
workspaceImageURL(path: string) { return `/fixture/${path}`; }
@@ -761,8 +763,8 @@ export class MockPort extends MockTheme implements AgentPort {
async setPreset(preset: Preset) {
this.state.preset = preset;
}
async storage() {
return mockStorage();
async storage(query?: Parameters<typeof mockStorage>[0]) {
return mockStorage(query);
}
async planStorageMove(root: string, dir: string) {
@@ -780,6 +782,7 @@ export class MockPort extends MockTheme implements AgentPort {
async setEffort(effort: string) {
this.state.effort = effort;
}
async setModelMode(mode: string) { this.state.modes = this.state.modes?.map((m) => ({ ...m, active: m.id === mode })); }
async setGoal(text: string) {
this.state.goal = text;
}
@@ -0,0 +1,49 @@
import { MockTheme } from "./mock_theme";
import type { BackupApplyResult, BackupCatalog, BackupCreated, BackupCreateRequest, BackupEntry, BackupPlan } from "./backup";
const CATEGORIES: BackupCatalog["categories"] = [
{ id: "settings", defaultOn: true, consent: false },
{ id: "extensions", defaultOn: true, consent: false },
{ id: "memory", defaultOn: true, consent: false },
{ id: "automation", defaultOn: true, consent: true },
{ id: "secrets", defaultOn: false, consent: false },
];
// The fixture keeps what it was handed in memory, so the settings section can
// be driven end to end with no account service behind it.
export class MockBackup extends MockTheme {
private backupList: BackupEntry[] = [];
async backups(): Promise<BackupCatalog> {
return { categories: CATEGORIES, backups: this.backupList, limits: { maxCount: 10, maxBytes: 4 << 20 }, minPassphrase: 10 };
}
async createBackup(req: BackupCreateRequest): Promise<BackupCreated> {
const backup: BackupEntry = {
id: "b" + (this.backupList.length + 1), label: req.label, format: 1, appVersion: "demo",
platform: "darwin/arm64", categories: req.categories, ciphertextBytes: 18432, createdAt: new Date().toISOString(),
};
this.backupList = [backup, ...this.backupList];
return { backup };
}
async deleteBackup(id: string): Promise<void> {
this.backupList = this.backupList.filter((b) => b.id !== id);
}
async previewBackup(): Promise<BackupPlan> {
return {
planId: "demo", createdAt: new Date().toISOString(), platform: "darwin/arm64", samePlatform: true,
categories: ["settings", "automation"],
items: [
{ id: "provider:deepseek", category: "settings", kind: "provider", name: "deepseek", status: "same", summary: "openai https://api.deepseek.com", recommended: false },
{ id: "hook:PreToolUse#1", category: "automation", kind: "hook", name: "PreToolUse#1", status: "new", consent: "executes", summary: "npx prettier --check .", details: ["event: PreToolUse", "match: edit_file"], recommended: false },
{ id: "memory:docs/REASONIX.md", category: "memory", kind: "memory", name: "docs/REASONIX.md", status: "changed", summary: "REASONIX.md", content: "回答保持简洁。\n提交信息用英文。", recommended: true },
],
};
}
async applyBackup(_planId: string, items: string[]): Promise<BackupApplyResult> {
return { applied: items };
}
}
@@ -1,4 +1,5 @@
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
import type { ProgressWatchSettings } from "./boundary";
import { MockShell } from "./mock_shell";
// The boundary half of the fixture: what the agent is refused outright, and how
@@ -14,6 +15,7 @@ function effectiveBash(s: SandboxSettings): string {
}
export class MockBoundary extends MockShell {
async decideWorkspaceTrust(_trust: "trusted" | "declined") {}
// One interrupted barrier and one that a later turn took over, so the card
// and the reason it disappears are both reachable without a crash.
private adjudicated: Adjudications = {
@@ -78,6 +80,11 @@ export class MockBoundary extends MockShell {
return { ...this.rules };
}
async revokeRememberedProjectRule(rule: string): Promise<PermissionRules> {
this.rules = { ...this.rules, remembered: (this.rules.remembered ?? []).filter((saved) => saved !== rule) };
return { ...this.rules };
}
async sandbox(): Promise<SandboxSettings> {
return { ...this.jail };
}
@@ -97,6 +104,21 @@ export class MockBoundary extends MockShell {
return { ...this.browser };
}
private watch: ProgressWatchSettings = {
pause: false, rounds: 20, tokenMultiple: 8, defaultRounds: 20, defaultTokenMultiple: 8,
path: "/Users/you/.reasonix/config.toml",
};
async progressWatch(): Promise<ProgressWatchSettings> {
return { ...this.watch };
}
async saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">): Promise<ProgressWatchSettings> {
if (s.rounds < 1 || s.rounds > 1000 || s.tokenMultiple < 1 || s.tokenMultiple > 1000) throw new Error("progress watch setting out of range");
this.watch = { ...this.watch, ...s };
return { ...this.watch };
}
// The fixture carries the broken file, because a banner nobody can reach is
// a banner nobody designs. Repairing it here clears it, so the whole
// interaction is reachable without a Go build.
+2 -2
View File
@@ -1,12 +1,12 @@
import { download } from "./download";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, ScopeLayer, SkillCatalog, SkillEntry } from "./port";
import { MockLook } from "./mock_look";
import { MockMarket } from "./mock_market";
// The extension half of the fixture: skills, and the packages that bring them.
// MockPort extends this rather than holding one, so the class still satisfies
// AgentPort in a single declaration and the settings pane's whole extension
// tab can be developed with no Go process at all.
export class MockExtensions extends MockLook {
export class MockExtensions extends MockMarket {
private skillList: SkillEntry[] = [
{
name: "review", slashName: "review", description: "复核这一轮改动,给出严重度分级",
@@ -0,0 +1,178 @@
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
import { HttpError } from "./http_error";
import { MockLook } from "./mock_look";
const DIGEST = "sha256:5f0c1e9a7b3d2c4e6f8a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e";
// Four rows that each read differently: pinned and installable, one source
// that expands into several skills, one already installed, and one whose
// approved version carries no digest and so installs only on the person's trust.
const PACKAGES: (MarketPackage & { source: string; pinned: boolean })[] = [
{
kind: "skill", handle: "nanfei892", name: "make-ui-not-ai", slug: "nanfei892/make-ui-not-ai",
summary: "前端界面技能:从真实产品任务出发,设计并验证可用、完整、有辨识度的界面。",
description: "适合需要从零搭一个页面、或者把现有页面改得不像模板的时候。\n\n它会先问清楚这个页面给谁用、要完成什么,再给出布局与验证步骤。",
homepage: "", repoUrl: "https://github.com/nanfei892/ship-it-skills", tags: ["frontend", "ui-design"],
latestVersion: "1.0.0", installCount: 2118, starCount: 41, upCount: 41, downCount: 3, approvalRate: 41 / 44, score: 0.83, verified: true, status: "active", updatedAt: "2026-09-04T02:56:03Z",
source: "https://github.com/nanfei892/ship-it-skills/tree/3f1c2e7a9b0d4c6e8f1a2b3c4d5e6f7a8b9c0d1e/make-ui-not-ai", pinned: true,
},
{
kind: "plugin", handle: "acme", name: "review-kit", slug: "acme/review-kit",
summary: "代码评审套件:按改动范围逐块评审,只标出会出事的地方。",
description: "三个技能 + 一个 /pr 命令。不启动任何进程,不注册钩子。",
homepage: "", repoUrl: "https://github.com/acme/review-kit", tags: ["review", "git"],
latestVersion: "1.4.0", installCount: 986, starCount: 12, upCount: 12, downCount: 2, approvalRate: 12 / 14, score: 0.61, verified: false, status: "active", updatedAt: "2026-09-20T10:00:00Z",
source: "https://github.com/acme/review-kit/tree/9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b", pinned: true,
},
{
kind: "mcp", handle: "irmia", name: "irmia-devkit", slug: "irmia/irmia-devkit",
summary: "一组对模型友好的开发工具:批量读写、结构化搜索、依赖图。",
description: "", homepage: "", repoUrl: "https://github.com/irmia2026/irmia_devkit_mcp", tags: ["tool", "coding"],
latestVersion: "2.7.0", installCount: 1527, starCount: 5, upCount: 5, downCount: 1, approvalRate: 5 / 6, score: 0.52, verified: false, status: "active", updatedAt: "2026-07-22T12:11:46Z",
installed: { version: "2.7.0", contentHash: DIGEST },
source: "irmia-devkit-mcp", pinned: true,
},
{
kind: "skill", handle: "1574022644", name: "lm-studio-vision-bridge", slug: "1574022644/lm-studio-vision-bridge",
summary: "通过本地 LM Studio 视觉模型为 agent 提供图片识别能力。",
description: "", homepage: "", repoUrl: "https://github.com/FuchaZ/lm-studio-vision-bridge", tags: ["vision", "local"],
latestVersion: "2.0.0", installCount: 1678, starCount: 0, upCount: 0, downCount: 0, approvalRate: null, score: 0.29, verified: false, status: "active", updatedAt: "2026-07-28T07:14:25Z",
source: "https://github.com/FuchaZ/lm-studio-vision-bridge/blob/master/SKILL.md", pinned: false,
},
{
kind: "theme", handle: "lumen", name: "dusk-harbor", slug: "lumen/dusk-harbor",
summary: "低饱和的暮色主题:浅色、深色各一套,只含主题,不带任何技能或钩子。",
description: "", homepage: "", repoUrl: "https://github.com/lumen/reasonix-themes", tags: ["theme", "dark"],
latestVersion: "1.2.0", installCount: 412, starCount: 5, upCount: 5, downCount: 0, approvalRate: 1, score: 0.44, verified: false, status: "active", updatedAt: "2026-09-18T08:00:00Z",
source: "https://github.com/lumen/reasonix-themes/tree/7d3e1c2b9a8f7e6d5c4b3a291807f6e5d4c3b2a1/dusk-harbor", pinned: true,
},
];
// What the demo account has submitted, one row per review state a publisher sees.
const MINE: MarketPackage[] = [
{ ...PACKAGES[0], handle: "demo", slug: "demo/ship-notes", name: "ship-notes", summary: "发布说明整理技能。", status: "pending", installCount: 0, latestVersion: "0.1.0" },
{ ...PACKAGES[4], handle: "demo", slug: "demo/paper-light", name: "paper-light", summary: "纸面质感的浅色主题。", status: "active", installCount: 37, latestVersion: "1.0.1" },
{ ...PACKAGES[1], handle: "demo", slug: "demo/lint-kit", name: "lint-kit", summary: "代码检查插件。", status: "rejected", installCount: 0, latestVersion: "0.2.0" },
{ ...PACKAGES[4], handle: "demo", slug: "demo/night-desk", name: "night-desk", summary: "只给自己用的深色主题。", status: "private", installCount: 0, latestVersion: "0.1.0" },
].map(({ source: _source, pinned: _pinned, ...rest }) => ({ ...rest, installed: undefined }));
export class MockMarket extends MockLook {
private marketInstalled = new Set<string>(["irmia/irmia-devkit"]);
private mine = [...MINE];
private marketVotes = new Map<string, -1 | 0 | 1>([["acme/review-kit", 1]]);
// Voting reads its own sign-in answer so a test can draw the signed-out entry.
marketSignedIn = true;
async marketList(q: MarketQuery): Promise<MarketList> {
const needle = (q.q ?? "").trim().toLowerCase();
const rows = PACKAGES.filter((p) => (!q.kind || p.kind === q.kind) && (!q.pinned || p.pinned) &&
(!needle || `${p.name} ${p.summary} ${p.tags.join(" ")}`.toLowerCase().includes(needle)));
return { packages: rows.map((p) => this.view(p)), limit: 24, offset: 0 };
}
async marketDetail(slug: string): Promise<MarketDetail> {
const p = PACKAGES.find((x) => x.slug === slug);
if (!p) throw new Error("market.not_found");
const pkg = this.view(p);
return {
package: pkg, pinned: p.pinned, installed: pkg.installed,
approved: { version: p.latestVersion, source: p.source, contentHash: p.pinned ? DIGEST : "", riskLevel: "", createdAt: p.updatedAt },
};
}
// review-kit plans as a source that expands into several skills, which is
// the confirmation this tab exists to force.
async planMarket(req: MarketRequest): Promise<MarketPlan> {
const p = PACKAGES.find((x) => x.slug === req.slug)!;
if (!p.pinned && !req.trust) throw new HttpError(409, "the approved version is not pinned to reviewed content", { code: "market.unpinned" });
const base = { ok: true, status: "planned", applied: false, source: p.source, slug: p.slug, version: p.latestVersion, contentDigest: DIGEST, unreviewed: !p.pinned };
if (p.slug === "acme/review-kit") {
return {
...base, planId: "high:sha256:mock",
actions: ["review", "risk", "pr-notes"].map((name) => ({
kind: "skill", action: "copy_skill", status: "planned", riskLevel: "high", name,
riskReasons: ["one source expands to 3 skills; every one of them is installed"],
})),
};
}
return {
...base, planId: "low:sha256:mock",
actions: [{
kind: p.kind === "theme" ? "plugin" : p.kind,
action: p.kind === "theme" ? "install_plugin_package" : "copy_skill",
status: "planned", riskLevel: "low", name: p.name,
}],
};
}
async installMarket(req: MarketRequest): Promise<MarketPlan> {
const plan = await this.planMarket(req);
if (plan.unreviewed && req.digest !== plan.contentDigest) throw new HttpError(400, "an unreviewed install needs the digest of the preview it confirms", { code: "market.unpreviewed" });
this.marketInstalled.add(req.slug);
return { ...plan, status: "done", applied: true, actions: plan.actions?.map((a) => ({ ...a, status: "done" })) };
}
async publishMarket(sub: MarketSubmission): Promise<MarketPublished> {
const pkg: MarketPackage = {
kind: sub.kind, handle: "demo", name: sub.name, slug: `demo/${sub.name}`, summary: sub.summary ?? "", description: sub.description ?? "",
homepage: "", repoUrl: sub.repoUrl ?? "", tags: sub.tags ?? [], latestVersion: sub.version || "0.1.0", installCount: 0, starCount: 0, upCount: 0, downCount: 0, approvalRate: null, score: 0,
verified: false, status: sub.visibility === "private" ? "private" : "pending", updatedAt: new Date().toISOString(),
};
this.mine.unshift(pkg);
return { package: pkg, created: true, version: pkg.latestVersion };
}
async myMarket(): Promise<MarketPackage[]> {
return [...this.mine];
}
async planOwnMarket(req: MarketOwnRequest): Promise<MarketPlan> {
const p = this.mine.find((x) => x.slug === req.slug);
if (!p) throw new Error("market.not_yours");
return {
ok: true, status: "planned", applied: false, slug: p.slug, version: p.latestVersion, contentDigest: DIGEST, unreviewed: true,
planId: "low:sha256:own",
actions: [{ kind: p.kind === "theme" ? "plugin" : p.kind, action: "install_plugin_package", status: "planned", riskLevel: "low", name: p.name }],
};
}
async installOwnMarket(req: MarketOwnRequest): Promise<MarketPlan> {
if (req.digest !== DIGEST) throw new Error("market.unpreviewed");
const plan = await this.planOwnMarket(req);
this.mine = this.mine.map((p) => (p.slug === req.slug ? { ...p, installed: { version: p.latestVersion, contentHash: DIGEST } } : p));
return { ...plan, status: "done", applied: true, actions: plan.actions?.map((a) => ({ ...a, status: "done" })) };
}
async submitMarket(slug: string): Promise<MarketPackage> {
const i = this.mine.findIndex((x) => x.slug === slug && x.status === "private");
if (i < 0) throw new Error("market.not_private");
this.mine[i] = { ...this.mine[i]!, status: "pending" };
return this.mine[i]!;
}
async marketMyVote(slug: string): Promise<MarketVote> {
if (!this.marketSignedIn) return { signedIn: false, value: 0 };
return this.tally(slug, this.marketVotes.get(slug) ?? 0);
}
async voteMarket(slug: string, value: -1 | 0 | 1): Promise<MarketVote> {
if (!this.marketSignedIn) throw new Error("market.signed_out");
this.marketVotes.set(slug, value);
return this.tally(slug, value);
}
private tally(slug: string, value: -1 | 0 | 1): MarketVote {
const p = PACKAGES.find((x) => x.slug === slug);
if (!p) throw new Error("market.not_found");
const was = slug === "acme/review-kit" ? 1 : 0;
const up = p.upCount - (was === 1 ? 1 : 0) + (value === 1 ? 1 : 0);
const down = p.downCount + (value === -1 ? 1 : 0);
return { signedIn: true, value, upCount: up, downCount: down, approvalRate: up + down ? up / (up + down) : null, canVote: true, own: false, emailVerified: true };
}
private view(p: (typeof PACKAGES)[number]): MarketPackage {
const { source: _source, ...rest } = p;
const installed = this.marketInstalled.has(p.slug) ? { version: p.latestVersion, contentHash: DIGEST } : undefined;
return { ...rest, installed };
}
}
@@ -1,4 +1,5 @@
import type {
ModelEffort,
Protocol,
ProviderCheck,
ProviderEdit,
@@ -42,6 +43,12 @@ export class MockProvider extends MockBoundary {
models: ["gpt-4o", "claude-sonnet-4"], default: "gpt-4o",
hasKey: true, inUse: false, preset: false, keyEnv: "MYRELAY_API_KEY",
visionModels: ["gpt-4o"], canSetVision: true,
reasoningProtocol: "openai", supportedEfforts: ["low", "medium", "high"],
modelEfforts: { "claude-sonnet-4": { supportedEfforts: ["low", "high", "max"], defaultEffort: "high" } },
inheritedEfforts: {
"gpt-4o": { supportedEfforts: ["low", "medium", "high"] },
"claude-sonnet-4": { supportedEfforts: ["low", "medium", "high"] },
},
},
// The Responses wire is the one that can carry a turn by reference, so it
// is the only entry here offering the choice.
@@ -115,6 +122,11 @@ export class MockProvider extends MockBoundary {
this.sources = this.sources.map((p) => (p.name === name ? { ...p, continuation: mode } : p));
}
async renameProvider(names: string[], displayName: string): Promise<void> {
const label = displayName.trim();
this.sources = this.sources.map((p) => (names.includes(p.name) ? { ...p, displayName: label || undefined } : p));
}
async setProviderThinking(name: string, on: boolean): Promise<void> {
this.sources = this.sources.map((p) => (p.name === name ? { ...p, sendsThinking: on } : p));
}
@@ -133,6 +145,7 @@ export class MockProvider extends MockBoundary {
headers: edit.headers, extraBody: edit.extraBody,
reasoningProtocol: edit.reasoningProtocol, supportedEfforts: edit.supportedEfforts,
defaultEffort: edit.defaultEffort,
modelEfforts: edit.modelEfforts ? mockModelEfforts(edit.modelEfforts) : p.modelEfforts,
}
: p,
);
@@ -160,3 +173,9 @@ function relayCatalog(): string[] {
}
return out;
}
// The kernel keeps only models that declare levels; the rest inherit.
function mockModelEfforts(sent: Record<string, ModelEffort>): Record<string, ModelEffort> | undefined {
const kept = Object.entries(sent).filter(([, v]) => v.supportedEfforts.length > 0);
return kept.length ? Object.fromEntries(kept) : undefined;
}
@@ -1,4 +1,4 @@
import type { StoragePlan, StorageState } from "./storage";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
// A C: drive with three gigabytes left and a session store that ate the rest —
// the situation the panel exists for, so the fixture shows it rather than a
@@ -7,7 +7,14 @@ const VOL = { volume: "C:", volumeFree: 3_100_000_000, volumeTotal: 476_000_000_
const ROAMING = "C:/Users/you/AppData/Roaming/reasonix";
const LOCAL = "C:/Users/you/AppData/Local/reasonix";
export function mockStorage(): StorageState {
export function mockStorage(query?: StorageQuery): StorageState {
const all = mockStorageAll();
if (query?.layout) return { ...all, roots: all.roots.map((r) => ({ ...r, bytes: 0, files: 0, pending: true })) };
if (query?.root) return { ...all, roots: all.roots.filter((r) => r.id === query.root) };
return all;
}
function mockStorageAll(): StorageState {
return {
editable: true,
roots: [
+13
View File
@@ -36,6 +36,8 @@ export interface ModelPrice {
export interface ModelEntry {
ref: string;
provider: string;
// What the user renamed provider to; provider stays the identity.
displayName?: string;
model: string;
kind?: string;
active?: boolean;
@@ -61,6 +63,17 @@ export interface ModelEntry {
preset?: boolean;
}
// An optional way the session's model can run, beside its effort level. The
// kernel lists one only for a model that declares it; label and hint are keys
// the frontend owns the wording for.
export interface ModelMode {
id: string;
labelKey: string;
hintKey: string;
costlier: boolean;
active: boolean;
}
// A configured provider as the settings panel lists it.
// Which model takes which job. Only the roles with a field behind them in the
// kernel appear here; image routing joins once it can name its own model
+69 -11
View File
@@ -5,6 +5,8 @@ export type { Attachment, DroppedRef };
import type { AccountState, AccountUser, DeviceGrant } from "./account";
import type { Adjudications } from "./adjudication";
import type { BackupApplyResult, BackupCatalog, BackupCreated, BackupCreateRequest, BackupPlan } from "./backup";
export type * from "./backup";
export type { AdjudicationEntry, AdjudicationState, Adjudications } from "./adjudication";
import type { HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource } from "./hook";
import type { CapabilityScope, McpCatalog, McpDraft, McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, ScopeLayer } from "./mcp";
@@ -12,9 +14,9 @@ import type { MemoryCatalog, MemoryEdit, MemoryEntry } from "./memory";
import type { UsageReport } from "./usage";
export type { MemoryEdit } from "./memory";
export type { Money, UsageDay, UsageModel, UsageProvider, UsageReport } from "./usage";
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelPrice, RoleAssignments } from "./model";
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments } from "./model";
import type { NetworkProbe, NetworkSettings } from "./network";
import type { ApprovalMode, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
import type { SkillCatalog, SkillEntry } from "./skill";
import type { UpdateProgress, VersionEntry, VersionHub } from "./version";
@@ -23,24 +25,26 @@ import type { ChangeDiff, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, Wor
// The port is one contract; its subjects each keep their own file, the way the
// wire and the layers below already do. This is where a reader still finds
// them all.
export type { AccountState, AccountUser, ApprovalMode, ApprovalVerdict, CapabilityScope,
export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, ApprovalVerdict, WorkspaceTrust, CapabilityScope,
Checkpoint, CompactionSettings, Completion, CompletionItem, ContextBreakdown, DeviceGrant, HistoryMessage, HostTodo, BrowserTab,
HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource, JobEntry, McpCatalog, McpDraft,
McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, MemoryCatalog,
MemoryEntry, ModelEntry, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
RewindResult, RewindScope, RoleAssignments, ScopeLayer, SessionEntry, SessionStatus,
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
VersionHub, WalletLine, WalletReading, ChangeDiff, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo };
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
import type { Appearance, ThemeImport, ThemePack } from "./look";
import type { BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./boundary";
import type { BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings } from "./boundary";
import type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
export type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
import type { StoragePlan, StorageState } from "./storage";
export type { ModelEffort, Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
export type * from "./plugin";
export type * from "./market";
// GET /mcp. One external tool provider. state is ready | connecting | failed |
// disabled | idle: disabled is switched off and stays off across restarts, idle
@@ -58,6 +62,19 @@ export type * from "./boundary";
export type * from "./provider";
/** A skill chip as the kernel takes it; offset only orders chips in a line. */
export interface InvocationRequest {
name: string;
kind: "skill" | "subagent";
offset: number;
}
/** A line holding chips: submit is what the model reads, chips taken out. */
export interface ChipCall {
submit: string;
invocations: InvocationRequest[];
}
/** What the kernel answers when a line is queued — mid-turn guidance or a whole
* turn waiting its go. itemId is the durable entry, and it is the whole reason
* the line can be taken back: without it the row on screen has no name to
@@ -164,6 +181,24 @@ export interface AgentPort {
// it on the way. Installing is the same door: a folder, a link, or this
// archive unpacked — there is no separate import.
exportPlugin(name: string): Promise<PluginExport>;
// The community market: a source like a pasted address, not a trust root.
// Install is the same plan-then-apply pair, pinned to the reviewed digest.
marketList(q: MarketQuery): Promise<MarketList>;
marketDetail(slug: string): Promise<MarketDetail>;
planMarket(req: MarketRequest): Promise<MarketPlan>;
installMarket(req: MarketRequest): Promise<MarketPlan>;
// Publishing spends the account session; both refuse when signed out.
publishMarket(sub: MarketSubmission): Promise<MarketPublished>;
myMarket(): Promise<MarketPackage[]>;
// The account's own package in any state, installed against the digest its
// own preview showed; the kernel reads the package from the registry as the
// account, never from what the window sends.
planOwnMarket(req: MarketOwnRequest): Promise<MarketPlan>;
installOwnMarket(req: MarketOwnRequest): Promise<MarketPlan>;
submitMarket(slug: string): Promise<MarketPackage>;
marketMyVote(slug: string): Promise<MarketVote>;
// value 0 withdraws. The kernel sends the account token to the registry.
voteMarket(slug: string, value: -1 | 0 | 1): Promise<MarketVote>;
// Text the window assembled, put on disk. Returns where it went, or null when
// the host handled it without a path to report (a browser download).
saveText(name: string, content: string): Promise<string | null>;
@@ -207,11 +242,15 @@ export interface AgentPort {
savePermissions(lists: PermissionLists): Promise<PermissionRules>;
/** Take back what a prompt allowed for this session — one rule, or all of them when rule is "". */
revokeSessionGrant(rule: string): Promise<PermissionRules>;
revokeRememberedProjectRule(rule: string): Promise<PermissionRules>;
sandbox(): Promise<SandboxSettings>;
saveSandbox(s: SandboxSettings): Promise<SandboxSettings>;
browserTools(): Promise<BrowserToolsSettings>;
// Writes the user file, then rebuilds: the tools are bound while assembling.
saveBrowserTools(enabled: boolean): Promise<BrowserToolsSettings>;
progressWatch(): Promise<ProgressWatchSettings>;
// Applies to the running turn at its next round; no rebuild.
saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">): Promise<ProgressWatchSettings>;
// null in a browser tab: there is no window to keep running.
trayPrefs(): Promise<TrayPrefs | null>;
setTrayPrefs(icon: boolean, closeToTray: boolean): Promise<TrayPrefs | null>;
@@ -250,7 +289,7 @@ export interface AgentPort {
// Persisted, then the runtime is rebuilt: boot reads every role model while
// assembling, so an assignment cannot reach a runtime that is already up.
setRole(role: string, ref: string): Promise<void>;
storage(): Promise<StorageState>;
storage(query?: StorageQuery): Promise<StorageState>;
planStorageMove(root: string, dir: string): Promise<StoragePlan>;
moveStorage(root: string, dir: string): Promise<StoragePlan>;
providers(): Promise<ProviderEntry[]>;
@@ -272,6 +311,8 @@ export interface AgentPort {
setProviderWebSearch(name: string, on: boolean): Promise<void>;
setProviderThinking(name: string, on: boolean): Promise<void>;
setProviderContinuation(name: string, mode: string): Promise<void>;
// Relabels the entries shown as one account; "" puts the derived name back.
renameProvider(names: string[], displayName: string): Promise<void>;
// Whether the opening sequence still owes this machine a showing, and the
// acknowledgement that closes it out.
@@ -285,6 +326,10 @@ export interface AgentPort {
// one. Resolves only on failure: a success ends with the process handing over
// to the build it just installed.
goToVersion(version: string): Promise<void>;
// Installs the release goToVersion left verified and waiting, and restarts
// into it. Refused with update.restart_busy while work runs here, unless
// force says the person has seen that.
restartToVersion(version: string, force: boolean): Promise<void>;
// Returns an unsubscribe. A browser tab has no shell to report progress, so
// it never fires there.
onUpdateProgress(cb: (p: UpdateProgress) => void): () => void;
@@ -297,6 +342,12 @@ export interface AgentPort {
accountLogin(): Promise<DeviceGrant>;
accountPoll(deviceCode: string): Promise<{ status: "pending" | "complete"; slowDown?: boolean }>;
accountLogout(): Promise<void>;
// Backups exist only for a signed-in account; every call refuses otherwise.
backups(): Promise<BackupCatalog>;
createBackup(req: BackupCreateRequest): Promise<BackupCreated>;
deleteBackup(id: string): Promise<void>;
previewBackup(id: string, passphrase: string): Promise<BackupPlan>;
applyBackup(planId: string, items: string[], consented: string[]): Promise<BackupApplyResult>;
workspaces(): Promise<WorkspaceInfo>;
// Rebuilds the whole runtime against another folder. The conversation does
// not come along, so the caller has to reload the transcript afterwards.
@@ -364,7 +415,8 @@ export interface AgentPort {
// only this says how, and asking per path is what keeps a session that
// touched two hundred files from shipping two hundred diffs nobody opened.
changeDiff(path: string): Promise<ChangeDiff>;
workspaceFiles(path?: string, query?: string): Promise<WorkspaceFiles>;
// Dot entries are left out unless hidden is set; VCS stores never appear.
workspaceFiles(path?: string, query?: string, hidden?: boolean): Promise<WorkspaceFiles>;
workspaceFile(path: string): Promise<WorkspaceFile>;
saveWorkspaceFile(file: WorkspaceFile): Promise<WorkspaceFile>;
// Where an image in the workspace is served, for a rendered document to show.
@@ -389,7 +441,9 @@ export interface AgentPort {
// of their watermarks, because a duplicate folds and a gap does not.
subscribe(onEvent: (ev: WireEvent) => void, onGap?: () => void, bootstrap?: () => Promise<number>): () => void;
submit(text: string): Promise<void>;
// chips carries skill chips beside the line; without it the line is parsed
// as typed, so a kernel that predates chips still runs a leading one.
submit(text: string, chips?: ChipCall): Promise<void>;
// /submit 409s once a turn holds the session. Mid-turn input is durable and
// goes through the inbox, which delivers it at the next tool boundary. The
// receipt is what makes it cancellable while it waits there.
@@ -397,7 +451,7 @@ export interface AgentPort {
// A whole turn, queued because one is already running. The kernel
// refuses /submit with a code rather than a sentence, so this is what
// the client does about it instead of showing anyone the refusal.
queueFollowup(text: string): Promise<Queued>;
queueFollowup(text: string, chips?: ChipCall): Promise<Queued>;
// Takes a queued line back before the turn reads it, and refuses once it has.
cancelQueued(itemId: string): Promise<void>;
// The queue as the kernel holds it. Read on every inbox_changed frame: the
@@ -472,9 +526,13 @@ export interface AgentPort {
setPlanMode(on: boolean): Promise<void>;
setApprovalMode(mode: ApprovalMode): Promise<void>;
// The person's answer to "trust this folder?" for the session's workspace.
decideWorkspaceTrust(trust: "trusted" | "declined"): Promise<void>;
setPreset(preset: Preset): Promise<void>;
setModel(ref: string): Promise<void>;
setEffort(effort: string): Promise<void>;
// "" turns the session's model mode off.
setModelMode(mode: string): Promise<void>;
compaction(): Promise<CompactionSettings>;
saveCompaction(softLimitTokens: number): Promise<CompactionSettings>;
setGoal(text: string): Promise<void>;
@@ -3,6 +3,9 @@
export interface ProviderEntry {
name: string;
// The label a person gave this source. Only ever shown: every ref, the
// default model and saved sessions keep pointing at name.
displayName?: string;
kind: string;
baseUrl: string;
models: string[];
@@ -40,6 +43,12 @@ export interface ProviderEntry {
// level auto resolves to. Absent is no declaration.
supportedEfforts?: string[];
defaultEffort?: string;
// Per model, for a relay serving several vendors under one key: what a model
// declares for itself, the ladder it inherits when it declares nothing, and
// a reasoning protocol it declares for itself.
modelEfforts?: Record<string, ModelEffort>;
inheritedEfforts?: Record<string, ModelEffort>;
modelProtocols?: Record<string, string>;
// Removing the one in use would leave the session on a model that no longer
// resolves, so the row offers no delete.
inUse: boolean;
@@ -164,6 +173,16 @@ export interface ProviderEdit {
// An empty list clears the declared vocabulary; omitted leaves it alone.
supportedEfforts?: string[];
defaultEffort?: string;
// The whole per-model answer for the listed models: a model left out
// inherits the connection's levels again. Omitted leaves every model alone.
modelEfforts?: Record<string, ModelEffort>;
}
// One model's effort vocabulary and the level auto resolves to; no default is
// the first level.
export interface ModelEffort {
supportedEfforts: string[];
defaultEffort?: string;
}
// What the panel sends back after the user has looked at the probe.
+21
View File
@@ -1,3 +1,4 @@
import type { ModelMode } from "./model";
import type { Via } from "./wire";
// A conversation and the states it passes through: what is running, what it
// cost, and the points it can be wound back to.
@@ -30,6 +31,9 @@ export interface HistoryMessage {
// rebuilt card has only the words, which a tool's own output can imitate.
toolFailed?: boolean;
toolRefusalCode?: string;
// outputDiff marks a rebuilt shell result whose whole output is a unified
// diff, re-derived by the host so a reopened transcript renders it as one.
outputDiff?: boolean;
}
// One call inside a history message. resolvedName/capabilityId say what a
@@ -134,6 +138,17 @@ export interface RewindConflict {
export type ApprovalMode = "ask" | "auto" | "dontAsk" | "yolo";
export type WorkspaceTrust = "" | "trusted" | "declined";
/** Where the session's posture came from. A defaulted session opens in auto
* only when the OS sandbox confines writes and the folder is trusted. */
export interface ApprovalDefault {
defaulted: boolean;
writesConfined: boolean;
trust: WorkspaceTrust;
trustable: boolean;
}
// "light" was retired into balanced: its only enforced differences were two
// sub-agent switches, and a setting that costs a choice without changing what
// it names is a question not worth asking. Old sessions still send it.
@@ -210,8 +225,14 @@ export interface SessionStatus {
decisions?: Decision[];
preset: Preset;
effort?: string;
// Absent unless the session's model declares a mode: its switch is not drawn.
modes?: ModelMode[];
modelRef?: string;
// The label the user gave modelRef's provider; absent when none is set.
providerDisplayName?: string;
toolApprovalMode: ApprovalMode;
// Absent from a kernel that predates it; drawn only where it is present.
approvalDefault?: ApprovalDefault;
autoApproveTools: boolean;
bypass: boolean;
goal: string;
+36 -15
View File
@@ -1,11 +1,12 @@
import { PLAN_ACTIONS, type PlanAction } from "./session";
import type { AccountState, AgentPort, Appearance, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import type { AccountState, AgentPort, Appearance, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import { HttpError, type Attachment, type ChangeDiff, type DroppedRef, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port";
import { SseTheme } from "./sse_theme";
import type { StoragePlan, StorageState } from "./storage";
import { SseBackup } from "./sse_backup";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { host } from "./host";
import { current } from "../i18n";
import { openLiveStream } from "./livestream";
// The running project is the default, so its requests stay the bare path they
// have always been and only a cross-project read carries the folder.
@@ -15,7 +16,7 @@ import { current } from "../i18n";
// a load. The read is one small JSON body and answers from memory.
const UPDATE_POLL_MS = 500;
export class SsePort extends SseTheme implements AgentPort {
export class SsePort extends SseBackup implements AgentPort {
status() {
return this.get<SessionStatus>("/status");
}
@@ -178,8 +179,9 @@ export class SsePort extends SseTheme implements AgentPort {
return this.post("/roles", { role, ref });
}
storage() {
return this.get<StorageState>("/storage");
storage(query?: StorageQuery) {
const q = query?.layout ? "?layout=1" : query?.root ? "?root=" + encodeURIComponent(query.root) : "";
return this.get<StorageState>("/storage" + q);
}
// Both answer with a plan: a refused move is reported in the body rather
@@ -241,6 +243,16 @@ export class SsePort extends SseTheme implements AgentPort {
if (!res.ok) await SsePort.fail("/update/install", res);
}
async restartToVersion(version: string, force: boolean): Promise<void> {
const res = await fetch("/update/restart", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ version, force }),
});
if (!res.ok) await SsePort.fail("/update/restart", res);
}
// Failure is silent on purpose. Nothing the user asked for is happening here,
// there is nothing for them to do about it, and the two ordinary answers are
// both non-events: a kernel with no update capability has no route, and a
@@ -420,10 +432,11 @@ export class SsePort extends SseTheme implements AgentPort {
return this.get<ChangeDiff>(`/changes/diff?path=${encodeURIComponent(path)}`);
}
workspaceFiles(path = "", query = "") {
workspaceFiles(path = "", query = "", hidden = false) {
const params = new URLSearchParams();
if (path) params.set("path", path);
if (query) params.set("q", query);
if (hidden) params.set("hidden", "1");
return this.get<WorkspaceFiles>(`/workspace/files${params.size ? `?${params}` : ""}`);
}
@@ -615,10 +628,12 @@ export class SsePort extends SseTheme implements AgentPort {
};
// EventSource resumes on its own: it reconnects carrying the last id it
// saw, and the server replays from there. Recovery here is for the frames
// shed without the connection ever dropping.
const es = new EventSource(this.base + "/events", { withCredentials: true });
es.onmessage = (m) => feed(m.data);
const detach = () => es.close();
// shed without the connection ever dropping, and a replacement stream for
// one that went silent without dropping resumes from the same cursor.
const detach = openLiveStream(
() => this.base + "/events" + (seen === null ? "" : `?lastEventId=${seen}`),
feed,
);
if (bootstrap) void bootstrapFrom(bootstrap);
return () => {
live = false;
@@ -626,14 +641,14 @@ export class SsePort extends SseTheme implements AgentPort {
};
}
submit(text: string) {
return this.post("/submit", { input: text });
submit(text: string, chips?: ChipCall) {
return this.post("/submit", { input: text, ...chips });
}
steer(text: string) {
return this.post0<Queued>("/inbox/items", { input: text, intent: "steer" });
}
queueFollowup(text: string) {
return this.post0<Queued>("/inbox/items", { input: text, intent: "followup" });
queueFollowup(text: string, chips?: ChipCall) {
return this.post0<Queued>("/inbox/items", { input: text, intent: "followup", ...chips });
}
async cancelQueued(itemId: string) {
await this.del("/inbox/items/" + encodeURIComponent(itemId));
@@ -732,6 +747,9 @@ export class SsePort extends SseTheme implements AgentPort {
setApprovalMode(mode: ApprovalMode) {
return this.post("/tool-approval-mode", { mode });
}
decideWorkspaceTrust(trust: "trusted" | "declined") {
return this.post("/workspace-trust", { trust });
}
setPreset(preset: Preset) {
return this.post("/preset", { preset });
}
@@ -741,6 +759,9 @@ export class SsePort extends SseTheme implements AgentPort {
setEffort(effort: string) {
return this.post("/effort", { effort });
}
setModelMode(mode: string) {
return this.post("/model-mode", { mode });
}
compaction() {
return this.get<CompactionSettings>("/compaction");
}
@@ -0,0 +1,22 @@
import { SseTheme } from "./sse_theme";
import type { BackupApplyResult, BackupCatalog, BackupCreated, BackupCreateRequest, BackupPlan } from "./backup";
// Backups kept in the signed-in account. Every call is refused while signed
// out, so a caller shows the section only when the account says signed in.
export class SseBackup extends SseTheme {
backups() {
return this.get<BackupCatalog>("/backups");
}
createBackup(req: BackupCreateRequest) {
return this.post0<BackupCreated>("/backups", req);
}
deleteBackup(id: string) {
return this.del("/backups/" + encodeURIComponent(id));
}
previewBackup(id: string, passphrase: string) {
return this.post0<BackupPlan>("/backups/" + encodeURIComponent(id) + "/preview", { passphrase });
}
applyBackup(planId: string, items: string[], consented: string[]) {
return this.post0<BackupApplyResult>("/backups/apply", { planId, items, consented });
}
}
@@ -1,5 +1,6 @@
import { SseShell } from "./sse_shell";
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
import type { ProgressWatchSettings } from "./boundary";
// Where the agent may reach: the permission rules a call is matched against and
// the sandbox the shell runs in.
@@ -16,6 +17,9 @@ export class SseBoundary extends SseShell {
revokeSessionGrant(rule: string) {
return this.post0<PermissionRules>("/permissions/revoke", { rule });
}
revokeRememberedProjectRule(rule: string) {
return this.post0<PermissionRules>("/permissions/remembered/revoke", { rule });
}
sandbox() {
return this.get<SandboxSettings>("/sandbox");
}
@@ -28,6 +32,12 @@ export class SseBoundary extends SseShell {
saveBrowserTools(enabled: boolean) {
return this.post0<BrowserToolsSettings>("/browser-tools", { enabled });
}
progressWatch() {
return this.get<ProgressWatchSettings>("/progress-watch");
}
saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">) {
return this.post0<ProgressWatchSettings>("/progress-watch", s);
}
configProblem() {
return this.get<ConfigProblem | null>("/config/problem");
}
Loaded 100 of 1037 files, more files were not shown because too many files have changed in this diff. Show more