Compare commits

...
Author SHA1 Message Date
esengine 7b783209fd fix(studio): a thought the reader set to Open is not capped to the live six-line window
The cap keyed on data-live alone, so Open and Open-while-running looked the same while the model thought. SayCard now marks data-full when the thinking mode is open and the cap skips it.
2026-11-07 10:15:47 -08:00
esengine 03943d8a84 fix(studio): the pin button hides until its row is hovered, a pinned row keeps a marker, and the model menu's image badge no longer wraps
.ds-row .sesspin-action set display: grid in a later rule of equal specificity than the one hiding it, so every row showed the button at rest. A pinned row shows .ds-pin at rest when quick actions are on, as it already did when they are off. The badge in the model menu could shrink to one character per line beside a long name; it is now flex: none. Both are held by perf scripts and a rows.css rule test.
2026-11-07 09:19:30 -08:00
esengine 3db2fc12a6 fix(studio): the settings page index shares the title's column, and an open sub-page hides every row of the page it came from
On single-column pages the title and cards are centred in an 880px column while the page index was not, so it sat 95px to the left of them. A sub-page's hide rule lost to the later display rule of inline rows (equal specificity), leaving three empty rows above it. Both are held by perf/settings-page-column.mjs.
2026-11-07 08:25:49 -08:00
yhh 847d450594 docs(release): studio 2.58.0 notes name the update announcement and the new default 2026-11-07 07:11:37 -08:00
yhh 289033df01 feat(studio): a newer release is announced by default and from the sidebar: update_mode reads as notify until the user picks one, so a fresh install checks the catalog in the background, and the sidebar footer carries a button that names the version and opens the version page; a user who chose manual keeps it because the choice is now written to the file 2026-11-07 07:11:24 -08:00
yhh 2fff350d61 fix(jobs): the view test waits for the live job's first line before it reads the running list: the job goroutine had not written it yet on a loaded runner, so the tail came back empty in one run in about two thousand 2026-11-07 06:56:05 -08:00
yhh a5b6cbf977 ci: the push-only full suites run as shards: internal/frontend/serve and internal/assembly/boot are cut by test name, with the rest of the tree on its own runner, on Windows and under -race, so the wall time follows the largest shard instead of the largest package 2026-11-07 06:10:57 -08:00
yhh e115d0da4a ci: a newer push to studio cancels the CI and Studio runs still going for an older one, as a pull request already does; main-v2 keeps a result for every commit 2026-11-07 05:49:11 -08:00
yhh 0e43c6fbba fix(studio): ending a goal from its card removes the card at once: the status is only re-read while a turn runs, so a stopped goal that was cleared kept showing until the next turn; pause and resume read it again too 2026-11-07 05:11:08 -08:00
yhh de8dad66d6 docs(release): studio 2.58.0 release notes, with the board restyle grouped by area and the unreleased fragments merged into it 2026-11-07 05:07:56 -08:00
yhh 0b455f4c81 style(control): the skip-ordering comment keeps to the three lines the repo allows 2026-11-07 04:50:47 -08:00
yhh 01000d4646 fix(control): a skipped question is marked as a skip before its answer reaches the turn: the turn that got the answer first could run on or end before the gate knew why, so it finished as cancelled, was handed a dismissal, or did not requeue the guidance queued into it, which showed as two intermittent failures under the race detector 2026-11-07 04:49:51 -08:00
yhh 18015b1faf fix(studio): three inputs that write the host and had no action name now have one, which the census invariants step, never reached while the tests failed before it, refuses to pass without: the plugin paste field, the market cache retry and the onboarding start button 2026-11-07 04:23:12 -08:00
yhh 7586f9e785 fix(studio): two errors vitest counts as a failed run no longer escape: the image viewer's test supplies the pointer capture jsdom lacks, and the tool output's measuring frame skips an animation query the environment does not have 2026-11-07 03:58:10 -08:00
yhh abeb6b1b36 fix(studio): the workbench no longer renders again every two seconds for nothing: the terminal list poll skips the setter when it finds the list it already holds, since React runs the component once more for an update that hands back an equal value, which is what the poll test saw as a stray commit on the runner; the temporary probes are removed 2026-11-07 03:09:18 -08:00
yhh 1317859a0c test(studio): temporary probes in the workbench file reader, silent unless a test installs them, so the poll test's failure on the runner says whether the file entry was empty or evicted 2026-11-07 02:54:13 -08:00
yhh 8447cc10ac test(studio): the poll test waits through four quiet windows before it counts, and its failure says how many milliseconds after the baseline each commit landed 2026-11-07 02:34:23 -08:00
yhh 03cdec40a2 test(studio): the Markdown poll test says in its failure which poll committed, with how many file reads and listings had happened, so a runner-only difference shows its own cause 2026-11-07 02:23:59 -08:00
yhh f65f584c3f test(studio): the Markdown poll test waits until the panel has stopped committing on its own before it counts what a poll commits, since what opening a file leaves behind lands at a machine-dependent time 2026-11-07 02:00:17 -08:00
yhh bda618594f fix(studio): the browser guards run again from the first to the last script: five real layout regressions are fixed (chip mirror width, a leftover delete button's position, the context card's hover bridge, held approval buttons at 420px, the narrow file panel header), and the guards follow the icon rail being off by default, the settings tabs and deep pages, the phone's more tray and the other deliberate shape changes. Weakened on purpose and to be revisited: session-selected-row asks for 1.05 and 1.02 contrast instead of 1.2 and 1.12, nest-nodes' node ceiling is trivially met by the windowed flow, project-header no longer checks a more menu it does not have 2026-11-07 01:32:38 -08:00
yhh 98c340be47 test(studio): the backup connection tests find the passphrase field by the change action it now names, and the Markdown poll test waits out the 220ms tab-entry timer before it takes its baseline 2026-11-07 01:32:38 -08:00
yhh 87d20e14b3 fix(studio): on a desktop the settings search results fill the page column beside the category list instead of floating across it and the page behind, with the close button kept above them 2026-11-07 01:32:38 -08:00
yhh 4779d91c5b feat(studio): settings are found by what a person types: search ranks by relevance, takes several words and a sentence, and 23 of 43 everyday phrasings that found nothing now land in the first three, held by a findability test; each page opens with an index of what it holds, so settings folded into it have a visible way in 2026-11-07 01:24:25 -08:00
yhh 07d2d4bdd0 Show the cache hit rate after the cold start, and the output ratio
The session's first executor request pays for the prefix every later one
reuses, so the session-wide hit rate mixes "did the cache work once warm"
with "how much a cold start could reuse". usagereport already records the
cold start, but nothing a live session reads projected it.

The live figures come from the serve ledger, which restarts on /new,
/resume and /fork; usageTally does not, so its ColdStart can belong to an
earlier conversation in the same process. The broadcaster therefore takes
the cold start from the ledger's own population: the first request the
ledger files under the executor (pricing.LedgerSource, the rule Add
already used), split by the same CacheSplit the ledger applies, and reset
with it. SessionUsage carries it as coldStart.

- /status (TUI): "warm cache" row, ledger hit rate with the cold start
  taken out of hit, miss and unknown; "out/in" row, output ÷ input.
- Desktop session overview behind the cache meter: the same two figures,
  computed in state/overview beside cacheRate.
- wire-parity: ColdStartUsage mirrored in port/session.ts.
2026-11-07 09:06:37 +00:00
yhh 2e2566c84d test(studio): the Markdown poll test lets what opening the file left pending land before it takes its baseline, so a slower runner does not count it as a poll render 2026-11-07 00:53:34 -08:00
yhh 1b0fbd8bb1 fix(studio): after a batch selection focus returns to the project menu that started it, which no longer is a single list menu, and the session, workbench and rail tests follow the deliberate changes to the project menu, the scope dropdown, the palette ranking, the dirty-tab prompt and the line-numbered code blocks 2026-11-07 00:53:06 -08:00
yhh 6fd42831e2 fix(studio): opening the model panel reads the model list once, and the settings, composer and plan tests follow the deliberate structure of the settings nav folds, the account page behind its pill, the scope tag, the merged model and effort button and the send button staying beside stop 2026-11-07 00:42:50 -08:00
yhh e386c45525 fix(studio): the structural gates are held to the design as it is: SegChoice and PillButton name their action through a certified prop, the shared Menu row's callback is followed into its parent, the slider, strip scroll, dialogs and plan picker carry action names, keyframes, radii, orphan components, translations and settings rows are brought back in line, and the phone layout no longer hides the branch chip from the hover guard 2026-11-07 00:39:10 -08:00
yhh 0fac934b3f fix(ci): the Windows shell listing joins paths the way the OS it is computed for does, the save allocation check is skipped under the race detector that defeats regexp's pool, and the context-retrieval benchmark whose premise archived folds ended is removed with the surface helper only it used 2026-11-07 00:39:03 -08:00
esengine 48cad29d31 fix(studio): the storage move panel plans once the path stops changing and ignores answers for paths already typed past
Every keystroke sent a plan request that walks the whole root. The panel now waits 400 ms after the last change and drops an answer whose path is no longer the one in the box.
2026-11-06 23:50:40 -08:00
yhh 0f26eda9f4 chore: stop tracking the python bytecode caches that the last commit swept in from two untracked benchmark directories 2026-11-06 23:47:52 -08:00
yhh 14649962fc refactor(provider): remove the summary request flag and what only it served: the Request.Summary field and ErrSummaryToolsUnsupported, the adapters' no-tools branches, the broker's capability probe, and the test doubles and live test written for a summarizer call that no fold makes; the reported-usage effect test now detects a fold by its archive pointer and has a control arm 2026-11-06 23:47:31 -08:00
yhh 0f81082776 refactor(agent): remove the summarizer's bill and request identity, which nothing writes now that a fold is an archive: the spend accumulator, the unset usage and request id on the fold result, and the zero usage carried into telemetry and the receipt 2026-11-06 23:24:39 -08:00
yhh 31db766ff1 test(boot): through the real assembly a hand-asked compact folds again after a little more was said, the report names the fold an archive, and a declined one does not claim a summary 2026-11-06 23:22:14 -08:00
yhh 05c8743f03 fix(agent): a fold is described as the archive it is: the decline wording no longer says a summary is being paid for, /context and the TUI name an archived fold instead of calling it summarized or printing the raw mode, and the codes, modes and translations nothing produces any more are removed 2026-11-06 23:08:04 -08:00
yhh be92796684 test: the coordinator handoff and guardian compaction tests stop scripting a summarizer call, which compaction no longer makes since it archives to a file 2026-11-06 22:51:42 -08:00
yhh 6264845f53 fix(tool): a refusal's remedy names the setting tool by its capability id, tool:setting, now that the tool sits behind use_capability and a bare "setting" is not a name the model can see 2026-11-06 22:51:42 -08:00
esengine 20c2705623 chore: clear the unused agent symbols and the two lines App.tsx is over its ceiling
These block every push through lint and make check; none of them changes behaviour.
2026-11-06 22:49:20 -08:00
esengine 1923e50a34 fix(storage): a move plan proves the nearest existing parent writable instead of creating the target
Planning ran on every keystroke and its write probe called MkdirAll on the target, leaving a folder per typed prefix. The probe now runs in the nearest existing ancestor, free space is read from it, and Move creates the target once the user starts the move.
2026-11-06 22:46:13 -08:00
yhh 7c388628f2 fix(studio): the session-message form's recipient select fills the form instead of the 140px every select in a settings card gets, so its label no longer wraps over two lines 2026-11-06 22:41:24 -08:00
yhh 339f443ffb feat(boot): setting, present_files, compress and context_budget move behind use_capability and are indexed in its description, cutting the first-round tool definitions by another 4 KB 2026-11-06 22:34:44 -08:00
yhh c8afd3be60 feat(boot): the browser tools move behind use_capability, whose description now indexes the deferred tools by id with a short purpose, so a model that could see a browser but not web_fetch no longer opens a page to read a JSON endpoint 2026-11-06 21:59:04 -08:00
yhh 756c6159a6 fix(agent): a compact asked for by hand no longer answers "too little added" after a checkpoint; asking waives how much was added, and an unchanged context is still declined 2026-11-06 21:22:30 -08:00
yhh 26f72585ee fix(control): in yolo mode remember no longer asks for approval either, so neither memory tool prompts under the mode that skips every other approval 2026-11-06 21:13:36 -08:00
yhh 5b80976176 fix(control): in yolo mode forget no longer asks for approval; it archives a memory rather than destroying it, and remember still asks 2026-11-06 21:11:56 -08:00
yhh 044d2ee025 fix(studio): in a narrow conversation the composer's model pill shrinks the model name instead of letting the divider and reasoning level spill out of the pill and onto the send button 2026-11-06 21:11:55 -08:00
yhh 081f35e9dd fix(studio): the settings category strip on a phone can be scrolled with a mouse wheel, brings the chosen category into view, and fades at its edge so a cut-off tab reads as more to the right 2026-11-06 20:20:22 -08:00
yhh 6e56478321 fix(studio): on a phone the settings sheet no longer draws the desktop's nav-column divider and darker strip, which showed as a stray vertical line through the page 2026-11-06 20:18:23 -08:00
yhh 37ec853810 feat(studio): on a phone the settings sheet fills the screen as one column, with the categories as a strip above the page, so rows, titles and the skills list get the whole width instead of a sliver beside a nav column 2026-11-06 20:14:40 -08:00
yhh 73a1b23696 fix(studio): on a phone the composer's more-tools button can be seen (heavier dots inside a ring), and the model pill takes the room its text needs and shows the reasoning level instead of overlapping the button and leaving a dangling dot 2026-11-06 20:00:17 -08:00
yhh 8c71d355b0 feat(studio): the phone's reasoning control is the product's own depth line instead of a look modelled on another product's slider; a thin line in the brand colour with a current flowing along it, a node per level, and a thumb wearing the Reasonix mark. Also fixes the model pill's separator, which had been written as a NUL character 2026-11-06 19:55:42 -08:00
yhh 155ffaa49b feat(studio): the reasoning slider follows the finger continuously and eases onto a level, its stars drift, its light stays inside the card, and it carries a dot and a name for every level with a bloom behind the thumb; the sheet's search box is spaced from the list 2026-11-06 19:52:02 -08:00
yhh ef0ceacf78 feat(studio): the reasoning slider is lit from inside: stars that twinkle out of step, a glow that sweeps along the fill, and a glowing thumb; all of it holds still under reduced motion 2026-11-06 19:45:24 -08:00
yhh 552f48ab7a feat(studio): on a phone the reasoning levels are one gradient slider with the level named above it, chosen when it is let go, and the model rows in the sheet are compact 2026-11-06 19:42:41 -08:00
yhh e8b13d13dd feat(studio): on a phone the model menu is a sheet along the bottom edge instead of two squeezed columns; the model list scrolls by itself while the reasoning levels stay in view as one row of chips 2026-11-06 19:38:53 -08:00
yhh ae46c30d3e feat(studio): on a phone the composer's tool row is add, the model pill and send, with the other controls one tap behind a 'more tools' button; stop is a round pause button, and the pinned control order no longer applies on a phone 2026-11-06 19:27:53 -08:00
yhh 411e19d87e feat(studio): on a phone the title bar keeps the session list, the title with the machine under it and one menu, a lost connection says it is reconnecting, and an approval is two 44px buttons with the other choices behind 'more options' 2026-11-06 19:14:06 -08:00
yhh c155364b3d fix(studio): on a touch screen the select options are finger-sized and the context card's expand control and manage link are tall enough to press 2026-11-06 18:52:04 -08:00
yhh e89b435af9 feat(studio): every select opens the app's own list instead of the system one; one rule in the shared select styles covers all 35, behind a feature guard so engines without styleable selects keep the native list 2026-11-06 18:47:00 -08:00
yhh 0549af617b fix(studio): the context details card is tighter, and its expanded rows keep the card's side margin instead of sitting against the edge; a long tool name is cut with an ellipsis rather than pushing the figure out of the card 2026-11-06 18:39:35 -08:00
yhh ce51afd2dc fix(studio): a code block wraps long lines instead of scrolling sideways; each source line is its own row carrying its number, so the number stays with the first row of a wrapped line 2026-11-06 18:33:23 -08:00
yhh 2bfb7de07a feat(studio): the plan list opens from the height of its bar and leaves again before it is removed; the exit holds the list only while the stylesheet gives it an animation, so reduced motion and a missing animation close at once 2026-11-06 18:29:54 -08:00
yhh 8658f020af fix(studio): the global shortcut setting reads like its neighbours; the chord is shown in the notation people use, the three one-shot actions are pill buttons instead of a selector strip, recording is visible on the field, and the explanatory text wraps instead of being cut off 2026-11-06 18:22:53 -08:00
yhh 0a38f2e47a fix(studio): the open plan is as wide as its collapsed bar, and the transcript gives way to it instead of ending underneath 2026-11-06 18:17:52 -08:00
yhh 7c7e3ff99c perf(session): resuming a long session no longer repeats work; a replay observes each touched path once, event records are framed in bulk, and a load whose ledger agrees with its transcript is a verified baseline so switching away from an untouched session reads nothing back; resume of a 15 MB log goes from about 1.0 s to 0.42 s 2026-11-06 18:17:52 -08:00
yhh 868a14cadc perf(session): a save that finds its files as its own last save left them no longer replays the event log; the prefix is verified by digest in the pass that digests the transcript, cutting a 5000-message save from 137 ms to 50 ms 2026-11-06 17:25:44 -08:00
yhh 63da66261c perf(serve): package init no longer loads the OS mime table, taking Windows startup from 143 ms to 91 ms; page files already carry their own content types 2026-11-06 17:06:12 -08:00
yhh 76ed2566ed feat(context): a fold archives the whole span and keeps nothing: no first-turn pin, recent tail, kept user turns, user-words list, work index or summary; the pointer is one short message and keep, recent_keep, user_turn_keep_tokens and first_turn_pin_tokens are retired 2026-11-06 10:02:32 -08:00
yhh e808b48946 fix(studio): the dev server proxies /terminals to the kernel 2026-11-06 08:36:55 -08:00
yhh 817d78b399 fix(memory): the new save refusals carry wording in the desktop, and the remember freshness effect test states the mode it exercises 2026-11-06 08:28:08 -08:00
yhh 813af4958e feat(memory): the model asks before remembering — remember holds a suggestion the user accepts, edits or dismisses; memory.proactive is ask, auto or off, and YOLO saves directly; the open goal panel joins the composer 2026-11-06 08:17:42 -08:00
yhh b0786368ca feat(studio): the goal strip follows the Chat-Goal-Memory board with status dot, one usage line, stop reason and pause, resume and end; the memory row reads as the board does 2026-11-06 07:33:23 -08:00
yhh c457700233 feat(studio): first-run ends with the project step and a hand-over: drop or pick a folder, the card shrinks away, the composer rises and types its prompt 2026-11-06 07:26:01 -08:00
yhh c4375bcde9 fix(studio): the open-with menu names the editor as the board does (VS Code) and cuts a long name short instead of spilling out of the menu 2026-11-06 07:19:37 -08:00
yhh f5c4d463b9 feat(studio): first-run motion from the Onb-Motion board: the wordmark draws in, a 7-second session demo loops beside the welcome, the connect button becomes the R and then a green tick, the glow breathes 2026-11-06 07:18:11 -08:00
yhh 402b524fd7 fix(studio): the first-run welcome says what to do instead of a slogan 2026-11-06 07:13:08 -08:00
yhh a846cf7fc1 fix(studio): the compaction confirmation writes the context size with K like the board 2026-11-06 07:11:02 -08:00
yhh 87d9ed6150 feat(studio): the changes header shows the full branch name and the full count on hover, since it truncates them 2026-11-06 07:09:39 -08:00
yhh 9d5775c789 fix(studio): the changes page header stays one 40px row with the branch and the summary truncated and the buttons whole; the context details rows are 28px apart, not 34 2026-11-06 07:07:37 -08:00
yhh aadd62e2e5 fix(studio): the context details card follows the Chat-Tools board: K for thousands, the one-line hint, MCP counted as connected and tools, a plain Manage MCP, values in the interface face 2026-11-06 07:05:34 -08:00
yhh 77d875db8d fix(studio): the commit history in the explorer follows the WB-Frame board: 32px heading, 26px rows inset from the edge, hash, subject and a bare age 2026-11-06 07:00:05 -08:00
yhh c9fb8f7ee3 fix(studio): the refined-prompt card is drawn as the Compose board has it: inset in the composer, header, body and a footer on the page tone 2026-11-06 06:57:52 -08:00
yhh 1b3d1e0c8d fix(studio): the side question leaves the input the moment it is sent and returns to it only if the ask fails 2026-11-06 06:56:21 -08:00
yhh 6420f14fda fix(studio): a remote project row's new-session button is a plus as well 2026-11-06 06:55:04 -08:00
yhh f200f6108f fix(studio): markdown tables wrap their cells to the column instead of scrolling sideways 2026-11-06 06:54:15 -08:00
yhh 572e0d54b3 fix(studio): a project row's new-session button is the plus the Sess-Rows board draws, not a chat bubble 2026-11-06 06:48:31 -08:00
yhh 0a43322267 feat(studio): opening a project whose folder is gone asks, in the list, whether to remove it from the list, instead of reporting the system's sentence 2026-11-06 06:47:20 -08:00
yhh 1ff0525a34 fix(serve): opening a project whose folder is gone is refused with workspace.folder_missing and the path, not the system's stat sentence 2026-11-06 06:44:19 -08:00
yhh ec45959a6e fix(studio): on macOS the brand in the sidebar head starts to the right of the window controls instead of under them, as the Mac-Titlebar board draws it 2026-11-06 06:41:20 -08:00
yhh 253733c1f9 fix(studio): the effort column of the model panel no longer reaches 8px past the panel, which clipped its heading against the top edge 2026-11-06 06:36:23 -08:00
yhh 32f597d16e feat(studio): the dividers are a line with no handle, and a collapsed sidebar is reopened by the icon in the top bar and Ctrl+B, as the Rail-Collapse board draws it
The grip on each divider (a dotted handle, an arrow tab when shut) is gone; the
divider still drags, resets on double-click and sizes from the keyboard, and a
shut column is a thin edge that can be dragged back out. The sidebar toggle is
the panel icon at the top left whenever the sidebar is collapsed, and its
shortcut is Ctrl+B as the board says, replacing Ctrl+backslash.
2026-11-06 06:18:10 -08:00
yhh c78b996d13 fix(studio): the idle run-state row no longer leaves a blank band above a note strip, so the strip sits at the top of the composer as the Compose-Notes board draws it 2026-11-06 05:19:16 -08:00
yhh 933ee31d6c feat(studio): the notes above the composer are one line at a time, as the Compose-Notes board draws them; a remote project's terminal says which machine it is on
The composer showed the connect strip, the trust question and the sandbox note
together, each with a paragraph, and a short stack clipped them over the input.
Now one strip is drawn, by priority (cannot send, trust, no sandbox): a single
line, the explanation in the hover text, calm grey for reminders and warm only
for "cannot send".

A terminal opened from a remote pane runs on that machine through the existing
forwarding, and its header carries the host. A test holds that the host's
requests are forwarded and a paired device's never reach the far kernel.
2026-11-06 05:06:01 -08:00
yhh 83bdeb81f0 feat(studio): the workbench gets a terminal of your own, as the Terminal board draws it
A fourth kind of tab opens a real shell (ConPTY on Windows, a pty elsewhere) in
the project folder, outside the model's permissions and sandbox and with the
host's provider keys left out of its environment. The kernel keeps each shell's
output, so a tab left or a window reloaded comes back as it was; a tab is named
for the command submitted while the shell is busy and carries a blue dot, and
closing a busy one asks first.

Also: the composer's note stack no longer clips to a few pixels when it holds
two notes, which left the second one overlapping the input.
2026-11-06 04:44:41 -08:00
yhh 3cf430d823 feat(studio): restoring a backup asks for the passphrase and reads the differences in a dialog, and says what happened in one strip per outcome, as the recovery board draws it 2026-11-06 03:34:59 -08:00
yhh efcd3bbf51 feat(studio): notices carry an info, warning or error glyph instead of a dot, and a pending permission request puts Refuse at the left, as the cards board draws them 2026-11-06 03:06:42 -08:00
yhh a3303025fe feat(studio): a turn closes with a tick and Done · duration at the right edge, as the reply board draws it; start time moves to the hover 2026-11-06 02:57:38 -08:00
yhh 6ebff84af2 feat(studio): the extension page gets a project pill beside Add, so the project is switched in place; the settings row stays as the same control 2026-11-06 02:50:06 -08:00
yhh 5e00091026 feat(studio): the extension project scope moves into settings as an Extension scope row on the Permissions page; the extension page only says so when it is managing another project, as the new Set-scope board draws it 2026-11-06 02:35:44 -08:00
yhh 4c6ba31a1c test(studio): the automation card test waits for the scheduled-runs row, which appears once its board is read 2026-11-06 02:28:38 -08:00
yhh 5868b02bdf feat(studio): hooks and scheduled runs move from the extensions page into an Automation card on the Agent behaviour page, as the new Set-automation board draws it 2026-11-06 02:28:06 -08:00
yhh ab6052b8a2 feat(studio): the extensions page drops the separate Runtime card for the board's one-line note on when changes take effect, keeping the rescan button beside it 2026-11-06 02:12:29 -08:00
yhh d437997362 feat(studio): a reply whose layout fails shows its source with an as-written tag, and a tool card that cannot be drawn falls back to a simplified line, as the recovery board draws them 2026-11-06 02:06:21 -08:00
yhh e4c97fdfab feat(studio): visual effects is one switch and the appearance page gets its advanced-appearance row, as the look board draws them 2026-11-06 02:04:33 -08:00
yhh c8eb8693fa feat(studio): native session import is a dialog with a dashed drop area, theme and session import results are the recovery board's green and warning strips 2026-11-06 02:03:09 -08:00
yhh 851f5fe19f feat(studio): the export-to-file categories are pill checkboxes with two passphrase columns and a warning-tone shortfall, as the recovery board draws them 2026-11-06 02:01:16 -08:00
yhh 7c26f0d5ec fix(studio): the notice and config sub-lines get their own class names, which the monospace guard reads as prose 2026-11-06 01:59:36 -08:00
yhh 0e04e0d753 feat(studio): the unparseable-config banner is the recovery board's card at the top of the settings page, with the offending line, the repair and a backed-up repair action 2026-11-06 01:59:03 -08:00
yhh f05b2392c3 feat(studio): update, size-ask and stall notices use the board's card; the unsaved-preset dialog lists what changed; settings-did-not-open is a centred placeholder; notices past three fade out 2026-11-06 01:55:41 -08:00
yhh e41bddb590 feat(studio): notices are the board's one strip — 380px cards with an icon and ghost-pill actions, stacked above the composer, newest three kept; the queue style guard follows the new rules 2026-11-06 01:50:17 -08:00
yhh e7cc0d9841 fix(studio): the extensions page names its group External services, as the installed board does 2026-11-06 01:39:50 -08:00
yhh 61d2f00f17 feat(studio): the appearance size row offers small, standard and large in place, as the look board draws it 2026-11-06 01:36:56 -08:00
yhh 7c4f42b1c0 fix(studio): the context card says where the window folds on its own, and the compaction confirm uses the board's wording 2026-11-06 01:25:54 -08:00
yhh cc79075291 feat(studio): the queue strip sits inside the composer card between the run row and the input, as the composer board draws it 2026-11-06 01:20:41 -08:00
yhh 1fbc0f52dd test(studio): a waiting steer stands at the end of the transcript, also while a sentence streams 2026-11-06 01:15:51 -08:00
yhh 195e048b55 feat(studio): steer and queued lines follow the composer board — a waiting steer is a dashed bubble at the end of the conversation, a queued line is one pill row with make-steer, send-now and withdraw 2026-11-06 01:09:14 -08:00
yhh b1106b98f3 fix(studio): settings number fields get a visible box, and the slash menu lays its rows out on one line as the composer board does 2026-11-05 22:50:10 -08:00
yhh 53ca70d347 fix(studio): code type is set in IBM Plex Mono as the Type board declares, not JetBrains Mono 2026-11-05 21:01:44 -08:00
yhh e000d91b13 fix(studio): the usage page's export button is drawn as a quiet pill instead of a bare browser button 2026-11-05 20:58:47 -08:00
esengine dc768371ab chore(agent): drop the snipping helpers nothing calls since compaction archives to a file
The unused linter reports eight symbols in prune.go that the archive-to-file change left without a caller; the strategy type and its hint mapping, which are still used, stay.
2026-11-05 20:48:17 -08:00
esengine e9b0b23224 docs(release-notes): file-changes setting wording 2026-11-05 20:44:46 -08:00
esengine 7f4d00ca95 fix(desktop): the file-changes setting says the count sits at the top of the input box
The setting promised it above the input box while the chip draws inside the composer's top edge, as the design board shows. Both lines of the setting now say top.
2026-11-05 20:44:46 -08:00
yhh 470cc01b0a fix(studio): the usage page lays its charts out as the board does and its table header follows the board's type
With two currencies the third chart took a row of its own and left the right half of the page empty; the spend charts now stack in one column beside the usage chart. The daily table's header is 11px medium in the interface face, not 13px bold mono, and its rows take 9px padding.
2026-11-05 20:29:34 -08:00
yhh ff1ba768f0 fix(studio): the session overview and context details cards follow the board's content, not only its sizes
Overview: four figures (requests with the main/sub split, cost, tokens, cache hit), each source as a bar with its cost, and a footer with the work time and a link to the context details; the context window is no longer drawn twice. Context details: the compact action, the MCP count and the manage link share one footer row instead of a server list, and a row with a breakdown shows its figure and a chevron, not a bordered pill.
2026-11-05 19:53:59 -08:00
yhh de226cae50 fix(studio): the title bar behind the settings window is veiled too
The veil starts below the title bar, so with the enlarge mode gone its text showed sharp above the blurred app. A strip beside the veil, not inside it because a blur only reaches what is within its own element, now blurs that band and lets clicks through to the title bar's buttons.
2026-11-05 19:45:30 -08:00
yhh af59635bf6 fix(studio): settings is drawn as a gear, not a sun
The sidebar's settings button and the common settings page wore a circle with eight rays, which is the sun the appearance page and the light-or-dark control already are, and at 16px the two could not be told apart. Both now use one toothed gear; the appearance page keeps its sun.
2026-11-05 19:27:25 -08:00
yhh 281029d1a8 fix(studio): the settings window has a largest size and stays centered instead of filling a big screen
1360 by 900 at most, over the dimmed app; a smaller window still gets it filled. On a 2000px window the content was either 700px columns or a block with 180px of empty margin each side. Follows the revised board Set-Wide.
2026-11-05 19:20:51 -08:00
yhh 3bf7491840 refactor(studio): the settings window loses its enlarge mode and keeps one close button
The sheet already fills the window, so enlarging only took it over the title bar and added three window buttons beside its own two. The control, the stored preference, the window buttons it summoned and the drag-region rules that went with it are removed; the board draws no buttons in that corner besides closing.
2026-11-05 19:13:20 -08:00
yhh 4d29b0bf74 fix(studio): the settings block is wider so a wide window leaves less empty margin
Single-column pages cap at 880px and two-column pages at 1400px, up from 760 and 1160. Follows the boards Set-Wide and Set-Full.
2026-11-05 19:06:45 -08:00
yhh 1d98de9d4b feat(studio): settings rows say only what differs
A block that applies on this machine at once says nothing; one that needs a restart, belongs to the workspace and so on gets a tag beside its name, and the page title carries one line saying what the usual is. Descriptions stay on one line with the whole text on hover, rows take 6px padding, and a row with no description is a single line. Follows the board Set-Full.
2026-11-05 19:05:28 -08:00
yhh 7ec53ac034 fix(studio): in two-column settings the page title sits with the block and a row with a control and a long note no longer squeezes its label
The right column of a settings row was sized to the widest line of the note under its control, so at 572px a column took 501 and the label shrank to one character. It is now capped at 320px and the note wraps. The page title takes the block's width and centering.
2026-11-05 18:48:28 -08:00
yhh f78be8c399 fix(studio): settings content is centered and 常用 goes two-column when wide; the changes page folds its per-file note into a tag past eight files
Settings: the card column is centered, 760px for single-column pages and 1160px for two-column ones, with 常用 added to the two-column pages; below 860px it stays one column. Changes: past eight files a new or untracked file shows a tag in its own header instead of a folded row under it, the top line splits modified from untracked, and the header weights, the 14px checkbox and the note's weight follow the board. Follows the new boards Set-Wide and WB-Changes-Many.
2026-11-05 18:44:50 -08:00
yhh 97a5ba687c docs(studio): the picker paragraph in TOKENS.md stays within the prose budget 2026-11-05 18:26:01 -08:00
yhh cb6fe7ef9c fix(studio): pickers follow one panel spec; in the model panel only the rows scroll
The model panel pins its title, filter and footer and scrolls just the list inside its own column, with the effort column fixed beside it and both capped at 440px. The agent preset menu is 340px with 12px descriptions and the workspace dialog 340px. TOKENS.md now states the picker panel rules the board Spec-Pickers draws instead of the older 12px-corner, 11px-description line. Menu items can be marked foot to stay under the scrolling rows.
2026-11-05 18:25:26 -08:00
yhh 5a0cf3f6ea build(studio): make studio skips the frontend and kernel steps when nothing they read is newer than what they made
STUDIO_REBUILD=1 forces both. The comparison is a small Node script because the launcher runs under a Git Bash that may have no GNU find.
2026-11-05 18:14:07 -08:00
yhh bd62832a62 fix(studio): the model and effort panel is the board's size
600px instead of 700, capped at 440px tall; model rows are one 34px line with the provider note and badges beside the name; effort rows lose the strength bars and the third line; the column fits its content and stays in view, and its footer is the single line the board draws.
2026-11-05 18:08:46 -08:00
yhh 9e95d0837e fix(studio): the work-mode menu follows the board and no longer lists Ask
300px panel, a bold title with the scope on the right, two-line rows with a 12px description, the current row in a grey wash with a blue tick. The unimplemented Ask row and its two strings are gone; it comes back when the mode exists.
2026-11-05 17:59:15 -08:00
yhh f0aca7b212 fix(studio): the execution-process group is a quiet line, not a card
No border or box: a 26px line in the transcript's own type, the running label shimmers as text (the same 1.6s light as thinking) and nothing else moves while it is folded, open it is an indented list on a hair rule, a failure is a small pill, and a finished group folds to one faint line of count and time. Follows the Think-Exec light variant.
2026-11-05 17:47:32 -08:00
yhh b3b5ef409f fix(studio): the configure-workspace dialog follows the board
360px panel, a small two-segment switch with the creation note under it, a source-branch label, 32px search and branch rows in the mono face with a pill for the current or occupied branch, and the one-line note at the foot. The per-row chevrons, dividers and the switch's icons and ticks are gone; the folder name and path stay as quiet lines because the board does not draw the current-folder page.
2026-11-05 17:44:52 -08:00
yhh b7da613abe fix(studio): the not-connected note is drawn as the board has it, and the changed-files pill no longer sits on a note
The note is a borderless warn wash inside the input box with a text link, not a bordered strip with a filled button. The pill's pull-up margin assumed nothing above it in the box; with a note present it now takes its own line.
2026-11-05 17:33:54 -08:00
yhh 4d0794b8f0 fix(studio): the overlays aligned to the board draw its 500 as --w-reg, not --w-med
The board sets body text at 500; the default weight ladder is 500 / 600 / 700, so 500 is --w-reg. Rules written as --w-med drew every line one step heavier than the board, and on Windows 600 falls on the bold face. Side panel answers also take the board's 1.7 line height.
2026-11-05 17:29:24 -08:00
yhh ca80fec2fc fix(studio): a hand-written provider at a declared address takes that preset's vendor mark; the setup cards show vendor marks
The mark for a model came only from the preset a provider was installed from, so an entry pointing at api.deepseek.com by hand drew a letter tile. The address is now read through the curated preset serving it, as vision models already are. Setup service cards draw the mark their vendor declares.
2026-11-05 17:17:45 -08:00
yhh 1b8b56aa65 feat(studio): compaction archives earlier turns to a file instead of summarizing; a skipped ask ends the turn as finished; chat overlays follow the design board
Compaction moves the folded span into <session>.outputs/fold-<hash>.md and leaves a pointer the model reads back with read_file. The summarizer path, its retries, bounds and tests are removed.

Answering an ask with no selection ends the turn normally instead of reporting a failure.

Find bar, overview and context cards, compact dialog, image preview, root-missing banner, toasts, side panel and prompt refine are aligned to the Chat-Tools board; the agent preset picker follows the Agent-Picker board.
2026-11-05 16:25:25 -08:00
esengine 1cdbb9303f test(tui): the wait for the provider after a saved key is the hang-catching budget
A fixed ten seconds reported a hang on a loaded Windows runner where the first-run test had already spent forty seconds. testenv.Budget exists for waits that only catch a hang.
2026-11-05 08:19:41 -08:00
esengine 8beea984d0 test(config): the churn in the state-root walk test finishes every removal it starts
A removal that lost a race with the walker was dropped, so the directory stayed behind and, because names are never reused, a later walk could not tell it from an unreadable one and failed after the five-second re-stat window. The churn now retries the removal until it succeeds, which is what the never-reused-name invariant assumes.
2026-11-05 08:19:27 -08:00
esengine 8a4c879184 docs(release-notes): installer and the shared computer-use driver 2026-11-05 07:15:21 -08:00
esengine f1bbea3885 fix(installer): a path-less computer-use driver no longer blocks an install or update
The running-copy check matched processes whose path it could not read by name, and the name list included cua-driver.exe, which other computer-use apps ship too. An elevated or orphaned driver from another app therefore read as Studio still running, and the retry loop could never succeed. Only the app's own executable and its host are matched by name now.
2026-11-05 07:15:12 -08:00
esengine 863575ebf7 docs(release-notes): point the restyle lines at the commits' current hashes 2026-11-05 10:50:35 +00:00
esengine 6a33920ae7 docs(release-notes): settings switches 2026-11-05 10:45:30 +00:00
esengine 965b9c1af8 feat(studio): plan progress, office preview and new-answer divider are switches as the board draws them; the language reads Simplified Chinese; execution rows lose the fold arrow 2026-11-05 10:45:25 +00:00
esengine 802f368ce8 docs(release-notes): starter chips and changed settings 2026-11-05 10:41:05 +00:00
esengine c93eabbe4c feat(studio): starter chips in the empty conversation and a changed-settings card on the common page 2026-11-05 10:41:00 +00:00
esengine 3fbc2dc105 docs(release-notes): live pulses 2026-11-05 10:21:41 +00:00
esengine b2b1b23027 fix(studio): running pulses take the live duration and the streaming caret blinks as the board draws it 2026-11-05 10:21:36 +00:00
esengine b0123ea2f9 docs(release-notes): keyframe convergence 2026-11-05 10:20:06 +00:00
esengine 53cd9a0ac3 refactor(studio): keyframes converge to the six spec 07 shared ones plus the ones a board draws, registered in a test 2026-11-05 10:20:01 +00:00
esengine 0e404acc2e docs(release-notes): onboarding motion 2026-11-05 10:01:17 +00:00
esengine 15078fbdb6 feat(studio): onboarding motion: 40px step slide, tile entrance, staggered choices, error shake and fade 2026-11-05 10:01:12 +00:00
esengine 2cde9eb7ce docs(release-notes): motion pass 2026-11-05 09:58:57 +00:00
esengine cf4ed6d7c6 fix(studio): motion follows spec 07: entrance animations play again, spec durations and one global reduced-motion rule 2026-11-05 09:58:51 +00:00
esengine 280e48f4dd docs(release-notes): composer model name 2026-11-05 09:52:53 +00:00
esengine e239a07459 fix(studio): the composer keeps the full model name in a narrow conversation column 2026-11-05 09:52:48 +00:00
esengine 77aaa519eb docs(release-notes): execution group frame 2026-11-05 09:49:21 +00:00
esengine 25f26b071d fix(studio): execution group keeps its hairline frame as the Think-Exec board draws it, without the shell tag 2026-11-05 09:49:15 +00:00
esengine 448ff4d19c docs(release-notes): conversation and workbench type pass 2026-11-05 09:33:00 +00:00
esengine f4f342cecf fix(studio): conversation cards, status popovers and workbench follow the boards' font size and weight; reading text is the regular weight 2026-11-05 09:32:54 +00:00
esengine dff8c7dc16 fix(studio): card install button, contribution rows and market filter follow the boards' size and weight 2026-11-05 09:13:23 +00:00
esengine df02bd5938 docs(release-notes): font size and weight pass 2026-11-05 09:08:53 +00:00
esengine 22aeea5cd6 fix(studio): match the boards' font size and weight on feedback, usage, onboarding, extensions, memory, remote and cards; package detail with the install step in the head; icon-only settings controls 2026-11-05 09:08:47 +00:00
esengine 9919c5c4d1 docs(release-notes): my published rows 2026-11-05 08:51:20 +00:00
esengine 52ce4c5dd1 fix(studio): my published packages list as rows, and the market group stays flat on hover and focus 2026-11-05 08:51:14 +00:00
esengine 81f368fb94 docs(release-notes): extensions market flat 2026-11-05 08:46:04 +00:00
esengine 870a5ef3cd feat(studio): extensions market and list sit flat on the page, without a frame per group 2026-11-05 08:45:58 +00:00
esengine b5833bff78 docs(release-notes): extensions list sections 2026-11-05 08:36:59 +00:00
esengine e512b30cf1 feat(studio): extensions list as flat sections beside an unframed detail 2026-11-05 08:36:53 +00:00
esengine 9c25f0e2e6 docs(release-notes): phone access popover 2026-11-05 08:30:06 +00:00
esengine ec63de72e5 feat(studio): phone access shows the QR beside its expiry and actions 2026-11-05 08:30:00 +00:00
esengine 98d83116de docs(release-notes): remote machine cards 2026-11-05 08:24:11 +00:00
esengine b9c03d4a95 feat(studio): remote machines as cards with one next step and the on/off in the menu 2026-11-05 08:23:54 +00:00
esengine c8c4162818 docs(release-notes): name the commit on the receipt and card lines 2026-11-05 08:16:29 +00:00
esengine 5e1c9b652f docs(release-notes): receipt removal and light remember/guardian cards 2026-11-05 08:16:07 +00:00
esengine 8472468039 refactor(studio): remove the delivery-receipt chain and the completion-standard row; light remember and guardian cards 2026-11-05 08:16:07 +00:00
esengine b666c13dd4 docs(release-notes): lighter compaction, execution and delivery cards 2026-11-05 07:31:37 +00:00
esengine 12bad68c6a feat(desktop): the compaction card, the execution group and delivered files lose their frames, as Chat-Cards-Light draws
The compaction card is titled by what it did (earlier content folded into a
digest, auto or manual) with its reason and sizes in one line and no bar or
frame of its own; the execution group has no outer frame, its failure count is
red text, and a failed step's reason sits behind a 2px red rail instead of in a
tinted box; delivered files are one list with hairlines, their open action a
text link. Frames stay on the cards that ask for something.
2026-11-05 07:31:37 +00:00
esengine c4927af867 docs(release-notes): fold earlier content after compaction 2026-11-05 07:24:21 +00:00
esengine 7b43b14559 feat(desktop): the transcript folds what an earlier compaction replaced, as Chat-Compaction draws it
After the second compaction the content before the second-latest compaction
folds behind one divider line at the top of the conversation ("N earlier
messages folded into a digest · show the folded content"); the stretch the
latest compaction closed stays on screen, so a fresh compaction never leaves the
page empty, and each further compaction folds one more stretch into the same
line. The fold is display only, always starts folded, and opens in place.
2026-11-05 07:24:21 +00:00
esengine 58c0b7b4a8 fix(desktop): register the palette's scope chip action
The scope chips added to the command palette carry data-action=palette.scope,
which the action catalogue did not list.
2026-11-05 06:46:30 +00:00
esengine ae2ade4485 docs(release-notes): usage page layout per board 2026-11-05 06:38:24 +00:00
esengine 9549614364 feat(desktop): the usage page lays out as Usage-Page does
The paid figure is the first of four tiles instead of a hero above them, with
its estimate and coverage caveats under it; daily tokens and daily spend sit
side by side, spend drawn as bars rather than a line; the duplicate block
heading above the page goes. The kernel still reports only a total for output,
so there is no output tile.
2026-11-05 06:38:24 +00:00
esengine b2abc6b103 docs(release-notes): command palette per board 2026-11-05 06:16:12 +00:00
esengine e28583407c feat(desktop): the command palette draws scope chips, counted groups and session status, as Cmd-Palette does
The palette gets the board's scope chips under the input (actions, sessions,
settings; each toggles its scope), a count on each group once something is
typed, and a coloured dot with the word on a session that is running or waits
on the person, the rest carrying their age. Notes that say something about a
row sit beside its name; a shortcut keeps the right edge. The foot puts the keys
on the left and the scope symbols on the right. Files are not a scope: nothing
in the port lists a workspace's files.
2026-11-05 06:16:12 +00:00
esengine 20880b24b1 docs(release-notes): onboarding welcome step per board 2026-11-05 06:06:23 +00:00
esengine a54fd45859 fix(desktop): the onboarding welcome step is centred with the wordmark, as the board draws it
Onb-Flow's welcome centres a large wordmark, the headline, one line of body,
the primary button and the note; it was a left-aligned card with the skip
beside the button. The skip stays, now under the button. The board's
import-from-backup link is not drawn: onboarding has no import flow to call.
2026-11-05 06:06:23 +00:00
esengine 06c8ca12e7 docs(release-notes): appearance, display and operation settings pages per board 2026-11-05 05:13:56 +00:00
esengine 98e3406998 feat(desktop): Appearance, Conversation display and Operation & performance are three pages of their own
They were one component hiding the other two's blocks with a stylesheet. Each
page is now composed as its board draws it: Appearance as theme, text and
window cards (wallpaper, typeface, size and window behind pills; light/dark,
effects, weight and contrast on the row), Conversation display as process,
files and previews, and notices, and Operation & performance as shortcuts, tabs
and navigation, while running, and performance. A single-control block draws as
one row through Group's inline form. The advanced-appearance disclosure goes:
its weight, contrast and effects are rows now. Rows carry the id landing and
search look for.
2026-11-05 05:13:56 +00:00
esengine 33c66feebd docs(release-notes): connection and account settings pages per board 2026-11-05 05:02:44 +00:00
esengine bf6ff1be7f feat(desktop): the Connection and Account pages are the board's cards of single rows
Set-connect draws network, and other machines (remote, phone access) as one
row each behind a pill; Set-about draws account, version and diagnostics
(version, logs, trace export) and a short About note, with community and
sponsor behind pills. Landing on a setting that is its own page now targets the
page itself, and the section counts leave out blocks drawn inside another page.
2026-11-05 05:02:44 +00:00
esengine af75bc400f docs(release-notes): data settings page per board 2026-11-05 04:55:48 +00:00
esengine 6a733bb779 feat(desktop): the Data settings page is the board's three cards of single rows
Set-data draws memory and usage, session storage and backup with one short
row per setting: memory (with its count), usage, upload volume, storage and the
two backups sit behind a pill, while currency, auto-archive, session location
and 1.x sessions act on the row. The board's upload switch is not drawn: the
kernel has no upload setting, only a report of request sizes, so that row opens
the report. Opening settings at 'memory' still lands on the memory page.
2026-11-05 04:55:48 +00:00
esengine d29b99824e docs(release-notes): agent behaviour settings page per board 2026-11-05 04:44:40 +00:00
esengine 3d0fae17aa feat(desktop): the Agent behaviour page is the board's three cards of single rows
Set-agent draws persona and presets, plans and questions, and collaboration as
one short row per setting. Plan approval, narration and sub-agent write scope
become segmented rows, the question wait and the reference size keep their
value beside the row, and sessions-between-sessions sits behind a pill. What
the old long descriptions said about what a switch releases or costs stays as
the row's hover text. The plan-mode switch goes (the start mode owns it), and
the file-changes, fold-work, receipt and level-notice switches move to the
conversation-display page where the board puts them.
2026-11-05 04:44:39 +00:00
esengine d7d32602c1 docs(release-notes): permissions settings page per board 2026-11-05 04:36:53 +00:00
esengine c4e07cbe0c feat(desktop): the permissions settings page is the board's three cards of short rows
Set-safety draws approval (tool approval, rules, outside text, tools), sandbox
and execution, and web as one row each: the controls that are a value stay on
the row, the ones that are an editor sit behind a pill with their count. The
outside-text switch keeps what it covers and what it does not as the row's hover
text, so the board's one line does not drop it. Message keys the old blocks
alone used are removed.
2026-11-05 04:36:53 +00:00
esengine 2221374763 docs(release-notes): models settings page per board 2026-11-05 04:29:14 +00:00
esengine 0b8c08fd53 feat(desktop): the Models settings page is the board's two cards of four rows
Set-models draws services (model services, health check) and uses (by-use
models, context maintenance) as short rows, each with its scope and when it
takes effect. A block that names an action now draws as a setting row with a
pill that opens the same page; the long content moves behind the pill, so
providers, by-use models and the compaction gauge are pages of their own.
Also: Ctrl+Enter reads as written in the board, and the selected settings
nav item keeps the regular weight the board draws.
2026-11-05 04:29:14 +00:00
esengine 637d1aa4c8 docs(release-notes): full-width wording and tool failure line 2026-11-05 04:13:50 +00:00
esengine 4e09005d01 fix(desktop): the full-width option has its own words again, and a refused tool's failure line says the host's reason
Appearance borrowed feedback.full_width for its full-width option, so removing
that key with the old feedback form left the option reading as a raw key. The
option gets look.full_width in its own namespace.

In the activity group a failed call's line preferred the last line of its
output over the host's error. For a command that tail is its stderr and says
more than "exit status n"; for any other tool the output is not about the
failure, so the host's error wins there. Tests that still expected the drawings
replaced by the boards (exit labels beside the exit summary, the old missing-file
sentence, the ext settings target) now hold what the boards draw.
2026-11-05 04:13:50 +00:00
esengine 945a3de38f docs(release-notes): sidebar rows per the session-row board 2026-11-05 04:09:30 +00:00
esengine 112ea17e8d feat(desktop): sidebar rows follow the session-row board: no project counts, remote state inline, unread dot named
Sess-Rows draws project rows without a session count, folded or open, and a
remote project with its chevron and its state word (connecting, offline) after
the host. The count, its component and its two messages go; what is left to
load is already said by the show-more row beneath the sessions. The connection
line now carries only the detail the word does not (the step, the error).

An unread row shows its time beside the left dot, as the board draws it, so the
dot is the only unread mark and now carries the accessible name. The effort
reading in the composer gets the board's icon. The perf mock gives its sessions
times so the harness shows what the board shows.
2026-11-05 04:09:30 +00:00
esengine 69f88a7dbc docs(release-notes): settings frame 2026-11-05 03:26:34 +00:00
esengine febf34475a fix(desktop): Settings fills the window as the board draws it
Settings opens as a sheet filling the window inside a 24px margin, the
board's frame, rather than a smaller box centred in it; the content
column keeps its 760px measure. The Common page's header adds the board's
line that it is a shortcut and each setting also lives in its category.
2026-11-05 03:26:33 +00:00
esengine ad4098781c docs(release-notes): switch spec 2026-11-05 03:21:32 +00:00
esengine eb386d72ac feat(desktop): switches and button weight as the actions spec draws them
The shared switch is the spec's: a 34 by 20 outlined track with a 12px
knob, grey when off, and when on an accent outline over the accent wash
with the knob in the accent. Buttons from the shared kit set their label
in the medium weight the spec gives every button size.
2026-11-05 03:21:32 +00:00
esengine d8948232ef fix(desktop): keep the usage chart's hues in the design sheet
The cache hit and miss hues moved from the legacy stylesheet, which they
had pushed past its size budget, into the design sheet beside the chart
rules that use them.
2026-11-05 03:19:50 +00:00
esengine 14117d1d33 docs(release-notes): usage page 2026-11-05 03:19:12 +00:00
esengine 2228d88625 feat(desktop): usage as a page of its own, input split by cache, CSV export
The rail, the palette and the meter open Usage as its own page, titled
Usage, holding only the usage blocks: the display currency, usage and
cost, and upload volume. A section drawn as a page of its own now keeps
only the blocks the catalogue files under it, so Extensions likewise
shows no blocks from neighbouring sections.

The daily chart draws the input side as stacked bars, cache hits and
misses in a validated pair of hues with a legend, and its tooltip adds
the total the provider reported, which shares no axis with the split.
The tiles read input with its cache rate, the provider-reported total,
requests with turns, and active days with requests a day; output is not
drawn, since the kernel does not report it apart from the total. Export
CSV writes the range's days, one money column per currency.
2026-11-05 03:19:12 +00:00
esengine c1b11291cf docs(release-notes): approval bar 2026-11-05 03:11:52 +00:00
esengine 8e89cefdd0 feat(desktop): a package's approval drawn as a bar beside its votes
The vote row in a market package's detail draws the approval rate as a
bar, green for the share of positive votes, before the words that state
it. A package with no votes draws no bar.
2026-11-05 03:11:52 +00:00
esengine 3283baaa6d docs(release-notes): market cards 2026-11-05 03:10:53 +00:00
esengine 8473abce76 feat(desktop): the market as a grid of cards with install on the card
Discover lays packages out as cards: a kind icon, the name with a check
when an admin marked it trusted, the publisher and version, two lines of
summary, then the kind, whether it is pinned, installs and approval along
the foot. An installed package says so in the corner; a pinned one not
yet installed offers Install there, which opens the package straight onto
what it would install. A package nobody pinned has no shortcut: trusting
its publisher stays a choice made on its page.
2026-11-05 03:10:52 +00:00
esengine d13c2ce4c2 docs(release-notes): extensions page 2026-11-05 02:54:26 +00:00
esengine 7abb8c1429 feat(desktop): extensions as a page of their own, list beside detail
The rail, the command palette, the meter and the runtime notice now open
Extensions as its own page rather than as a section of Settings: titled
Extensions, without the settings navigation or search, and with the
Installed tab laid out as the list beside a detail pane. Choosing a
package, service or skill in the list draws it whole in the pane, with
its state, description, facts, what it provides, and every action it
offers; the list rows keep only their name line and switch. The first
item is chosen when the page opens. The pane ends with the note on when
a change takes effect.

The page is the Settings surface opened on one destination, so every
extension read, refresh and model update stays one path. Settings opened
on its own keeps Extensions as a section that unfolds rows in place.
2026-11-05 02:54:26 +00:00
esengine 6a39ba41b5 docs(release-notes): usage page 2026-11-05 02:40:23 +00:00
esengine 0355736aaf feat(desktop): usage opens as its own page from the rail and the meter
Usage and cost becomes a page of its own under Data, and the rail's usage
button and the meter's wallet open straight onto it instead of onto the
Data section where it sat under memory. Landing on a block that is its
own page scrolls to the page's top, so the breadcrumb is in view.
2026-11-05 02:40:23 +00:00
esengine efa33a20f4 docs(release-notes): theme page 2026-11-05 02:34:18 +00:00
esengine 288a47b341 feat(desktop): the interface theme opens as a page, naming the one in use
Interface theme becomes a page of its own under Appearance: on the
section it is one row naming the theme in effect, and opening it shows
the palettes, the installed themes and theme import under a breadcrumb.
2026-11-05 02:34:18 +00:00
esengine 65a5f4fd53 docs(release-notes): agent presets page 2026-11-05 02:32:10 +00:00
esengine cfdc832472 feat(desktop): agent presets open as a page of their own
Agent presets, with the preset editor, the system persona and the mode
picker switch that live on it, move onto a page of their own under Agent
behaviour. The catalogue says which settings are drawn on another
block's page, so a search hit or link on the persona or the picker switch
opens the presets page rather than a section that no longer shows them.
2026-11-05 02:32:10 +00:00
esengine c6a5fb039b docs(release-notes): settings pages batch 2 2026-11-05 02:29:44 +00:00
esengine 29c7d74ce8 feat(desktop): automation, schedules, network, storage, export and versions open as pages
The second batch of settings blocks that are lists or editors moves onto
pages of their own: automation rules and scheduled runs under Extensions,
the network under Connections, storage and exporting or importing the
configuration under Data, and versions and updates under Account and
About. Each shows on its section as one row and opens under a breadcrumb.
2026-11-05 02:29:43 +00:00
esengine f5db50570c docs(release-notes): settings pages 2026-11-05 02:26:00 +00:00
esengine 948604dfee feat(desktop): settings blocks that are lists or editors open as pages
The settings catalogue can now say a block is a list or an editor rather
than a value. Such a block shows on its section as one row (its title,
its hint, what it holds now, and a chevron), and opening it gives the
column to that block alone under a breadcrumb back to the section. Its
contents mount only once it is opened. Search and links that land on
such a block open its page; changing section closes it; outside the
settings screen the block draws in place as before.

The first blocks given pages: models by use, the rules you spell out,
the tools the model can use, and messages between sessions.
2026-11-05 02:26:00 +00:00
esengine a867ab1466 docs(release-notes): first-run steps 2026-11-05 02:01:54 +00:00
esengine 7ee8ae0174 feat(desktop): first-run setup as steps: welcome, connect, default model
First launch opens on a welcome that says in one line what Reasonix does
and that setup takes about a minute. Setting up goes through steps shown
at the top, with Not now on each: connect a model service, chosen from
cards that name what each is (a relay or self-hosted entry is the only
one that asks for an address; a listed service says the address it
filled), then pick a default model from the models the key can reach.
Only a typed address that answers both ways asks how it is spoken to;
listed services never do. Steps slide in the direction they go, in the
panel duration, and still under reduced motion. Opening a project stays
the app's own page after setup finishes.
2026-11-05 02:01:54 +00:00
esengine 1cb5919859 docs(release-notes): phone access card 2026-11-05 01:57:25 +00:00
esengine 0dbb8abc1e feat(desktop): phone access card says what a phone can do and how it gets here
The device-access card is titled Use Studio on your phone and says what a
paired phone can do. Phones already connected come first, under one
count, each saying which way it came in. Then How the phone gets here
offers the two routes as their own blocks, same Wi-Fi first with its
address and its own switch, QR code and network picker, then From
anywhere with the account it needs. The routes stay separate: switching
one on never opens the other.
2026-11-05 01:57:25 +00:00
esengine ac07cd4967 docs(release-notes): remote machine cards 2026-11-05 01:54:50 +00:00
esengine 5f937aa60d feat(desktop): remote machines as cards with the next step on them
Each machine is a card: its dot, name, address and state as a tag on one
line, its folder and counts under it. A machine that is not connected
offers Test it right on the card; edit, test, hide from the rail and
remove move into the card's More menu, and the switch stays in view.
Machines found in ~/.ssh/config can be added together with Add them all.

The market install card's close button now names what it dismisses, so
it is not mistaken for the Settings sheet's own Close.
2026-11-05 01:54:50 +00:00
esengine 14e6b7c264 docs(release-notes): skill menu 2026-11-05 01:47:57 +00:00
esengine 8a18aa87e6 feat(desktop): skill actions behind a More menu, update and uninstall as cards
Updating a skill from its source folder and uninstalling it move off the
row into a More menu, so a skill row is its name and marks, description,
where it lives, and its switch. Uninstall is the danger-coloured item.
The update and uninstall confirmations become cards: the question as the
title, warnings as tinted boxes rather than edge bars, and the buttons at
the foot.
2026-11-05 01:47:57 +00:00
esengine 92d544fc78 docs(release-notes): publish form 2026-11-05 01:45:40 +00:00
esengine 4e74324227 feat(desktop): publish form and my publications as the board lays them out
Publishing is a titled card. The type comes first and the source address
straight after it, since what the package is decides what the source may
be; name and version follow side by side, then the rest. Visible only to
me is drawn as a switch in a tinted row, and the footer holds the note
beside the submit button. In My publications each version says where it
stands as a coloured dot and a word: public, in review, not approved,
private.
2026-11-05 01:45:40 +00:00
esengine a4c94f26cd docs(release-notes): market package detail 2026-11-04 18:52:10 +00:00
esengine d66f91ea27 feat(desktop): market package detail and cached-data notice restyled
A market package opens as a card: its name, kind and trust tags on one
line, the summary, the facts in a tinted box, the vote row, and a footer
that holds the note beside Back and the next step. Kind and trust read as
tags in the list too. The unpinned warning and the notice that the list
is from the cache are tinted boxes rather than edge bars, the cached one
with Retry on its own line.
2026-11-04 18:52:10 +00:00
esengine 7416015e68 docs(release-notes): market install confirmation 2026-11-04 18:50:36 +00:00
esengine 954c8ff8e6 feat(desktop): market install confirmation as a titled card
The confirmation every market install passes is a card titled with what
will be installed and a close button. A pinned version opens with the
digest check in the success colour and the digest itself beneath; an
unpinned or own version keeps its warning first. Warnings and the
read-each-of-these group read as tinted boxes instead of coloured bars
down the edge. The kernel's risk grouping is unchanged.
2026-11-04 18:50:36 +00:00
esengine e9fbb51d45 docs(release-notes): skill rows 2026-11-04 18:46:54 +00:00
esengine 263344eae7 feat(desktop): skill rows on two lines, drift beside the name
A skill row is its name (or command) with what it is — drift from its
source, subagent, read-only — on the first line and its description on
the second; where it lives is a tag, then the switch. Drift reads in the
warning colour, and a source folder that is gone in the error colour, so
it stands out of the list rather than waiting in a menu. A failed
service is marked by its red dot alone: the red bar down its left edge
is gone.
2026-11-04 18:46:54 +00:00
esengine 0dca195e5c docs(release-notes): extension rows 2026-11-04 18:42:19 +00:00
esengine e7bcc1e4b4 feat(desktop): extension rows carry a name, one line, and a switch
A package or service row is its status dot, its name in full (never cut),
one line under it saying what it brings and where it came from, and its
switch. Update, export, remove, reconnect and sign-in move into the row's
body, and a row with something wrong opens by itself so the reason and
the way to answer it are in view without a click.
2026-11-04 18:42:19 +00:00
esengine 8a58309a39 docs(release-notes): extensions add menu 2026-11-04 18:37:18 +00:00
esengine 80b9d44fb0 feat(desktop): extensions open with one Add… menu, runtime note at the foot
Everything that puts something into the extensions list sits behind one
Add… button beside the Installed / Discover tabs: install from the market,
from a repository address, import a local folder (which opens the folder
picker straight away), or connect an external service. A way this window
cannot take right now is left out of the menu: no new install while a
package is mid-update, no service while the kernel is not live. The
per-group Add and Connect buttons are gone.

The runtime card no longer leads the page. It sits at the foot of the
installed list as one line saying that enabling, disabling, installing and
updating take effect from the next turn, with Reload runtime beside it for
the one case that still needs it: an extension's own code edited on disk.
2026-11-04 18:37:18 +00:00
esengine ccd4e35459 docs(release-notes): feedback form 2026-11-04 18:22:25 +00:00
esengine 7480ae88fa feat(desktop): the feedback form laid out as the board draws it
Categories are cards that say what each is for; the body keeps its
preview toggle and byte count, with the public-if-filed line beneath it;
screenshots sit in one row with Capture this window and Add images, and
the warning about what a capture carries sits under them. Nickname and
contact keep their row. What is sent along reads as one line that opens
to the full environment and the logs folder; the security policy link
moves to the footer beside Send. The discussions pointer goes, since the
form itself now takes ideas and questions.

The receipt is a check, a short thanks, the receipt number in a box with
an icon copy button, what happens next, and View my feedback or Write
another. Legacy rules for the replaced classes are removed.
2026-11-04 18:22:25 +00:00
esengine 2688439ae2 docs(release-notes): office previews 2026-11-04 18:13:53 +00:00
esengine 3d50eddccc feat(desktop): Excel and Word previews laid out as the board draws them
The read-only note stays at the top beside a read-only tag, with zoom out
and in on both kinds and fit width for Word; a workbook switches sheets
from a tab strip at the bottom that also says when rows or columns were
cut. With Office preview off the preview names the file and offers both
ways out: open it in the system application, or turn the preview on in
Settings, which the workbench now reaches through the pane.
2026-11-04 18:13:53 +00:00
esengine 083e0b3369 docs(release-notes): PDF toolbar 2026-11-04 18:04:08 +00:00
esengine a87cb3ac52 feat(desktop): PDF toolbar and the PDF that will not open
The PDF preview's toolbar is the board's: icon buttons for the previous
and next page around a page box that takes a typed number, held to the
document's page count; zoom out and in step through fixed scales from
whatever fit-width last drew, with the drawn scale between them; fit
width is a toggle. A PDF that cannot be read (damaged, or asking for a
password) gets one strip saying so with Open in system application.
2026-11-04 18:04:08 +00:00
esengine 5d80ed0c50 docs(release-notes): browser login dialog 2026-11-04 17:54:41 +00:00
esengine 8ab2315dd7 feat(desktop): browser login dialog laid out as the board draws it
The sign-in a page or proxy asks for in the built-in browser gets a titled
dialog with a close button, the site's own prompt as it sent it, labelled
fields, the note on where the password goes under a lock, and a footer
holding Cancel and Sign in. A proxy keeps the same dialog under its own
title.
2026-11-04 17:54:40 +00:00
esengine d8ae172f20 docs(release-notes): open-with split button 2026-11-04 17:52:08 +00:00
esengine fbcd7d077d feat(desktop): open-with as a split button with a titled menu
The open-with control remembers the last program chosen and opens the
project in it from its left half; the arrow lists every program. The menu
is titled, draws each program as its initial on a tile coloured by the
kernel's id (the kernel ships no icons), sets the file manager apart under
a divider with the platform's own wording, says when nothing was found,
and ends with the project's path.
2026-11-04 17:52:08 +00:00
esengine 845da7ab45 docs(release-notes): restore the blank line under the English marker 2026-11-04 17:34:28 +00:00
esengine 543c28d7e5 docs(release-notes): conversation selection bar 2026-11-04 17:34:01 +00:00
esengine 6affac79d3 feat(desktop): selection bar in the conversation, above the selection
Selecting text in a finished reply raises the same small bar the editor
has: quote selected text, or copy it. It appears 120ms after the pointer
lets go, never takes focus, and gives way to the context menu, a
collapsed selection or a scroll.

Both bars now share one component and sit above the selection as the
board draws them, dropping below only where the scrolling area leaves no
room above; the editor's bar carries its line range as a trailing note.
2026-11-04 17:34:01 +00:00
esengine 00d05b1069 docs(release-notes): editor selection toolbar 2026-11-04 17:09:12 +00:00
esengine 12aa7764cf feat(desktop): selection toolbar in the editor, text menu in board order
Selecting code in the workbench editor raises a small bar under the
selection 120ms after it settles: add to conversation with the line range
it covers, or copy. It closes on collapse, scroll or the context menu, and
a selection ending at a line's first column does not count that line.

The in-page text menu now leads with add to conversation, then cut, copy,
paste and select all with their platform shortcuts, then undo and redo,
each group separated.
2026-11-04 17:09:05 +00:00
esengine 2942eead2c docs(release-notes): workbench menus 2026-11-04 16:35:13 +00:00
esengine fb1cbc5476 feat(desktop): the file and browser-tab menus follow the WB-Menus board
File menu: a header names the file and its folder ("根目录" at the top);
加入对话 comes first, then 复制引用 with the @reference it copies shown on the
right, then 复制路径; a rule, then reveal and open in the system app. Each
item carries its icon. An unusable reference keeps its item, greyed, with the
reason beneath it. A copy that lands says so in the bottom note capsule
("已复制 @README.md"); one that fails stays in the menu until the next try.

Browser tab menu: a header names the tab ("浏览器标签 · localhost:5173"), and
each close choice shows how many tabs it would close. Accessible names stay
the items' own words, without the counts or references drawn beside them.

The keyboard test for the file menu walks to 复制引用 rather than counting a
fixed number of steps, since the board moves it.
2026-11-04 16:35:12 +00:00
esengine d1fccb0db2 docs(release-notes): workbench state cards 2026-11-04 16:13:00 +00:00
esengine 07c77d946f feat(desktop): the workbench's empty and failed states are cards, as the WB-Parts board draws them
One WorkbenchState card (icon, what it is, why, what to do) now carries:
- a file that is no longer there: "文件已不在原处 · 可能被删除或移动了", with
  重新读取 (reads it again) and 关闭页签;
- a document over the preview limit: "文件太大,无法预览 · 48 MB,预览上限 32 MB",
  with 用系统应用打开 in the card (the toolbar's copy steps aside there);
- a folder without git: "这个文件夹不是 git 仓库 · 改动和提交历史需要 git".
A page that does not load keeps its own BrowserFailure, drawn as the same
card with a globe. The board's 在此初始化… is not offered: no kernel route
initialises a repository.
2026-11-04 16:13:00 +00:00
esengine 86b1663194 docs(release-notes): unsaved close prompt and workbench slide 2026-11-04 16:03:07 +00:00
esengine 29c042439b feat(desktop): closing an unsaved tab asks first, and the workbench slides in and out
Closing a file tab with unsaved text opens a small prompt under it, as the
WB-Frame board asks: 保存 saves that file (any open one, not only the file on
screen: the files hook gains saveAt and dropAt) and then closes it, 不保存
drops the draft and closes, 取消 keeps the tab and its text.

The workbench slides in 24px from the right over 200ms when it is brought
out, never when it is drawn already out; its own close button slides it back
out over 160ms before putting it away. Reduced motion skips both.
2026-11-04 16:03:06 +00:00
esengine e0f087d0cc docs(release-notes): workbench tab states and motion 2026-11-04 15:49:55 +00:00
esengine 3ada521676 feat(desktop): workbench tabs carry their state and move as the WB-Frame board draws them
States: a file with unsaved text shows a dot where its close button is, and
the close button again under the pointer (the files hook now reports which
open files are unsaved); the page the model is working on keeps its blue dot,
now after the name and pulsing every 1.6s; Go, Python and sheet files colour
their icons apart. Tabs keep their width; the ones the strip cannot show fold
into "+n ⌄", a list to pick from that closes on Escape or on leaving it.

Motion: a tab that opens after the strip was drawn grows in over 180ms (one
drawn with the strip does not move); closing narrows a tab to nothing over
140ms before it goes; switching fades the canvas in over 120ms and moves the
tab's colours over the same time. Reduced motion stills all of it.

The tab strip's ref is now owned by useOverflowTabs, so the scroll listener
it installs has a receiver the action census can name.
2026-11-04 15:49:55 +00:00
esengine 0b9f9ba258 docs(release-notes): file toolbar and diff 2026-11-04 15:25:17 +00:00
esengine a7fbbe1797 feat(desktop): the file page's toolbar ends in "…" and its Diff reads as the board draws one
The file bar is path, view toggle, save, then "…", which opens the same file
menu the tree's right-click does (reveal, copy path, copy reference, add to
the conversation) under the button; the separate reveal icon is gone. A
reveal asked for from that menu reports a refusal beside the file, where it
was asked, rather than in the explorer's notice area that may be hidden.

The file page's Diff is drawn by the changes page's renderer, now
UnifiedDiff.tsx: the line before and after numbered, the @@ header kept, the
full width of the page; a click on a number goes to the file view.

The workbench's UI action entries move to actions_workbench.ts, the pattern
actions_feedback.ts set, to keep actions.ts under its line ceiling.
2026-11-04 15:25:17 +00:00
esengine 7196c57600 docs(release-notes): explorer sections 2026-11-04 15:14:08 +00:00
esengine 93b39b7559 feat(desktop): the explorer's three sections fold and read as the WB-States board draws them
改动, 提交历史 and 文件 each head with a fold arrow and the board's title
weight. The 改动 title opens the changes page (when there are changes), its
count reads "4 个文件", and 提交… is the dark button between 暂存全部 and
推送…. 提交历史 is open from the start, names the branch on the right, and
draws each commit as its short hash, subject and age, with the author in the
tooltip; the lane graph is drawn only when the history actually forks. The
file tree gains its 文件 header. Fold buttons are named "收起改动" / "展开文件",
so they do not share a name with the toolbar's Files button.
2026-11-04 15:14:08 +00:00
esengine b83be788f4 docs(release-notes): workbench changes page 2026-11-04 14:12:13 +00:00
esengine a922116e9d feat(desktop): the changes page, as the WB-Frame board draws it
A workbench tab "改动 n" lists every changed file of the workspace's own
repository on one page. Its head names the branch, how many files and
+/− lines, and carries 暂存全部, 提交… and 推送… (the explorer's own commit and
push cards). Each file has a sticky header with its fold, staged checkbox,
git status letter, path and +/−; hovering it offers 打开文件 and 丢弃, which
asks once more before the file goes back to the last commit. An edited
file shows its diff at once with the line before and after numbered and the
@@ header kept; a new or untracked file starts folded behind a line saying
what it is. A diff is read only once its file is open and the page shown.

The composer's "n 个文件已更改" pill now opens this page; the Files press
that used to share its signal has its own (revealFiles), so it still opens
the explorer. The workbench-browser race tests keep their guarantee, that a
browser finishing late does not take the page over, now stated for the
changes page.

The file tree moves out of WorkbenchPanel.tsx into WorkbenchFileTree.tsx to
make room under the file's line ceiling.
2026-11-04 14:12:13 +00:00
esengine 4f0cd15d62 docs(release-notes): workbench tabs and status line 2026-11-04 13:04:45 +00:00
esengine f28fdf47f1 feat(desktop): the workbench's tab strip, new tab and file status line follow the WB-Frame board
Tab strip: the explorer is the fixed first tab and carries the session's
change count; a new tab shows as "+ 新标签" in the strip while it is open;
the strip ends with a button that closes the workbench, which the pane maps
to the same view change the top bar's toggle makes.

New tab: left-aligned "在工作台打开" with three entries, 资源管理器 (the change
count on it), 文件… (opens the explorer with its search focused) and 浏览器,
then 最近打开 as a plain list with each file's folder in mono. The separate
"改动" entry is gone, as on the board; the count rides on the explorer.

File page: a status line under the editor names the file's language, the
line the cursor is on (CodeEditor now reports it) and 未保存 while the draft
differs from the file. It states no encoding: the workspace read carries
none, and naming one would be a claim nothing checked.
2026-11-04 13:04:44 +00:00
esengine 4c043f7b50 docs(release-notes): thought crossfade and step clock 2026-11-04 12:52:07 +00:00
esengine 62b5435558 feat(desktop): the thought's title crossfades, and a running step counts from its own start
When a thought this card watched finishes, the running title "思考中 · 6.2 秒"
fades out over 200ms in the same place as "思考 6.2 秒 · 214 字" fades in,
instead of the new title alone fading up; the old one is gone after 240ms.

A running step in the activity group counts its seconds from the kernel's
startedAt, so a row drawn late (a reload, a pane brought forward) shows how
long the command has really run; a call with no start still counts from its
row.
2026-11-04 12:52:07 +00:00
esengine 0692adbaaf docs(release-notes): activity group motion 2026-11-04 12:49:54 +00:00
esengine f90ab3ccec feat(desktop): the activity group moves and reads as the Think-Exec board draws it
Title: folded while it runs, it says the step under way and how long, with a
spinner and a faint sweep across the row ("执行过程 ⟳ 运行命令 go test ./... ·
12 秒 … 3 项操作"); open, it counts what runs; a failure keeps "1 项失败"; once
done it reads "执行过程 ✓ 4 项操作 · 38 秒", the time measured from the first
call starting to the last ending (else their durations added). The action
count rolls up when it changes under the reader, never on first draw, and
the group folds over 240ms.

Rows: the right side says what a step came to (48 行, 2 处, +12 −3, 退出 1, or
its seconds) instead of a duration for everything. A running step has a
spinner where its tick will be; the tick pops when it finishes under the
reader. A step that fails under the reader shakes once, and its failure sits
in a red box under the row (a host refusal, else the command's last output
line) with "看完整输出", instead of the whole call body opening. A step running
10s with nothing to show says "还没有输出 · 停止这一条". Rows entering together
fade up 4px over 180ms, 30ms apart, at most five apart.

ActivityGroup's title moves to ActivityHead.tsx, which keeps Transcript.tsx
inside its line ceiling. Reduced motion stills every one of these.
2026-11-04 12:49:53 +00:00
esengine a5de9732e4 docs(release-notes): thought folds and caret 2026-11-04 12:30:48 +00:00
esengine ad1a116854 feat(desktop): a thought keeps the reader's fold across reloads, and the answer's caret sits on its last word
A thought the reader opened or closed is remembered per conversation and
drawn that way after a reload. Card ids are minted per load, so a thought is
named by its turn's user message index and its place among that turn's
thoughts; an unsaved conversation remembers for this load only. The pane's
memory keeps one identity per conversation and reads the latest items
lazily, so a streamed token re-renders no card through it.

While an answer streams its caret is drawn after the last word of the last
paragraph or list item rather than on a line of its own, and it blinks once
a second with a hard cut, as Run-Wait specifies.
2026-11-04 12:30:48 +00:00
esengine ad6ab61c10 docs(release-notes): thinking block 2026-11-04 12:25:04 +00:00
esengine 6cf29ac1b7 feat(desktop): the thinking block follows the Think-Exec board
While the model thinks the title reads "思考中 · 3.2 秒" and a light sweeps
across it every 1.6s; nothing else on the card moves. Opened while live, the
reasoning grows to six lines and then scrolls, the newest line at the bottom
with the caret. Once the thought ends the sweep stops and the title fades
(200ms) to "思考 6.2 秒 · 214 字", seconds first with one decimal; a thought
that opened on its own folds again over 240ms, one the reader opened stays.

A turn stopped or failed while the thought was still arriving marks it cut:
the title turns amber, "思考到一半停下了 · 3.1 秒" or "思考到一半出错了 · …",
with a line saying what was thought so far is inside. The title change only
animates when this card watched the thought finish, never on remount.
Reduced motion shows plain grey text and switches without animating.
2026-11-04 12:25:04 +00:00
esengine 2f40d46434 fix(desktop): a landing stops gliding once its transcript has left the document
The 300ms glide added for the message rail kept queuing frames after the
transcript was unmounted, which nest-mount's settling-landing test caught;
it now stops on the same condition the settling pass already checks.
2026-11-04 12:25:03 +00:00
esengine 4d85f9882f docs(release-notes): feedback two columns 2026-11-04 11:57:09 +00:00
esengine 17f5a5621e feat(desktop): My feedback is a list beside one report's page, as the Fb-Mine board draws it
The dialog opens wide on My feedback: the list on the left carries each
report's title, category and day, a waiting question as an orange
"等你回复" first in the order, and where it stands in one word (已收到,
已登记 #n, 处理中, 已在 vX 修复, 下个版本修复, 暂不做, 和 #n 重复); a report
with new replies is bold with a dot. The right is that report's page: title,
chips, receipt with copy and a "…" menu holding withdraw, the timeline across
the top, the whole exchange as bubbles with "你 · 10-30 14:02" above each,
and the reply box always open at the bottom with 附图 and 发送.

Replies are read once their report is the one on the right, so the folded
thread, its toggles and the Reply/Cancel buttons are gone; an unsent reply
is kept per report while moving between them. The header carries the tabs
(写新反馈 / 我的反馈 with the waiting count), the level line "反馈等级 n ·
已采纳 n 条" that opens level, account and leaderboard in a popover, and the
close button; the full-width toggle is gone. The refresh note and the
earlier-identity warning are small print under the list.
2026-11-04 11:57:09 +00:00
esengine 1a06fb1167 docs(release-notes): message rail 2026-11-04 11:32:00 +00:00
esengine c370090b8b feat(desktop): the message rail spreads over the column and shows where you are, as the Msg-Rail board draws it
Marks sit at each message's place in the whole conversation (a mounted
message by its own box, an unmounted one by its block), filling the column
instead of a compact cluster in the middle. A grey band follows the viewport
and the marks inside it draw darker; a message that changed files draws
longer; stars are orange. Hovering grows the pointed line and two neighbours
each side (22/16/12px) and opens a card: "你的第n条 · 改了n个文件", two lines of
the message, and the click/right-click hint. Marks closer than 5px join into
segments of at most 56px; hovering one lists its messages with their numbers
and stars, and a press on bare track lands on the mark at that height.
Unloaded earlier messages show as a count pill at the top with a tooltip.
Ctrl+Up/Down rings the mark it walks to and shows its card. A landing on a
mounted message glides over 300ms and the message flashes. The rail needs
three of your messages, is hidden in the folded narrow layout, and reduced
motion switches without animating.

The test for BackgroundJob imported Tool from the port, which does not
export it; it comes from the wire module.
2026-11-04 11:31:51 +00:00
esengine f4b199cc68 docs(release-notes): background start row 2026-11-04 11:07:07 +00:00
esengine 200db241af feat: a command sent to the background names its job, and its row follows that job, as the Bg-Jobs board draws it
A background start carried its job's id only in the sentence the model reads,
so a window could not tie the call to the job without parsing it. The shell
execution now carries jobId on every layer to the wire. In the execution group
that call reads "放到后台运行", its row shows the job's live state from the
pane's job list — running and for how long, then how long it took and its exit
status — and while it runs a note says the turn goes on without it, with a way
to its output.

The run tabs hook no longer writes its list when a request adds nothing, which
re-rendered without end for a caller passing a fresh setter each time; the job
tab test that did so and hung its worker now keeps one. Execution's comment in
wire.ts states what the type is for rather than how it came to be.
2026-11-04 11:07:07 +00:00
esengine d6a51d576b docs(release-notes): job output tab 2026-11-04 10:58:19 +00:00
esengine bf34fd5d3a feat(desktop): a background job's output opens as a workbench tab, as the Bg-Jobs board draws it
"看输出" no longer covers the conversation with a dialog: the job opens as a
workbench tab, the same way a sub-agent's run does — one list of run tabs, one
open path, one close path. The tab names the job, reads how long it has run or
how it finished ("跑完了,退出 1 · 4 分 02 秒"), stops it while it runs, and
shows the command with what it printed. It follows the newest output until the
reader scrolls up, and an arrow brings it back. The dialog stays the fallback
where the pane has no workbench to open it in.
2026-11-04 10:58:19 +00:00
esengine 9b74480018 fix(desktop): the bench's sample jobs live beside its sample sessions, under the mock's line ceiling 2026-11-04 10:46:21 +00:00
esengine 90638d6ff3 docs(release-notes): background job list 2026-11-04 10:45:08 +00:00
esengine 7d2e9b9457 feat: finished background jobs stay listed with how they ended, and the rail's job list follows the Bg-Jobs board
The status surface carried running jobs only, so a job that ended left no
trace there: the rail could never read "failed", and a person had no way to
see how long a finished build took or how a test run exited. /status now also
carries endedJobs — this session's finished jobs, kept until the session is
purged — and every job view carries the last line it wrote; an ended one adds
when it ended and, for a command, its exit status. The last line is what the
command wrote itself, kept before the finished job's in-memory tail is
released, not the host's note of how it failed. jobs stays the running list,
since that is what its readers take it to be.

The rail's list follows the board: running jobs above ended ones, each with
how long it ran, what it runs and its last line; an ended one with its exit
status; stop asks again before it acts; ended jobs can be cleared from this
window. The reading is a blue pill while one runs, stays red for a failure
until the list has been opened on it, and is grey once all have ended.
Durations pad seconds as the board writes them (4 分 02 秒).
2026-11-04 10:45:08 +00:00
esengine 01681c2083 fix(desktop): text that skipped t() reads as words again, and every test watches for a raw key
Keying the wording tables also keyed a few strings whose readers never pass
them through t(): two thrown errors and the empty-body line in a memory's
detail. Those now translate where they are made. The test setup watches every
rendered text node and readable attribute, and fails a test whose screen shows
a message key, so a call site that skips t() cannot reach a person as
"plan.action.start".

Tests that check the wording tables read them through the keys they now hold.
2026-11-04 10:17:19 +00:00
esengine f712df203d refactor(desktop): the frontend's own wording tables name their messages by key
The tables a call site hands to t() later — the kernel refusal and notice
wording, the settings index, glyph names, run states and the rest — now hold
keys, and their wording joins i18n/msg, the kernel's under kernel.ts. Search
keywords stay as they are: they are matched against what a person types, not
translated. Text the kernel itself sends in Chinese still resolves through
en*.ts, which stays until the kernel names those by code.

Notice wording reads a message's placeholders from its Chinese, not from its
key, and the checks read both tables through the key. Two test files broken
by the earlier test rewrite (overlapping edits in one line, and an import left
unused) are whole again.
2026-11-04 09:57:50 +00:00
esengine 9c0b515b94 fix(desktop): keep the English for Chinese the kernel sends, and key the tests that held it in constants
The key batches dropped every en*.ts entry no frontend source still named.
That cannot tell an unused string from one the kernel sends at runtime — a
preset is named 日常搭档 on the wire and the window translates it on arrival —
so those reached English windows in Chinese. The legacy catalogue is back
whole until each of its readers is accounted for; it still carries the three
settings strings and the two de-duplicated keys.

Tests that kept a Chinese template in a constant now keep its key, and
find-paging no longer mocks t() into echoing its argument.
2026-11-04 09:39:12 +00:00
esengine 08d257b629 test(desktop): tests ask for English by the key that now carries it
Tests that compute the expected text with t("中文") in an English window
looked up an English entry the key batches had removed, and compared the page
against Chinese. They now pass the message's key, as the components do.
2026-11-04 09:31:00 +00:00
esengine 15ca4a7a5c refactor(desktop): the remaining areas name their messages by key, and no t() call passes Chinese
Appearance, onboarding, about, safety, settings, panels and the app shell move
the same way, along with the three calls whose text sat in a nested ternary
and the effort descriptions, which a table now names by key. The table check
counts a key named anywhere in the sources as asked for, since a table handed
to t() is still asking.
2026-11-04 09:12:45 +00:00
esengine 7656b39895 refactor(desktop): the extensions, memory, usage, data, models and persona areas name their messages by key 2026-11-04 09:11:14 +00:00
esengine bd29399c9b refactor(desktop): the workbench, session list, remote and feedback areas name their messages by key
Same move as the conversation areas: each call passes a key, the wording in
both languages lives in i18n/msg/<area>.ts, keys already named in common are
reused, and the English entries nothing names any more leave en*.ts.
2026-11-04 09:10:38 +00:00
esengine e833af629b refactor(desktop): the conversation, composer, plan and subagent areas name their messages by key
Every t()/plural() call in these areas now passes a key ("chat.delivered_files",
"composer.task_input"); its Chinese and English sit together in
i18n/msg/<area>.ts, and a message used by more than one area lives in common.
Keys take their meaning from the English already in the catalogue. The
English entries no source names any more are gone from en*.ts.

The catalogue checks pass whole for the first time on this branch: the three
settings strings that had no English have one, and the two keys defined twice
keep the reading that already won at runtime.
2026-11-04 09:09:08 +00:00
esengine 4ba8900a16 feat(desktop): messages are named by key, with both languages in one table
A message is now t("plan.action.start"), and its Chinese and English sit side
by side in i18n/msg/<area>.ts, so neither can be edited without the other in
view. The plan card and strip, the subagent group, its rail list and its
workbench tab are the first areas moved; the rest still pass the Chinese
source text, which t() keeps resolving until each area moves.

The new tables are held by msg.test.ts: every key is area.meaning and lives in
its area's table, both languages are written with the same placeholders and no
Chinese in the English, every key the interface asks for exists, none goes
unasked, and none is defined twice. The English entries the moved areas no
longer use are gone from en*.ts.
2026-11-04 09:04:23 +00:00
esengine 1bcb35a395 test(desktop): the empty-session brand check reads the design sheet the rule moved to
The redesign moved .hero .sym-mark from app.css into design/shell.css, so the
check found no rule and failed though the brand still does not animate.
2026-11-04 09:01:01 +00:00
esengine 492318c235 docs(release-notes): subagent workbench tab 2026-11-04 08:52:30 +00:00
esengine 2d3b791634 feat(desktop): a subagent's workbench tab follows the Subagent board
The tab carries the delegate's colour avatar. Its head names the delegate in
mono with its model, and while the run is live offers "停止子代理", which
stops the call that spawned it. The run's first user message, the task the
main session handed over, heads the tab in its own box instead of reading as a
line of the run. The foot says the conversation is read-only and goes back to
the delegate in the conversation through the transcript's own landing.
2026-11-04 08:52:30 +00:00
esengine 0214f174e2 docs(release-notes): subagent rail reading 2026-11-04 08:46:34 +00:00
esengine 3fafc4a528 feat(desktop): the run rail's subagent reading and its list follow the Subagent board
While a delegate runs the reading is a blue pill with a breathing dot, "N 个子
代理在跑"; once all have ended it is grey, "N 个子代理 · 都结束了", counting
delegates rather than dispatch calls. Its list is the board's: a title with
running and total, one row per delegate with the same fixed-colour avatar as
the conversation's group, its name, task and state, a row opening its run,
and a stop-all that goes to the port for each running delegate.
2026-11-04 08:46:34 +00:00
esengine 9b89318ef5 docs(release-notes): subagent group card 2026-11-04 08:42:48 +00:00
esengine a0a50379ce feat(desktop): delegates sent out together are one group card, as the Subagent board draws it
Two or more consecutive dispatches in a run of work read as their own card:
"N 个子代理并行" with running, failed and delivered counts and a stop-all,
then one row per delegate with its colour avatar (fixed per name), its name
and task, and where it stands — the step it is on, delivered with steps and
time, or failed with the kernel's reason written out. A running row says what
it is doing now and can be stopped alone; every row opens its run in the
workbench through the pane's existing opener. When all have delivered the card
folds to a summary; a failure keeps it open. A lone delegate stays a row of
the execution group, as the board's fifth case does.

Cause: three parallel delegates folded into "执行过程 · 1 项运行中 · 1 项失败",
which said neither who they were nor which one failed.

Neighbouring behaviours tested: lanes around ordinary calls, a lone delegate,
stop one and stop all through the port, fold after delivery and not after a
failure, opening in the workbench, the reads and plan work around it.
2026-11-04 08:42:48 +00:00
esengine 24a506d553 chore(repolint): lower the budgets the deleted account row freed 2026-11-04 08:32:30 +00:00
esengine 0155e10ca2 docs(release-notes): one row per read 2026-11-04 08:32:08 +00:00
esengine cd1519f4d3 fix(desktop): clear the census and catalogue debt this branch's redesign added
The sidebar foot spelled its files check inside data-action, so the action
census read "files" as an action; the account row lost its last caller when the
account entry moved into settings, and its strings and styles went with it, so
the nav's own account entry names chrome.account where the census can see it;
the memory detail passed its group titles as bare literals. The action, orphan
and literal checks are back to the pre-branch baseline.
2026-11-04 08:32:00 +00:00
esengine 002a3216fe feat(desktop): each read in the execution group is its own row, as the boards draw it
A run of consecutive reads is still folded in state; the execution group now
spreads it back into one row per call, so it reads "读取 scripts/export.py"
row by row like every other call instead of one "读取了 2 个文件" summary.
The group's count was already the number of calls.
2026-11-04 08:32:00 +00:00
esengine 3c175b156d docs(release-notes): plan card and strip 2026-11-04 08:26:02 +00:00
esengine 00601023cc feat(desktop): the plan is one card in the conversation, with the strip above the composer only while it is out of view
The plan card follows the Plan-Card and Chat-Plan boards. Proposed, running,
waiting on a person and finished are the same card changing in place: numbered
steps before it starts, then the kernel's live list with a done mark, a turning
mark on the step in hand, and a badge for where it stands. The work after a
started plan is filed under its card, and the plan's own todo_write rewrites
are left out of that work because the card already draws the list.

The strip above the composer is the card's stand-in: a tab inset 14px with the
step it is on and one segment per step, shown only while the card is off
screen and no decision is waiting. Opened, it is the same tab grown upward,
titled once, with a way back to the card through the transcript's own landing.

Cause: the pending card was one sentence and three buttons, because the plan
request carried no steps, and the live list lived only in the strip, so the
plan was shown twice once it ran. The kernel now sends the starter list the
approval seeds as the plan request's input, from the same parser, and the live
event names its kind as the replayed one already did.

Blast radius: plan approvals on every frontend (the TUI keys its plan answers
on kind, which only the replay carried), the wire form of a plan approval, the
transcript's work layout around a started plan, and the plan-progress
preference, now on unless a person turned it off.

Neighbouring behaviours tested: replayed plan prompt, seed equals the card's
list, unscoped calls still send no input, revise/exit cards, fold-work folding
a card's work, refused todo_write kept, strip collapse/escape/outside press,
goal-only strip, progress preference.

Why this layer: the controller is the one place that parses a plan into steps,
so the card shows what approving starts rather than a second parse.
2026-11-04 08:25:53 +00:00
esengine 91e4854d2f docs(release-notes): feedback, reasoning and plan strip 2026-11-04 07:04:21 +00:00
esengine 627ee68b83 feat(desktop): reasoning and the plan strip follow the Think-Exec and Plan-Card boards
Reasoning heads with a 13px semibold muted line and opens to 13px/1.75 muted text indented 18px, set apart from the answer by 8px. The plan strip is the composer's own top row, as the board's "pinned above the input" state draws it: 36px on the page tone with a hair line under it, the label and count in 13px semibold, the current step muted, and, when plan progress is shown, one 14px segment per step — done in green, current in blue, waiting in the border tone — while the progress bar stays for screen readers.

An activity row whose call has no verb of its own (update plan, save memory, and the like) reads its tool label again instead of "Other".
2026-11-04 07:04:21 +00:00
esengine 16bc3c6919 style(desktop): the feedback center takes the Fb-Mine board's controls
The send/mine tabs are a segmented control; the progress filters are 26px pills whose chosen one takes the accent border; status reads as a 20px filled pill — amber when it waits on you, the brand tint while in progress or answered, green once fixed, neutral otherwise; a report's title is 13px semibold over an 11px faint line; the current step of its timeline takes the brand tint. The sheet sits on the surface tone. The status chips stay on the decorative brand token rather than the net status colour, and the token test follows the new selectors.

The board's list-and-detail split is not part of this change: every reply box and thread is scoped to its own list item today, and moving them out is a behaviour change of its own.
2026-11-04 06:59:11 +00:00
esengine 520885013a style(desktop): the icon tooltip takes the Spec-Overlays padding and a quiet shortcut
Spec-Overlays draws a tooltip as 5px/8px padding with the name in 13px semibold and the shortcut in regular faint ink; the shortcut had inherited the name's weight in the muted ink. The Rail-Foot board, which drew a dark tooltip, now uses the spec's too.
2026-11-04 06:51:25 +00:00
esengine bd18fe8f9d fix(desktop): the selected settings category is semibold, as the NavItem spec draws a selected item
Spec-Lists draws a selected NavItem at 600. The settings boards wrote font-weight: 600 before a font shorthand that reset it to 500, so the left column had been matched to the slip; the boards are corrected on the canvas and the column now follows the spec, like the session rows and the memory rail.
2026-11-04 06:26:31 +00:00
esengine 6e72cc30e8 docs(release-notes): memory page and type settings 2026-11-04 06:21:32 +00:00
esengine 0f3f03aa93 fix(desktop): type in the design sheets follows the text-size and weight settings, and text is smoothed as the canvas draws it
The design-layer sheets wrote sizes as bare pixels and weights as bare numbers, so the text-size and weight settings could not move anything they drew, and the fontscale and spec-literals gates never saw them because they read src/styles only. The gates now read src/design too; sizes ride the six size tokens (board in-between sizes like 11.5 and 12.5 snap to the nearest step), weights ride --w-reg/--w-med/--w-semi, and radii ride the radius tokens.

Text is antialiased everywhere, not only in the macOS desktop shell: a browser on macOS drew the same weights visibly heavier than the design canvas, which is antialiased. The property does nothing on Windows or Linux.
2026-11-04 06:21:24 +00:00
esengine 0c428a31f8 feat(desktop): the memory page follows the Mem-Review board
Confirm and edit are 24px pills in the detail's header beside the title, with archive as an icon button there, so the actions stay in view instead of sitting under the facts. The facts drop their icons and read as two groups of four, content and use. A list row is title, the new badge, and a word only when the memory is fading or forgotten; the clear/fuzzy/faded glyph is gone, and the second line names the type before the galaxy.
2026-11-04 06:21:23 +00:00
esengine aa1fcea7e9 docs(release-notes): workbench and palette 2026-11-04 06:12:20 +00:00
esengine ca167b6c47 feat(desktop): the workbench and the command palette follow their boards
Workbench (WB-Frame, WB-Parts): a raised panel with the float shadow; a 42px tab strip on the page tone with the file-tree tab first, the active tab a raised folder tab of 35px that sits into the panel, others 34px and quiet; a 40px path bar whose segments are faint 12px mono with the file name in semibold text; the read/edit/diff choice as a 24px segmented control; Save as the 32px primary button, outlined while there is nothing to save. The editor sits on the raised surface. Its default width is 520 and the drag starts at 360 with a 900 ordinary limit, as the board's width rule states.

Command palette (Cmd-Palette): 14px corners, a 52px search row at 15px with an Esc key, 11.5px section labels, 36px rows that light with the float highlight, and a 36px footer on the page tone.
2026-11-04 06:12:20 +00:00
esengine b2db4be204 docs(release-notes): file cards and flat empty state 2026-11-04 05:34:38 +00:00
esengine 9937a1cb4a feat(desktop): delivered files are the board's one-column file cards, and the empty conversation is flat
A delivered file is one row in a single column: 16px file icon, 13px semibold name, 12px faint description, an Open pill and a 28px more button, 10px/12px padding in a 10px bordered card; the one open in the workbench takes the accent border. The two-column grid and the 22px badged icons are gone, and the more button keeps its "More actions: name" label.

The empty conversation drops the grid and glow it painted under the hero and sits on the plain surface, as both editor boards draw it.
2026-11-04 05:34:38 +00:00
esengine 3acc6dbaaf docs(release-notes): turn and execution card 2026-11-04 05:31:43 +00:00
esengine 6734594790 feat(desktop): the turn and its execution card follow the Chat-Reply and Think-Exec boards
Your message is a 15px overlay bubble at 14px/1.6 and at most 78% of the column; the reply head is a 20px accent R tile beside the 13px semibold name. The execution process is one card with a hair border and 10px radius: a 30px header carrying the running pill and the step count, then 28px rows of kind glyph, the board's verb (读取, 编辑, 运行命令…), the target in 12px mono, the reading, and the status mark at the right end. The raw tool id stays on the verb's title, where it is still one hover away.

The legacy !important rules on .activity-body, .activity-group and .call[data-call] > .c are deleted so the design layer can draw them; src/design/turn.css holds the replacements.
2026-11-04 05:31:43 +00:00
esengine e8c432a024 docs(release-notes): composer restyle 2026-11-04 05:26:10 +00:00
esengine 04ad0e3c25 feat(desktop): the composer follows the Chat-Composer board
One 15px box with the composer's only shadow; a top row that exists only while a turn runs or files have changed, holding the run pill and the changed-files pill; 14px/1.6 input with 10px/16px padding; a toolbar of 28px borderless pills in the 12px meta size; the model name in 13px semibold text beside its effort; 32px round send and stop, stop drawn as a danger ring. Under 590px of conversation the pickers tighten and under 420px the effort reading folds into the model button, as the narrow-window state shows. The status line keeps 11px faint labels with muted semibold values.

The legacy !important rules on .compose, the pickers and the attach buttons are deleted, since an important declaration in the legacy layer outranks every design-layer rule; the replacements live in src/design/composer.css.
2026-11-04 05:26:10 +00:00
esengine ca59c7cc00 docs(release-notes): tools entry reading 2026-11-04 05:15:56 +00:00
esengine 732d5f94d8 feat(desktop): the sidebar's tools entry names the session's approval mode
The Rail-Foot board labels each icon with its current reading; usage and remote already did, and the tools entry now reads "Tools & permissions · Ask" from the focused session's approval mode, through the same approvals table the composer uses.
2026-11-04 05:15:56 +00:00
esengine 4464970e1d docs(release-notes): window bar and sidebar foot 2026-11-04 05:14:37 +00:00
esengine fe55132d55 feat(desktop): the window bar, empty state and sidebar foot follow the editor board
The window bar is 48 high on the surface with a hair line under it; the breadcrumb is 13px and the four icon buttons are 28px, ordered find, device access, theme, workbench. The sidebar foot is the Rail-Foot board's single toolbar row: usage, files, tools, extensions, memory and remote as 28px icons whose names carry their reading, settings at the right end, and feedback as its own 30px row with the unread count. The account entry leaves the rail for settings, as the board says. The empty state's mark sits in a 44px accent tile above a 22px title.

The legacy .chrome, .railfoot, quick-nav, wallet, user-foot and hero rules are deleted rather than overridden; the replacements live in src/design/shell.css in the design layer.
2026-11-04 05:14:26 +00:00
esengine f4f32cc7d3 docs(release-notes): send-now button 2026-11-03 20:07:47 -08:00
esengine ff34193a55 feat(desktop): the send-now button on a queued line is the board's 24px pill
The legacy qnow rules are removed and the button takes its look from src/design/queue.css, with a test pinning its height, border, radius and surface.
2026-11-03 20:07:43 -08:00
esengine 94424bda54 docs(release-notes): shared sidebar row styles and bundled Inter 2026-11-03 19:59:59 -08:00
esengine 9c4393d30d refactor(desktop): project rows take their look from the shared rows stylesheet
Local and remote project rows carry the ds-proj class and the legacy .wsrow, .wsname, .wsacts, .rowswap, .wsadd and .wsmore rules are deleted. Rows are 30px with a chevron, folder, name and hover actions as in the board.
2026-11-03 19:58:11 -08:00
esengine b4c548b1a8 fix(desktop): serve the bundled Inter first and declare its weights as numbers
The --ui stack named a system Inter that is rarely installed, so Latin text fell through to the platform CJK font. The bundled Inter faces also declared font-weight through var(), which is invalid inside @font-face, so every weight resolved to the last face (SemiBold). The faces now declare 400, 500 and 600, and the stack starts with the bundled family.
2026-11-03 19:44:29 -08:00
esengine 8e203505d9 refactor(desktop): session rows take their look from one stylesheet shared with the design boards
Session and project row styling now lives in src/design/rows.css, the same file the design boards embed, and the legacy .sessrow, .sesstitle, .sessword and quick-action rules are deleted rather than overridden. Hover swaps the time for a more button and a pin sits right of the title, as in the board. Also repairs three tests left red by the earlier filter dropdown change.
2026-11-03 19:32:26 -08:00
esengine 5c97da241b refactor(styles): put legacy sheets in a cascade layer and design styles above it
Every stylesheet the app imports now sits in the legacy layer, and the measured settings and sidebar rules live in a design layer that wins by layer order instead of selector weight, so they no longer need :root[data-theme] prefixes. Rail pixels are identical before and after.
2026-11-03 19:18:25 -08:00
esengine 10709e6642 docs(release-notes): sidebar head height 2026-11-03 19:04:25 -08:00
esengine 1462713862 style(desktop): the sidebar head is 32 high with 12 above it and project groups sit flush, as designed; macOS keeps the title-bar height 2026-11-03 19:04:25 -08:00
esengine 8f7690f021 docs(release-notes): legacy tag removed 2026-11-03 19:01:33 -08:00
esengine 8b8ca36057 feat(desktop): session rows no longer carry the legacy tag, as the design allows one tag at most 2026-11-03 19:01:33 -08:00
esengine e7f13057ae docs(release-notes): session versions in menu 2026-11-03 19:00:05 -08:00
esengine d23cfeb216 feat(desktop): the earlier-versions chip is gone from the row; it is a menu entry that unfolds them, as designed 2026-11-03 19:00:05 -08:00
esengine 8bf1c73193 docs(release-notes): session rows designed states 2026-11-03 18:54:06 -08:00
esengine 962da251ec feat(desktop): session rows follow the design: left status dot, status word, date in the designed words, no indent, calm states 2026-11-03 18:54:06 -08:00
esengine cdd3d9add2 docs(release-notes): sidebar fold-all button 2026-11-03 18:48:07 -08:00
esengine b0a0adc2fe feat(desktop): collapse all projects is the icon button on the tab row, as drawn in the design 2026-11-03 18:48:07 -08:00
esengine 39d90df113 docs(release-notes): sidebar header menu moved 2026-11-03 18:46:04 -08:00
esengine d10bdfaad3 feat(desktop): batch select lives in the project row menu as designed; the project header has no more menu 2026-11-03 18:46:04 -08:00
esengine 1dcf6af8d3 docs(release-notes): sidebar tab row 2026-11-03 18:39:51 -08:00
esengine 032ae8f084 feat(desktop): the project header is the designed tab row: Projects/Recent segment, filter and add on one line 2026-11-03 18:39:51 -08:00
esengine 43598a1dbc docs(release-notes): sidebar brand head 2026-11-03 18:34:50 -08:00
esengine 3197b4c563 feat(desktop): the sidebar head is the designed one: logo and STUDIO badge aligned to the buttons, a collapse button, 264 default width 2026-11-03 18:34:50 -08:00
esengine e05823be3e docs(release-notes): sidebar head designed 2026-11-03 18:26:07 -08:00
esengine 594a2bd740 feat(desktop): the sidebar head follows the design: bordered new-session and search with their shortcuts, entries moved to the foot 2026-11-03 18:26:06 -08:00
esengine c338eb9a5b docs(release-notes): session rows designed size 2026-11-03 18:15:19 -08:00
esengine a51e003255 feat(desktop): session and project rows take the designed height and size, and local projects get a fold chevron 2026-11-03 18:15:19 -08:00
esengine 21ebb4770d docs(release-notes): host unreachable names the firewall 2026-11-03 17:56:20 -08:00
esengine 92a7ae08e2 fix(desktop): the shell checks the host is reachable before loading it and names the firewall when it is not 2026-11-03 17:56:20 -08:00
esengine 23102a7e8b docs(release-notes): designed font stack 2026-11-03 17:51:10 -08:00
esengine 46dd272fc4 feat(desktop): the interface font stack is the designed one: Inter, system CJK fonts, then the bundled faces 2026-11-03 17:51:10 -08:00
YHH bfdc7e2a1d Merge pull request #14355 from kevin9327/fix/gitignore-worktree-info-exclude
fix(gitignore): read info/exclude from the common dir when .git is a file
2026-11-03 17:45:27 -08:00
esengine 5f874dc1d4 docs(release-notes): settings left column icons and font 2026-11-03 17:41:42 -08:00
esengine e2c1587e77 feat(desktop): settings left column takes the designed icons, 240 width, row type and the designed font stack 2026-11-03 17:41:42 -08:00
esengine 9d18948916 chore(repolint): a release-note item is no longer held to a column limit, only to naming its change 2026-11-03 17:27:21 -08:00
esengine a1b4dd382b docs(release-notes): shorten two settings notes 2026-11-03 17:21:34 -08:00
esengine 2e57b9bf90 docs(release-notes): settings row vocabulary 2026-11-03 17:19:10 -08:00
esengine a5d5e76531 feat(desktop): the Common page rows use the designed row vocabulary: segmented choices, dropdown, switch, one-line notes 2026-11-03 17:19:10 -08:00
esengine fb7ef5b50b docs(release-notes): settings measured style 2026-11-03 17:16:00 -08:00
esengine 8ee94c1039 feat(desktop): settings shell, cards and rows take the measured values from the design 2026-11-03 17:16:00 -08:00
esengine 24e5559fca docs(release-notes): settings common cards 2026-11-03 17:11:19 -08:00
esengine 765ac8aa23 feat(desktop): the Common settings page is two cards of one-line rows, with language as a dropdown, as designed 2026-11-03 17:11:19 -08:00
esengine 960d437962 docs(release-notes): settings title line and agent cards 2026-11-03 17:09:44 -08:00
esengine 7574157761 feat(desktop): the page title reads on one line, and Agent behaviour groups plans, questions and collaboration into cards 2026-11-03 17:09:44 -08:00
esengine d3cd11eecd docs(release-notes): settings rows and data cards 2026-11-03 17:06:52 -08:00
esengine 7dd377fe47 feat(desktop): settings rows read as one line with the control on the right, and Data is three section cards 2026-11-03 17:06:52 -08:00
esengine 3323381609 docs(release-notes): settings left column 2026-11-03 17:04:35 -08:00
esengine 43e78eda03 feat(desktop): settings puts the title and search in the left column and shows an item count on each category, as designed 2026-11-03 17:04:35 -08:00
esengine 5dc2f28c9e docs(release-notes): settings section cards 2026-11-03 17:01:25 -08:00
esengine ff391889b4 feat(desktop): settings sections are one card with a row per item, the control on the right, as designed 2026-11-03 17:01:25 -08:00
esengine a8af9ee097 docs(release-notes): permissions sub-headings 2026-11-03 16:57:16 -08:00
esengine 84ebeedc06 feat(desktop): the permissions page reads as one page with sub-headings; plan approval moves to Agent behaviour and file cards to Conversation display 2026-11-03 16:57:16 -08:00
esengine 10449cbd87 docs(release-notes): settings items moved 2026-11-03 16:56:18 -08:00
esengine eae50a7a58 feat(desktop): settings moves trace export, backups, session location, auto-archive and 1.x sessions to the pages the design files them under 2026-11-03 16:56:18 -08:00
esengine e0eba2e4a7 docs(release-notes): settings eleven pages 2026-11-03 16:55:27 -08:00
esengine d3b6daef14 feat(desktop): settings splits appearance into Appearance, Conversation display and Controls and performance 2026-11-03 16:55:27 -08:00
esengine f832f84a39 docs(release-notes): settings nav order 2026-11-03 16:53:37 -08:00
esengine 0767a73bd3 feat(desktop): settings nav follows the design order flat, and each page says how many items it holds and what it is for 2026-11-03 16:53:37 -08:00
esengine da62677471 chore(desktop): English names for the Common settings page 2026-11-03 16:45:37 -08:00
esengine 522f7d1f01 docs(release-notes): settings common page 2026-11-03 16:42:44 -08:00
esengine 862f8865c4 feat(desktop): settings opens on a Common page holding the six most-changed items 2026-11-03 16:42:44 -08:00
esengine 86dcc1b9b8 docs(release-notes): sidebar remote online 2026-11-03 16:40:52 -08:00
esengine 0de5fe17c4 feat(desktop): the sidebar's remote entry says how many machines are online 2026-11-03 16:40:52 -08:00
esengine 068dfffaa1 docs(release-notes): icon rail off by default 2026-11-03 16:40:09 -08:00
esengine e461d48992 feat(desktop): the icon column is off unless chosen; the sidebar carries its entries 2026-11-03 16:40:09 -08:00
esengine 52c86b5e36 docs(release-notes): workbench recent files 2026-11-03 16:31:49 -08:00
esengine c5483809c6 feat(desktop): the workbench start page lists recently opened files 2026-11-03 16:31:49 -08:00
esengine 34cad82075 docs(release-notes): settings search path 2026-11-03 16:31:12 -08:00
esengine 8def264b95 feat(desktop): settings search shows page › sub-section and finds a folded page by its old name 2026-11-03 16:31:12 -08:00
esengine 9c89690c25 docs(release-notes): settings automation in extensions 2026-11-03 16:30:38 -08:00
esengine a2289aa88a feat(desktop): settings folds automation into the Extensions page 2026-11-03 16:30:38 -08:00
esengine 47a4010016 docs(release-notes): settings agent behaviour page 2026-11-03 16:30:21 -08:00
esengine d0b1bc8fd2 feat(desktop): settings folds personas and peer messages into the Agent behaviour page 2026-11-03 16:30:21 -08:00
esengine 3483500174 docs(release-notes): settings data and account pages 2026-11-03 16:29:55 -08:00
esengine 111debd2b7 feat(desktop): settings folds memory, usage and storage into Data, and account and version into one page 2026-11-03 16:29:48 -08:00
esengine bfb4edc753 docs(release-notes): settings connections page 2026-11-03 16:29:01 -08:00
esengine 10d87b83a3 feat(desktop): settings folds remote machines and phone access into the Connections page 2026-11-03 16:29:01 -08:00
esengine 628a5acaa8 docs(release-notes): settings model page 2026-11-03 16:28:22 -08:00
esengine 80353df7ef feat(desktop): settings folds the model-service page into one Model page 2026-11-03 16:28:22 -08:00
esengine 810d6c33d8 docs(release-notes): name the commit behind each interface change 2026-11-03 16:24:21 -08:00
esengine ed18015c1d feat(desktop): single-line question options are a normal row tall 2026-11-03 16:23:43 -08:00
esengine 9d9926e48d refactor(desktop): drop shadows read one shade variable 2026-11-03 16:23:43 -08:00
esengine df2ae96613 feat(desktop): workbench file icons are tinted by file kind 2026-11-03 16:23:43 -08:00
esengine 498d2af00b feat(desktop): background jobs can be stopped all at once after one confirmation 2026-11-03 16:23:43 -08:00
esengine 751974427d feat(desktop): the background-job reading says running, failed or ended 2026-11-03 16:23:43 -08:00
esengine 2b6405327d feat(desktop): the palette opens with a recently-used group 2026-11-03 16:23:43 -08:00
esengine aebed7fb56 feat(desktop): session search highlights the matching part of the title 2026-11-03 16:23:43 -08:00
esengine cbaaeb98f8 feat(desktop): a pending permission card is outlined, its button row loses the divider and grey strip 2026-11-03 16:23:43 -08:00
esengine 6eda696596 feat(desktop): folded thought reads amount first, then whole seconds 2026-11-03 16:23:43 -08:00
esengine f2b44860c4 feat(desktop): a failed sub-agent reads Failed with its reason, not Interrupted 2026-11-03 16:23:43 -08:00
esengine ce6c6d144f feat(desktop): sub-agent footer reading says running count, then the total once finished 2026-11-03 16:23:43 -08:00
esengine 14d1d36915 test(studio): the recent-sessions flow reads the filter dropdown
The test reached for the old segmented container and its pressed buttons. It
opens the dropdown and reads the button's label instead.
2026-11-03 16:23:43 -08:00
esengine 8b1218dd48 docs(release-notes): session filter dropdown 2026-11-03 16:23:43 -08:00
esengine b4386d746f feat(studio): the session filter is a dropdown with counts and two new filters
Four tiny segmented buttons sat in the rail, with no room for a count and no
way to ask for what was waiting on the person or what was unread.

One button now reads the current filter and opens a list of All, Running,
Needs you, Unread, Pinned and Archived, each with its count where it is known
(Archived is paged, so it shows none). Anything but All shows a clear mark
beside the button. Needs you lists conversations whose turn is waiting on an
answer; Unread lists the ones with a reply not yet seen. The rows keep the
data-action and value the old buttons had.

Blast radius: Sidebar, a new SessionFilter, the scope rule, Workspaces' call
into it, the rail's stylesheet, and two older tests that now open the menu.

Why this layer: the scope rule owns what each filter lists.
2026-11-03 16:23:43 -08:00
esengine f066c3ad83 docs(release-notes): settings page title 2026-11-03 16:23:43 -08:00
esengine 66cf4d8970 feat(studio): each settings page opens with its group and name
Settings pages started straight at the first card, so after a scroll or a
search jump nothing in the page said which page it was; the left list was the
only landmark. A request in the settings-redesign thread asked for a compact
page title or breadcrumb.

Each page now opens with a one-line title: the group it belongs to in the
light step, then the page name as the single h2.

Blast radius: SettingsTitle in SettingsNav, one line in Settings, four rules.

Why this layer: the nav already owns the group and page names.
2026-11-03 16:23:43 -08:00
esengine c78941743c docs(release-notes): pending card tag 2026-11-03 16:23:43 -08:00
esengine c1c3419343 feat(studio): every card waiting on the person says so with the same tag
Only the permission card said it was waiting ("Waiting"); a question or an
extension's request sat in the transcript looking like anything else, and the
tags counted questions instead.

The permission, question and extension-request cards now carry one tag,
"Needs you", as an outlined accent pill, which turns to "Handled" once
answered.

Blast radius: three cards, one string, one rule.

Why this layer: each card names its own state; one style draws them alike.
2026-11-03 16:23:43 -08:00
esengine c6fb0eb95b test: skip symlink fixtures where the platform cannot build symlinks
Cause: ten fixtures in serve, ext/plugin and platform/repair built a symlink
with os.Symlink and failed the test on error. A non-elevated Windows run
without Developer Mode returns ERROR_PRIVILEGE_NOT_HELD there, so ten tests
failed and the whole serve suite went red, though CI's elevated runners never
saw it.

Fix: skip those fixtures with the underlying error as the reason, the pattern
the boot package already uses; the refusals they pin down still run wherever
symlinks can be built.

Blast radius: ten test sites in five files, no production code.

Neighbouring behaviours tested: the same tests run unchanged where symlinks
work (ext/plugin, platform/repair, serve), and the packages vet for windows.

Why this layer: the fixture owns whether it can build its own precondition.

Fixes #14969
2026-11-03 15:26:16 -08:00
esengine f941eec2e1 docs(release-notes): session batch bar 2026-11-03 08:55:41 -08:00
esengine 5b51c01280 feat(studio): batch selection is a bar fixed to the foot of the session list
Selecting sessions opened a small bordered box in the middle of the list, its
buttons 5px-padded to no standard height.

It is now a bar fixed to the bottom of the list, on the float surface with its
shadow and a top edge, entering with the panel timing, and its buttons are the
small 24px pills.

Blast radius: the session-batch stylesheet.

Why this layer: these rules are the bar's only definition.
2026-11-03 08:55:41 -08:00
esengine ec7a7e29ee docs(release-notes): body 14px 2026-11-03 08:55:22 -08:00
esengine c84e1682e7 feat(studio): the conversation body reads at 14px by default
The spec sets the reply body at 14px. The reading dial defaulted to 13px in
Chinese and 12.5px in English, and its steps were built around that, and a few
execution-row sizes were multiples of it that landed between the six steps.

The standard step is now 14px in both languages (small 12; large and larger
16.5 and 19 in Chinese, 16 and 18 in English, chosen clear of the old retired
values the saved setting still migrates). The small multiples in the execution
rows name the meta and micro steps instead.

Blast radius: look.ts steps and default, the first-frame fallback, four rules.

Why this layer: look.ts owns the reading dial; the stylesheets only read it.
2026-11-03 08:55:22 -08:00
esengine be3b0cac19 docs(release-notes): weights three steps 2026-11-03 08:53:36 -08:00
esengine 5f78e0b21e feat(studio): every font weight comes from the three weight tokens
The stylesheets wrote about 230 weights as bare numbers (300, 400, 450, 500,
550, 600, 620, 650), so text carried weights the spec does not have and the
weight setting could not move them together.

Each now names the nearest of --w-reg, --w-med and --w-semi (up to 550,
up to 650, above), and a test keeps it so. Where the settings page re-points
--w-med to --w-semi the same rule holds.

Blast radius: 12 stylesheets and the theme test that pinned two old spellings;
450 and 550 text moves to the regular and medium steps.

Why this layer: the tokens own the weights; rules only name them.
2026-11-03 08:53:36 -08:00
esengine cd01aefd1d docs(release-notes): type sizes six steps 2026-11-03 08:52:54 -08:00
esengine f360221b06 feat(studio): every text size comes from the six size tokens
The spec has six type sizes, 11 / 12 / 13 / 14 / 17 / 22. The stylesheets
still wrote about 300 sizes as a bare calc of pixels, among them 117 at 9 and
10px and some at 7.5, 10.5 and 11.5, so text sat between the steps and a size
setting could not keep them in proportion.

Each now names the nearest token (9 to 11 up to micro, 11.5 to 12.5 meta, 13
ui, 14 and 15 lead, 17 to 19 title, 20 to 23 display); 16px on coarse
pointers and the display figures of 25px and up are left. A test keeps it so.

Blast radius: 17 stylesheets; small text grows by one or two pixels.

Why this layer: the tokens own the sizes; rules only name them.
2026-11-03 08:52:54 -08:00
esengine c3e3979414 docs(release-notes): jobs time and stop 2026-11-03 08:48:25 -08:00
esengine 11169ceb3b feat(studio): background tasks read their time in minutes and stop on a second press
The task list wrote "741s" and stopped a task on one press of a small square.
It now reads "12 min 21 s", and the stop control turns into "Stop? Confirm"
for three seconds, so a stray press cannot end a running task.

Blast radius: Jobs, a duration helper with its own test, one action id, the
strings and one rule; the deck-jobs test presses twice.

Why this layer: Jobs owns how a task row reads and stops.
2026-11-03 08:48:25 -08:00
esengine 5834759fb1 docs(release-notes): small buttons 24 2026-11-03 08:47:07 -08:00
esengine c3563fc225 feat(studio): small buttons are 24px: stage all, commit, the changed pill, model filter chips
An audit of the live interface found small actions at 20, 23 and 26px where
the spec has one small size of 24. Stage all and Commit in the explorer
header (20), the changed-files pill (26) and the model filter chips (23) now
draw at 24, with the spec's meta size for their text.

Blast radius: four stylesheet rules.

Why this layer: each rule was the control's own size.
2026-11-03 08:47:02 -08:00
esengine 0049f6d880 docs(release-notes): palette frame 2026-11-03 08:45:58 -08:00
esengine 0a8829fbb8 feat(studio): the palette opens 600 wide, 12% from the top, over a 16% dim
The palette was 560 wide at 14% over a blurred wash, drawn with the card
timing. It now follows the board: 600 wide, 12% from the top, a plain 16% dim
behind it, the large corner, and the panel timing on entry.

Blast radius: the two palette rules in studio.css.

Why this layer: these rules are the palette's only frame.
2026-11-03 08:45:58 -08:00
esengine 93aa8daf61 docs(release-notes): palette scopes 2026-11-03 08:45:44 -08:00
esengine f46e8a274b feat(studio): the palette narrows by scope, ranks a best match and finds settings
The palette searched quick actions and eight conversations only, always
mixed, with no way to look at one kind.

A leading > # or , now narrows to actions, conversations or settings (Tab
cycles, Backspace on an empty field drops it, and the scope shows as a tag in
the field). A search puts the single best match alone first, then conversations,
settings and actions, five of each at most. Settings come from the existing
settings index and open at their block. The footer prints the scope keys.

Not here: file search (there is no file index to ask) and the search-all-
conversations fallback, which needs the sidebar's content search wired in.

Blast radius: Palette, the Sidebar that hosts it, three strings and one rule.

Why this layer: Palette owns how results are grouped; the sidebar only hands
it the settings index.
2026-11-03 08:45:44 -08:00
esengine e3bfdf96c4 docs(release-notes): rail edits longer 2026-11-03 08:43:33 -08:00
esengine b14f1ea829 feat(studio): a message that changed files draws a longer line on the rail
Every line on the message rail was the same length, so a message that changed
files looked like any other. Those lines are now longer.

Blast radius: one attribute in Rail and one rule in app.css.

Why this layer: Rail knows each mark's file count; the stylesheet draws it.
2026-11-03 08:43:33 -08:00
esengine d1cc623de8 docs(release-notes): rail card words 2026-11-03 08:43:15 -08:00
esengine d7e6e0129b feat(studio): the message rail's card reads "Your 6th · changed 3 files" with a hint
The hover card spaced numbers from their words ("your 6th message") and gave
no hint what a click or a right-click does.

The header now reads without those spaces, names how many files the message
changed beside its number, and a line under the text says click jumps and
right-click stars.

Blast radius: the card in Rail, one rule, four strings.

Why this layer: Rail owns its card.
2026-11-03 08:43:15 -08:00
esengine 87550bbda1 refactor(studio): the row's right-hand state or time is its own component
Workspaces.tsx reached 802 lines, over the 800-line ceiling. The choice
between a state word and the time moves next to SessionAge, which already
owns the time.
2026-11-03 08:40:47 -08:00
esengine ff09e4edc2 docs(release-notes): browser blank page 2026-11-03 08:38:38 -08:00
esengine 0b21cafee4 feat(studio): the browser's blank page speaks Chinese and says what the blue dot means
The blank page was titled in English and did not say how to tell the model is
operating a page.

It is now titled "Built-in browser", says to enter an address and that a blue
dot on the tab shows the model at work, and that a site that refuses
embedding opens from the toolbar.

Blast radius: the blank-page markup in BrowserPanel and its English strings.

Why this layer: BrowserPanel owns its own blank page.
2026-11-03 08:38:38 -08:00
esengine 14804c7c7c docs(release-notes): launcher descriptions 2026-11-03 08:38:18 -08:00
esengine dddcf2b3d5 feat(studio): the workbench's new tab says what each entry opens
The new tab was three bare buttons, so a first-time visitor had to guess what
"Files" or "Changes" would open.

It now has a heading and a line under each entry saying what it opens. The
buttons keep their names and actions.

Blast radius: WorkbenchLauncher, its stylesheet and four strings.

Why this layer: the launcher owns its own words.
2026-11-03 08:38:18 -08:00
esengine a6955a49fa docs(release-notes): activity earlier fold 2026-11-03 08:37:30 -08:00
esengine 42e8d2e5ae feat(studio): a long run of work folds its earlier steps into one line
A run with eighteen steps listed all eighteen, so the few that mattered were
buried. Past six, the earlier steps now fold into one line that says how many
and of what kind ("14 earlier steps: read 9 · search 3 · edit 2"); the last six
stay listed and the line opens the rest. A failed or still-running step is
never folded, however old.

Blast radius: the activity group in Transcript, a new pure helper, one action
id, strings and one rule.

Why this layer: the group owns what it lists; the helper only decides the cut.
2026-11-03 08:37:30 -08:00
esengine e79f2b8168 docs(release-notes): workbench folder tabs 2026-11-03 08:33:58 -08:00
esengine 71212621b4 feat(studio): workbench tabs are folder tabs joined to their content
Tabs were small outlined pills floating in a strip, so the current one was
hard to tell and nothing tied it to the page below.

All tabs now share one height with top-rounded corners; the current tab takes
the content's colour and edge so it joins the page, and reads heavier. The
strip sits on the page colour so the joined tab shows.

Blast radius: the workbench tab rules in studio.css.

Why this layer: these rules are the tabs' only definition.
2026-11-03 08:33:58 -08:00
esengine df24cd4ece docs(release-notes): recent sessions keys 2026-11-03 08:32:21 -08:00
esengine c97d7b6afa feat(studio): recent sessions show what is running and can be walked by keyboard
The list said nothing about which conversation was running and had to be
tabbed through row by row. A running or waiting row now reads "Running" or
"Needs you" where its time sat, Up and Down walk the rows (and back to the
search field), and a line under the list says so.

Blast radius: RecentSessions, the Sidebar prop that feeds it, two strings and
one rule.

Why this layer: the dialog owns its rows; the sidebar already holds the runs.
2026-11-03 08:32:20 -08:00
esengine a30f26dafa docs(release-notes): changed pill right 2026-11-03 08:31:39 -08:00
esengine 1a649b2ffa feat(studio): the changed-files pill sits at the right of the input box's top row
The pill had a row of its own, centred under the run badge. The board puts it
on the badge's row, at the right edge.

Blast radius: one rule in changedpill.css.

Why this layer: the row is the pill's own container.
2026-11-03 08:31:39 -08:00
esengine 5326f02ccb docs(release-notes): connect note 2026-11-03 08:30:40 -08:00
esengine 97c3ebbbf2 feat(studio): the not-connected note says nothing can be sent, with a warning icon
The note above the input read "No model service connected yet" with a bare
link, which did not say what it meant for what the person had typed.

It now leads with a warning icon and says "no model service is connected, so
nothing can be sent" (or the same for a missing API key). The typed text is
kept as before.

Blast radius: ConnectNote, one rule in app.css, two strings, one test.

Why this layer: ConnectNote owns this note's words.
2026-11-03 08:30:40 -08:00
esengine 6b3f2cab88 docs(release-notes): weights and input size 2026-11-03 08:30:08 -08:00
esengine 7f7f64e7fb feat(studio): weights are 500/600/700 and the input box text is 14px
The design spec sets three weights, 500 / 600 / 700, and the input box at the
lead size. English held 400 / 500 / 600 and the box followed the transcript
reading dial, so the two disagreed with the board.

The weight tokens now carry the spec on every language (light still steps
down, heavy steps up), and the input box draws its text at --fs-lead on
every pointer. The reading dial keeps the transcript.

Blast radius: tokens.css weights, two input-box rules, TOKENS.md, one theme test.

Why this layer: the tokens own the weights and the size.
2026-11-03 08:30:08 -08:00
esengine 6af297b927 docs(release-notes): session row word 2026-11-03 08:29:04 -08:00
esengine a0ce00b5f7 feat(studio): a conversation row names its state in place of the time
The row's dot said a conversation was running or waiting, but only colour
told which. A running row now reads "Running" and a waiting one "Needs you"
where the time sat; every other row keeps its time or unread mark.

Blast radius: the row in Workspaces, two strings and three rules in treerows.css.

Why this layer: the row owns what sits on its right edge.
2026-11-03 08:29:04 -08:00
esengine 5cbedc4916 docs(release-notes): meter icons 2026-11-03 08:25:34 -08:00
esengine 2db788599f feat(studio): the status readings lead with an icon and set value against label
The bottom readings were bare words and numbers. Cache and the session cost
now lead with a 12px icon, the number reads in the text colour and its label
in the light one, and the warm context level keeps its number warm.

Blast radius: the MeterRail markup for two readings, two icons, studio.css.

Why this layer: MeterRail owns the readings; the stylesheet names the colours.
2026-11-03 08:25:34 -08:00
esengine 5050eef812 docs(release-notes): context reading warm 2026-11-03 08:21:56 -08:00
esengine 20903c3470 feat(studio): the context reading turns warm near the limit
The context ring and percentage looked the same at 19% and at 90%, so the
limit arrived without warning. From 80% the reading and its ring now turn the
warning colour; nothing opens or interrupts.

Blast radius: the context button in MeterRail and two studio.css rules.

Why this layer: MeterRail owns the reading and states the level; the
stylesheet only names the colour.
2026-11-03 08:21:56 -08:00
esengine 782ad209bd docs(release-notes): composer running state 2026-11-03 08:19:34 -08:00
esengine efdbf9788c feat(studio): the input box shows running, steering and the model's vendor as drawn
While a turn runs the stop control was a dark labelled pill, the send control
appeared only once something was typed, the placeholder kept saying "describe
a task", and the model button carried no mark of who serves it.

Stop is now a 32px red-ringed circle and send stays beside it (grey until
there is text). With text typed during a run the send control becomes a dark
"Steer" or "Queue" pill. The placeholder reads "You can keep typing while it
runs". The model button leads with the vendor's mark. Both buttons carry an
aria-label because their text is hidden in the circle form.

Blast radius: Composer, its two button rules in studio.css, one app.css rule
(a disabled send is no longer hidden while running), one string.

Why this layer: the composer owns what the send and stop controls say.
2026-11-03 08:19:34 -08:00
esengine d15b1180cf docs(release-notes): model and effort in one button 2026-11-03 08:15:39 -08:00
esengine 5d560c2879 feat(studio): model and reasoning effort share one button and one panel
The model and the reasoning effort were two buttons with two menus on either
side of the input box, though effort is a capability of the model and the
level was written twice ("Auto" in two languages).

One button now reads the model and the effort once, and opens one panel: the
models on the left as before, the efforts of the current model beside them.
Picking an effort applies it and closes the panel; picking a model applies it
and leaves the panel open so an effort can follow. A model that reports no
levels still points at where they are declared. Picker gains an aside column
with its own close handle, a stay-open rule, and Left/Right to cross columns.

Blast radius: Picker/MenuRow, Composer, the effort helpers and the composer
menu, narrow and studio stylesheets (the old effort button's rules are gone).

Neighbouring behaviours: tests that opened the old effort button now use the
merged one; the new panel has its own interaction tests.

Why this layer: Picker owns menu behaviour; the composer only says which two
lists go in it.
2026-11-03 08:15:39 -08:00
esengine bbef48cc65 docs(release-notes): composer controls height 2026-11-03 08:04:15 -08:00
esengine 52534f832b feat(studio): the input box's toolbar controls share one 28px height
The attach, side and refine buttons were 29px, the branch chip 29px and the
mode and permission chips 32px, so the row of controls under the input
stepped up and down. The board sets every selector at 28px with the send
button at 32px.

Blast radius: the studio.css rules for the toolbar controls; the narrow-window
icon-only mode picker follows (30px to 28px).

Neighbouring behaviours tested: the style suites and the full desktop suite;
the box was rendered in both schemes.

Why this layer: the sizes live in these rules and a test now pins them.
2026-11-03 08:04:15 -08:00
esengine d5ad9ba17f docs(release-notes): spec corners, type and motion 2026-11-03 07:49:23 -08:00
esengine 0280ffb399 feat(studio): corners, type size and motion timing follow the design spec
The design spec steps corners 5 / 7 / 10 / 15 px, sets the lead size at 14px
and times a line, a card and a panel 120 / 180 / 260 ms. The tokens held
4 / 6 / 9 / 12, 15px and 140 / 220 / 340 ms, and 199 corners and 34 timings in
the stylesheets were written as bare numbers, so moving a token reached none
of them.

The tokens now carry the spec's values, every bare corner of 3px and up draws
from the nearest radius token (so a pack's corner recipe reaches it too), and
every bare 100-150ms timing uses --t-line. Two ratchet tests keep it so.
Weights are left alone: the language and weight settings already own them.

Blast radius: all stylesheets; corners grow by about a pixel and 15px text
becomes 14px where --fs-lead is used, motion is a little quicker.

Neighbouring behaviours tested: the style suites and the full desktop suite;
chat and workbench were rendered before and after in both schemes.

Why this layer: tokens own the value, stylesheets only name it.
2026-11-03 07:49:23 -08:00
esengine 2948ec98ec docs(release-notes): recent sessions groups 2026-11-03 07:39:48 -08:00
esengine d0f2be43a0 feat(studio): recent sessions are grouped by day with a compact filter row
The list ran together from the first row to the last, the search box had no
icon, and the environment choice took a labelled row of its own.

Rows now sit under "today" and "earlier" headings, the search box carries its
icon and shares one row with a compact environment select (still named
"environment" for assistive tech), and the empty state and paging are
unchanged. The board's running-state dot and arrow-key hint are not built:
this list has no running state and no arrow-key navigation to back them.

Blast radius: RecentSessions and treerows.css; picking, paging, retry and the
unsupported-environment message are untouched.

Neighbouring behaviours tested: paging with retry, locating a collapsed
project, environment switching, plus the full desktop suite.

Why this layer: the dialog owns how its own rows are grouped.
2026-11-03 07:39:48 -08:00
esengine 1bc4c58aa5 docs(release-notes): move-session dialog 2026-11-03 07:31:40 -08:00
esengine 38143faff0 feat(studio): move-session dialog shows what travels beside what stays
The dialog asked for the target in a drop-down and put what travels and what
does not into two run-on paragraphs, so the consequence was read last.

It now lists the target projects as a radio list with their paths, sets what
travels and what stays behind in two short columns, and with no other project
offers to add one right there. Footer buttons use the shared medium buttons.

Blast radius: MoveSessionDialog, the hook that hosts it, one action id and
the English strings; the other pick dialogs keep their own button styles.

Neighbouring behaviours tested: move succeeds, picks the chosen project, is
refused with the kernel's reason, partial move, empty state with Add project,
focus on open, plus the full desktop suite.

Why this layer: the dialog owns its own content and the host passes the
add-project action through the hook rather than the dialog reaching for it.
2026-11-03 07:31:40 -08:00
esengine cb465b4109 docs(release-notes): session menu groups 2026-11-03 07:20:21 -08:00
esengine 7381820724 feat(studio): session menu is grouped by what the action does
The menu was one flat list: close and branch sat between rename and archive,
export appeared twice, and why an item was unavailable lived in a tooltip.

It now has five groups (position, name, content, destination, ending), one
export entry with Markdown and JSON as choices, pause first while the
conversation runs, and the reason a close or move waits written under the
item and tied to it with aria-describedby. The menu is 260px wide.

Blast radius: SessionMenu and its styles; every action keeps its data-action
and accessible name, so the sidebar, sync, move and close suites are untouched.

Neighbouring behaviours tested: new group-order, export, running-state and
spaced-path tests, plus the full desktop suite.

Why this layer: SessionMenu owns the order and wording of the row actions.
2026-11-03 07:20:21 -08:00
esengine 7fae97de84 docs(release-notes): corner notices share one shape 2026-11-03 07:08:24 -08:00
esengine 419b65dbca feat(studio): corner notices share one shape
Failures, notes, offers, the update reminder and the size question were five
hand-made boxes of different widths, with the failure told apart only by red
text and its actions drawn as underlined links.

They now render through one ToastRow: a fixed 380px column, a leading icon
that tells a failure from a note, and the shared small pill buttons for the
actions. The size question stacks its choices under the text.

Blast radius: ToastStack, UpdateReady, SizeAsk and toasts.css; behaviour
(stay until dismissed, at most three, report, restart, zoom) is unchanged.

Neighbouring behaviours tested: the existing toast, update-ready, versions and
appearance suites plus the full desktop suite.

Why this layer: one component owns the notice shape so a new kind of notice
cannot be drawn a sixth way.
2026-11-03 07:05:16 -08:00
esengine 47fb2f3ac2 fix(inbox): read and write the queue schema 1.x leaves behind
Cause: Studio's inbox is schema 2 while 1.x writes schema 3, so a session
that had been used in 1.x opened as "written by a newer version" and its
queued messages could be neither sent nor deleted.

Fix: accept schema 3 and keep its fields (fingerprint, attachment identities,
image inputs and source refs) through every rewrite so nothing 1.x wrote is
dropped. This build cannot read 1.x image inputs, so an entry carrying them is
held with the foreign_images block code on both the run and steer paths
instead of being sent without the images.

Blast radius: state/sessioninbox (envelope, request hash, images flag), the
two dispatch paths in session/control, and the block-reason tables in the
desktop and terminal frontends.

Neighbouring behaviours tested: queue tests of the inbox, control, tui, serve
and i18n packages, plus the desktop queue and translation tests.

Why this layer: the envelope owns what is persisted and the shared
applyInboxReferences owns whether an entry may run.
2026-11-03 06:40:40 -08:00
esengine 27cc9f4191 docs(release-notes): note the free-text box choosing Other 2026-11-03 04:30:06 -08:00
esengine 3933dbed40 fix(ask): clicking into the free-text box chooses Other
The box under a question's options only switched to Other once
something was typed, so a click into it left the radio empty and
the card read as if nothing were chosen. Focusing the empty box now
selects Other; a single pick's note box keeps that pick, and
multi-choice picks stay alongside.
2026-11-03 04:29:56 -08:00
esengine a377a43652 docs(release-notes): note the unified settings fields 2026-11-02 23:29:06 -08:00
esengine 0e1dff5b5a fix(desktop): give every settings text field the spec box
kit.css draws the text inputs, textareas and unit fields inside the settings
pane: 32px high, 7px corners, 13px text, one focus ring and one error ring;
the per-model rows of model services use the 24px size. The unit field's box
is the outer span, its input stays borderless inside it. The rules that drew
these fields per page (session limits, providers, network, remote, backup,
appearance, peers, presets, advanced fields) keep only widths and the mono
family for addresses and keys. Two label-span rules in model services no
longer reach the unit field. field.test.ts holds those page rules to it.
2026-11-02 23:29:04 -08:00
esengine 5d73c8e205 docs(release-notes): point the drop-down note at the landed commit 2026-11-02 23:06:19 -08:00
esengine 22f02727f6 docs(release-notes): note the unified drop-downs 2026-11-02 23:03:48 -08:00
esengine e3e06303f5 fix(desktop): give every drop-down the spec look
kit.css owns single-choice selects: 32px high, 7px corners, 13px text and one
12px arrow set in 10px from the right edge, with the same hover, focus ring and
disabled look everywhere; data-size="sm" is the 24px variant for list rows
(permission rules, the phone-access address). The page rules that re-set the
native look for model roles, providers, permissions, network, remote, phone
access, memory editing, presets and the recent-machine picker keep only their
widths. select.test.ts holds every other stylesheet to that: none may set a
select's size, corners, padding or arrow.
2026-11-02 23:03:48 -08:00
esengine fd6fa05b19 test(history): assert a complete index only once the projection has settled
CI's Windows full suite saw the index answer State=ready with Indexed=Total=1
while a rescan was still pending, so SearchIndexProgress honestly reported
building and the test, which had only waited for the hit to appear, failed.
The test now retries until the projection reports every source indexed and
nothing pending, fails at once on any unreadable-source signal, and only then
asserts that no incomplete-index signal is raised.

Not reproduced locally: the base passed 20x on macOS, 90x under six concurrent
processes on Windows, and a full package run on Windows.
2026-11-02 22:57:54 -08:00
esengine bbdfaebdf3 docs(release-notes): note the unified button sizes 2026-11-02 22:28:07 -08:00
esengine 3d6e532b0e fix(desktop): give every button the spec sizes
Small buttons are 24px pills, medium ones 32px with the 10px corner, icon
buttons 28px squares with the 7px corner. kit.css carries the shared rules;
the page-scoped rules that re-set height, radius, font size and weight for
the title bar, the project tree, reply actions, model services, remote,
appearance and automation now use the same values. A browser sweep of the
main window and all sixteen settings pages measures 324 buttons, 0 off spec
(105 before).
2026-11-02 22:27:48 -08:00
YHH f06c3efca5 Merge pull request #14927 from lancy69/fix/14925-selection-menu-font
fix(studio): normalize reply selection menu tracking
2026-11-02 21:54:35 -08:00
YHH 40b9b8f211 Merge pull request #14947 from chuiwenwei/fix/history-search-does-not-hide-a-source
fix(history): a search says when the index could not read a source
2026-11-02 21:37:39 -08:00
YHH 055b60bc0c Merge pull request #14948 from chuiwenwei/fix/acp-lease-refusal-carries-a-code
fix(acp): a lease refusal says which refusal it is
2026-11-02 21:16:57 -08:00
YHH 1fd91e8f4a Merge pull request #14938 from lancy69/fix/14931-version-download-overlap
fix(desktop): prevent version download text overlap
2026-11-02 20:37:50 -08:00
esengine 3e9e807132 ci: drop the empty boot entry the root token budget step still required
The step runs only ./internal/runtime/agent, but its jq still required a
passing package event for internal/assembly/boot after the task budget
tests there were removed, so the step failed on every pull request.
2026-11-02 19:56:34 -08:00
Lancy 29c1e04267 fix(studio): normalize reply selection menu tracking 2026-11-03 03:19:14 +00:00
esengine 715a9f11fb fix(desktop): translate the memory page's remaining labels and list its demo data
The memory page drew 参考, 用过, 生效, 范围, 低, 高 and 所有项目 without
an English entry, and its new demo facts were not on the English-window
guard's fixture list, so browser guards failed for every pull request.
2026-11-02 18:56:49 -08:00
esengine d88acbe67d feat(desktop): a three-column memory page with galaxies, retention and review
The memory page lists facts by type and galaxy with a search box,
shows each fact's retention, uses and last recall, lets a newly
learned fact be confirmed or archived from the list, and edits its
type, galaxy and importance.
2026-11-02 18:14:18 -08:00
esengine d0289a4d0f feat(memory): expose galaxy, strength and review state to the memory page
The memory list now carries each fact's galaxy, importance, retention
state, use count, days left before it turns fuzzy, whether it decays,
and whether it is new (written in the last 14 days and not yet reviewed).
POST /memory/confirm reinforces a fact and marks it reviewed; a save from
the page also marks it reviewed and may move its galaxy, importance and
type. The review mark lives in the usage sidecar, so it never rewrites
the fact file.
2026-11-02 17:41:01 -08:00
esengine 87c91ebd06 refactor(memory): copy usage records with maps.Copy 2026-11-02 17:23:53 -08:00
esengine bfc838a3ce feat(memory): a forgetting curve, use reinforcement and links between facts
Each fact has a retention of e^(-days since last use / strength). Reading a
fact multiplies its strength by 1.8 and an automatic recall by 1.2, capped at
365 days; constraints, pinned and evergreen facts never fade. Recall keeps a
clear fact's score, scales a fuzzy one down and holds a faded one at 0.35 so a
strong match still comes back. Facts recalled in the same turn build a link,
and a link of weight two brings its neighbour along. Usage lives in a
.usage.json beside the facts, so a recorded use never rewrites a fact file or
moves its revision. memory search and read report the state.
2026-11-02 17:21:32 -08:00
esengine eef0e8590b fix(memory): a one-letter galaxy name never counts as named by the turn 2026-11-02 17:05:09 -08:00
esengine c22dd9495d feat(memory): rank recall by importance and bring constraints along
Automatic recall scores the name, title, description, galaxy and body as one
field and the synonyms as a second field at 0.6, then weights the result by
importance (0.85, 1.0, 1.15) and a 1.1 boost for facts used in the last 30
days. A constraint is brought whenever its galaxy is named in the turn or is
the galaxy of one of the three best hits, or when its own score reaches 8% of
the best, and it leads the block; at most three are held and an expired one is
never recalled.
2026-11-02 17:05:09 -08:00
esengine ffe1b34bda docs(release-notes): note the manual compaction fix 2026-11-02 17:03:42 -08:00
esengine cee09e76ba fix(context): a manual compaction above the trigger folds when it frees space
Compacting by hand accepted any real saving only while the context was still
below the automatic trigger. Above it, the request had to land below the
trigger or was declined, so the moment compaction was most needed the user
was told there was nothing to compact. A manual request now accepts any fold
that is strictly smaller; an unchanged context and too little new history are
still declined as before.
2026-11-02 17:03:39 -08:00
YHH bbb9cf74a9 Merge pull request #14943 from lancy69/fix/13655-renderer-safe-skill-warnings
fix(skills): render first and new discovery warnings safely in TUI
2026-11-02 16:55:10 -08:00
esengine b70010f018 docs(release-notes): split the task budget note to fit the line limit 2026-11-02 16:53:59 -08:00
esengine 5bc01bedd2 docs(release-notes): note the removed task budgets 2026-11-02 16:53:33 -08:00
esengine a341d5615e refactor(config): remove the user task budgets
task_cost_budget, task_time_budget_minutes and task_token_budget no longer
exist: a task is not capped by what it spends. Old config files carrying them
are cleaned on start with a one-time notice, and the settings declaration and
config template drop them. The budget machinery a Goal run injects for itself
(goal_token_budget, off by default) and the run usage accounting stay. The
effect tests that only exercised the configured budgets are removed, and the
Windows CI steps that named them are pruned in the same commit.
2026-11-02 16:39:16 -08:00
esengine cb46820a92 test: the CI owner check no longer expects the removed isolation package 2026-11-02 16:35:27 -08:00
esengine da640949c8 feat(memory): store a galaxy, importance, strength and use count per fact
Facts gain optional galaxy, importance (1 to 3), strength (days), last-used
time and use count in their frontmatter, and a constraint type that never
fades. Absent fields resolve to defaults on read, so existing files stay
valid and are not rewritten; an update that omits them keeps what is stored.
A constraint is written with a project routing type so an older binary
still files it correctly. The remember tool accepts galaxy and importance,
and memory read reports them.
2026-11-02 16:19:16 -08:00
esengine 562449cd9d fix(desktop): drop the compaction threshold actions and strings that nothing renders 2026-11-02 16:00:53 -08:00
esengine 863f66cb7e test: update the compact ratio default the CLI reports 2026-11-02 15:57:06 -08:00
esengine 2662c799d6 ci: drop the Windows root auxiliary budget step whose tests were removed 2026-11-02 15:51:27 -08:00
esengine 7e1cdccb8d refactor(context): drop the soft compaction paths and default to 0.90
Context maintenance follows one trigger: a share of the model window, now
0.90 by default for new configs (existing files keep what they saved).
Removed the absolute-size economic boundary and its setting, HTTP route and
settings UI, the draggable fold handle in the context panel, the retired
multi-threshold and cold-resume keys (old files are cleaned on start), and
the failure-output elision applied to retained messages during a fold,
including the extra model call that picked the lines to keep.
2026-11-02 15:50:49 -08:00
esengine 4aeb419ea9 ci: stop testing the removed isolation package 2026-11-02 15:50:06 -08:00
esengine 2e2a484781 ci: stop requiring Windows tests for features that were removed
The pull-request-only Windows steps listed tests by name and failed when one
was missing. The sub-agent, isolation, background-job and carried-proof
rewrites deleted a number of them, so every pull request failed its Windows
check. Drop the steps whose whole list is gone, prune the removed names from
the rest, and remove the concurrency inventory runner whose two inventories
named nothing that exists.
2026-11-02 09:29:27 -08:00
esengine 611094ba17 docs(release-notes): note the sub-agent rewrite and the removed checks 2026-11-02 08:53:08 -08:00
esengine ad2c963475 refactor(delegation): replace the task family with spawn_agents
Rewrite internal/runtime/delegation around one Runner and one tool.
spawn_agents runs 1 to 8 independent tasks in parallel, each in its own
session with three base tools (read_file, write_file, bash); plan mode and
read-only skills keep a read-only child. A child's full answer is archived;
the caller gets the opening, a reference, and the files the child read and
wrote, and pages the rest through read_subagent_result.

Removed with it: task, read_only_task, parallel_tasks, fleet,
list_subagents, background sub-tasks, steering and its limits and settings
UI, continue_from, write_paths claims, isolated worktrees, best-of-n, the
run graph and the runtime-resume benchmark. Stale writes by children stay
refused through the per-agent file views.

The use_capability description no longer names task:fleet, so the stable
prefix and its golden files change once.
2026-11-02 08:52:45 -08:00
esengine 0bb0f44785 refactor(delegation): sub-agents return prose, not a structured completion report
complete_subtask, the host adjudication of its criteria and the 'lowered by the
host' note are gone, with the handoff audit channel and its metrics, trajectory
field and benchmark columns. A delegated child ends the way any turn does and
the parent reads its last reply.

The host's own receipts appended to a child's reply and the write-scope check
stay: they need a record of what the child changed, which is replaced with the
evidence ledger later.
2026-11-02 07:16:52 -08:00
esengine 5766a8ebeb refactor(tools,plan): complete_step takes no evidence; drop conclude_blocked and plan criteria
complete_step now only says a step is done: it names the step and what is now
true, and the host advances the task list as before. It no longer asks for
commands, files or criteria to cite and no longer checks them against the
session's receipts.

conclude_blocked existed to let the readiness gate through and goes with it;
the prompts that pointed at it now ask for a sentence in the reply. Plan steps
lose their acceptance and verification fields: the submit_plan schema, the
planner and executor prompts, the rendering and the re-approval rule all
stop mentioning them, and plans saved with them still load.

The executor's no-action nudge now keys on a plan naming files only.
2026-11-02 06:33:28 -08:00
esengine 3d0fe85e7e refactor(event): remove the readiness and audit channels nothing sends to
With the final-readiness gate gone, FinalReadinessError, the readiness audit
and the structured readiness on TurnDone have no producer, and neither do the
contract-shadow, completion-report, evidence-bundle, outcome-progress,
protocol-recovery, verification-drift and project-check-probe audits. Removes
the types, the sink capabilities and their forwarding in every wrapper, the
trajectory and metrics fields, the final_readiness outcome and notice code,
and the delivery-recovery submit port.

The ACP status shape and the desktop reader of old sessions keep their
readiness fields: external editors and recorded sessions still carry them.
2026-11-02 05:59:36 -08:00
esengine 8c785fa4b0 refactor(cli,config): remove --fail-on-unverified and agent.host_verification
Both only steered the final-readiness gate, which no longer exists: the flag
chose exit 3 for an unmet readiness verdict and the setting chose whether a
changed turn was held for a check, a review and a sign-off. Neither can do
anything now.

`run` still exits 4 when an untrusted folder refused its edits. A user config
that still carries host_verification loads unchanged; the key is ignored.
Removes the HoldOwedChecks and WaiveOwedChecks plumbing behind them.
2026-11-02 05:27:28 -08:00
esengine b364c1693c refactor(agent): remove the final-readiness gate, review gate and baseline checks
A turn that ends with its answer is done. The agent no longer asks, at the
point of stopping, whether a check ran after the last write, whether a
high-risk change was reviewed, or whether captured test criteria still hold;
it no longer ends a run with a readiness error, and the controller no longer
re-runs the turn to satisfy one.

Goal keeps its update_goal report and the evaluator, and still holds a
complete claim while the task list is unfinished; it no longer reads a
readiness result or freezes a verification contract. Review sub-agents are
ordinary sub-agents: nothing requires them to file a typed report.

Removes the readiness, owed-review, baseline-criteria, carried-proof and
review-report code, the delivery checkpoint, the readiness continuation, the
orphaned helpers they left in evidence, and the tests that asserted the removed
behaviour.
2026-11-02 05:15:57 -08:00
Will 0bbcefcb59 docs(release-notes): record #14940 2026-11-02 21:14:15 +08:00
Will 448b8bdf5c docs(release-notes): record #14940 2026-11-02 21:14:12 +08:00
Will b6a1d9b5f3 fix(history): a search that could not read a source says so
Cause: the catalog hides a source with health<>'ok' from the query
(internal/state/historycatalog/catalog.go:520) and drops a candidate whose
transcript fails to load after RecordReadFailure, but /history/search answered
with hits only. The rail renders nothing for zero hits, so a search over an
incomplete index reached it as the same answer as a query that matched
nothing. The Agent history tool already carries the incomplete notice; the
desktop half lost its equivalent when the Wails history panel
(partial + status.indexed/total) was replaced by frontend-next.

Fix: /history/search answers {hits, index{state,indexed,total,failed,partial,
reason}}, read from the counters the catalog already publishes after the
search, with one shared predicate (history.SearchIndexProgress) that the
history tool now uses too. The rail renders one line while index.partial:
"could not be indexed" for reason=failed, the catalog's own progress for
reason=building. A complete index renders nothing extra.

Blast radius: the /history/search wire shape changes from a bare array to
{hits,index}; its only consumer is the Electron rail in this tree. A degraded
in-memory projection no longer reports "still building" once its counters
agree it answered for every source.

Verification: new serve tests drive an unreadable event log end to end and
assert the response carries failed/partial with zero hits; a control asserts a
healthy index adds nothing for a matching or an empty query. The rail tests
cover the failed line, the building line, matches kept beside the line, and the
silent healthy case.
2026-11-02 21:08:37 +08:00
Will e5a6c3ae93 fix(acp): a lease refusal says which refusal it is
A session method refused because another runtime owns the transcript answered
-32600 and prose, the code a malformed frame gets; an ownership probe that could
not decide answered -32603, the same as a disk that would not write. A client
could not tell "held" from "unreadable" from "your request was broken", so it
could not decide whether to ask the person to close a window or retry.

session/new, session/load and session/resume now answer -32022 with data.code
busy.session_in_use for a held lease and -32023 with data.code
session.lease_unprovable when the store's probe cannot prove ownership either
way; -32600 is left to a genuinely malformed request. The human message stays.
The HTTP frontend, the kernel, the lease and the inbox are untouched.

docs/ACP.md states the table and what a client does with each answer. repolint's
family-vs-writer check is now parameterised by family; it reads the lease
sentinels beside the inbox ones, so a lease sentinel added without a coded case
fails by name against the ACP writer.
2026-11-02 21:00:01 +08:00
esengine 9eacc9afa9 test(plugin): drop the Linux probe test whose subject was removed
The unconfined-launch probe it exercised was deleted with the sealing
removal; the Linux-only file did not compile on the CI runners.
2026-11-02 04:34:53 -08:00
Lancy 82c27867b3 fix(skills): render runtime discovery warnings as notices 2026-11-02 12:27:26 +00:00
esengine fb02f44834 refactor(agent): stop sealing, shadowing and receipting each turn
A turn no longer builds a shadow task contract, a completion report or a
sealed evidence bundle, takes a workspace snapshot, or hands the host a
completion receipt. Removes the agent-side producers and their wiring in boot,
the observation and verdict packages that only they used, and the
write-protection and unconfined-launch probes that only boot read.

The receipt and audit shapes on the event wire, the terminal and the desktop
settings stay for a later cleanup; nothing sends them now.
2026-11-02 04:01:03 -08:00
esengine be5c98c744 refactor(agent): remove missing-tool-call-reasoning detection and recovery
The loop no longer classifies a tool-call turn that arrived without
reasoning, retries it, or keeps a file-backed cooldown for it. Removes the
watch and the cooldown state, the deferred stream sink they needed, the
provider policy interfaces and their implementations, the descriptor field on
the extension wire (schema and SDK regenerated), the state-dir path, the
run-metrics counters and the protocol-recovery kinds.

The protocol-recovery audit channel itself stays for now.
2026-11-02 03:26:03 -08:00
YHH a87c4d1fff Merge pull request #14941 from esengine/fx/pr-author-gate
ci: limit pull requests to maintainers and an allowlist of invited authors
2026-11-02 02:06:23 -08:00
YHH ea2c04d046 Merge pull request #14940 from lancy69/fix/14936-reasoning-without-answer
fix(agent): recover DeepSeek reasoning-only final answers
2026-11-02 01:55:03 -08:00
esengine 60caacd887 ci: limit pull requests to maintainers and an allowlist of invited authors 2026-11-02 01:52:42 -08:00
Lancy 860651f866 fix(agent): recover DeepSeek reasoning-only final answers 2026-11-02 09:11:50 +00:00
Lancy 2f63333c56 fix(desktop): prevent version download text overlap 2026-11-02 07:35:20 +00:00
YHH c43bd2bdc3 Merge pull request #14902 from esengine/dependabot/npm_and_yarn/desktop/electron/electron-45.1.0
chore(deps-dev): bump electron from 44.7.2 to 45.1.0 in /desktop/electron
2026-11-01 19:39:13 -08:00
esengine 457b586331 build(windows): declare electron_wer.dll and drop dxil.dll for Electron 45
Electron 45 ships electron_wer.dll, unsigned, and no longer ships dxil.dll.
Sign electron_wer.dll with the other unsigned PE images, and stop requiring
a Microsoft-signed dxil.dll. Checked against the 45.1.0 win32 x64 and arm64
archives: both hold exactly the declared set.
2026-11-01 19:10:41 -08:00
esengine 678cf19f03 Merge studio into dependabot/electron-45.1.0 2026-11-01 19:10:24 -08:00
kevin9327 35791b47dd Merge branch 'studio' into fix/gitignore-worktree-info-exclude 2026-11-02 11:59:48 +09:00
YHH 0b482f3283 Merge pull request #14017 from octo-patch/feat/minimax-m31-flash-rollout-20261020
feat(minimax): default to M3.1 Flash and preserve reasoning contracts
2026-11-01 18:44:25 -08:00
YHH 5ae138c556 Merge pull request #14634 from SivanCola/feature/model-health-ui-upstream
feat(studio): add opt-in health status to grouped picker / 为原分组模型列表增加可选检测状态
2026-11-01 18:06:24 -08:00
YHH fb6c043d2d Merge pull request #14847 from lancy69/fix/14845-tool-result-shadow
fix(studio): fade clipped tool results without coloured shadows
2026-11-01 17:44:04 -08:00
YHH 737a181c49 Merge pull request #14923 from esengine/release/studio-2.57.0
release(studio): notes for 2.57.0
2026-11-01 16:37:22 -08:00
esengine 5b90af36a4 release(studio): notes for 2.57.0 2026-11-01 16:27:59 -08:00
YHH c8eec1b946 Merge pull request #14922 from esengine/fix/power-save-run-motion
fix(studio): Reduced effects also stop the looping run animations
2026-11-01 10:55:12 -08:00
YHH 873140996c Merge pull request #14920 from esengine/test/extra-root-prefix-diff
test(boot): show where an extra root moved the cache-stable prefix
2026-11-01 10:21:12 -08:00
esengine 5dcb9a3a35 fix(studio): Reduced effects also stop the looping run animations
A long wait keeps the running state, and its three infinite animations, on
screen for minutes; the R mark's stroke-dashoffset trace alone costs a style
recalc every frame. Reduced effects, the mode meant for a window that keeps
the GPU busy, now draws the mark whole and stops the row sweep and spinners.

Fixes #14919
2026-11-01 10:14:02 -08:00
esengine 029df0ec25 test(boot): show where an extra root moved the cache-stable prefix
The assertion said only that the prefixes differed. It failed once on the
Windows full suite and passed on rerun; report the first differing byte with
context so the next failure says what moved.
2026-11-01 09:50:30 -08:00
YHH f6aa704059 Merge pull request #14915 from esengine/feat/office-preview
feat(workbench): preview Word and Excel files read-only
2026-11-01 08:55:17 -08:00
YHH dc5a49b8fa Merge pull request #14914 from esengine/fx/serve-password-file
fix(serve): read the password from --password-file instead of argv
2026-11-01 08:39:19 -08:00
esengine fb08cdc0d5 feat(workbench): preview Word and Excel files read-only
.docx and .xlsx were classed unsupported, so a delivered report or sheet
could only be opened in the system app. The kernel now serves them as an
office preview kind (binary, 32 MB), and the workbench renders them to static
HTML (docx-preview; ExcelJS with SSF number formats) shown through
staticDocument() in a sandbox="" frame, so nothing in a file runs or fetches.
Both parsers are lazy chunks, and a machine setting turns the preview off.

Refs #14622
2026-11-01 08:14:16 -08:00
esengine 2bd960b09d fix(serve): read the password from --password-file instead of argv
--password put the serve password in argv, where ps and /proc/<pid>/cmdline
show it to every local user; --hash-password needed it there too. Add
--password-file, held to the same single-line, owner-only rule as
--token-file, and accept it for --hash-password.

Fixes #14913
2026-11-01 08:12:54 -08:00
YHH f770f33791 Merge pull request #14912 from kevin9327/fix/driver-refusal-rune
fix(computer): cut a long CUA refusal reason on a rune boundary
2026-11-01 07:52:17 -08:00
YHH b58d62b589 Merge pull request #14911 from kevin9327/fix/eval-result-rune
fix(browser): cut a long eval result on a rune boundary
2026-11-01 07:52:09 -08:00
kevin 37e203408d fix(computer): cut a long CUA refusal reason on a rune boundary 2026-11-02 00:15:50 +09:00
kevin 2407ebd639 fix(browser): cut a long eval result on a rune boundary 2026-11-02 00:15:50 +09:00
YHH f247be74ea Merge pull request #14909 from esengine/fx/seed-aged-meta
test(serve): age seeded sessions through their branch meta
2026-11-01 06:22:56 -08:00
YHH f88faddf41 Merge pull request #14907 from kevin9327/fix/bounded-segment-rune
fix(evidence): cut quoted mutation-scan segments on a rune boundary
2026-11-01 06:22:45 -08:00
YHH 027668a0ee Merge pull request #14908 from kevin9327/fix/args-summary-rune
fix(recovery): cut ArgsSummary on a rune boundary
2026-11-01 05:55:08 -08:00
esengine b0c5d3287e test(serve): age seeded sessions through their branch meta
seedAged set file times only, but listings read activity from the branch
meta, which SaveSnapshot stamps with the write time. Every seeded session
was therefore 'now', ordered only by milliseconds of write order, and the
recent-order tests failed whenever two writes shared a millisecond.

Write the intended time into the meta as well as the transcript and event
log, so the seeded ages are the ones the listing reads.
2026-11-01 05:31:08 -08:00
kevin 498b834b71 fix(recovery): cut ArgsSummary on a rune boundary 2026-11-01 22:04:09 +09:00
kevin 2051fc937f fix(evidence): cut quoted mutation-scan segments on a rune boundary 2026-11-01 22:03:20 +09:00
YHH ee47753988 Merge pull request #14906 from esengine/dependabot/npm_and_yarn/desktop/frontend-next/npm-1dcfd208cf
chore(deps): bump the npm group in /desktop/frontend-next with 10 updates
2026-11-01 04:55:53 -08:00
YHH 5ecf313c3d Merge pull request #14901 from esengine/dependabot/go_modules/go-ccc6468e39
chore(deps): bump the go group with 4 updates
2026-11-01 04:55:14 -08:00
YHH fea800bac6 Merge pull request #14905 from esengine/dependabot/github_actions/actions-4430c5f07e
chore(deps): bump actions/setup-java from 4.9.1 to 6.1.0 in the actions group
2026-11-01 04:46:10 -08:00
esengine ba6bf964bb test(surface): read the default catalog as optional under a2ui 0.14
a2ui 0.14 types SurfaceModel.defaultCatalog as possibly undefined. Studio's
surface always names its catalog in createSurface, so the catalogue checks
still hold; an absent catalog now fails them rather than the type check.
2026-11-01 04:37:51 -08:00
esengine 82de6d9622 chore(deps): tidy the desktop module for the go group bump 2026-11-01 04:27:05 -08:00
dependabot[bot] 14dbabfec2 chore(deps): bump the npm group
Bumps the npm group in /desktop/frontend-next with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@a2ui/react](https://github.com/a2ui-project/a2ui/tree/HEAD/typescript/renderers/react) | `0.13.0` | `0.14.0` |
| [@a2ui/web_core](https://github.com/a2ui-project/a2ui/tree/HEAD/typescript/core/web_core) | `0.13.0` | `0.14.0` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.14` | `6.43.15` |
| [@mantine/charts](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/charts) | `9.7.3` | `9.7.4` |
| [@mantine/core](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/core) | `9.7.3` | `9.7.4` |
| [@mantine/hooks](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/hooks) | `9.7.3` | `9.7.4` |
| [katex](https://github.com/KaTeX/KaTeX) | `0.19.0` | `0.19.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.61.0` | `1.64.0` |
| [maplibre-gl](https://github.com/maplibre/maplibre-gl-js) | `6.15.0` | `6.16.0` |
| [@babel/parser](https://github.com/babel/babel/tree/HEAD/packages/babel-parser) | `8.0.7` | `8.0.8` |


Updates `@a2ui/react` from 0.13.0 to 0.14.0
- [Release notes](https://github.com/a2ui-project/a2ui/releases)
- [Changelog](https://github.com/a2ui-project/a2ui/blob/main/typescript/renderers/react/CHANGELOG.md)
- [Commits](https://github.com/a2ui-project/a2ui/commits/HEAD/typescript/renderers/react)

Updates `@a2ui/web_core` from 0.13.0 to 0.14.0
- [Release notes](https://github.com/a2ui-project/a2ui/releases)
- [Changelog](https://github.com/a2ui-project/a2ui/blob/main/typescript/core/web_core/CHANGELOG.md)
- [Commits](https://github.com/a2ui-project/a2ui/commits/HEAD/typescript/core/web_core)

Updates `@codemirror/view` from 6.43.14 to 6.43.15
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@mantine/charts` from 9.7.3 to 9.7.4
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/9.7.4/packages/@mantine/charts)

Updates `@mantine/core` from 9.7.3 to 9.7.4
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/9.7.4/packages/@mantine/core)

Updates `@mantine/hooks` from 9.7.3 to 9.7.4
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/9.7.4/packages/@mantine/hooks)

Updates `katex` from 0.19.0 to 0.19.1
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.19.0...v0.19.1)

Updates `lucide-react` from 1.61.0 to 1.64.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.64.0/packages/lucide-react)

Updates `maplibre-gl` from 6.15.0 to 6.16.0
- [Release notes](https://github.com/maplibre/maplibre-gl-js/releases)
- [Changelog](https://github.com/maplibre/maplibre-gl-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/maplibre/maplibre-gl-js/compare/v6.15.0...v6.16.0)

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

---
updated-dependencies:
- dependency-name: "@a2ui/react"
  dependency-version: 0.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@a2ui/web_core"
  dependency-version: 0.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@mantine/charts"
  dependency-version: 9.7.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@mantine/core"
  dependency-version: 9.7.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@mantine/hooks"
  dependency-version: 9.7.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: katex
  dependency-version: 0.19.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: lucide-react
  dependency-version: 1.64.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: maplibre-gl
  dependency-version: 6.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@babel/parser"
  dependency-version: 8.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-11-01 11:59:19 +00:00
dependabot[bot] 6bccc90b84 chore(deps): bump actions/setup-java
Bumps the actions group with 1 update: [actions/setup-java](https://github.com/actions/setup-java).


Updates `actions/setup-java` from 4.9.1 to 6.1.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/cf277c60eb25467037889841efdb72551f06f6c3...b843ebf9b913146b38da2eaa7aeddf76074ae1ad)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-11-01 11:59:01 +00:00
dependabot[bot] 91b5467cba chore(deps-dev): bump electron in /desktop/electron
Bumps [electron](https://github.com/electron/electron) from 44.7.2 to 45.1.0.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v44.7.2...v45.1.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 45.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-11-01 11:57:55 +00:00
dependabot[bot] 34707b672f chore(deps): bump the go group with 4 updates
---
updated-dependencies:
- dependency-name: charm.land/bubbletea/v2
  dependency-version: 2.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: github.com/bmatcuk/doublestar/v4
  dependency-version: 4.10.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: github.com/pkg/sftp
  dependency-version: 1.13.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: github.com/tdewolff/parse/v2
  dependency-version: 2.8.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-11-01 11:56:25 +00:00
YHH 839114a857 Merge pull request #14900 from esengine/fx/bg-job-resume-note
fix(jobs): tell the model a running background job resumes the session when it ends
2026-11-01 03:29:21 -08:00
esengine d56abf84d1 fix(jobs): tell the model a running background job resumes the session when it ends
The host already opens a turn when a background job ends, but bash's launch
message, bash_output and wait only offered more polling. A long build outlived
several capped waits and the model closed the turn reporting it unfinished.

The manager now answers whether a completion will resume a given session, and
the three readers state it for running jobs only where that holds.

Refs #14879
2026-11-01 03:03:27 -08:00
YHH e52e22bcce Merge pull request #14898 from esengine/fx/host-row-label
fix(queue): label a background job's follow-on that needs a decision as background
2026-10-31 23:55:01 -07:00
esengine de35fb7b0f fix(queue): label a background job's follow-on that needs a decision as background 2026-10-31 23:31:15 -07:00
YHH a8fe2001c8 Merge pull request #14894 from kevin9327/fix-failure-summary-rune
fix(plugin): cut MCP startup failure summaries on a rune boundary
2026-10-31 22:51:12 -07:00
YHH 1efd6dcae6 Merge pull request #14893 from esengine/fx/chip-steer-label
fix(composer): name a running send with a skill card as queued
2026-10-31 22:25:16 -07:00
kevin 41cd0d7021 fix(plugin): cut MCP startup failure summaries on a rune boundary 2026-11-01 14:05:40 +09:00
esengine 5f82ea087f fix(composer): name a running send with a skill card as queued 2026-10-31 22:01:06 -07:00
YHH 14a85d29cb Merge pull request #14891 from kevin9327/fix-semantic-truncate-rune
fix(capability): cut semantic-router candidate descriptions on a rune boundary
2026-10-31 20:51:42 -07:00
YHH 4367b57921 Merge pull request #14890 from kevin9327/fix-byte-size-rounding-carry
fix(size): move byte sizes to the next unit when rounding reaches 1024
2026-10-31 20:51:04 -07:00
kevin 40a7506965 fix(capability): cut semantic-router candidate descriptions on a rune boundary 2026-11-01 12:02:33 +09:00
kevin de34556d42 fix(size): move byte sizes to the next unit when rounding reaches 1024 2026-11-01 12:01:31 +09:00
YHH 1a4a8e2366 Merge pull request #14887 from esengine/c5-notes-2.56.1
docs(release): studio 2.56.1 notes
2026-10-31 19:22:53 -07:00
esengine eea2d3d2f1 docs(release): studio 2.56.1 notes 2026-10-31 19:10:52 -07:00
YHH ae7ff78e78 Merge pull request #14886 from esengine/fx/sec-pyv
fix(shellsafe): read-only only for a bare node or python version probe
2026-10-31 19:10:43 -07:00
esengine ca08f277f4 fix(shellsafe): read-only only for a bare node or python version probe 2026-10-31 18:29:40 -07:00
esengine aaa4f586b1 fix(shellsafe): treat only the exact version-probe spellings of node and python as reads 2026-10-31 09:56:54 -07:00
YHH b9daee966a Merge pull request #14877 from esengine/fx/queue-row-actions
fix(queue): draw edit and move only on lines the kernel can still change
2026-10-31 04:25:23 -07:00
esengine cda88398cc fix(queue): draw edit and move only on lines the kernel can still change 2026-10-31 04:03:12 -07:00
YHH 5f372e2229 Merge pull request #14873 from esengine/fx/phone-layout
fix(studio): phone-width spacing for the sidebar logo and the Settings header
2026-10-31 01:25:50 -07:00
esengine c0c5acb382 fix(studio): phone-width spacing for the sidebar logo and the Settings header
At phone width the sidebar menu button touched the logo, Settings put Close on
its own row under the search box, and each settings tab kept a 6.5em name
minimum so only three fit. The logo gets a left gap, the header grid holds
expand and Close side by side, and tab names size to their content.
2026-10-31 01:03:51 -07:00
YHH 9371541e1c Merge pull request #14871 from esengine/fx/primary-button-fill-2
fix(studio): a primary button in settings keeps its fill with its foreground
2026-10-30 23:25:54 -07:00
esengine 8981f49c6a fix(studio): a primary button in settings keeps its fill with its foreground
The settings type rule recoloured every enabled primary button's text to the
accent foreground and relied on each container to supply the fill. A container
with no fill rule of its own, such as the scheduled-run form, showed light text
on no fill, so Preview and Confirm looked blank. The rule now carries the fill
and border with the foreground.
2026-10-30 23:08:50 -07:00
YHH cec99a96d1 Merge pull request #14866 from esengine/fx/browser-tab-cap
fix(browser): bound the pages the agent can have open at once
2026-10-30 22:55:06 -07:00
esengine d1852af99f fix(browser): reserve the page count atomically so overlapping opens cannot exceed the limit
The check counted open pages and then created one, so concurrent agent opens at
the limit could all pass. A reservation counts pages still being created, and is
released once the page is in the list.
2026-10-30 22:28:49 -07:00
esengine 945568ce89 fix(browser): bound the pages the agent can have open at once
A model that opened a page per search result left dozens of renderer processes
running; one report shows 30 pages and about 2 GB held. Only a prompt hint asked
the model to close pages, and the shell's own sleep cap cannot keep up with a
burst of opens. The session now refuses a new agent page once 12 are open and
says how to make room; a page the person opens is never refused, though it
counts toward the total.
2026-10-30 21:30:12 -07:00
YHH a736f120bc Merge pull request #14851 from kevin9327/fix/capdiag-rune-boundary
fix(capdiag): cap sanitized diagnostic text on a rune boundary
2026-10-30 15:55:03 -07:00
kevin9327 d2a603eb9e fix(capdiag): keep collect.go under the file-size ceiling
Move the rune-boundary cut into a small helper in text.go so the fix
does not push collect.go over repolint's 800-line limit.
2026-10-31 06:58:57 +09:00
kevin9327 5b4909fcab fix(capdiag): cap sanitized diagnostic text on a rune boundary 2026-10-31 06:51:04 +09:00
YHH 6248f800a4 Merge pull request #14850 from esengine/fx/migrate-tests-skip-windows
test(migration): skip the unreadable-source cases where the platform cannot build one
2026-10-30 13:55:12 -07:00
esengine cdc749cca8 test(migration): skip the unreadable-source cases where the platform cannot build one
On Windows a regular file at the sessions path is reported as absent, which is
the normal fresh install, so the fixtures could not make an unreadable source
and the two tests failed on every Windows push run. The fixtures now skip there
and no longer claim the file fallback works on every platform.
2026-10-30 12:58:41 -07:00
YHH 837689dfa4 Merge pull request #14823 from chuiwenwei/fix/migrate-says-when-it-could-not-read
fix(migrate): an unreadable source is not an empty one
2026-10-30 11:56:22 -07:00
YHH b92e5affba Merge pull request #14848 from esengine/fx/ci-windows-full-timeout
ci(windows): give the full push suite room for a slow boot package
2026-10-30 11:25:45 -07:00
YHH 93105b148f Merge pull request #14849 from jf200s/fix/picker-clean-attribute-keys
fix(studio): clip attribute keys like their values
2026-10-30 11:25:08 -07:00
F dd8b40eb53 fix(studio): clip attribute keys like their values
clean() clipped attribute values to VALUE_CLIP but left keys unbounded, so a page could return thousands of long attribute names. Keys are now clipped with the same limit, with a unit assertion for a 600-character key.
2026-10-31 01:45:11 +08:00
esengine eccc45eca2 ci(windows): give the full push suite room for a slow boot package
The Windows full-suite step failed twice within a few hours on push runs, with
internal/assembly/boot at 1630s and then at 1680s, which is its 28m package
limit. The package alone can take over 25 minutes on a runner, so the limit
becomes 36m inside a 45m step budget.
2026-10-30 10:26:42 -07:00
Lancy 9b5b9dbce6 fix(studio): fade clipped tool results without coloured shadows 2026-10-31 00:43:11 +08:00
YHH d7a11e2d27 Merge pull request #14795 from jf200s/feat/element-picker
feat(studio): pick an element in the built-in browser
2026-10-30 09:25:04 -07:00
YHH f16e5e3f04 Merge pull request #14824 from chuiwenwei/fix/an-unreadable-config-is-not-a-missing-one
fix(doctor): an unreadable config is not a missing one
2026-10-30 08:27:18 -07:00
F 3e7c42bc2b fix(studio): keep verify in the picker's world and cap attributes
verify() now asks the isolated world like lands() does, the attribute map is capped at 64 entries, and a held Ctrl+Shift+C no longer re-arms the picker on every repeat.
2026-10-30 22:53:54 +08:00
YHH 613165df93 Merge pull request #14843 from kevin9327/fix/completestep-cmd-rune-boundary
fix(complete_step): cut listed commands in the citation hint on a rune boundary
2026-10-30 07:26:26 -07:00
YHH 7d261d9edd Merge pull request #14842 from kevin9327/fix/evidence-quote-short-rune-boundary
fix(evidence): cut a cited command in a rejection message on a rune boundary
2026-10-30 07:25:08 -07:00
kevin9327 fc939ca7a6 fix(complete_step): cut listed commands in the citation hint on a rune boundary
Signed-off-by: kevin9327 <[email protected]>
2026-10-30 22:30:51 +09:00
kevin9327 c9a3131e77 fix(evidence): cut a cited command in a rejection message on a rune boundary
Signed-off-by: kevin9327 <[email protected]>
2026-10-30 22:29:40 +09:00
YHH af0e954f56 Merge pull request #14841 from ShryukGrandhi/fix/issue-14413-studio-feedback-decode-branch-meta-c-use
fix(sessionstore): decode the branch-meta sidecar as UTF-8 before transcoding
2026-10-30 05:55:25 -07:00
YHH 9976474a6c Merge pull request #14825 from chuiwenwei/fix/runtime-doctor-says-unknown
fix(doctor): a runtime nobody can ask is not clean
2026-10-30 00:29:39 -07:00
ShryukGrandhi b126505b4f fix(sessionstore): decode the branch-meta sidecar as UTF-8 before transcoding
The sidecar is JSON written by json.MarshalIndent. Reading it through the
GB18030 detection cascade meant a single stray non-UTF-8 byte re-decoded the
whole file as GB18030, where a lead byte before a Windows path separator
swallowed the backslash of an escaped "\\" and left an invalid \W escape.
Decode the bytes as written first; transcode only when that fails.

Signed-off-by: ShryukGrandhi <[email protected]>
2026-10-30 00:20:57 -07:00
Will 9b6a0f3327 docs(release-notes): record #14824 2026-10-30 14:16:24 +08:00
Will 5b4cfd0607 docs(release-notes): record #14823 2026-10-30 14:16:19 +08:00
Will 74ef86d84a docs(release-notes): record #14825 2026-10-30 14:15:48 +08:00
YHH c7a36b43b1 Merge pull request #14821 from esengine/fx/android-verification-lint
fix(android): verify the 24 artifacts the release lint task resolves on CI
2026-10-29 21:59:23 -07:00
esengine 8ce6939120 fix(android): verify the 24 artifacts the release lint task resolves on CI 2026-10-29 21:30:54 -07:00
YHH 1c147780ff Merge pull request #14676 from SivanCola/fix/environment-snapshot-precedence
fix(boot): preserve environment snapshot precedence / 保持环境探测快照前缀稳定
2026-10-29 21:26:18 -07:00
YHH 50661c33db Merge pull request #14820 from esengine/fx/android-verification-aapt2
fix(android): verify the Linux aapt2 jar the CI build resolves
2026-10-29 21:21:59 -07:00
esengine 6fda6bf868 fix(android): verify the Linux aapt2 jar the CI build resolves 2026-10-29 20:53:17 -07:00
YHH ccec12753c Merge pull request #14819 from esengine/fx/android-verification-metadata
fix(android): verify the two POMs the Gradle classpath needs on CI, and store the file with LF
2026-10-29 20:47:24 -07:00
esengine 524bf552fb fix(android): verify the two POMs the Gradle classpath needs on CI, and store the file with LF 2026-10-29 20:23:46 -07:00
YHH 34bac65194 Merge pull request #14817 from esengine/fx/android-verify-token2
ci(android): let the workflow build a branch as a trial run that publishes nothing
2026-10-29 20:17:37 -07:00
Will 383178d22c fix(doctor): a runtime nobody can ask is not clean
`doctor runtime` always passed a nil BuildResult, so it read the empty
process-local DefaultRuntimeOwner and printed clean=true/cleanRollback=true as
facts about a session it could not observe. The receipt ledger is in-process
only, so a fresh CLI cannot know whether a crash or mid-migration left
irreversible work.

Report recoverability and resume as nil (JSON null) when no runtime was
available to ask; the text says unknown and names why. The BuildResult seam
still computes and prints the observed verdicts exactly as before.
2026-10-30 11:06:59 +08:00
Will 41578aff84 fix(doctor): an unreadable config is not a missing one 2026-10-30 11:03:05 +08:00
SivanCola 4cef3671b3 Merge studio into feature/model-health-ui-upstream
Problem: upstream studio changes made the model-health UI PR conflict in Composer.

Root cause: studio added WorkspaceInfo while the health hook removed the former
inline ModelEntry loading state in the same import declaration.

Fix: retain studio's workspace information, running-message intent and composer
hint behavior alongside the consent-gated health hook. Remove the unused
ModelEntry import. The grouped picker, default-off setting and old-server hints
remain unchanged; the feature diff remains 514 additions across 22 files.

Verification: production frontend build, frontend tests and census; go vet,
pinned lint and repository checks; HTTP health authorization/settings tests,
builtin tests and inventory-reconciled boot partitions.
2026-10-30 10:58:58 +08:00
esengine 38b253563c ci(android): let the workflow build a branch as a trial run that publishes nothing 2026-10-29 19:57:11 -07:00
YHH 154743d3b8 Merge pull request #14813 from esengine/fx/criteria-walk-parallel
perf(agent): read the criteria walk's directories and files concurrently
2026-10-29 19:52:51 -07:00
YHH d92e52b580 Merge pull request #14814 from esengine/fx/android-verify-token
fix(ci): the Android page check writes its token file owner-only
2026-10-29 19:52:15 -07:00
esengine 916f5943fa perf(agent): read the criteria walk's directories and files concurrently 2026-10-29 19:08:42 -07:00
esengine 7f259f2f96 fix(ci): the Android page check writes its token file owner-only 2026-10-29 18:55:28 -07:00
YHH f26fb981be Merge pull request #14812 from esengine/fx/android-script-modes
fix(ci): run the Android scripts through bash and build the kernel at the tag's version
2026-10-29 18:46:15 -07:00
esengine f93eb63929 fix(ci): run the Android scripts through bash and build the kernel at the tag's version 2026-10-29 18:25:53 -07:00
YHH a75ab7728b Merge pull request #14811 from esengine/fx/android-pnpm-order
fix(ci): set up pnpm before the Node cache that needs it in the Android workflow
2026-10-29 17:55:47 -07:00
esengine 23cb22d3cb fix(ci): set up pnpm before the Node cache that needs it in the Android workflow 2026-10-29 17:25:40 -07:00
YHH cbcc2ebb1e Merge pull request #14810 from esengine/release/studio-2.56.0
docs(release-notes): studio 2.56.0
2026-10-29 17:01:19 -07:00
F fb0b7396e3 fix(studio): bound what a pick may return
The page wrote the answer, so the shell now rebuilds it from an allow-list, clips every string to the page script's own limits, drops unknown fields and treats a shape it cannot read as gone. A rect that is not numbers is dropped; a negative origin is clamped by the screenshot.
2026-10-30 07:56:06 +08:00
esengine 674c2b45e7 docs(release-notes): studio 2.56.0 2026-10-29 16:25:53 -07:00
YHH ec6929c157 Merge pull request #14808 from esengine/fx/parity-scheduled
docs(parity): scheduled runs exist in Studio; the 1.x heartbeat loop is the gap
2026-10-29 14:09:01 -07:00
esengine dcd4135049 docs(parity): scheduled runs exist in Studio; the 1.x heartbeat loop is the gap 2026-10-29 13:56:57 -07:00
YHH 08ea935ea9 Merge pull request #14784 from lancy69/feat/14319-recent-sessions
feat(studio): add globally paged recent session discovery
2026-10-29 12:58:29 -07:00
YHH 2a3a3738db Merge pull request #14801 from lancy69/fix/12303-yielded-claim-evidence
fix(agent): preserve unknown scope across yielded workspace claims
2026-10-29 12:28:51 -07:00
YHH d1eb9c968e Merge pull request #14800 from lancy69/fix/12080-workspace-startup-read
fix(studio): share pane startup workspace metadata
2026-10-29 12:27:39 -07:00
YHH a2597fb17c Merge pull request #14805 from xxoingr/fix/worktree-commands-one-at-a-time
fix(worktree): run one repository's worktree commands one at a time
2026-10-29 12:26:40 -07:00
YHH 41ea14ccc9 Merge pull request #14806 from esengine/fx/import-orphan-index
fix(sessionstore): stale indexes with no transcript no longer make every 1.x import copy_failed
2026-10-29 11:58:11 -07:00
esengine b3bea0d076 fix(sessionstore): stale indexes with no transcript no longer make every 1.x import copy_failed 2026-10-29 11:29:54 -07:00
YHH efff0a825f Merge pull request #14803 from xxoingr/fix/earlier-version-delete-shows
fix(studio): an earlier version in the sidebar shows its delete button
2026-10-29 11:25:38 -07:00
xxoingr 44712c3aad fix(worktree): run one repository's worktree commands one at a time
`git worktree add` creates the new entry's directory under
<common>/worktrees before it writes the entry's files, and every worktree
command resolves each entry's HEAD, reading its commondir and dying on one
it finds empty: "failed to read .../commondir: Success". Two isolated
tasks started in parallel each create a candidate from the same
repository, and on a loaded Linux runner the second add read the first's
entry in that window (TestPreparationConcurrentCandidatesDoNotShareResultState,
run 40689344657).

Every `git worktree` command in the package now goes through runWorktree,
which holds one gate per repository, keyed by its common Git directory,
and gives up waiting when its context ends.
2026-10-30 02:09:35 +08:00
YHH d188af4207 Merge pull request #14783 from lancy69/feat/14699-scheduled-appearance
feat(studio): add a daily dark-mode schedule
2026-10-29 10:59:14 -07:00
xxoingr 8a89f0874b fix(studio): an earlier version in the sidebar shows its delete button
An earlier version or recovery copy has no row menu; its × is the only
way a pointer deletes it. `.rail .sessrow > .wsdel { display: none }`
came in with the Studio rail when session rows moved deletion into their
menu, and since then the copy row is the only one that renders .wsdel,
so the rule hid exactly that button. The hover, focus-within and
hover:none rules beside it were already there to show it.

perf/sessionrows.mjs now puts the button on its earlier-version row and
holds it shown and inside the row on hover and on keyboard focus.
2026-10-30 01:47:47 +08:00
Lancy 350379ffff feat(studio): add globally paged recent session discovery 2026-10-30 01:27:28 +08:00
Lancy 65ecbce661 feat(studio): add a daily dark-mode schedule 2026-10-30 01:19:11 +08:00
Lancy 2656f33f28 fix(agent): preserve unknown scope across yielded workspace claims 2026-10-30 01:13:00 +08:00
Lancy e31c2347b4 fix(studio): share pane startup workspace metadata 2026-10-30 01:05:03 +08:00
F 74c2f11209 feat(studio): pick an element in the built-in browser
The shell half: the in-page picker script, pick / cancelPick / verify on BrowserViews, the three IPC handlers and preload methods, and the unit and live suites. Inert until a control calls it.
2026-10-30 00:27:39 +08:00
YHH be65b117e0 Merge pull request #14493 from imcreazy/ci/android-release
ci(android): build the APK on a tag and attach it to the release
2026-10-29 08:52:55 -07:00
imcreazy 19f05e8509 Attach the APK only when it is signed, and wait long enough for the release
An unsigned package cannot be installed at all, so publishing one put an
unusable file on every release page. The wait was also shorter than the
release actually takes.
2026-10-29 23:11:20 +08:00
YHH 4fde96dccc Merge pull request #14781 from lancy69/fix/14759-dim-composer-placeholder
feat(studio): add a default-on composer hint visibility setting
2026-10-29 07:39:33 -07:00
YHH 9c38dd1129 Merge pull request #14771 from 1012905/fx/session-remove-many-kernel
feat(serve): 通过 POST /tree/sessions/remove-many 批量删除会话
2026-10-29 07:20:19 -07:00
Lancy 355e047c27 feat(studio): add a default-on composer hint visibility setting 2026-10-29 22:07:16 +08:00
1012905 0abc13d3c0 chore(release-notes): name the pull request in the batch-remove fragment
The fragment lost its issue reference when the lines were cut to one
sentence each, and the release-note lint asks every change to name the
issue or pull request it belongs to. Both lines now end with the
reference, and both stay under the 200-column limit.
2026-10-29 21:42:55 +08:00
YHH 4055d9e404 Merge pull request #14791 from 1012905/fx/settings-search-storage-race
test(desktop): the settings search is measured without the storage tally
2026-10-29 06:40:23 -07:00
1012905 ddd0db3ee7 feat(serve): batch session deletion via POST /tree/sessions/remove-many
Kernel half of session batch delete. The handler validates the whole batch up front (bad path -> 400, empty -> 400), refuses a running session (409), a leased transcript (409), and a path outside the adopted workspace (403), then erases all guarded sessions.

No desktop UI in this change; the management view lands in a follow-up PR.

Go tests cover the handler, including two new edge cases:
- a path inside the session dir but belonging to another project (refused wholesale, 403, the in-workspace file left untouched);
- a running session in the middle of the list (refused with 409, nothing erased before the refusal).

Cuts the release-note fragment to one sentence per line and drops the
self-reference, so each line stays within the 200-column limit.
2026-10-29 21:02:04 +08:00
1012905 ba95fc74f1 test(desktop): the settings search is measured without the storage tally
The settings pane schedules a tally of the disk usage a beat after it opens,
and that walk reads the port. The search test measures the port as it stood
before the search, and reads it again afterwards to prove the search read
nothing. When the two moments fell more than a beat apart — which the full
suite's load makes likely — the tally landed in between and the test failed
on a read the search never asked for.

Seeding the tally before the pane opens means no walk is scheduled at all, so
the search is measured against the port as it stood, with nothing else reading
it on the way past.

The failing assertion is unchanged: the search still has to read nothing.
2026-10-29 20:40:20 +08:00
YHH 1da925d0f9 Merge pull request #14790 from esengine/fx/shortcut-cards-order
fix(studio): Esc-to-stop sits with the send settings on the Shortcuts page
2026-10-29 05:14:47 -07:00
esengine eaf5329101 fix(studio): Esc-to-stop sits with the send settings on the Shortcuts page 2026-10-29 04:59:32 -07:00
YHH dc659c7509 Merge pull request #14788 from esengine/fx/full-installer-link
fix(studio): the full-installer link after a failed update opens the system browser
2026-10-29 04:48:06 -07:00
esengine 310567fd08 fix(studio): the full-installer link after a failed update opens the system browser 2026-10-29 04:31:49 -07:00
YHH 77059988f8 Merge pull request #14780 from lancy69/docs/14603-cross-project-dispatch
docs: explain cross-project task dispatch
2026-10-29 04:23:39 -07:00
YHH 4b87d87883 Merge pull request #14774 from kevin9327/fix/worktree-stderr-rune-boundary
fix(worktree): cut git stderr in error messages on a rune boundary
2026-10-29 03:54:40 -07:00
Lancy b8997b34eb docs: explain cross-project task dispatch 2026-10-29 18:38:40 +08:00
YHH 3c6ad78273 Merge pull request #14773 from kevin9327/fix/recovery-task-summary-runes
fix(control): bound the fallback recovery task summary by runes
2026-10-29 03:23:41 -07:00
kevin 2cbd60e326 fix(worktree): cut git stderr in error messages on a rune boundary
stderrSuffix capped git's stderr at 500 bytes with a plain slice, so a localized
(multi-byte) git message was split mid-character and the error ended in invalid
UTF-8. Back the cut up to a rune start.
2026-10-29 19:00:58 +09:00
YHH 7b41ec8a08 Merge pull request #14769 from chuiwenwei/test/every-aggregate-field-declares-its-provenance
test(stats): every aggregate field declares its provenance
2026-10-29 02:40:34 -07:00
kevin 9be610d41d fix(control): bound the fallback recovery task summary by runes 2026-10-29 18:31:57 +09:00
YHH d16063929c Merge pull request #14726 from SivanCola/codex/worktree-apply-ancestor-boundary-14025
fix(worktree): guard apply ancestors and confine deletions / 防止父目录链接重定向隔离结果删除
2026-10-29 01:53:20 -07:00
YHH 0ab4329305 Merge pull request #14748 from lancy69/feat/4262-line-code-references
feat(control): support precise @path:start-end file references
2026-10-29 01:51:26 -07:00
YHH 692a46d8d3 Merge pull request #14768 from jf200s/fix/fork-inherit-compaction
fix(control): a fork inherits its source's context projection
2026-10-29 01:50:05 -07:00
YHH 8066440bb7 Merge pull request #14749 from lancy69/fix/14738-stale-mcp-removal
fix(control): remove stale MCP entries without losing source authority
2026-10-29 01:48:35 -07:00
YHH aa09229ab2 Merge pull request #14750 from lancy69/feat/11915-running-message-default
feat(studio): choose steer or follow-up as the running-message default
2026-10-29 01:47:11 -07:00
Lancy 87d59a1816 feat(control): resolve precise line-range file references 2026-10-29 15:03:12 +08:00
Lancy f98edeec7b fix(control): reconcile MCP entries after their source disappears 2026-10-29 14:55:25 +08:00
Lancy cab93a8b02 feat(studio): choose default intent for messages sent mid-run 2026-10-29 14:46:40 +08:00
F 2b517203be docs(release-notes): name the fragment after #14768
Refs #14768
2026-10-29 14:39:31 +08:00
Will 08a1288603 test(stats): every aggregate field declares its provenance 2026-10-29 14:38:30 +08:00
F e5eb7cbee2 docs(release-notes): point the fragment at the rebased commit
Refs 02bf796dc
2026-10-29 14:35:54 +08:00
F d5db1cc446 docs(release-notes): a fork keeps its source's compaction
Refs 4566c19b8
2026-10-29 14:34:49 +08:00
F 02bf796dcc fix(control): a fork inherits its source's context projection
A conversation branched from a compacted session reopened on the full
history. createBranchPrefix copied the transcript, the branch metadata and
the checkpoints but never the projection sidecar, and resumeInto binds with
loadSidecar=true, so the fork found no sidecar and visibleByFullScan handed
the model the whole transcript. Recovery forks and 1.x continuations already
go through InheritCompactionState; the user-facing branch path never called it.

BranchAt now inherits inside the branch's own save window, next to the
checkpoint copy, so a branch is never published with a fold it cannot read.
Controller.Branch, which swaps panes in place, binds with loadSidecar=true so
the same-pane path loads what it just inherited.

The inherit helper keeps its fail-soft contract: no source sidecar, a corrupt
or unsupported one, a projection whose covered prefix reaches past the fork
point, and a failed write all leave the fork on the full history. Recall
budget, blocked fields and a non-applied receipt belong to the source
session's runtime generation, so the copy clears them; the projection,
generation, lineage key and an applied receipt still travel.
2026-10-29 14:34:49 +08:00
YHH 396b2c6b6d Merge pull request #14764 from esengine/fx/browser-open-steer
fix(tools): tell the model browser_open is visible and web_fetch reads static pages
2026-10-28 23:10:45 -07:00
YHH 79a2232e55 Merge pull request #14763 from esengine/fx/remote-link-linger
fix(serve): hold a request's remote link past its answer
2026-10-28 22:59:56 -07:00
YHH af1628b8ea Merge pull request #14747 from lancy69/feat/14466-collapsed-plan-progress
feat(studio): optional progress track for collapsed plans
2026-10-28 22:51:48 -07:00
YHH 73da13ee39 Merge pull request #14746 from lancy69/feat/14724-unread-completion-marker
feat(studio): show green unread markers instead of session dates
2026-10-28 22:47:27 -07:00
YHH 955474f6fc Merge pull request #14677 from SivanCola/codex/carried-proof-file-state-14025
fix(agent): invalidate carried proof after file-mode changes / 文件模式变化时拒绝旧证明
2026-10-28 22:42:41 -07:00
YHH 1ba8ed87cb Merge pull request #14757 from chuiwenwei/feat/bench-batches-emit-an-auditable-manifest
feat(e2ebench): a batch emits an auditable manifest
2026-10-28 22:42:03 -07:00
esengine 181e697147 fix(tools): tell the model browser_open is visible and web_fetch reads static pages 2026-10-28 22:36:18 -07:00
esengine bccf76ab7e fix(serve): hold a request's remote link past its answer 2026-10-28 22:17:27 -07:00
YHH 8a1fda41a7 Merge pull request #14730 from SivanCola/codex/reported-total-token-budget-14025
fix(agent): count reported totals in token budgets / 将已报告总量计入 token 预算
2026-10-28 21:57:59 -07:00
YHH da91464adb Merge pull request #14684 from SivanCola/codex/ancestor-copy-admission-14025
fix(delegation): reserve ancestor copy admission / 预留祖先副本续跑准入
2026-10-28 21:57:19 -07:00
YHH 1619856458 Merge pull request #14761 from esengine/docs/release-notes-2.55.0
docs(release-notes): studio 2.55.0
2026-10-28 21:49:09 -07:00
esengine 51eb1bb24f docs(release-notes): studio 2.55.0 2026-10-28 21:37:37 -07:00
YHH 812e405a8d Merge pull request #14735 from esengine/fx/sandbox-git-bash
fix(sandbox): a Windows session passes over a Git Bash that cannot start under the token
2026-10-28 21:35:53 -07:00
YHH 17fe3c7110 Merge pull request #14734 from kevin9327/fix/gitignore-dotdot-names
fix(gitignore): treat a root name starting with .. as inside the repo
2026-10-28 21:35:15 -07:00
YHH c58b1242c8 Merge pull request #14729 from chuiwenwei/fix/register-the-unguarded-wire-mirrors
fix(repolint): register the wire mirrors that had no guard
2026-10-28 21:34:37 -07:00
YHH 90f2ad2c3b Merge pull request #14727 from chuiwenwei/fix/an-unprovable-lease-is-its-own-state
fix(sessionstore): an unprovable lease is its own answer
2026-10-28 21:33:58 -07:00
YHH 9c973fae65 Merge pull request #14716 from chuiwenwei/fix/restore-keeps-an-image-only-turn
fix(tui): a restored image-only turn is still a turn
2026-10-28 21:33:20 -07:00
Will ce2c3acd44 docs(release-notes): record #14757 2026-10-29 09:39:05 +08:00
kevin 5b7f6a3057 fix(gitignore): shorten the infoExcludeFile comment to the repolint limit 2026-10-29 07:55:44 +09:00
octo-patch 03c8dacd0a fix(minimax): preserve discovered model ids 2026-10-29 01:34:13 +08:00
Lancy 6202465e3e feat(studio): optionally show progress on collapsed plans 2026-10-28 16:48:34 +00:00
Lancy bb2a71f959 feat(studio): replace unread session dates with green markers 2026-10-28 16:47:14 +00:00
YHH 7dec45ad05 Merge pull request #14711 from lancy69/fix/14539-isolated-test-config-locks
fix(testenv): isolate config locks from the real user home
2026-10-28 05:37:27 -07:00
kevin 9a89dc2d34 fix(gitignore): treat a root name starting with .. as inside the repo 2026-10-28 21:19:32 +09:00
esengine 6ac89de76e fix(sandbox): a Windows session passes over a Git Bash that cannot start under the token 2026-10-28 05:19:02 -07:00
YHH 54c5d01574 Merge pull request #14733 from esengine/fx/primary-action-ink
fix(studio): primary actions in settings keep their paired text colour
2026-10-28 05:16:54 -07:00
YHH 8412374533 Merge pull request #14709 from lancy69/fix/14607-rename-dialog-typography
fix(studio): use semantic typography in session rename dialog
2026-10-28 05:04:12 -07:00
YHH 35e79a8d46 Merge pull request #14708 from lancy69/fix/14600-sticky-project-header
fix(studio): keep project controls outside the scrolling tree
2026-10-28 05:03:24 -07:00
YHH b554637a26 Merge pull request #14707 from lancy69/fix/14601-settings-button-height
fix(studio): align settings header button heights
2026-10-28 05:02:38 -07:00
YHH 3e269c1362 Merge pull request #14732 from esengine/fx/graphics-on-window-page
feat(studio): graphics rendering lives on the window and performance page
2026-10-28 05:01:52 -07:00
YHH 02bf511ba2 Merge pull request #14731 from esengine/fx/menu-over-plan
fix(studio): composer menus open above the plan bar
2026-10-28 05:01:06 -07:00
Will 74ad7e5f39 feat(e2ebench): a batch emits an auditable manifest
A batch printed its aggregates and kept nothing they could be recomputed
from, so a published figure could not be tied to the runs behind it after
the temp directory was gone. -batch DIR now writes results.jsonl (one row
per attempt) beside batch.json (manifest v1), and -mode audit re-derives
the report's headline figures from those rows with the harness's own
aggregation, printing the claim beside the recomputation and naming every
figure that moved.

The manifest records what the figures are over (command axes, a task-id
digest, filters, and a denominator defined in tasks), which run produced
them (commit or an explicit unknown, model/provider names, seed when
known, timestamps, harness and runtime), and why they can still be
checked (results_sha256, the claimed figures, and a ledger of planned /
attempted / completed / scored / failed / unmeasured / excluded that the
audit checks for consistency). Absent figures are omitted and read as
unknown, never as zero.

This is an aggregation audit, not a replay: it proves the published
figures follow from the retained rows. It does not re-execute the model,
tools or environment, and a real replay (commit, corpus fingerprint,
model/provider configuration, seed, flags, raw results, runtime version)
is not implemented.
2026-10-28 19:52:12 +08:00
esengine 25a97bb683 fix(studio): primary actions in settings keep their paired text colour 2026-10-28 04:46:02 -07:00
esengine 972e910722 feat(studio): graphics rendering lives on the window and performance page 2026-10-28 04:31:11 -07:00
esengine a0f197a535 fix(studio): composer menus open above the plan bar 2026-10-28 04:22:51 -07:00
YHH f665a00794 Merge pull request #14700 from esengine/fx/settings-no-page-header
feat(desktop): drop the title block above each settings page
2026-10-28 04:04:57 -07:00
YHH acb47daee9 Merge pull request #14686 from esengine/fx/activity-title-size
fix(studio): the Execution row uses the same text size as the Thinking row
2026-10-28 04:03:58 -07:00
SivanCola fbf0b99ccd fix(agent): count reported totals in local and root token budgets
Problem: Accepted total-only usage remained visible to reporting but did not
cross token budgets, allowing additional tool rounds after the known spend.

Root cause: Both local and shared-root enforcement added only input/output
components, so missing or partial splits hid a known reported total.

Fix: Use one accounting helper for the larger of the nonnegative total and
component sum, and saturate both counters. Keep usage, pricing, shadow component
telemetry, context ownership and existing soft-gate/finalization behavior.

Verification: Real OpenAI HTTP/SSE adapter and Boot execute real file tools.
Final19named regressions pass; exact preceding owners fail10 events with9controls.
Root240packages once:24330named passes/215existing skips. Complete Agent race
2162pass/10existing skips; focused Boot race7pass. Vet, pinned lint, check, format,
workflow and release gates pass; four Win/Linuxamd64 checks compile only.
2026-10-28 18:36:33 +08:00
Will 9b666634e6 docs(release-notes): record #14727 2026-10-28 17:11:54 +08:00
Will 40ab228ab6 docs(release-notes): record #14716 2026-10-28 17:11:54 +08:00
SivanCola 19c7b76de1 fix(worktree): reject redirected apply ancestors and confine deletion
Problem: An isolated candidate deletion could follow a source directory link
and remove a file outside the repository while reporting success.

Root cause: Leaf comparison followed ancestor links and publication deleted
through an ordinary pathname, without an ancestor precondition or held root.

Fix: Check ancestors before path-by-path leaf hashing and before publishing
deletions, and delete through an open repository root. Preserve whole-workspace
file-to-directory conversion and the Store's existing same-ID conflict retry.
Extend the existing Windows isolation inventory with junction regressions.

Verification: Final root inventory covers 240 packages once with 24324 named
passes and 215 existing skips. Both required package commands exit zero.
Complete worktree/isolation race has 242 passes and no skips. The final focused
13 names pass; the exact preceding owner fails 9 events with 4 controls.
Vet, pinned lint, check, format, workflow and release-note gates pass. Windows
and Linux amd64 compile checks pass; native hosted verification is pending.
2026-10-28 17:00:26 +08:00
Will d803f11506 fix(migrate): an unreadable source is not an empty one
The implicit legacy-session import passed no report, so a ReadDir failure
became a no-op skip and returned (0, nil). /migrate then told a user whose
legacy sessions directory existed but could not be read that there was
nothing to migrate.

- sessionstore: return a typed LegacySourceError (a SkippedSession plus the
  cause) from the implicit path; an absent directory stays the silent no-op.
  The explicit --from path keeps reporting through its LegacyReport.
- migration: /migrate names the directory and the read failure; the summary
  counts it as a warning instead of "no legacy data needed migration".
- boot: unchanged, stays quiet — the boot caller is non-verbose, so the new
  error is recorded but not emitted, exactly like every other failed import.
- markers: the failure returns before the marker write, so a source that was
  never read is never recorded as imported; a repaired source imports and
  stamps on the retry.
2026-10-28 14:55:17 +08:00
YHH 70b90453a5 Merge pull request #14656 from SivanCola/codex/subagent-status-admission-14025
fix(delegation): refuse unknown saved run states / 拒绝未知子任务恢复状态
2026-10-27 23:34:23 -07:00
YHH 7205271b07 Merge pull request #14661 from SivanCola/codex/goal-usage-delegation-scope-14025
fix: retain delegated Goal usage ownership / 保留委派任务的 Goal 用量归属
2026-10-27 23:33:26 -07:00
YHH a6ffc30339 Merge pull request #14667 from lancy69/feat/14649-adjustable-viewport
feat(studio): add adjustable conversation viewport width
2026-10-27 23:32:55 -07:00
YHH f3e3ac9f2e Merge pull request #14654 from esengine/fx/openwith-explains-inside-project
feat(openwith): say why an installed program is not offered
2026-10-27 23:32:35 -07:00
SivanCola f133704176 fix(ci): make proof fixture independent of host sandbox availability
Problem: Linux coverage could not establish the carried-proof fixture;
CodeQL also flagged the shared inventory parser for exponential backtracking.
Root cause: the fixture inherited an unavailable host bash sandbox, and
repeated name-suffix groups could repartition the same slash sequence.
Fix: select sandbox off only in the isolated test user configuration;
parse the entire optional suffix once and add a bounded long-input test.
Verification: required builtin/Boot exit0, focused coverage four named
passes, 14-name shared contract, two Node tests, vet, pinned lint and check.
Original regex fails the bounded regression with one passing control.
No production Go, assertion, workflow, timeout, skip or baseline change.
Hosted Linux coverage, CodeQL and own-head Windows evidence remain due.
2026-10-28 14:28:12 +08:00
SivanColaandesengine ac956885c1 fix(deps): sync upstream x/net security update
Problem: The govulncheck job on #14676 reports GO-2026-6860 through
the HTML parser used by the price-checking tool.

Root cause: This branch still selects golang.org/x/net v0.61.0, although
upstream #14685 has already upgraded both Go modules to the fixed version.

Fix: Apply upstream commit 80ebd88bf7
unchanged, selecting x/net v0.62.0 in the root and desktop modules.
No workflow bypasses or suppression rules are introduced.

Verification: govulncheck v1.9.0 with Go 1.26.10 exits successfully for
both modules with zero reachable vulnerabilities. Desktop build, network
and environment package tests, full builtin/boot and pricecheck tests,
go vet, make lint, make check and whitespace checks pass. The staged
four-file patch is byte-identical to the upstream commit.

Co-authored-by: esengine <[email protected]>
2026-10-28 14:23:41 +08:00
SivanCola 382016dfe1 fix(ci): avoid backtracking in concurrency inventory names
Problem: CodeQL reports exponential backtracking in the shared Windows
inventory test-name parser.
Root cause: repeated suffix groups can partition the same slash sequence.
Fix: accept the entire optional non-whitespace suffix once, preserving the
previous accepted language. Cover nested names and a long malformed suffix.
Verification: two Node tests pass; the original regex fails the bounded
regression while the existing contract test passes. Go and inventory bytes
are unchanged; prior exact-head Go qualification remains separate.
2026-10-28 14:18:44 +08:00
kevin9327 fc63bc00a6 fix(gitignore): read the gitdir, commondir and HEAD pointers through the injected reader
infoExcludeFile now reads .git, commondir and HEAD with the same reader
At uses for the ignore files, instead of its own os.Stat/os.ReadFile calls.
A .git directory or a missing file reads as nothing, so the plain-repository
path is unchanged, and a pointer is still followed only to a directory with
a non-empty HEAD file. This drops the new path sinks CodeQL flagged.
2026-10-28 15:13:11 +09:00
Will 4fefec819c fix(sessionstore): an unprovable lease is its own answer
SessionLeaseHeldByOtherRuntime folded every lease-lock probe failure into
"held", so a lock it could not open became the same answer as a live holder.
Redaction then skipped a session nobody held and reported an active lease,
leaving the secret scrub silently incomplete; session and task listings
claimed active/running on the same non-evidence.

Add ErrSessionLeaseUnprovable beside ErrSessionLeaseHeld and expose
SessionLeaseOwnership as the three-state probe. Only ErrSessionLeaseHeld
proves a holder; any other lock failure is unprovable with its cause kept.
The bool wrappers keep their old "may be busy" answers, so callers that only
skip work are unchanged.

Redaction refuses an unprovable session, reports it as a skipped file with
the reason (and exits non-zero), and never calls it an active lease. Machine
session state and running task/subagent status become unknown when ownership
cannot be proven; held and free keep their current answers.
2026-10-28 13:56:02 +08:00
Will c1079d99ba fix(repolint): register the wire mirrors that had no guard
The registry declares 324 pairs now: the 202 it already had and 122 page
copies that agree field for field with the struct their handler writes. The
table moved to a second file, wireparity_table.go, rather than taking
wireparity.go past the 800-line ceiling; the checker still reads one list.

Left out on purpose, because the writer is not a named struct and a guard
over the lookalike struct would not see the response drift:
account.DeviceGrant (/account/login assembles a map), market.Page
(/market/packages assembles a map) and control's McpDraft (/mcp/parse
assembles a map). host.ts's Refusal and ViewRect are the Electron preload's
bridge, not kernel wire; workspace.ts's WorkspaceEntry has no writer.

Tests: the added half resolves, is exact today and is unique per reader; a
dropped field on createBackupRequest -> BackupCreateRequest names the field.
2026-10-28 13:55:00 +08:00
Will 6c1f68df48 fix(tui): a restored image-only turn is still a turn
The terminal's hand-written HistoryMessage dropped images, via and modelRef.
A turn that was only an image has empty text, so Restore saw nothing to draw
and dropped it from the transcript, and every reload lost the device and the
model of turns it did keep. Carry the fields that decide that a turn exists
and what it was, restore the image-only row with an honest line, and register
the pair in repolint so a dropped field fails make check.
2026-10-28 13:43:20 +08:00
Lancy 9e68ee8f25 fix(testenv): isolate config locks from the real user home 2026-10-28 11:43:21 +08:00
Lancy 74f0bf4dc9 fix(studio): use semantic typography in session rename dialog 2026-10-28 11:34:35 +08:00
Lancy 9ce320d1c2 fix(studio): keep project controls outside the scrolling tree 2026-10-28 11:34:06 +08:00
Lancy 8ccc09925f fix(studio): align settings header button heights 2026-10-28 11:34:06 +08:00
YHH da10d503d5 Merge branch 'studio' into codex/subagent-status-admission-14025 2026-10-27 20:32:52 -07:00
YHH 96cdd810da Merge branch 'studio' into codex/goal-usage-delegation-scope-14025 2026-10-27 20:32:17 -07:00
YHH d2f94a6bc3 Merge branch 'studio' into feat/14649-adjustable-viewport 2026-10-27 20:31:43 -07:00
YHH ccd0f0ca9f Merge branch 'studio' into fx/openwith-explains-inside-project 2026-10-27 20:31:09 -07:00
YHH a001b16563 Merge pull request #14685 from esengine/fx/xnet-0.62
fix(deps): bump golang.org/x/net to v0.62.0 for GO-2026-6860
2026-10-27 20:30:52 -07:00
YHH cc08d5552b Merge pull request #14619 from SivanCola/fix/studio-worktree-labels
fix(studio): clarify worktree labels and directory choices / 简化工作树标识并保留目录模式选择
2026-10-27 20:09:28 -07:00
YHH bf42f9ad83 Merge pull request #14494 from SivanCola/fix/isolation-prepared-candidates-14025
feat(isolation): prepare Store-owned candidates / 由结果 Store 统一准备候选
2026-10-27 20:08:52 -07:00
YHH e511357b6d Merge pull request #14446 from SivanCola/codex/opaque-write-admission-14025
feat(mutation): coordinate opaque writes across roots / 未知范围写入跨根互斥
2026-10-27 20:08:14 -07:00
YHH 79b96679e0 Merge pull request #14459 from SivanCola/codex/process-root-wait-14025
feat(proc): expose root wait completion / 区分根等待完成与取消返回
2026-10-27 20:07:37 -07:00
YHH 6d29079a09 Merge pull request #14549 from chuiwenwei/fix/doctor-catalogs-stops-saying-ok
fix(doctor): a catalog that recorded a failure is not ok
2026-10-27 20:07:00 -07:00
Sivan 787886669f docs(ci): clarify carried proof limits and share Windows inventory checks 2026-10-28 11:04:54 +08:00
SivanCola 8378f0834c fix(delegation): identify busy ancestor continuations and share Windows checks 2026-10-28 11:03:31 +08:00
SivanCola 286106c0e7 docs(boot): disclose environment snapshot prefix correction
Problem: Release notes did not describe the snapshot ownership fix or its
possible one-time cache-prefix change for previously affected sessions.
Root cause: The impact was documented only in the PR and user guide.
Fix: Add Chinese and English unreleased entries preserving the first-request
cache-reuse caveat requested during review.
Verification: Release-note rendering/reference tests (12) and make check
pass; the staged diff contains only the two-line documentation fragment.
2026-10-28 11:00:51 +08:00
esengine 600bd55461 feat(desktop): drop the title block above each settings page
The navigation already shows the section and page, so the first card now sits
at the top of the page. The setting count and the section's value stay in the
navigation row.
2026-10-27 19:54:48 -07:00
esengine dc92a55618 fix(studio): the Execution row uses the same text size as the Thinking row 2026-10-27 19:43:13 -07:00
SivanCola 7142f95dba feat(studio): add opt-in health chips to grouped model picker
Problem: Saved connections lack health evidence when selecting a model, while
automatic credential-bearing checks require an explicit user opt-in.
Root cause: The previous UI slice also redesigned the disabled picker, hiding
account endpoint headings and reducing density outside the requested scope.
Fix: Preserve the original grouped rows, hosts, ordering and modelLabel behavior.
Add only short accessible health chips after reading backend consent. Put the
global switch outside the service editor, and explain unsupported older servers
and settings-read failures without enabling probes. Rebase onto current studio.
Verification: 640 frontend tests, production build, census fixtures, vet, pinned
lint, repository checks, HTTP authorization/route tests and builtin tests pass.
All 849 boot cases accounted for: 846 pass, 3 skip. Negative controls fail the
group-layout and old-server regressions. Native Electron light/dark wide/narrow
captures and 390px browser inspection verify retained row density and hosts.
Native disable/reopen issues no new requests; no generation requests occur.
2026-10-28 10:36:23 +08:00
esengine 80ebd88bf7 fix(deps): bump golang.org/x/net to v0.62.0 for GO-2026-6860 2026-10-27 19:33:39 -07:00
YHH 8ae73f9332 Merge pull request #14659 from esengine/fx/installer-closes-running-studio
feat(installer): close a running Studio thoroughly and say how when it cannot
2026-10-27 19:31:57 -07:00
SivanCola 7352385cc6 fix(delegation): reserve ancestor copy admission before publication
Problem: Competing continuations could create two copies of one ancestor in
the same parent session, leaving later continuation ambiguous.

Root cause: The source ref lock ended before a newly allocated copy published
metadata, and each preparation acquired a different destination ref lock.

Fix: Reserve the canonical nearest-source and target-parent pair before
searching for copies. Transfer the reservation to the returned run's existing
release lifetime, and release it on failed preparation. Preserve independent
parents, sources, current-reference locks and persisted formats.

Verification: Real TaskTool publication interleavings and Store regressions;
exact old-owner overlay fails 9 named events with 5 controls. Root 240-package
coverage, delegation/sessionstore race, vet, pinned lint, make check,
actionlint and Windows/Linux compile-only checks pass. Windows named-step
inventory validation rejects missing package completion and skipped cases.

Refs #14025
2026-10-28 10:29:45 +08:00
Lancy 6ade8b3238 feat(studio): add adjustable conversation viewport width 2026-10-28 09:16:34 +08:00
kevin9327 b3c6f06533 fix(gitignore): only follow gitdir and commondir pointers to a directory with HEAD 2026-10-28 09:21:36 +09:00
esengine 87fb22211d docs(release-notes): studio 2.54.0 2026-10-27 17:19:09 -07:00
YHH cbdc515ce7 Merge branch 'studio' into fx/installer-closes-running-studio 2026-10-27 16:51:12 -07:00
YHH fb3e8535e7 Merge branch 'studio' into fx/openwith-explains-inside-project 2026-10-27 16:50:39 -07:00
SivanCola 1117fb2eca fix(agent): bind carried proof to regular file state
Invalidate persisted Delivery proof when file mode changes without a byte edit.
Use the existing content-version reader and verify surrounding identity, mode,
size and mtime observations. Refuse links, directories and special files as
regular-file proof inputs.

Version fingerprint values so legacy byte-only proof conservatively requires
fresh evidence in both reader directions, while goals and transcripts remain
readable. Cover real Boot persistence/rebuild, compatibility and Unix kinds,
and add a required native Windows inventory with bilingual documentation.

Refs #14025
Refs #14133
2026-10-28 05:05:30 +08:00
SivanCola f181b32fcc fix(boot): preserve each data home's environment snapshot
Problem: A rebuild can render another data home's environment probe answer
instead of its own still-valid persisted prefix, invalidating compaction.

Root cause: Process-cache lookup preceded durable snapshot lookup, while
shared-result adoption left existing snapshots untouched and did not merge
transient failures against previous successful observations.

Fix: Prioritize fresh trusted snapshots and merge/persist shared refreshes
at the environment owner. Preserve the snapshot format and trust checks.
Keep this prefix change separate from the remote-project UI as requested
in the review of #14615.

Verification: All 219 root Go packages with tests passed on this independent
branch, including full boot; environment race, go vet, make lint, formatting
and diff checks passed. Public-probe and cross-process boot regressions fail
with the original production implementation and pass with this fix. The
split patches reconstruct the combined source with an identical Git tree.
2026-10-28 04:21:27 +08:00
SivanCola 430aea04a1 ci: run status admission inventory before broad Windows smoke
Preserve every workflow step, condition, timeout and assertion. Move the
status-owned pair away from concurrently added budget inventories so both
sets compose, and collect the named evidence before broad smoke can fail.

Refs #14656
2026-10-28 03:57:35 +08:00
SivanCola 7972a60da7 fix(event): preserve quote and scope capabilities together
Carry the accepted UsageQuoter event owner alongside Goal usage scope capture.
Retain both optional capabilities in wrappers and their shared inventory while
keeping scope-specific fixture additions independent of the quote additions.

Verify that nested bound usage reaches the occurrence-time quote owner once,
keeps the earlier Goal attribution, and preserves the emitted JSON. Document
the two owners and retain existing Windows required-test coverage.

Refs #14661
Refs #14671
2026-10-28 03:53:07 +08:00
YHH f00e8ac590 Merge pull request #14672 from esengine/fx/global-shortcut-page
fix(studio): put global-shortcut on a settings page and keep Composer under the size ceiling
2026-10-27 12:01:37 -07:00
YHH af176c59f3 Merge branch 'studio' into fx/global-shortcut-page 2026-10-27 11:34:12 -07:00
YHH cd9c8723de Merge pull request #14551 from chuiwenwei/fix/acp-inbox-refusals-carry-codes
fix(acp): an inbox refusal says which refusal it is
2026-10-27 11:22:28 -07:00
esengine 61bbcd5a16 feat(installer): close a running Studio thoroughly and say how when it cannot 2026-10-27 10:58:38 -07:00
YHH edefe37198 Merge pull request #14371 from SivanCola/codex/worktree-snapshot-content-14025
fix(worktree): capture current bytes and refuse stale apply / 修复快照旧内容与过期整合覆盖
2026-10-27 10:57:26 -07:00
YHH 7059b4ac45 Merge pull request #14631 from SivanCola/codex/skill-entry-root-budget-14025
fix(control): share root budget with explicit skills / 显式技能共享任务根预算
2026-10-27 10:56:48 -07:00
esengine e210f9be5e fix(studio): put global-shortcut on a settings page and keep Composer under the size ceiling 2026-10-27 10:47:07 -07:00
YHH bfbb153ab3 Merge pull request #14522 from chuiwenwei/fix/a-total-says-how-many-it-could-not-price
fix(stats): a total says how many calls it could not price
2026-10-27 10:26:13 -07:00
YHH ffa0911a56 Merge pull request #14671 from SivanCola/codex/authoritative-local-cost-budget-14025
fix: use authoritative local cost quotes / 本地费用门禁采用权威报价
2026-10-27 10:21:42 -07:00
YHH 6ff61b6c0e Merge pull request #14037 from imcreazy/fix/closing-the-last-page-keeps-the-workbench
fix(studio): closing the last page offers a new tab
2026-10-27 10:20:32 -07:00
YHH e85a2de9c4 Merge pull request #14578 from esengine/c5-hide-prefix
feat(providers): a provider can hide the prefix all its models share
2026-10-27 10:09:02 -07:00
esengine 4713b74f1d feat(openwith): say why an installed program is not offered 2026-10-27 10:07:45 -07:00
YHH 0f5e12064c Merge pull request #14670 from esengine/fx/usage-upload-bytes
feat(usage): show how much is uploaded to model providers
2026-10-27 09:53:33 -07:00
YHH a36dfec9e5 Merge pull request #14647 from esengine/fx/settings-pages-nav
feat(desktop): long settings sections split into pages in the navigation
2026-10-27 09:52:57 -07:00
YHH c112ecdfc9 Merge pull request #14592 from lancy69/feat/8290-global-window-shortcut
feat(studio): add an opt-in global show/hide shortcut
2026-10-27 09:52:40 -07:00
SivanCola 6279a638dc fix: use authoritative quotes for local task cost gates
Problem: local task cost gates could count a base rate while the accounting
event charged the vendor's occurrence-time peak rate, allowing extra tools.

Root cause: Agent budgeting recomputed spend with the legacy float adapter
instead of consuming the quote owned by the existing pricing sink.

Fix: forward an optional usage-quoter capability, capture one authoritative
quote for both delivery and budgeting, and accumulate fixed-point original
currency buckets. Preserve known subtotal and incomplete-usage semantics.
Keep display currency, provider requests and cancellation separate.

Verification: actual Agent and Boot peak/off-peak/relay regressions, preceding
owner negatives with controls, full root package inventory, relevant owner
and Boot race suites, static gates, cross-compilation and a pinned Windows
inventory with verifier mutants. Hosted native and independent review remain
separate. Shared root money and reservations are not implemented here.

Refs #14025.
2026-10-28 00:33:05 +08:00
esengine 1a208789c5 feat(usage): show how much is uploaded to model providers
Every model request carries the whole conversation, so upload grows with
context size times request count and nothing in the app showed it. Each send
attempt now reports its body size, a per-process meter keeps it per provider
and day beside the usage records, and Settings > Usage shows today, the last 30
days, the average request and the providers.
2026-10-27 08:41:11 -07:00
esengine d21e4bebbf feat(desktop): long settings sections split into pages in the navigation 2026-10-27 07:21:20 -07:00
lancy69 e0728253bb feat(studio): add an opt-in global show/hide shortcut
Own one disabled-by-default shortcut in the local Electron profile, with immediate authorized IPC and visible registration failures. Preserve old registration and saved state when rebind or persistence fails; restore at ready and unregister during quit.

Keep browser/network viewers unsupported and in-app bindings unchanged. Cover lifecycle, authorization, failure rollback, bounded native input and settings neighbours; add a real Linux/X11 OS-key smoke and transparent platform limits.

Fixes #8290. Refs #7623; stopped-app launching is not supplied.
2026-10-27 21:54:36 +08:00
YHH 75317a6fe3 Merge pull request #14666 from lancy69/feat/14610-remember-feedback-contact
feat(studio): remember last submitted feedback contact
2026-10-27 06:17:35 -07:00
YHH c7a869c9e9 Merge pull request #14662 from esengine/fx/session-tail-fork
fix(session): open the newest part of a history too large to replay
2026-10-27 06:09:34 -07:00
YHH 984b715f97 Merge pull request #14664 from esengine/fx/compact-notice-code
fix(compaction): word the dropped-turn notices in the window's language
2026-10-27 06:08:57 -07:00
YHH a4c2ebd5be Merge pull request #14571 from esengine/c5-vision-filter
feat(studio): the vision row lists only models declared to read images
2026-10-27 06:08:35 -07:00
YHH c4960bd263 Merge pull request #14589 from lancy69/feat/14388-theme-surface-opacity
feat(theme): allow per-scheme backdrop surface opacity
2026-10-27 05:50:13 -07:00
YHH 37a0cf23c6 Merge pull request #14591 from lancy69/fix/14497-queue-composer-layout
fix(studio): integrate pending queue into composer layout
2026-10-27 05:46:55 -07:00
YHH 281d9264cc Merge pull request #14590 from lancy69/fix/12019-soft-contrast-tier
fix(studio): give Soft contrast a lower AA palette
2026-10-27 05:43:45 -07:00
YHH 7bbc19bb1a Merge pull request #14588 from lancy69/fix/14470-wallpaper-settings
feat(studio): expose wallpaper settings and selected preview
2026-10-27 05:40:38 -07:00
esengine 6161a95f5c fix(session): open the newest part of a history too large to replay
A session whose message payload passes the replay bound could not be opened
and the window offered no way forward. The refusal now carries a code, and
the window offers to continue from the most recent part as a new session; the
original files are only read.
2026-10-27 05:33:53 -07:00
YHH d371619a9e Merge pull request #14657 from lancy69/fix/14632-session-delete
fix(studio): keep deleted legacy conversations out of the sidebar
2026-10-27 05:30:48 -07:00
YHH 3fef1b4ea1 Merge pull request #14602 from lancy69/fix/14231-editor-save-controls
fix(studio): keep file Save compact and readable
2026-10-27 05:30:31 -07:00
Lancy 180d3a71eb feat(studio): remember last submitted feedback contact 2026-10-27 20:03:17 +08:00
Lancy 336ca9cc30 fix(sessionstore): persist removal of legacy v4 conversations 2026-10-27 19:50:00 +08:00
YHH e956185f58 Merge branch 'studio' into fix/gitignore-worktree-info-exclude 2026-10-27 04:07:01 -07:00
esengine eeb0e27878 fix(compaction): word the dropped-turn notices in the window's language
The two notices a fold leaves when user turns survive only through the summary
carried no code, so a Chinese window showed the English fallback. They now
carry a code and a typed payload like the other coded notices.
2026-10-27 04:00:05 -07:00
SivanCola 57678cc06a fix: retain Goal usage ownership across delegated work
Problem: background children from an older chat or Goal could charge late
usage to a newly active Goal while still retaining their original root budget.

Root cause: the usage tee selected its current recorder at event delivery,
so recorder epoch checks were applied to the wrong owner.

Fix: capture an opaque process-local accounting scope at the shared tool-call
boundary. Forward the optional capability through sink wrappers, preserve it
for nested delegation, and select the captured Goal recorder at consumption.
Keep session tally, quoting, title exclusion, provider requests and wire data
unchanged. Document between-turn accounting and persistence limitations.

Verification: real Boot delayed-background and nested fixtures, same-Goal
controls, Goal lifecycle and JSON tests, exact preceding-owner negatives,
root package inventory, owner/Boot race tests, static gates, cross-compilation
and a pinned native Windows inventory with fail-closed verifier mutants.
Hosted native execution and independent review remain separate gates.

Refs #14025.
2026-10-27 18:24:18 +08:00
YHH e19aff7f26 Merge pull request #14655 from esengine/fx/blocked-calls-carry-identity
fix(agent): calls the host stops before they run carry a refusal identity the window words
2026-10-27 03:06:49 -07:00
YHH fed8d4e387 Merge pull request #14650 from esengine/fx/settings-storage-tally
fix(desktop): opening settings no longer walks the disk for the storage total
2026-10-27 03:02:54 -07:00
Will 03b0268479 docs(release-notes): record #14551 2026-10-27 18:01:53 +08:00
Will d7d0c8aeef fix(acp): an inbox refusal says which refusal it is
Every inbox handler answered a store refusal with -32600 and prose, so a client
could not tell "gone" from "wrong state" from "may already have run" — and one
that retried on any error re-sent work the kernel refuses precisely because the
turn may already have run. The HTTP frontend already names 14 conditions; ACP
now answers the same names, with -32020 for "the queue refused this call" and
-32021 for "the work may already have run", and leaves -32600 to a malformed
frame alone.

docs/ACP.md states the table and each refusal's class (terminal,
retry-after-repair, do not retry). repolint's family-vs-writer check now reads
both writers, so a sentinel added to the store cannot reach ACP unnamed.
2026-10-27 18:01:52 +08:00
Will c1321f9971 docs(release-notes): record #14522 2026-10-27 18:01:49 +08:00
Will dff7a9e7f3 fix(stats): a total says how many calls it could not price
A row already records why its money may be incomplete (`cost_estimated`,
`incomplete_reason`), but RangeStats kept only a bool, so a total could say
"this folded in an estimate" and never "of these calls, N were never priced,
because …". Both read paths now count those calls and name the distinct
reasons; the projection carries the reason as schema 8, which reindexes from
the daily files; an old or reason-less row still counts, it just adds no
reason to the vocabulary.
2026-10-27 18:01:49 +08:00
YHH 1fbb96530a Merge pull request #14648 from esengine/fx/summary-retry-connection-reset
fix(agent): retry a context summary once when its stream is reset
2026-10-27 02:59:18 -07:00
SivanCola 32c323a553 fix(delegation): reject unsupported persisted run states
Problem: Saved child records with missing or unknown status could reach
continuation and transcript-copy preparation.

Root cause: The shared metadata validator rejected three known bad states
but implicitly admitted every other string, including future enum values.

Fix: Permit only the existing completed/cancelled states before unchanged
identity checks. Keep metadata decoding and writers unchanged so refusal
preserves unknown fields and transcript bytes for diagnosis.

Verification: Real on-disk store/reopen matrix covers nine state shapes and
five preparation routes, artifact preservation and reference-lock release.
Exact preceding owner fails 25 named events with 31 passing controls.
Qualification results and compatibility limits are recorded in the PR body.
2026-10-27 17:43:47 +08:00
esengine 96cd01d6e2 fix(agent): calls the host stops before they run carry a refusal identity the window words 2026-10-27 02:37:11 -07:00
esengine f41b9d4131 fix(desktop): opening settings no longer walks the disk for the storage total 2026-10-27 02:10:00 -07:00
esengine 8094bcb3a8 fix(agent): retry a context summary once when its stream is reset 2026-10-27 01:40:28 -07:00
YHH d5ead8314c Merge pull request #14635 from esengine/fx/settings-type-roles
feat(desktop): settings text follows one set of type roles
2026-10-27 00:53:36 -07:00
esengine d2a2f9fe97 feat(desktop): settings text follows one set of type roles 2026-10-27 00:27:33 -07:00
YHH cf5f12ed55 Merge pull request #14620 from SivanCola/feature/provider-health-api
feat(provider): gate connection health on user opt-in / 连接检测默认关闭并限定用户授权
2026-10-26 23:57:52 -07:00
YHH 1d81d02a3f Merge pull request #14630 from esengine/fx/workbench-git-scroll
fix(desktop): the multi-repository Git panel scrolls instead of growing past the explorer
2026-10-26 22:54:49 -07:00
YHH 3c4982bc5c Merge pull request #14626 from esengine/fx/modal-focus-no-ring-after-mouse-open
fix(desktop): return focus to a mouse-opened dialog's opener without a focus ring
2026-10-26 22:54:10 -07:00
SivanCola 6012926b9f fix(control): bind root budgets for explicit subagent skills
Problem: Explicit slash, composer and CLI profile children could exceed a
configured root token/wall budget because they bypassed the normal Agent turn.

Root cause: Those Controller entries invoked skill runners without preparing
the executor-owned scope; Goal skills installed observation without the spend
limit. Synchronous profile calls also lacked turn admission serialization.

Fix: Bind the existing Goal/root scope after blocking hooks for interactive
skills. Reuse synchronous Controller admission and root preparation for CLI
profile run/preview, retaining child pause and cancellation semantics.

Verification: Actual Boot HTTP/file-read and Controller lifecycle regressions;
exact old-owner overlay fails 13 named events with four passing controls.
Complete 239-package coverage includes the retained original Boot 15m alarm
and two disjoint remainder groups; four owner race suites and focused Boot
race pass. Root vet, full lint, check and actionlint pass. Windows/Linux
compile-only checks and the literal native gate contract pass locally.
2026-10-27 13:54:03 +08:00
esengine fd901eabed fix(desktop): the multi-repository Git panel scrolls instead of growing past the explorer 2026-10-26 22:36:58 -07:00
esengine b79621c47c fix(desktop): return focus to a mouse-opened dialog's opener without a focus ring 2026-10-26 22:30:41 -07:00
esengine f193dfabe7 fix(desktop): return focus to a mouse-opened dialog's opener without a focus ring 2026-10-26 22:30:30 -07:00
YHH 27b8e944ec Merge pull request #14621 from esengine/fx/phone-test-wait-for-delivery
test(notify): wait for the first phone delivery before attaching the link
2026-10-26 21:59:06 -07:00
esengine 5d973599fc test(notify): wait for the first phone delivery before attaching the link 2026-10-26 21:34:00 -07:00
YHH 37a74507d1 Merge pull request #14618 from esengine/fx/queue-hold-notice-clears
fix(desktop): clear the held-queue notice when the queue resumes
2026-10-26 21:32:07 -07:00
YHH b7fe9ca5f7 Merge pull request #14614 from SivanCola/codex/goal-resume-planner-budget-14025
fix(control): renew planner budget on Goal resume / 恢复 Goal 时续给规划预算
2026-10-26 21:30:02 -07:00
SivanCola 012bd085aa fix(provider): declare health consent as immediately effective
Problem: The setting catalogue described health consent as applying on session open.
Root cause: The declaration did not match the controller's per-request global read.
Fix: Mark the guarded setting Immediate; disable and authorization changes are
checked before cached evidence is returned.
Verification: Settings package tests and the registered HTTP opt-in/disable
regression pass. No payload or request logic changed.
2026-10-27 12:22:01 +08:00
SivanCola 590bc21470 feat(provider): add opt-in authorized connection health API
Problem: automatic picker probes need explicit user consent and must not
borrow credentials for project-defined destinations.
Root cause: the combined implementation used the merged configuration and
had no backend-enforced default-off setting.
Fix: use a strict user-global configuration snapshot, require the opt-in and
provider access before every cache read, refuse all redirects, and expose a
guarded settings API. Cache only fixed-size digests and status/timestamps;
never retain upstream error bodies or log credentials.
Verification: authorization/default-off/disable and malformed-config HTTP
regressions, previous-reader save overlay, desktop setting round trip,
provider-health race tests, and frontend route contracts passed. Full lint
and inventory-partitioned boot validation are tracked separately in the PR.

Refs #14617
2026-10-27 12:11:50 +08:00
SivanCola e031cd0cf6 fix(studio): clarify worktree labels and directory choices
Problem: Directory labels were inconsistent across the header and composer,
and the default green badge looked like a success status.

Root cause: Separate presentation strings described the same workspace
state, including an unnecessary badge for the ordinary directory case.

Fix: Show neutral labels only for isolated worktrees, retain explicit
current-folder/worktree choices with checkmarks, and share explanatory
tooltips while preserving full paths and branches. Update translations,
usage documentation, and the release note.

Verification: Frontend interaction, translation and style checks;
TypeScript; make check; gofmt; go vet; make lint; browser comparisons in
light/dark themes at wide and phone widths. Required Go package and
production build results are recorded in the pull request.
2026-10-27 12:06:27 +08:00
esengine 589bcab7a3 fix(desktop): clear the held-queue notice when the queue resumes 2026-10-26 21:03:37 -07:00
Will 87f7238ab7 chore: retrigger the suite after a flaky boot run
The boot package ran 726s on that job against 264s here, and this change does
not reach it: projectiondb.OpenReadOnly/Inspect have one production caller and
it is the doctor command. No code change.
2026-10-27 11:51:12 +08:00
Will 1bcded8b1b docs(release-notes): record #14549 2026-10-27 11:51:12 +08:00
Will e0f288b8ed fix(doctor): a catalog that recorded a failure is not ok
Problem: `reasonix doctor catalogs` printed the projection's integrity_check
string as the catalog's state, so a history catalog whose history_sources row
recorded health='corrupt' printed `ok`, and a future-schema database -- which
the runtime opens as StateDegraded -- printed `ok` too. The command a user is
told to run to check their data agreed with neither the catalog nor
`catalogs reindex --json`, which reports the same state as "failed": 1.

Root cause: the state column came from Inspection.Integrity and nothing else.
The command never asked the catalog what it recorded, and never compared the
recorded schema against the schema this build migrates to.

Fix: read the facts reindex already reads. refreshStatus and refresh now share
one readStatusCounts with the new read-only Inspect readers, so the
diagnostic's failed is the number reindex reports, and the reason is the one
the catalog recorded. A newer schema reports degraded against
projectiondb.SupportedVersion, which ApplyMigrations also uses. --json gains
state, failed, pending, corruptLines/lagBytes and failures only when there is
something to say, so a healthy catalog's output is unchanged. Text stays one
line per catalog. Exit stays 0: like `catalogs reindex --json`, this command
reports rather than repairs.

Verification: red-first. A reindexed undecodable transcript printed
`history  ok`; it now prints `history  failed ... failed=1 source=<path>:
<recorded reason>`. A schema-2 database printed `ok`; it now prints
`degraded ... supported=1`. Controls: healthy line and exit unchanged, missing
line unchanged, healthy JSON keys unchanged. Mutation: dropping the failure
read turns the corrupt-source test red, dropping the degraded read turns the
future-schema test red. projectiondb.OpenReadOnly reads a live WAL, which
immutable=1 skipped. No projection schema, history index or reindex path
changed. Refs docs/USAGE_CATALOG.md:25, docs/HISTORY_SEARCH_CATALOG.md:42-43.
2026-10-27 11:51:12 +08:00
YHH 61a6c0b18b Merge pull request #14604 from SivanCola/codex/tracked-command-job-owner-14025
fix(proc): retain published Job Object owner / 保留已发布的作业对象 owner
2026-10-26 20:49:37 -07:00
YHH 5831a67380 Merge pull request #14612 from esengine/fix/feedback-esc-lightbox
fix(studio): Escape closes only the innermost open layer
2026-10-26 20:47:14 -07:00
SivanCola b3ba6ee899 fix(control): renew the planner budget on Goal spend resume
Problem: A dual-model Goal resumed after a spend-budget pause can stop its
new planner research after one tool round despite the user renewing the slice.

Root cause: ResumeGoal resets only the executor's local budget. The tool
planner keeps its own Agent accumulator and stricter local gate, so renewing
the shared executor root does not clear that old planner spend.

Fix: On a successful budget-spend resume, also reset the existing companion
planner Agent through the runner's established accessor. Preserve manual
pauses, refused resumes, cumulative Goal statistics and delivery evidence.
No new API, default, cancellation authority, prompt or persisted format.

Verification: Actual Controller/Coordinator loop reproduces two resumed
planner requests instead of four on the preceding owner. Actual Boot plus
HTTP/SSE reads three fixture files per renewed slice; the exact old owner
overlay compiles and fails both regressions with four passing controls.
Full 239-package coverage reconciles 24061 unique test/subtest passes and
215 existing skips. The original Boot command hit its 15m package alarm;
all 846 top names close via 842 original terminals and disjoint 2/2-name
remainder groups, without raising timeouts. Complete control/coordinator
race1758 passes/4 existing skips, actual Boot race1 pass, static gates and
Windows/Linux compile-only checks pass. New-head hosted/native gates remain.
2026-10-27 11:36:01 +08:00
esengine fc68f914f8 fix(studio): Escape closes only the innermost open layer 2026-10-26 20:27:40 -07:00
SivanCola eafeac4ea4 test(proc): recognize the Windows recorder by executable path
Problem: The Windows fallback control missed its recorder marker and could
not remove the still-running taskkill test executable.

Root cause: exec.Command("taskkill", ...) preserves the bare command in
argv[0]. The recorder compared that argument with taskkill.exe, missed its
special entry point, and entered the complete package suite instead.

Fix: Identify the recorder with os.Executable, require LookPath to select
the fixture's same file, and fail closed on an unexpected recorder entry.
Add a bare-command alias probe whose fallback selector runs only the inert
helper. Run/Wait completes before temporary-directory cleanup. Keep the
production ownership repair unchanged and extend the native inventory to
seven pass events versus four expected failures and three passing controls.

Verification: Windows cross-compile, Windows vet and pinned Windows proc
lint pass; full root vet/lint and make check pass at unchanged baseline.
All 6268 selected Darwin inputs match the previous qualified commit; this
append changes only the Windows fixture, verifier inventory and docs.
The complete original Windows failure log is preserved. Fresh native
Windows execution on this new commit remains required, not inferred.
2026-10-27 11:25:55 +08:00
YHH 432c8927f2 Merge pull request #14606 from esengine/fix/share-endpoint-facts
fix(openai): an endpoint's refusal of stream_options is learned once per process
2026-10-26 20:16:15 -07:00
esengine 306aeecded fix(openai): an endpoint's refusal of stream_options is learned once per process 2026-10-26 19:58:28 -07:00
SivanCola 75a36959fd fix(proc): retain published command job ownership
Problem: Repeated cancellation or shell reaping can discard an established
Windows Job Object owner and perform a new PID-based stop attempt.

Root cause: TrackedCommand removes its one-shot TrackedJob on the first kill
and also starts a redundant PID tree observer after successful job assignment.

Fix: Keep the published owner across repeated and late cancellation, and let
an established Job Object own its tree. Preserve existing non-job fallbacks.
Add native Windows owned-child probes with a harmless taskkill recorder and
an exact preceding-owner counterfactual inventory gate.

Verification: Root inventory reconciles 239 packages, 24060 test/subtest
passes and 215 existing skips. Original Boot command hit its 15m package
alarm; all 845 top-level tests close through the recorded disjoint remainder
groups without a timeout increase. Complete proc and shell race suites pass.
Root vet, full lint, make check and actionlint pass. Windows new/old owners
compile and Windows vet passes; actual native regressions remain a hosted gate.
No full PID-identity, descendant-quiescence or physical-scope claim.
2026-10-27 10:48:46 +08:00
Lancy 03c406a5a4 fix(studio): keep file Save compact and readable 2026-10-27 09:33:58 +08:00
esengine 74805c102c docs(release-notes): studio 2.53.1 2026-10-26 18:01:26 -07:00
YHH e9268cc022 Merge pull request #14597 from esengine/fix/settings-header-no-drag
fix(studio): the full-window settings header's buttons respond to clicks
2026-10-26 18:01:04 -07:00
esengine d05faa37a7 fix(studio): the full-window settings header's buttons respond to clicks 2026-10-26 17:40:10 -07:00
YHH b0bb1588bc Merge pull request #14595 from SivanCola/codex/controller-root-routing-budget-14025
fix(control): include semantic routing in root budgets / 语义路由计入根预算
2026-10-26 17:35:56 -07:00
esengine e27fecef3c docs(release-notes): studio 2.53.0 2026-10-26 16:30:32 -07:00
SivanCola b19415a2a4 fix(control): account semantic routing in the root task budget
Problem: semantic routing runs before Agent/Coordinator root preparation, so its reported usage can permit an extra executor tool round.

Root cause: Controller binds no root before routing, and SemanticRouter does not report finalized usage to the accepted context observer.

Fix: share executor root preparation after admission/hooks and before routing in headless and interactive turns. Observe semantic terminal usage once, independent of audit/sink and answer success. Preserve cache hits, new-task/scope/reset lifetimes, normal accounting and cancellation authorities.

Verification: disjoint required/rest commands pass all 239 root packages; four complete owner race suites pass 3939 events with 14 existing skips; actual Boot race passes 12 events. Exact preceding owners compile and fail 15 regression events with six passing controls. Format, vet, full lint and make check pass at byte-identical baseline. Six Windows/Linux binaries compile; local literal Windows gate verifies 21 required events/two package terminals and rejects skip/missing-terminal controls, not native execution.
2026-10-27 07:23:30 +08:00
esengine d54c00e138 docs(release-notes): name the security hardening in 2.53.0 2026-10-26 15:35:03 -07:00
YHH 61f71af0c8 Merge pull request #14594 from SivanCola/codex/git-storage-environment-14025
fix(gitcmd): isolate host storage environment / 隔离宿主 Git 存储环境
2026-10-26 15:33:15 -07:00
esengine e8f3bfc7f2 test(peers): hold the turn open while the dispatch switch is turned off 2026-10-26 15:09:20 -07:00
SivanCola 33f4e9f642 fix(gitcmd): bind host storage to the resolved repository
Problem: ambient Git index/object variables can redirect host worktree preparation and object access.

Root cause: pinned Git/common/worktree directories do not override GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY or GIT_ALTERNATE_OBJECT_DIRECTORIES. The shared constructors retained them.

Fix: remove those three ambient overrides in Env and Detached. Keep deliberate host private-index/object overrides and repository-declared alternates. Cover actual delivery/candidate preparation and a Boot isolated-write/apply flow.

Verification: all 239 root packages pass across disjoint required/rest commands; complete gitcmd/worktree/isolation race 306 passes; Boot race four passes. Exact preceding owner compiles and fails 18 regression events while seven controls pass. Full lint, vet and make check pass with byte-identical baseline. Windows/Linux builds compile; local literal Windows inventory verifies 25 required events and three package terminals, not native execution.
2026-10-27 05:38:13 +08:00
YHH 827bc2cf4c Merge pull request #14593 from SivanCola/codex/coordinator-root-budget-14025
fix(coordinator): share root task budgets / 规划与执行共享根任务预算
2026-10-26 12:53:43 -07:00
esengine b8b7ac87f9 fix(serve): a paired device opens only folders the window already lists 2026-10-26 12:24:35 -07:00
esengine 3463385901 fix(config): a project's system_prompt_file waits for the folder to be trusted 2026-10-26 12:20:14 -07:00
SivanCola 2ae3cc8fa8 fix(coordinator): share planner and executor root token budgets
Problem: configured tasks used independent planner and executor root
counters, allowing an extra executor tool round after planning spend.

Root cause: Agent root binding began only inside each role's Run. A
plan-only turn never opened executor Run and could lose its accounting.

Fix: prepare the executor-owned root before routing, bind companion Agent
runs to it, and account legacy raw-planner usage at its logical terminal.
Track preparation by session object and host task scope. Adopt only the
preparing executor's binding after its own evidence reset; preserve clock,
immutable limit snapshots and captured old-job counters across continuation.
Keep local per-Agent gates/telemetry and all approval/cancellation boundaries.

Verification: required2 plus disjoint rest237 cover239 packages exactly
once,23980 test/subtest passes215 existing skips. Four full owner race
suites2786pass10skip; actualBoot shared and delegated token/wall16racepass.
Precise preceding Coordinator overlay compiles9fail4controls/2pkgfail.
LiteralWindows20named events/3pkg passes locally;6Win/Linux builds compile
only. Rootvet/full lint/makecheck/gofmt/diff pass,2663 baseline unchanged.

Refs #14025
2026-10-27 03:14:17 +08:00
esengine e19b45d39b fix(cli): trust --yes does not approve a provider endpoint unless --allow-providers is given 2026-10-26 12:07:12 -07:00
esengine 3653373671 fix(serve): refuse auth none on a reachable address unless --allow-open-network is given 2026-10-26 12:05:45 -07:00
YHH 97202ddd48 Merge pull request #14587 from lancy69/fix/14545-model-tooltip-aliases
fix(studio): show account and model aliases in composer tooltip
2026-10-26 11:54:48 -07:00
lancy69 c5334b70f3 fix(studio): integrate pending queue into composer layout 2026-10-27 02:40:28 +08:00
Lancy 47df9ff015 fix(studio): make soft contrast lower without crossing AA 2026-10-27 02:39:29 +08:00
Lancy 4a28c20aad feat(theme): add per-scheme backdrop surface opacity 2026-10-27 02:39:02 +08:00
Lancy 0aedda5da2 feat(studio): expose wallpaper settings and selected preview 2026-10-27 02:36:36 +08:00
Lancy 72e46ebbc8 fix(studio): show account and model aliases in composer tooltip 2026-10-27 02:33:39 +08:00
YHH 573c87ce81 Merge pull request #14585 from esengine/fix/feedback-sync-open-cap
fix(feedback-sync): evaluate every open row, not only the first 200
2026-10-26 10:58:39 -07:00
esengine 3321d690ea fix(feedback-sync): evaluate every open row, not only the first 200 2026-10-26 10:35:48 -07:00
YHH 897f9fc2dc Merge pull request #14583 from SivanCola/codex/anthropic-interrupted-usage-14025
fix(anthropic): retain interrupted usage / 保留中断流已知用量
2026-10-26 09:53:30 -07:00
esengine 6336f67634 style(hook): range over FieldsSeq 2026-10-26 09:35:58 -07:00
esengine 3e6cb52f6a fix(hook): keep an unquoted Windows path as written when listing a hook's programs 2026-10-26 09:35:14 -07:00
esengine 4227c20b83 fix(memory): a pinned memory is never written without confirmation 2026-10-26 09:34:09 -07:00
esengine a755eb601c fix(serve): cut a revoked device's streams under the registry lock 2026-10-26 09:33:06 -07:00
esengine 191d1e17a1 fix(permission): a git read in a repository that runs programs is put to the person 2026-10-26 09:32:51 -07:00
esengine 147d5365e8 fix(usecap): label catalog answers that quote MCP-written descriptions 2026-10-26 09:15:14 -07:00
esengine e8c22e7ce7 fix(config): a project folder cannot choose what enters the provider schema 2026-10-26 09:15:14 -07:00
esengine 4ca33493a6 fix(instructions): say where an untrusted folder's standing instructions came from 2026-10-26 08:58:00 -07:00
SivanCola 00a68b64b7 fix(anthropic): retain known usage when streaming is interrupted
Problem: Anthropic error, EOF and cancellation exits could discard usage
already reported by message_start or message_delta. Bounded consumers lost
the counts, while normal recovery reconstructed counts it already knew.

Root cause: the cumulative usage emitter was reachable only after
message_stop; consumers may stop immediately upon receiving ChunkError.

Fix: give each response one cumulative accumulator and delivery attempt.
Flush partial usage before explicit errors with existing Estimated identity,
retain cancellation-aware best-effort delivery on other exits, and compute
the original error classification before accounting changes sink state.
Keep exact committed usage, request construction and retry policy unchanged.

Verification: required builtin/boot plus disjoint rest cover all239 root
packages once,23966 test/subtest passes215 existing skips. Six full owner
race suites2615pass10skip; actualBoot4race events0skip. Exact preceding
decoder compiles and fails14events with4controls passing. Native-step
contract18events/2pkg passes locally;4Win/Linux binaries compile only.
Rootvet/full lint/makecheck/gofmt/diff pass; baseline2663 byte-identical.

Refs #14025
2026-10-26 23:54:59 +08:00
esengine 495a234781 fix(serve): keep install and enable routes to the window, not paired devices 2026-10-26 08:46:06 -07:00
esengine 167c01014a fix(serve): cap the login form body 2026-10-26 08:45:25 -07:00
esengine de21fb4880 fix(shellsafe): an awk program in the call is inline code 2026-10-26 08:45:05 -07:00
esengine ab144a4cd8 fix(installsource): vet an IP literal target before a proxy is used 2026-10-26 08:44:49 -07:00
esengine ed624e928b fix(net): judge the IPv4 an IPv6 transition address carries in the fetch guards 2026-10-26 08:43:53 -07:00
YHH 7934cbb25b Merge pull request #14575 from esengine/c5-persona-default
feat(studio): a personal agent preset can be the new task default
2026-10-26 08:35:19 -07:00
YHH 34c6199885 Merge pull request #14574 from SivanCola/codex/root-websearch-token-usage-14025
fix(search): count native root usage / 根预算计入原生搜索用量
2026-10-26 08:07:08 -07:00
esengine 51f2b1b5ec feat(providers): a provider can hide the prefix all its models share
The prefix is read off the entry's own model list up to its last slash and left
out of how models are drawn, in every listing; ids and refs never change, an
alias wins, and models that would read the same keep their prefix. Fixes #14573.
2026-10-26 07:46:44 -07:00
esengine 0fc3806db7 feat(studio): a personal agent preset can be the new task default
A deleted default is dropped on the refusal the kernel names and the task opens
without it. Fixes #14572.
2026-10-26 07:38:47 -07:00
SivanCola 2fa7ad62b1 fix(search): charge native search usage to the active root budget
Problem: an explicitly bounded Agent root omitted known native search usage,
allowing an extra normal tool round after a reported search answer.

Root cause: the search terminal finalized its independent request count and
reported usage without calling the accepted optional root observer.

Fix: observe the finalized record once before optional reporting. Preserve
query isolation, request counts, callback copies, unknown usage, native
completion, paid-backend consent and zero-limit behavior.

Verification: all 239 root packages pass through disjoint commands; four
complete owner race suites and real boot OpenAI/Anthropic HTTP regressions
pass. The compiled preceding owner fails seven events while nine controls
pass. Static gates, six cross-compiles and the literal 16-event Windows
contract pass locally; own native execution remains a hosted gate.
2026-10-26 22:38:38 +08:00
YHH c2a082f328 Merge pull request #14568 from esengine/c5-settings-bottom
fix(studio): an enlarged settings window reaches the bottom of the window
2026-10-26 07:36:10 -07:00
YHH e39ff6a8cf Merge pull request #14567 from esengine/c5-peer-unread
feat(peers): a session that received a message from another session is marked unread
2026-10-26 07:22:04 -07:00
esengine 5b9fedcafd feat(studio): an enlarged settings window fills the whole window and its button shows the state
The sheet now covers the title bar too, its header standing in for the bar
(drag region, macOS light clearance, Windows buttons); the enlarge button's
arrows turn inward while enlarged. Refs #14418.
2026-10-26 07:12:17 -07:00
esengine e062fcad00 feat(studio): the vision row lists only models declared to read images
A model chosen earlier stays visible, marked as not declared. Refs #14418.
2026-10-26 07:07:26 -07:00
YHH 90310e50bc Merge pull request #14556 from esengine/c5-dispatch-reply
feat(peers): a dispatched session answers the session that opened it on its own
2026-10-26 07:05:27 -07:00
YHH 9d1aafe7b6 Merge branch 'studio' into c5-peer-unread 2026-10-26 06:58:53 -07:00
YHH e07e0cb335 Merge pull request #14566 from SivanCola/codex/test-capability-refusal-evidence-14025
test(boot): assert refusal evidence / 用执行证据验证权限拒绝
2026-10-26 06:53:49 -07:00
YHH f87512d326 Merge pull request #14562 from SivanCola/codex/root-triage-token-usage-14025
fix(agent): count triage root usage / 根预算计入分类与筛查用量
2026-10-26 06:51:30 -07:00
esengine 2aff58cea3 fix(studio): an enlarged settings window reaches the bottom of the window
The centring border that dialogs share left a title-bar-high strip under a
settings sheet that is 100% tall. Refs #14418.
2026-10-26 06:49:03 -07:00
esengine 6e6186d123 feat(peers): a dispatched session answers the session that opened it on its own
When a turn that ran a task from the session that dispatched this one ends,
its outcome and final answer go back as a message to that session. Only the
opener is answered, so a reply never starts a reply. Refs #13341.
2026-10-26 06:45:39 -07:00
YHH 2ecca64075 Merge pull request #14548 from esengine/c5-vendor-icons
feat(providers): vendor marks on the preset list and the model menu
2026-10-26 06:45:09 -07:00
YHH 82d0a3e8c8 Merge pull request #14561 from EMEEEEMMMM/feat/wallpaper-scene-layers
feat(studio): render scene layers and per-track audio
2026-10-26 06:38:17 -07:00
esengine 7fb9b9c11f feat(peers): a session that received a message from another session is marked unread
A newly held message stamps the receiver through the existing unread fact and
announces itself with a quiet operator notice, which makes the window read the
session list again. Refs #13341.
2026-10-26 06:34:32 -07:00
SivanCola 6ba04e3621 test(boot): check refusal execution evidence instead of child timing
Problem: a Windows run rejected three correctly refused capability calls
because the child conversation took 2.138s against a 2s wall-time check.

Root cause: the metric spans provider rounds and persistence, so it does
not establish whether target resolution refused before execution.

Fix: assert error/refusal guidance, Executed=false and no shell receipt for
all three results. Remove unused timestamps and preserve the existing 60s
completion guard. Production behavior and permission policy are unchanged.

Verification: the single root command passes all 239 packages, selected
boundary race checks pass 17 events, and a compiled executed-receipt test
mutant is rejected. Formatting, vet, lint and repository checks pass;
Windows/Linux boot binaries compile, without a native-execution claim.
2026-10-26 21:32:20 +08:00
YHH eadf190dda Merge pull request #14560 from SivanCola/codex/bounded-root-token-usage-14025
fix(agent): count bounded root usage / 根预算计入有界辅助调用
2026-10-26 06:30:26 -07:00
EMEEEEMMMM b03f059771 fix(studio): bound scene layer numbers 2026-10-26 21:13:06 +08:00
SivanCola 00c8d9142e fix(agent): count triage usage in the root token budget
Problem: an explicitly bounded root omitted billed command classifications
and external-content screening, allowing extra normal tool rounds.

Root cause: the shared triage terminal emitted usage without charging the
existing context-bound root counter.

Fix: observe terminal usage through the accepted private root helper before
the optional display guard. Preserve verdicts, screening notices, event
conditions, missing usage, normal telemetry and cancellation authority.

Verification: all 239 root packages pass in disjoint commands; full Agent
race and real boot HTTP/SSE matrices pass. The compiled preceding owner
fails ten events while five controls pass. Static gates, four platform
cross-compiles and the literal 15-event Windows contract pass locally.
2026-10-26 21:09:21 +08:00
YHH 6c3982a6fa Merge pull request #14555 from esengine/c5-dispatch-ui
feat(peers): the model can ask to open folders as projects, behind one approval card
2026-10-26 06:08:16 -07:00
YHH 7e302f39c1 Merge pull request #14558 from esengine/c5-fix-14553
fix(studio): the icon rail no longer cuts through the macOS window controls
2026-10-26 05:51:30 -07:00
SivanCola 02653e31b9 fix(agent): count bounded auxiliary usage in root token scope
Problem: an explicitly bounded Agent root omitted reviewer/advisor usage
already emitted by the shared bounded-call terminal, allowing extra rounds.

Root cause: normal-round accounting does not own auxiliary request usage.

Fix: connect the bounded terminal to one copied, replaceable context
observer bound to the original root counter. Preserve job cancellation,
normal telemetry, missing usage and zero-limit behavior.

Verification: all 239 root packages pass in disjoint commands; nine complete
owner/caller race suites and real boot HTTP/SSE regressions pass. Exact old
owners reproduce the gap; selected accepted auxiliary composition passes.
Formatting, vet, lint, repository checks and eight cross-compiles pass.
The 20-event Windows contract is locally verified, not native execution.
2026-10-26 20:30:53 +08:00
YHH bf149af099 Merge pull request #14559 from xxoingr/fix/mux-pane-position-at-subscribe
fix(studio): a pane the shared stream attaches late re-reads what went by
2026-10-26 05:25:17 -07:00
esengine c12af38c28 fix(studio): the icon rail no longer cuts through the macOS window controls
The rail's edge and background start below the title bar where the shell
floats the controls. Fixes #14553.
2026-10-26 05:24:14 -07:00
xxoingr c83ba32982 fix(studio): a pane the shared stream attaches late re-reads what went by
A local pane rides the hub's multiplexed stream, which attaches it only
once that stream is open. Until then the subscription has no position,
and the attach's watermark became its baseline as if nothing before it
mattered. A pane that read its transcript mid-turn and was attached after
the turn ended never received the reply or turn_done, and went on showing
the person's message alone.

The subscription now reads where the stream stands when it subscribes,
with the attach read of /events/replay. An attach that finds the stream
has moved on since reports what went by as a gap, and the pane rebuilds
from /history as it does for any other hole.
2026-10-26 20:09:04 +08:00
YHH 63ef499a62 Merge pull request #14552 from SivanCola/codex/root-auxiliary-token-usage-14025
fix(agent): count auxiliary root tokens / 根预算计入压缩与诊断用量
2026-10-26 05:01:36 -07:00
EMEEEEMMMM 15fb25d923 docs(release-notes): name the scene layers change 2026-10-26 19:55:09 +08:00
EMEEEEMMMM 37a2f0a31a feat(studio): render scene layers and per-track audio 2026-10-26 19:53:33 +08:00
esengine 8394467359 feat(peers): the model can ask to open folders as projects, behind one approval card
open_projects raises a fresh card naming every folder; approving adds the
folders to the project list and trusts them, vetted per folder like an extra
workspace root. Off unless the user's dispatch switch is on. Refs #13341.
2026-10-26 04:47:37 -07:00
SivanCola 3baa8532b9 fix(agent): charge auxiliary usage to the active root token budget
Fold summary and failure-diagnosis terminal usage into the existing
context-bound root scope, including billed failures. Preserve local
normal-round telemetry and manual maintenance outside a root context.
2026-10-26 19:27:31 +08:00
YHH dba175d8f7 Merge pull request #14547 from SivanCola/codex/planner-local-task-budget-14025
fix(boot): honor planner task budgets / 规划器遵守既有任务预算
2026-10-26 03:57:22 -07:00
esengine 7974185006 feat(providers): a relay's mark stays on its service, model rows keep their own
Presets declare whether they relay other makers' models; the model menu
heading and the settings service list draw the service mark, while a relay's
model rows fall back to the letter chip. Refs #14538.
2026-10-26 03:53:28 -07:00
YHH 8c2f7c163c Merge pull request #14544 from par73e/fix/14527-project-root-validation
fix(cli): validate machine interface project roots
2026-10-26 03:44:58 -07:00
esengine e5a3a41e61 fix(providers): register the vendor filter action and its English label 2026-10-26 03:33:08 -07:00
esengine a50aac0022 feat(providers): vendor marks on the preset list and the model menu
Presets declare their vendor; the wire carries it per model as mark, and the
client draws artwork for declared vendors only. Refs #14538.
2026-10-26 03:33:07 -07:00
YHH 9b3241aecb Merge pull request #14546 from bercedev/fix/mcp-list-get-extra-args
fix(cli): reject extra arguments to mcp list and mcp get
2026-10-26 03:32:58 -07:00
par73e ede7df2c00 fix(cli): validate machine interface project roots 2026-10-26 18:19:54 +08:00
SivanCola f2752a056d fix(boot): honor configured task budgets in tool-enabled planner
Pass the existing TaskBudget conversion to planner Agent Options. Cover
real HTTP planning reads, context overrides, disabled and unknown-cost
cases, executor fallback and request-prefix stability.
2026-10-26 18:11:24 +08:00
berce 4ec65d610c fix(cli): reject extra arguments to mcp list and mcp get
both ignored everything after the subcommand (or the name), so a typo like --jsno printed the human output and exited 0. now they exit 2 like mcp browse does.
2026-10-26 13:06:57 +03:00
YHH f6f1734dc6 Merge pull request #14542 from esengine/c5-fix-14541
fix(studio): a default-preset change answered with 204 is success, not a JSON read
2026-10-26 03:05:40 -07:00
YHH eed84be6d6 Merge pull request #14537 from esengine/c5-plan-bar-design
feat(studio): the plan bar is its own card with a collapse button
2026-10-26 02:53:39 -07:00
YHH 93d7679db5 Merge pull request #14536 from SivanCola/codex/test-mcp-session-fixtures-14025
test(boot): retire MCP fixture sessions / MCP 漂移夹具先关闭旧会话
2026-10-26 02:43:12 -07:00
esengine cc90a23fac fix(studio): a default-preset change answered with 204 is success, not a JSON read
The kernel answers POST /agent-presets/default with 204 and no body, but the
port read the answer as JSON, so a change that had landed reported
'Unexpected end of JSON input'. The call is status-only and uses post().
2026-10-26 02:28:20 -07:00
YHH 190cbeb483 Merge pull request #14531 from SivanCola/codex/estimated-usage-quote-14025
fix(pricing): preserve usage uncertainty / 报价保留估算用量的不确定性
2026-10-26 02:19:53 -07:00
esengine 089133fe56 feat(studio): the plan bar is its own card with a collapse button
The plan bar sat inside the composer box and the open list had no obvious way
back out. It is now a card floating just above the composer; the open list has
a header (running and pending counts) and a collapse button at its top right,
and Esc or a press outside closes it through the shared dismiss path. The jump
buttons move up so the card does not cover them, and the conversation gets a
little bottom room while a plan exists.
2026-10-26 02:09:35 -07:00
YHH 378f65045c Merge pull request #14534 from lancy69/fix/14209-thinking-stream
fix(studio): keep replies streaming while guidance waits
2026-10-26 02:09:01 -07:00
SivanCola 3ebb4ebc25 test(boot): retire MCP fixtures before the next session
Problem: restart-style MCP tests keep prior controllers alive while changing
server definitions or constructing a later session. Their background handshakes
and schema writes are therefore outside the test's advertised phase boundary.
The macOS job on #14531 observed a missing followed-tools notice, although its
precise interleaving was not captured and local old/new stress both pass.

Fix: close the owning controller before each transition in follow/unfollow and
accepted-tool restart fixtures. Existing idempotent Close drains the fixture's
MCP host and queued schema writes. Retain every notice, held-tool, provider
surface, actual-call and refusal assertion without retry, sleep or weaker gate.

Verification: six MCP effect test/subtest events and four existing deterministic
Close barrier tests pass under race. Forty fresh compiled processes pass each
before and after; they do not reproduce the hosted nil notice. Full lint, vet,
make check and four Windows/Linux amd64 builds pass. Full builtin/boot plus
disjoint root inventory continue separately; no production source changes.

Refs #14025
Refs #14133
Refs #14531
2026-10-26 17:08:04 +08:00
Lancy 1bc745ea59 fix(studio): keep replies streaming while guidance waits 2026-10-26 16:35:46 +08:00
SivanCola da70bab265 test(pricing): align estimated usage consumer contracts
Problem: broad validation found three adjacent fixtures requiring selected
cost aliases or complete quotes even when their usage was reconstructed.
The shared quote repair correctly takes the existing incomplete-display path.

Fix: assert preserved original amounts, incomplete/partial coverage and absent
selected aliases for estimated wire, ACP status and persisted stats rows. Keep
reported-usage alias and legacy-row controls. Expand the existing Windows
inventory to all eighteen named events across seven packages.

Verification: affected consumer tests pass, with complete eventwire/ACP/stats
race suites at 351 passing events and no skips. The compiled preceding pricing
owner now fails fifteen regression events with three controls passing. Final
local Windows-step contract passes eighteen events; required-skip and missing
package-terminal controls fail. Full lint, vet and make check remain clean;
final original root validation continues against unchanged frozen Go sources.

Refs #14025
Refs #14133
2026-10-26 16:31:27 +08:00
SivanCola 9799eae998 fix(pricing): preserve incomplete estimated usage quotes
Interrupted provider streams can reconstruct positive usage without terminal
reported counts. The shared quote owner forwarded that provenance but ignored
it when certifying complete cost facts, allowing recovered usage to make a
fully covered ledger.

Use the existing incomplete-usage result while retaining fixed-point amounts
and occurrence-time valuations. Reported usage, including rate-card estimates,
keeps its completeness. Existing quote fields, reasons and ledger versions stay
unchanged; no provider-visible request, permission or budget default changes.

Cover shared event/config adapters, persisted ledger coverage and real boot SSE
recovery with an actual file read. Add a Windows fourteen-event inventory in the
existing job. This is a monetary-fact prerequisite of the full concurrency plan,
not shared root-money admission or hard reservation.

Verification: fourteen focused events and the real boot race pass; compiled
preceding pricing fails twelve regression events with two controls passing.
The prior reader accepts the incomplete quote fixture. Root vet, full lint and
make check pass with the baseline unchanged. Eight Windows/Linux amd64 owner
builds compile; full root inventory and complete owner race continue separately.

Refs #14025
Refs #14133
2026-10-26 16:23:39 +08:00
YHH d797d79374 Merge pull request #14526 from SivanCola/feature/root-task-wall-budget-14025
Share explicit wall budgets across Agent delegation / 委派 Agent 共享显式耗时预算
2026-10-26 00:53:58 -07:00
YHH f781d9e244 Merge pull request #14525 from imcreazy/fix/rail-run-opens-on-hover
Open a folded rail run on hover, and close it on the way out
2026-10-26 00:32:07 -07:00
SivanCola 1e17cad975 Share explicit wall budgets through Agent delegation
Problem: a host wall budget could land the root Agent while delegated Agents
and Manager-owned background jobs kept independent start times or no wall bound.

Root cause: the opaque inherited root scope carried only token accounting; the
existing task time gate read each Agent's own clock after its tool rounds.

Fix: attach an immutable root task start/limit pair to that existing scope.
Preserve the start across continuations and snapshot new root overrides without
rewriting old job bounds. New tasks/reset use a new clock. The existing task and
fleet projection helper also carries this sidecar while keeping Manager-owned
cancellation/deadlines. Use existing time soft landing and finalization; keep
defaults unlimited, with no hard deadline, new timer, schema or permission.

Verification: frozen6730Go/fourmodules; required2+rest237 cover239packages,
23754test/subtestpasses/215existingskips. Complete Agent/delegation/jobs race
2652passes/10existing skips; actual boot background task/capability/fleet4race
passes; accepted14523sixGo selectedoverlay54racepasses; prefixguards4passes.
Exact preceding owner compiles and fails9events/2controls across2packages.
Windows17-event step passes locally and rejects skip/missingterminal controls;
six Windows/Linuxamd64 test binaries compile, not native execution.
Rootvet/full lint/makecheck/format/diff pass; baseline byte-identical2663.

Refs #14025
Refs #14133
2026-10-26 15:15:08 +08:00
imcreazy a6bb1f97da Open a folded rail run on hover, and close it on the way out
A run stands for several questions, so a press on it could only guess.
Hovering it now shows the list, and leaving takes it away again.
2026-10-26 15:14:23 +08:00
YHH 829adcf220 Merge pull request #14523 from SivanCola/fix/task-budget-usage-completeness-14025
Keep incomplete usage out of certified task costs / 保持任务用量与成本完整性
2026-10-25 23:30:13 -07:00
SivanCola 2bd768738a Fix incomplete provider usage in task cost accounting
Problem: a priced Agent could report complete task costs after a round omitted
usage, and estimated usage could certify a cost threshold.

Root cause: the shared HTTP attempt helper synthesized request-only usage
without its existing Estimated identity; the budget observer also treated
missing or estimated token accounting as a fully priced reading.

Fix: preserve exact HTTP counts while marking synthesized usage incomplete.
Keep only reported priced costs in the Agent subtotal, retain uncertainty for
the task lifetime, and land when the known subtotal alone reaches the limit.
Keep defaults, schemas, cancellation and finalization unchanged. Add real
OpenAI HTTP boot coverage and an explicit Windows test inventory.

Verification: frozen 6729 Go files/four module files; required2 +rest237 close
239 packages, 23754 test/subtest passes and215 existing skips. Complete five
owner race2407passes10existing skips; real HTTP boot race4passes; cache guards4.
Exact preceding owners compile with13failing events/5controls; producer-only
rollback4fails/1control; deliberate unknown-erases-known mutation1fail.
Root vet, full lint, make check and formatting pass with baseline unchanged;
six Windows/Linux amd64 owner/boot binaries compile (not native execution).

Refs #14025
Refs #14133
2026-10-26 14:03:36 +08:00
YHH ebab571cdc Merge pull request #14518 from SivanCola/feature/root-task-token-budget-14025
Share token budgets through Agent delegation / 委派 Agent 共享 token 预算
2026-10-25 22:30:45 -07:00
SivanCola 181c8dac78 feat(agent): share reported token budgets through delegated runs
Problem: configured task token limits counted only each Agent's own provider
usage. Delegated Agents could continue spending outside the parent's reading.

Root cause: local runBudget instances never shared a root token total, while
background task/fleet closures start from Manager-owned contexts that omit
caller values.

Fix: bind a private atomic root counter to the existing task ledger and pass
its immutable per-run threshold through derived contexts. Project only this
scope into both background producers, preserving job cancellation authority.
Keep local telemetry, cost/wall gates, defaults, prefixes and soft landing.

Verification: compiling preceding owners9fail/2controls; preceding job-context
only4fail/2foregroundcontrols. Complete3owner race2639pass/10existing skip;
realboot5scenes+parent6racepass, explicit cacheguards4pass. Final239 package
inventory23710pass/215existing skip, required builtin/boot, rootvet, full lint,
makecheck, formatting and6Windows/Linux builds pass. New native inventory
requires20namedpasses/no skips; local jq and required-skip negative verified.

No hard reservation, root money aggregation, process kill, remote/durable
accounting or Coordinator cross-root scope is claimed. Refs #14025, #14133.
2026-10-26 12:54:21 +08:00
YHH 834b633c01 Merge pull request #14515 from SivanCola/fix/isolation-refusal-wording-14025
fix(studio): word isolation refusals on tool cards / 为隔离任务的拒绝原因补上说明
2026-10-25 21:49:32 -07:00
YHH e596cefc00 Merge pull request #14377 from SivanCola/codex/fileview-provenance-14025
fix(builtin): record only complete returned file views / 修复读取版本误认与过期覆盖
2026-10-25 21:48:19 -07:00
YHH 97d1b184a6 Merge pull request #14511 from SivanCola/test/serve-catalog-fixtures-14025
test: retire temporary serve catalogs / 清理临时 serve 夹具的共享 catalog
2026-10-25 21:30:07 -07:00
SivanCola 743c27f5a9 fix(studio): word the refusals isolated tasks already emit
Cause: internal/runtime/isolation and the isolated task tool refuse with
isolation.unknown_id, unavailable, not_git, apply_conflict and
unsupported_arg, but the kernel catalogue words none of them. A tool card
renders say(code), so these refusals showed no reason in either language.

Fix: word the five codes in kernel.ts and en_kernel.ts, in the terms of the
tool path that emits them.

Refs #14025
2026-10-25 21:28:04 -07:00
SivanCola 3d27a1a4e1 fix(builtin): bind file views to complete returned reads
Problem: whole-file rewrites can discard changes hidden by a later overlay
version, a paged read, or an oversized read that clears the previous guard.

Root cause: read_file renders one read and records another. Missing and
incomplete observations also collapse into the permissive never-seen path.

Fix: observe the renderer's decoded stream with bounded capture, refresh only
complete returned reads, retain older guards, and track first partial reads
explicitly. Keep never-seen compatibility and disabled protection behavior.

Verification: compiling old-owner controls fail five behavior regressions;
fixed focused race tests and real boot.Build checks pass. Full builtin,
root-package tests, go vet, make check and full lint are recorded in the PR.
Written bilingual security bounds and release notes accompany this slice.

A first partial read refuses replacement with the file's line count and the
read_file limit that certifies it. Without that, a default-limited read of a
file longer than 2000 lines can never be certified by rereading as told.

Refs #14025
Refs #14133
2026-10-25 21:21:51 -07:00
YHH a4cce4e7eb Merge pull request #14510 from esengine/c5-rail-focus-visible
fix(studio): only keyboard focus magnifies the question rail
2026-10-25 21:08:03 -07:00
YHH 632bb81057 Merge pull request #14267 from lancy69/fix/14223-workspace-alignment
fix(studio): align footer avatar and workspace text
2026-10-25 21:06:58 -07:00
YHH fb76a1c8a3 Merge pull request #14507 from imcreazy/perf/harness-serves-paged-history
perf(harness): serve the transcript in pages when `?paged=N` is set
2026-10-25 21:00:49 -07:00
YHH 2a72d4273d Merge pull request #14509 from esengine/chore/update-acknowledgments
Update acknowledgments contributors / 更新致谢贡献者
2026-10-25 20:53:41 -07:00
Lancy eaf5fba77e fix(studio): align footer avatar and workspace text 2026-10-26 11:48:55 +08:00
SivanCola 2279f841db test(serve): retire shared catalogs with temporary runtime fixtures
Problem: the no-index history search test could inherit a process catalog from
a temporary runtime fixture, so its asserted precondition was not established.

Root cause: shared catalogs deliberately outlive controllers. Several actual
boot fixtures and the shared unread harness omitted their existing fixture
cleanup, allowing the process pointer to outlive the temporary home.

Fix: use the existing shared cleanup in those owners, establish no-index state
explicitly, and preserve coverage for real active-catalog failures. Production
code, workflow gates, timeouts, defaults and baseline remain unchanged.

Verification: preceding fixtures compile and fail the lifecycle regression;
the provider-editor sibling control passes. Complete serve1798pass/5existing
skip and complete disjoint race inventory1798pass/5skip; root239 package
inventory23696pass/215existing skip, required builtin/boot, rootvet, full lint,
makecheck, formatting and Windows/Linux amd64 compile pass. Original Linux500
and the local fullrace5m alarm remain separate evidence, not claimed fixed by
an external rerun. Refs #14025, #14133; followup to #14505.
2026-10-26 11:46:27 +08:00
imcreazy 57598edb62 perf(harness): serve the transcript in pages when ?paged=N is set
Every existing script keeps full history: without the parameter the port
answers 404, which the reader already treats as an unpageable kernel.
2026-10-26 11:44:54 +08:00
esengine fbc3c10d60 fix(studio): only keyboard focus magnifies the question rail
A mark that kept focus after a press set the rail's focus again whenever the
window handed focus back, so its neighbours stayed magnified and its peek stayed
open with the pointer elsewhere. Focus now moves the rail only when it is
focus-visible.
2026-10-25 20:44:04 -07:00
SivanCola 4ed7e0896c docs: update acknowledgments contributors 2026-10-26 03:39:47 +00:00
YHH c65f8be1b4 Merge pull request #14505 from SivanCola/codex/subagent-panic-outcome-14025
Fix delegated panic settlement before completion / 修复委派 panic 的终态结算
2026-10-25 20:23:17 -07:00
SivanCola beefbedc05 fix(delegation): classify panics before settling child outcomes
Foreground panic unwinding could publish a completed worker graph before its error was known, while legacy nil panics were swallowed by both foreground and background exits.

Classify return versus panic at one private delegation boundary, persist the failed transcript while owned, and release before terminal callbacks without letting old cleanup retire a reentrant replacement. Keep foreground panic propagation and accepted Manager finalizers. Cover real boot direct/capability dispatch, compiled nil-panic modes, error identity and ordinary outcome controls.
2026-10-26 10:53:48 +08:00
YHH ec56ac27a3 Merge pull request #14501 from esengine/c5-empty-state-new-session
fix(studio): with no folder, a session still opens, so settings, feedback and a new session work
2026-10-25 19:10:11 -07:00
YHH d95b0b112a Merge pull request #14430 from wanjinhao1/agent/fix-14427-report-id-notfound
Fix #14427: report show/delete with an unknown ID on an empty store exits 1 with a not-found error
2026-10-25 18:57:09 -07:00
esengine ce2ef197bd fix(studio): with no folder, a session still opens, so settings, feedback and a new session work
Settings and feedback refused with 'add a folder first' when no pane was open and
no folder was known, and the empty main area offered only Add folder. The kernel
already opens a session with no root in the app's own workspace, so the sheets
and the empty state now ask for exactly that.
2026-10-25 18:53:52 -07:00
YHH 438e5b71c4 Merge pull request #14453 from esengine/dependabot/npm_and_yarn/desktop/electron/npm-c754d40a5b
build(deps-dev): bump electron-builder from 26.15.3 to 26.18.0 in /desktop/electron in the npm group
2026-10-25 18:19:47 -07:00
YHH 98570c95c0 Merge pull request #14498 from esengine/c5-notes-2.52.0
docs(release): studio 2.52.0 notes
2026-10-25 17:02:21 -07:00
esengine 5e222f332e docs(release): studio 2.52.0 notes 2026-10-25 16:44:45 -07:00
YHH 1cc6bf327a Merge pull request #14496 from SivanCola/fix/job-panic-result-14025
fix(jobs): keep recovered panics from success / 防止任务 panic 被误报成功
2026-10-25 13:29:13 -07:00
SivanCola c8fcbc138e fix(jobs): keep recovered panics from becoming successful results
Problem: legacy nil-panic recovery can publish a successful job after its
invocation or cleanup panics, and error-valued panics lose their cause identity.

Root cause: two boundaries treat a non-nil recover value as the only panic
signal and format panic errors without wrapping the original error.

Fix: share a private normal-return guard across invocation and finalization,
retain typed panic causes, preserve finalizer error classification and sibling
cleanup, and keep the existing invocation stack message without adding stacks
to finalizers. Move invocation recovery into its cohesive owning file.

Verification: compiling preceding owners fail six test/subtest events with two
controls passing. Actual compiled Manager child tests cover explicit panicnil
modes zero/one, invocation, cleanup and no-launch. Frozen required2/rest237
partitions close all239 packages with23656 passes/215existing skips; complete
jobs/delegation race549 passes and accepted14464 three-owner composition671
passes have zero skips. Independent required command has2349passes/66existing skips.
Vet, complete lint, make check and formatting pass at unchanged2664baseline.
Windows/Linuxamd64 owner builds are compilation only; exact-head hosted/native
and independent review remain separate gates. No Goexit, other-goroutine panic,
process-scope completion or crash recovery is claimed.
2026-10-26 03:40:51 +08:00
YHH abd3e3a62e Merge pull request #14464 from SivanCola/codex/checkpoint-writer-admission-14025
Cancel background writer admission and bind cleanup to registrations / 后台写入者可取消准入与身份清理
2026-10-25 12:29:20 -07:00
SivanCola 80baf7e0c5 Merge studio and preserve job-owned writer token finalization
Problem: accepted job finalization in #14469 conflicts with the pending
cancellable writer registration paths in task and fleet.

Root cause: both sides own background cleanup. Closure-only cleanup misses
proven no-launch, while ID-only cleanup cannot protect replacement writers.

Fix: retain Manager-owned terminal callbacks and pass each returned writer
registration token through the shared jobCleanup option. Remove the obsolete
ID-only helper. Preserve the foreground token lifetime, accepted transcript
and progress finalizers, and every other upstream change in this ordinary merge.

Verification: the original full 239-package root command passes with 23668
test/subtest passes and 215 existing skips. Complete checkpoint/delegation/jobs
race passes 663 events, zero skips (one unchanged package cached); real boot
handoff and no-launch race passes ten events. Independent required builtin/boot
passes 2354 events with 66 existing skips. Compiling preceding callers fail
four no-launch scenarios plus parent; the Manager/token generation regression
passes. Vet, full lint, make check, formatting and diff gates pass, baseline
2664 unchanged. Six Windows/Linux amd64 owner compilations are compilation
only. Exact new-head hosted/native validation and independent review remain.
2026-10-26 02:59:07 +08:00
YHH c1ffa46df2 Merge pull request #14469 from SivanCola/codex/job-resource-finalization-14025
Finalize background resources when job launch is declined / 修复后台任务未启动时的资源清理
2026-10-25 11:29:39 -07:00
YHH 36ecb1868a Merge pull request #14495 from xxoingr/test/workbench-menu-focus-after-effect
test(studio): wait for the reader menu to take focus before reading it
2026-10-25 11:29:27 -07:00
xxoingr 022c4db333 test(studio): wait for the reader menu to take focus before reading it
The reader menu opens from a promise callback, so its commit runs at
default priority and the effect that focuses the first item is a
separate Scheduler task (setImmediate). findByRole resolves from the
MutationObserver that sees the menu, then RTL drains with setTimeout(0);
when a millisecond passes before the next loop turn, the timer wins and
the assertion reads the reader, which still has focus.

Wait for the focus the way the other focus assertions in the suite do.
2026-10-26 02:11:39 +08:00
YHH 91858315ca Merge pull request #14490 from SivanCola/codex/settings-network-draft
Separate saved network settings from drafts / 区分网络草稿与已保存配置
2026-10-25 11:00:18 -07:00
YHH 14323ca29d Merge pull request #14491 from SivanCola/codex/settings-scroll-search
Reset settings category scrolling and await search targets / 修复设置滚动与异步搜索定位
2026-10-25 10:59:50 -07:00
YHH 6458adf7b6 Merge pull request #14492 from SivanCola/codex/settings-backup-route
Register file backups on machine settings routes / 修复本机配置备份接口 404
2026-10-25 10:53:14 -07:00
SivanCola 728a0ed165 feat(isolation): prepare Store-owned candidates from one baseline
Problem: candidate groups need a common baseline while preserving the result
Store's source, managed-root and identity ownership.

Root cause: ordinary Begin captures afresh, while arbitrary snapshot/path
adoption would bypass the owning Store and its existing settlement contract.

Fix: add an opaque process-local Preparation, recheck source/workspace/Git
directory identities, and create independent results from the captured
snapshot. Extract the existing Begin bodies into private shared helpers.
No best-of, runtime, default or persisted-format activation is included.

Verification: complete 239-package root inventory, full isolation race,
independent builtin/boot command, vet, full lint and make check pass with the
2664 baseline unchanged. Compiling unsafe controls distinguish baseline
refresh and replaced-directory admission; the preceding Store implementation
passes the new preparation tests. Linked-source overlay passes; Windows and
Linux amd64 compile only. Native/hosted validation and independent review
remain separate gates.
2026-10-26 01:50:29 +08:00
imcreazy 4f915fe504 ci(android): build the APK on a tag and attach it to the release 2026-10-26 01:39:33 +08:00
YHH 5a16e27b59 Merge pull request #14479 from SivanCola/fix/mutation-json-members-14025
fix(mutation): reject ambiguous record members / 拒绝事务记录歧义字段
2026-10-25 10:30:56 -07:00
YHH 70955832c3 Merge pull request #14482 from xxoingr/test/netclient-stream-dialer-proxy-boundary
test(netclient): pin the stream dialer's proxy boundary
2026-10-25 10:30:18 -07:00
SivanCola 6881c9748d fix(studio): register file backups on machine settings routes
Problem: local settings cannot load the file-backup catalog because both
machine settings URL forms return an untyped 404.

Root cause: file-backup handlers are registered only with account backup
routes, while the machine settings router registers a separate route set.

Fix: register the existing file-backup handlers with machine settings.
Normal runtime routes still register that same set once. Preserve host-only
checks, paired-device rejection, local-owner selection and mutation gates;
do not change payloads, encryption or persisted formats.

Verification: new regressions fail before the fix with plain 404 responses
and pass afterward for both machine prefixes. Focused machine settings,
file backup, account backup and mutation-gate tests pass. gofmt, go vet,
make lint, make check and the required builtin/boot tests pass.
2026-10-26 01:12:34 +08:00
SivanCola d11ca0472c fix(studio): reset category scrolling and wait for search targets
Problem: switching settings categories retains an unrelated scroll offset, while a searched setting that loads later never receives navigation feedback.

Root cause: categories share one scrolling element and landing resolves the anchor once before asynchronous content arrives.

Fix: key the panel by category, clear pending landing only when a guarded category change succeeds, and observe target arrival until its feedback lifetime begins. Keep existing categories, layouts, disclosures and unsaved-change guards.

Verification: five regressions fail on the unmodified baseline; all 28 settings test files and 215 tests pass with the fix. TypeScript/Vite and repository checks pass. Browser verification confirms a tools panel at scrollTop 5747 switches to network at 0 and search opens the existing advanced contrast disclosure.
2026-10-26 01:10:04 +08:00
SivanCola 57784a251c fix(studio): separate saved network settings from drafts
Problem: Network settings could be saved without edits, and pending reads,
saves or diagnostics could overwrite the state of a newly selected connection.
Connection tests also appeared to exercise unsaved form values.

Root cause: The form used one settings object for both saved state and edits,
with async completions that were not scoped to the owning connection.

Fix: Keep saved settings and the editable draft separately, disable no-op
saves, freeze submitted drafts, and identify diagnostics as testing saved
settings. Remount the form on connection changes and ignore completions from
unmounted owners. Preserve the existing expanded connection details layout.

Verification: The seven extracted regression cases failed on the baseline.
Network and i18n tests pass (10 files, 181 tests), including edit reversion
and loading/error coverage; tsc -b, make check and git diff --check pass.
2026-10-26 01:08:14 +08:00
YHH 4377886f88 Merge pull request #14478 from EMEEEEMMMM/feat/wallpaper-scene-audio-dxt
feat(studio): loop every scene soundtrack and decode DXT backdrops
2026-10-25 10:00:24 -07:00
YHH 28cab7d14b Merge pull request #14483 from esengine/c5-theme-syntax-tokens
feat(theme): five optional syntax tokens for comments, types, operators, punctuation and variables
2026-10-25 09:53:30 -07:00
YHH bd5ea6ddd3 Merge pull request #14485 from esengine/c5-fix-unreachable-port-flake
test(serve): take the unreachable address from a port that was seen to refuse
2026-10-25 09:53:20 -07:00
EMEEEEMMMM 6e2ba1056a fix(studio): bound DXT image and LZ4 sizes 2026-10-26 00:38:39 +08:00
YHH b458854377 Merge pull request #14472 from SivanCola/codex/isolation-runner-panic-14025
fix(isolation): retain work after runner panic / 保留 runner panic 后的隔离成果
2026-10-25 09:31:16 -07:00
esengine b29f8bf16b test(serve): take the unreachable address from a port that was seen to refuse 2026-10-25 09:30:56 -07:00
esengine d1f852953a test(theme): reach the syntax guard's hooks through the harness bundle, as the built page serves them 2026-10-25 09:19:28 -07:00
SivanCola 6287044a20 merge studio to retain the accepted feedback assertion fix
Problem: the ordinary test synchronization append conflicts with upstream's
independently accepted fix for the same passive draft-persistence boundary.

Root cause: both sides replace the immediate heldDraft assertion with waitFor;
upstream additionally explains why the effect may follow the thumbnail commit.

Fix: resolve the sole conflict to the exact accepted upstream test including
its comment. Preserve all incoming dependency/UI changes and every original
runner-panic implementation Go file. This merge is required by a verified
content conflict, not unrelated base drift; no rebase or force-push.

Verification: final inventory of 239 root packages is reconciled from disjoint
required2 and rest237 commands, both exit0, with frozen Go/module inputs.
Isolation race covers all 51 top-level tests through the original completed
39 trees plus the remaining12 under the same5m budget; original full owner
command hit its cumulative alarm and is retained as exit1, not called pass.
Five affected frontend files have99 tests pass; production typecheck/build,
formatting, root vet, full lint and make check pass without baseline widening.
Exact merge-head hosted/native checks and independent review remain pending.
2026-10-26 00:06:20 +08:00
YHH 52e9abe311 Merge pull request #14481 from esengine/c5-deps-vitest
build(deps): bump vitest to 5.0.5
2026-10-25 09:01:44 -07:00
esengine d0bff6bcf5 feat(theme): five optional syntax tokens for comments, types, operators, punctuation and variables 2026-10-25 08:58:37 -07:00
xxoingr 8c42340ea4 test(netclient): pin the stream dialer's proxy boundary
NewStreamDialer is how SSH reaches a remote host under the user's proxy
(remote/attach), and none of dialer.go was exercised: netclient sat at
47% against a 47.2% floor on a declared-sensitive path.

Four tests drive it against fake proxies on loopback:
- an http proxy gets CONNECT to the target with Proxy-Authorization, and
  the credentials never also travel as Authorization;
- a proxy that answers 407 yields an error, not an open stream;
- an https proxy sees a TLS handshake first, so CONNECT and credentials
  never cross in cleartext;
- a SOCKS5 proxy gets username/password auth and the target by name.

Each fails when the matching guard in dialer.go is removed. The
netclient floor rises from 47.2 to 65.8.
2026-10-25 23:54:17 +08:00
YHH f45fb56f62 Merge pull request #14476 from esengine/c5-fix-feedback-flake
test(studio): wait for the held feedback draft before asserting it
2026-10-25 08:44:36 -07:00
SivanCola 127ee9755b test(studio): await feedback draft persistence after paste
Problem: Studio job 121205496051 fails after the pasted image preview appears,
because the retained draft still contains no image.

Root cause: findByAltText observes the DOM commit, while holdDraft runs in a
passive effect. The test assumes those distinct boundaries complete together.
The unchanged merge passed a later external rerun, which does not repair the
scheduling assumption.

Fix: keep the exact single-image assertion for both heading and tab targets,
but await the draft condition with waitFor. Production UI and Go source stay
unchanged.

Verification: full Feedback interaction file 65 pass, restored focused cases
2 pass, typecheck pass. Two independent no-op-save controls each fail both
targets, proving persistence is still checked. Root vet, full lint, make check
and required builtin/boot pass at unchanged baseline 2664. The preceding
native/hosted evidence remains attached to af958f; the new head needs fresh CI.
2026-10-25 23:43:47 +08:00
esengine 0c9d5d1fd1 build(deps): bump vitest to 5.0.5 2026-10-25 08:40:03 -07:00
SivanCola 50a5186930 fix(mutation): reject ambiguous coordination record members
Problem: ambiguous private registry records can be admitted or scavenged as
known dead-owner state.

Root cause: struct JSON decoding merges repeated members and folds case;
unknown-field rejection alone does not preserve the original declarations.

Fix: validate unique decoded names and exact schema spelling for records and
capacity objects before typed decoding and liveness checks. Keep writer
encoding, legal spacing/order, escaped unique names and existing resources.
Join a killed process before closing cooperative input in death fixtures.

Verification: complete 239-package root inventory and required builtin/boot
commands exit 0; mutation race 88 pass, pending wait/opaque composition 116
pass. The compiling preceding decoder fails 22 behavior test/subtest events,
with two controls passing. Formatting, vet, full lint and make check pass
without changing the 2664 baseline. Windows/Linux amd64 compile only; decode
microbenchmark overhead is measured and disclosed separately from product
performance. No runtime or default activation.
2026-10-25 23:34:40 +08:00
YHH d51fb03f6b Merge pull request #14475 from lancy69/fix/13512-prompt-customization
docs: explain supported prompt customization
2026-10-25 08:33:23 -07:00
YHH 7d65acd714 Merge pull request #14474 from lancy69/fix/13919-project-persona-guidance
docs: explain project-specific persona guidance
2026-10-25 08:32:52 -07:00
esengine 0fd8d526e5 test(studio): wait for the held feedback draft before asserting it 2026-10-25 08:29:37 -07:00
YHH 751f0b13ca Merge pull request #14473 from esengine/c5-deps-npm
build(deps): bump mantine, lucide-react, maplibre-gl and vite
2026-10-25 08:27:21 -07:00
EMEEEEMMMM 7273f04d81 feat(studio): decode DXT scene textures 2026-10-25 23:18:33 +08:00
EMEEEEMMMM def94177cd feat(studio): play every scene soundtrack 2026-10-25 23:18:27 +08:00
Lancy e6337ef356 docs: explain supported prompt customization 2026-10-25 23:15:15 +08:00
Lancy d5e7c53401 docs: explain project-specific persona guidance 2026-10-25 23:14:21 +08:00
esengine 9609216f74 build(deps): bump mantine, lucide-react, maplibre-gl and vite to their latest patch and minor releases 2026-10-25 08:05:47 -07:00
YHH 8add01aacc Merge pull request #14468 from imcreazy/feat/rail-opens-a-folded-run
Let the rail say when history is unloaded, and open a folded run
2026-10-25 07:53:27 -07:00
YHH bdf3784f96 Merge pull request #14471 from esengine/c5-deps-go
build(deps): bump modernc.org/sqlite to 1.61.0 and tdewolff/parse to 2.8.19
2026-10-25 07:40:48 -07:00
imcreazy 186583996d Let the two new rail surfaces take the pointer
`.railhost` sets pointer-events: none, and only `.srail` opted back in. The
unloaded-history marker and the opened run inherited the none, so presses fell
through to the transcript. A style test now holds the rule.
2026-10-25 22:38:44 +08:00
SivanCola af958fdae8 fix(isolation): retain uninspected work after a runner panic
Problem: an isolated task could write files, then lose its discoverable result
identity when a host runner or provider callback panicked.

Root cause: Store.Execute called Finish only after an ordinary runner return;
panic unwind bypassed both completed-result and inspection publication.

Fix: recover at the runner invocation boundary, retain an error-valued panic
as a cause, and publish the existing inspection identity under the result
operation owner. Do not infer physical completion, inspect for emptiness, or
delete work on a panic. Preserve ordinary outcomes and truthful live-only
recovery when bookkeeping cannot be admitted or persisted.

Verification: final 239-package inventory closes using 238 unchanged broad
packages and the final isolation race package; 111 owner race and 3 real boot
race cases pass without skips. The compiling preceding owner fails 11 cases
with 3 ordinary-error controls passing. Required builtin/boot, root vet, full
lint and make check pass at unchanged baseline 2664. Linux/Windows amd64 are
compile-only; hosted/native and independent review remain separate gates.

Refs #14025
Refs #14133
2026-10-25 22:25:13 +08:00
esengine 148a3eabe7 build(deps): tidy the desktop module for the same bumps 2026-10-25 07:05:07 -07:00
YHH 82997a2d8f Merge pull request #14463 from esengine/c5-remove-reading-grips
feat(studio): remove the reading column's drag handles
2026-10-25 06:59:35 -07:00
esengine 682ecbe392 build(deps): bump modernc.org/sqlite to 1.61.0 and tdewolff/parse to 2.8.19 2026-10-25 06:56:32 -07:00
YHH 40f3156907 Merge pull request #14457 from esengine/dependabot/github_actions/actions-5a66b01c52
build(deps): bump the actions group with 3 updates
2026-10-25 06:40:28 -07:00
SivanCola 966f6ece9b test(ci): qualify background resource owners on native Windows
Problem: Windows PR smoke did not run jobs/delegation unit tests, and pure
delegation changes could skip native platform legs. Linux PR race coverage
also omitted the delegation owner tests.

Root cause: package routing predates the shared background resource lifecycle.
Compiling these owners as boot dependencies does not execute their tests.

Fix: run both complete packages on Windows with a retained JSON inventory,
require all15 portable lifecycle cases to pass, route pure delegation changes
to native legs and include delegation in the existing Linux race sweep.

Verification: platform-scope tests and pinned actionlint pass; the inventory
gate accepts actual local owner output and rejects missing-case, skipped-case
and missing-owner controls. Make check passes at unchanged baseline2664.
All6699 Go files match the qualified implementation commit; no product Go
source or deadline changed. Exact-head native/hosted qualification is pending.
2026-10-25 21:36:48 +08:00
YHH a3473882ce Merge pull request #14467 from esengine/c5-hub-auto-workspace
fix(serve): a new session with no project opens in the app's own workspace
2026-10-25 06:35:57 -07:00
SivanCola 9260e62870 fix(jobs): finalize resources when managed launch is declined
Problem: background task/fleet can strand checkpoint writers, transcript locks
and progress resources when the Manager never invokes the supplied closure.

Root cause: resource ownership moves to closure-only defers before the Manager
checks invalid identifiers or its closed root context. Those branches bypass
the closure and its cleanup, while the caller suppresses its own ending.

Fix: register optional host finalizers at the Manager's managed-execution
boundary. Run all callbacks once before completion after actual closure return
or proven no-launch, preserve cleanup failures/panic and transfer the actual
task/fleet resources and uninvoked terminal projections to that owner.

Verification: final239-package inventory uses238 broad packages plus the final
jobs package after a deterministic liveness-test-only change:23596 test/subtest
passes/215 existing skips. Full owner race648 and real boot race5 pass without
skips; required builtin/boot2346 passes/66 skips. Original public regressions
and compiling predecessor controls fail. Selected token-adapter composition
passes40 race cases. Vet/lint/check pass at unchanged baseline2664.

The liveness pair uses a controlled clock; production thresholds are unchanged.
Managed-closure completion is not descendant scope or durable crash recovery.
Refs #14025
2026-10-25 21:28:02 +08:00
esengine 8b4dea758d fix(serve): a new session with no project opens in the app's own workspace 2026-10-25 06:15:17 -07:00
SivanCola 3858f0d51f fix(checkpoint): separate writer admission from registry locking
Problem: cancelled background task/fleet calls can remain behind checkpoint
admission while the shared writer registry also blocks queries and cleanup.

Root cause: registration holds the registry mutex across the barrier, durable
undo retirement, notification and Store persistence. Callers discard their
admission context and defer cleanup by reusable string ID alone.

Fix: reserve identified pending registrations, prepare outside the registry
mutex and serialize latest snapshot publication separately. Pass context at
all task/fleet sites and transfer token-bound cleanup to admitted jobs. Preserve
legacy APIs, completed undo retirement and active work across parent turns.

Verification: original root239 passes with23590 test/subtest passes/215 existing
conditional skips; required builtin/boot2346 passes/66 skips. Complete owning
race487 passes/zero skips; real boot handoff race5 passes/zero skips. Compiling
preceding-owner/caller controls fail the intended assertions. Vet/lint/check
pass without raising baseline2664. Linux/Windows compilation is not native QA.

The jobs-owner no-launch finalization gap remains explicitly separate.
Refs #14025
2026-10-25 20:43:12 +08:00
esengine 1d723d2a28 feat(studio): remove the reading column's drag handles 2026-10-25 05:40:54 -07:00
YHH a43ba964fd Merge pull request #14458 from esengine/c5-grips-need-room
fix(studio): draw the reading column's handles only when there is a range to move in
2026-10-25 05:26:56 -07:00
YHH f7c2506cf2 Merge pull request #14450 from esengine/c5-drop-topbar-sandbox
fix(studio): drop the top bar's second route to Tools and permissions
2026-10-25 05:26:40 -07:00
SivanCola 5d484c4fe2 feat(proc): expose tracked root wait completion
Problem: A foreground command can return bounded cancellation while cmd.Wait
is still blocked, so call return cannot establish resource-lifetime completion.

Root cause: The tracked wait owner exposes its logical result and cleanup
diagnostics but no separate observation of the still-running root Wait.

Fix: Register a stable root-wait channel before the waiter starts and close it
after Wait returns, before result publication. Expose it with the explicit
RootWaitCompleted name. Preserve existing execution, cancellation, retry and
error behavior. No production lease consumer or whole-scope claim is added.

Verification: Deterministic cancellation/retry/publication tests, portable Go
child execution and a real blocked-output process/lease regression pass. Root
Wait remains distinct from descendant or external-scope completion; nil means
no registered observation. Full local/static and platform evidence is recorded
in the PR without inheriting previous heads' native results.
2026-10-25 20:00:34 +08:00
esengine 4db40e881c fix(studio): draw the reading column's handles only when there is a range to move in 2026-10-25 04:59:49 -07:00
dependabot[bot] 6c4f00ce42 build(deps): bump the actions group with 3 updates
Bumps the actions group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [actions/setup-python](https://github.com/actions/setup-python).


Updates `github/codeql-action/init` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/analyze` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `actions/setup-python` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/5fda3b95a4ea91299a34e894583c3862153e4b97...6d4fd22b20465ccc0f7354476588e8df7c9ca0e0)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-25 11:56:24 +00:00
dependabot[bot] 5232ed58f5 build(deps-dev): bump electron-builder
Bumps the npm group in /desktop/electron with 1 update: [electron-builder](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-builder).


Updates `electron-builder` from 26.15.3 to 26.18.0
- [Release notes](https://github.com/electron-userland/electron-builder/releases)
- [Changelog](https://github.com/electron-userland/electron-builder/blob/[email protected]/packages/electron-builder/CHANGELOG.md)
- [Commits](https://github.com/electron-userland/electron-builder/commits/[email protected]/packages/electron-builder)

---
updated-dependencies:
- dependency-name: electron-builder
  dependency-version: 26.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-25 11:53:19 +00:00
YHH a76cbec375 Merge pull request #14447 from esengine/c5-tool-blurbs
fix(studio): give the config-gated tools a blurb in the settings tool list
2026-10-25 04:53:06 -07:00
esengine 3a67b488a2 test(studio): the narrow-window guard no longer expects the removed top-bar button 2026-10-25 04:52:55 -07:00
esengine 553ed22a50 fix(studio): drop the top bar's second route to Tools and permissions 2026-10-25 04:39:26 -07:00
esengine 889211c785 fix(studio): give the config-gated tools a blurb in the settings tool list 2026-10-25 04:31:31 -07:00
YHH 540c14998b Merge pull request #14443 from esengine/c5-pane-wide-drop
feat(studio): a file dropped anywhere on the conversation pane is taken
2026-10-25 04:26:35 -07:00
SivanCola 25169a45f1 feat(mutation): admit explicitly opaque write effects
Problem: Directory-scoped coordination cannot exclude writers that address a
source file through a hardlink outside that directory.

Root cause: Path containment and named inode identity do not represent an
unresolved write effect; reinterpreting old directory records would silently
change their resource promise and nested admission could reuse a weaker hold.

Fix: Publish explicit version-3 opaque effects in the existing registry,
coordinate them symmetrically against all path-bearing transactions, and
require effect coverage when reusing a parent lease. Preserve ordinary named
parallelism, model capacity, previous directory semantics and final OS-locked
grant authority. No runtime or default requests the new effect here.

Verification: Full 239-package root inventory and independent builtin/boot
commands exit 0; mutation race 82 passes/zero skips; preceding-reader binary
preserves live/dead new records; weakened-owner control fails eight cases;
selected wait-snapshot composition has 36 race passes/zero skips. Root vet,
full lint, changed-file checks and formatting pass at unchanged baseline 2664.
Windows/Linux amd64 compile only; local execution is macOS.
2026-10-25 19:21:21 +08:00
YHH 9fce63aa5a Merge pull request #14442 from esengine/c5-price-currency-select
fix(studio): style the model-price currency select like the inputs beside it
2026-10-25 04:03:58 -07:00
esengine c41306961a feat(studio): a file dropped anywhere on the conversation pane is taken 2026-10-25 03:52:53 -07:00
esengine 0865bc1071 fix(studio): style the model-price currency select like the inputs beside it 2026-10-25 03:46:57 -07:00
YHH 46d63cf414 Merge pull request #14440 from SivanCola/codex/mutation-record-validation-14025
fix(mutation): retain unsupported registry records / 保留未知事务记录并拒绝准入
2026-10-25 03:30:33 -07:00
SivanCola 619c5b243d fix(mutation): retain unsupported coordination records
Problem: unfamiliar coordination records can be ignored during admission and
deleted after their OS owner exits.

Root cause: registry decoding discarded unknown fields, and version-1 resource
validation accepted every effect except capacity.

Fix: reject unfamiliar object fields and unsupported path effects before
liveness/scavenging, retain original bytes, and include record identity in the
typed protocol error. Preserve known version-1/2 records and omitted-effect
legacy semantics without changing writers or activating the runtime protocol.

Verification: complete 239-package root inventory, full mutation race,
compiling preceding-owner counterexamples, actual owner-process death,
known-record controls, selected waiting-observation composition, required
builtin/boot and static gates. Windows/Linux builds are compile-only local
evidence; exact-head hosted/native review remains separate.
2026-10-25 17:56:13 +08:00
YHH 1c330cbd9e Merge pull request #14438 from SivanCola/codex/isolation-settlement-fence-14025
fix: fence ordinary isolated-result writers / 隔离结果操作识别持久恢复屏障
2026-10-25 02:54:14 -07:00
YHH 6343cc7970 Merge pull request #14432 from xxoingr/fix/pane-tab-close-stays-inside
fix(studio): a pane tab's close button stays inside the tab
2026-10-25 02:53:50 -07:00
YHH ef0874a111 Merge pull request #14436 from esengine/c5-fix-14434-retention
fix(agent): cap verbatim retention by the compaction trigger
2026-10-25 02:21:47 -07:00
SivanCola adcc4b4d80 fix(isolation): fence ordinary result operations before recovery
Problem: A durable future settlement record must stop ordinary result
writers, including stale queued operations and destructive siblings.

Root cause: The per-ID mutex serializes operations but carries no durable
knowledge that a recovery owner has published a settlement fence.

Fix: Check host-rooted external fence metadata after operation ownership,
refuse any existing entry without interpreting its bytes, recheck context
and release only this attempt on refusal. Cover tools, Finish/Abort, empty
inspection, sweep, a real child process and actual boot/controller rebuild.
No journal writer, schema or complete recovery is introduced. Preceding
writers ignore this fence; future activation needs a cooperating protocol.

Verification: Full isolation race100 passes, zero skips; real boot race3
passes. Preceding owner compiles and fails15 test/subtests, including a
child process; predecessor boot owner actually writes source in both cases.
Final root inventory/required and static qualification recorded in PR.
Linux/Windows amd64 compilation is separate from native evidence.

Refs #14025
Refs #14133
2026-10-25 17:20:38 +08:00
xxoingr caf5c2d45e docs(release-notes): keep the pane tab note's English line within 200 columns 2026-10-25 17:19:50 +08:00
YHH bb273fec87 Merge branch 'studio' into fix/pane-tab-close-stays-inside 2026-10-25 01:56:24 -07:00
esengine e1c38d3036 fix(agent): cap verbatim retention by the compaction trigger, not only the window
User turns, retained failures and [[keep]] turns were budgeted as shares of
the window. On a 1M window under the 100K cost trigger those shares plus the
tail passed the trigger, so every fold was refused as still at or above it.
Each budget is now also held to a share of the trigger.
2026-10-25 01:55:42 -07:00
YHH 463e2f76f0 Merge pull request #14435 from esengine/c5-css-budget
style(studio): hold the stylesheet to its line budget
2026-10-25 01:55:32 -07:00
esengine 172ae8b80f style(studio): hold the stylesheet to its line budget after two merges landed together 2026-10-25 01:42:56 -07:00
YHH d03be98bcb Merge pull request #14425 from SivanCola/codex/worktree-literal-paths-14025
fix(worktree): keep integration paths literal / 修复特殊文件名导致隔离整合越范围写入
2026-10-25 01:22:31 -07:00
xxoingr decba9d9eb fix(studio): a pane tab's close button stays inside the tab
The folder and host labels on a pane tab were flex: none, so a long one
could not shrink: the title, the only part that could, went to zero width
and the close button was pushed past the tab's edge onto the next tab,
where a press activates that tab instead.

Both labels now shrink to an ellipsis and take at most 40% of the tab, and
the title keeps at least 4em. perf/tab-close-inside.mjs checks, in the
real stylesheet, that the close button stays inside its tab and is what a
press at its centre reaches, with a long title, a long folder, and both a
long folder and a long host.
2026-10-25 16:21:25 +08:00
YHH 3af39d9d0a Merge pull request #14407 from EMEEEEMMMM/feat/wallpaper-scene-mvp
Feat/wallpaper scene mvp
2026-10-25 01:00:15 -07:00
YHH 4775113397 Merge pull request #14415 from SivanCola/codex/isolation-native-ci-14025
ci: run complete mutation and isolation owners on Windows / 补齐事务与隔离机制原生及竞态验收
2026-10-25 00:57:35 -07:00
YHH 21a4569b39 Merge pull request #14406 from lancy69/feat/14139-thinking-preview
feat(studio): separate collapsed thinking previews from folding
2026-10-25 00:56:22 -07:00
SivanCola 479c8e7b16 merge: adopt reviewed studio owners for literal path review 2026-10-25 15:44:19 +08:00
SivanCola c79c5192ee test(mutation): pin legacy wait admission and adopt reviewed Windows fixes 2026-10-25 15:30:48 +08:00
Lancy d42cdbb434 feat(studio): separate collapsed thinking previews from folding 2026-10-25 15:26:36 +08:00
YHH 42c3c9f195 Merge pull request #14420 from esengine/c5-prefs-centered
fix(studio): centre the enlarged settings window's content column
2026-10-25 00:25:14 -07:00
YHH 526908630a Merge pull request #14429 from esengine/c5-serve-bind-first
fix(cli): serve binds its address before it reports anything
2026-10-25 00:24:55 -07:00
EMEEEEMMMM 7d9e9b7b6c fix(studio): bound scene image decoding 2026-10-25 15:14:56 +08:00
YHH 2dce532bf5 Merge pull request #14419 from chuiwenwei/fix/record-the-tokens-the-price-came-from
fix(stats): record the tokens the price came from
2026-10-25 00:14:02 -07:00
YHH 083d30bec8 Merge pull request #14416 from chuiwenwei/fix/lease-probe-asks-the-lock
fix(sessionstore): the lease probe asks the lock, not the sidecar
2026-10-25 00:13:21 -07:00
YHH d4d8876e92 Merge pull request #14423 from chuiwenwei/fix/history-keeps-the-arguments-it-restored
fix(tui): keep the arguments a restored tool call actually had
2026-10-25 00:13:01 -07:00
YHH 4af626e714 Merge pull request #14424 from esengine/c5-within-exact
fix(mutation): a prefix is exact only when its spelling agrees
2026-10-25 00:12:29 -07:00
YHH ffe06c2db8 Merge pull request #14422 from esengine/c5-gitin-stdout
test(worktree): gitIn returns standard output only
2026-10-25 00:12:07 -07:00
wanjinhao1 3154e9163d Fix #14427: report show/delete with unknown ID on empty store exits 1 with not-found error 2026-10-25 15:09:28 +08:00
SivanCola 5671e00f3f ci: require mutation owner coverage and probe capacity without a latency alarm 2026-10-25 15:08:12 +08:00
esengine c3d192dc4b fix(cli): serve binds its address before it reports anything
Only a serve with --port-file or --open listened first; a plain serve printed the
banner, share link, token path and the non-loopback warning and bound afterwards, so
an address that can never work still looked like a running server. Every command now
binds first, the report uses the bound address, and the now-unreachable
bind-inside-RunGraceful branch is gone.
2026-10-25 00:04:33 -07:00
esengine 5aa737bb95 fix(studio): centre the enlarged settings window's content column
.prefs-col had a max-width and no inline margin, so once the settings window was
enlarged the column hugged the navigation and every extra pixel piled up on its
right. In the enlarged mode the column is now centred in the room beside the nav.
2026-10-24 23:53:26 -07:00
SivanCola 24a775a94c fix(worktree): preserve literal paths through Git application
Problem: a candidate filename could act as a Git wildcard or magic pathspec,
writing an undeclared source file or dropping the actual literal result.
Newline-delimited hashing could also reinterpret a single filename.

Root cause: option separation was mistaken for literal path selection, and
ambient Git pathspec flags could override the host's intended selection.

Fix: centralize literal pathspecs for tree/index inspection and the shared
restore owner; C-quote hashing input by bytes; remove inherited selection
flags in the common host Git environment while preserving explicit flags.
Whole-workspace application reuses the shared writer. Keep the portable
stdout/stderr Git fixture repair aligned with the native CI follow-up.

Verification: complete 239-package root inventory, three-owner race, real boot
race, uncached required builtin/boot, compiling predecessor controls, six
Windows/Linux owner cross-compiles, formatting, root vet, lint and make check.
Native hosted checks and independent review remain separate gates.

Refs #14025
Refs #14133
2026-10-25 14:51:01 +08:00
esengine b26eddec18 fix(mutation): a prefix is exact only when its spelling agrees
withinExact used filepath.Rel, which ignores case on Windows, so a different
spelling of an absent file was reported as held there while heldWithin's comment
and TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings require the filesystem to
prove two spellings are one file. Require the prefix bytes to agree as well.
2026-10-24 23:43:39 -07:00
Will aeeff97700 fix(tui): keep the arguments a restored tool call actually had
The terminal's copy of /history's tool call had no argumentNormalization
field, so a restored transcript rebuilt the card from Arguments alone —
the proxy's effective text — and drew it as what the model wrote. The
original was not merely hidden; it never left the decoder.

Carry the host's normalization through HistoryToolCall into the
eventwire.Tool the restored transcript draws and inspects, following the
serve payload and the desktop copy that already do. The card still draws
only the effective arguments: showing the original is a UI decision for a
later change, and changing the card would also change a live call.

Register the pair as a declared subset of the serve historyToolCall in
wireparity, naming the six fields the terminal decides from. The exact
table is the desktop's and is read as a TypeScript interface, so a Go
reader belongs in the partial table, which compares both ways over the
declared names.
2026-10-25 14:41:21 +08:00
esengine 15f72be36f test(worktree): gitIn returns standard output only
The helper used CombinedOutput, so on Windows, where core.autocrlf is true, git's
'LF will be replaced by CRLF' warning on stderr was returned with the blob id and
TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_type passed the pair to
update-index --cacheinfo. Standard output is what a caller reads; standard error
now only joins a failure message.
2026-10-24 23:38:00 -07:00
YHH 8bd18a5db4 Merge pull request #14233 from lancy69/fix/14220-execution-typography
fix(studio): execution typography follows body preferences
2026-10-24 23:36:49 -07:00
SivanCola eab502bd8e test(worktree): keep Git diagnostics out of object IDs
Problem: the newly executed native Windows owner inventory failed while
constructing its candidate-type fixture, before product application ran.

Root cause: gitIn returned CombinedOutput, so a successful hash-object CRLF
warning on stderr was concatenated with its object ID and fed to update-index.

Fix: keep stdout and stderr separate at the shared fixture owner, retaining
both in failure diagnostics. Pin autocrlf=true in the compatibility regression
so this contract is exercised on every host. No production or gate changes.

Verification: the preceding helper compiles and reproduces the same diagnostic
pollution locally; the complete worktree race, Windows compilation, required
builtin/boot, vet, lint and make check pass. Hosted native revalidation remains
separate from local and earlier full-root evidence.
2026-10-25 14:35:10 +08:00
YHH 1aa101e6a9 Merge pull request #14412 from esengine/c5-measure-forgets-drag
fix(studio): a line-width preset wins over a width the column was dragged to
2026-10-24 23:31:33 -07:00
Will 495f8ede4c fix(stats): record the tokens the price came from 2026-10-25 14:16:21 +08:00
Will de2256dfc4 fix(sessionstore): the lease probe asks the lock, not the sidecar
A live holder keeps the OS lock for its whole lifetime, and its `.lease.json`
sidecar can be deleted by a person or a file scanner while the process still
owns the session. The probe answered free on a missing sidecar without looking
at the lock, so session list said idle, task list flipped a running item to
interrupted, and redact-sessions would rewrite a live transcript. It now asks
the lock whenever a lock file exists, and still creates nothing on the common
uncontended path.
2026-10-25 14:06:30 +08:00
Will 0e63e353fe fix(sessionstore): the lease probe asks the lock, not the sidecar
A live holder keeps the OS lock for its whole lifetime, and its
`.lease.json` sidecar can be deleted by a person or a file scanner while the
process still owns the session. The probe answered free on a missing sidecar
without looking at the lock, so session list said idle, task list flipped a
running item to interrupted, and redact-sessions would rewrite a live
transcript. It now asks the lock whenever a lock file exists, and still
creates nothing on the common uncontended path.
2026-10-25 14:04:37 +08:00
SivanCola 3fa1ea460c ci: execute complete isolation owners on native Windows
Problem: green PR checks could omit complete isolation/worktree owner tests on
Windows and under Linux race; pure isolation changes could skip native legs.

Root cause: the smoke and race package allowlists lacked both owners, while
the platform classifier treated untagged isolation Go as neutral.

Fix: classify isolation paths as platform-relevant; execute complete Windows
owners with an uploaded JSON inventory, require both package terminals and
portable boundary tests, and include both packages in PR race. Replace the
redundant rollback subset. Preserve existing permissions, deadlines and cache
rules; do not alter product code.

Verification: compiling old-workflow/classifier regressions, exact inventory
positive/negative controls, final classifier/testenv suites, complete 238-package
root inventory, three-owner race, required builtin/boot, vet, lint and check.
Local execution is macOS; native hosted outcomes remain a separate gate.

Refs #14025
Refs #14133
2026-10-25 14:02:42 +08:00
YHH bf472862ca Merge pull request #14368 from SivanCola/codex/mutation-primitive-14025
feat(state): add mutation transaction primitive / 写入事务基础机制与测试
2026-10-24 22:59:58 -07:00
esengine d28a6089fc fix(studio): a line-width preset wins over a width the column was dragged to
The width a hand set is kept in --flow-w and outranks the stylesheet, so once the
reading column had been dragged, choosing the full-width preset changed --doc but
not the column and did nothing. Choosing a preset now drops the dragged width.
2026-10-24 22:54:22 -07:00
YHH 8c669ebe61 Merge pull request #14410 from SivanCola/codex/worktree-entry-modes-14025
fix(worktree): compare file modes during integration / 修复隔离整合丢失文件模式与类型
2026-10-24 22:35:06 -07:00
YHH df14e2f085 Merge pull request #14409 from SivanCola/codex/isolation-operation-leases-14025
fix(isolation): serialize result operations through cleanup / 隔离结果操作统一跨进程租约
2026-10-24 22:33:54 -07:00
SivanCola 600b4b8cb4 fix(worktree): compare entry modes during isolated application
Problem: executable-only changes and equal-byte regular/link changes could be
discarded as already applied, and source metadata edits could be overwritten.

Root cause: baseline, candidate and live file versions retained only blob IDs,
collapsing distinct Git modes and file kinds into one identity.

Fix: compare Git mode and blob together, follow the checkout's trusted/index
mode rules, hash real links and emulated links with their actual Git semantics,
and reject unsupported transitions before any source write. Keep the user's
index and configuration unchanged.

Verification: complete 238-package root inventory, worktree/isolation race,
real boot race, required builtin/boot, compiling predecessor controls and
operation-lease composition. Formatting, root vet, lint and make check pass.
Cross-compilation is recorded separately from pending hosted/native review.

Refs #14025
Refs #14133
2026-10-25 13:08:35 +08:00
EMEEEEMMMM e01aae97fd fix(studio): wrap wallpaper scene parse errors 2026-10-25 13:02:47 +08:00
SivanCola 36cb5e6c7b test(filelock): mirror relative-root rename admission
Problem: the rooted-lock rename regression failed on Windows before reaching
the lock operation.

Root cause: Go's direct os.OpenRoot uses a Windows handle without delete
sharing, while production opens the lock directory relative to its managed
Root using an Openat handle with delete sharing. The fixture conflated them.

Fix: open the test directory through its parent Root, matching the production
constructor. Retain the real rename/replacement and physical lock-location
assertions on every platform. Document the direct-root Windows limitation.
No production code, timeout or platform skip changes.

Verification: complete filelock race (25 passes), Windows amd64 compilation,
required builtin/boot, root vet, full lint and make check. The prior full root
qualification is retained separately; new hosted Windows evidence is pending.
2026-10-25 13:00:44 +08:00
EMEEEEMMMM aaaaf4d2ef fix(studio): address wallpaper scene review 2026-10-25 12:42:39 +08:00
SivanCola d1fe589bf8 fix(isolation): serialize result inspection and settlement
Problem: concurrent Stores could inspect one retained result and then write
or remove it while another operation was still using its candidate.

Root cause: reinspection checked authority without retaining a common owner.
Paths, first Finish, Abort and expiry cleanup could bypass Apply/Discard too.

Fix: add directory-handle OS file locks and one persistent per-result mutex
namespace. Hold admission through reinspection, mutation and synchronous
cleanup; preserve lock identity, active owners and live-only Finish retries.
Forward path-query cancellation and keep legacy writer limitations explicit.

Verification: complete 238-package root inventory, owner and real boot race,
compiled predecessor regressions, actual prior reader/sweeper compatibility,
Windows/Linux owner cross-compilation, formatting, vet, lint and make check.
No new native-platform or independent-review result is claimed here.

Refs #14025
Refs #14133
2026-10-25 12:33:34 +08:00
EMEEEEMMMM 89935ce452 fix(studio): keep scene controls out of the composer layout 2026-10-25 12:27:43 +08:00
EMEEEEMMMM db7a840a3f docs(release-notes): name the Wallpaper Engine scene import 2026-10-25 12:27:43 +08:00
EMEEEEMMMM b242c67680 feat(studio): import Wallpaper Engine scenes with sound and volume 2026-10-25 12:27:43 +08:00
YHH 8ebd327b12 Merge pull request #14408 from esengine/c5-actions-split
refactor(studio): move the feedback actions out of the full actions file
2026-10-24 21:21:18 -07:00
Lancy 04bdd5702b fix(studio): execution typography follows body preferences 2026-10-25 12:15:49 +08:00
esengine 811e335a89 refactor(studio): move the feedback actions out of the full actions file
actions.ts sat exactly at the 800-line ceiling, so any pull request that added a
single action id failed repolint. The 44 feedback.* ids are one concern and move
to actions_feedback.ts, the way the market, schedule and document groups already
live in their own files; the registry content is unchanged.
2026-10-24 20:55:34 -07:00
YHH 7a530b37ee Merge pull request #14405 from SivanCola/codex/checkpoint-cancellable-admission-14025
fix(checkpoint): cancel admission and preserve other writers / 取消检查点等待并保护其他写入者
2026-10-24 20:51:30 -07:00
SivanCola ee9f75e286 fix(checkpoint): cancel admission before invocation entry
Problem: cancelled calls remain parked behind checkpoint exclusivity, delay
their provisional resource cleanup, and may later enter hooks or execution.

Root cause: condition waits do not consume the invocation context. Observer
cleanup also exposes a shared writer-count decrement that can run twice.

Fix: centralize context-aware barrier admission, recheck cancellation after
durable preparation, return idempotent permits and bind the Agent caller.
Preserve completed undo retirement and every other active owner's admission.

Verification: deterministic cancellation and owner-preservation schedules,
compiling predecessor behavior failures, real boot direct/deferred coverage,
durable undo reloads, full root inventory, owner/boot race and required suites;
gofmt, vet, full lint and make check with unchanged gate baseline.
2026-10-25 11:14:07 +08:00
YHH e29812d38f Merge pull request #14404 from esengine/c5-edit-crlf
fix(edit): a literal edit match keeps the line ending of the line it lands in
2026-10-24 19:53:30 -07:00
YHH e4779b9f95 Merge pull request #14402 from SivanCola/codex/isolation-cached-result-14025
fix(isolation): reject settled result caches / 拒绝已处理成果的过期缓存
2026-10-24 19:53:13 -07:00
esengine 772b2f1de6 fix(edit): a literal edit match keeps the line ending of the line it lands in
The literal branches of applyOldStringEdit wrote the replacement verbatim, so a
multi-line new_string inserted bare LF into CRLF files while only the fuzzy paths
converted it. Each literal replacement now takes the ending of its own line, which
also keeps an LF region of a mixed file free of stray CR.
2026-10-24 19:28:14 -07:00
SivanCola 749e691863 fix(isolation): reinspect published results before settlement
Problem: a Store could apply an old cached Git tree after another Store had already applied or discarded that result, resurrecting its bytes in the source workspace.

Root cause: cache hits skipped durable publication and candidate admission; Git objects survive removal of the worktree. Refreshing without comparing the previous observation would also silently widen an earlier path declaration.

Fix: always reuse durable recovery/inspection for lookups, keep finished caches out of Pending, and refuse changed baseline/candidate/target trees until fresh path admission. Preserve first-Finish live-only and unsupported-version checks. No persisted, permission, tool-definition or default changes. Cross-process settlement locking and journals remain separate.

Verification: final complete 238-package root inventory passes (217 pass/21 no tests;23310 test/subtest passes,215 existing skips,194 valid cached packages). Full isolation race52 passes and real boot focus31 passes with no skips. The accepted predecessor Store compiles and fails9 behavior events. Gofmt, vet, full lint, make check and diff checks pass. Additional required builtin/boot command is running; new hosted/native evidence remains pending.
2026-10-25 10:20:29 +08:00
YHH e59262c519 Merge pull request #14401 from SivanCola/codex/isolation-runtime-options-14025
fix(isolation): honor task runtime options / 隔离任务遵守模型与步数参数
2026-10-24 19:06:13 -07:00
YHH 35003e6628 Merge pull request #14400 from SivanCola/codex/isolation-first-finish-14025
fix(isolation): retain first-Finish failures / 首次成果检查失败保留隔离结果
2026-10-24 19:05:34 -07:00
SivanCola cbac083536 fix(isolation): preserve task runtime selection and step caps
Problem: worktree task dispatch silently discarded effort, step limits and configured subagent models; parent model aliases also lost live session effort overrides.

Root cause: isolation branched before ordinary task runtime resolution, and alias resolution reloaded config instead of retaining the canonical parent runtime entry.

Fix: reuse shared task/model/effort/budget resolution, pass ephemeral options to the unattended Build, and canonicalize parent aliases to their runtime entry. Existing unsupported isolation restrictions remain refused; best-of keeps its prior policy. No persisted, wire, tool-definition or default changes.

Verification: complete 238-package root inventory passes (217 pass/21 no tests; 23289 test/subtest passes, 215 existing conditional skips, 198 cached packages). Owning delegation/isolation race has 400 passes; real boot focus has 39 passes, no skips. Compiling old owners fail 18 runtime scenarios plus parent, with two automatic-budget controls passing. First-Finish composition race has 32 passes. Gofmt, vet, full lint, make check and diff checks pass. Supplemental required builtin/boot is still running; hosted/native results remain pending.
2026-10-25 09:46:07 +08:00
SivanCola cf5fedae64 test(isolation): cover CRLF checkout during first-Finish recovery
Problem: Windows CI rejected successfully recovered isolated output because the new boot assertion required LF bytes.

Root cause: Git checkout may apply core.autocrlf conversion; recovery preserves the expected text with CRLF.

Fix: explicitly enable autocrlf in the disposable repository and normalize CRLF for the content assertion, retaining same-ID recovery and source-before-apply checks.

Verification: the old assertion compiles and deterministically fails with CRLF on macOS; the repaired four boot regressions pass under race (6 test/subtest events). Required builtin/boot pass (2291 passes, 66 existing conditional skips); vet, lint, make check and formatting pass. Native Windows replacement CI remains pending.
2026-10-25 09:36:50 +08:00
SivanCola d816d53347 fix(isolation): retain results after first finalization failure
Problem: isolated work could lose its recovery ID or be removed when the
first candidate inspection or completed-result publication failed.

Root cause: Finish treated inspector/persistence errors like successful
empty results, and Execute hid Finish errors behind a runner failure.

Fix: retain the candidate and original ID, publish a separate versioned
inspection marker without overwriting existing records, reuse rooted
admission and same-ID inspection, and report live-only retry explicitly
when recovery publication is unavailable. Join both errors. Keep normal
completed records unchanged and refuse unsupported inspection versions.

Verification: complete 238-package root inventory, isolation and real boot
race tests, compiling preceding-owner behavior failures, actual previous
reader/sweeper compatibility controls, formatting, vet, lint and check.
Local evidence is macOS only; hosted checks and independent review remain.
2026-10-25 09:01:57 +08:00
YHH 5b2b297e6e Merge pull request #14399 from SivanCola/codex/hook-checkpoint-admission-14025
fix(agent): admit reader hook writes through checkpoints / 只读调用 hook 纳入检查点写入准入
2026-10-24 17:49:06 -07:00
SivanCola ab68ff145f fix(agent): admit reader hook effects through checkpoint barrier
Problem: A reader's hooks can change files without participating in rewind
exclusion, retiring undo, or recording the checkpoint's coverage gap.

Root cause: The invocation caller selects checkpoint admission using only the
concrete tool's read-only flag, even though it already knows that its hook set
may write. The checkpoint owner is correct but this caller bypasses it.

Fix: Include possible tool-hook writes in the two existing admission/coverage
conditions. Reuse the existing writer permit and deferred cleanup. Keep empty
hook readers unchanged and keep tool execution, coverage, and effects separate.

Verification: Compiling original owners fail the actual callback/undo and four
boot regressions; fixed owner/checkpoint and boot race tests pass. Full root
inventory, required builtin/boot, formatting, vet, lint and make check are
qualified separately in the PR. Asynchronous hook process lifetime and the
barrier's existing cancellation limits remain explicit follow-up work.
2026-10-25 08:14:28 +08:00
YHH ffccf6877c Merge pull request #14397 from SivanCola/codex/provisional-write-admission-14025
fix(lease): release provisional write claims / 撤销未执行写入的临时占用
2026-10-24 17:03:54 -07:00
YHH b786e30b7a Merge pull request #14398 from SivanCola/codex/hook-workspace-invalidation-14025
fix(agent): preserve hook workspace invalidation / 保留 hook 工作区失效通知
2026-10-24 16:48:39 -07:00
SivanCola 72b725862f fix(agent): preserve workspace invalidation for hook effects
Problem: A hook can change files before refusing a tool or outside a reader's
declared paths without notifying an opted-in workspace sink. Nested writers
also omit publication until an outer writer happens to complete.

Root cause: Invalidation is gated by concrete tool execution and classification,
and publication belongs to the batch caller rather than the invocation owner.

Fix: Track possible tool-hook effects separately from Executed, publish through
the shared invocation exit after cleanup, and share result metadata projection
between batch and nested calls. Unknown hook scope invalidates content, tree and
Git metadata without claiming a confirmed write or widening authorization.

Verification: Full root 238-package inventory passed; complete agent/event race,
eight boot direct/capability hook cases under race, original-owner compiling
negative controls, and provisional-lease composition passed. Formatting, vet,
full lint and make check passed without changing the baseline. Local evidence
is macOS only; optional sink wiring and evidence accounting remain separate.
2026-10-25 07:29:49 +08:00
SivanCola bc330925c0 fix(lease): separate run and yielded-claim lifetimes
Problem:
Aborting provisional admission can expire an unrelated live user wait or
retake paths deliberately yielded to a user. A pending wait is also lost
when the last writer or an earlier restoration finishes afterward.

Root cause:
Physical release reset run state, and one retained extent represented both
active and parked ownership. Restoration dropped its temporary protection
before reholding the original caller. Expired queued grants and retention
callbacks could contribute resources to the next run.

Fix:
Separate active and parked retention, advance generations at the final
participating run end, and reconcile outstanding yields whenever writing
holds drain. Keep restoration protection through rehold, fence late
admission and retention, and clean expired grants without dropping current
writers. Actual in-flight writes stay protected after their run ends.

Verification:
The submitted owner compiles and fails all 14 selected test/subtest failure
events across the new public-API schedules. Corrected full workspacelease
and agent race pass, as do actual boot race, all 238 packages in disjoint
required/remaining partitions, required builtin/boot, vet, lint and check.
Local evidence is macOS only; hosted checks and review apply to the new head.

Refs #14025
Refs #14133
2026-10-25 06:50:34 +08:00
SivanCola affb71f9dd fix(lease): release provisional paths before mutation entry
Problem:
A refused write can pin a never-executed path through later model requests.
Repeated failed restoration can also report success to an expired caller.

Root cause:
Admission merges every request into the run-retained extent before local
preparation succeeds. Call cleanup only decrements in-flight accounting.
Resume keeps its once-only result in per-invocation locals, and refused
upgrades omit the terminal wait notice after registrar contention.

Fix:
Track identified provisional calls separately from retained extents and
publish their remaining union through the existing claim registrar. Retain
before possibly writing hooks or execution. Preserve earlier writes and
parallel claims, report failed cleanup conservatively, validate lifetimes
before and after reacquisition, and cache terminal callback outcomes.
Close registrar waits on refusal and clear completed cleanup's wait state.

Verification:
Complete 238-package inventory and required builtin/boot pass on the final
source; full workspacelease/agent race and focused real boot race pass.
Compiling original execution, resume and upgrade-wait owners fail the
selected deterministic regressions. Formatting, vet, lint and check pass.
Local platform evidence is macOS only; hosted checks and review are separate.

Refs #14025
Refs #14133
2026-10-25 05:36:29 +08:00
YHH 7adc244b94 Merge pull request #14396 from esengine/docs/release-studio-2.51.0
docs(release): studio 2.51.0 notes
2026-10-24 14:28:52 -07:00
esengine 453eee6b69 docs(release): studio 2.51.0 notes
Gathers the 38 fragments since 2.50.0 into one note: video wallpaper, find
case/regex switches, shortcut sheet, paged transcript loading, a narrower
reading column, dispatch trust and the delegation write-safety fixes.
2026-10-24 14:05:54 -07:00
YHH ce57da64cf Merge pull request #14366 from SivanCola/codex/transcript-paging-ui
feat(studio): load transcript pages incrementally / 正文渐进分页界面
2026-10-24 13:56:18 -07:00
YHH c0e99ad1a3 Merge pull request #14394 from SivanCola/codex/optimistic-call-admission-14025
fix(agent): coordinate optimistic child writes / 修复乐观子代理具名写入绕过占用
2026-10-24 13:31:41 -07:00
YHH 33ab226422 Merge branch 'studio' into codex/transcript-paging-ui 2026-10-24 13:30:24 -07:00
YHH 15652de96a Merge pull request #14392 from SivanCola/codex/isolation-record-boundary-14025
fix(isolation): verify recovered record identity / 校验隔离恢复记录的宿主身份
2026-10-24 13:30:17 -07:00
YHH ce7baf7e75 Merge pull request #14365 from SivanCola/codex/history-paging-api
feat(history): add cursor-based history API / 独立游标分页接口
2026-10-24 13:29:55 -07:00
SivanCola 11652b0146 test(studio): preserve API-first history route gate
Problem: standalone UI route checks require the paging API to have landed.

Maintainer guidance confirms that this check should remain a landing
dependency until #14365 is merged and the UI branch updates its studio base.
Restore the original route scanner and keep the HTTP fallback integration
tests, which prove runtime compatibility independently of landing order.

Verification: the original route test passes in the combined API/UI tree.
The standalone failure remains expected until the API base update. Vet,
complete lint, check and builtin/Boot validation pass without changing
production code, timeouts or coverage floors.
2026-10-25 04:06:37 +08:00
SivanCola ffc054a483 fix(agent): admit optimistic child writes through the shared scheduler
Problem: optimistic children can write paths held by live sibling tasks,
including through dynamically resolved local capabilities.

Root cause: whole-workspace optimistic grants bypass explicit-fence wrapping,
child options omit the shared scheduler, and parent admission skips children.

Fix: inherit the existing scheduler and admit actual named targets at execution,
using the actor grant's root and one complete resource set. Retain parent and
explicit-fence ownership, opaque tool availability and existing permissions.
Leave observation roots, provider prefixes and tool definitions unchanged.

Verification: compiling old owners write held files in four real boot task/fleet
direct/capability cases; fixed cases refuse overlap and preserve disjoint writes.
Full238-package root, required builtin/boot, complete three-owner race and focused
real assembly race pass. Vet/lint/check pass without raising the baseline.
Native hosted checks and independent review remain separate delivery gates.
2026-10-25 03:58:01 +08:00
SivanCola a60af4d12f fix(i18n): complete standalone paging refusal translations
Problem: the API-only PR failed the English refusal catalogue check.

Root cause: splitting the implementation retained the refusal codes but left
their English wording only in the UI branch.

Fix: include the same two English translations in the API branch, without
adding UI implementation.

Verification: reproduced the catalogue failure; all 170 i18n tests, typecheck,
vet, full lint, check and builtin/Boot suites pass. Existing catalogue
assertions and budgets are unchanged.

Place the shared refusal translations in EN_KERNEL on both branches so the
independent API and UI changes compose without a translation conflict. All
170 locale tests and typechecks pass on each branch.
2026-10-25 03:56:57 +08:00
SivanCola 7096a83db3 test(studio): validate optional history paging fallback
Problem: standalone paging UI failed the CI serve route contract.

Root cause: the scanner assumed every frontend endpoint was mandatory, while
paging explicitly supports older servers through full-history fallback.

Fix: declare only the exact paging call optional, require its fallback route,
and exercise the HTTP adapter and hook across unsupported and real failures.

Verification: reproduced the original failure; route tests, 19 frontend tests,
typecheck, vet, lint, check and builtin/Boot suites pass. Covergate tests pass;
unchanged computer coverage is 88.2% on macOS versus the 89.8% floor, also
reproduced on the sibling branch. Ubuntu coverage remains for CI.

Place the shared refusal translations in EN_KERNEL on both branches so the
independent API and UI changes compose without a translation conflict. All
170 locale tests and typechecks pass on each branch.
2026-10-25 03:56:57 +08:00
YHH dc7489570a Merge branch 'studio' into codex/history-paging-api 2026-10-24 12:29:21 -07:00
SivanCola aa694db5a3 fix(isolation): confine recovered-record queries to opened roots
Problem: CodeQL found two new uncontrolled-path stat alerts in record admission.
The first validator also read a claimed Git administrative backlink before
confining that path to the host-opened common directory.

Root cause: comparing embedded paths with global os.Stat did not itself provide
a filesystem namespace boundary, and marker discovery still used path globs.

Fix: enumerate and read slots through the opened managed root, perform identity
stats through root-relative handles, and constrain candidate/common metadata
reads before opening a backlink. Derive effective identities from checked host
roots, retain unknown fields unchanged, and treat brackets in root names literally.
Existing alias resolution remains metadata-only and grants no content-read scope.

Verification: final 238-package root inventory and required builtin/boot pass;
complete isolation race has 32 passes and real boot isolation race has five,
without skips. Six old-owner behavior groups compile and fail, namespace and
literal-directory regressions pass, and vet/lint/check retain the same baseline.
The replacement hosted CodeQL verdict and independent review remain pending.
2026-10-25 03:22:32 +08:00
SivanCola 2be21f5e53 merge: preserve first-save rail stars when syncing studio
Problem: the transcript paging UI conflicts with the upstream first-save bookmark fix. Root cause: both changes update the Pane-to-Transcript boundary, and retaining the runtime-id fallback loses unsaved stars on first save. Fix: merge studio f96ec2f869, retain session-path-only bookmark ownership alongside paging, and exercise both full and paged restoration. Verification: the upstream regression fails with the old scope and passes after resolution; 37 focused tests and 791 frontend files / 6343 tests pass, as do the production build, go vet, complete make lint and builtin/Boot suites. No lint baseline or deadline was changed.
2026-10-25 03:16:18 +08:00
YHH f96ec2f869 Merge pull request #14393 from xxoingr/fix/rail-star-before-first-save
fix(studio): a star placed before a conversation's first save stays with it
2026-10-24 12:04:48 -07:00
YHH cf077634d8 Merge pull request #14391 from xxoingr/test/final-observation-hang-budget
test(agent): wait out a held final observation, not a slow seal
2026-10-24 11:52:44 -07:00
SivanCola dcd09b8352 fix(isolation): bind recovered records to host-managed identities
Problem: a rebuilt store can trust a copied or mismatched durable record and
use its embedded candidate paths outside that record's actual managed slot.

Root cause: discovery checks the claimed workspace but does not bind snapshot
and candidate identity to the host-opened repository and observed marker slot.
Comparing Git markers only by inode also accepts cross-candidate hard links.

Fix: centralize record admission before every durable consumer; verify managed
directory membership, source identity, prefix roots and the linked candidate's
actual marker/backlink. Retain rejected records without rewriting metadata,
and resolve legacy identity through filesystem checks without launching Git.

Verification: five compiling old-owner negative-control groups fail as expected;
final 238-package root inventory and required builtin/boot pass, owning isolation
race has 30 passes and real boot isolation race has five, all without new skips.
Formatting, root vet, full lint and make check pass with the baseline unchanged.
Native hosted checks and independent review remain separate delivery gates.
2026-10-25 02:46:04 +08:00
xxoingr 48a1614e8c fix(studio): a star placed before a conversation's first save stays with it
An unsaved conversation scoped its rail stars by the pane's runtime id, and
the first save switched the scope to the session path, which reloaded from
an empty key: the star disappeared. The id is also a per-launch counter
(r1, r2, ...), so the key it left behind was read again by whichever unsaved
conversation pane r1 held after the next restart.

An unsaved conversation now keeps its stars in memory, and the first save
writes them under the session's path, merged with anything stored there.
The pane passes the session path alone as the scope.
2026-10-25 02:45:56 +08:00
YHH 65def98d70 Merge pull request #14387 from SivanCola/codex/role-fileview-ownership-14025
fix(agent): isolate review-role file views / 修复审查角色刷新执行代理读取记录
2026-10-24 11:29:07 -07:00
xxoingr 5230537980 test(agent): wait out a held final observation, not a slow seal
TestFinalObservationIsBoundedAfterACompletedAnswer gave Run one second
after cancel (three after the deadline) to come back. Run still seals the
contract and the bundle after its context ends, because the first lock
attempt does not wait; locally that takes about 18ms, but on a loaded
windows-latest runner it overran the window and the test reported a held
observation that was not there (studio runs 40110025629, 40064086851).

The observation the test holds never returns until it is released, so any
finite wait catches the regression; use testenv.Budget for both waits.
2026-10-25 02:16:00 +08:00
YHH e2a3342f8d Merge pull request #14390 from SivanCola/codex/isolation-inspection-retain-14025
fix(isolation): retain results on inspection errors / 修复恢复检查失败丢失隔离结果
2026-10-24 11:03:59 -07:00
SivanCola be47298166 fix(isolation): retain published results on recovery inspection errors
Recovery conflated a failed Git inspection with an empty result and erased
the durable marker before attempting worktree removal. Return inspection
errors without cleanup and report the existing unavailable refusal instead
of forgetting a valid result ID.

Exercise apply path discovery, Apply and Discard against real retained
worktrees after restarting the store and temporarily hiding Git. Preserve
marker bytes and work, then retry the same ID after Git returns. Keep age
expiration and pre-publication Finish recovery as separate lifecycle units.
2026-10-25 01:40:02 +08:00
YHH cf93de51be Merge pull request #14386 from SivanCola/codex/guardian-readonly-boundary-14025
fix(guardian): enforce resolved read-only execution / 修复审查角色动态写入越权
2026-10-24 10:29:13 -07:00
YHH 59bd6bc212 Merge pull request #14384 from esengine/c5-flow-narrow
feat(studio): the reading column can be dragged narrower than the readable measure
2026-10-24 10:29:04 -07:00
SivanCola efba49918c fix(agent): isolate planner and guardian file observations
Separate review-role FileViews from the executor and bind direct/deferred
local tools to one fresh record for each role. Install the planner frontend
before binding so its ledger/audit and view ownership survive boot wiring.
Keep the generic read-only filter a same-actor operation, and preserve the
existing nil-runtime planner adapter without changing its tool surface.

Add four real-boot stale-overwrite regressions, runtime/clone accounting
checks, a generic-filter compatibility case, and written security bounds.
The compiling old-owner overlay reproduces all four overwrites.
2026-10-25 01:12:51 +08:00
SivanCola 80c1b58c6e fix(guardian): enforce read-only execution after capability resolution
Guardian's visible registry was filtered, but its read-only capability proxy
could resolve an executor writer or a declared host mutation. Set the
permanent execution flag in the session constructor so actual targets are
checked before they run, without changing the review's executor approval.

Add local resolver and real boot regressions, with compiling old-owner
failures for both mutation classes and an actual file overwrite. Document
the authorization boundary and the separate file-view ownership concern.
2026-10-25 00:57:29 +08:00
YHH 0dd1a41153 Merge pull request #14382 from SivanCola/codex/fileview-ownership-14025
fix(agent): give each child its own file views / 修复父子代理文件读取记录共享
2026-10-24 09:52:40 -07:00
YHH 1c253bb218 Merge pull request #14379 from esengine/c5-first-run
feat(studio): a launch with no chosen or remembered project opens the app's own auto workspace, not home
2026-10-24 09:52:31 -07:00
esengine 4eb303b1ea docs(release-notes): reading column drag floor 2026-10-24 09:49:57 -07:00
esengine b0dac9152c feat(studio): the reading column can be dragged narrower than the readable measure
The drag floor was the readable measure itself (760), so the two presets were the
only widths a hand could reach on a typical window. A hand now reaches down to
640; the readable measure stays the untouched default and what Home returns to.
2026-10-24 09:47:32 -07:00
YHH 0357832547 Merge pull request #14348 from lancy69/fix/14326-workbench-context-menu
fix(studio): integrate workbench text context menus
2026-10-24 09:43:34 -07:00
YHH 55bbd301db Merge pull request #14381 from esengine/c5-dispatch-trust
fix(dispatch): trust an undecided project the user named in their own list
2026-10-24 09:33:27 -07:00
esengine 083a17ee02 fix(storage): list the auto workspace folder among the state root's entries
A first launch with nothing chosen creates <state>/workspaces/auto; the storage
panel and a state-root move must own it, or a move would strand it.
2026-10-24 09:32:30 -07:00
SivanCola 0e3fcb96ef fix(agent): give each child independent file observations
Problem: a child's read can refresh the parent's stale overwrite guard,
allowing the parent to discard an edit it has never read.

Root cause: copied file tools share FileViews, while deferred local tools
resolve through the root registry even inside nested children.

Fix: bind one fresh view per child to its direct and deferred file tools.
Keep disabled protection, tool identity, grants, read-only execution and
stable schemas intact. Install bindings on fresh capability frontends only.

Verification: original-owner controls reproduce the real boot overwrite;
a parent-pointer mapping control fails four nested cases, while the actor
binding passes all six shared-runtime/clone combinations. Root package,
owning race, composition, vet, check and lint evidence is recorded in the PR.

Refs #14025
Refs #14133
2026-10-25 00:13:36 +08:00
YHH e13e76e1c5 Merge pull request #14373 from esengine/c5-video-wallpaper
feat(studio): a wallpaper can be an MP4 or WebM video, played muted and looping
2026-10-24 09:06:31 -07:00
esengine d3880a6c83 fix(dispatch): trust an undecided project the user named in their own list
A dispatch target is resolved against the user's sidebar and the dispatch
switch is already the user's consent, so stopping on the folder's trust card
asked the same person twice. The folder is now recorded as trusted and the task
goes through; a folder that cannot be trusted whole still keeps its pane open on
the card with the task unsent.
2026-10-24 09:04:07 -07:00
YHH d561a40068 Merge pull request #14378 from esengine/c5-import-picker
fix(studio): the native session import card uses the app's button like the legacy one beside it
2026-10-24 08:54:44 -07:00
YHH fff33508f8 Merge pull request #14375 from esengine/c5-title-budget
fix(studio): automatic naming asks once more with room when the title model only thought
2026-10-24 08:42:12 -07:00
YHH 5e342dc0ec Merge pull request #14376 from imcreazy/fix/activity-line-one-type-scale
Set the activity line in one type scale
2026-10-24 08:41:00 -07:00
esengine 087ea05686 feat(studio): a launch with no chosen or remembered project opens the app's own auto workspace, not home 2026-10-24 08:39:37 -07:00
esengine 5f5a63afa6 fix(studio): the native session import card uses the app's button like the legacy one beside it 2026-10-24 08:38:22 -07:00
imcreazy 8aaa4af237 Set the activity line in one type scale
Its four spans were 11px/500, 11px/650, 9.5px/500 and 10px/450. The running
chip already has a wash behind it, so the heavier weight separates twice.
2026-10-24 23:19:36 +08:00
YHH 7c22c5340e Merge pull request #14372 from esengine/c5-arg-example
fix(tools): refusals of malformed calls, todo lists and diff evidence show what a valid one looks like
2026-10-24 08:19:03 -07:00
YHH 34e61e806d Merge pull request #14370 from esengine/c5-comp-fix
fix(studio): a conversation bound to an Agent preset continues when a built-in tool schema changed
2026-10-24 08:17:50 -07:00
esengine e401575036 fix(studio): automatic naming asks once more with room when the title model only thought 2026-10-24 08:14:50 -07:00
esengine 2b4ee07fd7 feat(studio): a wallpaper can be an MP4 or WebM video, played muted and looping 2026-10-24 08:11:52 -07:00
YHH 33abf2ef03 Merge pull request #14369 from esengine/c5-wallpaper-stream
feat(studio): wallpapers upload as a streamed body, have no size limit and are served in ranges
2026-10-24 07:59:52 -07:00
SivanCola ef34969818 Merge studio and drop obsolete design-document lint repair
Problem: Both extracted PRs became modify/delete conflicted while their local
qualification was running.

Root cause: Upstream removed FIRST_RUN_WORKSPACE.md in #14367 after the slices
had repaired its inherited lint metadata and paragraph violations.

Fix: Accept the upstream deletion and remove the now-obsolete prerequisite
repair. Retain every feature, regression and security-document change.

Verification: make check passes. The staged merge changes no Go file relative
to the independently qualified slice heads; their full package/race evidence
remains explicitly scoped to those source trees. Hosted checks must run on
the new merge heads. No unrelated production change or baseline relaxation.

Refs #14025
Refs #14133
2026-10-24 22:53:21 +08:00
SivanCola 7639c30135 Merge studio and drop obsolete design-document lint repair
Problem: Both extracted PRs became modify/delete conflicted while their local
qualification was running.

Root cause: Upstream removed FIRST_RUN_WORKSPACE.md in #14367 after the slices
had repaired its inherited lint metadata and paragraph violations.

Fix: Accept the upstream deletion and remove the now-obsolete prerequisite
repair. Retain every feature, regression and security-document change.

Verification: make check passes. The staged merge changes no Go file relative
to the independently qualified slice heads; their full package/race evidence
remains explicitly scoped to those source trees. Hosted checks must run on
the new merge heads. No unrelated production change or baseline relaxation.

Refs #14025
Refs #14133
2026-10-24 22:53:20 +08:00
esengine a5a7439b75 fix(tools): refusals of malformed calls, todo lists and diff evidence show what a valid one looks like 2026-10-24 07:50:23 -07:00
SivanCola fd8729692b test(worktree): reject stat-cache-hidden source overwrites
Problem: A snapshot-only regression does not establish that the shared tree
reader also protects the source workspace before Apply publishes a candidate.

Root cause: The old cached index can hide a user's intervening edit from the
whole-tree drift check, allowing Apply to return success and overwrite it.

Fix: Exercise actual snapshot/candidate/Apply assembly on disposable real Git
repositories. Require ErrWorkspaceMoved and unchanged user bytes. Document the
existing Git driver confinement and the additional rehashing cost explicitly.

Verification: A compiling overlay of the original production file fails for
both wrong success and actual overwrite; the repaired owner passes. Full final
worktree race passes (48.247s). Broader root and static qualification records
are retained separately; production code is unchanged by this test follow-up.

Refs #14025
Refs #14133
2026-10-24 22:48:54 +08:00
esengine 780b7fb1f9 fix(studio): a conversation bound to an Agent preset continues when a built-in tool schema changed 2026-10-24 07:38:15 -07:00
SivanCola 0f943c7f7a fix(worktree): rebuild private index before capturing content
Problem: A candidate snapshot can contain old bytes after the working file
changed while preserving its size and mtime.

Root cause: Copying the source index carries Git's trusted stat cache and
assume-unchanged state into StageAll, so staging can skip the actual bytes.

Fix: Preserve the copied index's staged tree, rebuild the private index from
that tree, then stage current content. Handle an unmerged private index first.
Keep staged ignored inputs, the real conflicted index, HEAD and refs intact.
Document concrete Git/path/authority boundaries and the unchanged need for
source coordination. Include the inherited documentation-lint prerequisite.

Verification: All three cache-policy variants fail against the original
function through a compiler overlay and pass with the repair. Full worktree
race passes, plus final expanded cache and conflict regressions. Check, lint
and root vet pass. Full builtin/boot exits zero (22.243s / 444.076s tests),
using a measured 15-minute package alarm with no product-deadline change.
The exact-commit full root sweep and hosted checks remain separate evidence.

Refs #14025
Refs #14133
2026-10-24 22:37:40 +08:00
esengine 3c3ba4395b feat(studio): wallpapers upload as a streamed body, have no size limit and are served in ranges 2026-10-24 07:35:07 -07:00
YHH 2a57596001 Merge pull request #14367 from esengine/c5-rm-firstrun
docs: remove the first-run workspace design (moved to #14363)
2026-10-24 07:34:40 -07:00
SivanCola db465ec26f feat(state): introduce independently reviewable mutation transactions
Problem: Task-length write claims cannot independently represent permission,
assignment and the physical mutation lifetime across cooperating runtimes.

Root cause: Shared admission, input preconditions, cancellation and release
have no common short-transaction owner that callers can adopt independently.

Fix: Extract the mutation resource/queue/capacity owner and content versions
from the preserved concurrency implementation. Add minimal non-blocking legacy
claim compatibility and exception-safe publication cleanup. Keep all runtime,
isolation, hook, controller and default activation outside this review slice.
Document authority, actual sinks, compatibility and unenforced boundaries,
and map the remaining source into dependent review units. Normalize an
inherited design-document lint prerequisite without relaxing its baseline.

Verification: make check, full lint and root vet pass. Owning-package race
passes, including real subprocess death and a strengthened cleanup regression
that fails without the cleanup via compiler overlay and passes with it.
Builtin passes. The full boot invocation reaches its 600-second package alarm;
all 811 top-level tests close in four inventory-verified groups (808 pass,
3 conditional skips, no missing/unexpected/failing selected tests). Linux and
Windows amd64 test binaries cross-compile; native platform and hosted CI
evidence are separate gates. The remaining root-package sweep is in progress.

Refs #14025
Refs #14133
2026-10-24 22:23:12 +08:00
esengine 4f896f2710 docs: remove the first-run workspace design (moved to #14363) 2026-10-24 07:20:48 -07:00
SivanCola 5846f3a226 fix(studio): continue history search after batched page commits
Problem: searching for a message outside the initial page could remain incomplete after one fast response. Root cause: the search effect depended only on busy/hasMore flags that React may batch back to their previous values. Fix: advance search on committed page revisions and release the completed single-flight request before publishing that revision. Verification: failing-before revision regression, 28 focused tests including scroll and anchor neighbors, and a browser replay fetching four pages and finding the first message.
2026-10-24 22:15:33 +08:00
SivanCola 591aac54e9 feat(history): expose bounded cursor paging independently of the UI
Problem: full transcript projection and transfer scale with conversation length. The session store now owns stable page snapshots and cross-page context, exposed through optional controller reads and an additive HTTP route. Existing full-history clients are unchanged. Validate through real Boot/resume, full-projection equivalence, cursor invalidation and append stability, request authority and foreground preemption. Focused history tests, race checks, Go lint and release-note tests pass; broad suites are recorded in the PR.
2026-10-24 22:13:10 +08:00
SivanCola 5d24185309 feat(studio): load transcript pages with stable reading anchors
Rebase the UI portion of #14087 onto current studio, preserving regex/case search and session-scoped navigation. Use optional paging with explicit unsupported-route fallback and retain mounted controls when prepending. Browser verification exposed zero-box display-contents wrappers; share rendered-box geometry with the landing writer and add a failing-before regression. Verification: 37 focused frontend tests, TypeScript checking, production build and wide/narrow light/dark browser renders; full-suite results and backend dependency are documented in the PR.
2026-10-24 22:13:10 +08:00
SivanCola 5810c1033b feat(history): define additive paging wire types
Define the optional history-page response and typed refusal translations shared by the API and UI splits. No caller switches to paging in this commit. Verification is performed on each independent split before publication.
2026-10-24 22:03:01 +08:00
YHH ef79a2215e Merge pull request #14335 from lancy69/fix/14324-remove-workbench-wide
fix(studio): remove redundant workbench widening mode
2026-10-24 06:58:22 -07:00
kevin9327 23c7322789 fix(gitignore): read info/exclude from the common dir when .git is a file
Signed-off-by: kevin9327 <[email protected]>
2026-10-24 22:56:14 +09:00
YHH 9614465f5b Merge pull request #14345 from 1012905/studio
feat(studio): cancel an update download from the Versions panel
2026-10-24 06:53:17 -07:00
YHH 80d90498c9 Merge pull request #14351 from Oliver-Rui/docs/first-run-workspace
feat(studio): first run without a picked workspace
2026-10-24 06:30:37 -07:00
YHH 1384c6db9d Merge pull request #14353 from esengine/c5-grip-quiet
fix(studio): the reading column's grips draw nothing until the pointer is on them
2026-10-24 06:29:58 -07:00
1012905 6aec414270 fix(desktop): register versions.cancel in the action registry 2026-10-24 21:18:43 +08:00
esengine 24df650056 fix(studio): the reading column's grips draw nothing until the pointer is on them 2026-10-24 06:11:25 -07:00
Oliver 50f7e2711b docs: add first-run workspace design proposal
Documents the last coupled first-run decision: with nothing remembered,
the first pane's workspace root falls back to the user's home directory
(pickLaunchWorkspace), so a session that was never given a chosen project
reads as temporary. Proposes a kernel-provisioned auto workspace instead,
modelled on MiMo Desktop's session-first behaviour (auto-created session
directories, global provider config, optional projects), and maps it onto
the existing launch chain, session store and workspace switch.
2026-10-24 21:08:48 +08:00
esengine 0d35e92bc4 docs(release-notes): keep the English item within the width budget 2026-10-24 06:05:11 -07:00
YHH 1862e06e2b Merge pull request #14344 from lancy69/fix/14323-skill-description-search
fix(completion): find slash skills by description
2026-10-24 06:04:32 -07:00
1012905 9bc6bfd45f Merge branch 'studio' of https://gh-proxy.com/https://github.com/1012905/DeepSeek-Reasonix into studio 2026-10-24 20:58:52 +08:00
1012905 b838d8d0ed Wait for cancelled install cleanup
Keep the install slot claimed until a cancelled download finishes cleanup, preventing it from cancelling a new install or deleting its files. Add regression tests and a release note.
2026-10-24 20:58:25 +08:00
1012905 6e3947fdfb Merge branch 'esengine:studio' into studio 2026-10-24 20:47:56 +08:00
YHH 336e4c71c4 Merge pull request #14347 from esengine/c5-feedback-ready
fix(studio): the feedback form takes a paste in the commit that draws it
2026-10-24 05:47:49 -07:00
Lancy a9dbff297d fix(studio): integrate workbench text context menus 2026-10-24 20:41:34 +08:00
esengine e1940bae40 fix(studio): the feedback form takes a paste in the commit that draws it 2026-10-24 05:29:25 -07:00
1012905 6d6990785e Add cancellation for version downloads
Expose download cancellation through the update API and Versions UI. Cancellation waits for the transfer to stop, removes its partial and cached artifacts, and reports a distinct non-failure state. Automatic updates do not retry a cancelled release but can proceed when a newer version is offered.
2026-10-24 19:59:33 +08:00
Lancy 9055a5cb63 fix(completion): find slash skills by description 2026-10-24 19:58:05 +08:00
YHH bcf61cf0bd Merge pull request #14342 from esengine/dependabot/npm_and_yarn/desktop/frontend-next/undici-8.12.3
build(deps): bump undici from 8.12.1 to 8.12.3 in /desktop/frontend-next
2026-10-24 04:52:43 -07:00
dependabot[bot] af7b5fabe0 build(deps): bump undici from 8.12.1 to 8.12.3 in /desktop/frontend-next
Bumps [undici](https://github.com/nodejs/undici) from 8.12.1 to 8.12.3.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.12.1...v8.12.3)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.12.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-24 11:21:06 +00:00
YHH a5db484ac3 Merge pull request #14340 from esengine/dependabot/npm_and_yarn/tools/acp-conformance/fast-uri-3.1.9
build(deps-dev): bump fast-uri from 3.1.8 to 3.1.9 in /tools/acp-conformance
2026-10-24 03:29:08 -07:00
YHH 3a0daf2abd Merge pull request #14339 from esengine/dependabot/npm_and_yarn/workers/forum/zod-4.6.6
build(deps): bump zod from 3.25.76 to 4.6.6 in /workers/forum
2026-10-24 03:04:11 -07:00
dependabot[bot] c6ba3522b9 build(deps-dev): bump fast-uri in /tools/acp-conformance
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.8 to 3.1.9.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-24 09:50:36 +00:00
dependabot[bot] 0da7d75b75 build(deps): bump zod from 3.25.76 to 4.6.6 in /workers/forum
Bumps [zod](https://github.com/colinhacks/zod) from 3.25.76 to 4.6.6.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.76...v4.6.6)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.6.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-24 09:50:24 +00:00
YHH da9fb7a9f9 Merge pull request #14213 from chuiwenwei/fix/import-completes-the-sidecar
fix(migrate): an imported session completes its sidecar
2026-10-24 02:04:06 -07:00
YHH a26dacdbb1 Merge pull request #14337 from esengine/c5-ask-draft
fix(studio): an unanswered ask card keeps its picks when its block unmounts
2026-10-24 01:29:36 -07:00
esengine 52dae508f7 fix(studio): an unanswered ask card keeps its picks when its block unmounts 2026-10-24 00:52:25 -07:00
Lancy 630b941e58 fix(studio): remove redundant workbench widening mode 2026-10-24 15:23:54 +08:00
YHH 42ec9546cf Merge pull request #14332 from jf200s/feat/browser-page-sleep
feat(browser): a page nobody drives sleeps, and wakes as the same target
2026-10-23 22:29:17 -07:00
F ee15d89fa2 fix(browser): the awake cap obeys the sleep switch too
Off means nothing sleeps, cap included: a person who never turned the
setting on kept losing the least recently used page's renderer when a
seventh page opened. The cap now runs only on a finite threshold, the
same one the sweep reads.
2026-10-24 13:06:41 +08:00
esengine 3621696b13 docs(release-notes): keep the English item within the width budget 2026-10-23 21:56:42 -07:00
YHH b19969e0b4 Merge pull request #14333 from lancy69/fix/14205-feedback-dialog-paste
fix(studio): route feedback image paste through the dialog
2026-10-23 21:52:54 -07:00
F e05325230c feat(settings): idle page sleep is off until the person turns it on
The rx-page-sleep desktop preference decides it, default off: prefs.js
owns the threshold, the shell reads it at boot and on every preference
save, and Settings -> Appearance carries the toggle. A woken page also
comes back fitted into its panel at the scale it had before the sleep.
2026-10-24 12:51:37 +08:00
F 8a3d9a6091 test(browser): a live probe that sleeps real pages and reports what it reclaimed
Five real Chromium pages through BrowserViews at a tiny threshold: the
working set they cost, the working set the sleep returns, a wake that
must answer against the page as it loaded, and a command the sleep
outlives that must fail rather than hang. Run with pnpm sleep-live.
2026-10-24 12:51:24 +08:00
Lancy 7bbbaf25dc fix(studio): route feedback image paste through the dialog 2026-10-24 12:21:29 +08:00
F 27b5dbd9a3 feat(browser): a page nobody drives sleeps, and wakes as the same target
An agent page's renderer is hundreds of megabytes the Studio pays for
whether anyone is watching. A page untouched for fifteen minutes and not
on screen is closed whole — address and a picture kept — under the same
targetId, so the kernel's handle survives; the next command, show, or
typed address rebuilds the view, replays the domains the kernel last
enabled (a domain switched off mid-session stays off), and answers only
once the page has loaded — a mid-load answer would describe the page it
was, and the kernel could not tell. A command the sleep outlives is
failed fast, for the session that dies never settles it and the kernel,
told nothing of a sleep, would wait on a page that will not speak. Six
awake pages at most — a wake yields no room: the page asked for
outranks the pages left alone — and eighteen asleep ones; past that the
oldest is buried and the kernel is told, the way a browser discards
abandoned tabs. REASONIX_NO_PAGE_SLEEP puts the feature away.
2026-10-24 11:52:23 +08:00
YHH 858a73d03f Merge pull request #14320 from imcreazy/fix/reading-column-grips
Let the reading column's grips, sidebars and composer move together
2026-10-23 16:04:11 -07:00
YHH f902caae16 Merge pull request #14322 from esengine/c5/compaction-notice
fix(compaction): a finished fold is announced with a code and a typed payload, so it follows the language
2026-10-23 15:54:22 -07:00
YHH 851f1dcf64 Merge pull request #14321 from imcreazy/fix/todo-panel-floats-over-the-column
Keep the plan panel from pushing the rail and the jump arrows
2026-10-23 15:53:19 -07:00
imcreazy 56b6f1a9b9 Leave the composer's own ceiling alone
It sized itself 42px inside the pane on purpose; pulling that to 34px to match
the text moved the mirror's wrapping off the caret's in composer-reflow.
2026-10-24 06:44:33 +08:00
YHH 2071971c8b Merge pull request #14300 from imcreazy/feat/keyboard-shortcuts-sheet
feat(studio): a shortcut sheet in the settings
2026-10-23 15:29:29 -07:00
esengine 769f4d1184 fix(compaction): a finished fold is announced with a code and a typed payload, so it follows the language 2026-10-23 15:18:40 -07:00
imcreazy 4a3ef66d9c Add the release note for the floating plan panel 2026-10-24 06:15:59 +08:00
imcreazy c3307775dc Keep the plan panel from pushing the rail and the jump arrows
Opening the todo list grew the composer, which shortened the scroller, which
re-measured the rail: every mark moved up and the two jump buttons went with
them. The panel now floats above the composer instead of taking its room.
2026-10-24 06:15:34 +08:00
imcreazy 9c7cb1aa70 Take the width away again when a first drag is cancelled
`App` owns `--flow-w` on `.app` only after a commit. Writing one back on
cancel would leave a pixel width nobody removes, pinning the column.
2026-10-24 06:08:30 +08:00
imcreazy 206d95dd8a Let the column's grips reach the sidebars and the composer
`--flow-w` went to `:root`, where a committed value on `.app` outranks it;
the sidebars only gave way on release, and the composer never followed at all.
2026-10-24 05:59:39 +08:00
imcreazy 237d63417a fix(studio): the key column holds the longest chord
`Enter / Shift+Enter` was wider than the shared minimum, so its description
sat 10px right of the others. 13em covers it.
2026-10-24 05:14:25 +08:00
YHH eeacfea88f Merge pull request #14296 from imcreazy/feat/conversation-rail-fold
feat(studio): fold a crowded conversation rail, and let a question be starred
2026-10-23 14:12:58 -07:00
imcreazy 6bf6b440ed fix(studio): the shortcut sheet lines its labels up
Each key cell was as wide as its own text, so the descriptions started at six different positions. A shared minimum on the key cell gives them one column.
2026-10-24 05:04:33 +08:00
YHH 27829cd3ff Merge pull request #14317 from esengine/c5/job-started-notice
fix(jobs): a started background job is announced with a code and its payload, so it follows the language
2026-10-23 14:04:16 -07:00
imcreazy 9068636c21 fix(studio): a rail fold never names a question a rewind removed
`groups` is a state slice; after a rewind shortens `marks`, a stale fold still pointed past its end and `marks[v.from]` was undefined. The fold is filtered to what exists.
2026-10-24 04:58:52 +08:00
YHH f08e2be652 Merge pull request #14294 from imcreazy/fix/run-analysis-way-in-and-out
fix(studio): make run analysis easy to enter and to leave
2026-10-23 13:55:58 -07:00
YHH 4975865891 Merge pull request #14299 from imcreazy/feat/session-export-markdown
feat(studio): export a conversation as Markdown
2026-10-23 13:53:01 -07:00
imcreazy 08158b6167 fix(studio): the rail's stars and folding on current studio
Rebased onto studio after #14301, which also touched Transcript.tsx.
2026-10-24 04:43:08 +08:00
imcreazy 6df4907583 fix(studio): a sidebar open reports its failure
`useSidebarOpen` dropped the promise with `void`, so `Workspaces` never saw the rejection and the row stayed on its old selection. It now returns the open's own promise.
2026-10-24 04:39:49 +08:00
esengine 935afed07c test(studio): saving a native archive as Markdown keeps its turns 2026-10-23 13:35:56 -07:00
YHH 660f2c9b8b Merge pull request #14301 from imcreazy/feat/find-regex-and-case
feat(studio): match case and regular expressions in the find bar
2026-10-23 13:35:22 -07:00
esengine 2184e43c60 fix(jobs): a started background job is announced with a code and its payload, so it follows the language 2026-10-23 13:32:42 -07:00
imcreazy 8032428bbf fix(studio): the back arrow names the view it returns to
It was a button with no declared action, which raised the undeclared input count. It sets the same view the sidebar uses.
2026-10-24 04:20:04 +08:00
imcreazy 453c9b7aeb fix(studio): 区分大小写 lives in one catalogue
`en_settings.ts` already defines it, and `i18n.test.ts` rejects a key that appears in two catalogue files.
2026-10-24 04:12:31 +08:00
imcreazy 51cb4202ba fix(studio): the export headings stay in the one catalogue
`en_export.ts` repeated 我 and 会话, and `i18n.test.ts` rejects a key that lives in two catalogue files. The headings now use the entries already there. The menu test follows the second export row.
2026-10-24 04:11:22 +08:00
imcreazy 3d5d965850 fix(studio): an empty match is not a hit
`^` and `a*` match the empty string, so the counter reported hits the painter had no range for. Both sides now skip them. Transcript.tsx keeps its 800-line ceiling by typing the mode inline instead of importing it.
2026-10-24 04:04:20 +08:00
imcreazy fe4b1d6d75 fix(studio): the count and the paint read the query the same way
The painter still searched the DOM for the literal text while the counter honoured the new modes, so a regex hit had no range to scroll to and a case-sensitive count did not match the painted one. Both now share one finder, a regex match advances past its own length, and a pathological pattern is cut short instead of freezing the window.
2026-10-24 03:56:58 +08:00
imcreazy 19bf655776 fix(studio): every shortcut row names the chord the window answers
The workbench chords are the platform modifier plus Shift, so a plain Shift+F types a letter. The zoom and modifier-Enter rows were missing.
2026-10-24 03:55:46 +08:00
YHH c8a7ab08c4 Merge pull request #14313 from jf200s/fix/cache-hit-rate
fix(stats): an endpoint's zero cache hit is a full miss, not an absence
2026-10-23 12:29:18 -07:00
esengine 9ad90c43bf docs(release): note for the cache hit rate fix 2026-10-23 12:05:51 -07:00
YHH 68e602fa97 Merge pull request #14316 from esengine/c5/fleet-test-race
test(delegation): observe the running fleet job before the children are released
2026-10-23 11:53:10 -07:00
esengine fee24df408 test(delegation): observe the running fleet job before the children are released 2026-10-23 11:38:24 -07:00
F 3f388550f7 fix(stats): an endpoint's zero cache hit is a full miss, not an absence
prompt_tokens_details with cached_tokens: 0 is the endpoint reporting the
split, so miss is derived from prompt - hit instead of staying zero and
vanishing into the unknown bucket; a hit above prompt_tokens is a broken
report and clamps to it. The overview and the meter floor the rate, so 100%
means every split token hit.
2026-10-24 01:43:05 +08:00
YHH 7bca7b7c7a Merge pull request #14306 from esengine/c5/release-2.50.0
docs(release): studio 2.50.0 notes
2026-10-23 09:53:00 -07:00
esengine e511c0beaa docs(release): studio 2.50.0 notes 2026-10-23 09:36:58 -07:00
YHH 0812e937c1 Merge pull request #14304 from esengine/c5/slash-typed-command
fix(studio): a command typed in full is still sent by Enter when longer commands share its name
2026-10-23 09:35:51 -07:00
YHH b4aa67ecb1 Merge pull request #14303 from esengine/c5/nav-label-center
fix(studio): a sidebar destination's label centres on its icon
2026-10-23 09:30:49 -07:00
imcreazy a55b816321 feat(studio): a shortcut sheet in the settings
A shortcut sheet in the settings.

Every chord the window answers to, on one page: find, search, the three views, stepping through
your questions, the workbench toggles, send, close, and the width keys. Chords are written the way
the platform writes them, so macOS shows ⌘ where Windows and Linux show Ctrl. It is a page of the
settings table of contents, so searching for "shortcut", "Esc" or "运行分析" lands on it.

The strings live in `en.ts` with every other catalogue entry: the i18n guard requires one
catalogue file, and an earlier revision of this branch split them out and failed it.
2026-10-24 00:17:25 +08:00
esengine ee4db04dd9 fix(studio): the sidebar's lower destination icons match the upper ones in size and weight 2026-10-23 09:12:20 -07:00
imcreazy 0948930529 fix(studio): make run analysis easy to enter and to leave
Make run analysis easy to enter and to leave.

- The entry in the window bar now carries its name. It needs an inline layout for that: the
  button is a 30px grid cell, so the icon and the label stacked and the text broke one character
  per line. `.chrome .thbtn.top-action` takes over the layout for this variant and keeps the
  label on one line; below 1180px the label steps aside so the other controls stay put.
- The analysis page has a back arrow beside its title.
- Picking a conversation that is already on screen returns its pane to the reading view instead
  of re-opening it. That rule now lives in `sidebaropen.ts` — partly because it is a rule about
  panes rather than window wiring, partly because `App.tsx` was 11 lines over its ceiling with
  it inline. It has its own test: open pane returns to reading, unopened opens a pane, a folder
  request opens a pane, and asking twice lands twice.

`Pane.tsx` differs from `studio` in one line (`onBack`), so the `useAgentRequest` wiring from
#14291 stays intact.
2026-10-24 00:09:14 +08:00
esengine c122a0c33a fix(studio): a command typed in full is still sent by Enter when longer commands share its name 2026-10-23 09:05:28 -07:00
imcreazy 70c77311c8 feat(studio): match case and regular expressions in the find bar
Match case and regular expressions in the find bar.

`usefind` now hands back `regex`, `caseSensitive` and `setMode` with the rest of its state —
the previous push declared them on `Finding` and never returned them, which is what
`frontend-next` caught. Changing either reading restarts from the first hit.
2026-10-23 23:59:55 +08:00
esengine aafd47647a fix(studio): a sidebar destination's label centres on its icon 2026-10-23 08:41:27 -07:00
imcreazy 4c9472e006 feat(studio): export a conversation as Markdown
Rewritten against the shape the kernel actually writes. A transcript line is a provider message
(`role`, `content`, `tool_calls`, `host_authored`), so the export keys on `role`: user and assistant
only, skipping host-authored user lines, tool results and system lines, and skipping assistant turns
that carry only tool calls. Headings go through `t()`. Native archives keep their `records` instead of
exporting an empty list. The test now uses real JSONL lines and covers the native-archive case.
2026-10-23 23:37:27 +08:00
YHH 80a3649883 Merge pull request #14293 from imcreazy/fix/reading-column-handles
fix(studio): make the reading column's handles visible and reachable
2026-10-23 08:31:34 -07:00
YHH 0586891b82 Merge pull request #14298 from xxoingr/docs/mcp-panel-not-mcp-add
docs(mcp): point a running chat at the /mcp panel, not /mcp add
2026-10-23 08:30:25 -07:00
YHH 498ac3a50f Merge pull request #14297 from xxoingr/test/two-asks-wait-for-the-yield
test(control): wait for the second ask to yield, not just to register
2026-10-23 08:12:40 -07:00
xxoingr d0baf85892 docs(mcp): point a running chat at the /mcp panel, not /mcp add
The controller runs only `/mcp connect` (#14167), so `/mcp add x` now
answers "unknown /mcp subcommand". Both guides and `reasonix mcp help`
still sent a running chat there to save and connect a server. They now name
`n` in the terminal /mcp panel, which installs through the same
POST /mcp/install as Desktop's Add and connect. The guides also name `p`,
the panel's project install, which InstallMCPServer writes to the
project's `reasonix.toml` (MCPConfigPathForEntry -> ProjectConfigPath).

TestTheDocsNameOnlyMCPAndSkillsSubcommandsThatRun reads docs/*.md and the
READMEs and holds every `/mcp <sub>` and `/skills <sub>` they write to the
subcommands completion offers, which are the ones the controller runs.
2026-10-23 22:57:57 +08:00
xxoingr bb7e1156f0 test(control): wait for the second ask to yield, not just to register
TestTwoAsksRetakeTheClaimOnlyAfterTheLastIsAnswered answered the first ask
as soon as two asks were registered. Ask registers before it yields the
workspace claim, and Yield reads the claim log from disk before it counts
the waiter, so on a slow Windows runner the second ask could still be short
of the yield when the first one resumed. The first resume then saw no other
waiter and took the claim back, and the test reported the claim retaken while
the second ask waited (studio 39941591112, windows-latest).

The test now waits for the second ask's prompt-queued notice. Ask reaches
lockPromptFor only after its yield, so the notice is the point at which the
second ask is waiting on the user.
2026-10-23 22:52:54 +08:00
imcreazy 9487d458ab fix(studio): make the reading column's handles visible and reachable
Rebuilt on current `studio`; the previous push was based on an older snapshot and carried unrelated reverts.
2026-10-23 22:51:53 +08:00
YHH 2639a5f526 Merge pull request #14292 from esengine/c5/billing-mode-selector
feat(providers): a connection says whether it is billed per use or by subscription
2026-10-23 07:31:55 -07:00
YHH 5345930379 Merge pull request #14291 from esengine/c5/agent-workbench-tab
feat(studio): a sub-agent opens beside the conversation as a workbench tab that reads its full record
2026-10-23 07:30:45 -07:00
YHH e4903bb43f Merge pull request #14063 from SivanCola/codex/present-files-settings
feat(studio): add opt-in file delivery settings / 新增文件交付卡片开关
2026-10-23 07:29:33 -07:00
YHH f22623f5d1 Merge pull request #14279 from tongsailong/fix/outside-observed-roots
fix(completion): report writes outside observed roots
2026-10-23 07:05:04 -07:00
esengine 1e4806ada6 feat(studio): a sub-agent opens beside the conversation as a workbench tab that reads its full record 2026-10-23 06:56:04 -07:00
YHH f07ec5db87 Merge pull request #14066 from SivanCola/codex/present-files-default-on
feat(tools): propose default-on file delivery / 提议默认启用文件交付卡片
2026-10-23 06:50:40 -07:00
esengine 66a992d41c feat(providers): a connection says whether it is billed per use or by subscription 2026-10-23 06:48:15 -07:00
YHH fbaa4595cd Merge pull request #14290 from esengine/c5/subagent-parent-call
feat(delegation): a sub-agent run names the tool call that spawned it
2026-10-23 06:35:31 -07:00
阿稳 62ea682420 test(completion): read the ledger's path identity in the out-of-sight tests
CI on Windows failed both new assertions: the detail named
`c:\outside-watch\reasonix cli.desktop` and the temp deliverable came
back lowercased, while the tests compared against the path spelled as
constructed. `evidence.normalizePath` lowercases on Windows, because the
ledger needs one identity per file and Windows hands back either case.

Compare against that identity instead of the constructed spelling: the
rule under test is about which paths are reported, not how the host
happens to capitalise them.
2026-10-23 21:32:36 +08:00
esengine 981d70747b Merge studio into present-files default-on 2026-10-23 06:29:20 -07:00
esengine 7eae730528 feat(delegation): a sub-agent run names the tool call that spawned it
Refs #13693
2026-10-23 06:15:55 -07:00
esengine 40a219f211 style(port): keep port.ts at the 800-line ceiling 2026-10-23 06:10:10 -07:00
阿稳 f3521711c7 test(completion): a granted root is not reported as out of sight
The gap names writes the host watches nothing of. A folder the person
granted as workspace is walked with the root and observed the same way,
so a write there is work product and must not carry the caveat.

This pins the rule at the boundary extraRoots decides, so a later change
cannot start reporting exactly the deliveries the grant exists to allow.
2026-10-23 21:06:11 +08:00
YHH 6e97bf257a Merge branch 'studio' into codex/present-files-settings 2026-10-23 05:39:43 -07:00
YHH 493736c685 Merge pull request #14152 from lancy69/fix/14126-minimal-image-capability
fix(composition): allow lightweight mode without image capability
2026-10-23 05:39:10 -07:00
阿稳 402eda1383 Merge remote-tracking branch 'origin/studio' into fix/outside-observed-roots 2026-10-23 20:37:13 +08:00
YHH 5e8d0d704a Merge pull request #14278 from tongsailong/fix/subagent-progress
fix(delegation): track background subagent progress
2026-10-23 05:12:26 -07:00
YHH e6ce624bf3 Merge pull request #14277 from tongsailong/fix/bounded-wait
fix(jobs): bound background wait timeout
2026-10-23 05:09:19 -07:00
阿稳 f2ff4b3425 Merge branch 'fix/outside-observed-roots' of https://github.com/tongsailong/DeepSeek-Reasonix into fix/outside-observed-roots 2026-10-23 20:06:57 +08:00
阿稳 dbcf4dc572 fix(hostwrite): read the home directory once for both lists
`Caches` read the process's home directory itself, which put the package two
reads over its `process-input` budget and the repo one over its ceiling. Both
lists answer from the same fact, so it is read in one place and passed to each.

The cache list also gained the two entries `Dirs` already had on the platforms
that have them: `$HOME/.cache` everywhere, and `Library/Caches` on macOS. A
cache filter that named only `go`, `.cargo` and `.npm` would report a Unix build
cache as out of sight on the one platform whose caches live under a different
directory name.
2026-10-23 20:06:21 +08:00
YHH edfc6b12b6 Merge pull request #14286 from esengine/feat/host-self-hosted-gateway
feat(remote): the desktop host and the phone page can use a gateway the person runs
2026-10-23 05:02:18 -07:00
YHH 061a1f69cc Merge branch 'studio' into fix/outside-observed-roots 2026-10-23 04:39:17 -07:00
YHH 30cc944d20 Merge branch 'studio' into fix/subagent-progress 2026-10-23 04:39:13 -07:00
YHH 9e60fd762a Merge branch 'studio' into fix/bounded-wait 2026-10-23 04:39:11 -07:00
esengine 24e6ccf64b feat(remote): the desktop host and the phone page can use a gateway the person runs 2026-10-23 04:38:13 -07:00
阿稳 f9de73aeda fix(completion): read build caches from their own source
Two defects, both found by CI on Windows and both in this PR's own new code.

`underScratchOrCache` asked `hostwrite.Dirs()` for the toolchain caches. That
function answers what a jail grants a command, and Windows jails nothing, so it
returns nil there: every build cache write was reported as out of sight on the
one platform where the list is empty. `hostwrite.Caches()` answers the question
actually being asked — where does a build write through — and is defined on every
platform. The two questions stay apart because conflating them is what made a
Windows build produce a caveat about its own module cache.

The one-off directory `sessiontemp` hands a turn is recognised by its position,
not by its name. It is a direct child of the host's temporary root, so that root
is read from the host and the first component below it is compared against the
prefix. The previous version walked every component, which exempted any folder
deeper in the tree that happened to spell the prefix, and it assumed the root was
named `/tmp`, which is not where Windows puts it.

The tests that pinned these edges set `HOME` and `TMPDIR` and then read the
result, which is why they agreed with the code on Linux and disagreed with it
everywhere else. `HOME` and `TMPDIR` are POSIX-only names: on Windows the
temporary root comes from `TEMP`, so `t.Setenv("TMPDIR", …)` changed nothing and
the assertions ran against the real root. They now call `disposable` with the
roots as arguments, and build paths from the host's own temp root, so each
assertion is about the rule rather than about an environment.

Tested: the thirteen out-of-scope tests, the packages this PR touches, and
`GOOS=windows go vet` on `internal/runtime/agent` and `internal/base/hostwrite`.
2026-10-23 19:21:36 +08:00
YHH 370b90716b Merge pull request #14281 from ShryukGrandhi/fix/issue-14163-studio-feedback-list-recovery-lineage-fo
fix(sessionstore): a NUL-filled branch-meta sidecar reads as absent
2026-10-23 04:20:28 -07:00
YHH 80e897cbf2 Merge pull request #14276 from esengine/fix/task-description-optimistic-default
fix(delegation): the task tool description says optimistic is the default for undeclared writes
2026-10-23 03:47:34 -07:00
YHH 30e7514754 Merge pull request #14275 from esengine/fix/coordination-hints-default-optimistic
fix(settings): the session-coordination hints say optimistic is the default
2026-10-23 03:45:01 -07:00
YHH 3ec18e6cb0 Merge pull request #14274 from esengine/fix/grants-test-waits-for-persists
test(approval): wait for every queued grant write before reading the sidecar
2026-10-23 03:44:24 -07:00
esengine 26d429e2b3 fix(delegation): the task tool description says optimistic is the default for undeclared writes 2026-10-23 03:35:17 -07:00
esengine 4f27fdd99f fix(settings): the session-coordination hints say optimistic is the default 2026-10-23 03:29:31 -07:00
阿稳 a440f83b0a fix(completion): report writes outside observed roots
A turn that wrote a file outside the workspace and then answered ended with a
`done` receipt and no gaps. The host saw the tool call succeed, but its
observation only covers the workspace and its granted extra roots, so the write
was dropped before the report was built: no mutation, nothing to verify, nothing
to say. `final_readiness` agreed — it judges a turn by whether it has a writer,
and a write it cannot see is not one — so the turn ended as a quiet success
while the work it claimed had landed nowhere the host could check.

Record those writes as a gap instead. `outsideObservedWrites` reads the turn's
own successful write receipts and keeps the paths that are outside the workspace
and outside what the host owns: the session scratch directory, toolchain caches.
Two exemptions carry the weight. A public temporary root is not exempt — a
person who asked for a file there asked for something to keep — and neither is
`bin/` under a cache, because `go install` puts the user's own executable in
`~/go/bin`. A scratch directory earns its exemption only as a direct child of a
temporary root, so a folder is not exempt for spelling the prefix.

Reported, never enforced. The verdict drops from done to partial and the receipt
names the gap; the turn ends exactly as it did before. A person who asked for a
file outside the workspace got what they asked for, and what they are owed is
the line saying that part is out of sight, not a turn that will not end. For the
same reason the gap is a notice, not a warning.

CompletionReceipt also stops discarding the report's own gaps when the verdict is
unknown. It returned only the claim-yield and mod gaps there, so a turn the host
could not judge — a plan with no criteria, writing only outside the workspace —
went back to saying nothing at all.

Blast radius: the completion report on turns that write outside the workspace,
and the receipt the host renders for them. The readiness gate, Delivery, Goal,
cancel and budget paths do not read the report, so none of them is affected.

Neighbouring behaviours tested: a turn that writes outside the workspace is not
reported as finished; one that stays inside keeps its quiet ending; a plain
answer and a read-only investigation end with no caveat and no continuation; a
scratch probe carries none; a deliverable in the public temp directory does; a
lookalike directory and an installed executable are not exempt; an unfinished
todo list still holds the turn; an unjudged turn keeps its gap. Desktop
`ReceiptCard` gained one case, with the three catalogue entries the i18n gate
requires; typecheck and the full vitest run pass.

Scope: this reports a write the host cannot see. It does not decide that a turn
which never wrote anything was unfinished — a read-only multi-step turn that
stops early still ends as it did.
2026-10-23 18:29:02 +08:00
阿稳 c9a6a3b4fc fix(delegation): track background subagent progress
A background sub-agent writes its work into its own transcript and its tool
calls, never into the job that started it. The job's activity stamp therefore
never moved, and the watchdog judged the run by bytes on streams that stay
empty: a busy sub-agent read as a job that had been silent since it started, and
a stalled notice went out about work that was progressing.

Bind the child's progress to its job at beginExecution — the one boundary every
delegated run crosses, foreground included, where a context with no job binds
nothing. A finished tool call is one reported step; token deltas refresh the
activity stamp at most once every two seconds, because the watchdog reads
minutes and the child emits far more often than that.

Reported steps are kept apart from output text rather than folded into it. They
answer different questions — whether the child advanced, and whether it wrote
here — and a caller decides from the pair. `wait` says both, and a stalled
notice says what the job had reported before going quiet, since a job that
worked and stopped needs a different next move than one that never got going.

Tested: internal/tools/jobs (a job that writes nothing stays alive while it
reports progress; wait reads steps separately from output), internal/tools/builtin
(progress wording per reading), internal/runtime/delegation (binding at
beginExecution, a finished tool call reports one step, deltas are paced, a
foreground run stays unbound), internal/contract/event, and desktop
frontend-next (typecheck; full vitest run, 6120 tests). The desktop stalled-notice
sentence had no English for its new wording and the i18n gate caught it; the
three entries are added here.
2026-10-23 18:28:55 +08:00
阿稳 162648e8e6 fix(jobs): bound background wait timeout
`wait` accepted any `timeout_seconds`, and omitting it meant blocking until
every job finished. A sub-agent that never exits then holds the turn open with
no way for the caller to tell a long wait from a hung session: the tool result
is the only thing that returns, and it returns only when the job does.

Cap one wait at 300 seconds and apply that cap when the timeout is omitted too,
so omitting it means "wait for the jobs" rather than "wait forever". The result
says when the ceiling was reached, and only then: a job that finishes first
returns before it, and claiming a 300s wait that never happened would be a
second false report in a message whose whole job is to be accurate.

The schema states the same bound the runtime applies. It previously promised an
unbounded wait, which the tool no longer performs.

Tested: internal/tools/builtin (wait ceiling applied and reported, a 1s wait
returns progress instead of holding), internal/tools/jobs, and the boot golden
recapture for the changed tool schema. internal/assembly/boot has one
pre-existing failure on this host (TestEffectSessionScratchIsReachableByItsSpelling)
that reproduces on the unmodified base: bubblewrap cannot create a namespace
here, so the sandboxed bash call the test relies on refuses to run.
2026-10-23 18:27:26 +08:00
esengine 71970278a5 test(approval): wait for every queued grant write before reading the sidecar 2026-10-23 03:23:46 -07:00
YHH 8fd97e1245 Merge pull request #14273 from esengine/fix/composer-toolbar-one-row
fix(studio): the buttons under the composer stay on one row as the conversation column narrows
2026-10-23 03:21:55 -07:00
YHH 2784587243 Merge pull request #14271 from esengine/feat/settings-sheet-expand
feat(studio): the settings window can be enlarged to fill the window, and remembers it
2026-10-23 03:18:51 -07:00
esengine 58c112c919 fix(studio): the buttons under the composer stay on one row as the conversation column narrows 2026-10-23 03:04:12 -07:00
esengine ccc7010c8f docs(release-notes): name the mirrored feedback issue 2026-10-23 02:57:35 -07:00
esengine a3880cf953 feat(studio): the settings window can be enlarged to fill the window, and remembers it 2026-10-23 02:56:29 -07:00
YHH 6ca52b40fb Merge pull request #14270 from esengine/fix/remember-note-says-where
fix(approval-card): the always-allow note says where paid search, egress and sign-in answers are kept
2026-10-23 02:51:00 -07:00
YHH 35a508c30a Merge pull request #14093 from lancy69/fix/14028-ask-unavailable
fix(studio): label Ask work mode as not implemented
2026-10-23 02:44:52 -07:00
esengine 728bab14d9 fix(approval-card): the always-allow note says where paid search, egress and sign-in answers are kept 2026-10-23 02:38:06 -07:00
YHH f14ae798be Merge pull request #14269 from esengine/fix/analysis-icon-activity
fix(studio): the run analysis icon is a pulse line that matches its neighbours
2026-10-23 02:28:43 -07:00
YHH f174ee55e4 Merge pull request #14268 from esengine/feat/nav-entries-customize
feat(studio): hide or reorder the navigation entries of the icon rail and the sidebar
2026-10-23 02:28:04 -07:00
YHH e8e03fde3a Merge pull request #14262 from esengine/fix/grants-persist-ordered
fix(approval): session grants persist in order, so a revoke is not undone by an earlier write
2026-10-23 02:13:16 -07:00
esengine 6fa3ab8dcf fix(studio): the run analysis icon is a pulse line that matches its neighbours 2026-10-23 02:12:52 -07:00
esengine 892d071d42 feat(studio): hide or reorder the navigation entries of the icon rail and the sidebar 2026-10-23 02:05:10 -07:00
YHH b6a321054e Merge pull request #14266 from esengine/feat/nav-parity
feat(studio): the sidebar offers the same destinations as the icon rail, from one list
2026-10-23 01:57:10 -07:00
YHH 741f40c7d4 Merge pull request #14265 from esengine/fix/effort-display-follows-choice
fix(effort): the status says the depth the person chose even when the request does not change
2026-10-23 01:48:19 -07:00
YHH de2f8b81a1 Merge pull request #14263 from esengine/fix/sidecar-not-executable-says-so
fix(sidecar): a runtime that is not a program says what the plugin has to ship
2026-10-23 01:44:41 -07:00
esengine 26a21fd177 feat(studio): the sidebar offers the same destinations as the icon rail, from one list 2026-10-23 01:44:18 -07:00
YHH 79ab732ae1 Merge pull request #14261 from esengine/fix/credential-heal-on-refusal
fix(desktop): check and restore the launch credential cookie after a refused request
2026-10-23 01:36:59 -07:00
esengine ecdf0fb140 fix(approval): session grants persist in order, so a revoke is not undone by an earlier write 2026-10-23 01:32:38 -07:00
YHH f702bf02ed Merge pull request #14259 from esengine/fix/run-skill-shows-name
fix(studio): a skill step names the skill it ran
2026-10-23 01:31:06 -07:00
YHH 08ac241c5e Merge pull request #14258 from esengine/fix/reveal-icon-folder
fix(studio): the reveal-in-file-manager icon is an open folder, not an arrow
2026-10-23 01:30:28 -07:00
YHH 9af6d78c1e Merge pull request #14257 from esengine/fix/subagent-entry-and-meaning
fix(delegation): tell the model a sub-agent is not a new session
2026-10-23 01:29:51 -07:00
YHH 384c0b8087 Merge pull request #14252 from esengine/fix/read-refusal-says-why
fix(tools): a refused read says which target, which folders may be read and why
2026-10-23 01:29:13 -07:00
esengine 9224c80c92 fix(effort): the status says the depth the person chose even when the request does not change 2026-10-23 01:28:14 -07:00
esengine 6e4681f33b fix(sidecar): a runtime that is not a program says what the plugin has to ship 2026-10-23 01:23:00 -07:00
esengine 907357fb33 fix(desktop): check and restore the launch credential cookie after a refused request 2026-10-23 01:13:06 -07:00
esengine 30b38c9063 fix(studio): a skill step names the skill it ran 2026-10-23 01:08:29 -07:00
YHH ab8e51c497 Merge pull request #14255 from esengine/fix/feedback-markdown-preview
feat(feedback): preview the report body as Markdown before sending
2026-10-23 01:06:05 -07:00
esengine c6930ad1b7 fix(studio): the reveal-in-file-manager icon is an open folder, not an arrow 2026-10-23 01:05:38 -07:00
esengine b4db2d6516 test(agent): compare the refused read's target by name, not by a path spelling Windows rewrites 2026-10-23 01:02:33 -07:00
YHH 180450189e Merge pull request #14254 from esengine/fix/feedback-mine-fast-and-list-first
fix(desktop): my feedback shows the list it has at once and keeps level, account and board behind one row
2026-10-23 01:01:37 -07:00
YHH 01f839c664 Merge pull request #14240 from esengine/feat/optimistic-claims-default-on
feat(config): optimistic claims are the default for sub-agent claims and the write lease
2026-10-23 01:01:03 -07:00
YHH 47cefa8fb2 Merge pull request #14239 from chuiwenwei/fix/an-unreadable-fact-is-not-absent
fix(memory): a fact that cannot be read is not absent
2026-10-23 01:00:26 -07:00
esengine 5cad66697b fix(delegation): tell the model a sub-agent is not a new session 2026-10-23 00:58:08 -07:00
esengine 1bbd409235 feat(feedback): preview the report body as Markdown before sending 2026-10-23 00:50:07 -07:00
YHH bdf86eb379 Merge pull request #14238 from chuiwenwei/fix/inbox-origin-is-not-the-persons
fix(tui): a runtime continuation is not the person's message
2026-10-23 00:40:25 -07:00
YHH 1261a1aade Merge pull request #14243 from esengine/feat/session-grants-persist
feat(approval): a conversation remembers the answers given for it, and resuming it does not ask again
2026-10-23 00:39:44 -07:00
YHH 66297898b9 Merge pull request #14244 from esengine/ci/browser-guards-timeout
ci(studio): give the browser guards job 25 minutes
2026-10-23 00:39:04 -07:00
esengine 0ba5b81993 docs(release): note the faster My feedback page 2026-10-23 00:35:57 -07:00
esengine 9a5fbb9e2e fix(desktop): my feedback shows the list it has at once and keeps level, account and board behind one row 2026-10-23 00:35:19 -07:00
esengine a375cbde45 docs(release): note that a refused read says why 2026-10-23 00:25:33 -07:00
esengine 691bc617a1 fix(tools): a refused read says which target, which folders may be read and why 2026-10-23 00:25:22 -07:00
esengine 3f0cf0bb10 ci(studio): give the browser guards job 25 minutes 2026-10-22 23:54:12 -07:00
YHH 994b9e05eb Merge pull request #14226 from esengine/fix/sidebar-rows-align-folders
fix(desktop): the sidebar's top and bottom rows sit on the project folders' icon line
2026-10-22 23:53:40 -07:00
esengine aff96b0800 docs(release): note that session grants are remembered with the conversation 2026-10-22 23:50:54 -07:00
esengine 0fc7a3c70a feat(approval): a conversation remembers the answers given for it, and resuming it does not ask again 2026-10-22 23:50:43 -07:00
Will a327256d5d fix(migrate): ask the existing helper whether the copy exists
absorbInterruptedCopy called os.Lstat itself, which CodeQL reports as a new
path sink in changed code. sessionArtifactExists is the store's pre-existing,
baselined reader for the same question: the copy exists when its transcript or
an owned sidecar does. It answers bool, so missing and unreadable look alike;
the import's callers already accept that, and a copy that is there but cannot
be read still reports its error where it is loaded.
2026-10-23 14:50:13 +08:00
Will a3172ed092 fix(migrate): keep the sidecar read where CodeQL already knows it
CodeQL flagged the two sidecar paths as new sinks once copyBranchMetaSidecar moved into migrate_copy.go: the os.ReadFile(BranchMetaPath(srcPath)) body was baselined in migrate.go, and a moved copy is not. The function moves back to its original position with only the error return changed, and completeBranchMetaSidecar now asks the pre-existing LoadBranchMeta reader whether the destination sidecar is there instead of calling os.Stat itself: ok means present and never rewritten, an error means unreadable and never clobbered, and only a clean absence proceeds. sameDirPath (a pure path comparison, no filesystem access) moves to migrate_passes.go so migrate.go stays inside its ratcheted file-size budget.
2026-10-23 14:50:13 +08:00
Will b92e5f0b50 fix(migrate): an imported session completes its sidecar
An import publishes the transcript first and the branch sidecar that carries
what it means second. A run stopped in that window left a transcript with no
sidecar, and the retry treated the transcript as completion: the flat import
healed only subagent artifacts and stamped the marker, and the v4 import only
healed the title. Both now finish the sidecar, refuse a failed sidecar write
as incomplete so the next run retries, and leave a finished copy untouched.
A v4 copy that is still a prefix of 1.x is brought up to the source; a copy
continued in 2.x keeps its own turns.
2026-10-23 14:50:13 +08:00
esengine 8def830791 docs(release): note that optimistic claims are on by default 2026-10-22 23:18:00 -07:00
esengine 5f7365e656 feat(config): optimistic claims are the default for sub-agent claims and the write lease 2026-10-22 23:16:04 -07:00
Will 9a986bc21a fix(desktop): import the unreadable-memory type the port re-exports
tsc caught it: port.ts re-exports UnreadableMemory from the local scope but
never imported it, so the panel's new type did not compile.
2026-10-23 13:52:11 +08:00
esengine 7633a44adb test(desktop): the session tab centring guard allows for the font the browser falls back to 2026-10-22 22:29:09 -07:00
ShryukGrandhi 8e88bd1665 fix(sessionstore): a NUL-filled branch-meta sidecar reads as absent
A power loss on NTFS can leave a .meta sidecar extended but never flushed, so it holds only NUL bytes. LoadBranchMeta reported that as a decode error, which made the recovery-lineage walk refuse to archive anything in the folder and kept UpdateBranchMeta from ever rewriting the file. The file records nothing, so it now loads as a missing sidecar and the next save replaces it.

Signed-off-by: ShryukGrandhi <[email protected]>
2026-10-22 22:19:01 -07:00
YHH e64f9f5507 Merge pull request #14225 from lancy69/fix/14219-composer-reflow
fix(studio): keep composer chip text aligned during reflow
2026-10-22 22:14:08 -07:00
YHH 66fb456651 Merge pull request #14229 from esengine/fix/workbench-wide-and-full-at-the-edges
fix(desktop): the workbench's wide toggle sits at the left edge of its bar and the full-size toggle at the right edge
2026-10-22 22:12:52 -07:00
esengine 630d4ce71c fix(desktop): the session tabs keep whole glyphs: shift the label down instead of trimming its box 2026-10-22 22:09:19 -07:00
esengine 3bb09ebc23 fix(desktop): the session pill sits on a whole pixel and its tab text stays centred across text sizes 2026-10-22 22:04:55 -07:00
esengine e812286ad7 fix(desktop): the session tabs centre their text between the pill's edges 2026-10-22 21:57:36 -07:00
esengine a9a451a565 fix(desktop): the sidebar account avatar is one rounded square 2026-10-22 21:49:13 -07:00
Will cc328cfda8 fix(memory): a fact that cannot be read is not absent
loadMemory returned (false, nil) for a read error, so every caller treated a
present-but-unreadable fact file as missing: it vanished from /memory, the
desktop panel, the memory tool and recall, and a save by name then minted a new
id at revision 1 over it with no snapshot, losing the old identity and history.

loadMemory now returns a typed FactReadError. ListActive reports unreadable
facts beside the readable ones, and the listing, read-by-name, panel, tool and
recall audit all name them; recall still serves the readable facts and keeps
its old sentence for a whole-directory failure. A save by that name refuses
before snapshotting or writing anything.
2026-10-23 12:46:33 +08:00
esengine def7a9733c fix(desktop): the sidebar logo's first letter starts where the row labels start 2026-10-22 21:45:38 -07:00
esengine da724fde38 fix(desktop): the sidebar logo lines up with the rows and has equal space above and below 2026-10-22 21:38:38 -07:00
esengine abf4643a1d fix(desktop): the sidebar's top rows drop their hint text and keys 2026-10-22 21:38:38 -07:00
esengine 734eedebdf fix(desktop): the sidebar's top and bottom rows sit on the project folders' icon line, and Files carries a hint like its neighbours 2026-10-22 21:38:38 -07:00
Lancy 0da4ecfd91 fix(studio): keep composer chip text aligned during reflow 2026-10-23 12:34:49 +08:00
esengine d34063a89b fix(desktop): the workbench's wide toggle sits at the left edge of its bar and the full-size toggle at the right edge 2026-10-22 21:33:34 -07:00
YHH 08428a8bd3 Merge pull request #14224 from esengine/fix/relocation-test-zero-quote
test(stats): the relocation fixture expects the zero-amount quote the projection now keeps
2026-10-22 21:08:43 -07:00
Will 216ea245fd fix(tui): a runtime continuation is not the person's message
The terminal counted and drew a host-authored inbox entry as the person's
own pending line, so `d d` on it reached DELETE /inbox/items/<id> and the
kernel dropped the runtime's continuation. The desktop has always filtered
these out; the terminal never decoded the field it filters on.

The mirror now carries origin, readonly and capacity, and a declared-subset
wire-parity check holds it (and the page's copy) to the fields a decision
reads, so dropping one is a `make check` failure instead of a silent zero.

A host continuation is out of the pending rows and out of the count, except
where it is blocked or uncertain and needs a decision only the person can
make; there it is drawn marked as the runtime's. A read-only snapshot offers
no mutation key and says why.
2026-10-23 11:59:16 +08:00
esengine a0934614fc test(stats): the relocation fixture expects the zero-amount quote the projection now keeps 2026-10-22 20:43:42 -07:00
YHH add2e45c05 Merge pull request #14221 from esengine/fix/sidebar-rows-one-color
fix(desktop): the sidebar rows read in one colour and New session has no fill at rest
2026-10-22 20:28:52 -07:00
YHH 3649924679 Merge pull request #14214 from chuiwenwei/fix/usage-projection-forgets-old-roots
fix(stats): a relocated stats root does not count twice
2026-10-22 20:15:23 -07:00
YHH cd3c673973 Merge pull request #14215 from chuiwenwei/fix/usage-projection-keeps-zero-money
fix(stats): the projection keeps a zero-amount quote
2026-10-22 20:04:01 -07:00
YHH 1dae038558 Merge pull request #14212 from chuiwenwei/fix/import-marker-needs-every-session
fix(migrate): a failed copy must not stamp the import marker
2026-10-22 20:03:19 -07:00
YHH 839f2a2deb Merge pull request #14104 from chuiwenwei/fix/active-goal-retraction
fix(control): retract the goal contract when a goal stops
2026-10-22 20:02:38 -07:00
YHH 61c2633834 Merge pull request #14216 from esengine/fix/schedule-page-trust-current
fix(schedule): offer the open project for trust on the scheduled runs page
2026-10-22 20:01:57 -07:00
esengine 930808cb6d fix(desktop): the sidebar rows read in one colour and New session has no fill at rest 2026-10-22 19:55:08 -07:00
YHH aff13c0dc3 Merge pull request #14211 from esengine/fix/settings-fit-names-memory-icon
fix(desktop): the settings sheet shows its whole menu, the memory icon is centred, and the run analysis view keeps one name
2026-10-22 19:36:36 -07:00
YHH 63cefcd0fb Merge pull request #14210 from esengine/fix/feedback-paste-anywhere
fix(desktop): a paste into the feedback form reaches it after a click on a blank part
2026-10-22 19:25:51 -07:00
esengine 872031925d fix(schedule): offer the open project for trust on the scheduled runs page 2026-10-22 19:24:56 -07:00
Will 2023dfe047 chore: retrigger the check suite
The force-push to the restacked head did not start the test/build workflows
(closing and reopening only re-ran the labeler), so this empty commit emits a
synchronize event. No code change.
2026-10-23 10:13:18 +08:00
esengine fb3f5e4e57 fix(desktop): the settings sheet shows its whole menu, the memory icon is centred, and the run analysis view keeps one name 2026-10-22 19:00:49 -07:00
YHH 94b51af86d Merge pull request #14208 from esengine/fix/tool-blurbs-complete
fix(desktop): describe every built-in tool in the settings list in the interface language
2026-10-22 18:56:19 -07:00
YHH 9f1ad0873d Merge pull request #14207 from esengine/fix/sidebar-rows-align
fix(desktop): line up the sidebar rows' icons and labels with the rail hidden
2026-10-22 18:55:39 -07:00
esengine 74212941ae fix(desktop): a paste into the feedback form reaches it after a click on a blank part, and takes an image offered only as a clipboard item 2026-10-22 18:53:27 -07:00
Will 269bc394d1 fix(stats): the projection keeps a zero-amount quote
A line that carried a currency and a quote of zero was priced by the exact
JSONL read but discarded by the projection, which treated amount 0 as "no
price". A single-day range reads JSONL while a multi-day range reads the
projection, so one session showed [{CNY 3},{USD 0}] on 今日 and [{CNY 3}] on
30 天, lost costEstimated on a day whose only quote was zero, and could emit
the false "usage but no cost record" note.

A currency is the whole test for "priced"; zero is a genuine free quote. Both
build paths now keep it: projectRecord no longer drops amount 0, the rederive
view no longer filters cost<>0, and the rollup read no longer skips amount 0,
while a row with no currency still carries no money. No schema column was
needed, so SchemaVersion is unchanged; every launch reconciles the day files
through rederive, which repairs a projection built by the old filters.

Tests cover the fixed two-day history through the exact read, the incremental
append projection and the rebuilt projection, the zero-amount-only day's
costEstimated in both, and controls for a currency-less row and a non-zero
amount.
2026-10-23 09:51:05 +08:00
Will 4f25e4e555 fix(stats): a relocated stats root does not count twice 2026-10-23 09:50:40 +08:00
Will 6065e522c0 fix(migrate): a failed copy must not stamp the import marker 2026-10-23 09:42:21 +08:00
YHH b10f8bfd6f Merge pull request #14204 from esengine/fix/receipt-command-shown-short
fix(desktop): show only the start of a long command in the delivery receipt
2026-10-22 18:38:36 -07:00
esengine cc49a64de4 fix(desktop): describe every built-in tool in the settings list in the interface language 2026-10-22 18:27:26 -07:00
Will b572d96811 fix(control): retract the goal contract when a goal stops
<active-goal contract="full"> carries the whole goal-mode contract into the
model-visible history, and nothing retired it when the goal stopped: the next
request carried no goal block while the contract still said to keep pursuing
it, and active-goal being a superseded tag made a fold keep that stale copy.

A stop the model did not report now renders a tombstone under the same tag:
reason paused, cleared, budget, or host (the completion check could not run).
A model-reported block and a completed goal compose nothing, because the model
already knows. Delivery is asked of the visible history through the delivery
PR's projectionDebt: the tombstone is owed while the newest active-goal block
in view is a contract or reminder, and not once it is the tombstone itself, so
a rewind or fold that drops it owes it again with no remembered state.
SupersededUserBlockTags is unchanged, so the tombstone is the newest copy a
fold keeps.

The boot harness's blockOf now matches a tag prefix, since a variant block
opens with attributes after its tag.
2026-10-23 09:23:32 +08:00
esengine d968c3e329 fix(desktop): line up the sidebar rows' icons and labels with the rail hidden 2026-10-22 18:19:53 -07:00
esengine 3821d42d8a docs(release-notes): keep the receipt fragment within the line width 2026-10-22 18:03:51 -07:00
esengine d1858ef240 docs(release-notes): receipt command shown short fragment 2026-10-22 18:03:30 -07:00
esengine 36571f34ae fix(desktop): show only the start of a long command in the delivery receipt 2026-10-22 18:03:14 -07:00
YHH a08a4d1c8a Merge pull request #14202 from esengine/fix/kernel-refuses-retry-of-maybe-run-item
fix(inbox): the queue refuses to retry an entry that may already have run
2026-10-22 17:28:37 -07:00
esengine 85e1227ec6 docs(release-notes): queue refuses retry of maybe-run fragment 2026-10-22 16:58:48 -07:00
esengine 14cc437ffe fix(inbox): the queue refuses to retry an entry that may already have run 2026-10-22 16:58:34 -07:00
YHH de91470edc Merge pull request #14201 from esengine/docs/fragment-14179
docs(release-notes): fragment for #14179
2026-10-22 16:28:40 -07:00
YHH 6a59451d7b Merge pull request #14200 from esengine/feat/meter-session-total
fix(desktop): label the meter's running total as the session's and add the turn's own amount
2026-10-22 16:03:40 -07:00
esengine 17f98c8d2a docs(release-notes): fragment for #14179 2026-10-22 15:53:16 -07:00
YHH 519288c24c Merge pull request #14179 from chuiwenwei/fix/schedule-missed-one-shot-expires
fix(schedule): a missed one-shot stops being active
2026-10-22 15:53:02 -07:00
YHH 2e3cffd9b8 Merge pull request #14199 from esengine/docs/fragments-for-merged-prs
docs(release-notes): fragments for #14178, #14180, #14181, #14195 and #14196
2026-10-22 15:52:22 -07:00
esengine d56044f47e docs(release-notes): meter session and turn money fragment 2026-10-22 15:39:59 -07:00
esengine fe71ea4d71 fix(desktop): label the meter's running total as the session's and add the turn's own amount 2026-10-22 15:39:43 -07:00
esengine a5f083449a docs(release-notes): fragments for #14178, #14180, #14181, #14195 and #14196 2026-10-22 15:33:04 -07:00
YHH 4fa25f9a04 Merge pull request #14195 from lancy69/feat/sidebar-expansion-memory
feat(studio): propose remembered sidebar expansion states
2026-10-22 15:32:02 -07:00
YHH 62b96c7737 Merge pull request #14196 from lancy69/feat/settings-flat-scrim
feat(studio): separate Settings with a feedback-style flat scrim
2026-10-22 15:31:22 -07:00
YHH cd1241e3a8 Merge pull request #14180 from chuiwenwei/fix/tui-inbox-retry-knows-what-may-have-run
fix(tui): refuse a retry that may already have run
2026-10-22 15:30:41 -07:00
YHH 0355e62b93 Merge pull request #14178 from chuiwenwei/fix/desktop-pane-currency-fold
fix(desktop): keep a session's money per currency
2026-10-22 15:29:56 -07:00
YHH dd6d27e9bf Merge pull request #14181 from chuiwenwei/fix/migration-respects-session-lease
fix(migrate): leave a leased session where it is
2026-10-22 15:29:16 -07:00
YHH 1441c85e27 Merge pull request #14198 from imcreazy/feat/drag-the-reading-column
feat(studio): drag the edges of the reading column
2026-10-22 14:31:42 -07:00
imcreazy ab2878f33c feat(studio): drag the edges of the reading column 2026-10-23 04:57:44 +08:00
Lancy c85d97648f feat(studio): separate Settings with a flat feedback-style scrim 2026-10-23 01:53:16 +08:00
Lancy 7ca1d60d4f feat(studio): remember sidebar project expansion choices 2026-10-23 01:52:40 +08:00
YHH 2ddc6f646e Merge pull request #14193 from esengine/docs/release-notes-2.49.0
docs(release): studio 2.49.0 notes
2026-10-22 10:16:16 -07:00
esengine ec980105a2 docs(release): studio 2.49.0 notes 2026-10-22 10:05:14 -07:00
YHH 00bcf2610a Merge pull request #14191 from esengine/fix/sidebar-rhythm-and-separators
fix(desktop): even out the sidebar's shortcut rows and use one hint separator
2026-10-22 10:04:20 -07:00
YHH 01c8881a5f Merge pull request #14188 from esengine/fix/title-thought-only-says-so
fix(serve): say when the title model spent its budget thinking
2026-10-22 09:52:34 -07:00
esengine cc499a99b9 fix(desktop): pull the shortcut rows up without adding a stylesheet line 2026-10-22 09:29:36 -07:00
esengine 2a670f9419 docs(release-notes): sidebar rhythm and separators fragment 2026-10-22 09:21:03 -07:00
esengine 9309ce419a fix(desktop): even out the sidebar's shortcut rows and use one hint separator 2026-10-22 09:20:44 -07:00
YHH b771548207 Merge pull request #14187 from esengine/fix/sidebar-drop-duplicate-remote-row
fix(desktop): drop the sidebar's second link to the remote settings when the icon rail is hidden
2026-10-22 09:18:09 -07:00
esengine 26c4aec7d3 docs(release-notes): title thought-only fragment 2026-10-22 09:14:26 -07:00
esengine 2c4b9c29a9 fix(serve): say when the title model spent its budget thinking 2026-10-22 09:14:12 -07:00
YHH 05419f3569 Merge pull request #14176 from EMEEEEMMMM/feat/usage-today-hourly
feat(usage): show hourly usage for a single-day window
2026-10-22 09:04:14 -07:00
esengine c505806479 docs(release-notes): keep the sidebar fragment within the line width 2026-10-22 08:47:49 -07:00
YHH 9f8478b578 Merge pull request #14186 from esengine/ci/race-full-suite-package-timeout
ci(race): give the full-suite package timeout headroom over assembly/boot
2026-10-22 08:47:13 -07:00
EMEEEEMMMM 70c8f4c18d feat(usage): show hourly usage for a single-day window 2026-10-22 23:46:07 +08:00
esengine 6301f4cb46 docs(release-notes): sidebar single remote entry fragment 2026-10-22 08:26:30 -07:00
esengine cda7945c58 fix(desktop): drop the sidebar's second link to the remote settings when the icon rail is hidden 2026-10-22 08:26:16 -07:00
YHH b0e69e04bb Merge pull request #14184 from esengine/fix/rail-hidden-sidebar-rows-one-type-scale
fix(desktop): give the sidebar's shortcut rows one type scale when the icon rail is hidden
2026-10-22 08:20:27 -07:00
esengine 09e10c5a3c ci(race): give the full-suite package timeout headroom over assembly/boot 2026-10-22 08:13:44 -07:00
YHH 62228e6f5d Merge pull request #14136 from FuchaZ/studio
feat(theme): add Patina as a plain light/dark palette
2026-10-22 08:04:23 -07:00
YHH 249248678d Merge pull request #14182 from esengine/fix/dag-redaction-test-counts-reads
test(sessionstore): count the bytes a redaction reads instead of process-wide allocation
2026-10-22 07:53:55 -07:00
YHH 12c3d7e0cc Merge pull request #14112 from chuiwenwei/fix/unpriced-calls-visible
fix(stats): count a call with no cost fact as incomplete
2026-10-22 07:53:18 -07:00
esengine c7260eacf1 docs(release-notes): sidebar rows type scale fragment 2026-10-22 07:39:25 -07:00
esengine 1199ca4356 fix(desktop): give the sidebar's shortcut rows one type scale when the icon rail is hidden 2026-10-22 07:39:06 -07:00
YHH 76020b84cb Merge pull request #14170 from imcreazy/fix/continuation-prompt-names-the-host
fix(control): keep the host's continuation prompt out of the user's turns
2026-10-22 07:30:34 -07:00
YHH 2ef6093fba Merge pull request #14175 from esengine/fix/tool-list-blurbs-in-ui-language
fix(desktop): describe each tool in the settings list in the interface language
2026-10-22 07:29:55 -07:00
esengine dc426fc539 test(sessionstore): count the bytes a redaction reads instead of process-wide allocation 2026-10-22 07:26:56 -07:00
Fucha f84861bea2 fix(theme): lighten Patina dark fgFaint to clear AA on floatHi 2026-10-22 22:25:58 +08:00
Fucha f5c5b79a47 docs(release): note the Patina theme in #14136 2026-10-22 22:21:22 +08:00
Fucha 006c369d09 fix(theme): raise Patina light synNumber to clear AA on codeBg 2026-10-22 22:21:14 +08:00
imcreazy 57da2aa319 fix(control): restore the source changes and register the older opening 2026-10-22 21:59:26 +08:00
esengine accf15760b docs(release-notes): tool list description fragment 2026-10-22 06:37:24 -07:00
imcreazy 71f434721b test(serve): regenerate the trace fixture after the prompt change 2026-10-22 21:37:17 +08:00
esengine 6b717476f8 fix(desktop): describe each tool in the settings list in the interface language 2026-10-22 06:37:10 -07:00
imcreazy c6afee53d8 fix(control): keep the host's continuation prompt out of the user's turns 2026-10-22 20:49:34 +08:00
YHH 42f1cd739c Merge pull request #14045 from imcreazy/fix/unprefixed-request-remote-pane
fix(serve): carry an unprefixed request to a remote pane
2026-10-22 05:43:28 -07:00
YHH 95d86f1700 Merge pull request #14044 from imcreazy/fix/dns-tcp-reply-framing
fix(netdns): read a TCP reply past its length prefix
2026-10-22 05:42:49 -07:00
YHH 6b56a1fb0e Merge pull request #14161 from lancy69/fix/14116-bounded-session-replay
fix(sessionstore): bound history replay memory
2026-10-22 05:28:30 -07:00
YHH c7b90c0676 Merge pull request #14162 from lancy69/fix/14145-side-progress
fix(studio): stream progress for side questions
2026-10-22 05:27:40 -07:00
imcreazy c6586b9ec9 docs(release-notes): name the unprefixed request fix 2026-10-22 20:21:37 +08:00
imcreazy 8560defcf8 docs(release-notes): name the DNS TCP reply framing fix 2026-10-22 20:21:30 +08:00
Lancy 788cf7e18f fix(studio): stream progress for side questions 2026-10-22 19:58:32 +08:00
Lancy 93e5cf97de fix(sessionstore): bound history replay memory
Retain legacy DAG payload locations instead of discarded message bodies, preflight JSON without copying large strings, and refuse oversized records or retained histories without modifying source data or falling back to stale checkpoints.

Refs #14116
2026-10-22 19:58:06 +08:00
YHH a18234d724 Merge pull request #14167 from esengine/fix/mcp-skills-offer-only-what-runs
fix(control): offer only the /mcp and /skills subcommands the controller runs
2026-10-22 04:29:19 -07:00
YHH 240a24ca41 Merge pull request #14165 from esengine/fix/memory-lock-test-no-wallclock
test(memory): drop the wall-clock bound on uncontended saves
2026-10-22 03:48:37 -07:00
esengine 4ec14ba1fb docs(release-notes): mcp and skills completion fragment 2026-10-22 03:48:26 -07:00
esengine f9d0c934f6 fix(control): offer only the /mcp and /skills subcommands the controller runs 2026-10-22 03:48:14 -07:00
esengine e986ba01fb test(memory): drop the wall-clock bound on uncontended saves 2026-10-22 03:35:37 -07:00
YHH bbd7a01725 Merge pull request #14154 from esengine/fix/host-verification-off-by-default
feat(config): the host no longer holds a changed turn to its checks unless asked
2026-10-22 02:24:16 -07:00
YHH d337b7d8b0 Merge pull request #14150 from KHG420/codex/studio-wechat-paste-attachments
fix(studio): copy pasted files into managed attachments
2026-10-22 02:19:16 -07:00
YHH c163b58980 Merge pull request #14157 from esengine/fix/side-panel-renders-markdown
fix(desktop): draw the side conversation's answer as markdown
2026-10-22 01:53:00 -07:00
YHH c5d0df2120 Merge pull request #14158 from esengine/feat/loopmetrics-inputs
feat(loopmetrics): how to feed a real session, plus usage and M4 definition fixes
2026-10-22 01:52:36 -07:00
xiexian 9c12321906 fix(studio): copy pasted files into managed attachments 2026-10-22 16:26:22 +08:00
esengine 42c398aa6e feat(loopmetrics): session inputs note, truncation sidecar, and usage dedupe that survives resumed sessions 2026-10-22 01:19:37 -07:00
YHH 9ef691ebc9 Merge pull request #14155 from esengine/feat/loopmetrics-phase1
feat(loopmetrics): phase 1 harness and first results on long-session conditions
2026-10-22 01:19:07 -07:00
esengine f0c704db90 docs(release-notes): side panel markdown fragment 2026-10-22 01:14:18 -07:00
esengine 8e498b3f8c fix(desktop): draw the side conversation's answer as markdown 2026-10-22 01:13:45 -07:00
YHH 9a27d9a967 Merge pull request #14140 from SivanCola/fix/studio-turn-completion-state
fix(agent): bound post-answer finalization / 修复回答结束后的收尾阻塞
2026-10-22 01:05:17 -07:00
Will 95b8aa15a0 docs(release-notes): record unpriced calls counting as a floor
The reviewer asked for a bilingual fragment and supplied its outline: a
pre-schema row and a title call now count toward a total labelled a floor.
2026-10-22 16:00:47 +08:00
Will 485e2506eb fix(stats): count a call with no cost fact as incomplete
A call that spent tokens but recorded no money was counted in the token
and request totals and contributed 0 to Cost, with nothing in the payload
saying its money was unknown. Two paths produced that shape.

Title generation wrote such a row live: the serve title model builds its
own stats.Recorder instead of emitting through the assembled sink, so no
CostQuoteSink sits in front of it. The title event carried a rate card but
no quote, and the recorder writes cost fields only for a non-nil quote.
It now prices the call at emit time with event.EnsureCostQuote, using the
same QuoteContext construction the assembled sink uses
(boot.QuoteContextFor), so it is quoted like any other call.

Rows written before the cost fields existed (and any row that simply
lacks them) had the same effect on reads. The incompleteness trigger is
now "no cost fact": an explicit cost_complete=false, or tokens counted
with no cost_amount. One predicate (usagecatalog.CostIncomplete) is
applied on all three read surfaces, so they agree.

No price is invented and no legacy row is backfilled.
2026-10-22 16:00:37 +08:00
esengine b98bad7509 fix(loopmetrics): drive.py refuses a foreign reasonix.toml, and the report states analysed and excluded blocks 2026-10-22 00:46:57 -07:00
esengine d5916445b2 feat(loopmetrics): phase 1 harness, scripted turns and first results 2026-10-22 00:44:44 -07:00
esengine 17bb7dd92c feat(loopmetrics): a run may be several legs of one session 2026-10-22 00:41:29 -07:00
YHH 0b48f5f3b7 Merge pull request #14149 from esengine/feat/loopmetrics
feat(loopmetrics): read-only loop metrics for trajectories, with negative controls
2026-10-22 00:35:56 -07:00
esengine 2df3595ac5 docs(release): note the host verification default 2026-10-22 00:33:25 -07:00
esengine 09d43155a1 feat(config): the host no longer holds a changed turn to its checks unless asked 2026-10-22 00:23:59 -07:00
Lancy 46f6adebbb fix(composition): make lightweight image capability optional 2026-10-22 15:09:42 +08:00
esengine f4fe395407 test(loopmetrics): a duplicated tool_result event counts one call 2026-10-21 23:49:43 -07:00
esengine eebf779b7c docs(loopmetrics): add the metadata header and split the long paragraphs 2026-10-21 23:47:17 -07:00
esengine 9d3a8958e1 feat(loopmetrics): read the flat wire-log envelope and fall back to message-level reasoning 2026-10-21 23:46:40 -07:00
esengine 1184b33233 feat(loopmetrics): read-only analyzer for repeated tool calls and reasoning, with negative controls 2026-10-21 23:45:22 -07:00
esengine 664d75d1d2 docs(loopmetrics): pre-register the loop metrics and their thresholds 2026-10-21 23:43:50 -07:00
YHH c9b435284b Merge pull request #14099 from chuiwenwei/fix/projection-delivery-from-visible-history
fix(control): derive projection delivery from the visible history
2026-10-21 23:42:28 -07:00
YHH 7a139fe8fa Merge pull request #14110 from chuiwenwei/fix/guardian-review-cost-accounted
fix(stats): quote a guardian review's usage like any other call
2026-10-21 23:37:52 -07:00
YHH 8029860f15 Merge pull request #14144 from SivanCola/fix/studio-summary-terminal-race
fix(agent): reject interrupted summaries at EOF / 修复压缩流结束与取消竞态
2026-10-21 23:37:06 -07:00
Will 6b56aade96 fix(desktop): keep a session's money per currency 2026-10-22 14:31:46 +08:00
Will 54a1d436a8 fix(migrate): leave a leased session where it is
The flat-import re-home took the save locks but never asked the session
lease, so it could move a WAL-backed transcript out of a live session root
and delete the old path while another runtime (or this one) still owned it.

Gate every move/remove in the migrate family on the same predicate
renameOverlongSession uses: sessionLeaseHeldLocally OR
SessionLeaseHeldByOtherRuntime. A deferred base is not counted as imported
and leaves the one-time marker unwritten, so a later run settles it.

Tests: audit red/green pair, unleased control, same-process lease control.
2026-10-22 14:28:57 +08:00
Will b3ff8de554 fix(tui): refuse a retry that may already have run 2026-10-22 14:20:33 +08:00
Will 7ded08677e fix(schedule): a missed one-shot stops being active 2026-10-22 14:14:01 +08:00
SivanCola b6b1b3da08 docs(studio): publish PR 14140 release-note fragment
Problem: the post-answer finalization fix note would be missed by the current release fragment collection.

Root cause: it used the retired shared unreleased.md path and did not name the fixing PR.

Fix: move the bilingual entry into unreleased/14140-bound-post-answer-finalization.md, preserving #13953 and adding #14140 to both bullets.

Verification: release-note and Markdown-reference tests pass (12 tests); make check, fragment discovery, bilingual references, release rendering, and whitespace checks pass. Only release-note documentation changes.
2026-10-22 14:09:17 +08:00
SivanCola 3698fb6dad docs(studio): publish PR 14144 release-note fragment
Problem: the compaction fix note was outside the release fragment glob and cited the related finalization PR instead of its own fix.

Root cause: the note used the retired shared unreleased.md layout.

Fix: move the bilingual entry into unreleased/14144-compaction-eof-vs-cancellation.md and reference #14144 in both bullets.

Verification: release-note and Markdown-reference tests pass (12 tests); make check, fragment discovery, exact bilingual references, release rendering, and whitespace checks pass. Only release-note documentation changes.
2026-10-22 14:08:04 +08:00
YHH 9264b58ba4 Merge pull request #14143 from esengine/fix/browser-eval-timeout
fix(browser): stop an eval script whose promise never settles
2026-10-21 22:28:49 -07:00
SivanCola 8380c9b67c fix(agent): reject interrupted summaries at EOF
Problem: compaction could report success and accept a partial digest after
the summary deadline or caller cancellation.

Root cause: providers close their channel when context ends. Selecting EOF
instead of the simultaneously ready cancellation accepted nonempty output;
the deferred ceiling classifier only corrected non-nil errors.

Fix: keep EOF validation at one result boundary and check the request context
before accepting a digest or classifying truncation. Preserve typed ceiling
and cancellation failures, usage accounting, and stable provider requests.

Verification: force EOF/cancellation overlap with a progress barrier and fake
clock, cover normal completion/truncation and charged usage, and exercise
the real Build-to-notice path. Baseline production source fails on macOS
and Linux; fixed Linux cases pass 20 repetitions. Focused race/cache tests,
full root tests, required tool/boot tests, vet, lint, make check, and release
script checks pass. A separate combined tree also validates this fix with
the post-answer finalization change from PR #14140.
2026-10-22 13:19:02 +08:00
esengine 869c4dc6e3 fix(browser): stop an eval script whose promise never settles 2026-10-21 22:01:28 -07:00
Will 2a8bdbce0b fix(control): resolve the projection seer on the current executor
The seer controller.New bound was the executor's method value, and that value
reads the session in place: a SetSession from /new, resume, a branch switch, an
extension system prompt or a fold is already visible to it. Those sites answer
from the current session. A model switch is different — it builds a new
controller, and ApplyRuntimeMigration adopts the old conversation into it. The
carried turns still hold the standing blocks the old model was sent, so the
derived rule reported them delivered and the new model's first request carried
no listing.

The seer now resolves through the controller and answers "nothing is visible"
for a generation that has delivered nothing yet. ApplyRuntimeMigration marks
that state when the replacement's model ref differs from the outgoing one; the
hand-off to the runner clears it, so exactly one request restates the standing
blocks and the visible history answers after that. A rebuild that keeps the
model keeps the derived answer.
2026-10-22 12:41:12 +08:00
SivanCola 859b3d3382 fix(agent): bound post-answer observation and honor turn cancellation
Problem: A completed model answer could leave the controller running while
workspace observation or an evidence stream lock kept finalization alive.

Root cause: Post-answer observation and sealing detached their deadlines
from the turn context. Observation called filesystem operations synchronously,
so its existing timeout did not actually bound the caller's wait.

Fix: Carry the run context through every finalization entry point. Bound
observation with a buffered result that captures no mutable turn state,
record missing observations as gaps, and cancel unused initial snapshots.
Keep optional finalization cancellation from discarding completed answers.

Verification: Deterministic cancellation, deadline, observation-budget,
held-seal-lock and real boot/controller completion regressions fail before
the fix and pass on macOS and Linux arm64 after it. Related race checks,
121 frontend tests, formatting, vet, lint and changed-file checks pass.
The required builtin-tool and full boot package suites also pass.
2026-10-22 11:59:03 +08:00
Fucha b4e6be051e docs(theme): list Patina with the plain palettes 2026-10-22 11:29:53 +08:00
Fucha 72e85d0634 Merge branch 'esengine:studio' into studio 2026-10-22 11:29:18 +08:00
Fucha 68b68a58e0 revert: restore upstream theme author guide 2026-10-22 11:22:31 +08:00
Fucha 0db2d1d0d7 docs(theme): list Patina with the plain palettes 2026-10-22 11:20:30 +08:00
Fucha 1551bf6254 feat(theme): add Patina as a plain light/dark palette 2026-10-22 11:20:15 +08:00
YHH a708a88a75 Merge pull request #14130 from esengine/docs/release-notes-2.48.0
docs(release): studio 2.48.0 notes
2026-10-21 19:01:52 -07:00
Will 8f48aa844f docs(release-notes): record the guardian cost fix
The reviewer asked for a bilingual fragment: the paid total now includes
the guardian's reviews, and an unpriced row marks the total as a floor.
2026-10-22 09:54:27 +08:00
esengine 07af85d9eb docs(release): studio 2.48.0 notes 2026-10-21 18:46:55 -07:00
YHH c0c7d9c435 Merge pull request #14128 from esengine/fix/session-menu-fits-short-window
fix(desktop): keep a project's context menu inside a short window
2026-10-21 18:46:38 -07:00
esengine 32782ef1a4 docs(release): note the project menu fix 2026-10-21 18:14:05 -07:00
esengine ffb01d54f7 fix(desktop): keep a project's context menu inside a short window 2026-10-21 18:13:33 -07:00
YHH 69b6927cd9 Merge pull request #14124 from esengine/fix/changes-list-row-cap
fix(desktop): draw a page of the changes list, not every row
2026-10-21 17:53:47 -07:00
esengine f7777b4f9e fix(desktop): draw a page of the changes list, not every row 2026-10-21 17:32:22 -07:00
YHH 7a46cde93c Merge pull request #14047 from imcreazy/fix/undefined-bg-property
fix(studio): name a property the sheets define
2026-10-21 12:49:05 -07:00
YHH 692e669d3f Merge pull request #14123 from esengine/fix/machine-dir-must-exist
fix(cli): refuse a machine --dir that is not a directory
2026-10-21 10:29:43 -07:00
YHH 17cb9e2257 Merge pull request #14121 from esengine/fix/compact-ratio-float-noise
fix(cli): save the compact ratio as the decimal the user typed
2026-10-21 10:29:33 -07:00
YHH cb441be28a Merge pull request #14117 from esengine/fix/tool-card-lazy-body
fix(desktop): do not mount the body of a finished, closed tool card
2026-10-21 10:29:14 -07:00
esengine 296e06655a chore(desktop): drop scratch fixtures that were committed by mistake 2026-10-21 10:04:32 -07:00
esengine b6bbcb372a fix(cli): only convert a percentage exactly when it is in range 2026-10-21 10:04:17 -07:00
esengine 482441e50b fix(cli): refuse a machine --dir that is not a directory 2026-10-21 09:50:13 -07:00
esengine ed969ae2bc test(desktop): open the card before the output-cap guard reads what it left out 2026-10-21 09:46:19 -07:00
esengine 49959f6b39 fix(cli): save the compact ratio as the decimal the user typed 2026-10-21 09:45:14 -07:00
esengine 450f3e58eb fix(desktop): do not mount the body of a finished, closed tool card 2026-10-21 09:10:37 -07:00
YHH 8d6523d054 Merge pull request #14111 from xxoingr/docs/guide-terminal-keys-mcp
docs(guide): the terminal key tables match the /mcp panel and the keys 2.x has
2026-10-21 08:05:32 -07:00
YHH 006f8e7930 Merge pull request #14109 from xxoingr/docs/guide-zh-wsl-remote
docs(guide): the Chinese guide describes WSL machines and the agent-shell refusal
2026-10-21 06:06:08 -07:00
YHH 9e271a2272 Merge pull request #14107 from xxoingr/fix/price-source-format-chars
fix(config): a price source refuses invisible format characters
2026-10-21 06:05:25 -07:00
YHH 0123495435 Merge pull request #14064 from SivanCola/feat/preset-module-multiselect
feat(studio): grouped preset module multiselect / 预设模块分组多选
2026-10-21 06:04:41 -07:00
xxoingr c123e68f57 docs(guide): the terminal key tables match the /mcp panel and the keys 2.x has
Two rows of the picker table described the 1.x CLI: an "MCP import picker"
the 2.x terminal does not have (`reasonix mcp import` imports without one),
and an "MCP manager" whose `r` refreshed and whose number keys chose actions.
The 2.x /mcp panel (mcppanel.go, mcpmanage.go) moves with Up/Down or j/k,
opens a server's tools with Enter, toggles with Space, adds with n through a
review that installs with y, l or p, removes with d then y, signs in with a,
grants requested access with u, and reconnects a failed server with r.

The chat table also missed keys the terminal handles: Ctrl+S (steers a
running turn, sends an idle draft), Shift+PageUp/PageDown, Ctrl+Insert to
copy a selection, Shift+Insert to paste text where the terminal hands it
over, Ctrl+G to keep folded pastes as files, and Ctrl+D twice in the resume
picker. The tool approval row now says its numbers follow the rows shown and
that Esc does not answer in vi mode, and plan approval gets its own row. The
Chinese table also gains the Ctrl+Enter / /steer row it never had.

Pin the three behaviours no test held: Ctrl+S on an idle draft, an approval
number with a row missing, and Esc on a plan approval.
2026-10-21 20:27:03 +08:00
Will cbc094ebbf fix(stats): quote a guardian review's usage like any other call 2026-10-21 20:18:55 +08:00
xxoingr c06754bb45 docs(guide): the Chinese guide describes WSL machines and the agent-shell refusal
The English remote section gained two blocks the Chinese guide never got: a WSL
distribution as a machine (kind = "wsl", what it accepts, how Studio adds and
keeps it, where its model credentials come from, which subcommands stay
SSH-only), and the rule that an agent's own shell may run only `list`, `help`
and `wsl list|test` of `reasonix remote` (exit 77 otherwise). Translate both into
the same place in GUIDE.zh-CN.md, between the config example and the CLI block,
using the Studio labels the Chinese window shows (设置 → 远程, WSL 发行版).

The guide says `wsl test` runs for an agent, but the test pinned only
`wsl list`; add the `wsl test` row. A distribution that does not exist answers
after the same `wsl --list` the `wsl list` row already runs, so no distribution
is started.

Both were raised as non-blocking notes on #13784.
2026-10-21 20:15:41 +08:00
xxoingr 854323a0d9 fix(config): a price source refuses invisible format characters
validPriceSource and its page mirror sourceProblem refused control and blank
characters but not format characters (Unicode category Cf: zero-width, soft
hyphen, bidirectional controls). A source carrying one was stored as written
while the page drew something else: with U+200B or U+00AD inside the host the
kernel stores a host that is not "api-docs.deepseek.com", and the page's host
line, read through the browser's URL parser, shows "api-docs.deepseek.com";
U+202E in the path reverses the tail in the hover title.

Refuse Cf in the same loop on both sides, the check validElicitURL already
makes for a link a person is shown. The page uses \p{Cc}\p{Cf} so it also
refuses the C1 controls Go's unicode.IsControl already refused.

Raised as a non-blocking note on #13864.
2026-10-21 20:09:25 +08:00
YHH 2a77016909 Merge pull request #14103 from esengine/fix/memory-test-helper-redeclared
test(memory): reconcile the cross-process test helpers of the snapshot and lock changes
2026-10-21 03:52:20 -07:00
esengine 4ee2d8b27b test(memory): reconcile the cross-process test helpers of the snapshot and lock changes 2026-10-21 03:33:40 -07:00
YHH 3c07b93b30 Merge pull request #14100 from chuiwenwei/fix/memory-cross-process-lock
fix(memory): serialise store mutations across processes with one file lock per directory
2026-10-21 03:31:01 -07:00
YHH 58b315efbc Merge pull request #14096 from chuiwenwei/fix/memory-snapshot-must-hold-its-revision
fix(memory): a revision snapshot must hold the revision it names
2026-10-21 03:30:21 -07:00
YHH 074bb920b7 Merge pull request #14086 from SivanCola/codex/progressive-history-scheduling
fix(history): progressive legacy discovery and background scheduling / 旧会话渐进加载与后台调度
2026-10-21 03:29:41 -07:00
SivanCola 3193780122 fix(history): confine checkpoint reads to their session directory
Problem: CodeQL flagged direct path opens in the cancellable checkpoint readers.

Root cause: The normal and torn-tail JSONL readers followed file symlinks beyond
the parent directory supplied by their caller, even though frontend request
authorization is owned separately at the HTTP boundary.

Fix: Open both checkpoints through os.OpenInRoot, retaining cancellation and
recovery semantics while rejecting links outside that directory. Preserve
relative in-directory links. Add a deterministic boundary regression and
bilingual release notes, and separate the sidebar Retry action from its note.

Verification: The escaping-link regression fails before the change and passes
after it. Complete storage/replay/catalog/scheduler suites, focused race and
HTTP boundary tests, 13 frontend tests, TypeScript checking, release-note tests
and repository/Go lint pass. Hosted CodeQL must confirm the new head separately.
2026-10-21 18:02:48 +08:00
Will 87965d1863 test(control): pin a removal announced after a fold kept the block
An audit found that a fold which keeps the old block used to leave a
removal unannounced (the tombstone was gated on a bookkeeping flag the
fold cleared). Deriving delivery from the visible history fixes it; this
pins that a removed instruction is still announced while the model can
read the old block.
2026-10-21 17:51:05 +08:00
Will 3549a60457 docs(release-notes): record the cross-process lock
The reviewer asked for a bilingual fragment covering both effects: no
lost writes, and the new retryable lock error.
2026-10-21 17:46:57 +08:00
Will 4ed29fec06 docs(release-notes): record the snapshot-revision fix
The reviewer asked for a bilingual fragment; restoring a revision could
return another revision's body or report it missing.
2026-10-21 17:46:57 +08:00
Will b110b945f0 docs(release-notes): record derived delivery; rename the red test
Reviewer: state the constraint that holds now, and drop the red marker from
the file name; add a bilingual fragment for the user-visible changes.
2026-10-21 17:46:57 +08:00
Will 85ff4dc202 test(control): pin rewind and branch-switch re-delivery
An audit found the same hole through two more entry points: /rewind
truncates the turn that carried the block, and /switch replaces the
transcript with another branch's prefix. Deriving delivery from the
visible history fixes both, so the audit's two providers-boundary tests
now guard that they stay fixed.
2026-10-21 17:46:41 +08:00
Will 1772a7bbba fix(control): derive projection delivery from the visible history
A standing projection was settled as delivered when a turn was handed to the
runner, so anything that refused the turn before the provider (an extension
block, a cancel, a failed send) or a /new left the debt settled although no
request had carried the block.

Delivery is now a fact about the model-visible history: owed compares the
newest block of its tag in the visible user records, so a LocalOnly record or
compaction summary never answers for it. A refusal, cancel, fold, rewind or
session rotation changes the answer by itself; nothing is recorded at the
hand-off.

Removed: projectionDebt.{pending,delivered,settle,forget,sent,inherit},
promptFailureDebt.{composed,settle}, settleTurnProjections and runSettled, the
CompactionDone→forget handler, InheritScratchFrom and the per-owner forget
hooks. One-time failure/refusal notes are filtered by visibility instead of
settled, so a turn refused before the provider still owes them.
2026-10-21 17:46:41 +08:00
Will 3f0b2e4322 fix(memory): serialise store mutations across processes
internal/state/memory guarded its read-modify-write sequences with a
process-local mutex only, so two processes sharing a memory directory could
duplicate a subject key, lose a rename, roll a withdrawal over a newer
revision, or drop an index line.

Take one filelock per directory the store mutates (Dir and GlobalDir, sorted
by canonical absolute path) under the user lock root, bounded by a short wait
that returns a retryable StoreLockedError. The in-process mutex is still taken
first and the file lock second, one order everywhere, so the two cannot
deadlock.
2026-10-21 17:46:40 +08:00
Will 2596b4001d fix(memory): parse a fact without touching the filesystem
CodeQL flagged the new snapshot parser for stat'ing a caller-supplied path
(store_snapshot_identity.go:44, 'This path depends on a user-provided value').
The parser now takes the name only; loadMemory keeps the stat where it always
lived and fills the missing timestamps itself.
2026-10-21 17:46:39 +08:00
Will 21b47ad008 fix(memory): a revision snapshot must hold the revision it names
snapshotMemoryRevisionInDir re-read the fact file and filed those bytes
under memory.Revision without checking that the file still carried that
id and revision. Another process publishing revision N+1 in that window
made slot N hold revision N+1's bytes: Restore(id, N) then returned a
body revision N never had, and revision N became unrecoverable. The same
file name re-created with a different id was filed the same way.

The bytes the function already reads are now parsed by loadMemory's own
parser (parseMemory, factored out rather than duplicated) and the snapshot
is refused with the existing "memory revision conflict: expected %d,
found %d" wording when the id or revision no longer match. The check runs
before any slot is created, so a refusal files nothing. SaveWithOptions
and RestoreArchived already return a snapshot error before publishing, so
no caller changes. Single-process behaviour is unchanged: the
process-local mutex makes the mismatch impossible there.

Tests cover the two cross-process probes (refusal plus nothing filed),
archive-and-recreate with a new id, an ordinary save/restore control over
several revisions, and a mutation that removes only the check.
2026-10-21 17:46:39 +08:00
SivanCola 499bda0c93 fix(history): preserve deterministic replay coverage when syncing studio
Problem: PR #14086 conflicted with studio after the upstream listing-test repair.

Root cause: Both branches changed the same cache test. Upstream #14097 replaced
timing-dependent large fixtures with direct observation of cached replay, while
this branch added an assertion that derived summaries were persisted.

Fix: Merge studio at 58c80e3f9f, retain the upstream three-conversation corruption
fixture and preview checks, and assert the matching number of persisted entries.
Keep upstream commit history and the independent progressive-loading PR scope.

Verification: Complete sessionstore/sessionv4/historycatalog/backgroundwork
suites, focused cache/cancellation race checks repeated three times, real
resume/tree HTTP effects, 27 frontend interaction tests, and repository/Go lint.
2026-10-21 17:45:14 +08:00
YHH 58c80e3f9f Merge pull request #14097 from xxoingr/test/sessionstore-v4-listing-replay-count
test(sessionstore): observe the 1.x listing replay instead of timing it
2026-10-21 02:28:54 -07:00
YHH b346d3f016 Merge pull request #14091 from xxoingr/fix/agent-origin-user-texts
fix(agent): exempt from the origin check only what the person typed
2026-10-21 02:24:44 -07:00
xxoingr 7d5672d3a6 test(sessionstore): observe the 1.x listing replay instead of timing it
TestV4ConversationsWithoutAListingCacheAreReplayedOnce called a second
listing a replay unless it ran five times faster than the first. A busy
macOS runner measured 411ms against 1.35s and failed a PR that does not
touch sessionstore.

Garble each conversation's log in place after the first listing, keeping
the size and modification time the remembered listing is keyed on: a
listing that remembered returns the same rows, one that replays loses
them. Three small conversations now do what sixty large ones did.
2026-10-21 17:06:49 +08:00
Lancy b57079c75a fix(studio): label Ask work mode as not implemented 2026-10-21 16:45:07 +08:00
xxoingr 66915c2584 fix(agent): exempt from the origin check only what the person typed
userTexts, the strings a copied command is exempt for because the user
wrote them, returned each user turn's provider-visible Content. That is
the composed turn, with the host's recalled memory, instructions and
policy around the person's words, so a page's command that reached the
turn only through a host block was taken for one the user typed and was
neither counted nor put to the person.

Read each turn through sessionstore.UserMessageText, the definition the
history readers share. This only narrows the exemption.
2026-10-21 16:38:19 +08:00
YHH b7077ff4e2 Merge pull request #14082 from SivanCola/fix/workbench-file-drag
fix(studio): restore workspace file drag into chat / 恢复工作区文件拖入对话
2026-10-21 01:36:41 -07:00
YHH ce38855e90 Merge pull request #14090 from xxoingr/fix/agent-fold-digest-user-words
fix(agent): a degraded fold lists what the user said, not the host's blocks
2026-10-21 01:24:31 -07:00
YHH 0a715754bb Merge pull request #14084 from SivanCola/codex/startup-test-isolation
fix(startup): wait for MCP readiness and isolate tests / 修复启动等待与测试隔离
2026-10-21 01:20:50 -07:00
SivanCola 3b00e4bf63 fix(studio): preserve typed drop routing and register drag completion
Problem: The workbench drag change failed the action census and sidebar
text-drop regression in the frontend CI suite.

Root cause: The new dragend listener had no action identity, and the drop
router read the workspace-path payload without checking its advertised MIME
type. Transfers returning unadvertised data could be routed as file paths.

Fix: Register file-drop.finish through listenAction, require the workspace
MIME type before reading its path, and cover plain text and URI-list drops.

Verification: Reproduced the action census and both MIME regression failures
before the fix. All 752 frontend test files and 6027 tests pass, including
77 focused action/drop/intake tests. Typecheck, census, census:verify,
production build, bundle fixture exclusion, gofmt, go vet, make lint,
builtin/boot tests, and make check pass.

Refs #12771
2026-10-21 16:11:54 +08:00
xxoingr 9492263884 fix(agent): a degraded fold lists what the user said, not the host's blocks
mechanicalFoldDigest, the stand-in when the summarizer cannot digest a
fold, listed each user turn's provider-visible Content. The oldest turns
a fold takes carry the host's date and standing-instruction blocks, so
the 240-character line went on <current-date> and
<project-instructions> and the person's words were cut off, and a turn
of host blocks alone was listed as something the user said.

Read each turn through sessionstore.UserMessageText, the definition the
history index and its snippets already share, and skip a turn with
nothing left.
2026-10-21 16:04:49 +08:00
SivanCola 98c05123bb docs(studio): note restored workbench file dragging
Add the missing bilingual release-note fragment for PR #14082 so users
can discover that workbench files and folders can be dragged into chat.
Reference the related file-to-chat issue in both languages.

Verification: release-note rendering and Markdown reference tests passed
(12 tests), alongside the required pre-commit checks.

Refs #12771
2026-10-21 15:51:36 +08:00
SivanCola a4a01a20b4 docs(release): describe progressive legacy discovery
Problem: the split listing and scheduling change needs its own user-facing release fragment.

Fix: add bilingual notes for progressive summaries, incomplete coverage and foreground priority.

Verification: release-note rendering and Markdown reference tests, make check and git diff --check pass; runtime code is unchanged.
2026-10-21 15:42:46 +08:00
SivanCola 133a8952ba docs(release): describe ordinary MCP startup readiness
Problem: the user-visible startup behavior had no release fragment.

Fix: document in Chinese and English that ordinary resource reads wait for the authorized connection, as requested in review of #14084.

Verification: release-note rendering and Markdown reference tests pass; make check and git diff --check pass. No runtime code changed.
2026-10-21 15:42:19 +08:00
SivanCola 4e17efd046 fix(studio): restore workspace file drag references
Problem: Files and folders in the workbench could not be dragged into chat.
Root cause: Explorer and Git rows emitted no path payload, and composer
intake only recognized external files and ordinary text.
Fix: Emit a typed source-workspace path and route it through existing
reference validation. Preserve nested repository prefixes, clear cancelled
drag hints, and keep internal drags outside external-file deduplication.
Verification: Typecheck and 689 focused frontend tests passed, including
native-drop compatibility. Browser drag checks, make check, go vet, make
lint, builtin/boot tests, and both drop-handler Go suites passed.

Refs #12771
2026-10-21 15:31:19 +08:00
SivanCola 572ba05442 fix(history): discover legacy sessions progressively and yield background reads
Problem: Sidebar discovery waited for full foreign transcript replay while
history indexing competed with interactive session restoration.

Root cause: Complete-list reads owned metadata replay, identity replacement
scanned growing message slices, and derived-data producers lacked shared admission.

Fix: Return explicit progressive coverage, share single-flight summaries and
validated caches, fold replay identities linearly, and retain cancellable scan
generations. Admit background work through one foreground-aware scheduler.
Apply foreground admission at the history route group so independent readers
inherit it without coupling their implementation to background work.

Verification: Storage/scheduler/catalog suites, real Boot/resume and tree/HTTP
effects, sidebar interactions, production frontend build, race checks and lint.
Keep benchmark cleanup registered even when its bounded deadline fails.

Split from PR #14077; no startup fixes or transcript pagination are included.
2026-10-21 15:31:17 +08:00
SivanCola 4d2117f83d fix(startup): synchronize resource reads and isolate test IO
Problem: Boot resource reads intermittently refused an initializing server,
CLI validation stalled, and ACP rebuild-ordering tests exceeded their deadlines.

Root cause: Ordinary resource tools bypassed the existing connection owner,
output fixtures read finite pipes only after command return, and ACP tests
discovered the caller's user-scoped configuration and skills. Each mechanism
also reproduced on the unmodified baseline.

Fix: Route every registered resource tool through the authorized capability
runtime; drain both captured streams concurrently with failure-safe cleanup;
reuse process-level user-state isolation for ACP fixtures. Consolidate sibling
capture helpers, make resource-provider fixtures repeat-safe, and document
resource startup behavior.

Verification: Held-handshake cancellation/retry with unchanged schema bytes,
large-output helper subprocess, injected caller-skill isolation, complete
Boot/CLI/ACP inventories, repeated ACP ordering tests, capability tests, race
checks and repository/Go lint. Reconcile bounded Boot partitions instead of
raising the aggregate timeout or dropping tests.
2026-10-21 15:30:32 +08:00
YHH 35b9f132ae Merge pull request #14076 from chuiwenwei/fix/user-said-text-includes-appended-blocks
fix(history): strip the host-appended blocks from what the user said
2026-10-21 00:23:42 -07:00
Will 9ab636c5f6 docs(release-notes): record the appended-block fix and the one-time re-index
The reviewer asked for a bilingual fragment naming the TokenizerVersion 4
rebuild, since the 3 bump already made first open slower (#14048).
2026-10-21 14:58:56 +08:00
Will 329ca14a03 fix(history): read appended host blocks out of the user's words
sessionstore.UserSaidText stripped only the host blocks prepended to a
persisted user turn, while UserMessageText also stripped the blocks the host
appends after the person's words (<execution-policy>, <memory-recall>). The
history index and its snippets therefore indexed recalled facts and the frozen
policy as if the user had said them, so a query for a fact matched a row whose
snippet could not contain it: the same divergence #14022 fixed for the leading
blocks, one definition still giving two answers.

Move UserPreviewText's pipeline down into userAuthoredText and let UserSaidText
add the plan-mode marker strip; UserMessageText delegates to UserSaidText for
both RawContent and Content. The appended cut keeps the existing byte-exact
suffix rule, so a complete block the person quotes at the end of their own turn
is still indistinguishable from a host-appended one and is stripped; the test
pins that limit instead of hiding it.

Bump historycatalog.TokenizerVersion to 4 because the indexed vocabulary
changes again. ensureTokenizerVersion wipes history_fts/documents/sources/roots
when the stored stamp differs and the manager re-registers its roots, so the
next open re-indexes them; Rebuild stamps what it built with. Covered by
TestTokenizerVersionMismatchClearsMixedProjection and
TestRebuildSurvivesTheVersionCheck.

Tests: TestFTSIndexesWhatTheUserSaidNotTheAppendedBlocks and the extended
TestIndexAndSnippetAgreeOnTheUserText / TestSnippetBuildersReadTheIndexedUserText
pins were red before the change. TestAutoRecallNormalTurnIsUnchanged and
TestUserSaidTextQuotedExecutionPolicy are controls, green before and after.
2026-10-21 14:58:09 +08:00
YHH a2ad659757 Merge pull request #14071 from lancy69/fix/14012-feedback-account
fix(serve): expose account state to the feedback board
2026-10-20 23:23:38 -07:00
Lancy 32a42f9671 fix(serve): expose account state to the feedback board 2026-10-21 14:00:02 +08:00
YHH 4882d2a204 Merge pull request #14069 from pei711/fix/mcp-import-sqlite-14053
fix(mcp): import cc-switch servers without sqlite3
2026-10-20 22:23:25 -07:00
YHH c913955405 Merge pull request #14068 from pei711/fix/mcp-add-cli-errors-14052
fix(mcp): keep add argument errors readable
2026-10-20 22:00:29 -07:00
YHH 447bca2edf Merge pull request #14060 from esengine/fix/tui-quit-completion
fix(tui): offer /quit in the slash completion menu
2026-10-20 21:58:52 -07:00
YHH 97c52f02aa Merge pull request #14062 from lancy69/fix/14042-settings-search-layout
fix(studio): prevent settings search result overlap
2026-10-20 21:58:11 -07:00
YHH 0a6d853398 Merge pull request #14046 from imcreazy/fix/guardian-cursor-follows-rollback
fix(guardian): roll the transcript cursor back with the review
2026-10-20 21:57:30 -07:00
pei711 8b60e89bee fix(desktop): record SQLite transitive dependencies 2026-10-21 12:57:02 +08:00
YHH 1aa93e7f21 Merge pull request #14013 from SivanCola/fix/tool-argument-envelopes
fix(tools): normalize redundant argument envelopes / 兼容多余工具参数包装
2026-10-20 21:56:50 -07:00
pei711 f46f6fa64c fix(mcp): read cc-switch sqlite without external cli 2026-10-21 12:48:46 +08:00
pei711 bd13e43b2e fix(mcp): keep add errors readable and show help 2026-10-21 12:41:35 +08:00
SivanCola 9f08f957f3 feat(tools): propose enabling file delivery cards by default
Problem: File delivery requires explicit opt-in even when cards are useful.
Root cause: The user-only configuration key defaults to false.
Fix: Default it on while preserving explicit off choices, read boundaries
and tool exclusions. Keep this policy separate from the settings UI PR.
Verification: Config round trips, complete config/settings/builtin/boot
suites, exact request goldens, schema stability, vet and lint.

The default request fixture gains 143 schema tokens. A newly opened session
may have one cold prefix; this does not mutate the live session's schemas.
2026-10-21 12:27:21 +08:00
SivanCola eba966d685 refactor(studio): separate file delivery UI from the default policy
Problem: The settings PR also changed the default tool surface.
Root cause: UI discoverability and default-on policy were bundled together.
Fix: Restore the existing default, registration fixtures and configuration
rendering, retaining the searchable user-only switch below Tool approval.
Keep UI wording and route tests independent of a future default decision.
Verification: Settings and transport tests, browser captures, Go suites,
frontend build, vet, lint and repository policy checks.
2026-10-21 12:27:21 +08:00
SivanCola c2a485ebc5 feat(studio): add grouped preset module multiselect
Problem: Preset modules could only be added one at a time, with two different actions both labeled Add module.

Root cause: A native single-choice picker shared its label with manual blank-ID insertion, and catalog choices did not carry source provenance.

Fix: Add a shared searchable grouped checkbox picker, authoritative catalog provenance, batch selection and parameter-safe draft updates. Keep manual IDs under an explicit advanced entry and document selection semantics.

Verification: 219 frontend tests, typecheck, production build, census, owning Go packages including boot and builtin, focused Desktop preset/composition tests, vet, lint, make check and documentation checks passed. Browser checks covered themes, responsive layouts and save/reopen with MockHub.
2026-10-21 12:15:04 +08:00
SivanCola f095d8e9f8 feat(studio): expose file delivery settings and enable cards by default
Problem: AI file delivery cards required a manual, default-off config edit.

Root cause: present_files had no settings surface or read/write endpoint, and
the config delta renderer treated false as the default.

Fix: Add a searchable user-only switch immediately below Tool approval.
Default the tool on, preserve explicit off choices, and keep registration
changes at session opening. Update wire contracts, documentation and the
provider-visible tool fixtures for the additional 143 schema tokens.

Verification: Config round trips, route grants, UI persistence/search/order,
real boot tool-surface effects, frontend tests/build, Go tests/vet/lint and
browser checks in light/dark wide and phone layouts.
2026-10-21 12:04:27 +08:00
SivanCola 93409eec04 fix(tools): normalize envelopes and use patched Go
Problem: A redundant arguments envelope makes usable tool calls fail required
field validation and can cause repeated model retries. The current CI security
scan also reports GO-2026-6809 in the pinned Go 1.26.9 standard library.

Root cause: Scheduling, policy checks, execution, and history had no shared
representation of effective arguments inside a schema-invalid wrapper. The
root and Desktop modules still selected the affected Go patch release.

Fix: Unwrap one unambiguous envelope only after full target-schema validation.
Use effective arguments for host execution, evidence, decision barriers, and
surface actions while retaining the original provider call. Select Go 1.26.10
for both modules so CI and packaged Desktop use the patched standard library.
Style the compatibility notice and original-input disclosure like adjacent
tool metadata, with a visible expand marker and keyboard focus outline.

Verification: The envelope regression suites, provider history, permission,
proxy, and interactive surface tests passed. With Go 1.26.10, govulncheck ./...
reported zero reachable vulnerabilities and go vet ./... passed. Frontend type
and interaction checks passed on the rebased PR. The final card style passed
frontend type checking, 16 relevant UI tests, make check, and local browser
inspection in light and dark themes with its disclosure closed and open.
2026-10-21 11:56:58 +08:00
Lancy 4cd15feafe fix(studio): prevent settings search result overlap 2026-10-21 11:48:11 +08:00
imcreazy e8d1c2efaf fix(guardian): roll the transcript cursor back with the review 2026-10-21 11:41:08 +08:00
esengine 133008c612 fix(tui): offer /quit in the slash completion menu 2026-10-20 20:37:36 -07:00
YHH 596c3ada3c Merge pull request #14057 from esengine/docs/release-studio-2.47.1
docs(release): studio 2.47.1 notes
2026-10-20 20:34:33 -07:00
imcreazy 5c1fa16f00 fix(guardian): roll the transcript cursor back with the review 2026-10-21 11:32:32 +08:00
esengine 7e225e6493 docs(release): studio 2.47.1 notes 2026-10-20 20:26:44 -07:00
YHH 7234218c0f Merge pull request #14050 from esengine/fix/go-1.26.10-x509
build: use Go 1.26.10 for the crypto/x509 fix (GO-2026-6809)
2026-10-20 20:26:00 -07:00
YHH 9a9f6cc58a Merge pull request #14024 from chuiwenwei/fix/history-scan-confirms-absence
fix(history): a scan must confirm absence before it marks a source missing
2026-10-20 20:06:02 -07:00
YHH 6ae474fc5b Merge pull request #14023 from chuiwenwei/fix/history-unreadable-transcript-not-no-results
fix(history): an unreadable transcript is a corrupt source, not no results
2026-10-20 20:05:25 -07:00
YHH 1565de632a Merge pull request #14043 from lancy69/fix/14041-project-button-highlights
fix(studio): unify project header button highlights
2026-10-20 20:04:48 -07:00
esengine b0168f37dc build: use Go 1.26.10 for the crypto/x509 fix (GO-2026-6809) 2026-10-20 20:01:33 -07:00
imcreazy 1fe9055ca3 fix(studio): name a property the sheets define 2026-10-21 10:52:20 +08:00
imcreazy d014d9dacb fix(guardian): roll the transcript cursor back with the review 2026-10-21 10:52:13 +08:00
imcreazy 640ec25699 fix(serve): carry an unprefixed request to a remote pane 2026-10-21 10:52:07 +08:00
imcreazy d79787b973 fix(netdns): read a TCP reply past its length prefix 2026-10-21 10:52:00 +08:00
Lancy 396b2bba00 fix(studio): unify project header button highlights 2026-10-21 10:43:06 +08:00
Will 32f49f013b fix(history): a scan must confirm absence before it marks a source missing
reconcileRoot treated seen_generation<>generation as proof that a transcript is
gone. A row indexed by another process's persist event carries generation 0, so a
conversation indexed after this scan listed the directory was swept health=missing:
it vanished from history search and the incomplete-index warning stayed silent,
because the root's counts still looked complete. Measure absence with stat and
adopt live rows into this generation; only a transcript confirmed gone is swept.
2026-10-21 09:07:51 +08:00
YHH 27b5b6bf6a Merge pull request #14039 from esengine/docs/release-studio-2.47.0
docs(release): studio 2.47.0 notes
2026-10-20 17:53:12 -07:00
esengine df35515af1 docs(release): studio 2.47.0 notes 2026-10-20 17:42:12 -07:00
YHH 334acb2a1f Merge pull request #14038 from esengine/fix/term-one-text-node
fix(studio): draw a finished call's output as one text node, not one per line
2026-10-20 17:35:00 -07:00
esengine 4d97185ed0 fix(studio): draw a finished call's output as one text node, not one per line 2026-10-20 17:23:14 -07:00
YHH e78bbacf94 Merge pull request #14036 from imcreazy/fix/refine-tip-inside-the-card
fix(studio): keep the refine tip inside the ask card
2026-10-20 16:36:02 -07:00
imcreazy ede862f78c docs(studio): state the tip's anchor in one sentence 2026-10-21 07:22:30 +08:00
imcreazy 602c9a3e1d fix(studio): closing the last page offers a new tab 2026-10-21 07:13:30 +08:00
imcreazy 2e49f0f4f9 fix(studio): keep the refine tip inside the ask card 2026-10-21 07:13:24 +08:00
YHH 1711adf425 Merge pull request #14035 from esengine/dependabot/npm_and_yarn/workers/accounts/vite-7.3.7
build(deps): bump vite from 7.3.6 to 7.3.7 in /workers/accounts
2026-10-20 14:59:34 -07:00
dependabot[bot] 51e04cf6a4 build(deps): bump vite from 7.3.6 to 7.3.7 in /workers/accounts
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.6 to 7.3.7.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.7/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.7/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-20 21:50:36 +00:00
YHH 366f3e69f0 Merge pull request #14031 from SivanCola/feature/file-delivery-preview
Add file deliveries and document previews / 新增文件交付卡片与文档预览
2026-10-20 14:49:11 -07:00
SivanCola 684521935a Clarify the HTML preview default in release notes
Problem: the release fragment mentioned static HTML support without explicitly
calling out the change from automatic live rendering.

Fix: state in Chinese and English that HTML opens with scripts disabled and
desktop users select Interactive run to execute the page, as requested in review.

Verification: release-note script tests, make check and whitespace validation.
No runtime behavior, dependencies or test expectations changed.
2026-10-21 05:04:35 +08:00
SivanCola 6a0f3624e1 Align preview and tooltip contracts across frontend checks
Problem: frontend-next CI failed 25 assertions across action discovery,
orphan detection, HTML preview races and copy feedback consumers.

Root cause: TooltipButton was not declared as an interaction primitive; new
preview controls had no action identities, and the superseded image component
remained orphaned. Neighbouring tests still expected native title tooltips and
automatic HTML execution rather than the explicit Interactive run action.

Fix: certify and name the actual UI inputs, restrict tooltip blur to local
dismissal, remove the unused image renderer, and align consumer tests with the
new contracts while retaining copy failure/retry and browser race coverage.
The unnamed-input ceiling falls from 218 to 217 rather than being relaxed.

Verification: full frontend suite (749 files, 6010 tests); supplemental action,
tooltip and browser guards; typecheck and production build; go vet, make lint
and make check. No workflow skip, timeout increase or alert suppression.
2026-10-21 04:27:08 +08:00
SivanCola ce16065b2c Unify safe file references for delivery cards and the workbench
Problem: delivery-card Add to conversation bypassed the workbench's reference
validation, allowing names with token boundaries or MCP ambiguity into drafts.

Root cause: Pane independently formatted @ references from display paths.
The kernel grammar preserves literal backslashes and only escapes space/tab,
so generic backslash doubling would change the selected file.

Fix: share one grammar-specific formatter, use structured relative paths for
workspace cards, disable unrepresentable references while keeping preview and
path copying, and remove the unused intake refToken helper. Keyboard navigation
and initial focus skip disabled menu actions.

Verification: 170 related frontend tests, production build/typecheck, real
browser insertion, kernel backslash round-trip and scoped-resolution tests,
go vet, make lint and make check. Four new rejection cases fail before the fix.
2026-10-21 03:59:31 +08:00
YHH 01e181c5ab Merge pull request #14033 from esengine/dependabot/npm_and_yarn/workers/forum/vite-7.3.7
build(deps): bump vite from 7.3.6 to 7.3.7 in /workers/forum
2026-10-20 12:55:44 -07:00
YHH 97deb64504 Merge pull request #14032 from esengine/dependabot/npm_and_yarn/workers/crash-report/hono-4.13.11
build(deps): bump hono from 4.13.0 to 4.13.11 in /workers/crash-report
2026-10-20 12:53:35 -07:00
SivanCola 85bd880d68 Make file delivery an explicit user opt-in
Problem: the presenter was registered for every install, and review requested
default-off model behavior, repository screenshot removal and PDF hardening.

Root cause: present_files used the unconditional builtin path rather than the
existing user-scoped conditional registration and provider-surface mechanism.

Fix: gate registration with tools.present_files, protect user scope across
project merges, preserve the setting on save, and expose the tool only after
opt-in. Restore the unconditional schema budget and remove PR screenshots.
Document PDF.js 6's removal of eval/isEvalSupported, keep scripting disconnected
and explicitly disable XFA rather than passing an unsupported no-op option.

Verification: boot.Build direct/proxy off/on/forbidden/disabled/excluded effect
matrix; user-scope and save/reload tests; document tests and typecheck; real
browser PDF navigation; go vet, make lint, make check and release-note tests.
2026-10-21 03:48:14 +08:00
dependabot[bot] 60caef5ffc build(deps): bump vite from 7.3.6 to 7.3.7 in /workers/forum
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.6 to 7.3.7.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.7/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.7/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-20 19:45:26 +00:00
dependabot[bot] 99937db751 build(deps): bump hono from 4.13.0 to 4.13.11 in /workers/crash-report
Bumps [hono](https://github.com/honojs/hono) from 4.13.0 to 4.13.11.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.13.0...v4.13.11)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.13.11
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-20 19:45:18 +00:00
YHH 0476f59811 Merge pull request #14030 from esengine/ci/windows-full-timeout
ci(windows): give the full-suite package timeout headroom over assembly/boot
2026-10-20 12:43:53 -07:00
SivanCola 06dcafbb4e Document file delivery and preview release notes
Record the user-visible preview formats, responsive delivery cards and refresh fixes for PR #14031. Verified with make check and git diff --check; no runtime behavior changed.
2026-10-21 03:33:35 +08:00
SivanCola e1159a4685 Add structured file deliveries and workbench document previews
Problem: AI-created documents could not be handed off as validated, durable
file cards, and binary files could not be read through the text editor route.

Root cause: delivery identity lived only in ordinary tool output, while file
reading, view selection and auxiliary actions lacked a shared preview path.

Fix: add a bounded root-confined reader, Controller and raw-byte preview APIs,
and host-confirmed present_files results projected through live and stored
history. Add lazy PDF, worker CSV and sandboxed HTML viewers, responsive file
cards, type icons, current-file state and accessible shared tooltips. Preserve
dirty drafts while surfacing deletion/revocation and avoid metadata reads per
keystroke. Keep the current workbench request-consumption contract intact.

Verification: root and desktop Go tests, go vet, make lint/check, frontend
typecheck, 498 related tests and production build. Browser checks cover PDF
navigation, CSV literals, menus, request consumption and responsive themes.
The builtin schema inventory grows by 143 measured tokens; boot effect tests
verify stable schemas within a turn for direct and capability-proxy calls.
2026-10-21 03:32:25 +08:00
esengine 2fa664b391 ci(windows): give the full-suite package timeout headroom over assembly/boot 2026-10-20 12:25:20 -07:00
YHH d338b51bd0 Merge pull request #14021 from lancy69/fix/14016-session-highlight-padding
fix(studio): add right padding to session highlights
2026-10-20 08:52:37 -07:00
Lancy b884243777 fix(studio): pad session highlight trailing edge 2026-10-20 23:19:31 +08:00
YHH 35561f0bd8 Merge pull request #14022 from chuiwenwei/fix/history-index-strips-host-blocks
fix(history): index what the user said, not the blocks the host prepends
2026-10-20 07:52:21 -07:00
Will af3b715c10 docs(release-notes): record the incomplete-index notice for an unreadable transcript
The reviewer asked for a user-visible fragment on #14023.
2026-10-20 21:43:56 +08:00
Will 7186e8b5d8 fix(history): an unreadable transcript is a corrupt source, not no results
Cause: candidateTexts.of loaded a candidate's transcript only to build a
snippet. When that load failed it set failed[path] and returned, dropping the
candidate with no error, no repair, and no status change. The row's ledger
digest and size/mtime still matched, so Indexed == Total kept certifying the
projection complete and the history tool answered "No saved session history
matched ..." for a query the session did match.

Fix: record a deterministic read failure with the shape the indexer already
uses for the same fault. RecordReadFailure flips the row from ok to corrupt
with last_error (existing columns), publishes the revision so Status reports
Indexed < Total, and re-enqueues the source. The periodic scan (#13923: a
non-ok source is always revisited) then re-reads it, so a restored mode heals
without a save.

Transient failures are not distinguishable from permanent ones at this layer,
so every load failure is recorded; retries stay bounded because a row that is
already corrupt is not re-queued per search, and the scan retries it once per
interval (five minutes by default). No wording, persisted field, schema, or
tokenizer version changed.

Tests: the audit's red test now passes (the tool prefixes the existing
"history index is still building" notice and status reports Failed 1,
Indexed 0/1); a readable control that genuinely matches nothing still answers
no matches with no alarm; and after chmod is restored the next ReconcileRoot
scan serves the content again. Mutation check: reverting only the fix leaves
the first and third red with the quoted false claim.
2026-10-20 21:43:28 +08:00
Will f8821f43f9 docs(release-notes): record the one-time history re-index
Reviewer asked for a user-visible fragment: the change alters what history
search returns and rebuilds the index once (TokenizerVersion 3).
2026-10-20 21:17:49 +08:00
Will aeb01cc6bb fix(history): index the user's own words, not host-injected blocks
The FTS projection indexed raw user message Content while every display path
stripped the host's prepended blocks (<response-language>, <current-date>,
<project-instructions>, <available-skills>, <workspace>): a query for a
block-only phrase matched a row whose snippet could not contain it.

Move the strip into sessionstore.UserSaidText, the one definition of what a
user turn says, and call it from both sides: the indexer
(historycatalog/documents.go) and the snippet builders (history/search.go,
history/indexed.go). The history package's private stripComposePrefixes is
gone, so the two cannot drift again.

Scope is today's snippet behaviour: the leading blocks stripComposePrefixes
took, plus the plan-mode marker. Trailing <execution-policy>/<memory-recall>
stripping is deliberately not added; whether "the user said" also excludes
host-appended blocks is a product decision left for the PR body.

Bump historycatalog.TokenizerVersion to 3 because the indexed vocabulary
changes. ensureTokenizerVersion wipes history_fts/documents/sources/roots when
the stored stamp differs, and the manager re-registers its roots, which
re-indexes them; Rebuild stamps the version it built with. Covered by
TestTokenizerVersionMismatchClearsMixedProjection and
TestRebuildSurvivesTheVersionCheck.

Tests: TestFTSIndexesWhatTheUserSaidNotTheHostBlocks (red before the change),
TestIndexAndSnippetAgreeOnTheUserText, TestIndexKeepsPlainUserTextUnchanged,
TestSnippetBuildersReadTheIndexedUserText.
2026-10-20 21:10:57 +08:00
YHH 0203158a83 Merge pull request #14015 from SivanCola/feat/preset-on-demand
feat(composition): load preset resources on demand / 自定义预设资源按需加载
2026-10-20 05:28:35 -07:00
octo-patch f9718c5552 test(minimax): return valid streams and refresh discovery fixtures 2026-10-20 20:02:41 +08:00
octo-patch 2648d9b684 refactor(minimax): keep effort branches within complexity budgets 2026-10-20 20:00:52 +08:00
octo-patch eb2910aada fix(minimax): filter retired text models in live discovery 2026-10-20 19:50:13 +08:00
octo-patch 9e4c73d280 feat(minimax): default to M3.1 Flash and preserve reasoning contracts 2026-10-20 19:32:56 +08:00
SivanCola 073079d675 feat(composition): load preset resources on demand
Problem: selecting a custom preset captures unused skill templates and
extension packages and waits for optional MCP startup.

Root cause: resource snapshot creation also served as the runtime permission
model, making selection cost scale with installed resource payload.

Fix: add live capability-grant bindings while preserving legacy snapshots.
Resolve current skill/MCP/extension/hook/command authority at use, defer
optional startup, retain stable provider contracts, and fence new event
and archive formats from older readers. Use lightweight status summaries
and per-instance extension call leases for safe concurrent replacement.

Verification: root and Desktop suites, full boot/builtin suites, affected
storage/controller/frontend tests, lifecycle race checks, frontend interaction
tests and typecheck, lint/vet/check, actionlint, and Windows cross-compilation.
Twenty-sample real switch measurements include build, persist and publication;
64 MiB unused templates remain outside the binding and stay below the local
500 ms P95 target. Native Windows execution is delegated to hosted CI.
2026-10-20 18:49:01 +08:00
YHH 049a8c42e1 Merge pull request #14011 from xxoingr/fix/restore-resume-picker-delete
fix(tui): restore the resume picker's two-press delete that a merge dropped
2026-10-20 03:04:10 -07:00
xxoingr d451045f94 fix(tui): restore the resume picker's two-press delete that a merge dropped
#13954 let /resume delete a saved session with a confirming second
Ctrl+D. The merge of #13755 (d87d0d959) reverted it: its branch's
"Merge studio into a5/schedule-api" (ba48dfe0b) resolved files only
studio had changed to the branch's older copies, taking out the picker
code, its tests, the i18n catalogue and the release note.

Restore #13954's eleven files to their content on the studio side of
that merge; none has changed since.
2026-10-20 17:47:13 +08:00
YHH 8c0d397566 Merge pull request #14010 from xxoingr/fix/restore-bang-login-path
fix(shell): restore the login-shell PATH for ! commands that a merge dropped
2026-10-20 02:42:07 -07:00
YHH e418a0562f Merge pull request #13435 from imcreazy/feat/netdns-configured-resolvers
feat(netdns): resolve through the servers the person named
2026-10-20 02:31:43 -07:00
xxoingr a3f825daf9 fix(shell): restore the login-shell PATH for ! commands that a merge dropped
#13956 gave user ! commands the login shell's PATH through
shellrun.CommandEnv, shared with the bash tool. The merge of #13792
(ac1a737ee) reverted it: its branch's "Merge studio into
feat/13111-origin-ask" (ed1dfebb9) resolved files only studio had changed
to the branch's older copies, deleting shellrun/loginenv.go, the Env
line in RunShellWith, the regression test and the release note, and
moving the probe back into bash.go. A ! command again ran with only the
host's PATH, so tools from nvm or Homebrew were not found when Studio
was started from a launcher.

Restore the nine files to their content on the studio side of that
merge; none has changed since.
2026-10-20 17:24:50 +08:00
YHH fa9bd48500 Merge pull request #14009 from esengine/fix/import-sidecars-not-sessions
fix(import): do not report 1.x sidecar logs as unreadable sessions
2026-10-20 02:20:33 -07:00
esengine dae12c5711 fix(import): do not report 1.x sidecar logs as unreadable sessions 2026-10-20 02:05:02 -07:00
imcreazy deacb7c10c feat(netdns): declare the resolver keys, on top of current studio
Studio now requires every config key to be declared once with who may change it
and when it takes effect; the two keys this branch adds were not in that table,
so loading a config panicked with `setting.undeclared: network.dns`.

Both are `Guarded` / `OnOpen`, beside `network.proxy_url`: which resolver a
request goes through is a network boundary, so the model must not change it by
itself, and a new value applies on the next start rather than mid-session.

Also rebased onto studio, which the same push asked for.
2026-10-20 17:03:32 +08:00
YHH f44166a33f Merge pull request #14006 from SivanCola/fix/persona-composition-resources
fix(composition): load linked resources and explain MCP failures / 修复人设资源加载与 MCP 报错
2026-10-20 01:59:34 -07:00
YHH 0fcc6be63f Merge pull request #13948 from SivanCola/claude/realrepo-long-tasks
bench(realrepo): long tasks over a pinned snapshot of this repository
2026-10-20 01:50:20 -07:00
YHH a970ce173b Merge pull request #13987 from esengine/fb/exec-fs
remote: serve files over exec when the server refuses the sftp subsystem
2026-10-20 01:44:35 -07:00
SivanCola 6f967a5d4e fix(composition): resolve Windows junction sources by physical identity
Problem:
The native Windows link gate fails when capturing a junction source with
an internal junction, despite the ordinary symlink cases passing.

Root cause:
filepath.EvalSymlinks can retain a Windows junction alias and fail when
walking its child. It is not the repository's physical-identity resolver.

Fix:
Use fileutil.ResolveExistingPath for source roots, contained entries, and
single-file hook parents. Preserve containment checks, rooted content reads,
strict retained-directory verification, and filesystem error identities.

Verification:
Local source-capture and composition regressions and Windows amd64 test
compilation pass. Native verification remains owned by the Windows
composition-links gate, including the previously failing junction case.
2026-10-20 16:36:13 +08:00
YHH 11660393cc Merge pull request #14007 from esengine/a5/test-cold-import
test(studio): give a cold lazy import more room and cap the test workers
2026-10-20 01:29:16 -07:00
zumac 7f8b6ff994 fix(realrepo): check the stale-skills manifest without shasum
The Windows runner's Git Bash has no shasum, and the grader's
`shasum -c ... || exit 1` turned "command not found" into the same exit 1 as
a changed file, so the reference solution failed there with nothing printed.
The SHA-256 manifest is now checked with hashlib inside the grader's Python,
over raw bytes, and a mismatch names the files that differ.

gradeSeed now carries the grader's output in its error: the CI log said only
"exit status 1", which cannot tell a missing tool from a failed check.

Verified locally: on a PATH without shasum the reference solution grades
clean; a CRLF Makefile and an edited Go file each fail and are named; the
pristine seed still fails. snapshot.sh --check, the corpus tests and
make check pass.
2026-10-20 01:27:12 -07:00
esengine 58ac061010 execfs: create the temp file exclusively under an unguessable name; skip the shell-over-ssh test on Windows 2026-10-20 01:25:51 -07:00
SivanCola c6e1c0b8f8 test(composition): require native Windows link coverage
Problem:
The Windows smoke suite did not include agentpreset's snapshot tests and
symlink fixtures could be skipped without proving the reported platform.

Root cause:
The platform package list omitted agentpreset, and no fixture exercised
directory junctions. One portable assertion assumed Unix executable bits.

Fix:
Add native junction capture/restore, hook bundle, escape, cycle, and retained
snapshot rejection cases. Run nine focused source-link tests on Windows and
fail the gate for skipped or missing cases. Compare retained permissions to
the actual source filesystem mode.

Verification:
agentpreset and testenv suites, Windows amd64 test compilation, go vet,
make lint, make check, and actionlint pass locally. Native Windows execution
is explicitly pending the hosted composition-links step.
2026-10-20 16:20:09 +08:00
YHH 5aae5946fd Merge pull request #14000 from lancy69/fix/13982-git-indicator-alignment
fix(studio): align the composer Git indicator with its neighbours
2026-10-20 01:13:47 -07:00
YHH e198bd6741 Merge pull request #14004 from esengine/a5/new-answer-divider
feat(studio): mark where the new answers start when a session with unread answers opens
2026-10-20 01:11:54 -07:00
SivanCola eb2c2851d1 fix(composition): retain linked resources and explain dependency failures
Problem:
Saved personas can fail when selected with inherited skills, extensions, or
hooks. MCP startup failures lack structured server identity and guidance.

Root cause:
Ordinary discovery accepts linked sources but composition snapshot capture
rejects them. MCP startup errors carry the server only in prose instead of
the module metadata consumed by the localized refusal boundary.

Fix:
Flatten contained source links through rooted reads, retain strict snapshot
verification, and preserve module identities and sanitized failure causes
through HTTP and Chinese/English rendering. Keep required MCP failures
blocking and preserve the original session on a failed selection.

Verification:
Full root Go tests, go vet, make lint, make check, frontend typecheck, and
198 relevant frontend tests pass. Browser fixtures cover failure, retry,
management access, and light/dark wide/phone error presentation. Regressions
cover source deletion and restart, prefix stability, revocation, containment,
snapshot tampering, and credential redaction.
2026-10-20 16:03:09 +08:00
esengine ff70a2bc37 test(studio): give a cold lazy import more room and cap the test workers 2026-10-20 01:02:35 -07:00
YHH b745d0f372 Merge pull request #14003 from esengine/a9/ci-register-row-guards
ci(studio): run the session row guards in the browser guards job
2026-10-20 01:00:41 -07:00
zumac 7e92388102 bench(realrepo): long tasks over a pinned snapshot of this repository
The e2e fixtures finish in 5-15 requests, while a real task in this tree took
deepseek-flash 38. A change aimed at cutting round trips cannot show up on
the short suite, so this adds four long tasks whose workdir is a git archive
of commit 42636ddcb (pricing, ablation, agentgraph and five stdlib-only
leaves) plus a trimmed REASONIX.md, a Makefile, a module README and a small
benchctl consumer:

- rt-cache-write-rate: a CacheWrite rate threaded through cost, fingerprint,
  card identity and the catalog, plus an Anthropic row that uses it
- rt-stale-skills: Markdown-only repair of three project skills with no
  recognised verifier, the shape behind #13293's lingering stale_verification
- rt-rate-lookup-signatures: five lookups moved to (RateCard, bool),
  CatalogKey and ModelRef across about 20 call sites, most in _test.go
- rt-plan-ablation-module: a five-step plan-first task signed off per step

snapshot.sh rebuilds every workdir from the pin and --check detects drift.
One pinned test compares CheckedOn with the wall clock and would fail every
workdir from 2026-11-20; base/ anchors it at the snapshot day instead.

Each task ships a solution/; the corpus is registered with the authoring
guards, which check that every seed fails its grader and every solution
passes. A deepseek-flash baseline (2 trials) solved 8/8 at 13-31 requests
once two grader false negatives in rt-stale-skills were fixed; the README
records it, along with a multi_edit prompt arm and a complete_step
description arm that did not move the request count.
2026-10-20 00:57:36 -07:00
YHH e7c5939a5c Merge pull request #14002 from esengine/fix/agent-composition-unavailable-module-2
fix(composition): say which module is unavailable and why
2026-10-20 00:50:54 -07:00
YHH 46ea184425 Merge pull request #14001 from esengine/a5/file-request-once
fix(studio): a file opened from a conversation link is not reopened when Files is pressed
2026-10-20 00:40:52 -07:00
esengine b48c8ec14d docs(release-notes): shorten the Git indicator fragment under the line limit
The English item was 209 columns against the 200-column limit; the lint job named it.
2026-10-20 00:40:00 -07:00
YHH 02da795b1b Merge branch 'studio' into fix/13982-git-indicator-alignment 2026-10-20 00:39:15 -07:00
esengine ac53f8b48c ci(studio): run the session row guards in the browser guards job 2026-10-20 00:31:10 -07:00
esengine 4c0bcf58e9 feat(studio): mark where the new answers start when a session with unread answers opens
Refs #13962
2026-10-20 00:25:20 -07:00
esengine 72892df7c7 ci: run the branch-alignment guard; add the release-note fragment
The guard in perf/branch-alignment.mjs was not listed in the Studio workflow, so nothing ran it; the fix itself is user-visible and carries a fragment.
2026-10-20 00:21:32 -07:00
esengine 4ca0351086 fix(composition): say which module is unavailable and why
The unavailable refusal carried the module and detail only inside a nested
diagnostics list, so the window filled a generic sentence from a code alone.
Flat module/detail params now cross the boundary and both languages use them.
2026-10-20 00:21:21 -07:00
esengine 4342d20045 docs(release): note for the file-request replay fix
Refs #13993
2026-10-20 00:21:00 -07:00
esengine 70aee6bd23 fix(studio): a file opened from a conversation link is not reopened when Files is pressed
Refs FB-S30W-SF40
2026-10-20 00:21:00 -07:00
Lancy be2b4c4757 fix(studio): align the composer Git indicator with its neighbours 2026-10-20 15:18:59 +08:00
YHH 58ac20fddd Merge pull request #13995 from esengine/fix/split-oversized-files
refactor(studio): split two files that are over the 800-line ceiling
2026-10-20 00:18:54 -07:00
YHH 8c77234757 Merge pull request #13992 from esengine/a9/known-hosts-zone
fix(remote): never write an IPv6 zone into known_hosts, and skip unreadable managed lines with a warning
2026-10-20 00:18:50 -07:00
YHH 2895ba3249 Merge pull request #13983 from esengine/a9/delivery-prose-no-vcs
fix(readiness): Delivery owes no generic check for prose outside any repository
2026-10-20 00:07:02 -07:00
esengine b85f9939fe refactor(studio): split the market actions and the Settings props out of two files over the ceiling 2026-10-20 00:05:18 -07:00
YHH 5573356405 Merge pull request #13989 from SivanCola/split/jobs-launch
fix(jobs): a job started while the manager closes no longer races its wait
2026-10-20 00:03:25 -07:00
YHH a44df06755 Merge pull request #13986 from esengine/a5/mark-read
feat(studio): mark a session read from its menu without opening it
2026-10-19 23:58:26 -07:00
YHH 4d3dbd69f6 Merge pull request #13976 from imcreazy/fix/numeric-error-code-in-responses-and-gemini
fix(provider): read a numeric error code in the responses and gemini models
2026-10-19 23:56:54 -07:00
YHH 533e75933e Merge pull request #13946 from SivanCola/fix/session-scratch-literal-path
fix(control): tell the model its scratch directory is $TMPDIR and needs no cleanup
2026-10-19 23:55:03 -07:00
YHH d87d0d959e Merge pull request #13755 from esengine/a5/schedule-api
feat(schedule): controller port, serve routes and port types for scheduled runs
2026-10-19 23:53:38 -07:00
esengine 67d477877b fix(remote): never write an IPv6 zone into known_hosts, and skip unreadable managed lines with a warning 2026-10-19 23:49:59 -07:00
YHH f0ce91a097 Merge pull request #13985 from SivanCola/fix/cua-socket-readiness
fix(studio): wait for embedded CUA readiness / 修复 CUA 启动就绪竞态
2026-10-19 23:46:53 -07:00
YHH ea43bb3014 Merge pull request #13414 from imcreazy/android-shell
android: run the kernel and page as an Android app
2026-10-19 23:45:28 -07:00
YHH 66c923f4a8 Merge pull request #13720 from SivanCola/fix/electron-launcher-ownership
fix(studio): end a development shell with its launcher and survive a closed console / 开发版 Studio 随启动者退出,控制台关闭后不再卡死
2026-10-19 23:45:17 -07:00
esengine 0c3d51c7fc release note: shorten to the column limit 2026-10-19 23:35:28 -07:00
esengine 75825dbeb4 release note for #13987 2026-10-19 23:33:52 -07:00
zumac e2163d96e6 fix(jobs): a job started while the manager closes no longer races its wait
StartForSession called m.wg.Add(1) without a lock while Close could already
be inside m.wg.Wait() after cancelling the root: Add racing Wait at a zero
count is the misuse sync.WaitGroup documents. Under
`go test -race -run TestStartRacingClose -count=40 -cpu 1,2,8` on studio it
reports a data race and panics with "WaitGroup is reused before previous Wait
has returned".

The launch moves into launchJob, which decides under m.mu, the lock both
Close paths now cancel under, whether the job joins the wait group or is
refused. A refused job never runs: it ends Interrupted, its metadata is
written, and it is recorded and published like any other completion, so a
task recorder that saw it start sees it end. The goroutine body moves
unchanged.

Split out of #13947 at the reviewer's request so the jobs manager is
reviewed on its own.
2026-10-19 23:32:39 -07:00
esengine a3d2f761ce fix(readiness): an abridged debt keeps the progress signature of its full form; refresh trace readings 2026-10-19 23:32:10 -07:00
esengine a6312d252e remote: build the long test path in one expression 2026-10-19 23:28:30 -07:00
esengine 3c845b3e51 remote: split the shared scenario and shorten Volume's doc to meet the lint budgets 2026-10-19 23:25:51 -07:00
esengine 895231eab7 serve: the missing-page hint names the macOS bundle path 2026-10-19 23:25:51 -07:00
esengine 127e1fcce5 remote picker: fixture and interaction test for a folder that cannot be listed 2026-10-19 23:25:51 -07:00
esengine a5cc533cde remote picker: a folder the machine will not list can still be chosen
Dirs resolves the path on the far machine and lists it; when the listing
fails but a stat says the path is a directory, the answer is the directory with
Unlisted set instead of an error. The picker then names why the rows are
missing and 'use this folder' works on the confirmed path. A path that is not
there, or not a directory, stays an error. Listing a directory this account
cannot read is fs.ErrPermission on the script layer, as it is on SFTP.
2026-10-19 23:25:51 -07:00
esengine 2f4dd93504 remote test: say how the machine's files are reached
Probe took SFTP before any other step, so a server that refuses the subsystem
ended the test right after 'connection OK'. With the script layer behind
Client.Files it now completes, and the report carries sftp or exec.
2026-10-19 23:25:51 -07:00
esengine 4139ddb27f remote: fall back to scripts when the server refuses the sftp subsystem
pkg/sftp reports the refusal as an unnamed error string, so the subsystem is
now requested in sftpfs.New and a server reply of 'failure' becomes
ErrSubsystemRefused. Client.Volume prefers SFTP and, only on that sentinel,
serves the connection from execfs over the exec channel and remembers the
answer until the connection is replaced. Any other failure (dead link, refused
channel) is returned unchanged. The exec runner quotes the script and every
argument whole into one /bin/sh command line.
2026-10-19 23:25:51 -07:00
esengine 5bbad0c76b remote: move the exec file layer out of wslconn into execfs
The WSL file layer was a fixed set of /bin/sh scripts taking every path as a
positional argument. It is now a package of its own that takes a runner, so
another transport can reuse it. Scripts name a missing program by exit status,
refuse a file over the transfer limit by type, and check the byte count before
publishing a write. List, Download and UploadAtomic are added, and the shared
scenario now covers names with quotes, spaces, a leading dash, newlines, shell
metacharacters and a path over 3000 bytes.
2026-10-19 23:25:51 -07:00
imcreazy dbad5a9b3a fix(provider): share one tolerant code reader across the three models
`flexString` existed byte for byte in the openai, gemini and responses packages.
One type written three times diverges at the next edge case, so it moves to
`internal/base/flexstr`, which is a leaf (it imports nothing under reasonix) and
is declared in `tools/repolint/layers.go` so the layering gate allows every model
package to use it.

No wire behaviour changes: a string code, a number, an absent field and null read
exactly as before.
2026-10-20 14:23:27 +08:00
SivanCola f54af950c9 revert(studio): move CUA readiness fix to a dedicated PR
Problem: The CUA readiness change expands #13720 beyond its reviewed
launcher-ownership scope.

Root cause: The native CUA startup race was repaired alongside the
launcher conflict resolution, despite having a separate runtime owner.

Fix: Revert cad3c58f8a on this branch.
The CUA fix and regressions are preserved on fix/cua-socket-readiness,
based directly on studio. Keep the original launcher fix and conflict
resolution unchanged.

Verification: The resulting tree exactly matches e67fe2ce96. Electron
tests pass (189 passed, one existing platform skip), as do go vet,
make lint, make check, and the builtin/boot package tests.
2026-10-20 14:18:57 +08:00
esengine 1b4e253791 feat(studio): mark a session read from its menu without opening it
Refs #13962
2026-10-19 23:18:41 -07:00
SivanCola 7588b5ebee docs(studio): add CUA startup release note
Document the user-visible startup and restart fix from #13985 in the unreleased Studio notes, in Chinese and English. Verified with make check and the Studio release-notes renderer tests.
2026-10-20 14:18:24 +08:00
SivanCola 164d78aa27 fix(studio): wait for embedded CUA socket permissions
Problem: Embedded CUA startup can fail while a valid driver is still
initializing its socket permissions.

Root cause: The driver binds its socket before chmod sets 0600, while the
host treats the intermediate permissions as a terminal startup error.

Fix: Wait within the existing readiness deadline, preserving socket-type
and identity checks and forbidding metadata requests before private
permissions are observed. Cover initial startup and abort replacement
through the shared launch loop.

Verification: Deterministic permission-ordering and deadline regressions,
file/symlink rejection, 188 passing Electron tests with one platform skip,
native macOS CUA acceptance, go vet, make lint, make check, and builtin/boot
package tests. Split from #13720 at the maintainer's request.
2026-10-20 14:17:44 +08:00
imcreazy 4c31478ae5 android: bind the bridge to a confirmed origin and stop shipping a mailbox
- The native bridge answers only the kernel's page and the one computer the
  person scanned a code for; `openInApp` loads an origin once, and only that one.
- Non-web URLs are routed only for mailto: and tel: — an intent:// URL can start
  any exported activity.
- The `reasonix://` filter that nothing handled is gone.
- A release with no key is unsigned instead of signed with the debug key, and the
  debug build no longer wears the release key.
- The Gradle wrapper is pinned by checksum and the mirrors are out of the
  repository.
- The scripts find the kernel and the page at the repository root, speak English,
  and copy LICENSE from it instead of committing a second copy.
- The README is what a builder needs, and states what the permissions cost.
2026-10-20 14:17:37 +08:00
YHH 7804257a1f Merge pull request #13979 from lancy69/fix/13352-startup-notice
fix(studio): describe the startup splash as waiting for the local service
2026-10-19 23:15:54 -07:00
YHH 263bec799e Merge branch 'studio' into fix/session-scratch-literal-path 2026-10-19 23:10:34 -07:00
esengine 22632b359f fix(readiness): Delivery owes no generic check for prose outside any repository, and a verification debt is explained once 2026-10-19 23:05:04 -07:00
esengine da57c88a62 docs(release-notes): carry the splash note as an unreleased fragment
The 2.46.0 note is published and is not edited after a release; the same two lines move to a fragment for the next version.
2026-10-19 22:58:03 -07:00
SivanCola cad3c58f8a fix(studio): wait for embedded CUA socket permissions
Problem: Native macOS CUA acceptance can reject a valid driver startup with
"Invalid private CUA socket".

Root cause: The pinned driver binds its socket before chmod sets 0600.
The host treats the intermediate mode as a terminal error. The previous
fixture creates its socket with private permissions immediately, hiding
the production ordering.

Fix: Treat non-private socket permissions as pending readiness within the
existing deadline. Keep non-socket rejection and identity validation, and
never request metadata until private permissions have been observed.
The shared launch loop also covers abort-triggered replacement.

Verification: Deterministic regressions force observation before chmod on
initial launch and restart, and cover deadline expiry, child cleanup, files,
and symlinks. Both permission regressions fail before the fix. Electron
tests pass (193 passed, 1 existing skip), as do native macOS CUA acceptance,
go vet, make lint, make check, and the builtin/boot package tests.
2026-10-20 13:48:31 +08:00
Lancy 793bb263a3 fix(studio): describe the startup splash as waiting for the local service 2026-10-20 13:35:21 +08:00
imcreazy 5a292a1392 android: bind the bridge to a confirmed origin and stop shipping a mailbox
- The native bridge answers only the kernel's page and the one computer the
  person scanned a code for; `openInApp` loads an origin once, and only that one.
- Non-web URLs are routed only for mailto: and tel: — an intent:// URL can start
  any exported activity.
- The `reasonix://` filter that nothing handled is gone.
- A release with no key is unsigned instead of signed with the debug key, and the
  debug build no longer wears the release key.
- The Gradle wrapper is pinned by checksum and the mirrors are out of the
  repository.
- The scripts find the kernel and the page at the repository root, speak English,
  and copy LICENSE from it instead of committing a second copy.
- The README is what a builder needs, and states what the permissions cost.
2026-10-20 13:31:27 +08:00
imcreazy 35713ed46e android: bind the bridge to a confirmed origin and stop shipping a mailbox
- The native bridge answers only the kernel's page and the one computer the
  person scanned a code for; `openInApp` loads an origin once, and only that one.
- Non-web URLs are routed only for mailto: and tel: — an intent:// URL can start
  any exported activity.
- The `reasonix://` filter that nothing handled is gone.
- A release with no key is unsigned instead of signed with the debug key, and the
  debug build no longer wears the release key.
- The Gradle wrapper is pinned by checksum and the mirrors are out of the
  repository.
- The scripts find the kernel and the page at the repository root, speak English,
  and copy LICENSE from it instead of committing a second copy.
- The README is what a builder needs, and states what the permissions cost.
2026-10-20 13:27:31 +08:00
YHH b326c1e0f5 Merge pull request #13973 from esengine/fix/lease-extra-root-claim
fix(lease): a path outside the workspace is its own claim, not the whole workspace
2026-10-19 22:22:56 -07:00
imcreazy 57bf125488 android: the shell alone, cut to studio
The kernel-side pieces this branch came with are their own PRs now, so this keeps
only `android/`. Rebasing onto studio also drops the mailbox delivery code and the
mailbox credentials that the review asked to remove.
2026-10-20 13:22:28 +08:00
YHH ac1a737eef Merge pull request #13792 from esengine/feat/13111-origin-ask
feat(permission): ask a person when a call repeats text an external result gave the task
2026-10-19 22:17:29 -07:00
SivanCola e67fe2ce96 Merge studio into fix/electron-launcher-ownership
Problem: PR #13720 conflicts with the current studio branch in the Electron
unit test suite.

Root cause: Both branches inserted independent regression tests at the same
location after the host startup failure tests.

Fix: Preserve the launcher ownership, relaunch handover, and closed-console
tests together with studio's credential-cookie restoration test. Retain the
automatically merged shell changes and updated Electron dependencies.

Verification: Electron unit and Linux packaging tests passed (189 passed,
1 existing skip). Formatting, go vet ./..., make lint, make check, and the
builtin and boot package tests passed.
2026-10-20 13:08:35 +08:00
esengine a9b5a74306 test(boot): split the unresolvable and the outside-workspace declared-path cases 2026-10-19 21:59:27 -07:00
esengine e64f7f50cd test(boot): the origin-ask effect test does not depend on the host's bash sandbox 2026-10-19 21:55:42 -07:00
YHH 0c3539814d Merge pull request #13970 from esengine/a9/import-routing
fix(migration): file each 1.x Desktop conversation under its own project, never under the one picked
2026-10-19 21:51:34 -07:00
YHH 05acef3f03 Merge pull request #13974 from esengine/a9/windows-confine-tests
test(builtin): skip bash confinement tests when the spec cannot be confined on this host
2026-10-19 21:49:09 -07:00
YHH cd21b99e82 Merge pull request #13975 from esengine/a9/selected-session-row
fix(rail): make the current session's row distinct from hover and plain rows
2026-10-19 21:43:18 -07:00
esengine 21c7ff3691 fix(rail): make the current session's row distinct from hover and plain rows 2026-10-19 21:29:11 -07:00
esengine 8ad50df92b test(builtin): skip bash confinement tests when the spec cannot be confined, not when the platform lacks a backend 2026-10-19 21:27:40 -07:00
YHH 85cf456da1 Merge pull request #13968 from esengine/a5/agent-module-picker
feat(presets): describe each module in the add-module menu and loosen its spacing
2026-10-19 21:26:12 -07:00
YHH 9b50a7cf6b Merge pull request #13969 from esengine/a5/unread-plan
fix(studio): mark the session in front as seen whenever it is unread and the window has focus
2026-10-19 21:16:18 -07:00
YHH ff4066b56d Merge pull request #13951 from SivanCola/fix/compaction-fixtures-sized-from-first-request
test(boot): size the compaction fixtures from their own first request
2026-10-19 21:14:14 -07:00
esengine 958f8c7e97 fix(lease): a path outside the workspace is its own claim, not the whole workspace 2026-10-19 21:11:14 -07:00
esengine c7be40d404 docs(migration): say which conversations land in the chosen project 2026-10-19 21:07:00 -07:00
esengine 1e77d19966 fix(studio): mark the session in front as seen whenever it is unread and the window has focus 2026-10-19 20:57:46 -07:00
esengine 5e44450ac4 fix(migration): file each 1.x Desktop conversation under its own project, never under the one picked
Fixes #13920
2026-10-19 20:57:39 -07:00
esengine cd74ec3a60 docs(release): the feedback's issue is #13964 2026-10-19 20:49:17 -07:00
YHH 8bf2ca8bb1 Merge pull request #13769 from esengine/feat/win-sandbox-grants
feat(sandbox): a Windows command sandbox on a write-restricted token, off by default
2026-10-19 20:44:02 -07:00
esengine e84a047949 fix(census): read the registry from every actions*.ts, not one file name 2026-10-19 20:43:44 -07:00
esengine f1f5031f79 fix(approval): YOLO and a session grant do not answer a fresh-human call 2026-10-19 20:38:53 -07:00
esengine 7f4e86d3c0 docs(release): name the issue in the preset picker note 2026-10-19 20:38:28 -07:00
esengine c968232e5e feat(presets): describe each module in the add-module menu and loosen its spacing 2026-10-19 20:38:28 -07:00
zumac 14cd0438f9 test(boot): size the compaction fixtures from their own first request
TestEffectTruncatedSummaryOnPressureKeepsCodeAndRetries and
TestEffectModelSwitchKeepsTheCompactionProjection pin what happens turn
by turn around the compaction trigger, but the turn the trigger falls on
depended on how large the first request happened to be. Measured on
studio: about 550 more characters of prompt or tool schema flip the first
to "7 failed summaries" and the second to "switch spent 1 summarization
call", with no change to compaction. So any prompt or tool-description
edit, or a host whose tool surface is a few hundred characters larger,
failed them, and a change could pass on one runner and fail on another.

Each fixture now builds once, measures its first request the way the
prompt estimator does, and pads its system prompt up to a fixed size, so
the trigger and pressure turns are the fixture's. The sizes sit in the
middle of ranges where the outcome does not move: 30000-40000 characters
for the truncated-summary fixture (6 attempts throughout), 40500-48000 for
the model-switch fixture (3 folds before the switch, none after). With
0-5000 characters of extra first-request weight injected ahead of the
measurement, both now give the same result; before, both flipped near
550. The bounds are unchanged.
2026-10-19 20:29:23 -07:00
YHH 4b4292a4a4 Merge pull request #13958 from esengine/fb/remote-order
feat(remote): put a remote machine before another in the sidebar's book order
2026-10-19 20:25:21 -07:00
YHH e072a874d8 Merge pull request #13963 from esengine/a5/sessrow-overlap
fix(studio): a running session row marks its state once
2026-10-19 20:23:49 -07:00
YHH 561f1c2726 Merge pull request #13961 from esengine/fix/composition-test-wait-idle
test(control): wait for the first turn to end before clearing the session
2026-10-19 20:22:42 -07:00
esengine ed1dfebb9e Merge studio into feat/13111-origin-ask 2026-10-19 20:17:58 -07:00
esengine ba48dfe0b9 Merge studio into a5/schedule-api 2026-10-19 20:17:54 -07:00
esengine ff37e78d7d fix(studio): a running session row marks its state once 2026-10-19 20:07:55 -07:00
YHH 22f65521a8 Merge pull request #13956 from esengine/fb/bang-path
fix(shell): run user ! commands with the login shell PATH the bash tool uses
2026-10-19 20:05:31 -07:00
esengine f95955c781 test(control): wait for the first turn to end before clearing the session
waitForDone returns on the turn-done event, which the controller emits before it drops its running flag, so ClearSession could answer "cannot clear while a turn is running". Under 8 parallel race-enabled processes at one CPU the unchanged test failed 3 times in 1200 runs; with waitIdle it failed 0 times in 1200.
2026-10-19 20:01:36 -07:00
esengine abff4c7768 fix(sandbox): a Windows default is not confined, and a caller cannot enforce there on its own account
Windows has a backend now, so Available() is true, but it enforces only after the
user turns on windows_integrity. Tests and the e2e bench that ask whether a default
configuration is confined use ConfinesByDefault (false on Windows), and a caller's
override to enforce (an observed run asks for one) is honoured on Windows only
when the user opted in, since enforcing edits permissions on their folders.
2026-10-19 19:54:39 -07:00
YHH 10d3a411ed Merge pull request #13954 from esengine/feat/cli-delete-session
feat(tui): delete a saved session from the resume picker with a confirming second Ctrl+D
2026-10-19 19:54:22 -07:00
esengine c6e5891e26 fix(state): the Windows sandbox's record is an owned entry of the state root
A relocation moves only the entries StateRootEntries names. The grant record sat in
sandbox/ outside it, so a move would have left the record behind while the
permission entries it lists stayed on the folders, with nothing to take them back.
2026-10-19 19:54:15 -07:00
esengine 2f24a1b693 docs(sandbox): the launcher's refusal is the token's 2026-10-19 19:54:07 -07:00
esengine 59fc0b6520 fix(sandbox): strip the token's privileges, refuse a launcher started from a restricted process, and state what Windows bounds
The restricted token now drops every privilege but the traverse check, so backup
semantics cannot write outside the roots even from an elevated session. A SID is
synthetic only if no account resolves it. The launcher refuses to run under a
restricted token (a command's own launcher inherits it), grants only session temp
folders, hands the child just the standard handles, runs it in a job with UI
limits, and the default DACL and the grant mask are narrower. Links are not
followed at grant time and a link root is refused. REASONIX.md, the guide, the
Settings text and the release note list what is and is not bounded.
2026-10-19 19:54:07 -07:00
esengine 17fa87d048 test(boot): the sandbox record folder is a forbidden root 2026-10-19 19:54:07 -07:00
esengine 9bb02269b2 style(sandbox): lint findings 2026-10-19 19:54:07 -07:00
esengine 3670d56d9e fix(config): a saved user file keeps windows_integrity 2026-10-19 19:54:07 -07:00
esengine 6b723daf0d fix(sandbox): refuse to enforce when the grant record lies inside a writable root 2026-10-19 19:54:07 -07:00
esengine 28650f0615 docs(release): the Windows sandbox 2026-10-19 19:54:07 -07:00
esengine 4e59ccc796 feat(desktop): say what the Windows sandbox bounds, in Settings > Sandbox 2026-10-19 19:54:07 -07:00
esengine 9a9fa80242 feat(sandbox): reasonix sandbox list/revoke, env without process reads, Windows docs 2026-10-19 19:54:07 -07:00
esengine 42c6808c94 fix(sandbox): drop the Low-integrity label design; deletes and renames outside the roots are a documented gap 2026-10-19 19:54:07 -07:00
esengine f040da0929 feat(sandbox): label granted roots Medium without write-up so created files stay the user's level 2026-10-19 19:54:07 -07:00
esengine 0abdc91b9e fix(sandbox): resolve write roots on Windows volumes and junctions 2026-10-19 19:54:07 -07:00
esengine fab0f72a2f feat(sandbox): enforce on Windows when the user turns it on, prepared at session build 2026-10-19 19:54:07 -07:00
esengine 8c95b2cb64 feat(sandbox): lower the restricted token to Low integrity and label granted roots, so deletes are bounded too 2026-10-19 19:54:07 -07:00
esengine 7db2857171 wip 2026-10-19 19:54:07 -07:00
esengine f8ec0a7795 feat(sandbox): run a Windows command under the write-restricted token 2026-10-19 19:54:06 -07:00
esengine 0459e81d9c feat(sandbox): a grant book for the Windows write-restricted token's synthetic SID 2026-10-19 19:54:06 -07:00
esengine eadc7b1883 docs(release-notes): remote machine order 2026-10-19 19:51:41 -07:00
YHH 5b2e61d153 Merge pull request #13957 from esengine/a5/rail-two-tabs
perf(studio): stop the rail fold restyling the whole transcript on every frame
2026-10-19 19:50:14 -07:00
YHH 116162df9c Merge pull request #13955 from esengine/fix/slow-session-list
fix(sessionstore): remember what replaying a 1.x conversation answered
2026-10-19 19:44:53 -07:00
esengine 835738dfec test(config): build the order string with a builder 2026-10-19 19:43:25 -07:00
esengine 2ea83e3c0e feat(remote): put a remote machine before another in the sidebar's book order 2026-10-19 19:40:22 -07:00
esengine aafdf0bc0d docs(layout): keep the rail note within the prose limit 2026-10-19 19:38:20 -07:00
esengine 9fb6dbd5ae docs(release): note the rail fold improvement 2026-10-19 19:37:38 -07:00
esengine 23be188e9e perf(studio): stop the rail fold restyling the whole transcript on every frame 2026-10-19 19:37:26 -07:00
esengine 1786c1e3cc docs(release-notes): bang login path 2026-10-19 19:31:31 -07:00
esengine 6906e31e47 refactor(shellrun): probe the login shell without a test-only seam 2026-10-19 19:29:13 -07:00
esengine 58ec7d40f3 fix(shell): run user ! commands with the login shell PATH the bash tool uses 2026-10-19 19:28:01 -07:00
esengine 5ff43981d4 test(tui): range over SplitSeq 2026-10-19 19:27:32 -07:00
esengine 941c74a8a4 fix(sessionstore): remember what replaying a 1.x conversation answered 2026-10-19 19:27:27 -07:00
esengine 724fdfd87e docs(release): note the picker's delete 2026-10-19 19:21:41 -07:00
esengine 92bfe8fc4e feat(tui): delete a saved session from the resume picker with a confirming second Ctrl+D 2026-10-19 19:21:40 -07:00
zumac c8c8d8e341 fix(control): tell the model its scratch directory is $TMPDIR and needs no cleanup
The host already confines bash's $TMPDIR to a per-session directory, the
file tools resolve a leading $TMPDIR to it, and the host deletes it when
the session ends. The model was never told the last two, so it verified
with `scratch=$(mktemp -d); ...; rm -rf "$scratch"` or wrote tests to /tmp,
and the host refused them (shell.delete_nonliteral, shell.delete_sequence,
write scope): 26 refused calls over 36 deepseek-flash benchmark runs, one
model round each.

A <session-scratch> turn block now says: put throwaway files at
`$TMPDIR/<name>`, spelled that way in bash and in the file tools, and do
not delete them. The host path is deliberately not projected: under bwrap
bash sees the directory at /tmp, and on Windows a backslashed literal does
not survive a bash command line, so $TMPDIR is the one spelling every tool
resolves. The block follows the dateAnchor/projectionDebt pattern: it rides
the turn and never the prefix, settles only when a turn reaches the runner,
is re-owed after a fold, retracts itself once if the directory can no
longer be created, is projected only for a manager the assembly supplied,
and is inherited across a rebuild. Its bytes carry no per-session value,
so two sessions over the same state project identically.

No shell-safety rule changes; the effect test asserts that a recursive
delete through $TMPDIR or a mktemp variable is still refused.
2026-10-19 18:03:44 -07:00
zumacandClaude Opus 5.5 d2f919736b fix(agent): the supersede pass sees standing state the verbatim tail carries
supersedeStandingState judged only the fold region's kept turns, so a
standing block the region held once survived beside newer copies in the
recent tail. A fold whose region kept a single user turn dropped nothing:
TestEffectFoldDropsSupersededStandingState went from 3 <workspace> copies
to 4 across such a fold once a turn block shifted the fold one turn
earlier. partitionFoldWith now takes the tail, and the tail's user-turn
blocks are marked seen before the kept slice is walked newest-first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 17:41:58 -07:00
esengine 16ba8d38bf docs(release): studio 2.46.0 notes 2026-10-19 16:46:45 -07:00
YHH cda566e404 Merge pull request #13949 from SivanCola/fix/failed-fold-keeps-standing-state
fix(control): a fold that folded nothing does not re-owe standing state
2026-10-19 12:42:06 -07:00
zumac a00519304e fix(control): a fold that folded nothing does not re-owe standing state
Every CompactionDone forgot what the model had been told -- the date,
the skills listing, the project instructions and rules -- so the next
turn sent them again. A truncated or refused digest also emits
CompactionDone, with Messages == 0: nothing was summarised and the turn
that carried those blocks is still verbatim, so re-sending them only
grew the prompt the next attempt had to fold, by one skills listing per
failed fold.

The producer already says which case it is: a successful fold reports
Messages = len(fold) and emitCompactionAborted reports 0. The sink now
reads that instead of treating every completion as a fold.

TestEffectTruncatedSummaryOnPressureKeepsCodeAndRetries goes from 5 to 6
failed summaries over its 10 turns: the prompt no longer grows by a
re-sent listing after each failure, so the pressure fold lands a turn
later. That is inside the test's bound of 6 and is the growth the retry
gate is meant to read.
2026-10-19 12:25:11 -07:00
YHH 0b8de9e636 Merge pull request #13945 from SivanCola/fix/run-script-verbatim-read
fix(script): a script's read_file returns the file's text, not its display
2026-10-19 11:15:03 -07:00
YHH 564716a091 Merge pull request #13944 from SivanCola/claude/e2ebench-nested-rounds-trials
fix(e2ebench): count script calls inside their round; keep one trajectory per trial
2026-10-19 11:14:58 -07:00
YHH 6c631b0870 Merge pull request #13613 from SivanCola/codex/session-agent-compositions
feat(studio): add session Agent compositions / 新增会话级 Agent 预设
2026-10-19 10:58:52 -07:00
zumacandClaude Opus 5.5 c3daadf167 fix(script): a script's read_file returns the file's text, not its display
Inside run_script, call("read_file", ...) returned the model-facing form:
every line prefixed "   N→", CRLF folded to LF, the default 2000-line page,
and — past 32 KiB — a window with a "read on from here" marker. A script
that read a file, edited the text and wrote it back put all of that into
the file. With deepseek-flash on migrate-signature-3packages and
agent.code_mode on, one bulk rewrite corrupted 36 files and cost ~15 rounds
to repair.

Two layers each rendered for the model a result whose reader is a program:

- read_file: a call another tool made (tool.IsNested) gets the decoded text
  verbatim — line endings and final newline kept, no default limit, offset
  and limit selecting whole lines. The model's own read is unchanged.
- nestedInvoker: the calling tool reads what the tool returned, not the
  bound fitted to the model's context. The nested card still shows the
  bounded form; toolOutcome.keepFull records both originals in one place.

write_file already preserves the existing file's encoding, so a BOM file
round-trips once the read stops adding to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 10:57:36 -07:00
zumacandClaude Opus 5.5 938b20e7a3 fix(e2ebench): count script calls inside their round; keep one trajectory per trial
model_rounds and round_outcomes treated every call run_script made as a
top-level dispatch, so each inner result reopened a model gap and each inner
dispatch closed one. One recorded code_mode run with 24 executor requests
reported a median of 87.5 rounds. Nested calls carry no ParentID — only an id
derived from the caller's, inside its dispatch→result window — so the digest
had nothing structural to tell them apart.

The nested id is now declared in internal/contract/tool (NestedCallID /
IsNestedCallOf) and the agent's nestedInvoker builds ids through it, so the
producer and the reader share one definition. e2ebench's callNesting tracks
open top-level calls and treats subagent calls (ParentID) and invoker calls
(derived id) alike: neither cuts a round, joins a batch or adds tool time.
roundEnds and splitAtCorrect use the same tracker. NestedCalls reports how
many there were, and ModelRounds' doc now says what it counts.

With -trials N every trial wrote <task>.trajectory.jsonl, so only the last
survived and every trial's digest read it. Trials now write
<task>.trial-N.trajectory.jsonl (legs: <task>.trial-N.segM...); a single
trial keeps the old name. sft mode resolves the task id through
trajectoryTaskID, which strips trial and leg marks; traj, serve and barrier
modes glob *.trajectory.jsonl and label each file by its stem, so trials
read as distinct entries.

Cause: the digest had no identity for tool-made calls; the trajectory path
had no trial in it.
Blast radius: cmd/e2ebench digest and file naming; nestedInvoker's id string
is unchanged (same "<parent>.<n>" format, now built by the contract helper).
Neighbouring behaviours tested: subagent ParentID exclusion, segment paths,
answer-isolation audit across legs, existing round decomposition tests.
Why this layer: the id convention is owned where nested calls are created;
the digest reads it rather than restating the format.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 10:56:50 -07:00
SivanCola a3dab1bff1 fix(compositions): check retained hook credentials at argument boundaries
Problem: Windows CI rejects the retained-hook source-deletion fixture as a
credential-bearing launch before reaching its restoration assertions.

Root cause: credential projection scans the complete quoted shell string as a
generic value, splitting a Windows drive colon inside a quoted file argument.
That creates an unmatched quote and falsely marks the command as sensitive.

Fix: reduce shell launches to static arguments before credential inspection,
preserve exec and legacy argv launches, and continue refusing literal token and
header arguments or shell forms that cannot be reduced safely. Keep source
commands and retention/restoration semantics unchanged.

Verification: regression reproduces both quoted Windows path failures before
the fix, while exec/legacy argv and token/header refusal cases stay covered.
Focused retention/security regressions and their race run pass; full builtin
and boot tests, gofmt, vet, lint and changed-file checks pass locally.
2026-10-20 01:37:50 +08:00
SivanCola b5a8cfd299 fix(compositions): enforce filesystem boundaries and portable retention tests
Problem: CodeQL reported new path and allocation alerts, and Windows repeatedly
failed the retained-hook restoration test.

Root cause: Checked selectors were still passed onward as raw request values;
some generated paths and capacity hints did not expose their bounds at the IO
owner. The HTTP DTO also carried an internal inherited binding. The hook fixture
fed Windows backslashes through Bash resource parsing.

Fix: Resolve import/export selections to registered values, forward checked
relative names for resume/migration, and guard the generated session slug before
joining it to state storage. Use rooted declaration/resource reads and copies,
bound resource reads, and avoid unchecked capacity addition. Pass inherited
bindings outside the wire DTO. Normalize and quote the hook fixture path, including
spaces, while retaining source-deletion and exact-binding assertions.

Verification: Hosted alert locations reproduced locally with the path-injection
and allocation queries; before/after query results and the base scan are retained
separately. Boundary regressions fail on the original head and pass with the fix.
Owning HTTP, store, config, hook, sidecar, plugin, specification and encoding suites;
full builtin/boot tests; gofmt, vet, lint and changed-file checks. Hosted Windows
and CodeQL results remain required for exact-head acceptance.
2026-10-20 01:11:52 +08:00
YHH 42636ddcb4 Merge pull request #13937 from esengine/c5/hidden-panes-skip-layout
perf(studio): hidden panes skip layout while the rail animates
2026-10-19 09:39:32 -07:00
YHH da14d60b31 Merge pull request #13939 from SivanCola/fix/studio-changelog-publication
Publish the Studio website changelog after releases / 发布后自动同步并验证官网 Studio 更新日志
2026-10-19 09:37:51 -07:00
SivanCola 8df4882d16 fix(studio): restore opt-in visibility for the mode picker
Problem: The composer exposed the new mode picker to users who had not opted in,
contrary to the maintainer's product-visibility decision.

Root cause: Removing the persistent visibility preference also removed its
settings/search action and the RunLine mounting gate.

Fix: Restore the original on/off storage key and default-off behavior. Add the
switch and concise explanation to Persona management, restore settings search
and the action, and gate both mounting and catalog reads. Preserve card defaults,
catalog/editor design, persona copy and current conversation bindings. Failed
storage writes retain the previous choice and report an error.

Verification: Missing/disabled/unknown and toggle regressions fail before the
fix. Focused Chinese/English navigation, persistence, storage-failure and binding
tests pass. Full frontend: 729 files / 5,857 tests; build/typecheck, UI census and
ten identical fixture reports; gofmt, vet, lint, make check, builtin and boot
suites. Browser visual acceptance remains unavailable under the URL policy.
2026-10-20 00:35:37 +08:00
SivanCola 5211538354 feat(studio): give built-in personas warmer names and descriptions
Problem: Built-in preset names and descriptions read like configuration modes
rather than collaborators, and their relationship to Harness prompts was unclear.

Root cause: Technical copy was reused across the catalog, composer and persona
editor without explaining the existing short-identity prompt adaptation.

Fix: Rename the built-ins to Everyday partner and Lightweight partner, update
their bilingual descriptions and guidance, and derive the unbound picker label
from its canonical catalog definition. Keep IDs, persona bytes and selections
unchanged, retain historical translations, refresh revision metadata and document
the pinned official Harness reference and native prompt boundary.

Verification: Frontend 729 files / 5,852 tests and production build/typecheck;
contract, store and HTTP suites; gofmt, vet, lint, changed-file check, builtin and
boot suites. Fix a discovered worktree test race by waiting for the asynchronous
checkout state instead of the creation title shared by initial and checkout
phases. Browser visual acceptance remains unavailable under the URL policy.
2026-10-20 00:25:41 +08:00
SivanCola 17327b4875 feat(studio): add preset cards and explicit new-task defaults
Problem: Built-in presets use flat rows, choosing a future task mode requires
repetition, and persona fields are hidden behind an advanced disclosure.

Root cause: Management navigation and new-task launch intent have no shared
explicit preference; the editor groups basic persona fields with identifiers.

Fix: Match the selected two-column card hierarchy with badges, IDs, descriptions
and separate details, usage and copy actions. Persist an explicit built-in card
choice in a new machine-local key and inject it through the local/remote open
owners only for new tasks. Explicit request choices override it; restoring
history keeps its binding. Existing server preferences alone remain inert.
Expose both persona textareas, runtime context, scope and ID directly, rename
settings navigation to Persona, and keep literal persona fields authoritative.

Verification: Full frontend suite plus real MockHub launch effects, blank-pane
reuse, local/remote history, explicit overrides, storage compatibility/refusal,
cross-window refresh and independent footer actions. Production build/typecheck,
UI census/fixtures, format, vet, lint, changed-file and builtin/boot checks.
Browser-rendered visual comparison remains blocked by the URL policy, as
recorded in design-qa.md.
2026-10-20 00:15:23 +08:00
SivanCola 9023835220 fix(studio): clarify additional persona instructions
Problem: "After the first-party guidance" exposes prompt-construction jargon
without explaining what users should enter.

Root cause: Both persona editors use a position-based label; the composition
editor has no suffix helper text.

Fix: Label both suffix inputs "Additional instructions" and explain that the
text follows the built-in system instructions and may be left blank. Add the
Chinese/English copy and associate the helper with each textarea. Keep the
existing literal suffix binding and update affected interaction expectations.

Verification: 24 focused persona, preset and localization tests; production
build/typecheck; formatting, vet, lint, changed-file checks and builtin/boot
suites.
2026-10-19 23:59:15 +08:00
SivanCola 5ea0ed7046 fix(studio): align the preset creation button with its heading
Problem: The catalog's create icon and label stack inside a cramped button.

Root cause: Studio icons render as blocks while the button lacks a horizontal
layout; the title and description also share a flex item beside the button.

Fix: Give the button an inline-flex row with no wrapping, a 36px minimum height
and 16px horizontal padding. Align title and action in one grid row and place
the description across the next row, retaining the primary theme colors.

Verification: 27 focused frontend tests, production build/typecheck, formatting,
vet, lint, changed-file checks, and builtin/boot suites. The local preview stays
running; browser observation remains blocked by its URL security policy.
2026-10-19 23:50:35 +08:00
SivanCola 805d9d189a feat(studio): separate preset catalog and editor views
Problem: Preset browsing, creation and the Standard persona were mixed in one
long Settings page, hiding the save flow and exposing configuration too early.

Root cause: Catalog selection and editable drafts shared one inline surface,
without an owner-level guard for Settings navigation.

Fix: Follow the selected full-content editor reference. Group the catalog by
scope; open a separate editor with common fields first, expandable capabilities
and advanced options, and a persistent Cancel/Save footer. Preserve literal
persona bytes, revision checks, import/export/delete and session bindings.
Guard dirty exits across return, Settings close, category and search paths.
Keep pending saves mounted and reject stale import results. Reusable switches
declare button type so toggles cannot accidentally submit the editor form.

Verification: Full frontend suite: 727 files, 5843 tests; focused editor,
navigation, pending/refused save, import ownership and binding regressions;
production build, typecheck, UI census and fixture verification; root format,
vet, lint, changed-file checks and builtin/boot suites. Browser visual acceptance
remains blocked by the local URL security gate and is recorded in design-qa.md.
2026-10-19 23:41:23 +08:00
esengine 23bf613596 docs(release-notes): shorten the pane note to the column limit 2026-10-19 08:27:35 -07:00
esengine 9fe36abc5b perf(studio): hidden panes skip layout while the rail animates
Every open tab keeps its pane mounted, and a hidden pane was only
visibility:hidden, so it still took part in layout. The rail width is animated
per frame, so each frame laid out every pane's transcript again. With three
panes the median frame at 4x CPU slowdown went from 94 ms to 18 ms.
2026-10-19 08:27:35 -07:00
SivanCola b9d4dbadcc fix(studio): show the Agent mode picker by default
Problem: the mode picker required a separate settings toggle before users
could discover or use it.
Root cause: both the picker and its run-status row depended on a persistent
visibility preference unrelated to the conversation's selected mode.
Fix: render the picker directly, remove the retired setting, preference state,
search entry and action, and update the bilingual documentation. Standard
remains the default; displaying the catalog never selects a composition.
Verification: failing-first tests for absent and disabled legacy preferences;
default Standard with a saved Minimal preference; picker, settings, remote-open
and settings-search interaction suites; frontend typecheck, full suite, census,
fixture verification and build; browser preview; go vet, make lint, make check,
builtin tools and boot assembly tests.
2026-10-19 23:07:18 +08:00
SivanCola 9a7460ac87 fix(release): publish and verify the Studio website changelog
Problem: Publishing Studio did not regenerate its website changelog.

Root cause: Product publication and the website deployment had no explicit handoff, and Pages only accepts runs from the website branch.

Fix: Dispatch the website workflow after stable publication with the required version and a unique request ID. Wait for that deployment and propagate failures. Register dispatch on the default branch without enabling deployment from it, and use the workflow commit when recovering old tags.

Verification: All 13 publication tests, shellcheck, signing workflow contracts, go vet, make lint, make check, and the required builtin/boot suites pass.
2026-10-19 23:07:01 +08:00
SivanCola 17ad4fd742 fix(studio): distinguish preset scope from its ID
Problem: the custom-preset form grouped storage scope and ID under one label,
leaving their purpose unclear.
Root cause: two independent controls shared the Preset ID field container
without visible labels or usage guidance for each control.
Fix: give each control its own label and short translated description, and
associate the descriptions with the controls for assistive technology.
Verification: AgentPresets interaction tests, frontend typecheck, production
build, census and fixture verification; browser layout and console checks;
go vet, make lint, make check, builtin tools and boot assembly tests.
2026-10-19 22:58:46 +08:00
SivanCola a655a9fbe3 Merge studio into session Agent compositions
Problem: PR #13613 conflicted with the current studio branch.
Root cause: both branches extended session status, ACP configuration, and
project dispatch while the shared stylesheet and session-new function reached
their existing size limits.
Fix: retain composition bindings alongside tariff status, apply the ACP
approval ceiling, and reuse pending dispatch panes without dropping inherited
composition bindings. Move the picker styles to their own sheet and compose
the ACP config projections without increasing the ratchet budgets.
Verification: frontend typecheck, 5,830 frontend tests, production build,
census invariants and fixtures; go vet, make lint, make check; ACP, HTTP serve,
composition contracts/storage, session storage/control, delegation, builtin
tools, and boot assembly tests.

Merged studio 3d9f9ff74c into PR head
ae6a9de150.
2026-10-19 22:47:32 +08:00
YHH 3d9f9ff74c Merge pull request #13933 from esengine/c5/keep-launch-credential
fix(desktop): restore the launch credential cookie when something removes it
2026-10-19 07:02:13 -07:00
esengine 1fb85da958 docs(release-notes): shorten the credential note to the column limit 2026-10-19 06:46:00 -07:00
esengine c9d33ca8cb fix(desktop): restore the launch credential cookie when something removes it
The kernel refuses every request without it, and the cookie belongs to the
host rather than the port, so a removal left the window refused until a
relaunch. The shell holds the token and puts it back, logging the cause.
2026-10-19 06:46:00 -07:00
YHH 3e8c1dad79 Merge pull request #13930 from esengine/c5/mac-tray-template
fix(desktop): draw a monochrome template glyph in the macOS menu bar
2026-10-19 06:44:48 -07:00
YHH 16b91bc1ad Merge pull request #13925 from chuiwenwei/fix/e2ebench-compare-wall-population
fix(e2ebench): count a killed run's wall in the same population as the report
2026-10-19 04:26:18 -07:00
Will 7905358552 test(e2ebench): keep the compare goldens LF on every checkout
.gitattributes pins *.go to eol=lf but not *.golden, so a Windows checkout turns
the byte-compared goldens into CRLF and TestCompareOutputUnchangedWithoutUnaccountedRuns
fails there. Pin *.golden to eol=lf as well.
2026-10-19 18:51:01 +08:00
esengine cffde3f3e9 fix(desktop): draw a monochrome template glyph in the macOS menu bar
The macOS tray loaded the full-colour tray.png, so the menu bar showed the
whole app badge. A Template-suffixed black-on-transparent pair lets macOS tint
it for light and dark bars; Windows and Linux keep their files.
2026-10-19 03:05:23 -07:00
YHH 036b25d499 Merge pull request #13923 from chuiwenwei/fix/history-rescan-retries-corrupt-sources
fix(history): retry a corrupt source once its transcript is readable
2026-10-19 02:10:17 -07:00
YHH ce06cec619 Merge branch 'studio' into fix/history-rescan-retries-corrupt-sources 2026-10-19 01:53:12 -07:00
esengine 81521fa074 test(sessionstore): pass the fourth ImportV4From argument in the prefix test 2026-10-19 01:52:59 -07:00
Will d8e55c6fd4 fix(e2ebench): count every run's wall in compare's rate figures
aggregateArm dropped an unaccounted run's wall before adding it to
s.WallMs, so compare's Solved/hour and global wall/task divided by a
smaller wall than the suite report uses for the same -json artifact:
report.go adds every run's wall, and a killed run's wall is measured
unconditionally in main.go (only its tokens never landed). The
per-class accumulator ran before the skip, so one section printed two
populations, and the sign of "which arm is cheaper" could flip.

WallMs is now the all-run wall and AccountedWallMs the accounted
subset: Solved/hour and wall/task divide the all-run total, while
"Wall seconds / solved" keeps dividing accounted wall by accounted
solves because that denominator is solved runs. No new persisted
state, and neither how Unaccounted is decided (segmentrun.go) nor the
suite report changes.

This is the residual of #13446 applied to a field that is NOT
unmeasured. That PR decided which runs are unaccounted and kept their
spend out of the token/cost ratios; it never touched wall, and this
change does not revisit that decision. It fixes the wall population
those adjacent rate lines divide, measured for every run including the
ones #13446 excluded elsewhere. Not a re-report of #13446.

Verified: both probes are red on 53a8db913 (compare Solved/hour 120.0
vs the report's 3.9; global wall/task -30.0s vs the class row's
+870.0s) and green after; a no-unaccounted fixture renders compare
byte-identically (goldens); "Wall seconds / solved" divides accounted
wall by accounted solves under either rate line reverted to the
accounted total, while the matching probe turns red; the suite
report's Solved/hour convention is pinned unchanged. gofmt,
GOOS=windows go vet ./cmd/e2ebench/, go test ./cmd/e2ebench (only the
pre-existing environment TestStateHomeIsOutsideWorkdirParent fails,
identically with this change stashed), make check (repolint: clean),
and golangci-lint run ./cmd/e2ebench/... (0 issues) pass.
2026-10-19 16:52:54 +08:00
Will 88092a4c8b fix(history): retry a corrupt source on rescan, not only on save
Cause: a transcript that could not be read was stamped health=corrupt while its
root was left state=ready with the failing size/mtime signature. Both automatic
repair paths trusted that row: reconcileRoot returned before indexPath ran and a
restart reopened the same ready row. Equal size/mtime says nothing about
readability, so a restored mode or a remounted transcript was never revalidated;
status stayed Indexed 0 / Total 1 until a save or a manual `catalogs reindex
history`.

Fix: chose the scan's skip predicate — reconcileRoot only short-circuits when
state=ready, the signature matches, indexed==total, and no source of that root
is non-ok (health<>'ok' or missing_since<>0). That is the decision that
prevented indexPath from ever being consulted. The same rule is applied at
source granularity: sourceProjectionUnchanged now also requires the stored
health to be 'ok', because after the root is rescanned a legacy source with no
ledger digest (digest=="") would otherwise be certified healthy from its
fingerprints without being read.

Fast path kept: a root whose last scan was complete and healthy still returns
before any transcript is opened, so the five-minute tick remains a directory
walk plus stat, not a re-read of every transcript. A source that stays corrupt
is retried once per scan (one failed read per corrupt source per tick) and
scan_generation advances exactly once per scan, so a permanently unreadable
transcript cannot wedge or loop the worker.

Tests: the audit's TestCorruptSourceIsRetriedOnceTheTranscriptIsReadable (red
before: health stays corrupt after the transcript heals) plus controls for a
healthy unchanged root still skipping without a read (scan_generation, revision
and document ids unchanged; a now-unreadable transcript keeps health=ok) and for
a permanently corrupt source retried on every scan while its healthy sibling is
not re-read. Permission fixtures skip on Windows and root, and every catalog in
these tests points at a temp path.
2026-10-19 16:34:39 +08:00
YHH 53a8db913a Merge pull request #13917 from esengine/feat/history-tool-output
feat(studio): a finished call's long output is kept as its two ends in the page and fetched whole from the kernel on request
2026-10-19 01:19:36 -07:00
YHH 145b068da1 Merge pull request #13921 from esengine/c5/v4-prefixed-ids
fix(sessionstore): import the 1.x Desktop sessions whose ids carry a desktop- or desktop-manual- prefix
2026-10-19 01:16:44 -07:00
YHH bf4f3f44f0 Merge pull request #13880 from imcreazy/fix/v4-import-keeps-archived-state
fix(migration): keep the conversations 1.x had archived archived
2026-10-19 01:04:48 -07:00
esengine 6add7ebfc9 Merge studio into feat/history-tool-output 2026-10-19 01:01:41 -07:00
YHH f43ef4fd44 Merge pull request #13912 from chuiwenwei/fix/memory-migration-reports-unreadable-facts
fix(memory): report a fact the migration cannot read instead of stopping
2026-10-19 00:59:58 -07:00
esengine ce3c8b9b14 fix(sessionstore): import the 1.x Desktop sessions whose ids carry a desktop- or desktop-manual- prefix 2026-10-19 00:59:42 -07:00
YHH e3449c11ad Merge pull request #13907 from kevin9327/fix/gitignore-escaped-leading-bang
fix(gitignore): keep an escaped leading ! or # in root ignore patterns
2026-10-19 00:59:14 -07:00
YHH cca093601b Merge pull request #13915 from esengine/perf/progress-tail-window
perf(studio): a running command keeps only the end of its output in the page and says how much came before
2026-10-19 00:48:38 -07:00
YHH 78d1233283 Merge pull request #13916 from esengine/c5/hide-machines-with-rail
fix(studio): the sidebar does not repeat the Remote entry while the icon rail shows it
2026-10-19 00:42:31 -07:00
esengine 9552fbbfdc Merge studio into fix/gitignore-escaped-leading-bang 2026-10-19 00:41:49 -07:00
imcreazy 67e3ff51b4 fix(migration): keep the conversations 1.x had archived archived 2026-10-19 15:38:18 +08:00
esengine 6d171bacda fix(studio): keep the lookup tools' output whole, since the manifest card counts lines and hits from it 2026-10-19 00:32:50 -07:00
YHH eaaf2ce99d Merge pull request #13914 from esengine/fix/feedback-capture-after-teardown
fix(studio): a feedback capture that finishes after the dialog is gone writes no state
2026-10-19 00:24:36 -07:00
YHH f8f670fabb Merge pull request #13895 from jf200s/fix/studio-stream-deltas
fix(event): merge tool progress with the deltas around it; fix(studio): the reveal cursor follows a shorter replacement back
2026-10-19 00:23:57 -07:00
esengine 0c5e8e2264 feat(studio): a finished call's long output is kept as its two ends in the page and fetched whole from the kernel on request 2026-10-19 00:22:56 -07:00
esengine a47ffb4134 docs(release): note the sidebar Remote entry change 2026-10-19 00:20:22 -07:00
esengine 8376709ee3 fix(studio): the sidebar does not repeat the Remote entry while the icon rail shows it 2026-10-19 00:20:01 -07:00
YHH 60aba758a8 Merge pull request #13909 from esengine/fb/remote-hist
fix(remote): wait for a desktop answer that is still arriving instead of reporting the computer offline
2026-10-19 00:09:48 -07:00
YHH 13e007e845 Merge pull request #13903 from esengine/perf/subagent-children-mount-on-expand
perf(studio): a finished sub-agent card mounts its child calls only while it is open
2026-10-19 00:05:53 -07:00
esengine 47bf3ce5db perf(studio): a running command keeps only the end of its output in the page and says how much came before 2026-10-19 00:03:23 -07:00
F b5f18a7be9 test(event): use integer ranges for the two counting loops
golangci-lint's intrange rule rejects a C-style loop whose index is never
read. Both loops in the new cases just repeat an emit, so they take the
Go 1.22 form. Behaviour is unchanged.
2026-10-19 15:01:57 +08:00
esengine 581b367f04 fix(studio): a feedback capture that finishes after the dialog is gone writes no state 2026-10-19 00:00:32 -07:00
YHH 5238af4629 Merge pull request #13911 from esengine/feat/feedback-process-memory
feat(feedback): a report carries each process's memory as a fixed type and two numbers, shown in the form first
2026-10-18 23:54:04 -07:00
YHH 0afe5434a1 Merge pull request #13910 from chuiwenwei/fix/compaction-sidecar-respects-write-authority
fix(sessionstore): gate the compaction sidecar on the session write permit
2026-10-18 23:52:17 -07:00
YHH 214a8871da Merge pull request #13906 from kevin9327/fix/gitignore-global-excludes-like-git
fix(gitignore): read core.excludesFile the way git does
2026-10-18 23:41:14 -07:00
YHH ab5dfe515f Merge pull request #13908 from esengine/a9/session-row-title
fix(studio): a session title takes the room the row leaves it
2026-10-18 23:37:19 -07:00
YHH 28bd6a44d8 Merge pull request #13905 from chuiwenwei/fix/memory-restore-archive-staged
fix(memory): stage archive entries before publishing a restore
2026-10-18 23:34:46 -07:00
esengine e3f1e64dcd fix(studio): find and landing frames stop once their transcript has left the document 2026-10-18 23:30:37 -07:00
Will 730c2ab9fe fix(memory): report unreadable facts instead of stopping the migration
Store.MigrateV2WithVersion returned on the first fact whose os.ReadFile
failed, so facts later in the same directory and every later directory
were never migrated. MigrationReport carried only { Migrated int } and the
boot caller discarded it, so nothing named the skipped files: the audit
observed {Migrated:1} with zeta and omega never reached.

The contract says otherwise. docs/SESSION_MEMORY_RETRIEVAL.md:259-261: a
read condition is "reported instead of returning partial lists or
calculating a revision from incomplete history". :268-269: "These
conditions ... do not establish data deletion." The pass therefore records
each failed basename in MigrationReport.Unreadable, sorted, and continues
with the rest; boot's warning names them instead of printing only the
first path.

A per-fact read failure returns a nil error on purpose: the field is the
channel for "skipped", and folding it into err would make a skipped fact
indistinguishable from a pass that could not run. Directory and index
failures still return an error, because those leave the pass's own work
incomplete.

Tests cover: the audit fixture (alpha readable, beta chmod 000, zeta after
it, omega in the second directory) now migrating alpha/zeta/omega with
Unreadable=[beta.md], cross-directory sort order, a fully readable control
with no error and an empty list, the repair path for an already-migrated
fact behind an unreadable one, and the boot warning naming beta.md.
2026-10-19 14:28:15 +08:00
esengine 26830b037f docs(release-notes): remote slow answer 2026-10-18 23:22:19 -07:00
F 63e7e13f69 docs(event): state the constraint, not the history, above the delta key
Review asked for comments to describe what holds now and leave how the code
got here to the commit history. The behaviour is unchanged.

The history this replaces, kept where it belongs:

  * Leaving Source in the zero-probe kept ToolProgress on the passthrough
    path, so the first attempt at merging progress merged nothing in
    practice. Source is identity — it names the emitting party — so it
    belongs in the key, and keeping it there is also what stops a planner's
    text merging into an executor's.
  * The e2e run over a real kernel is what caught that; the unit tests did
    not, because they emitted deltas with no Source at all. Both are now
    covered: TestCoalesceMergesDeltasCarryingSource and
    TestCoalesceSeparatesBySource pin the key's shape.
2026-10-19 14:21:59 +08:00
Will 432a49fe73 fix(sessionstore): gate the compaction sidecar on the session write permit
Cause: persistCompactionStateLocked reached SaveCompactionState, a blind
atomic overwrite carrying no writer identity, while transcript saves fail
closed with ErrSessionWriteAuthorityMissing. A controller in the
stale-authority window (rebind, controller replace, lease generation change)
therefore installed and published a fold whose transcript save was refused:
it replaced the owner's installed digest and paid for the same span twice.

Enforced in the store rather than in the agent: (*Session).SaveCompactionState
presents exactly the permit save.go presents (requireWriteAuthorityForSave,
including BeginSave's in-flight accounting that lease Release waits for), so
there is one adjudication of the permit and no second check re-reading
WriteAuthority() in the agent. Every write to the owner's sidecar already
funnels through the agent's single persistCompactionStateLocked, which now
calls that method. The raw SaveCompactionState remains for paths that write a
fresh recovery lane and for low-level CAS tests that never bind authority.
The CAS on (generation, projection_version, covered_prefix_hash) is untouched:
this is an additional gate, not a replacement.

On refusal the typed authority error propagates as
errProjectionNotPersisted: commitSummaryProjection restores the previous
projection, so nothing is installed or published, and the next attempt under a
valid permit folds once.

Tests: internal/runtime/agent/compaction_sidecar_authority_test.go proves the
no-authority refusal (sidecar and digest unchanged), the authorized control
(one summary, one digest), and that a refused publish costs one normal retry,
not a second summary.
2026-10-19 14:20:56 +08:00
esengine ba2460c01b fix(studio): a session title takes the room the row leaves it
Refs #13904
2026-10-18 23:20:29 -07:00
kevin9327 67854e87b6 fix(gitignore): keep an escaped leading ! or # in root ignore patterns
reanchorPattern stripped the backslash from \!name and \#name before
handing a root pattern to the matcher, which then read the first as a
negation and the second as a comment. Files named !name or #name that git
ignores through the root .gitignore, .git/info/exclude or the global
excludes file were therefore searched and listed. Nested .gitignore files
were not affected, since their patterns are re-anchored under /dir/.

Keep the escape on root patterns when it guards a leading ! or #.
2026-10-19 15:20:17 +09:00
esengine 092a375576 feat(feedback): a report carries each process's memory as a fixed type and two numbers, shown in the form first 2026-10-18 23:19:27 -07:00
kevin9327 4892a8875e fix(gitignore): read core.excludesFile the way git does
The global ignore file was taken from the first core.excludesFile found:
$XDG_CONFIG_HOME/git/config before ~/.gitconfig, and the first value in a
file. Git reads the files in that order and keeps the last value, so a
user's ~/.gitconfig setting lost to the XDG one. The value was also used
nearly raw, so an inline comment or escaped backslashes produced a path
that does not exist, and the user's global ignore rules were silently
dropped.

Keep the last value across files and within a file, and decode the value
like git: strip unquoted # and ; comments, remove quotes, and unescape
\\ and \". Other backslashes stay as written so raw Windows paths still
work.
2026-10-19 15:18:44 +09:00
esengine b2b7b78aa2 fix(remote): wait for a desktop answer that is still arriving instead of reporting the computer offline 2026-10-18 23:16:42 -07:00
F f7ea4be898 fix(studio): the reveal cursor follows a shorter replacement back
useRevealed paces a streamed answer on the frame clock and keeps its cursor
in a ref. The effect returned early when the cursor had caught up and only
handled a shrink inside step(), which the early return made unreachable. A
replacement shorter than what was already revealed — regenerate, rewind,
history restore — therefore left the cursor above the new length, and every
later growth below it was short-circuited: the typing animation silently
stopped for that message while the text itself still arrived in one piece.

Handling the shrink before the early return fixes it. The cursor comes back
with the text, so the next growth is paced exactly like the first one.

The existing three tests did not cover this: they only grew the text. The
new case fails against the old code with "expected 0 to be greater than 0".
2026-10-19 14:16:25 +08:00
F 5e1aef3260 fix(event): merge tool progress with the deltas around it
A shell emits one ToolProgress per output line, so a build log crossed the
coalescer as one frame per line. Downstream that filled a subscriber's
64-frame queue and the hub then dropped answer text beside it, because text
and progress are both droppable. Measured on a real 500-line command: 285
frames unrepaired, 2 with this.

streamDelta replaces isStreamDelta and returns the key a burst merges under
plus its payload, so progress joins text, reasoning and compaction progress:

  * the key carries Source. Every progress chunk an executor emits sets
    Source="executor", and leaving it in the zero-probe kept the event on the
    passthrough path — the first attempt at this merged nothing in practice.
    Keeping it in the key also stops a planner's text merging into an
    executor's.
  * the key carries the call id, so two tools reporting at once never land
    in one another's card.
  * progress carrying more than ID+Output — a sub-agent's status channel sets
    Name, ParentID and a rate — fails the probe and still crosses untouched.
  * progress without an id is not buffered under an empty key.

Verified against a real kernel over SSE: a 500-line command emits 2 frames
that concatenate to the same 500 lines in order, and a 3000-line command
emits 4. Alternating text and progress is unchanged (the key switch flushes
either way, so that burst still cannot merge).
2026-10-19 14:16:22 +08:00
esengine 675c093dbc perf(studio): a finished sub-agent card mounts its child calls only while it is open 2026-10-18 23:01:20 -07:00
YHH 156cd28a5d Merge pull request #13855 from SivanCola/codex/market-source-client
fix: explain invalid market source commits / 准确提示市场来源提交号错误
2026-10-18 22:55:20 -07:00
YHH f2110ddc9c Merge pull request #13847 from esengine/feat/peers-settings-section
feat(studio): session messages have a settings entry of their own, with projects allowed through to add and take back
2026-10-18 22:40:09 -07:00
YHH 4a005015ac Merge pull request #13897 from esengine/chore/update-acknowledgments
Update acknowledgments contributors / 更新致谢贡献者
2026-10-18 22:39:59 -07:00
YHH 947907bb77 Merge pull request #13900 from esengine/feat/trust-wait-badge
feat(studio): a conversation out of sight that is asking whether to trust its folder is marked as waiting in the sidebar
2026-10-18 22:30:30 -07:00
esengine bd9df22bc6 feat(studio): a conversation out of sight that is asking whether to trust its folder is marked as waiting in the sidebar 2026-10-18 22:11:28 -07:00
YHH b6c837b0af Merge pull request #13893 from esengine/feat/13870-ask-refine
feat(studio): the free-text answer of a question card can optimise the prompt like the composer
2026-10-18 22:09:24 -07:00
YHH 0e0eecf2b6 Merge pull request #13890 from esengine/fb/dispatch-trust
fix(dispatch): keep an undecided project's window open and say the task was not sent
2026-10-18 21:55:58 -07:00
esengine 164c03c141 fix(dispatch): count panes waiting for a trust answer against the session ceiling 2026-10-18 21:39:58 -07:00
YHH 62afcaddf5 Merge pull request #13800 from chuiwenwei/fix/memory-rebuild-index-when-unreadable
fix(memory): rebuild the derived index when it cannot be read
2026-10-18 21:09:18 -07:00
YHH 5b1c1aeadd Merge pull request #13896 from esengine/feat/13142-board-default-join
feat(studio): linking a computer offers the contribution board with a name to confirm, and the terminal says how to join
2026-10-18 21:08:53 -07:00
YHH a8fc3c05b7 Merge pull request #13887 from lancy69/fix/6007-project-config-fallback
feat(config): support .reasonix/reasonix.toml project fallback
2026-10-18 21:08:45 -07:00
Will 0a78408ea9 test(memory): the active sweep branch must not recreate an archive entry
The kill test proves the leftover is removed and the live fact is untouched, but
nothing pinned the branch itself: forcing an active id down the rename-back path
now fails because ListArchived lists the live fact as archived again.
2026-10-19 12:01:20 +08:00
Will 1e1fb59a60 fix(memory): stage archive entries before publishing a restore
RestoreArchived moved the archive entry away only after the active file
and its index line were published. A process killed in between left one
id both active and archived, so ListArchived reported a live fact and a
retry was refused as "already active".

The entry now moves to a hidden .restoring-<id>-<stamp> name before the
active file is written. The stamp is the archive time, so the name
carries both the id and the original archive name with no journal and no
content comparison. Every restore sweeps leftovers first: an active id
means the dead restore had published, so only the redundant copy goes;
an absent id means it never published, so the entry is renamed back. A
failed sweep reports its error and publishes nothing.
2026-10-19 11:58:44 +08:00
YHH d18912cd30 Merge pull request #13854 from esengine/feat/12144-tui-keys
feat(tui): queued messages are drawn as rows and managed with the keys 1.x had
2026-10-18 20:41:20 -07:00
SivanCola 22c3c2b167 docs: update acknowledgments contributors 2026-10-19 03:37:09 +00:00
Lancy cfb483ae11 feat(config): support nested project config fallback
Resolve .reasonix/reasonix.toml only when the root project config is absent. Share selection across loading, local edits, installs and repair; retain project-scope restrictions and reject symlink escapes.

Fixes #6007
2026-10-19 11:36:17 +08:00
Will 36370578c9 test(memory): skip the permission-fault fixtures on Windows
chmod 000 does not remove read permission on Windows, so the fixtures that make
MEMORY.md unreadable would fail their own self-check there. The classification and
aside behaviour are unchanged on Unix; the directory-named case stays cross-platform.
2026-10-19 11:31:44 +08:00
esengine e0e1f7fa55 feat(studio): linking a computer offers the contribution board with a name to confirm, and the terminal says how to join 2026-10-18 20:28:33 -07:00
esengine 118f8a624a refactor(tui): switch on the selected id directly 2026-10-18 20:20:29 -07:00
esengine f987d4326b feat(studio): the free-text answer of a question card can optimise the prompt like the composer 2026-10-18 20:15:00 -07:00
esengine d67d18df49 fix(dispatch): keep an undecided project's window open and say the task was not sent 2026-10-18 20:02:09 -07:00
esengine 039756a281 docs(release-notes): shorten the queue-rows fragment under 200 columns 2026-10-18 20:01:11 -07:00
esengine 8bd8220792 Merge studio into feat/peers-settings-section 2026-10-18 20:00:55 -07:00
esengine db653b55f2 Merge studio into feat/12144-tui-keys 2026-10-18 19:57:36 -07:00
YHH 0e56db4b59 Merge pull request #13881 from imcreazy/fix/stream-error-code-may-be-numeric
fix(openai): read a numeric error code from a stream error event
2026-10-18 19:54:22 -07:00
YHH bf3fcd9934 Merge pull request #13879 from esengine/fix/skill-subagent-label
fix(delegation): a skill sub-agent's row carries the first line of its task as its label
2026-10-18 19:54:14 -07:00
Will a7cf278566 test(memory): the aside copy is kept for the person, not served as a fact
After an unreadable index is moved aside and the derived index republished, the
store must list exactly the fact files and the index must not name the aside.
2026-10-19 10:50:42 +08:00
Will 399a6b62e1 fix(memory): rebuild MEMORY.md only for deterministic read faults
MigrateV2WithVersion is the only entry that recomputes the derived
MEMORY.md, and it returned readIndexIn's error. Every mutation path reads
that same file first, so a restored store whose index has a foreign owner
or a changed ACL froze every memory write for good: boot only warned, and
the docs' promised re-index has no other entry.

Classify the read error by identity: only fs.ErrPermission (permission
denied, a foreign owner, an ACL) and a typed not-regular-file error (a
directory named MEMORY.md) reach the rebuild. Every other error -- an I/O
failure, a resource limit, a momentary lock -- still returns, because the
file may be perfectly good and republishing would overwrite hand-written
text a retry can still read.

Before publishing, atomically rename the unreadable entry to a stamped
name in the same directory (MEMORY.md.unreadable-<unixnano>), so the bytes
survive for a later repair; a rename needs write access to the directory,
not read access to the file. If the rename fails, return the original
error and publish nothing. flushIndexIn keeps the readable path, so
hand-written text is still preserved line for line when it can be read.

No new persisted state beyond the aside copy; the MigrateV2 signature and
boot call site are unchanged, and an unreadable store directory or fact
file still aborts the pass.

Tests: the audit probe is red before and green after; controls cover the
stale readable index, hand-written survival, byte-identical healthy boot,
missing-index rebuild, and a later save succeeding after the repair. New:
a transient read error leaves the index byte-identical (red against the
previous revision), the unreadable bytes survive under the stamped name, a
directory index repairs, and a failed aside rename publishes nothing. Each
new guard was reverted alone to confirm its control goes red.
2026-10-19 10:50:42 +08:00
esengine f7b9f2db90 docs(studio): split the sub-agent row layout note into short rules 2026-10-18 19:39:09 -07:00
esengine a304b5d426 Merge remote-tracking branch 'origin/studio' into fix/skill-subagent-label 2026-10-18 19:38:29 -07:00
YHH 4a3cca633b Merge pull request #13886 from esengine/fix/13884-archive-list
fix(studio): the sidebar says it is reading, not that nothing matches, while the rows of another view are still in hand
2026-10-18 19:37:54 -07:00
YHH 963d45aeec Merge pull request #13878 from esengine/a9/sse-fixtures
test(openai): pin how usage is read from captured DeepSeek streams (stop and length)
2026-10-18 19:37:20 -07:00
YHH d249fd9ae3 Merge pull request #13885 from esengine/fix/13883-first-tool-snapshot-bound
fix(agent): the first tool waits at most snapshotWait for the turn-start snapshot
2026-10-18 19:37:07 -07:00
esengine 3a3b3411b9 fix(studio): the sidebar says it is reading, not that nothing matches, while the rows of another view are still in hand 2026-10-18 18:50:14 -07:00
esengine 4cfe4b1d2f fix(agent): the first tool waits at most snapshotWait for the turn-start snapshot
The turn-start workspace snapshot was awaited with no bound, so in a very large
workspace (a drive root: 7 s on a warm SSD for the first turn, 0.5 s after) the
first tool could not start until the walk finished, and a command's own
timeout_seconds, which only starts when the command does, never covered it.
The wait now ends at snapshotWait, the bound the end-of-turn snapshot already
has: the walk is cancelled, the snapshot records itself incomplete, and the
bundle says the turn's changes were not compared.
2026-10-18 18:35:33 -07:00
esengine 252ed5f28b fix(studio): a narrow sub-agent list gives the task a line of its own
The row sized the name, the model chip and the status to their content and gave
the task only what was left, so with the chip present the task was squeezed to
8 px at a 360 px panel and 0 px at 280 px. Below 460 px of the list's own width
the row now takes up to three lines: name and status first, the task second,
the chip third.
2026-10-18 18:17:53 -07:00
imcreazy 94c1eb913d fix(openai): read a numeric error code from a stream error event 2026-10-19 09:10:01 +08:00
esengine da2f54207c fix(delegation): a skill sub-agent's row carries the first line of its task as its label
profileForSkill set only the name and count, so a skill sub-agent (explore,
locate, run_skill, read_only_skill) showed no task while a task sub-agent
showed its first line. The label helper moves to the event package, next to
the Profile it fills, and both producers use it.
2026-10-18 18:02:12 -07:00
esengine 56795d8913 docs(release): studio 2.45.0 notes 2026-10-18 17:56:42 -07:00
YHH 5163088956 Merge pull request #13853 from esengine/fix/browser-fit-v2
fix(studio): a page with a viewport of its own is centred at its size or scaled to fit, and the agent's clicks are mapped by the scale
2026-10-18 17:55:29 -07:00
esengine a0589c8ad0 test(studio): pixel checks that local html, a 3200x1800 image document and a PDF fill the built-in browser, with and without an agent viewport 2026-10-18 17:37:07 -07:00
esengine 45444a47cb test(openai): pin how usage is read from captured DeepSeek streams (stop and length) 2026-10-18 17:24:01 -07:00
YHH 6318ab0ae4 Merge pull request #13840 from esengine/fix/13833-root-missing
fix(agent): refuse a call whose project or external folder is gone, with one typed cause
2026-10-18 16:56:47 -07:00
esengine d536418db3 refactor(agent): lift the workspace lease hold out of prepareToolExecution; drop the test-only IsRootMissing 2026-10-18 16:39:50 -07:00
esengine 77134b5ef0 Merge studio into fix/13833-root-missing 2026-10-18 16:27:14 -07:00
YHH 8d714ba296 Merge pull request #13825 from esengine/feat/13320-bound-candidate
fix(compaction): retry a refused summary request at a smaller size
2026-10-18 16:23:24 -07:00
esengine f73a5b1507 docs(release-notes): wrap the root-missing fragment under 200 columns 2026-10-18 16:08:32 -07:00
esengine 814dbd5402 Merge remote-tracking branch 'origin/studio' into feat/13320-bound-candidate 2026-10-18 16:08:06 -07:00
YHH fdaf05584f Merge pull request #13820 from imcreazy/fix/explorer-after-closing-a-file
fix(studio): closing a file opened from the explorer comes back to the explorer
2026-10-18 14:41:38 -07:00
YHH dfa8f70ea4 Merge pull request #13877 from esengine/a9/cost-effect
test(boot): a DeepSeek-shaped OpenAI-compatible usage reaches the quote split by cache
2026-10-18 14:31:52 -07:00
YHH 54ca8a7847 Merge pull request #13876 from esengine/fix/13642-subagent-images
fix(delegation): a delegate gets the pasted images its task names, and a skill sub-agent is routed to the vision model
2026-10-18 14:29:31 -07:00
YHH f2ac8905ad Merge pull request #13875 from esengine/a9/versions-refresh-watch
fix(studio): opening the Versions page checks for a release when automatic download is chosen
2026-10-18 14:25:46 -07:00
esengine 1b25fca477 test(boot): a DeepSeek-shaped OpenAI-compatible usage reaches the quote split by cache (local, not pushed) 2026-10-18 14:12:49 -07:00
esengine fe6c7c3a91 fix(studio): opening the Versions page checks for a release when automatic download is chosen
Refs #13683
2026-10-18 14:10:17 -07:00
YHH e741c70074 Merge pull request #13874 from esengine/a9/fix-feedbackmine-selector
test(feedback): read the status chips inside the list, not the first one on the page
2026-10-18 14:10:02 -07:00
esengine c0d08b437c fix(delegation): a delegate gets the pasted images its task names, and a skill sub-agent is routed to the vision model
A task text that names a file in the host's own .reasonix/attachments directory
now gives the delegate that image (judged by the resolved path, read through the
same checks an @-reference gets), on the vision role with the note that it rides
the message, including on a follow-up turn that carried no image. The
read_only_skill runner took the turn's candidates without routing the model or
saying so, so a text-only skill sub-agent dropped them silently.
2026-10-18 14:09:16 -07:00
esengine f800585f31 test(feedback): read the status chips inside the list, not the first one on the page 2026-10-18 13:55:04 -07:00
YHH 45c763ce15 Merge pull request #13814 from esengine/fix/shell-mode-stuck
fix(studio): a refused ! command stays selected in the box so the next message replaces it
2026-10-18 13:36:21 -07:00
YHH 8e1ea87adc Merge pull request #13873 from esengine/fix/13872-tui-stream-append
fix(tui): appending to a streamed answer, its thinking or a command's output no longer copies it whole
2026-10-18 13:09:42 -07:00
esengine 30c63e6023 fix(tui): appending to a streamed answer, its thinking or a command's output no longer copies it whole
Each delta did s += piece on the row's text, so a long stream cost time and
memory quadratic in its length: 20000 progress events of one command (4.5 MB)
took about 13 s of the UI's own time. The text now grows in a buffer kept beside
the row, trusted only while the row still holds exactly what it last handed out.
2026-10-18 12:53:36 -07:00
YHH 80fcd85b54 Merge pull request #13843 from esengine/feat/11411-effort-in-place
feat(effort): changing reasoning depth no longer rebuilds the session and works mid-turn
2026-10-18 11:54:24 -07:00
esengine 0ce5ce6436 Merge studio into feat/11411-effort-in-place 2026-10-18 11:40:16 -07:00
YHH 27ad7ded21 Merge pull request #13871 from esengine/fix/13684-open-lookup
fix(serve): match an open pane's row whatever the case of its transcript name
2026-10-18 11:04:42 -07:00
YHH fe0020056e Merge pull request #13869 from esengine/a9/usage-billing-mode
fix(usage): keep a subscription's estimate out of the paid total
2026-10-18 10:56:03 -07:00
YHH f5d2874d61 Merge pull request #13868 from esengine/fix/jump-resolve-flaky
test(remote): bound the real-ssh jump test by the run's budget, not the product's 15 s
2026-10-18 10:49:57 -07:00
YHH de2763b21e Merge pull request #13866 from esengine/feat/13142-board-studio
feat(feedback): the contribution board in Studio and the terminal, through the kernel
2026-10-18 10:49:14 -07:00
esengine 3b54ee9438 fix(serve): match an open pane's row whatever the case of its transcript name 2026-10-18 10:44:09 -07:00
YHH 0d50803ab1 Merge pull request #13842 from esengine/feat/11531-lease-release
feat(workspace): a session waiting on a person yields its write claim, and says so when another session took it
2026-10-18 10:41:02 -07:00
esengine d8abd5b5ce fix(usage): keep a subscription's estimate out of the paid total
Refs #13276
2026-10-18 10:32:04 -07:00
esengine e6ec598a58 feat(feedback): the contribution board in Studio and the terminal, through the kernel 2026-10-18 10:31:58 -07:00
YHH 78cb353fd8 Merge pull request #13865 from esengine/fix/13858-port-identity
fix(studio): two model services on one host and different ports are two services, and a duplicate is never dropped from the list
2026-10-18 10:25:50 -07:00
YHH bf1ff861ad Merge pull request #13864 from esengine/a9/price-provenance2
feat(billing): a model's price can say where it was read and when
2026-10-18 10:22:42 -07:00
YHH a925403b6f Merge pull request #13860 from esengine/a5/opencode-go-gateway
fix(providers): recognise OpenCode Go under its documented /inference/go prefix
2026-10-18 10:21:17 -07:00
YHH 38060091bd Merge pull request #13862 from esengine/test/control-git-push-trust
test(control): cover the push trust gate and hold control/git_push.go to it
2026-10-18 10:20:58 -07:00
esengine d5997b6496 feat(tui): queued messages are drawn as rows and managed with the keys 1.x had 2026-10-18 10:20:12 -07:00
esengine 0b848ac7f2 fix(studio): the session messages switches and the allowed projects share one read of the settings 2026-10-18 10:19:33 -07:00
esengine d4089914ea test(remote): the real-ssh jump resolution test bounds ssh by the run's budget, not the product's 15 s
The product keeps its 15 second bound on one ssh -G; the source now carries it
as a field with that default, and the test that runs the installed ssh sets it
to testenv.Budget, a bound that only reports a hang.
2026-10-18 10:17:52 -07:00
YHH 7b499ffaeb Merge pull request #13863 from esengine/a5/effort-undeclared
fix(effort): say a model's levels are undeclared, and where to declare them
2026-10-18 10:13:03 -07:00
YHH 5d6743aa2c Merge pull request #13844 from esengine/a9/acp-permission-mode
feat(acp): --permission-mode fixes the session posture and caps what a client can raise
2026-10-18 10:12:13 -07:00
esengine add01e5f04 fix(studio): a viewport override with a zero or non-finite size means no override 2026-10-18 10:11:53 -07:00
esengine 223a158a02 Merge origin/studio into feat/11531-lease-release 2026-10-18 10:06:32 -07:00
esengine ad7e7f835f fix(studio): two model services on one host and different ports are two services, and a duplicate is never dropped from the list 2026-10-18 09:58:38 -07:00
imcreazy 481aa9f9a6 fix(studio): closing a file opened from the explorer comes back to the explorer 2026-10-19 00:58:10 +08:00
esengine 1766c04904 feat(billing): a model's price can say where it was read and when
Refs #13276
2026-10-18 09:56:46 -07:00
YHH ea0538a72e Merge pull request #13851 from esengine/fix/13335-sibling-folders-domain
fix(workspace): a writer with no declared extent holds its own folder, not the whole repository
2026-10-18 09:56:07 -07:00
esengine 5888922ffc refactor(i18n): keep the /effort undeclared wording in the setting catalogue 2026-10-18 09:53:32 -07:00
esengine 94e236f203 docs(release): note the /effort wording fix 2026-10-18 09:51:17 -07:00
esengine 8ce086e1bc fix(effort): say a model's levels are undeclared, and where to declare them 2026-10-18 09:51:16 -07:00
esengine e1e5301681 test(control): cover the push trust gate and its refusals, and hold the file to it
internal/session/control/git_push.go measured 0 from the control package's own
tests, so declaring it sensitive recorded a floor of 0 that could never fail.
The tests cover the trust refusal for both calls, a repository that is not
there, a branch with no upstream, and the commit lock released after a refused
push; the floor is now 100.
2026-10-18 09:47:51 -07:00
esengine 7b8f7de8b0 docs(release): note the OpenCode Go endpoint fix 2026-10-18 09:45:27 -07:00
esengine fe45ff0a8c fix(providers): recognise OpenCode Go under its documented /inference/go prefix 2026-10-18 09:43:43 -07:00
esengine ae9fba3fd0 fix(workspace): a writer with no declared extent holds its own folder, not the whole repository
Two project folders under one git repository share a writer domain on purpose
(the lock and the visibility of each other's claims), but an undeclared writer
claimed the domain root, so sibling projects waited on each other for bash and
MCP writes, and the refusal read held X, requested X. It now claims the
selected folder; a session on the repository root still overlaps both, and a
declared path that cannot be resolved still claims the whole repository.
2026-10-18 09:36:18 -07:00
SivanCola 9710592281 fix: explain invalid commit-pinned market sources accurately
Problem: trusting a publisher whose plugin source has a mistyped commit
reports a misleading local-or-unsafe-source installation failure.

Root cause: plugin source validation collapsed its refusal into the generic
bad-source class before the HTTP and presentation layers could explain it.

Fix: preserve a distinct invalid-plugin-source error through publication and
installation, translate its complete-commit requirement in both languages,
and align the shared client validator with existing registry path checks.

Verification: Go vet, repository lint, owning market/serve tests, the required
builtin/boot tests, 391 frontend market/i18n tests, and TypeScript checking.
2026-10-19 00:32:35 +08:00
YHH ebe4f2dca2 Merge pull request #13850 from esengine/c5/sensitive-git-push
chore(security): declare the Git push path and the host-only route list sensitive
2026-10-18 09:31:49 -07:00
esengine 817e3fa03d fix(studio): a page with a viewport of its own is centred at its size or scaled to fit, and the agent's clicks are mapped by the scale 2026-10-18 09:31:00 -07:00
YHH 442f05cd11 Merge pull request #13846 from xxoingr/test/boot-catalog-close-budget
test(boot): bound the catalog close in cleanup by the hang budget
2026-10-18 09:24:13 -07:00
esengine 8126f5cf1e chore(security): declare the Git push path and the host-only route list sensitive
The push is the one git action Reasonix performs that leaves the machine, and
the host-only route list decides which requests a paired device may make. Both
now demand review plus security_review and are held to the coverage they have:
gitpush 84.0, serve/git_push.go 77.0, serve/host_routes.go 93.0, and
control/git_push.go 0 (its behaviour is covered by the serve and boot effect
tests, which the per-package gate does not count).
2026-10-18 09:17:17 -07:00
YHH 904ca4359b Merge pull request #13834 from esengine/feat/git-panel
feat(studio): one Git panel: a changes tree, discard, push with a preview, commit details and a section per repository
2026-10-18 09:10:38 -07:00
esengine c5e8080866 feat(workspace): a session waiting on a person yields its write claim, and says so when another session took it
An Auto Guard card and a call that asks before it writes now give the claim
back for the wait; the lease records granted claims so the resume can name
who took the ground, the model gets workspace.claim_contended and the receipt
workspace_claim_yielded. A blocked acquisition ends at a user-set maximum wait
with workspace.claim_timeout.
2026-10-18 09:08:45 -07:00
xxoingr e4a0841fec test(boot): bound the catalog close in cleanup by the hang budget
closeBootTestHistoryCatalog gave the history and usage catalog closes a
fixed 5 s. Closing a SQLite catalog, and waiting out an open still in
flight, can take longer on a loaded Windows runner, and the posture
effect test failed there in cleanup with "close usage catalogs: context
deadline exceeded". The wait only exists to report a hang, so use
testenv.Budget; a close that never returns still fails the same way.
2026-10-19 00:07:46 +08:00
esengine 053247aa1e feat(studio): session messages have a settings entry of their own, with projects allowed through to add and take back 2026-10-18 09:06:42 -07:00
esengine ab66297bab feat(acp): --permission-mode fixes the session posture and caps what a client can raise
Refs #13839
2026-10-18 09:00:03 -07:00
YHH 02341bf950 Merge pull request #13763 from esengine/a9/tools-disabled
feat(tools): turn tools off by name, one registry removal for every surface
2026-10-18 08:55:30 -07:00
esengine a52b35551f feat(session): a sub-agent and the status line follow an in-place depth change
A child that shares the parent's provider starts on the depth in force now, and one
that inherits the parent's model without an effort of its own is built for it, as a
rebuild would have given. The status line reads the depth the session is on.
2026-10-18 08:51:08 -07:00
esengine edc7e01bb4 feat(session): change reasoning depth in place when the provider takes it per request
A provider that exposes the depths it accepts in Request.EffortOverride
(provider.RequestEffortProvider; the OpenAI-shaped adapter does) lets a session change
depth without a rebuild: the controller sets a session depth every main request
carries, updates its running selection, and accepts the change mid-turn. Serve (and so
the terminal and the desktop) and ACP both apply it in place and keep their rebuild for
a depth the provider does not take per request. The level is still saved to the user's
config so a restart opens on it.
2026-10-18 08:51:08 -07:00
esengine a07ca77b69 fix(git): repositories are listed once and kept until the structure changes, reads are settled and not repeated, a kernel without the route keeps the old behaviour 2026-10-18 08:50:37 -07:00
esengine 6b653a7f32 fix(agent): refuse a call whose project or external folder is gone, with one typed cause 2026-10-18 08:46:27 -07:00
YHH d8f9eef43d Merge branch 'studio' into a9/tools-disabled 2026-10-18 08:42:35 -07:00
YHH 89d86f4d3f Merge pull request #13838 from esengine/a9/peak-indicator
feat(status): show which of the vendor's billing windows the model is in
2026-10-18 08:42:12 -07:00
SivanCola ae6a9de150 Merge studio into session-agent-compositions after UI review fixes
Problem: the advancing Studio branch conflicted with the mod-turn settlement
test and changed queue pause and child-dispatch reporting contracts beside the
reviewed composition work.

Root cause: both branches added turn-completion waiting, the combined inbox
owner exceeded the file ceiling, and the effort audit still expected concrete
runtime identity in dispatch profiles after Studio restored selection profiles.

Fix: integrate exact Studio da9fbe7307 normally.
Keep both queued TurnDone and durable idle settlement. Group pause operations
in inbox_pause.go without behavior changes. Assert requested selections on
dispatch events and concrete identities on status events, retaining real
provider effort, rejection and persisted-child checks. Preserve landed queue,
billing, phone and remote protection work and the default-off composition UI.

Verification: format/vet/lint/make check; all 229 root Go packages and required
builtin/boot; affected HTTP/storage/controller and focused boot races; pause
controller regression; Desktop/SDK; 699 frontend files and 5670 tests,
typecheck/build/census; 12 browser/eight Electron renderer scenes; Electron
units (177 pass, one existing skip); 13 official ACP SDK conformance tests.
No threshold or authority expansion; no hosted check result is substituted.
2026-10-18 23:19:34 +08:00
esengine f8e16bfc29 feat(status): show which of the vendor's billing windows the model is in
The vendor's published schedule already prices every request; the status bar
now says, live, whether the model is billed at the peak or the off-peak rate
and when that changes, where the model is billed at the vendor's own rate.
2026-10-18 08:16:23 -07:00
esengine 30c6589451 wip peak indicator 2026-10-18 08:16:16 -07:00
esengine 0608ffeb63 fix(git): a repository name and a path are taken from a directory listing and from git's own list, never built from the request 2026-10-18 08:10:18 -07:00
SivanCola 2e2b316d86 fix(studio): address composition UI review and host archive uploads
Problem: repeated New session reset a blank pane's mode, copied built-in
descriptions ignored the window language, and identifier fields missed theme
styles. The picker was always visible and native archives were parsed in the
renderer. Unrelated transitions also repositioned every open popup.

Root cause: pane reuse resolved an unrequested default; editor styles depended
on a container the identifier row did not share; interface opt-in was missing;
archive transport carried parsed objects; placement had no anchor scope.

Fix: change a reused pane only for an explicit selection and preserve focus on
refusal, translate built-in copies, share field tokens, add a default-off local
picker switch that preserves bindings and the original hidden-state layout,
upload opaque files to the existing host decoder with typed localized errors,
keep the prior JSON envelope, and scope transitions to each popup's anchor or
container. Show catalog unavailability and wrap the editor components.

Verification: failing-first regressions; format/vet/lint/make check; all 228
root Go packages, required builtin/boot, HTTP/storage races and exact-binding
archive transport tests; Desktop/SDK; 691 frontend files and 5608 tests,
typecheck/build/census; 12 browser and eight Electron renderer scenes across
languages/themes/widths; Electron units (176 pass, one existing skip).
No authority, schema, safety policy or verification threshold is widened.
2026-10-18 23:06:07 +08:00
YHH da9fbe7307 Merge pull request #13836 from esengine/a5/phone-push-service-error
fix(phone): say a browser that cannot reach its push service is that, not an account outage
2026-10-18 08:02:05 -07:00
YHH c38beaf09a Merge pull request #13835 from esengine/fix/13811-browser-fit-wide-viewport
fix(studio): show a page the agent laid out wider than the pane whole, scaled to fit
2026-10-18 08:01:11 -07:00
esengine a3a8c1ff79 fix(serve): host-only routes are one list the handlers and the remote proxy both read; windows fixtures and a HEAD read that failed is not a detached HEAD 2026-10-18 07:56:16 -07:00
esengine aa60b7f48b fix(serve): a paired device cannot preview or send a push or discard a change 2026-10-18 07:52:20 -07:00
esengine 040aa7dbde fix(phone): say a browser that cannot reach its push service is that, not an account outage 2026-10-18 07:48:27 -07:00
esengine ad9219543b fix(studio): show a page the agent laid out wider than the pane whole, scaled to fit 2026-10-18 07:44:17 -07:00
YHH 5f4c9ffad7 Merge pull request #13822 from esengine/a5/phone-push
feat(phone): remind this phone through Web Push, with its own key and a push-only service worker
2026-10-18 07:32:53 -07:00
YHH e5be3420b3 Merge pull request #13832 from esengine/a5/step-up-effect
test(mcp): hold step-up authorization at the boot boundary
2026-10-18 07:31:52 -07:00
YHH d130baa87c Merge pull request #13828 from esengine/a9/balance-format
feat(billing): a balance endpoint declares the format it answers in
2026-10-18 07:28:27 -07:00
esengine d294509df6 docs(release): the Git panel 2026-10-18 07:27:42 -07:00
esengine 18d679077d feat(studio): one Git panel: a changes tree to stage, discard and commit, push with a preview, commit details, and a section per repository 2026-10-18 07:27:41 -07:00
esengine a81f06208c feat(git): push the current branch to its upstream from a throwaway repository, with a preview and typed refusals 2026-10-18 07:27:41 -07:00
esengine 617a7c40d6 feat(git): a commit opens into its files and each diff, and one changed file can be discarded 2026-10-18 07:27:41 -07:00
esengine 344db1d16b feat(git): a workspace folder of projects lists its repositories, and every git read and write names which one 2026-10-18 07:27:41 -07:00
YHH aa34e20192 Merge pull request #13830 from esengine/fix/thought-time-test
test: three timing-dependent tests no longer depend on wall-clock thresholds or cleanup races
2026-10-18 07:25:46 -07:00
YHH 9f19b31fdf Merge pull request #13791 from esengine/a9/queue-paused
fix(peers): a message the person let in starts over a paused queue, by what paused it
2026-10-18 07:23:09 -07:00
esengine 8dcbc55218 test(mcp): gofmt 2026-10-18 07:12:58 -07:00
esengine 8993a8eec1 test(mcp): drop an unneeded Sprintf 2026-10-18 07:12:58 -07:00
esengine a40d996915 test(mcp): hold step-up authorization at the boot boundary 2026-10-18 07:12:58 -07:00
esengine 1119118780 Merge origin/studio into a5/phone-push 2026-10-18 07:12:44 -07:00
esengine 92b39e3ed0 feat(billing): a balance endpoint declares the format it answers in
A balance_url on a host that is not a vendor's own now names its format
(balance_format) or is not read, and the key is not sent to it. The connection
editor edits the address and format. Existing relay entries are declared as
deepseek once, and a format the 1.x line drops from the shared file is put back
at the next start.
2026-10-18 07:10:10 -07:00
YHH 6b8928a8c3 Merge pull request #13782 from esengine/a5/appimage
feat(release): build and verify an AppImage of Studio on Linux (download only)
2026-10-18 07:08:12 -07:00
YHH 6b0c9ef061 Merge pull request #13784 from esengine/a5/remote-agent-guard
feat(cli): refuse remote subcommands that change the book or open a link for an agent's shell
2026-10-18 07:07:31 -07:00
YHH 6e9294dd99 Merge branch 'studio' into a9/queue-paused 2026-10-18 07:07:27 -07:00
SivanCola 38609899ae fix(composition): preserve edit authority and mandatory safety policy
Problem: ungranted HTTP clients could mutate presets or switch compositions,
and empty hook selections could remove configured safety guards. A paired
remote pane could also borrow the host credential, and a selected native
extension could override the host's denied read.

Root cause: preset routes bypassed request-scoped edit grants, proxying erased
device authority, and frozen automation was treated as the whole safety policy.

Fix: gate preset mutations before store/admission access, refuse paired preset
writes before remote credential forwarding, overlay current authorized guards
outside retained automation, update their shared runner on live edits, resolve
hook inspection/save/approval/reload against one home, and mark bound runtime
packages tighten-only. Preserve ordinary unbound extension behavior, immutable
bindings, optional retained automation and the accepted Standard/Minimal UI.

Verification: failing-first HTTP, proxy and real boot/provider regressions;
format, vet, lint, make check, full root and required builtin/boot suites;
affected package and focused boot races; Desktop/SDK; frontend 689 files/5598
tests, typecheck/build and census; browser eight/Electron renderer four scenes;
Electron units. Correct an inherited mod fixture's premature teardown using
the existing durable-idle notification, with 20 independent process runs.
No test threshold, approval gate or product Close behavior is weakened.
2026-10-18 22:03:15 +08:00
esengine 424343a8b4 test(boot): the mod-queued-prompt test waits for the turn it started before the temp dirs go
The queued prompt starts a turn of its own, still writing under the test's temp
directory when the test returned, so the directory's cleanup failed with 'directory not
empty'. Seen as 30 failures in 40 runs with eight test processes at once, 0 in 40 after.
2026-10-18 07:02:34 -07:00
esengine 118310d400 test(control): the heredoc cancel test waits for the shell to start, not for 2 seconds
The test gave a cold Git Bash two seconds to write the heredoc's file; on a shared
Windows runner the start can take longer, and the failure (4.25 s) was that wait plus
the cancel. The bound is now 15 seconds, only to keep a hang from lasting forever.
2026-10-18 07:02:34 -07:00
esengine 334598db44 test(boot): the thinking-time effect test asserts a measurement was taken, not a wall-clock size
The test compared the host's measured thought to the provider's 60 ms sleep minus 10,
which a loaded -race runner breaks (42 ms against 50). The size of the measurement is
the thought clock's own unit test with an injected clock; this test keeps its claim that
a measurement is taken, kept with the turn and framed identically.
2026-10-18 07:02:34 -07:00
YHH c58a1d7cb3 Merge pull request #13827 from esengine/a5/acp-sdk-conformance
test(acp): drive the agent with the official SDK and hold its output to the shipped schema
2026-10-18 07:01:43 -07:00
YHH 5b3968c6f5 Merge pull request #13795 from esengine/a5/push-host
feat(host): tell enrolled phones that something is waiting, and manage them from the desktop
2026-10-18 06:56:27 -07:00
YHH 26d7a21bf6 Merge pull request #13809 from esengine/chore/deps-go-group-with-desktop
chore(deps): bump the go group with 11 updates, including the desktop module
2026-10-18 06:56:15 -07:00
esengine f676e1d61a ci: run checks against studio 2026-10-18 06:48:32 -07:00
esengine 22bfd09c40 Merge remote-tracking branch 'origin/studio' into a5/remote-agent-guard 2026-10-18 06:48:32 -07:00
esengine 82b9616285 Merge remote-tracking branch 'origin/studio' into a5/schedule-api 2026-10-18 06:48:01 -07:00
esengine 14ea889c81 Merge remote-tracking branch 'origin/studio' into a5/appimage 2026-10-18 06:46:34 -07:00
esengine d73352f4a6 test(acp): drive the agent with the official SDK and hold its output to the shipped schema 2026-10-18 06:44:42 -07:00
esengine 751def6d32 chore(deps): bump the go group with 11 updates, including the desktop module 2026-10-18 06:37:48 -07:00
YHH d8f84ed690 Merge pull request #13819 from esengine/fix/dispatch-profile-selection
fix(delegation): the dispatch profile stays the call's selection
2026-10-18 06:33:48 -07:00
esengine a26328f24b style(compaction): wrap the last refusal 2026-10-18 06:29:11 -07:00
esengine f7bc03da36 fix(compaction): retry a refused summary request at a smaller size
A summary request the provider rejects as larger than its context (the documented
context_length_exceeded code, nothing else) is retried at 70%, 50% and 35% of the
refused input, shortened by the same deterministic steps as before; a refused request
costs nothing, so the digest is not given up until three smaller inputs were refused.
A provider that refuses every size ends as summary_input_too_large.
2026-10-18 06:28:05 -07:00
esengine 79d971d607 feat(phone): remind this phone through Web Push, with its own key and a push-only service worker 2026-10-18 06:19:59 -07:00
YHH 97d7009cbb Merge pull request #13804 from esengine/dependabot/github_actions/actions-e40781d787
chore(deps): bump the actions group with 2 updates
2026-10-18 06:09:58 -07:00
esengine ba25d7c854 fix(peers): a message the person let in starts over a paused queue, by what paused it
The inbox manifest now records why a queue is paused, as a typed code beside
the old boolean. A message approved on its card is claimed over any pause; one
let in by a standing release or dispatch switch only over a pause the host set
to guard stale input, and stays queued over one the person set. The pause and
older queued items are left as they were.
2026-10-18 06:07:09 -07:00
esengine 002b09a921 fix(delegation): the dispatch profile stays the call's selection; the actual model rides the status events
#13812 made the dispatch-time profile the concrete runtime. The effort-switch audit
test (merged beside it) holds that profile to what the call selected (empty model and
effort when it inherits), so studio went red. The selection is audited as the model
asked it; what the child ran on is on its status events, which the window already
reads, so the dispatch profile goes back to the selection.
2026-10-18 06:05:44 -07:00
SivanCola db1401cb5b merge: preserve remote composition selection with current Studio
Problem: Studio's shared remote-open lifecycle conflicts with the composition
callback, and the HTTP adapter omits the selected composition from its body.

Root cause: composition selection was carried by an App-local path rather than
the shared open-ordering owner and the transport serialization boundary.

Fix: merge exact Studio 5b1c282fed, extend
useOpenRemotePane and SseHub with the selection, preserve newest-open ordering,
duplicate-click coalescing and refusal cleanup, and keep editor styles with
their existing catalog stylesheet without widening the shared CSS budget.
Align the child-effort effect assertion with Studio's concrete dispatch model
and effort reporting while retaining provider, refusal and transcript checks.
Keep Standard/Minimal cards, the mode-only left trigger and right run state,
default-off semantics and all existing source/workspace authorization gates.

Verification: failing-first remote Hook/HTTP body regressions; format, vet,
lint, make check, full root and required builtin/boot tests; affected package
and focused boot races; Desktop/SDK Go; frontend typecheck, 689 files/5598 tests
and build; census fixtures; browser eight and Electron renderer four scenes;
Electron unit suite (176 pass, one existing skip). Local review/security review
does not replace maintainer approval or hosted CI.
2026-10-18 21:05:02 +08:00
esengine 9111f39d97 Merge remote-tracking branch 'origin/a5/schedule-api' into a5/schedule-api 2026-10-18 06:04:05 -07:00
esengine 7f21f635ab feat(tools): turn tools off by name, one registry removal for every surface
[tools].disabled removes a tool from every request and refuses a call to it with
the reason; tools another feature requires declare so and stay on with a note;
a project file can only add names. Settings lists every tool with a switch.
2026-10-18 06:03:20 -07:00
esengine aa90f02c8a Merge origin/studio into a5/schedule-api 2026-10-18 06:03:02 -07:00
YHH 1599703966 Merge pull request #13815 from esengine/feat/13111-origin-ask-ui
feat(studio): a settings switch for asking about text copied from outside
2026-10-18 06:02:08 -07:00
YHH bda7ee6772 Merge pull request #13816 from esengine/a9/readme-dsh
docs: point to the DeepSeek Harness integration from the README and the guide
2026-10-18 05:53:30 -07:00
YHH 7df0c74c55 Merge pull request #13807 from esengine/dependabot/npm_and_yarn/desktop/electron/npm-76902424de
chore(deps-dev): bump the npm group in /desktop/electron with 2 updates
2026-10-18 05:53:07 -07:00
YHH 63cfcee555 Merge pull request #13799 from esengine/dependabot/go_modules/desktop/go-1d423e97b0
chore(deps): bump golang.org/x/mod from 0.41.0 to 0.42.0 in /desktop in the go group
2026-10-18 05:52:35 -07:00
YHH a151994365 Merge pull request #13798 from chuiwenwei/fix/compaction-stale-context-not-latched
fix(agent): release a stale-context maintenance block on the growth that caused it
2026-10-18 05:52:16 -07:00
YHH 5b1c282fed Merge pull request #13812 from esengine/feat/13640-child-model
feat(delegation): sub-agents show the model and effort they ran on
2026-10-18 05:47:23 -07:00
YHH 1b0b593f66 Merge pull request #13813 from esengine/a5/search-model-not-added
fix(settings): do not offer search models from connections that were never added
2026-10-18 05:44:33 -07:00
esengine edbe588256 docs: point to the DeepSeek Harness integration from the README and the guide
The integration lives in docs/DSH.md; the README and the guide now say it
exists and give the three steps and the index address in the README.
2026-10-18 05:43:50 -07:00
YHH ed9a4ad1c9 Merge pull request #13778 from esengine/feat/13694-mod-rulings
feat(extension): a mod answers a tool call, adds turn context, reads the session and queues a prompt
2026-10-18 05:42:40 -07:00
YHH 31d39290ec Merge pull request #13808 from esengine/dependabot/npm_and_yarn/desktop/frontend-next/npm-8a0a74f25b
chore(deps): bump the npm group in /desktop/frontend-next with 7 updates
2026-10-18 05:30:14 -07:00
YHH 245b85ec13 Merge pull request #13638 from SivanCola/codex/unified-project-sidebar
Unify local and SSH project navigation / 统一本机与 SSH 项目侧栏
2026-10-18 05:29:51 -07:00
esengine 7533a016b2 feat(studio): a settings switch for asking about text copied from outside 2026-10-18 05:27:12 -07:00
YHH f43d985bda Merge pull request #13797 from chuiwenwei/fix/memory-finish-interrupted-rename
fix(memory): repair a rename interrupted after retiring the old file
2026-10-18 05:22:13 -07:00
esengine 0556027a94 fix(studio): a refused ! command stays selected in the box so the next message replaces it 2026-10-18 05:18:25 -07:00
esengine 44d3acfaf5 fix(desktop): the row keeps the model name and nothing in front of or behind it 2026-10-18 05:18:06 -07:00
esengine 8f691039d3 fix(desktop): the sub-agent row gives way on the provider, not the model name 2026-10-18 05:16:44 -07:00
esengine bcd54401f5 docs(release): shorten the search-model note 2026-10-18 05:09:12 -07:00
esengine 2f3ef27558 fix(settings): do not offer search models from connections that were never added 2026-10-18 05:04:45 -07:00
esengine e2c0aeb1a2 refactor(delegation): keep the child identity record in its own file 2026-10-18 05:00:56 -07:00
esengine b5c891a593 feat(desktop): sub-agents show the model and effort they ran on
The panel row, the full-record header and the nested card of the spawning call show
provider/model · effort, with the full reference as the title. The card takes it
from the child's status events, so a failed or interrupted run keeps it; a batch's
items each carry their own.
2026-10-18 05:00:56 -07:00
esengine 88982d21ad feat(delegation): every status of a child names the model and effort it ran on
The dispatch profile now names the concrete runtime a call resolves to (an alias
expanded, the parent's model when the call inherits), and each child's status events,
from queued to the terminal one, carry what its run actually started with, per item
for a batch. A failed or interrupted child therefore still says what it ran on.
2026-10-18 05:00:56 -07:00
dependabot[bot] 6e91c80dac chore(deps): bump the npm group in /desktop/frontend-next with 7 updates
Bumps the npm group in /desktop/frontend-next with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.3.0` | `19.3.1` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.3.0` | `19.3.1` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.7` | `14.6.8` |
| [jsdom](https://github.com/jsdom/jsdom) | `30.1.2` | `30.1.3` |
| [playwright](https://github.com/microsoft/playwright) | `1.64.0` | `1.64.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.3` | `8.3.4` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.3` | `5.0.4` |


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

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

Updates `@testing-library/user-event` from 14.6.7 to 14.6.8
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.7...v14.6.8)

Updates `jsdom` from 30.1.2 to 30.1.3
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v30.1.2...v30.1.3)

Updates `playwright` from 1.64.0 to 1.64.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.64.0...v1.64.1)

Updates `vite` from 8.3.3 to 8.3.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.4/packages/vite)

Updates `vitest` from 5.0.3 to 5.0.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.4/packages/vitest)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: react-dom
  dependency-version: 19.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: jsdom
  dependency-version: 30.1.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: playwright
  dependency-version: 1.64.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: vite
  dependency-version: 8.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: vitest
  dependency-version: 5.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-18 12:00:23 +00:00
dependabot[bot] 6509535600 chore(deps-dev): bump the npm group in /desktop/electron with 2 updates
Bumps the npm group in /desktop/electron with 2 updates: [electron](https://github.com/electron/electron) and [playwright](https://github.com/microsoft/playwright).


Updates `electron` from 44.7.0 to 44.7.2
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v44.7.0...v44.7.2)

Updates `playwright` from 1.64.0 to 1.64.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.64.0...v1.64.1)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 44.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: playwright
  dependency-version: 1.64.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-18 12:00:17 +00:00
YHH c70464535b Merge pull request #13793 from esengine/feat/13780-level-notice
feat(feedback): tell the person once when their level goes up, if they asked
2026-10-18 04:57:50 -07:00
dependabot[bot] 0cf0d10c2a chore(deps): bump the actions group with 2 updates
Bumps the actions group with 2 updates: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.38.3 to 4.39.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee)

Updates `github/codeql-action/analyze` from 4.38.3 to 4.39.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/24c54180a607b1449ed407dd24f251e4e9147c8d...6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-18 11:56:46 +00:00
YHH 54f4f5a8b6 Merge pull request #13787 from esengine/a9/peer-release-idle
fix(peers): the text of a message from another session is not read for file references
2026-10-18 04:53:09 -07:00
dependabot[bot] 68d3238e98 chore(deps): bump golang.org/x/mod in /desktop in the go group
Bumps the go group in /desktop with 1 update: [golang.org/x/mod](https://github.com/golang/mod).


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

---
updated-dependencies:
- dependency-name: golang.org/x/mod
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-18 11:52:28 +00:00
YHH c88d1715d4 Merge pull request #13790 from esengine/fix/terminal-feedback-surface
fix(cli): name the terminal's feedback surface in its first build so /feedback works from the start
2026-10-18 04:49:39 -07:00
YHH 8078d62b03 Merge pull request #13789 from esengine/a9/dsh-enable-step
docs(dsh): an installed plugin starts off and must be switched on
2026-10-18 04:43:20 -07:00
Will 944c61f9e6 test(memory): cover the interrupted-rename pairing and name-conflict edges
Two controls the audit's contract implies but nothing pinned: a staged copy whose
retired twin came from a different publish is left untouched (no pair, no writes),
and a fact created at the old name after the kill is never overwritten — the retry
refuses and keeps both artifacts for a later attempt. The refusal only named the
conflict once the exist check unwrapped the error, so it now uses
errors.Is(err, fs.ErrExist).
2026-10-18 19:42:39 +08:00
YHH 3f98212608 Merge pull request #13246 from chuiwenwei/fix/sessioninbox-unreadable-blobs-not-empty
fix(sessioninbox): stop publishing an empty queue when the bodies cannot be read
2026-10-18 04:42:33 -07:00
esengine 37892a1738 test(release): the artifact check requires the AppImage for both architectures 2026-10-18 04:40:24 -07:00
esengine 32c09fc148 feat: the origin question has its own card class, and names every origin it repeats
The approval carries the reason code origin_text, so Studio words it in the reader's
language, hides the full-access button (which cannot answer it), and shows the call
whole; the terminal wraps the subject and reason instead of clipping them; ACP
gets a fresh allow-once or reject. The label names up to three origins, then counts
the rest. The guide says headless YOLO refuses such a call.
2026-10-18 04:36:26 -07:00
YHH 50161c6b31 Merge pull request #13759 from esengine/a5/schedule-ui
feat(desktop): the scheduled runs screen
2026-10-18 04:30:52 -07:00
YHH 2ce0c3f97a Merge pull request #13777 from esengine/a5/wsl-cli
feat(cli): add, test and connect a WSL machine from the command line
2026-10-18 04:29:46 -07:00
esengine 29220a5c18 Merge origin/studio into a5/push-host 2026-10-18 04:26:23 -07:00
esengine ffa3d3df29 feat(desktop): mount the phone notification block under Remote settings 2026-10-18 04:25:25 -07:00
esengine 9e5c144b40 feat(control): the origin switch rides the permission rules save
PermissionLists carries askOnExternalOrigin; a save without it leaves it as it is,
the rules endpoint's rebuild applies it, and it keeps the endpoint's provider-edit
grant. The desktop type and mock mirror it.
2026-10-18 04:25:04 -07:00
Will 13938149d5 fix(agent): release the stale-context block on the growth that caused it
A fold that loses the transcript race records FailContextChanged, but no later
turn could release it: retriesOnGrowth did not name the code, the receipt holds
until the generation advances, and the generation only advances on a commit the
held receipt prevents. One racing append then latched automatic compaction off
for the rest of the session, across restarts, and the next overflow failed the
turn instead of folding.

Name the code in retriesOnGrowth so the append that made the fold stale is the
growth that releases the receipt. The receipt already carries InputHash and
InputTokens, and every other code keeps its hold. A plan/provider refusal still
holds, and the retry buys exactly one fold: without further growth the next
prepare neither folds again nor re-records the block.
2026-10-18 19:24:01 +08:00
Will 406bf96f1e test(memory): pin that an interrupted rename restores the old revision first
The recovery may either finish the rename forward or roll it back. Only rollback
keeps an ExpectedRevision from before the rename valid, so the choice is a
contract, not an implementation detail; forcing the forward path makes this test
fail with name=beta revision=2.
2026-10-18 19:21:09 +08:00
Will 3eadd288f9 fix(memory): repair a rename interrupted after retiring the old file
publishRename retires the old fact file, flushes the index, then publishes
the staged new file. A kill in that window leaves the fact's only copies as
hidden .retired-* and .staged-* files: Read/List/Revisions report nothing,
the save retry by id reports "memory id not found", and a retry by name
mints a second identity for the same name.

A save by id that finds no active file now looks for the hidden pair a
single publishRename call writes, matched by the nanosecond stamp in the
two file names, with the staged copy's frontmatter carrying that id. The
rule is disk-driven and rollback-first: readable retired bytes are restored
under the old name (same bytes and mode) and reindexed, so the caller's
expected revision still names the fact and the retried save redoes the
rename itself. Only when the retired copy cannot be parsed does recovery
publish the staged copy forward under its new name. With no matching pair
nothing is written and the id is still reported not found, so a retired
copy left by a committed rename is not revived.

Covered by the audit's kill probe, an uninterrupted-rename control, a name
retry after recovery, a failed (not killed) publish, an unpaired-artifact
guard, and a cross-scope move whose pair spans two directories.
2026-10-18 19:19:35 +08:00
esengine 9de84cee74 style: modernize the new tests 2026-10-18 04:17:47 -07:00
esengine 8ddda45b60 style(desktop): a shorter line for the queued-prompt row of the mod preview 2026-10-18 04:17:45 -07:00
esengine 51eab7c487 fix(permission): show the copied call on one line, controls removed 2026-10-18 04:14:49 -07:00
esengine 62d64b2eb7 style(boot): new(expr) in the origin test 2026-10-18 04:14:49 -07:00
esengine 38d5748406 style: keep render.go and permission.go inside their size budgets 2026-10-18 04:14:49 -07:00
esengine 4fb505dedf feat(permission): ask a person when a call repeats text an external result gave the task
With permissions.ask_on_external_origin on (user file only, off by default), a call
that is not a read and whose arguments repeat, byte for byte, text the task's
external results gave it is put to a person as a fresh approval when only the
writer fallback (Auto, YOLO) would have let it run. Explicit allow rules, session
allows, reads and authorized MCP servers are untouched; with nobody to ask the call
is refused naming the origin.
2026-10-18 04:14:49 -07:00
esengine 9bb7a469d2 refactor(config): render the notifications section from its own file 2026-10-18 04:12:50 -07:00
esengine 319f079a63 feat(host): tell enrolled phones that something is waiting, and manage them from the desktop 2026-10-18 04:11:40 -07:00
esengine 1f285edf60 feat(desktop): the mod preview lists reading the session and queueing prompts separately 2026-10-18 04:08:35 -07:00
YHH 81e0438de3 Merge pull request #13783 from esengine/feat/13111-origin-record
feat(agent): count a call whose arguments copy text an external result gave the task
2026-10-18 04:08:06 -07:00
esengine 8dec43e259 Merge remote-tracking branch 'origin/studio' into a5/schedule-api
# Conflicts:
#	internal/frontend/cli/schedule_exec_test.go
2026-10-18 04:07:56 -07:00
esengine db0685a360 fix(schedule): a project that is gone is not listed, so every path outside the list gets the same refusal 2026-10-18 04:06:50 -07:00
YHH 4d094ce1f9 Merge pull request #13781 from esengine/a5/wsl-ui
feat(desktop): add a WSL distribution as a remote machine from Settings
2026-10-18 04:04:26 -07:00
YHH 66d144dd90 Merge pull request #13779 from esengine/a5/sched-pkg
refactor(schedule): run the scheduled child from a small package the Studio host can import without the command line
2026-10-18 04:03:00 -07:00
Will a88c474cbd i18n: carry the unrecovered wording in the embedded catalogues
Review follow-up after rebasing onto studio: messages_en/zh/zh_tw.go sit at the
800-line ceiling, so the two new strings move into the embedded QueueMessages and
NotifyMessages catalogues, which have their own files. No call site changes.
2026-10-18 19:02:12 +08:00
esengine 0c000c7ad4 feat(feedback): tell the person once when their level goes up, if they asked 2026-10-18 04:01:54 -07:00
esengine 2c8eb49a14 test(modhost): the byte-ceiling refusal reaches the mod as its own code 2026-10-18 04:01:29 -07:00
esengine 2a9743d790 feat(extension): split the session capability, and bound one queued prompt
session.read and session.submit are separate capabilities, so a mod that only
reads usage cannot put words in front of the model. One prompt a mod queues is
bounded by [mods] submit_bytes (8 KiB, 0 for none) and refused with a typed
identity that does not spend the per-turn count. The docs say the turn a queued
prompt starts is held to this session's own approvals and sandbox.
2026-10-18 04:01:29 -07:00
esengine 96f261b398 docs(mods): the receipt gap for a running mod, in the docs and in matching English 2026-10-18 04:01:28 -07:00
esengine 4af9d05744 docs(extension): keep the new prose and comments inside the repo's budgets 2026-10-18 04:01:28 -07:00
esengine 336cbc3c22 chore(extension): regenerate the protocol artifacts for the prompt refusal identity 2026-10-18 04:01:28 -07:00
esengine f28360eadc feat(extension): a mod reads the session and queues a prompt, and settings bound what it may add 2026-10-18 04:01:28 -07:00
esengine 49abf8c521 feat(extension): an extension may add context blocks to the turn tail, bounded by the user's mod ceilings 2026-10-18 04:01:28 -07:00
esengine f26e1c1359 feat(extension): a block at tool.before may carry an answer that stands in for the tool's output 2026-10-18 04:01:28 -07:00
SivanCola 965f331798 test(studio): align browser guards with unified project rows
Problem: Studio browser guards failed on 17f816d11 when the ordering guard
counted SSH rows as conversations in its local project. The remaining quick
action fixture also expected two trailing Archive/Delete buttons.

Root cause: Browser acceptance scripts still encoded the former separate
machine lists, trailing Delete action and individual folding chevrons. The
English wording scan also lacked a newly visible demo conversation title.

Fix: Scope ordering to the named local project and independently assert SSH
rows and keyboard focus are preserved. Exercise default-off and explicit
quick-action choices for local and remote rows, including hit targets,
leading Pin/trailing Archive geometry and menu access. Load the real row
stylesheet in the compact fixture and match its existing 1px spacing while
retaining 34px rows, ellipsis and contained controls. Update manual tree
checks to use project-row folding and five-item previews.
Recognize the existing demo title as user content in the wording fixture list.

Verification: Reproduced the ordering, quick-action and fixture failures
before the repair. Ran all 55 CI browser-guard commands against the built
perf harness and reran repaired guards, the affected manual tree scripts,
gofmt, root vet, make lint,
make check and the required builtin/boot suites. This changes acceptance
scripts only; production UI, workflows and budgets are unchanged.
2026-10-18 18:59:22 +08:00
Will 51db70d463 i18n(desktop): translate inbox.unrecovered 2026-10-18 18:57:25 +08:00
Will d91111647b fix(sessioninbox): give the unrecovered refusal an identity and stop racing the sink
The reviewer's blocking item: rebindInbox (called from New) started a goroutine
that read c.sink while New was writing it. Capture the sink before starting the
goroutine, for the new unrecovered notice and for the pre-existing recovered one
(it had the same shape and the same race).

Also give the refusal an identity: ErrUnrecovered, wrapped by commitManifestLocked,
with a case in the serve switch so the frontend gets inbox.unrecovered instead of an
internal fault. repolint's refusal-path rule requires that case.
2026-10-18 18:57:20 +08:00
Will 50a235f0f7 fix(sessioninbox): show an unreadable queue as unknown instead of empty
The store already told a caller that the queue's bodies could not be read
(InboxSnapshot.Unrecovered), but nothing user-facing consumed it: the TUI's
/queue line and the notice stream both dropped the field, so an unreadable
queue still read as "inbox empty" and the user could not tell it from a
genuinely empty one.

The TUI mirror and renderQueueList now carry and say the unknown state, and
the controller emits a distinct inbox_unrecovered warn notice (with an
InboxUnrecovered payload naming the queue directory) beside the existing
inbox_recovered one, so its sentence is not confused with "recovered N".

Cause: the frontend contract field had no consumer on the TUI or notice
surfaces.
Blast radius: TUI /queue line, TUI and desktop notice wording, and the two
controller inbox open paths; additive field, code and payload, no on-disk
schema change, recovered behaviour unchanged.
Neighbouring behaviours tested: empty-but-readable queue, recovered salvage
count, TUI mirror JSON decoding of the real GET /inbox body, and both the
serve and control boundaries.
Why this layer: the store owns "unknown vs empty"; the surfaces that render
a queue are where a user can act on it.

Provider-visible prefix: none
2026-10-18 18:57:20 +08:00
Will dc409fc506 fix(sessioninbox): stop publishing an empty queue when salvage cannot read bodies
salvageOrphanBlobsLocked returned nil for both "no blobs" and "blobs
unreadable", so a corrupt manifest plus an unreadable blobs directory
committed an empty paused manifest (recovered=true, recoveredN=0,
items=null) over the quarantined one. The queued bodies stayed on disk but
were unreachable, the RecoveredN>0 notice never fired, and the queue looked
empty.

The salvage now reports the read failure, and that failure is not turned
into a manifest: the corrupt bytes stay in place as evidence, the store
comes up paused with unrecovered=true on the snapshot, and no commit can
overwrite the evidence while the bodies are unreadable. An absent or empty
blobs directory is still an empty queue, not a failure.

Cause: (items, err) collapsed to (nil, nil) for directory-level and
per-entry read errors.
Blast radius: sessioninbox Open/Snapshot plus the commit path; new
additive InboxSnapshot.Unrecovered field, no on-disk schema change.
Neighbouring behaviours tested: readable-dir salvage, absent/empty blobs
dir, healthy manifest untouched, symlink blob refusal.
Why this layer: the store owns the manifest/blob transaction, so the
"unknown vs empty" distinction belongs where the read fails.
2026-10-18 18:57:20 +08:00
esengine 9e0c35100c docs(dsh): say that an installed plugin starts off and must be switched on
Found while demonstrating the install through Discover: until the switch and
both components read Running there is no reasonix tool, and the model falls
back to running reasonix -p in its own shell, which only looks like a
delegation.
2026-10-18 03:55:34 -07:00
esengine 5910c8ae10 fix(peers): the text of a message from another session is not read for file references
An @token in a peer message or a background-job notice was frozen into the
prompt as a file reference, so a sender could make the receiver read a file
into its prompt, and an unreadable one paused the queue behind it. Items the
host authored are data and are no longer scanned.
2026-10-18 03:53:52 -07:00
YHH 08f01f2d79 Merge pull request #13776 from esengine/feat/mcp-panel
feat(tui): add and remove MCP servers from the /mcp panel
2026-10-18 03:52:00 -07:00
esengine d226841381 fix(cli): name the terminal's feedback surface in its first build so /feedback works from the start 2026-10-18 03:48:37 -07:00
esengine e32eaee455 Merge remote-tracking branch 'origin/studio' into a5/schedule-api
# Conflicts:
#	desktop/frontend-next/src/port/port.ts
2026-10-18 03:36:45 -07:00
SivanCola d5cee35e81 merge: preserve composition authority across current Studio session moves
Problem: current Studio conflicts in the state-root inventory and adds a move
path that strips the system message containing an immutable composition binding.
That could silently reopen a bound conversation with ordinary capabilities.

Root cause: cross-project adoption intentionally omits project instructions and
permissions, but the new mover did not distinguish immutable composition metadata
from an ordinary system prompt.

Fix: normally merge exact Studio0363720bd. Retain both rules and agent-presets
state roots. Refuse bound conversations in the common MoveSession owner before
copy/delete, preserve the source, and project the typed refusal with bilingual
HTTP wording. Keep ordinary unbound moves, ACP authentication/new-conversation
contracts, directory grants, exact dispatch inheritance and the accepted mode UI.
The preceding source-authority fix remains intact.

Verification: failing-first store and HTTP move tests now refuse blank/started
bound moves and retain the original. All226 root packages pass: mandatory2 plus
the inventoried other224; boot340.765s. Affected race suites pass. The full boot
race invocation previously reached its10minute aggregate limit; final evidence
uses one compiled source snapshot and four disjoint groups covering all753
tests exactly once, without raising the timeout. Static gates, Desktop/SDK,
frontend675files5489tests/typecheck/build/census and browser8/native4 scenes pass.
Local review/security review recorded separately from required maintainer review.
2026-10-18 18:34:12 +08:00
esengine 1b556ddbc8 feat(cli): refuse every remote subcommand that changes the book or opens a link when an agent tool started it 2026-10-18 03:33:32 -07:00
esengine 76e579923f fix(release): name the AppImage explicitly and make the verifier say what it wanted 2026-10-18 03:31:48 -07:00
esengine 97e4675986 Merge remote-tracking branch 'origin/studio' into a5/wsl-cli 2026-10-18 03:31:04 -07:00
esengine dd6b73380d Merge remote-tracking branch 'origin/studio' into a5/sched-pkg 2026-10-18 03:30:59 -07:00
esengine c4fa9c73f7 fix(schedule): the scheduled child drains the usage catalogs itself, with a test through the host's entry 2026-10-18 03:30:57 -07:00
esengine 3da8e6b23e test(agent): the origin ledger is on the restarted side of a new task 2026-10-18 03:30:51 -07:00
esengine 069e5b54f3 style(agent): use slices.Contains for the origin list 2026-10-18 03:30:51 -07:00
esengine cbeed94933 style(agent): keep execute_one and run_loop at the size ceiling 2026-10-18 03:30:51 -07:00
esengine 1de856a0f6 feat(agent): count a call whose arguments copy text an external result gave the task
The host keeps the text of a task's external results and counts, per turn, how
many were recorded and how many later non-read-only calls carried a verbatim
string from one. The numbers ride the completion-report audit; nothing is
blocked or shown to the model.
2026-10-18 03:30:51 -07:00
esengine d136ac2828 feat(release): build and verify an AppImage of Studio on Linux (download only) 2026-10-18 03:25:38 -07:00
YHH edcbc47c06 Merge pull request #13764 from esengine/feat/13694-mod-ui-3
feat(studio): Claude Code mods in the window: install preview, the band above the prompt, mod questions (settings block hidden)
2026-10-18 03:23:05 -07:00
SivanCola 17f816d11c Merge studio WSL transport and cover remote host boundaries
Problem: Review requested WSL boundary evidence and removal of the unrelated
design-qa.md diff after studio introduced non-SSH machines.

Root cause: The new WSL transport supersedes a blanket rejection at shared
remote endpoints. SSH construction must still reject WSL entries, while
generic probe, browse and open must select their configured transport.
The earlier QA cleanup also deleted a document already owned by studio.

Fix: Integrate studio through 0363720bd and cover the real Hub/remotehost/pool
route with disposable WSL and SSH factories. Assert WSL failures survive the
HTTP boundary and never construct an SSH client. Cover Windows, distribution,
empty and fallback default paths. Restore design-qa.md byte-for-byte from
studio so it no longer appears in the PR diff. Retain the upstream SSH form's
refusal to overwrite non-SSH entries.

Verification: Frontend 5529 tests and production build; full serve,
remotehost, remote, attach, wslconn, config, builtin and boot suites; gofmt,
root vet, make lint and make check. No live Windows/WSL qualification claimed.
2026-10-18 18:21:13 +08:00
SivanCola 643902d35a Merge studio project moves with host-qualified sidebar actions
Problem: PR #13638 became conflicting after studio introduced moving a
conversation to another project.

Root cause: HubPort acquired moveSessionTo beside the archive declaration
that this branch extends with an optional host. Automatic merging also
omitted the errors import needed by the upstream WSL edit guard.

Fix: Keep both capabilities and restore the required import. Retain the
upstream local-only move workflow and the host-qualified pin/archive routes.
Remove an inaccurate rename comment so the combined component stays within
its existing size ceiling without widening the baseline.

Verification: Frontend suite (5529 tests), production build, full serve,
sessionstore, config, builtin and boot tests; gofmt, root vet, make lint and
make check. Transport coverage exercises local project moves beside remote
flags; an interaction regression covers the shared keyboard menu and dialog.
An isolated development browser confirms menu access, project selection and
cancel with hover controls off. Hosted CI remains a separate observation.
2026-10-18 18:13:32 +08:00
esengine 8aa66a6521 Merge remote-tracking branch 'origin/studio' into a5/wsl-ui 2026-10-18 03:11:48 -07:00
esengine 447cc6ae39 ci: run checks against studio 2026-10-18 03:11:43 -07:00
esengine b67222e1a3 Merge remote-tracking branch 'origin/studio' into a5/wsl-cli 2026-10-18 03:11:43 -07:00
esengine 75d8b75051 refactor(schedule): the scheduled child runs from a small package the Studio host can import without the command line 2026-10-18 03:09:47 -07:00
YHH 0363720bd2 Merge pull request #13767 from esengine/a5/wsl-link
feat(remote): bring up a kernel in a WSL distribution and reach it over loopback
2026-10-18 03:08:05 -07:00
YHH 6693788cc4 Merge pull request #13771 from esengine/a9/dsh-integration-doc
feat(integrations): run Reasonix as a DeepSeek Harness subagent, with docs, a plugin package and a market index
2026-10-18 03:05:54 -07:00
YHH bc9d7a16ad Merge pull request #13773 from esengine/feat/13694-mod-receipt
feat(receipt): say when a Claude Code mod's effects were not observed
2026-10-18 03:03:11 -07:00
YHH 5375b9c8f8 Merge pull request #13772 from esengine/feat/12025-thread-badge
feat(feedback): show the reporter's level badge beside their replies in a thread
2026-10-18 03:01:01 -07:00
esengine 60938d36d9 feat(cli): add, test and connect a WSL machine from the command line 2026-10-18 03:00:14 -07:00
esengine d3139f5a4f fix(studio): the mod preview promises no re-confirmation the kernel does not enforce 2026-10-18 03:00:13 -07:00
esengine cebdb37431 fix(studio): the mock mods settings say the switch is not available yet 2026-10-18 03:00:12 -07:00
esengine 8951095309 test(desktop): settings search leaves out the hidden mod entry 2026-10-18 03:00:12 -07:00
esengine 01e775312e feat(studio): keep the mod host's settings out of sight until the kernel allows it 2026-10-18 03:00:12 -07:00
esengine 2cf8510775 test(desktop): the mod UI guard, release note and the strip's focus-ring room 2026-10-18 03:00:12 -07:00
esengine e2e642a488 feat(studio): the band above the prompt, mod questions with their source, and a button with no action id does nothing 2026-10-18 03:00:12 -07:00
esengine 61d2cfd5a8 feat(studio): the mod block in the install preview: full trust, static listing, hidden imports and not-previewed 2026-10-18 03:00:11 -07:00
esengine 721b61eb40 feat(studio): the Claude Code mod block in settings: switch, hook time limit and each mod's state by code 2026-10-18 03:00:11 -07:00
esengine 70d164489f feat(tui): add and remove MCP servers from the /mcp panel 2026-10-18 02:59:58 -07:00
YHH 598de997fa Merge pull request #13770 from esengine/feat/12073-union-list
feat(feedback): show linked devices' reports in My feedback and link from the terminal
2026-10-18 02:53:06 -07:00
esengine f52a7fa8c7 feat(desktop): choose a scheduled run's folder from the trusted projects 2026-10-18 02:51:38 -07:00
esengine e35b0f7f59 Merge remote-tracking branch 'origin/a5/schedule-api' into a5/schedule-ui 2026-10-18 02:50:23 -07:00
esengine 09f040098e Merge remote-tracking branch 'origin/studio' into a5/schedule-api
# Conflicts:
#	desktop/frontend-next/src/port/mock_boundary.ts
#	desktop/frontend-next/src/port/port.ts
#	desktop/frontend-next/vite.config.ts
2026-10-18 02:50:15 -07:00
esengine e05b98e35a fix(schedule): a schedule reads only a trusted project, compared as text and checked again at every run
The folder in a draft came from the request and went straight to Abs, EvalSymlinks
and Stat. It is now matched, as text, against the remembered projects the person
trusted (scheduler.Folders), and everything after the match uses the list's own
spelling. Anything else is refused as schedule.workspace_unknown, in the kernel,
the CLI and the board's folder list. A run re-checks trust before it starts.
2026-10-18 02:49:44 -07:00
YHH dcd4632f8f Merge pull request #13739 from esengine/feat/13693-subagent-steer
feat(delegation): guide a running sub-agent through task_id or from the window
2026-10-18 02:48:06 -07:00
SivanCola 78e164b54c fix(composition): refuse project-controlled launch overrides
Problem: selecting a repository preset could change an already authorized MCP
server's package arguments or destination and reuse its executable authority or
credentials. The same provenance issue applies to extension args and env.

Root cause: composition parameter validation checked shape and credential
references but did not distinguish repository declarations from personal ones.
Pinning an executable does not authorize the program selected by its arguments.

Fix: enforce source authority in the shared contract. Project MCP/extension
parameters allow only supported timeouts. Validate declarations, imports, old
revisions, and both definition/resolved halves of retained executable bindings.
Keep structural history validation separate so legacy unsafe bindings remain
readable without regaining execution. Align Mock save/import, document the source
table in both languages, and add the release note. Preserve Standard/Minimal,
unbound defaults, persona, additional directories and exact dispatch bindings.

Verification: failing-first boot tests prove changed args start a subprocess and
changed URL contacts another endpoint before the fix; both now refuse before
startup. User positive controls, scope/parameter table, store/revision/old-record
and event-log history tests pass. Format/vet/lint/makecheck, required builtin/boot,
full root, contract/store/sessionstore/serve/ACP race, Desktop/SDK Go and frontend typecheck/build pass;
671 frontend files / 5472 tests pass. Local review/security review recorded;
maintainer approval and new-head hosted CI remain separate requirements.
The full boot race invocation reached its existing aggregate 10-minute limit
as another test started; disjoint test-list groups are used to finish the whole
inventory without raising that timeout or skipping tests.
2026-10-18 17:42:48 +08:00
esengine 84e70760b7 feat(feedback): show the reporter's level badge beside their replies in a thread 2026-10-18 02:31:55 -07:00
YHH 4014c87a38 Merge pull request #13768 from esengine/feat/11995-sync-close
feat(feedback): close the mirror issue of a withdrawn report
2026-10-18 02:31:46 -07:00
esengine f3bcb117f7 Merge remote-tracking branch 'origin/studio' into a5/wsl-ui 2026-10-18 02:31:25 -07:00
esengine 4f80c12e2b Merge branch 'a5/wsl-link' into a5/wsl-ui 2026-10-18 02:31:25 -07:00
esengine 8b58ce9eed feat(desktop): add a WSL distribution as a remote machine from Settings 2026-10-18 02:31:25 -07:00
esengine f02ef7d620 feat(integrations): DeepSeek Harness plugin market index for the Reasonix bundle
The index pins the commit that last changed the package; scripts/dsh-market-index.mjs
pins it and validates it with DSH's own validator. docs/DSH.md explains Discover
and the Studio release runbook gains a precondition that keeps the pin current.
2026-10-18 02:30:46 -07:00
esengine 79430cf8cf feat(integrations): website, author and doc links on the DeepSeek Harness bundle 2026-10-18 02:30:29 -07:00
esengine e22b515af1 feat(integrations): DeepSeek Harness plugin market index for the Reasonix bundle
A format-1 index pinned to the commit that last changed the package, and
scripts/dsh-market-index.mjs to pin and validate it with DSH's own validator.
2026-10-18 02:30:29 -07:00
esengine 8ba8c1cf72 feat(integrations): a ready-made DeepSeek Harness bundle that runs Reasonix as a subagent
A configuration-only package (no code) in integrations/dsh-plugin, installed
from this repository's subdirectory with pnpm's path: selector, plus the
integration guide docs/DSH.md.
2026-10-18 02:30:29 -07:00
esengine 34eb44fcd0 docs: using Reasonix from DeepSeek Harness as an ACP subagent
How to wire dsh's subagent-acp provider to reasonix acp, what each permission
policy does to Reasonix, what the parent sees, and the limits, verified against
dsh 0.2.1-rc.2.
2026-10-18 02:30:29 -07:00
esengine 957bdaa323 chore(i18n): keep the message tables at their size ceiling 2026-10-18 02:27:40 -07:00
esengine 2f28235ab6 feat(receipt): say that a Claude Code mod's effects were not observed, and count a live mod as an unseen writer 2026-10-18 02:27:40 -07:00
YHH 7c56994e82 Merge pull request #13705 from esengine/a9/acp-auth-required
fix(acp): a session that cannot open for want of credentials is the authentication-required error
2026-10-18 02:26:40 -07:00
esengine 84d16a1508 Merge remote-tracking branch 'origin/studio' into a5/wsl-link 2026-10-18 02:24:49 -07:00
esengine 36044d1515 style(remote): match exit errors with errors.As in the Linux-only tests 2026-10-18 02:24:47 -07:00
YHH 1a11a6691d Merge pull request #13766 from esengine/a9/rules-dir
feat(rules): rules directories, delivered on the turn and scoped by paths
2026-10-18 02:20:33 -07:00
esengine 49e2a036e6 ci: run checks against studio 2026-10-18 02:19:16 -07:00
esengine 221061b759 Merge remote-tracking branch 'origin/studio' into a5/schedule-api
# Conflicts:
#	desktop/frontend-next/src/i18n/en.ts
#	desktop/frontend-next/src/i18n/kernel.ts
#	desktop/frontend-next/src/port/mock_boundary.ts
#	desktop/frontend-next/src/port/port.ts
#	desktop/frontend-next/vite.config.ts
#	internal/frontend/serve/routes.go
#	internal/session/control/port.go
#	tools/repolint/wireparity.go
2026-10-18 02:19:16 -07:00
esengine 119f392248 feat(delegation): guide a running sub-agent through task_id or from the window 2026-10-18 02:17:23 -07:00
YHH d4d7ef809e Merge pull request #13752 from esengine/a5/schedule-ticker
feat(schedule): the Studio host runs the ticker while supervised and visible
2026-10-18 02:16:16 -07:00
esengine 74b4c5d21b feat(feedback): show linked devices' reports in My feedback and link from the terminal 2026-10-18 02:15:42 -07:00
SivanCola f975d35816 merge: preserve session bindings across current Studio entry points
Problem: newer Studio changes conflict with the ACP session contract and HTTP
runtime opening after the previous assembly integration.

Root cause: Studio adds a new-conversation execution-mode flag where this branch
also owns explicit composition selection and immutable inherited bindings.

Fix: normally merge Studio 4d3c09211. Add NewConversation to the one SessionParams
definition and retain it in HTTP build options. Resolve moved transcript paths
before reading either branch settings or the original composition. Keep exact
dispatch inheritance, workspace refusal, candidate rollback, ACP directories and
the accepted Standard/Minimal composer and management design.

Verification: format, vet, lint, make check, mandatory builtin/boot, full root
Go, Desktop Go, frontend typecheck/build and census pass. Frontend 671 files /
5465 tests and eight browser/four native renderer scenes pass. Affected
boot/ACP/serve/control/remote/delegation race tests pass. Provider request bytes
equal actual Studio with and without persona. Local review/security review are
recorded separately from maintainer review. The unchanged macOS computer
coverage baseline remains 88.2% below 89.8%; no floor is changed.
2026-10-18 17:12:02 +08:00
YHH d70edac126 Merge pull request #13737 from esengine/feat/session-move-import
feat(studio): move a conversation to another project, carrying the conversation and not what the old project allowed
2026-10-18 02:11:57 -07:00
esengine 5bd0afb74d fix(desktop): keep the scheduled runs block on a failed read, label parked items as model text 2026-10-18 02:06:16 -07:00
esengine f9c6f37b78 fix(remote): stage WSL launches in a private home directory and bound the wait by the session 2026-10-18 02:04:21 -07:00
esengine ae0f9f8fee feat(feedback): close the mirror issue of a withdrawn report 2026-10-18 02:03:13 -07:00
esengine 057d8094ba fix(acp): a session that cannot open for want of credentials is the authentication-required error
session/new answered -32603 for a model with no API key or no configured
provider, although initialize advertises a setup auth method. The cause is now
a provider.ErrKeyMissing / ErrNoProvider identity and the frontend maps it to
the ACP schema's -32000.
2026-10-18 02:01:16 -07:00
esengine ab3f88ac9b Merge remote-tracking branch 'origin/studio' into a5/wsl-link 2026-10-18 02:00:31 -07:00
SivanCola de5627d5e0 merge: reconcile Agent compositions with current Studio assembly
Problem: Studio advancement made the Agent composition PR conflict in boot
assembly while adding trace export and a workspace-aware mod runtime source.

Root cause: wireMCP now has a composition error result, and extension preflight
filters immutable bindings where Studio introduced its package source.

Fix: normally merge Studio ed1c127d6. Keep OTLP cleanup before returning from
wireMCP and retain workspace-aware discovery before exact composition filtering.
Preserve upstream tighten-only dispatch, replay protection, ACP elicitation and
the withheld mod switch. Keep Standard/Minimal, opt-in binding, ACP directories,
exact dispatch inheritance and the accepted composer layout.

Verification: gofmt, vet, lint, make check, required builtin/boot, full root Go,
affected boot/extension/modhost/ACP/serve/control race suites, Desktop and SDK
tests pass. Frontend typecheck/build, census, 668 files / 5450 tests, Electron
unit tests and eight browser/four native renderer scenes pass. Raw provider
requests equal actual Studio with both persona and empty persona. Local review
and security review found no new issue. Coverage gate still reports only the
unchanged macOS computer baseline, 88.2% versus 89.8%; no floor is lowered.
2026-10-18 17:00:30 +08:00
esengine 137a5831e9 feat(rules): rules directories, delivered on the turn and scoped by paths
Rule files under .reasonix/rules, .claude/rules and the user's rules folder are
read from disk each turn. Rules without paths: join the turn as a block owed
like the standing instructions; rules with paths: are owed once the session has
touched a matching file and retracted when they stop applying. Glob parsing and
matching stay with skills' paths: through one exported entry point. Neither layer
reaches the cache-stable prefix; capability diagnostics report what was skipped.
2026-10-18 01:57:13 -07:00
YHH 4d3c09211b Merge pull request #13725 from esengine/feat/13714-skill-remove
feat(skills): remove a skill from Settings, and never take its whole folder root with it
2026-10-18 01:56:43 -07:00
esengine 2bf3bd7b7e ci: run checks against studio 2026-10-18 01:56:02 -07:00
esengine 9e74ed1806 Merge remote-tracking branch 'origin/studio' into a5/schedule-ticker 2026-10-18 01:56:00 -07:00
esengine c013a4f20b fix(serve): the move target is matched against declared project roots and never reaches the file system 2026-10-18 01:55:42 -07:00
YHH 113523c5f8 Merge pull request #13686 from esengine/feat/11721-browser-full
feat(studio): the browser and workbench can be viewed full screen with the conversation folded away
2026-10-18 01:50:05 -07:00
YHH 0f13cf54c5 Merge pull request #13765 from esengine/a5/wsl-kind
feat(remote): a host entry has a kind, and a WSL distribution is one
2026-10-18 01:46:19 -07:00
YHH 5c99c45ded Merge pull request #13750 from esengine/feat/13693-subagent-rate
feat(delegation): report an estimated token rate while a sub-agent streams
2026-10-18 01:46:13 -07:00
YHH 92ca1b2180 Merge pull request #13753 from esengine/feat/11995-withdraw-client
feat(feedback): withdraw your own report from My feedback or /feedback withdraw
2026-10-18 01:42:08 -07:00
esengine dd9cf72abf style(remote): range over a count in the live test 2026-10-18 01:40:06 -07:00
esengine 6cd443fc94 Merge branch 'a5/wsl-kind' into a5/wsl-link 2026-10-18 01:38:43 -07:00
esengine 34c17c5abf style(config): range over the name's length 2026-10-18 01:38:40 -07:00
YHH ae0af1d5ed Merge pull request #13760 from esengine/a9/default-mode
feat(agent): start new conversations in plan mode when the user chose it
2026-10-18 01:35:23 -07:00
esengine ce73697390 Merge branch 'a5/wsl-kind' into a5/wsl-link 2026-10-18 01:33:54 -07:00
esengine 3ed0deb61d Merge remote-tracking branch 'origin/studio' into a5/wsl-kind 2026-10-18 01:33:39 -07:00
esengine f91eba7de2 fix(config): a WSL distribution name is checked against the characters WSL allows 2026-10-18 01:33:35 -07:00
esengine 89fbc59761 feat(remote): translate Windows workspaces for a WSL machine and refuse a distribution without ps 2026-10-18 01:31:49 -07:00
YHH c9c258067d Merge pull request #13761 from esengine/a5/bootstrap-fs
refactor(remote): bootstrap depends on the file calls it uses, not on SFTP
2026-10-18 01:31:33 -07:00
esengine d235c1fce8 feat(remote): bring up a kernel in a WSL distribution and reach it over loopback 2026-10-18 01:28:00 -07:00
esengine 03725a6939 test(desktop): the full-screen guard waits out the entrance and keeps the pointer off the rail before it shoots 2026-10-18 01:23:09 -07:00
esengine ff5242bcfc docs(release-notes): shorten the full-screen browser line 2026-10-18 01:23:09 -07:00
esengine 00ecd029d4 feat(studio): the browser and workbench can be viewed full screen with the conversation folded away 2026-10-18 01:23:09 -07:00
YHH 74772ab76a Merge pull request #13744 from esengine/fix/1q3k-moved-root-path
fix(hub): open a session by its pre-move path after the state root moves
2026-10-18 01:21:59 -07:00
esengine a67c05616d feat(feedback): withdraw your own report from My feedback or /feedback withdraw 2026-10-18 01:19:11 -07:00
esengine a280324b78 feat(agent): start new conversations in plan mode when the user chose it
agent.default_mode (user file only, off by default) is applied by the assembly
for builds that open a fresh conversation: Studio panes without a saved
session, the TUI without a resume, and ACP session/new. A resume, a headless
run and sub-agents never take it. Settings gets the switch.
2026-10-18 01:11:28 -07:00
YHH ddc01a3793 Merge pull request #13762 from xxoingr/fix/completion-receipt-cut-at-character
fix(completion): cut a declined check's command at a character boundary
2026-10-18 01:10:52 -07:00
esengine 764fad7299 fix(hub): open a session by its pre-move path after the state root moves 2026-10-18 01:04:35 -07:00
esengine 45f7b33165 Merge branch 'a5/bootstrap-fs' into a5/wsl-link 2026-10-18 01:04:11 -07:00
YHH fc4c0e8e5b Merge pull request #13757 from esengine/feat/7950-btw
feat(tui): /btw asks a read-only side question that is not saved to the session
2026-10-18 01:03:26 -07:00
esengine 4025ef24dc feat(remote): a host entry has a kind, and a WSL distribution is one 2026-10-18 01:01:42 -07:00
YHH ed1c127d65 Merge pull request #13747 from esengine/a5/schedule-tick
feat(schedule): a tick that runs what is due, and the schedule commands
2026-10-18 00:55:49 -07:00
xxoingr a94ba07bab fix(completion): cut a declined check's command at a character boundary
The unverified-change gap names the command the host declined, bounded
at 80 bytes by command[:80]. A check with non-ASCII paths, common in a
Chinese project, could be cut inside a character, and the delivery card
then showed a replacement glyph before the cut mark. Step back to the
start of the character instead, keeping the budget and the mark.
2026-10-18 15:54:15 +08:00
YHH cdb7d2318a Merge pull request #13724 from esengine/feat/13694-mod-host
feat(modhost): run Claude Code mods as tighten-only extension runtimes
2026-10-18 00:54:01 -07:00
esengine b39058d42b feat(tui): /btw asks a read-only side question that is not saved to the session 2026-10-18 00:50:18 -07:00
esengine d91e7f8d17 refactor(remote): bootstrap depends on the file calls it uses, not on SFTP 2026-10-18 00:48:06 -07:00
YHH d31d8daf07 Merge pull request #13743 from esengine/fix/dispatch-proof-replay
fix(peers): a dispatch proof is valid once per sequence number, and the adopt/spool boundaries are pinned
2026-10-18 00:46:31 -07:00
esengine d821569545 Merge remote-tracking branch 'origin/a5/schedule-api' into a5/schedule-ui 2026-10-18 00:45:56 -07:00
esengine 4d7bd5e7a7 Merge remote-tracking branch 'origin/a5/schedule-ticker' into a5/schedule-api
# Conflicts:
#	internal/frontend/cli/schedule_manage.go
2026-10-18 00:45:51 -07:00
YHH 8b1895f05d Merge pull request #13746 from esengine/a5/acp-names-cancel
feat(acp): cancel one request, message ids, elicitation
2026-10-18 00:41:44 -07:00
esengine c52d0c325c Merge remote-tracking branch 'origin/a5/schedule-tick' into a5/schedule-ticker
# Conflicts:
#	docs/GUIDE.md
2026-10-18 00:41:40 -07:00
esengine 24c3fe0f87 feat(schedule): refuse agent-started commands, confirm by typing the plan id, record who created it
Subprocesses the host starts for the agent carry REASONIX_AGENT_CHILD=1, and the
person-only schedule commands refuse when it is set, so a pty opened by a tool
no longer passes the terminal check. add prints the whole plan and requires its
short id typed back; list shows how each schedule was confirmed. The Windows
preview test compares against the resolved workspace path.
2026-10-18 00:39:59 -07:00
esengine adee10bd33 fix(modhost): read the package through a root, and run the mod effect tests with the sandbox off 2026-10-18 00:35:49 -07:00
esengine 971346ff56 docs(release): the user-visible change in the mod host core is the MCP ruling, not a switch that is still withheld 2026-10-18 00:35:49 -07:00
esengine 58a6432e55 test(modhost): wait for the script's stderr line instead of reading it the instant the reply lands 2026-10-18 00:35:49 -07:00
esengine 035453edfb refactor(agent): one gate method for the trusted MCP path 2026-10-18 00:35:49 -07:00
esengine 92c7f9d107 fix(desktop): English wording for the withheld mods switch 2026-10-18 00:35:49 -07:00
esengine 338264c6b7 fix(modhost): an extension's ruling also reaches installed MCP tools, and the mods switch stays withheld until the gaps merge 2026-10-18 00:35:49 -07:00
esengine 905eb76143 refactor(modhost): satisfy the linters 2026-10-18 00:35:48 -07:00
esengine fea2a123f6 refactor(modhost): keep the touched files inside their size and comment budgets 2026-10-18 00:35:48 -07:00
esengine 7dad28cf6b fix(desktop): the mods routes and refusal wording the parity tests ask for 2026-10-18 00:35:48 -07:00
esengine 00d6b85d34 docs(mods): the mod host, its trust and what it serves 2026-10-18 00:35:40 -07:00
esengine 95428e8f17 feat(mods): the mods status and settings port, its routes, and the environment a host states 2026-10-18 00:35:39 -07:00
esengine ec49147dee feat(permission): a tighten-only extension's ask is a fresh human approval of the one call 2026-10-18 00:35:39 -07:00
esengine 2234a3d68b test(modhost): effect tests for the prefix, a refused allow and a mod that dies or hangs 2026-10-18 00:35:39 -07:00
esengine f2a2da4120 feat(modhost): a mod installs, previews and runs through the real boot assembly 2026-10-18 00:35:39 -07:00
esengine 2a035ffdb1 feat(config): the [mods] switch and hook budget, user file only 2026-10-18 00:35:39 -07:00
esengine 152db8582e feat(modhost): synthesise a runtime for an installed mod and supervise it through the sidecar client 2026-10-18 00:35:27 -07:00
esengine 3d624951db feat(ext): dynamic tools and kill-on-timeout for single-threaded runtimes; the mod host serves tools, commands and the band 2026-10-18 00:35:27 -07:00
esengine 047b9e96b0 style(modhost): gofmt the harness 2026-10-18 00:35:27 -07:00
esengine a21d8d8b64 feat(modhost): the host script and its protocol tests under a real node 2026-10-18 00:35:27 -07:00
esengine 3219135e7e feat(modhost): resolve the JavaScript runtime a mod runs on 2026-10-18 00:35:27 -07:00
esengine 674712eadf feat(ext): a static preview of a mod's hooks module from its syntax tree 2026-10-18 00:35:27 -07:00
esengine 0154163c84 feat(ext): tighten-only runtimes and the wire types for Claude Code mods 2026-10-18 00:35:27 -07:00
esengine e906f7b4d5 docs(peers): say what a dispatched session inherits, including YOLO's reach over ask rules 2026-10-18 00:32:28 -07:00
esengine 749b7a6ede Merge branch 'a5/schedule-api' into a5/schedule-ui 2026-10-18 00:31:27 -07:00
YHH 8d7a55e8ad Merge pull request #13742 from esengine/a5/otlp-export
feat(otlp): opt-in OpenTelemetry trace export, user config only
2026-10-18 00:29:50 -07:00
esengine 89f422ad27 Walk the runs backwards with slices.Backward 2026-10-18 00:24:51 -07:00
YHH c73eb851bf Merge pull request #13749 from esengine/docs/sandbox-in-containers
docs(sandbox): what the Linux sandbox does in a container, measured
2026-10-18 00:24:43 -07:00
esengine 2c23584ee4 Merge branch 'a5/schedule-api' into a5/schedule-ui 2026-10-18 00:24:03 -07:00
esengine 6745de2eda feat(desktop): the scheduled runs screen under Settings, Automation 2026-10-18 00:24:03 -07:00
SivanCola c6a421a97f merge: integrate landed persona and current Studio contracts
Problem: Agent compositions still carried the persona prerequisite diff and
missed newly added session lifecycle contracts on Studio.

Root cause: the branch predated the landed persona, ACP additional directories
and project-dispatch assembly paths. Combining their independent additions
also exceeded existing file/scalar-state budgets.

Fix: normally merge Studio 896552483 after verifying #13588 actually landed.
Resolve the Persona/composition imports and keep one ACP SessionParams owner.
Clone vetted extra directories through composition switches. Pass the exact
private binding into dispatched runtime assembly; reject cross-workspace
bindings before opening, and reject fresh assembly errors without a history
fallback. Ordinary unbound dispatch remains unchanged. Extract the command
catalog and Mock memory projection, and place the MCP guard with catalog
refresh state. No budget baseline is raised.

Keep Standard/Minimal cards, the left mode-name-only picker, right run status,
default-off/unbound Standard behavior and the landed user-config persona.
Update bilingual behavior/API documentation and release notes.

Verification: format, vet, lint, make check, required builtin/boot tests, full
root count1 atomic-coverage tests, the complete CI race package group, Desktop
and SDK suites; frontend typecheck/build and 667 files / 5443 tests. Electron
unit176 pass with one existing skip. Browser8 and Electron4 controlled rendering
scenes cover locale/theme/width, selection, mode cards, locked/new flows and
left/right layout. Real HTTP requests match landed Studio byte-for-byte with
user persona29139 bytes and empty persona29110 bytes, with fixed probe PATH.
ACP/dispatch regressions fail before propagation and pass afterward. Local
review and security review cover MCP contracts and dispatch/ingress authority.

Known local gate: make coverage-gate reports the untouched macOS computer
package at88.2 below89.8. The exact command on landed Studio reproduces the
same result and uncovered lines. No floor is lowered; hosted Linux gates
remain required. Hosted results for this new head are reported separately.
2026-10-18 15:23:19 +08:00
esengine 6ee9cd0069 Take the default folder from the caller, not the process 2026-10-18 00:20:06 -07:00
esengine 46284a98f4 feat(schedule): the controller port, the serve routes and the port types for scheduled runs
One draft shape and one set of confirmation facts for every frontend; a
creation is accepted only with the token of the preview the person saw; only
the machine's owner may create or resume, any reader may pause or delete.
2026-10-18 00:14:49 -07:00
esengine 92b2e46f69 docs(sandbox): what the Linux sandbox does in a container, measured 2026-10-18 00:08:54 -07:00
esengine 4cddc3390c feat(schedule): the Studio host runs the ticker while it is supervised and visible
The host also answers 'schedule exec' by running the CLI's entry, so the
supervisor can start it as the child. One kernel at a time ticks, under the
machine's ticker lock.
2026-10-18 00:08:22 -07:00
esengine e453fe539e Merge studio into a5/otlp-export 2026-10-18 00:01:14 -07:00
esengine 20d807cb80 Merge remote-tracking branch 'origin/studio' into a5/schedule-tick 2026-10-18 00:00:24 -07:00
esengine c85b77bee2 Split the ceilings in the scheduled runs guide 2026-10-18 00:00:06 -07:00
esengine da001ded22 Shorten the release note fragment 2026-10-17 23:58:24 -07:00
esengine 3d71e57313 Shorten the release note fragment 2026-10-17 23:58:20 -07:00
esengine fb99e0effd feat(schedule): a tick that claims and runs what is due, and the person-facing schedule commands
Scheduler.Tick reaps dead runs, claims at most one due slot inside its grace
and hands it to the supervisor; the schedule add, list, pause, resume, renew,
rm and tick commands drive it. Creating, resuming and deleting need a
terminal.
2026-10-17 23:58:11 -07:00
esengine 6d700b071b Merge remote-tracking branch 'origin/studio' into a5/acp-names-cancel 2026-10-17 23:57:46 -07:00
esengine b09691402d Keep ACP docs and protocol.go within the size standards 2026-10-17 23:57:44 -07:00
esengine b3ec1e2e87 feat(delegation): report an estimated token rate while a sub-agent streams 2026-10-17 23:56:26 -07:00
YHH 896552483e Merge pull request #13588 from SivanCola/feature/new_system_person_20261017
feat(persona): add a system persona to Studio / 新增 Studio 系统人设
2026-10-17 23:54:08 -07:00
esengine e6ba328ad4 Merge remote-tracking branch 'origin/studio' into a5/acp-names-cancel 2026-10-17 23:52:15 -07:00
esengine ba8900aba9 fix(peers): a dispatch proof is valid once per sequence number, and the adopt/spool boundaries are pinned 2026-10-17 23:48:36 -07:00
esengine 57ca36e635 fix(studio): the move dialog says checkpoints, task logs and queued messages stay behind 2026-10-17 23:47:27 -07:00
esengine 5fdd45af4b feat(acp): cancel one request, message ids, tool names on permission requests, elicitation
$/cancel_request cancels only the named request and answers -32800;
agent message chunks carry an opaque messageId; an MCP server's form or
page goes through elicitation/create when the client advertised the mode.
2026-10-17 23:43:20 -07:00
SivanCola cc629b6424 merge: isolate persona imports from evolving settings types
Problem: Studio's new SearXNG connection probe conflicts with the persona
types in three transport import lines, blocking this PR again.

Root cause: independent settings features append types to the same line.

Fix: normally merge Studio 6f68a14a9, preserving the search probe and persona
contracts. Keep persona-only imports separate so subsequent upstream type
additions do not repeatedly conflict. Remove one upstream trailing space.
Retain the upstream MCP scope upgrades, ACP session directories, Gemini
provider, session ordering and rail-search behavior without modifying their
implementations. Persona prompt, user-only scope and guarded writes remain
unchanged, as do the approved composition UI and default-off behavior.

Verification: three-way simulations of future settings imports merge cleanly
in all three files. Format, vet, lint, make check, required builtin/boot tests,
full root Go tests with atomic coverage, full CI race package group and Desktop
Go pass. Frontend typecheck/build and 662 files / 5425 tests pass. Chromium
checks cover persona navigation in both locales, light/dark, wide/390px;
rail search, session ordering, web access, settings rows and font scaling.
Local review and security review check the integration and fresh MCP consent;
they do not replace maintainer approval. No gate or budget was relaxed.
2026-10-18 14:37:59 +08:00
esengine 157ca8533c Merge studio into a5/otlp-export 2026-10-17 23:32:08 -07:00
SivanCola c190fe4bf4 Merge studio into unified project sidebar
Problem: the sidebar PR conflicts with studio's new per-session move actions.

Root cause: both branches extend the row key handler and the mock tree's
session projection. Sorting shortcuts also bubbled into tree navigation,
moving focus to a different session after a reorder.

Fix: retain keyboard context menus and session moves together, compose mock
placement with pin/archive decoration, and consume sorting keys at their
owning row. Keep the existing remote host and body-size guards unchanged.

Verification: 5478 frontend tests across 669 files; production frontend
build; full serve, sessionstore, builtin and boot suites; gofmt, root vet,
make lint and make check. A failing-first keyboard regression and isolated
browser checks prove reordered rows retain focus and keyboard menus still
expose pin, archive and move actions.
2026-10-18 14:32:00 +08:00
YHH 6943f966da Merge pull request #13718 from esengine/a5/video-url
feat(video): hand a public video address the user wrote to the model vendor (off by default)
2026-10-17 23:30:55 -07:00
esengine de67ca98e8 Merge remote-tracking branch 'origin/studio' into a5/otlp-export 2026-10-17 23:15:49 -07:00
esengine 436fa1754a Fix lint findings in the otlp package 2026-10-17 23:15:12 -07:00
esengine cf745b383c feat(studio): move a conversation to another project, carrying the conversation and not what the old project allowed 2026-10-17 23:13:36 -07:00
esengine 790943ac97 Merge studio into a5/video-url 2026-10-17 23:12:21 -07:00
YHH 6f68a14a9d Merge pull request #13732 from esengine/a5/tui-mcp-signin
feat(tui): sign in to an MCP server, or grant it more access, from /mcp
2026-10-17 23:12:14 -07:00
esengine 032e9b771d test(perf): the web access guard expects the video address as its own third list 2026-10-17 23:11:10 -07:00
esengine 089a348753 Merge studio into a5/otlp-export 2026-10-17 23:09:23 -07:00
YHH d87dc94c26 Merge pull request #13659 from esengine/a5/searxng-probe
feat(search): test a SearXNG address from settings and say why it does not answer
2026-10-17 23:07:45 -07:00
YHH 7c35e9bfab Merge pull request #13733 from esengine/feat/session-manual-order-2
feat(studio): move a conversation up or down in its project's list, kept by the kernel
2026-10-17 23:07:42 -07:00
esengine e667bbcd59 fix(desktop): declare the trace export block in the settings catalogue 2026-10-17 23:05:01 -07:00
esengine 9057c3211e feat(otlp): serve route, Studio settings group, docs 2026-10-17 23:04:15 -07:00
esengine b6dbbde594 feat(otlp): opt-in trace export from the user's config, with settings, boot wiring and effect tests 2026-10-17 22:55:38 -07:00
esengine 065d5200da feat(otlp): an exporter that turns a run into GenAI traces 2026-10-17 22:48:43 -07:00
YHH 8e14cdff21 Merge pull request #13731 from esengine/a5/acp-additional-dirs
feat(acp): session-scoped additionalDirectories as write roots
2026-10-17 22:45:40 -07:00
esengine 74e594faed Keep the i18n catalogs under the size ceiling 2026-10-17 22:43:43 -07:00
esengine 6a93a3d01e fix(sessionstore): the order index is a new format now that a row carries its rank 2026-10-17 22:40:36 -07:00
esengine f821e2be49 feat(studio): move a conversation up or down in its project's list, kept by the kernel 2026-10-17 22:40:36 -07:00
esengine 8ce6be77d8 feat(tui): sign in to an MCP server, or grant it more access, from the /mcp panel 2026-10-17 22:40:12 -07:00
esengine e60cc114e8 Merge remote-tracking branch 'origin/studio' into a5/video-url 2026-10-17 22:32:55 -07:00
YHH bbe38032c3 Merge pull request #13730 from esengine/a5/mcp-stepup
feat(mcp): let the user grant a server the access it asked for
2026-10-17 22:32:27 -07:00
esengine bb43735206 feat(acp): take additionalDirectories as session-scoped write roots
Each folder gets the vetting a user-granted root gets; one refused folder
refuses the request with a typed code. Nothing reaches the user's grants.
2026-10-17 22:31:06 -07:00
esengine 1571fe64dc feat(skills): remove a skill from Settings, and never take its whole folder root with it 2026-10-17 22:22:41 -07:00
YHH fc4626cf94 Merge pull request #13726 from esengine/fix/subagent-view-live-after-completion
fix(delegation): a run recorded complete is not live while its owner has yet to let go
2026-10-17 22:10:16 -07:00
YHH cbff25b46b Merge pull request #13715 from esengine/fix/rail-hit-first-click
fix(studio): the rail search box stays open while it holds a query, so the first press on a result is a click
2026-10-17 22:08:33 -07:00
esengine 33b05e79d8 Keep the OAuth authorization flow under the size limits 2026-10-17 22:05:28 -07:00
esengine cacc7c6789 Let the user grant an MCP server the access it asked for
A 403 insufficient_scope from a signed-in server now stops the model and
tells the user; granting more is a fresh approval asked every time, from
the server row or reasonix mcp auth --upgrade.
2026-10-17 22:05:28 -07:00
esengine 3f61753054 feat(mcp): type a 403 insufficient_scope answer, extend access by scope, tell the model by code and the user once 2026-10-17 22:05:28 -07:00
YHH 92d8795b31 Merge pull request #13721 from esengine/a9/acp-vendor-optin
fix(acp): vendor notifications reach only clients that declare them; transcriptPath rides _meta
2026-10-17 21:57:07 -07:00
SivanCola 576e89b111 merge: preserve persona alongside session references
Problem: the latest Studio session-reference settings overlap the persona
port imports and development proxy, leaving this PR in conflict.

Root cause: both features add settings types at the same transport boundary.

Fix: normally merge Studio 8c311b02b, retaining both Persona and SessionRef
contracts, Mock state, SSE methods, routes and settings panels. The upstream
dispatch-consent, bounded-fold and subagent-view implementations stay intact.
Persona prompt assembly, user-only scope, guarded settings and save timing
remain unchanged. The preceding test-only commit synchronizes cancellation
observation without changing the mock provider or relaxing error/close checks.

Verification: formatting, vet, lint, make check, required builtin/boot tests,
full root Go tests with atomic coverage, the full CI race package group and
Desktop Go tests. Frontend typecheck/build and 660 files / 5411 tests pass.
Chromium checks cover persona/settings navigation in Chinese and English,
light/dark and wide/390px layouts; upstream session-reference interactions,
settings rows, font scaling and explorer counts are covered as well.
2026-10-18 12:53:02 +08:00
YHH f249ab3e0e Merge pull request #13712 from esengine/a5/gemini-kind
feat(gemini): a native Gemini provider on the Interactions API, with video (experimental)
2026-10-17 21:50:26 -07:00
esengine 4618349369 Merge origin/studio into fix/rail-hit-first-click 2026-10-17 21:48:03 -07:00
esengine 27dbf237af Merge studio into a5/searxng-probe 2026-10-17 21:47:46 -07:00
esengine 83fde1922f fix(delegation): a run recorded complete is not live while its owner has yet to let go 2026-10-17 21:47:36 -07:00
SivanCola 8bebe521d6 test(agent): synchronize mock stream cancellation observation
Problem: the hosted race group failed the mock provider cancellation test
when the next received chunk was an already-pending tool call.

Root cause: cancellation does not prioritize the Done case over a send that
is also ready in select. The test canceled the context and immediately read
again without establishing that the producer had observed cancellation.

Fix: acknowledge cancellation through the test context's Err method before
draining the stream. Retain the strict cancellation-error and subsequent
channel-close assertions, plus the unchanged pre-canceled request test.
The hang guard uses the existing test budget. No mock/product behavior,
runtime prompt, UI, thresholds or workflow approvals change.

Verification: reproduced one failure in 10000 race-instrumented iterations
with GOMAXPROCS=2 before the change; 10000 final iterations pass afterward.
A temporary explicit-marker mutation that ignores cancellation is rejected.
The complete CI race package group passes, alongside gofmt, vet, lint,
make check and builtin/boot tests. Prior frontend/coverage evidence applies
to the unchanged product tree.
2026-10-18 12:39:10 +08:00
esengine eacf0618e8 fix(acp): vendor notifications reach only clients that declare them; transcriptPath rides _meta
The prompt result carried a top-level transcriptPath the ACP schema does not
allow, and status_update / mcpStatus_update went to every client, which
standard SDK clients answer with a method-not-found error each. The two
notifications now need clientCapabilities._meta["reasonix.io"].statusUpdates
and the path moves under _meta["reasonix.io"].
2026-10-17 21:28:25 -07:00
YHH 8c311b02bd Merge pull request #13708 from esengine/feat/13687-session-reference
feat(studio): reference a past conversation of this workspace from the message box
2026-10-17 21:27:41 -07:00
esengine 7b775ed15e test(video): an address another session delivered is not the user's, and a reopened conversation keeps who wrote what 2026-10-17 21:23:55 -07:00
SivanCola 8777a70d87 fix(studio): end a development shell with its launcher and survive a closed console
Problem
A development Studio shell started with `pnpm start` (`electron .`) had two
ways to outlive the launch that owned it:
- with its launcher killed outright, the shell and its kernel kept running
  with nobody holding them;
- with the launcher's console closed underneath it (a terminal gone, a
  background job's reader exited), the next kernel stderr line the shell
  echoed raised an EPIPE that nothing handled. Electron's modal "A JavaScript
  error occurred in the main process" box then held the main thread, so the
  shell stopped draining the kernel and ignored SIGTERM (still alive 30 s
  after it, 2/2 runs). The kernel logs a request every few seconds, so this
  followed within seconds of the console closing.

Root cause
main.js echoed kernel stderr with a bare process.stderr.write and attached
no error listener, and nothing in the shell watched for the loss of the
process that launched it. The kernel already ends with the shell through its
stdin lease; the shell had no equivalent towards its own launcher.

Fix
- src/launcher.js owns both: watchLauncher ends an unpackaged shell once its
  launcher is gone (POSIX reparents an orphan; Windows keeps the dead pid, so
  there it is probed), and consoleEcho drops output for a console that failed
  or backed up instead of throwing or queueing. shell.log and host.log keep
  every line.
- Both relaunch paths (the kernel's relaunch act and the X11 relaunch) hand
  the next instance over first: the launcher exits with this process, so a
  relaunched shell would otherwise read its own start as the launcher dying.
- shell.log records the shell's and the kernel's pids, which is how the live
  check finds them.

Verification
- node --test test/unit.mjs test/linux-packaging.test.mjs: 181 pass, 1 skip.
  The new real-pipe test fails (child exits 1 on EPIPE) without consoleEcho.
- pnpm launcher-live (new, real shell + kernel, macOS arm64): launcher
  SIGKILL after and before the handshake, closed console then SIGTERM, plain
  SIGTERM all pass; with the fix removed the first three fail.
- pnpm smoke: 62 ok, 1 fail ("the bridge exposes verbs and nothing else"),
  identical on unmodified studio.
2026-10-18 12:22:56 +08:00
YHH 64c5ff3273 Merge pull request #13717 from esengine/feat/13693-subagent-view
feat(delegation): read a sub-agent run's conversation while it runs
2026-10-17 21:19:13 -07:00
esengine 291d02c884 Merge remote-tracking branch 'origin/studio' into a5/searxng-probe 2026-10-17 21:14:41 -07:00
SivanCola f40ab35660 merge: reconcile persona with the settings interface
Problem: newer Studio settings UI and persona translations both appended to
the shared English catalog, leaving the PR conflicting. Its new setting-open
notices also lacked a destination for the guarded persona fields.

Root cause: translations for an independent settings domain shared an append
point, and the newly introduced frontend routing table did not know persona.

Fix: normally merge Studio 15de9ec33, preserve both sides' translations and
extract persona's unchanged entries into its own catalog module. Route the
three guarded persona setting ids to their editor and cover actual notice
clicks. Preserve the persona's user scope, supplemental prompt semantics,
save timing, settings navigation and existing authorization boundaries.

Verification: format, vet, lint, make check, complete root Go tests with
atomic coverage, Desktop Go, frontend typecheck/build and 657 files / 5400
tests. Verified all 25 extracted translations unchanged. Chromium navigation
passed in Chinese/English, light/dark and wide/390px layouts; settings rows
and font scaling pass. The later dispatch config/catalog combination also
passes an explicit-marker build overlay. No thresholds or approvals changed.
2026-10-18 12:06:50 +08:00
esengine b96ad582a6 feat(video): the Settings switch for handing a video address to the vendor, with docs 2026-10-17 21:04:31 -07:00
YHH 42c505b50e Merge pull request #13711 from esengine/feat/13320-bound-fold
fix(agent): give [[keep]] turns a budget of their own and stop the fold ceiling where the send-time output check stops
2026-10-17 21:03:07 -07:00
YHH 5c38676052 Merge pull request #13709 from esengine/feat/dispatch-open-switch
feat(peers): the dispatch switch is the consent between a session and the sessions it opens
2026-10-17 21:01:08 -07:00
esengine 58e2da8db2 feat(video): read_video takes an address the user wrote, when the user turned that on 2026-10-17 20:58:33 -07:00
esengine 4b483d3897 fix(control): cut a referenced conversation at a character boundary and quote its attributes with %q 2026-10-17 20:57:29 -07:00
esengine 085f20c576 feat(video): an address the vendor visits, on the Zhipu and Gemini wires 2026-10-17 20:54:12 -07:00
esengine 3e10e6faf6 Merge branch 'a5/gemini-kind' into a5/video-url 2026-10-17 20:52:28 -07:00
esengine 8f1fe5965d fix(gemini): follow only an upload address on the configured host and only Files-shaped names; say experimental in the config 2026-10-17 20:52:08 -07:00
esengine f1fa8538d5 wip(video-url): the user-only switch in the config 2026-10-17 20:49:18 -07:00
esengine 4d2c7cd4d0 fix(studio): the rail search box stays open while it holds a query, so the first press on a result is a click 2026-10-17 20:48:09 -07:00
esengine 863ed17e70 feat(delegation): read a sub-agent run's conversation while it runs 2026-10-17 20:47:52 -07:00
esengine a2aef0b953 fix(agent): give [[keep]] turns a budget of their own and stop the fold ceiling where the send-time output check stops 2026-10-17 20:42:45 -07:00
YHH 15de9ec334 Merge pull request #13706 from esengine/a9/acp-readiness-pause
fix(acp): a host-paused turn ends with end_turn, a paused outcome and a receipt
2026-10-17 20:41:07 -07:00
YHH ec207aabf7 Merge pull request #13707 from esengine/feat/13697-user-message-time
feat(chat): show when your own message was sent in its action row
2026-10-17 20:40:46 -07:00
YHH bec46d99ab Merge pull request #13704 from esengine/fix/13699-user-action-icons
fix(chat): the action icons under your own message match the reply's size
2026-10-17 20:40:35 -07:00
esengine 992e0155a5 Merge remote-tracking branch 'origin/studio' into a5/gemini-kind 2026-10-17 20:36:53 -07:00
esengine 3b5ef3dff1 style(gemini): satisfy the linters in the tests 2026-10-17 20:36:43 -07:00
YHH 33bb46e95a Merge pull request #13698 from esengine/a5/settings-ui
feat(settings): draw the setting card and point at a setting only the user may change
2026-10-17 20:30:15 -07:00
esengine 14206bc978 test(settings): the dispatch ceilings share the words of the dispatch switch 2026-10-17 20:29:24 -07:00
esengine da0c5c6a9f test(peers): range over SplitSeq with one variable 2026-10-17 20:29:24 -07:00
esengine 038732325c fix(settings): declare the two dispatch ceilings 2026-10-17 20:29:24 -07:00
esengine ffe30b62bf test(peers): range over SplitSeq 2026-10-17 20:29:24 -07:00
esengine eb48decd12 fix(desktop): the peers settings fixtures carry the dispatch ceilings 2026-10-17 20:29:24 -07:00
esengine e7d19e3d65 chore(peers): fit the repolint budgets 2026-10-17 20:29:24 -07:00
esengine 2002ef72ea feat(peers): ceilings, error identities and the dispatch tool's own words follow the switch being the consent 2026-10-17 20:29:24 -07:00
esengine ddba148a36 feat(peers): the dispatch switch is the consent between a session and the ones it opens 2026-10-17 20:29:24 -07:00
esengine 8720820542 Merge studio into a5/gemini-kind 2026-10-17 20:28:35 -07:00
esengine ec8a91e1e1 feat(studio): reference a past conversation of this workspace from the message box 2026-10-17 20:22:18 -07:00
esengine 25d9f7f028 style(gemini): split the stream loop and trim comments to the repo's limits 2026-10-17 20:19:25 -07:00
esengine aa80cdce2e feat(gemini): effect test, docs, release note and the desktop kind label 2026-10-17 20:18:28 -07:00
esengine 5b4f35502f fix(acp): a host-paused turn ends with end_turn, a paused outcome and a receipt
The final-readiness pause answered session/prompt with the non-standard
stopReason "error", so a client that reads only message text took unverified
work for finished. The turn now ends with end_turn, the typed outcome in _meta
and a host-written [paused] line at the end of the streamed text.
2026-10-17 20:16:53 -07:00
SivanCola fbdb85dc4e merge: retain guarded persona behavior with the settings tool
Problem: newer Studio introduces the model-facing setting tool across the
same catalog, boot assembly and controller used by the persona feature.

Root cause: validating only the earlier base would leave their combined
authorization and request contracts untested.

Fix: normally merge Studio 4840df982 and retain the existing guarded persona
entries, user-only loading and supplemental prompt assembly. Add a real
boot.Build effect test for all three persona fields: even in YOLO a model
proposal receives a guarded refusal, produces no approval card, preserves
the user file and discloses no value through refusal or settings listing.

Verification: gofmt, vet, lint, make check, full root Go tests with atomic
coverage, Desktop Go and SDK suites, frontend typecheck/build and 654 files
/ 5384 tests; Chromium settings navigation, row layout and font-scaling
checks in light/dark and wide/narrow layouts. Earlier Electron checks apply
unchanged. No baselines, thresholds, workflow approvals or draft state change.
2026-10-18 11:16:47 +08:00
esengine 006616829a feat(chat): show when your own message was sent in its action row 2026-10-17 20:16:31 -07:00
esengine b9510a1cc2 Merge remote-tracking branch 'origin/studio' into a5/settings-ui 2026-10-17 20:13:06 -07:00
YHH 0d9bae435a Merge pull request #13703 from esengine/a9/dsh-acp
fix(acp): a failed session/prompt turn is a JSON-RPC error with a typed code
2026-10-17 20:12:47 -07:00
esengine fbf2495a4c feat(gemini): the native preset, with only models whose pages were read 2026-10-17 20:08:07 -07:00
SivanCola 724d94af8c merge: reconcile persona with current Studio contracts
Problem: the approved persona head failed hosted Go tests and coverage, and
the combined Settings/config files exceeded their line budgets.

Root cause: CI tested synthetic merge 6309314327 against newer Studio
5e53be32a. Its attachment ceiling key had no settings declaration, while
independent additions accumulated in the shared settings/config files.

Fix: normally merge Studio 667f2ca16, including the upstream guarded
attachment-key declaration and ProviderEntry method extraction. Keep all
three guarded persona keys and their user-scope/prompt behavior. Extract
the unchanged settings navigation into SettingsNav, retaining keyboard
navigation, status values and the new version dot. Assert that the
attachment storage ceiling stays guarded.

Verification: gofmt, vet, lint, make check, complete root Go tests with
atomic coverage, independent Desktop Go tests, Electron tests, frontend
typecheck/build and 654 files / 5381 tests. Chromium fs-scale and settings
row guards pass, with light/dark 1280px/390px navigation checks. No budgets,
test thresholds, workflow approvals or draft state are changed.
2026-10-18 11:07:54 +08:00
esengine e24b481e40 fix(chat): the action icons under your own message match the reply's size 2026-10-17 20:05:22 -07:00
YHH 4840df9825 Merge pull request #13690 from esengine/a5/settings-tool
feat(settings): the model can propose a setting change and the user confirms it
2026-10-17 20:03:48 -07:00
esengine 8c4e0ef1e8 feat(gemini): video through inline data or the Files API, with WebM and MOV sniffed natively 2026-10-17 20:02:52 -07:00
YHH fadd893983 Merge pull request #13701 from esengine/fix/search-button-toggle
fix(find): the title bar search button closes an open find bar
2026-10-17 20:02:14 -07:00
esengine 12fb14abb6 feat(pricing): Google's Gemini rates, with a dated generation for the announced step 2026-10-17 19:57:22 -07:00
esengine 001d33f232 fix(acp): a failed session/prompt turn is a JSON-RPC error with a typed code
stopReason "error" is not in the ACP prompt-turn enumeration, and the cause
travelled only on a vendor notification, so a text-only client saw an
unexplained failure. The turn now ends with -32603, the redacted cause as the
message and data.code turn_failed.
2026-10-17 19:57:07 -07:00
YHH 667f2ca166 Merge pull request #13688 from SivanCola/fix/skill-spec-info-key
fix(studio): key each skill metadata row / 技能元数据行补上 key
2026-10-17 19:56:36 -07:00
esengine 33219e58c5 feat(gemini): a native provider kind on the Interactions API 2026-10-17 19:53:46 -07:00
esengine f6c18ac5a3 Merge branch 'a5/settings-tool' into a5/settings-ui 2026-10-17 19:50:31 -07:00
esengine fc80457d14 fix(settings): the card shows the whole value or the value is refused, no proposals in plan mode, no values for boundary rows 2026-10-17 19:50:12 -07:00
esengine 5d99b3a628 fix(find): the title bar search button closes an open find bar 2026-10-17 19:44:03 -07:00
esengine 96d21061db Merge remote-tracking branch 'origin/studio' into a5/settings-tool 2026-10-17 19:43:10 -07:00
SivanCola 7392199b3c Merge remote-tracking branch 'origin/studio' into fix/skill-spec-info-key 2026-10-18 10:41:01 +08:00
YHH 1d1a8c7ad8 Merge pull request #13691 from esengine/fix/config-size-budget
refactor(config): move ProviderEntry's model and key methods out of config.go
2026-10-17 19:40:28 -07:00
SivanCola fb84c5af25 fix(studio): refuse unknown SSH hosts and keep hover actions opt-in
Problem: remote sidebar mutations could dial an unconfigured SSH target,
accept unbounded JSON, and enable hover actions without a user choice.

Root cause: a missing host resolved to an empty workspace without an error;
remote JSON handlers lacked a shared size boundary, and the preference
treated every value except explicit off as enabled.

Fix: classify unknown hosts before link reuse or acquisition, share a
64 KiB single-body decoder across remote writes, and restore explicit-on
hover preferences with a safe storage-error default. Preserve configured
hosts, far-kernel refusals, menu access and declared workspace resolution.
Align synchronization fixtures and keep translated directory prose in the
interface font.

Verification: failing-first unknown-host and oversized-body regressions;
full serve, builtin and boot tests; 5389 frontend tests; production build;
gofmt, root go vet, make lint and make check; isolated browser interaction
checks for default-off, opt-in persistence, menu access and opt-out.
2026-10-18 10:32:18 +08:00
esengine c24492d2c0 feat(settings): draw the setting card, point at a setting only the user may change, and offer the choice over ACP 2026-10-17 19:29:39 -07:00
esengine c1c7d99ecb Merge remote-tracking branch 'origin/fix/declare-attachments-key' into fix/config-size-budget 2026-10-17 19:24:54 -07:00
esengine 02ff3c3352 fix(settings): the receipt notice names the setting and carries no machine payload 2026-10-17 19:23:30 -07:00
SivanCola cc14bcb75e fix: display the server-owned remote workspace default
Problem: The SSH project menu re-derived a default from the project list when
the host response omitted workspace.

Root cause: A client fallback duplicated the kernel's configured-default rule.

Fix: Pass the host response's workspace directly to the menu. Align the fixture
with the server response and cover both an explicit default that differs from
list order and an omitted default that must stay unset.

Verification: The omitted-default regression failed before repair. All 17
remote project/worktree interaction tests and the production build passed.
gofmt, root go vet, make lint, make check and builtin/boot tests passed.
2026-10-18 10:16:54 +08:00
esengine 14c5dcbcee style(search): slices helpers in the dial guard, tidy the probe tests 2026-10-17 19:15:00 -07:00
esengine 623e792df3 fix(search): probe only the saved SearXNG address and never a link-local or metadata one 2026-10-17 19:13:09 -07:00
esengine a8dc464e9a refactor(config): move ProviderEntry's model and key methods out of config.go
config.go is over its file-size budget on studio after two merged changes,
which fails lint for every new pull request. The model-price/override and
API-key methods belong with ProviderEntry; the one function that reads the
process environment stays in config.go so no process-input debt moves.
2026-10-17 19:09:37 -07:00
SivanCola 321e3bbaf0 fix(studio): scale explorer counts with interface text
Problem: the Studio browser guard on the persona PR measured file and
change counts staying at 10px when interface text was enlarged.

Root cause: both explorer headers share a small element without an explicit
font size, leaving the size response to the browser's smaller-text rules.
The stylesheet is inherited from studio; macOS Chromium did not reproduce
the Linux hosted failure.

Fix: give the shared counter rule an explicit 10px base multiplied by the
existing interface scale. Preserve the default appearance and the persona
and composition behavior. Add a bilingual release note.

Verification: gofmt, vet, lint, make check, builtin/boot tests, frontend
typecheck, production/perf builds and 651 files / 5370 tests; Chromium
fs-scale, text-size and explorer guards. In light and dark, both counts
measure 10px, 11.5px and 13px across the three interface size steps.
2026-10-18 10:05:34 +08:00
esengine 5393e818e2 Merge remote-tracking branch 'origin/studio' into a5/searxng-probe 2026-10-17 19:04:52 -07:00
esengine aab760b0b2 feat(settings): declare attachments.max_stream_bytes as a guarded size limit 2026-10-17 18:59:07 -07:00
SivanCola a72134c0ef fix(studio): key each skill metadata row
The metadata list mapped entries to an unkeyed fragment, so React warned about the missing key when Settings -> Extensions rendered a skill's spec. Give the fragment the entry key and add a regression test that fails without it.
2026-10-18 09:58:59 +08:00
esengine 530ccbf688 style(config): fold two comments so config.go is back inside its size budget 2026-10-17 18:57:53 -07:00
esengine 9c6c91da9c style(settings): errors.As in tests, slices.Contains, keep config.go within its size budget 2026-10-17 18:57:27 -07:00
esengine c7e6ef9a52 feat(settings): offer the setting tool, name the switch on a disabled dispatch, and document both 2026-10-17 18:57:27 -07:00
esengine f3a5a6bcce feat(settings): the setting tool, the host that confirms a change, and the structured remedy on refusals 2026-10-17 18:57:27 -07:00
SivanCola f5ebeb1bd6 merge(studio): reuse the guarded persona settings foundation
Problem: the latest studio settings catalog must know the persona fields, and
opt-in Standard mode must retain access to the prerequisite user-persona UI.

Root cause: the new catalog landed during the composition review, while the
initial composition page had replaced persona editing with a default adapter.

Fix: integrate the corrected persona parent and current studio, reuse its
editor in Standard mode, preserve settings-search navigation, and distinguish
user persona edits from immutable composition bindings. Keep the accepted two
cards, compact left picker and right run information.

Verification: gofmt, vet, lint, make check, root Go suite including boot/builtin,
frontend typecheck/build and 656 files / 5,385 tests pass. Parent persona keys
are Guarded; the actual request-byte and old-reader probes remain unchanged.
2026-10-18 09:52:37 +08:00
SivanCola 6fe0fd6867 fix(persona): declare guarded keys in the settings catalog
Problem: the new studio settings catalog rejects config fields without an
explicit classification, including the three persona fields added here.

Root cause: the catalog foundation landed after the persona implementation.

Fix: integrate studio and classify persona prefix, suffix and optional runtime
context as user-owned Guarded settings. Plain file writes do not rebuild a
runtime. Reject model proposals before any persona value can be changed.

Verification: root Go tests including settings, boot and builtin pass; gofmt,
vet, lint and make check pass; frontend typecheck/build and 5,370 tests pass.
2026-10-18 09:46:59 +08:00
SivanCola f3930c0c7f fix(composition): require explicit selection for session presets
Problem: default selections bound every new session and migrated the user's
persona, changing the ordinary path and making downgrade impossible.

Root cause: entry points manufactured a selection, persona settings were
adapted into the preset store, and blank unbound rewrites were not persisted.

Fix: keep unselected and Standard sessions unbound, preserve the user persona,
require explicit non-Standard selection, and restore durable state on failed
publication from either origin. Reuse the corrected persona parent, preserve
Standard/Minimal cards and the compact unified picker, and remove committed
composition captures. Freeze both skill discovery paths without copying locks.

Verification: root Go suite, vet, lint, make check, Desktop/SDK suites, Electron
unit tests and focused frontend suites pass. Real Build HTTP bytes match the
persona parent; its original reader can continue a new unbound transcript.
Project authorization, rollback and skill discovery regressions pass.
2026-10-18 09:45:52 +08:00
SivanCola e7d50cf0e9 fix(persona): preserve first-party guidance and user scope
Problem: project configuration could replace the user persona, and complete
mode could suppress built-in guidance. The UI misstated rebuild semantics.

Root cause: persona was absent from the user-scope hold, replacement was a
supported runtime option, and settings metadata described only new sessions.

Fix: hold and clone the user's persona across project decoding, retire complete
throughout config/runtime/API/UI, and describe next-build activation. Integrate
current studio without dropping its settings or transport contracts.

Verification: gofmt, vet, lint, make check, root and Desktop Go suites, boot
scope/prompt regressions, frontend typecheck/build and 5,369 tests pass.
2026-10-18 09:22:31 +08:00
esengine e858a46f70 feat(search): say why a SearXNG instance does not answer, and test the address from settings 2026-10-17 10:54:09 -07:00
SivanCola 4522bb569a merge(studio): retain local bindings during native video projection
Problem: studio's native-video projection refactor overlaps the local-only
Agent composition metadata filter at the provider boundary.

Fix: keep the shared projection predicate and include composition metadata in
its host-only fields. Preserve stored bindings/video references and remove
answered video payloads only from model requests. Add regressions for a binding
as the sole copy trigger and for binding/video projection together.

Verification: gofmt, go vet, make lint/check, uncached root Go tests,
provider/OpenAI/boot/builtin suites, Desktop Go tests, frontend typecheck,
production build and 5,348 frontend tests. Existing mode layouts are preserved.
2026-10-18 00:48:01 +08:00
SivanCola 914bb02fc0 merge(studio): preserve pinned compositions with followed MCP tools
Problem: studio's MCP-follow changes and session compositions both own the
publication of a server tool catalog.

Root cause: the new followed-tool metadata must travel with the same catalog
whose fingerprint the immutable session binding validated.

Fix: retain follow metadata and the composition check before catalog publication.
Preserve upstream account localization and MCP-follow behavior. Add refresh,
stale-adapter and reconnect regressions with following enabled and disabled,
plus a real boot/provider test showing old bindings block drift while new
conversations can bind the changed server.

Verification: gofmt, go vet, make lint/check, root Go and boot/builtin suites,
Desktop and SDK Go suites, composition-guard race tests, frontend typecheck,
production build and 5,347 frontend tests. Browser evidence remains in design QA.
2026-10-18 00:37:35 +08:00
SivanCola 525d397009 feat(studio): offer Standard and Minimal Agent modes
Problem: the shipped catalog and composer exposed more mode choices and status
badges than the requested Standard/Minimal workflow needs.

Fix: ship two native compositions, with a full-configuration Standard mode
and a terminal/image Minimal mode using a concise persona. Preserve retained
bindings and report retired defaults explicitly. Separate built-in cards from
custom presets, keep only the mode name in the trigger, and place run status
at the right. Keep source/lock details in the accessible label and menu.

Verification: gofmt, go vet, make lint/check, required boot/builtin tests,
complete root and Desktop Go suites, frontend typecheck/build and 5,343 tests.
Provider-boundary and custom read-only fixtures retain capability-denial
coverage. Browser checks cover light/dark, 1440px/390px and locked controls.
2026-10-18 00:28:15 +08:00
SivanCola eebc6d2a3c fix: make remote workspace binding explicit before link reuse
Problem: Host reads could borrow another pane's workspace or reuse an existing
link after its host was disabled. Review also requested explicit defaults,
boundary regressions and removal of development notes from the tree.

Root cause: remoteLink returned the first host pane before reading current
user configuration, so neither workspace identity nor the disabled policy
governed the reuse path.

Fix: Resolve the user-host default before reuse and accept only a matching
host/workspace pane. Share the declared-default-first rule with host views,
show actual and default connection paths separately, and document the rule
in both GUIDE languages. Remove three root QA notes and seven captures;
preserve their useful context in the PR discussion.

Verification: Reproduced both incorrect read behaviors before repair. Added
HTTP regressions for declared defaults, fallback, empty configuration,
reordered extras, explicit opens, project injection and disabled hosts with
existing panes. Focused frontend tests (80), production build, owning Go
serve/config and builtin/boot tests, gofmt, go vet, lint and make check passed.
2026-10-18 00:19:23 +08:00
SivanCola 8143adc062 merge(studio): preserve Agent composition UI during upstream integration
Problem: the session-composition branch conflicted with the latest studio
settings, composer, peer-thread and runtime-construction changes.

Root cause: both branches extended shared port declarations, provider wiring
and the composer status row; failed-build cleanup also gained a separate
cancellable construction lifetime upstream.

Fix: retain both sets of settings and providers, preserve the top-row unified
Agent menu and responsive anchoring, and combine lifetime cancellation with
composition resource cleanup. Keep upstream accessible status labels and
text-size scaling. Add provider-boundary coverage showing the new image tool
cannot exceed review/research tool selections, plus updated visual evidence.

Verification: gofmt, go vet, make lint/check, required boot/builtin tests,
root and Desktop Go suites, frontend typecheck/build and 5,342 tests, Electron
unit tests, and browser light/dark checks at 1440px and 390px.
2026-10-18 00:13:18 +08:00
SivanCola 8709b556f0 merge: integrate current studio project contracts
Problem: The unified sidebar conflicted with newer Studio worktree grouping
and text scaling changes.

Root cause: Both branches evolved project identity and row presentation from
the same baseline, including helpers extracted by the sidebar change.

Fix: Preserve workspaceContains across grouped local and SSH projects, keep
isolated session directories in open and palette routes, retain isolation and
peer-thread markers, and preserve font scaling. Include reviewed synthetic UI
captures and a release note for the relocated controls.

Verification: The integrated frontend suite passed 5381 tests. Four focused
grouped SSH/palette regressions and the production build passed. gofmt, go vet,
make lint, make check and builtin/boot tests passed without widening baselines.
2026-10-18 00:02:12 +08:00
SivanCola f2e2f1f460 feat(studio): unify Agent presets in the composer status row
Problem: Session composition controls competed with task actions in the
bottom toolbar, and the adjacent ellipsis did not explain how to manage
presets.

Root cause: Composition selection and management were rendered as separate
controls below the input. Floating menu placement also missed fold changes
published after window resize and the end of layout transitions.

Fix: Place one Agent presets menu beside the run status, show sources and
capability notes, and group management below a separator. Preserve locked
bindings and explicit new-conversation actions. Reuse the existing Picker
with per-choice action identities, disabled-row keyboard navigation and
current-state semantics; refresh shared floating anchors after reflow.
Update bilingual guides, release notes and before/after screenshot evidence.

Verification: All 633 frontend test files / 5,225 tests pass, including
selection/management, keyboard focus, pending/failure, lock/new-session,
source identity and responsive positioning regressions. Typecheck,
production build, gofmt, go vet, make lint, make check, and required
boot/builtin suites pass. Browser light/dark, wide/390px and resize recovery
were verified with synthetic fixtures.
2026-10-18 00:01:26 +08:00
SivanCola da051abb2c feat: unify local and SSH project navigation
Problem: Machine-grouped navigation made project switching cumbersome, and
session hover actions had inconsistent selection and keyboard behavior.

Root cause: Local and remote lists owned separate presentation and opening
flows. Remote defaults, paused stream costs, mutation deduplication and focus
restoration did not consistently survive asynchronous navigation.

Fix: Present one project list with source metadata, a common project picker,
fold-all control and bounded recent-session previews. Share neutral selection,
leading pin and trailing archive actions across local and SSH conversations.
Route new sessions and quick switching through the active environment, retain
latest navigation intent, and guard pending mutations and keyboard focus.
Forward remote session flags to the owning kernel and preserve typed errors,
configured workspaces and dedicated-stream connection costs.

Verification: 5263 frontend tests passed, production TypeScript/Vite build
passed, and focused browser and real SSH navigation checks are documented in
the QA reports. Owning Go serve/remotehost/attach suites passed. Pre-commit
gofmt, go vet, make lint, make check and builtin/boot tests passed without
widening the repository lint baseline.
2026-10-17 23:48:21 +08:00
SivanCola 9717c4ff2c fix(session): preserve composition admission and idle handoffs
Problem: an admitted turn could fail before adding its user message and unlock
its composition. Controller-owned continuations could strand ACP config changes,
and MCP disconnect could remove code needed by a subsequent reconnect.

Root cause: the started latch followed message insertion, ACP drained only its
own prompt returns, and connection cleanup owned session package resources.

Fix: durably latch composition admission before executing or acknowledging a
turn and refuse work while the latch is pending. Notify owners at real idle
transitions and drain ACP changes through its existing serialization domain.
Separate transport state from package lifetime, retain script sibling bundles,
exclude VCS metadata, reject linked transcript exports and freeze late tool names.
Add ACP exact-binding/history tests, actual old-reader evidence, bilingual notes
and the complete light/dark wide/390px before-and-after screenshot set.

Verification: deterministic async/sync admission and failed-save regressions,
ten staged background-continuation/config-switch runs, real MCP reconnect cycles,
shared lifecycle race tests, package coverage, vet, lint and repository checks.
Frontend full suite and independent Desktop/SDK/Electron suites remain green.

Refs: #13613
2026-10-17 19:36:35 +08:00
SivanCola 4fe345ec44 feat(studio): bind immutable Agent compositions to sessions
Problem: Studio persona settings cannot select an independent tool and module
combination for each conversation, or preserve that combination across reloads.

Root cause: discovery and runtime construction follow mutable shared configuration
without a versioned session composition or a shared admission boundary.

Fix: introduce versioned scoped definitions, retained resources and authoritative
session bindings. Assemble isolated runtime selections, enforce tool ceilings,
pin MCP contracts, and share the lifecycle gate with first-prompt admission.
Expose independent HTTP, CLI, ACP and Studio management and selection controls,
with migration, native import/export, diagnostics and read-only history recovery.

Verification: composition mechanism tests, provider-wire cache tests, lifecycle
rollback tests, sensitive-boundary regressions, frontend typecheck and all 5,219
frontend tests; gofmt, vet, lint and repository checks. Browser light/dark wide
and 390px screenshots and real Electron backend interactions are included.

Refs: #13588
2026-10-17 19:09:49 +08:00
SivanCola ce29aae765 docs(release): note for the Studio persona page 2026-10-17 17:03:44 +08:00
SivanCola 27434c885f feat(studio): a persona page that writes the user config for new sessions 2026-10-17 17:01:53 +08:00
SivanCola 21d8cc2fa9 feat(persona): compose a user persona into the cache-stable system prompt 2026-10-17 17:01:51 +08:00
4672 changed files with 257285 additions and 102458 deletions

No files matched your search

+3
View File
@@ -5,6 +5,7 @@
*.bat text eol=crlf
*.css text eol=lf
*.go text eol=lf
*.golden text eol=lf
*.html text eol=lf
*.js text eol=lf
*.json text eol=lf
@@ -25,6 +26,8 @@
*.xml text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
Makefile text eol=lf
.githooks/** text eol=lf
benchmarks/**/*.sh text eol=lf
internal/runtime/agent/testdata/perseveration/** -text
desktop/frontend-next/perf/media/delivery.pdf binary
+7
View File
@@ -0,0 +1,7 @@
# Logins allowed to open pull requests, one per line, matched case-insensitively.
# Repository owners, members and collaborators, and bots, are always allowed.
lancy69
chuiwenwei
xxoingr
kevin9327
imcreazy
+3
View File
@@ -2,6 +2,9 @@
Target branch: 2.x features and fixes go to `studio`. 1.x takes bug fixes,
provider/API compatibility, release/updater, security and platform stability
only, on `main-v2`.
Pull requests are limited to maintainers and invited contributors; one from
anyone else is closed automatically. Open an issue instead.
-->
## Summary
+37
View File
@@ -0,0 +1,37 @@
name: acp-conformance
# The agent is driven over stdio by the official ACP TypeScript SDK, and every
# message it writes is held to the schema that SDK ships. Not a required check:
# it runs when the ACP frontend or the harness changes, and on request.
on:
pull_request:
branches: [main-v2, studio]
paths:
- "internal/frontend/acp/**"
- "tools/acp-conformance/**"
- ".github/workflows/acp-conformance.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: acp-conformance-${{ github.ref }}
cancel-in-progress: true
jobs:
conformance:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "22"
- run: make acp-conformance
+293
View File
@@ -0,0 +1,293 @@
name: Android
# The Android shell under `android/` ships from the same tag the desktop line
# publishes, and lands on the same release page.
#
# It is a separate workflow rather than a job inside release-studio.yml for two
# reasons. The toolchains share nothing — Go, the Android SDK, a Gradle wrapper —
# so a runner carrying both would install three toolchains to build one artifact.
# And `signing-contract` pins which workflows may reach the SignPath credentials;
# this one never touches them, and keeping it out of that graph is what keeps the
# fingerprint stable.
#
# A failure here does not hold back the desktop release: the two are independent
# by construction, and an APK is worth less than the desktop builds it sits
# beside.
on:
push:
tags: ['studio-v*']
workflow_dispatch:
inputs:
tag:
description: "Existing studio tag to build for and attach to (for example studio-v2.51.0)"
required: false
type: string
ref:
description: "A branch to build as a trial run of this workflow; nothing is attached to any release"
required: false
type: string
permissions:
contents: write # upload the APK to the release the tag belongs to
concurrency:
group: android-${{ inputs.tag || inputs.ref || github.ref_name }}
cancel-in-progress: false
jobs:
resolve:
name: resolve the tag
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
sha: ${{ steps.release.outputs.sha }}
candidate: ${{ steps.release.outputs.candidate }}
publish: ${{ steps.release.outputs.publish }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
persist-credentials: false
# The same tag grammar release-studio.yml enforces, spelled the same way:
# two workflows that disagree about which tags name a release would publish
# one artifact for a tag the other refused.
- name: Resolve tag and version
id: release
env:
IN_TAG: ${{ inputs.tag }}
IN_REF: ${{ inputs.ref }}
REF_NAME: ${{ github.ref_name }}
run: |
# A trial run builds a branch as a throwaway version and publishes nothing.
if [ -n "$IN_REF" ] && [ -z "$IN_TAG" ]; then
sha="$(git rev-parse --verify "origin/$IN_REF^{commit}")"
{ echo "tag="; echo "version=v0.0.0-dev.${sha:0:7}"; echo "sha=$sha"
echo "candidate=false"; echo "publish=false"; } >> "$GITHUB_OUTPUT"
echo "trial build of $IN_REF at $sha"
exit 0
fi
tag="${IN_TAG:-$REF_NAME}"
semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$'
if [[ ! "$tag" =~ ^studio-v(.+)$ ]] || [[ ! "${BASH_REMATCH[1]}" =~ $semver ]]; then
echo "::error::studio release tag must be studio-vMAJOR.MINOR.PATCH[-PRERELEASE], got: $tag"
exit 1
fi
sha="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
if ! git merge-base --is-ancestor "$sha" origin/studio; then
echo "::error::$tag is not on studio history"
exit 1
fi
version="${tag#studio-}"
candidate=false
if [[ "${version#v}" =~ $semver ]] && [ -n "${BASH_REMATCH[4]}" ]; then
candidate=true
fi
{ echo "tag=$tag"; echo "version=$version"; echo "sha=$sha"
echo "candidate=$candidate"; echo "publish=true"; } >> "$GITHUB_OUTPUT"
echo "resolved $tag -> $version at $sha (candidate=$candidate)"
build:
name: build the APK
needs: resolve
runs-on: ubuntu-latest
permissions:
contents: read
# `publish` decides whether to attach anything, and the only honest answer to
# "will this APK install" is whether a key was restored.
outputs:
signed: ${{ steps.key.outputs.signed }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
cache-dependency-path: go.sum
# pnpm first: setup-node's pnpm cache needs the executable to exist.
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v4
with:
version: 10
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
distribution: temurin
java-version: "21"
cache: gradle
# The SDK is the one thing nothing sets for you, per android/README.md. The
# runner image carries one; this asserts the two pieces the build names
# rather than re-installing them, so a missing package fails here by name
# instead of surfacing later as an unresolved compileSdk.
- name: Provide the Android SDK
run: |
if [ -z "${ANDROID_HOME:-}" ]; then
echo "::error::ANDROID_HOME is unset; the runner image is expected to provide the Android SDK"
exit 1
fi
for piece in "platforms/android-35" "build-tools/35.0.0"; do
if [ ! -d "$ANDROID_HOME/$piece" ]; then
echo "::error::the Android SDK at $ANDROID_HOME has no $piece; android/README.md names it as a requirement"
exit 1
fi
done
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV"
# A key that exists signs; a key that does not leaves the artifact
# unsigned, which is what a release with no key has always produced. The
# decision is the repository's, not this workflow's: it reads the four
# secrets android/README.md already names.
- name: Restore the signing key
id: key
env:
KEYSTORE_BASE64: ${{ secrets.REASONIX_KEYSTORE_BASE64 }}
STORE_PASSWORD: ${{ secrets.REASONIX_KEYSTORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.REASONIX_KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.REASONIX_KEY_ALIAS }}
run: |
if [ -z "$KEYSTORE_BASE64" ] || [ -z "$STORE_PASSWORD" ] || [ -z "$KEY_ALIAS" ]; then
echo "signed=false" >> "$GITHUB_OUTPUT"
echo "no signing key configured: the APK will be unsigned"
exit 0
fi
mkdir -p android/keystore
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > android/keystore/release.jks
{
echo "storeFile=keystore/release.jks"
echo "storePassword=${STORE_PASSWORD}"
echo "keyAlias=${KEY_ALIAS}"
# PKCS#12 keystores use one password for both; only write the second
# when it is genuinely a different fact.
[ -n "$KEY_PASSWORD" ] && echo "keyPassword=${KEY_PASSWORD}"
} > android/keystore/keystore.properties
chmod 600 android/keystore/*
echo "signed=true" >> "$GITHUB_OUTPUT"
echo "signing key restored"
- name: Build the kernel and the page
working-directory: android
env:
VERSION: ${{ needs.resolve.outputs.version }}
run: |
export REASONIX_APP_VERSION="${VERSION#v}"
bash ./scripts/build-kernel.sh
bash ./scripts/sync-page.sh
# The one end-to-end check that needs no phone: the kernel the shell starts,
# serving the page the APK carries. Running it here means an APK that would
# open blank fails the build instead of the install.
- name: Verify the packaged page
working-directory: android
run: |
(cd .. && go build -o reasonix-verify ./cmd/reasonix)
KERNEL_BIN="$PWD/../reasonix-verify" bash ./scripts/verify-page.sh
- name: Assemble
working-directory: android
env:
VERSION: ${{ needs.resolve.outputs.version }}
run: |
# The version comes from the tag, so the About screen and the update
# check name this release. versionCode keeps its existing shape, which
# is why the build digit stays 1: it is a single decimal place, and a
# number that grows past it would collide with the next patch.
./gradlew assembleRelease --no-daemon "-PreasonixVersion=${VERSION#v}"
- name: Name the artifact
id: apk
working-directory: android/app/build/outputs/apk/release
run: |
src="$(ls ./*.apk | head -n 1)"
if [ -z "$src" ]; then
echo "::error::assembleRelease produced no APK"
exit 1
fi
case "$src" in
*-unsigned.apk) name="ReasonixMobile-${{ needs.resolve.outputs.version }}-unsigned.apk" ;;
*) name="ReasonixMobile-${{ needs.resolve.outputs.version }}.apk" ;;
esac
mv "$src" "$name"
sha256sum "$name" > "$name.sha256"
{
echo "path=$PWD/$name"
echo "name=$name"
} >> "$GITHUB_OUTPUT"
ls -l "$name" "$name.sha256"
# An unsigned package is not a lesser APK, it is an unusable one: Android's
# package manager rejects it outright rather than reporting a signature
# mismatch. Say so where the run summary can be read, instead of letting a
# green build imply a shippable artifact.
- name: Say what an unsigned build means
if: steps.key.outputs.signed != 'true'
run: |
echo "::warning::no signing key configured, so this APK is unsigned. Android refuses to install an unsigned package, so the release will carry no APK; the file stays a workflow artifact for whoever signs it."
{
echo "### No APK on this release"
echo ""
echo "The build produced an unsigned APK, which Android refuses to install."
echo "It is attached to this run as a workflow artifact only. Add the four"
echo "signing secrets to publish one."
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v8
with:
name: android-apk
path: |
android/app/build/outputs/apk/release/*.apk
android/app/build/outputs/apk/release/*.sha256
if-no-files-found: error
publish:
name: attach the APK
needs: [resolve, build]
# Nothing to attach unless the build was signed. Skipping is the honest
# outcome: a release page with an APK nobody can install is worse than a
# release page without one.
if: needs.build.outputs.signed == 'true' && needs.resolve.outputs.publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: android-apk
path: dist
# The release itself belongs to release-studio.yml, which creates it and
# owns its notes. This waits for it rather than creating a second one: two
# jobs racing to define a release is how a page ends up with the wrong body.
- name: Attach to the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
REPO: ${{ github.repository }}
run: |
# 90 minutes: on the last Studio release the page appeared about 40
# minutes after the tag run started, and the APK build spends part of
# that before this wait begins.
for _ in $(seq 1 360); do
if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
gh release upload "$TAG" dist/*.apk dist/*.sha256 --repo "$REPO" --clobber
echo "attached to $TAG"
exit 0
fi
sleep 15
done
echo "::error::release $TAG did not appear within 90 minutes; the APK was built but not published"
exit 1
+676 -44
View File
@@ -15,7 +15,7 @@ permissions:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/studio' }}
jobs:
# Cheap path gate for pull requests. PRs confined to docs/release-notes
@@ -179,9 +179,27 @@ jobs:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test ./...
- name: test (Windows composition links)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(CaptureDirectoryMaterializesContainedLinks|CaptureDirectoryRefusesUnsafeLinks|CaptureHookFileFlattensContainedLink|MaterializeStillRejectsLinkedRetainedFilesAndRoot|WindowsCompositionJunctions|WindowsLiveSkillJunctions|CompositionRetainsLinkedSkillAcrossRestart|CompositionLinkedSkillRejectsLinkedResources|CompositionLinkedExtensionResources|CompositionLinkedHookResources)$' ./internal/state/agentpreset ./internal/ext/skill ./internal/assembly/boot | tee "$RUNNER_TEMP/composition-links.jsonl"
jq -se '
["TestCaptureDirectoryMaterializesContainedLinks", "TestCaptureDirectoryRefusesUnsafeLinks",
"TestCaptureHookFileFlattensContainedLink", "TestMaterializeStillRejectsLinkedRetainedFilesAndRoot",
"TestWindowsCompositionJunctions", "TestWindowsLiveSkillJunctions", "TestCompositionRetainsLinkedSkillAcrossRestart",
"TestCompositionLinkedSkillRejectsLinkedResources", "TestCompositionLinkedExtensionResources",
"TestCompositionLinkedHookResources"] as $required |
[.[] | select(.Action == "pass") | .Test] as $passed |
all(.[]; .Action != "skip") and (($required - $passed) | length == 0)
' "$RUNNER_TEMP/composition-links.jsonl"
# Pull requests run a Windows smoke suite: the packages that actually
# carry *_windows.go code, the startup/checkpoint packages with portable
# filesystem contracts, plus cmd/. The full ./... sweep stays on pushes
# filesystem contracts, plus cmd/. Isolation/worktree/mutation have a
# complete JSON inventory below. The full ./... sweep stays on pushes
# to main-v2; the Unix legs always run the full suite. Keep the job-level
# budget above normal 7-9 minute runner variance; each package test binary
# remains independently bounded by Go's per-package timeout below.
@@ -205,11 +223,542 @@ jobs:
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
# Worktree rollback is absent from the broad Windows PR smoke list.
- name: test (Windows worktree rollback)
- name: test (Windows published command job owner)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 3
run: go test -count=1 -timeout=2m -run '^TestDiscardUnpublished.*OnWindows$|^TestCreateFromDetachedHEADAndOccupiedBranch$' ./internal/platform/worktree
shell: bash
run: |
set -euo pipefail
evidence="$RUNNER_TEMP/published-job-owner"
mkdir -p "$evidence"
go test -count=1 -timeout=3m -json -run '^TestPublishedJob' ./internal/base/proc | tee "$evidence/positive.jsonl"
node scripts/published-job-evidence.mjs positive "$evidence/positive.jsonl"
node scripts/published-job-evidence.mjs overlay "$evidence"
set +e
go test -count=1 -timeout=3m -json -overlay="$evidence/overlay.json" -run '^TestPublishedJob' ./internal/base/proc > "$evidence/negative.jsonl"
old_result=$?
set -e
test "$old_result" -eq 1
node scripts/published-job-evidence.mjs negative "$evidence/negative.jsonl"
- name: upload Windows published command job owner evidence
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: published-job-owner-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/published-job-owner/
if-no-files-found: error
retention-days: 7
- name: test (Windows isolation owners)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 25
shell: bash
run: |
set -o pipefail
go test -count=1 -p 4 -timeout=18m -json ./internal/platform/worktree/... ./internal/state/mutation/... | tee "$RUNNER_TEMP/isolation-owners.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $passed |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
(["reasonix/internal/platform/worktree", "reasonix/internal/state/mutation"] - $passed | length) == 0 and
(["TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings",
"TestLegacyWaitDoesNotConsumeExecutionCapacity"] - $tests | length) == 0
' "$RUNNER_TEMP/isolation-owners.jsonl"
jq -se '
. as $events |
{"reasonix/internal/platform/worktree": [
"TestWriteChangesRejectsRedirectedDeletion",
"TestWriteChangesUnlinksLeafDirectoryLink"
]} |
to_entries | all(.[]; . as $package |
([ $events[] | select(.Package == $package.key and .Test == null and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
length == 1 and .[0].Action == "pass") and
all(.value[]; . as $test |
[ $events[] | select(.Package == $package.key and .Test == $test and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
length == 1 and .[0].Action == "pass"))
' "$RUNNER_TEMP/isolation-owners.jsonl"
- name: upload Windows isolation test inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: isolation-owners-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/isolation-owners.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows delegated Goal usage scope)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(UsageScope.*)$' ./internal/contract/event ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/goal-usage-scope.jsonl"
jq -se '
["TestUsageScopeKeepsEarlierEventOwner",
"TestUsageScopeWithoutCapability" ] as $required |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
all(.[]; .Action != "fail" and .Action != "skip") and
(($required - $passed) | length == 0) and
((["reasonix/internal/contract/event", "reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages) | length == 0)
' "$RUNNER_TEMP/goal-usage-scope.jsonl"
- name: upload Windows delegated Goal usage scope inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: goal-usage-scope-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/goal-usage-scope.jsonl
if-no-files-found: error
retention-days: 7
- name: test (Windows Controller routing root budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(SemanticRootUsage)$' ./internal/runtime/capability | tee "$RUNNER_TEMP/controller-routing-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/capability"] - $packages | length) == 0 and
([
"TestSemanticRootUsage",
"TestSemanticRootUsage/billed_error",
"TestSemanticRootUsage/cache",
"TestSemanticRootUsage/estimated",
"TestSemanticRootUsage/invalid_answer",
"TestSemanticRootUsage/missing",
"TestSemanticRootUsage/nil_sink",
"TestSemanticRootUsage/no_observer",
"TestSemanticRootUsage/reported"
] - $tests | length) == 0
' "$RUNNER_TEMP/controller-routing-budget.jsonl"
- name: upload Windows Controller routing root budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: controller-routing-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/controller-routing-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows Coordinator root budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(PreparedRootBudget|CoordinatorRootBudget|EffectCoordinatorRoot)' ./internal/runtime/agent ./internal/runtime/coordinator | tee "$RUNNER_TEMP/coordinator-root-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/agent", "reasonix/internal/runtime/coordinator"] - $packages | length) == 0 and
(["TestCoordinatorRootBudgetRoutes",
"TestCoordinatorRootBudgetRoutes/raw", "TestCoordinatorRootBudgetRoutes/tool_planner",
"TestCoordinatorRootBudgetRoutes/failure_fallback", "TestCoordinatorRootBudgetRoutes/plan_only_continuation",
"TestCoordinatorRootBudgetRoutes/approval_declined_continuation", "TestCoordinatorRootBudgetRoutes/approval_allowed"
] - $tests | length) == 0
' "$RUNNER_TEMP/coordinator-root-budget.jsonl"
- name: upload Windows Coordinator root budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: coordinator-root-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/coordinator-root-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows resumed Goal planner budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(GoalResumeRenewsPlannerBudget|GoalNonBudgetResumeKeepsPlannerBudget|GoalTokenBudgetPausesAndResumes|EffectGoalResumeRenewsPlannerBudget)$' ./internal/session/control ./internal/assembly/boot | tee "$RUNNER_TEMP/goal-planner-resume.jsonl"
jq -se '
["TestGoalResumeRenewsPlannerBudget",
"TestGoalNonBudgetResumeKeepsPlannerBudget",
"TestGoalNonBudgetResumeKeepsPlannerBudget/manual_pause",
"TestGoalNonBudgetResumeKeepsPlannerBudget/not_resumable",
"TestGoalTokenBudgetPausesAndResumes",
"TestEffectGoalResumeRenewsPlannerBudget"] as $required |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
all(.[]; .Action != "fail" and .Action != "skip") and
(($required - $passed) | length == 0) and
((["reasonix/internal/session/control", "reasonix/internal/assembly/boot"] - $packages) | length == 0)
' "$RUNNER_TEMP/goal-planner-resume.jsonl"
- name: upload Windows resumed Goal planner budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: goal-planner-resume-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/goal-planner-resume.jsonl
if-no-files-found: error
retention-days: 7
- name: test (Windows root wall budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RootWall|EffectRootWall)' ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/root-wall-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestRootWallBudgetReachesDelegatedAgents", "TestRootWallBudgetReachesDelegatedAgents/foreground",
"TestRootWallBudgetReachesDelegatedAgents/nested", "TestRootWallBudgetReachesDelegatedAgents/siblings",
"TestRootWallBudgetReachesDelegatedAgents/context", "TestRootWallBudgetReachesDelegatedAgents/unconfigured",
"TestRootWallBudgetReachesDelegatedAgents/explicit_zero", "TestRootWallBudgetContinuationAndReset",
"TestRootWallBudgetContinuationAndReset/continuation", "TestRootWallBudgetContinuationAndReset/new_task",
"TestRootWallBudgetContinuationAndReset/reset", "TestRootWallBudgetContinuationAndReset/override"
] - $tests | length) == 0
' "$RUNNER_TEMP/root-wall-budget.jsonl"
- name: upload Windows root wall budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: root-wall-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/root-wall-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows root token budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RootToken|EffectRootToken|BudgetCountsKnownReportedTokens|ReportedToken)' ./internal/runtime/agent | tee "$RUNNER_TEMP/root-token-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/agent"] - $packages | length) == 0 and
(["TestRootTokenBudgetIncludesChildProviderUsage", "TestRootTokenBudgetIncludesChildProviderUsage/configured",
"TestRootTokenBudgetIncludesChildProviderUsage/context", "TestRootTokenBudgetIncludesChildProviderUsage/unconfigured",
"TestRootTokenBudgetIncludesChildProviderUsage/explicit_zero", "TestRootTokenBudgetSurvivesNestedAndConcurrentChildren",
"TestRootTokenBudgetSurvivesNestedAndConcurrentChildren/nested", "TestRootTokenBudgetSurvivesNestedAndConcurrentChildren/siblings",
"TestRootTokenBudgetSurvivesNestedAndConcurrentChildren/child_override", "TestRootTokenBudgetContinuationAndReset",
"TestRootTokenBudgetContinuationAndReset/continuation", "TestRootTokenBudgetContinuationAndReset/new_task",
"TestRootTokenBudgetContinuationAndReset/reset" ] - $tests | length) == 0
' "$RUNNER_TEMP/root-token-budget.jsonl"
jq -se '
. as $events |
{"reasonix/internal/runtime/agent": [
"TestBudgetCountsKnownReportedTokens",
"TestBudgetCountsKnownReportedTokens/split",
"TestBudgetCountsKnownReportedTokens/total_only",
"TestBudgetCountsKnownReportedTokens/partial_split",
"TestBudgetCountsKnownReportedTokens/lower_total",
"TestBudgetCountsKnownReportedTokens/missing_total",
"TestBudgetCountsKnownReportedTokens/negative_split",
"TestBudgetCountsKnownReportedTokens/zero",
"TestBudgetCountsKnownReportedTokens/negative",
"TestReportedTokenCountersSaturate"]} |
to_entries | all(.[]; . as $package |
([ $events[] | select(.Package == $package.key and .Test == null and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
length == 1 and .[0].Action == "pass") and
all(.value[]; . as $test |
[ $events[] | select(.Package == $package.key and .Test == $test and
(.Action == "pass" or .Action == "fail" or .Action == "skip")) ] |
length == 1 and .[0].Action == "pass"))
' "$RUNNER_TEMP/root-token-budget.jsonl"
- name: upload Windows root token budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: root-token-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/root-token-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows budget usage completeness)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RequestOnlyUsage|RunBudgetMissingUsage|TaskBudgetKnownCost|RunBudgetExplicitZero|TaskBudgetUsageCompleteness|TaskBudgetCostUses|EffectBudgetCompleteness|TaskBudgetGateIgnoresCostWhenUnpriced)' ./internal/contract/provider ./internal/runtime/agent ./internal/assembly/boot | tee "$RUNNER_TEMP/budget-usage.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/contract/provider", "reasonix/internal/runtime/agent", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestRequestOnlyUsageDoesNotCertifyTokenAccounting", "TestRunBudgetMissingUsageKeepsCostIncomplete",
"TestRunBudgetMissingUsageKeepsCostIncomplete/complete", "TestRunBudgetMissingUsageKeepsCostIncomplete/missing_first",
"TestRunBudgetMissingUsageKeepsCostIncomplete/missing_last", "TestRunBudgetMissingUsageKeepsCostIncomplete/all_missing",
"TestTaskBudgetKnownCostStillLandsAfterMissingUsage", "TestRunBudgetExplicitZeroUsageIsPriced",
"TestTaskBudgetUsageCompletenessFollowsItsLifetime", "TestTaskBudgetUsageCompletenessFollowsItsLifetime/continuation",
"TestTaskBudgetUsageCompletenessFollowsItsLifetime/new_task", "TestTaskBudgetUsageCompletenessFollowsItsLifetime/explicit_reset",
"TestTaskBudgetCostUsesOnlyTheReportedSubtotal", "TestTaskBudgetGateIgnoresCostWhenUnpriced",
"TestEffectBudgetCompletenessFromOpenAIStream", "TestEffectBudgetCompletenessFromOpenAIStream/missing_0",
"TestEffectBudgetCompletenessFromOpenAIStream/missing_1", "TestEffectBudgetCompletenessFromOpenAIStream/missing_2"] - $tests | length) == 0
' "$RUNNER_TEMP/budget-usage.jsonl"
- name: upload Windows budget usage inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: budget-usage-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/budget-usage.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows Git storage environment)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(HostStorageEnvironment|HostObjectStorage|HostExplicitStorageControls|DetachedStorageEnvironment)$' ./internal/platform/gitcmd ./internal/platform/worktree ./internal/assembly/boot | tee "$RUNNER_TEMP/git-storage-environment.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/platform/gitcmd", "reasonix/internal/platform/worktree", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
([
"TestDetachedStorageEnvironment",
"TestDetachedStorageEnvironment/explicit_env",
"TestDetachedStorageEnvironment/inherited_env",
"TestHostExplicitStorageControls",
"TestHostExplicitStorageControls/explicit_object_directory",
"TestHostExplicitStorageControls/private_index",
"TestHostExplicitStorageControls/repository_declared_alternate",
"TestHostObjectStorage",
"TestHostObjectStorage/ambient_alternate_not_read",
"TestHostObjectStorage/writes_stay_in_repository",
"TestHostStorageEnvironment",
"TestHostStorageEnvironment/GIT_ALTERNATE_OBJECT_DIRECTORIES",
"TestHostStorageEnvironment/GIT_INDEX_FILE",
"TestHostStorageEnvironment/GIT_OBJECT_DIRECTORY"
] - $tests | length) == 0
' "$RUNNER_TEMP/git-storage-environment.jsonl"
- name: upload Windows Git storage environment inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: git-storage-environment-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/git-storage-environment.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows Anthropic interrupted usage)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(InterruptedUsage|EffectAnthropicPartialUsageRecovery)' ./internal/model/anthropic ./internal/assembly/boot | tee "$RUNNER_TEMP/anthropic-interrupted-usage.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/model/anthropic", "reasonix/internal/assembly/boot"] - $packages | length) == 0 and
(["TestInterruptedUsageTerminal", "TestInterruptedUsageTerminal/upstream_error",
"TestInterruptedUsageTerminal/payload_error", "TestInterruptedUsageTerminal/premature_eof",
"TestInterruptedUsageTerminal/decode_error", "TestInterruptedUsageTerminal/reported_zero",
"TestInterruptedUsageTerminal/missing_control", "TestInterruptedUsageTerminal/complete_control",
"TestInterruptedUsageBoundedConsumer", "TestInterruptedUsageReadErrorAndCancellation",
"TestInterruptedUsageReadErrorAndCancellation/read_error", "TestInterruptedUsageReadErrorAndCancellation/cancelled_buffered_reader",
"TestInterruptedUsageAbandonedConsumer", "TestInterruptedUsageIdleTimeout",
"TestEffectAnthropicPartialUsageRecovery", "TestEffectAnthropicPartialUsageRecovery/upstream_error",
"TestEffectAnthropicPartialUsageRecovery/premature_eof", "TestEffectAnthropicPartialUsageRecovery/complete_control"] - $tests | length) == 0
' "$RUNNER_TEMP/anthropic-interrupted-usage.jsonl"
- name: upload Windows Anthropic interrupted usage inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: anthropic-interrupted-usage-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/anthropic-interrupted-usage.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows root websearch budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RootWebSearch|EffectRootWebSearch)' ./internal/tools/websearch | tee "$RUNNER_TEMP/root-websearch-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/tools/websearch"] - $packages | length) == 0 and
(["TestRootWebSearchUsageTerminal", "TestRootWebSearchUsageTerminal/reported",
"TestRootWebSearchUsageTerminal/no_reporter", "TestRootWebSearchUsageTerminal/missing",
"TestRootWebSearchUsageTerminal/no_observer", "TestRootWebSearchUsageTerminal/input_refused",
"TestRootWebSearchUsageTerminal/no_factory", "TestRootWebSearchUsageTerminal/factory_error",
"TestRootWebSearchUsageTerminal/billed_failure", "TestRootWebSearchUsageTerminal/cancel_after_usage",
"TestRootWebSearchUsageTerminal/cancel_before_start", "TestRootWebSearchUsageTerminal/stream_error"
] - $tests | length) == 0
' "$RUNNER_TEMP/root-websearch-budget.jsonl"
- name: upload Windows root websearch budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: root-websearch-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/root-websearch-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows explicit skill root budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^(TestProfileRootBudgetLifetime|TestProfileBudgetPreparationUsesTurnAdmission|TestEffectExplicitSkillRootBudget)$' ./internal/session/control | tee "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/session/control"] - $packages | length) == 0 and
(["TestProfileBudgetPreparationUsesTurnAdmission" ] - $tests | length) == 0
' "$RUNNER_TEMP/explicit-skill-root-budget.jsonl"
- name: upload Windows explicit skill root budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: explicit-skill-root-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/explicit-skill-root-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows root triage budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(RootTriage|EffectRootTriage)' ./internal/runtime/agent | tee "$RUNNER_TEMP/root-triage-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/runtime/agent"] - $packages | length) == 0 and
(["TestRootTriageTokenBudget", "TestRootTriageTokenBudget/classifier", "TestRootTriageTokenBudget/screen",
"TestRootTriageTokenBudget/billed_failure", "TestRootTriageTokenBudget/missing", "TestRootTriageTokenBudget/disabled",
"TestRootTriageTokenBudget/no_sink", "TestRootTriageTokenBudget/counts_without_total",
"TestRootTriageOutsideRunDoesNotCreateScope" ] - $tests | length) == 0
' "$RUNNER_TEMP/root-triage-budget.jsonl"
- name: upload Windows root triage budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: root-triage-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/root-triage-budget.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows local cost quote)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(LocalBudgetUsesOccurrenceQuote|LocalCost.*|UsageQuote.*)$' ./internal/contract/event ./internal/runtime/agent | tee "$RUNNER_TEMP/local-cost-quote.jsonl"
jq -se '
["TestLocalBudgetUsesOccurrenceQuote", "TestLocalBudgetUsesOccurrenceQuote/peak", "TestLocalBudgetUsesOccurrenceQuote/offpeak",
"TestLocalCostKeepsOriginalCurrencyBuckets", "TestLocalCostFixedPointThresholdAndUnknown",
"TestUsageQuoteUsesOneOwnerAndOccurrence", "TestUsageQuoteLegacyAndNilSinks"
] as $required |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $passed |
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
all(.[]; .Action != "fail" and .Action != "skip") and
(($required - $passed) | length == 0) and
((["reasonix/internal/contract/event", "reasonix/internal/runtime/agent"] - $packages) | length == 0)
' "$RUNNER_TEMP/local-cost-quote.jsonl"
- name: upload Windows local cost quote inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: local-cost-quote-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/local-cost-quote.jsonl
if-no-files-found: error
retention-days: 7
- name: test (Windows estimated usage quotes)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(EstimatedUsage|EffectEstimatedUsage|ToWireUsagePayloadJSON$|StatusExtensionTracksMultipleSessionsAndUsage$|CacheSplitProvenanceIsRecorded$|RecordedPlainCallIsUnchanged$)' ./internal/contract/pricing ./internal/contract/event ./internal/contract/config ./internal/assembly/boot ./internal/contract/eventwire ./internal/frontend/acp ./internal/state/stats | tee "$RUNNER_TEMP/estimated-quote.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/contract/pricing", "reasonix/internal/contract/event", "reasonix/internal/contract/config", "reasonix/internal/assembly/boot",
"reasonix/internal/contract/eventwire", "reasonix/internal/frontend/acp", "reasonix/internal/state/stats"] - $packages | length) == 0 and
(["TestEstimatedUsageQuotePreservesAmountsWithoutCompleteFacts", "TestEstimatedUsageQuotePreservesAmountsWithoutCompleteFacts/CNY",
"TestEstimatedUsageQuotePreservesAmountsWithoutCompleteFacts/USD", "TestEstimatedUsageLedgerRetainsPartialCoverage",
"TestEstimatedUsageLedgerRetainsPartialCoverage/separate_keys", "TestEstimatedUsageLedgerRetainsPartialCoverage/same_key",
"TestEstimatedUsageSavedQuoteReader", "TestEstimatedUsageQuoteSinkCoversTurnAndGuardian",
"TestEstimatedUsageQuoteSinkCoversTurnAndGuardian/turn", "TestEstimatedUsageQuoteSinkCoversTurnAndGuardian/guardian",
"TestEstimatedUsageConfiguredQuote", "TestEffectEstimatedUsageQuoteAfterStreamRecovery",
"TestEffectEstimatedUsageQuoteAfterStreamRecovery/reported_control", "TestEffectEstimatedUsageQuoteAfterStreamRecovery/interrupted",
"TestToWireUsagePayloadJSON", "TestStatusExtensionTracksMultipleSessionsAndUsage", "TestCacheSplitProvenanceIsRecorded", "TestRecordedPlainCallIsUnchanged"] - $tests | length) == 0
' "$RUNNER_TEMP/estimated-quote.jsonl"
- name: upload Windows estimated quote inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: estimated-quote-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/estimated-quote.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows bounded root budget)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(AuxiliaryUsageObserver|BoundedUsageObserver|RootBounded|EffectRootBounded)' ./internal/contract/provider ./internal/model/boundedllm ./internal/runtime/agent | tee "$RUNNER_TEMP/bounded-root-budget.jsonl"
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
all(.[]; .Action != "skip" and .Action != "fail") and
(["reasonix/internal/contract/provider", "reasonix/internal/model/boundedllm",
"reasonix/internal/runtime/agent"] - $packages | length) == 0 and
(["TestAuxiliaryUsageObserverReplacesAndCopies",
"TestBoundedUsageObserverOwnsOneTerminal", "TestBoundedUsageObserverOwnsOneTerminal/reported",
"TestBoundedUsageObserverOwnsOneTerminal/billed_failure", "TestBoundedUsageObserverOwnsOneTerminal/without_sink",
"TestBoundedUsageObserverOwnsOneTerminal/missing", "TestBoundedUsageObserverOwnsOneTerminal/input_refused"
] - $tests | length) == 0
' "$RUNNER_TEMP/bounded-root-budget.jsonl"
- name: upload Windows bounded root budget inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: bounded-root-budget-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/bounded-root-budget.jsonl
if-no-files-found: ignore
retention-days: 7
# The general Windows PR smoke list intentionally omits internal/contract/tool.
# Keep the session-temp portability contract covered without widening the
@@ -228,40 +777,6 @@ jobs:
timeout-minutes: 5
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
# Full push suite uses the same 18m package timeout as Windows smoke and a
# 35m step budget above it. -count=1 as at the smoke step. A failure prints
# the failed tests' output and the 15 slowest boot tests.
- name: test (full)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
timeout-minutes: 35
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
# built around.
REASONIX_RELEASE_CACHE_GUARD: "1"
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
shell: bash
run: |
go test -count=1 -p 4 -timeout=18m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
- name: test (full) failure detail
if: failure() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
shell: bash
run: |
f="$RUNNER_TEMP/full.jsonl"
[ -s "$f" ] || exit 0
echo "::group::output of failed tests"
jq -rn '[inputs | select(.Test != null)] | group_by(.Package + "/" + .Test)
| map(select(any(.[]; .Action == "fail")))[]
| map(select(.Action == "output") | .Output) | join("")' "$f" || true
echo "::endgroup::"
echo "slowest internal/assembly/boot tests (seconds, name):"
jq -rn '[inputs | select((.Package | endswith("/internal/assembly/boot")) and .Test != null and .Action == "pass")]
| sort_by(-.Elapsed)[:15][] | "\(.Elapsed)\t\(.Test)"' "$f" || true
race:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
@@ -296,13 +811,130 @@ jobs:
if: github.event_name == 'pull_request'
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./internal/runtime/agent/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
run: go test -race ./internal/runtime/agent/... ./internal/runtime/delegation/... ./internal/platform/worktree/... ./internal/state/mutation/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
- name: test -race (full)
if: github.event_name != 'pull_request'
# Push-only full suites. internal/frontend/serve and internal/assembly/boot run
# hundreds of serial tests and set the wall time of one whole-tree `go test`,
# so each is cut by test name into shards that run on their own runners. The
# -run / -skip patterns partition the names exactly: every test lands in one
# shard. `rest` is every other package. Pull requests keep the focused steps
# in the `test` and `race` jobs above.
test-windows-shards:
if: github.event_name != 'pull_request'
name: test (windows-latest, ${{ matrix.shard }})
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include:
- { shard: rest }
- { shard: serve-1, pkg: ./internal/frontend/serve, flag: -run, filter: '^Test[A-E]' }
- { shard: serve-2, pkg: ./internal/frontend/serve, flag: -run, filter: '^Test[F-R]' }
- { shard: serve-3, pkg: ./internal/frontend/serve, flag: -skip, filter: '^Test[A-R]' }
- { shard: boot-1, pkg: ./internal/assembly/boot, flag: -skip, filter: '^TestEffect([^0-9A-C]|$)' }
- { shard: boot-2, pkg: ./internal/assembly/boot, flag: -run, filter: '^TestEffect[D-O]' }
- { shard: boot-3, pkg: ./internal/assembly/boot, flag: -run, filter: '^TestEffect([^0-9A-O]|$)' }
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- name: test (shard)
timeout-minutes: 40
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
# built around.
REASONIX_RELEASE_CACHE_GUARD: "1"
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
SHARD: ${{ matrix.shard }}
PKG: ${{ matrix.pkg }}
FLAG: ${{ matrix.flag }}
FILTER: ${{ matrix.filter }}
shell: bash
run: |
if [ "$SHARD" = rest ]; then
mapfile -t pkgs < <(go list ./... | tr -d '\r' | grep -v -E '/internal/(frontend/serve|assembly/boot)$')
else
pkgs=("$PKG")
fi
filter=()
if [ -n "$FILTER" ]; then filter=("$FLAG" "$FILTER"); fi
go test -count=1 -p 4 -timeout=36m -json "${filter[@]}" "${pkgs[@]}" | tee "$RUNNER_TEMP/shard.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
- name: test (shard) failure detail
if: failure()
shell: bash
run: |
f="$RUNNER_TEMP/shard.jsonl"
[ -s "$f" ] || exit 0
echo "::group::output of failed tests"
jq -rn '[inputs | select(.Test != null)] | group_by(.Package + "/" + .Test)
| map(select(any(.[]; .Action == "fail")))[]
| map(select(.Action == "output") | .Output) | join("")' "$f" || true
echo "::endgroup::"
echo "slowest tests (seconds, package, name):"
jq -rn '[inputs | select(.Test != null and .Action == "pass")]
| sort_by(-.Elapsed)[:15][] | "\(.Elapsed) \(.Package) \(.Test)"' "$f" || true
race-shards:
if: github.event_name != 'pull_request'
name: race (${{ matrix.shard }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { shard: rest }
- { shard: serve-1, pkg: ./internal/frontend/serve, flag: -run, filter: '^Test[A-E]' }
- { shard: serve-2, pkg: ./internal/frontend/serve, flag: -run, filter: '^Test[F-R]' }
- { shard: serve-3, pkg: ./internal/frontend/serve, flag: -skip, filter: '^Test[A-R]' }
- { shard: boot-1, pkg: ./internal/assembly/boot, flag: -skip, filter: '^TestEffect([^0-9A-C]|$)' }
- { shard: boot-2, pkg: ./internal/assembly/boot, flag: -run, filter: '^TestEffect[D-O]' }
- { shard: boot-3, pkg: ./internal/assembly/boot, flag: -run, filter: '^TestEffect([^0-9A-O]|$)' }
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- name: Install and verify Linux sandbox backend
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
sudo sysctl -w kernel.unprivileged_userns_clone=1
fi
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
bwrap --ro-bind / / --dev /dev --proc /proc -- true || echo "::warning::bubblewrap is installed but this runner denies user namespaces; unavailable-backend tests will run fail-closed"
- name: test -race (shard)
timeout-minutes: 30
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./...
SHARD: ${{ matrix.shard }}
PKG: ${{ matrix.pkg }}
FLAG: ${{ matrix.flag }}
FILTER: ${{ matrix.filter }}
run: |
if [ "$SHARD" = rest ]; then
mapfile -t pkgs < <(go list ./... | grep -v -E '/internal/(frontend/serve|assembly/boot)$')
else
pkgs=("$PKG")
fi
filter=()
if [ -n "$FILTER" ]; then filter=("$FLAG" "$FILTER"); fi
go test -race -timeout=25m "${filter[@]}" "${pkgs[@]}"
# sdk/go is a nested stdlib-only module invisible to root `go test ./...`.
# Its DTOs are generated from internal/ext/extension/protocol, and the
+3 -3
View File
@@ -43,20 +43,20 @@ jobs:
if: matrix.branch != ''
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Initialize CodeQL
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
- name: Perform CodeQL Analysis
if: matrix.branch == ''
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:${{ matrix.language }}"
# Results from a checked-out line are filed under that line's ref, not the
# default branch the schedule happened to run from.
- name: Perform CodeQL Analysis (${{ matrix.branch }})
if: matrix.branch != ''
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:${{ matrix.language }}"
ref: refs/heads/${{ matrix.branch }}
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
go-version-file: go.mod
cache: true
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with:
python-version: '3.12'
+32
View File
@@ -0,0 +1,32 @@
name: Deploy website
# GitHub requires workflow_dispatch to be registered on the default branch.
# The production implementation is the same workflow path on website.
on:
workflow_dispatch:
inputs:
release_version:
description: "Optional product release version associated with this publication"
required: false
default: ""
type: string
release_request:
description: "Optional release workflow correlation ID"
required: false
default: ""
type: string
studio_version:
description: "Studio version that must be present in the generated changelog"
required: false
default: ""
type: string
permissions: {}
jobs:
require-website:
runs-on: ubuntu-latest
steps:
- run: |
echo "::error::Deploy the website with --ref website; production is restricted to that branch."
exit 1
+27
View File
@@ -0,0 +1,27 @@
name: PR author gate
# Pull requests are limited to maintainers and the logins in .github/pr-authors.txt.
# This runs in the base repository's context, checks out the base branch only, and
# never executes pull-request code; the allowlist it reads is the base branch's.
on:
pull_request_target:
types: [opened]
permissions:
contents: read
pull-requests: write
concurrency:
group: pr-author-gate-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- run: node scripts/pr-author-gate.mjs
env:
GH_TOKEN: ${{ github.token }}
+28 -1
View File
@@ -389,6 +389,14 @@ jobs:
version="${{ needs.resolve.outputs.version }}"
bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-$arch.deb" "$arch" "${version#v}"
- name: Verify the AppImage
if: runner.os == 'Linux'
shell: bash
run: |
platform="${{ matrix.platform }}"
arch="${platform#linux/}"
bash scripts/verify-linux-appimage.sh "dist/ReasonixStudio-linux-$arch.AppImage" "$arch"
# With signing on, this leg only hands the bundle on: the signing jobs
# run nothing but the signer, and windows-package packages in between.
- name: Upload the unsigned Windows bundle for signing
@@ -1199,7 +1207,7 @@ jobs:
fi
{
printf '%s\n\n' '---'
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。x86_64 用文件名带 amd64 的包,aarch64(统信 UOS、麒麟等)用带 arm64 的包。\n\n" "$windows"
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。x86_64 用文件名带 amd64 的包,aarch64(统信 UOS、麒麟等)用带 arm64 的包。也提供 \`.AppImage\`(只提供下载,不会自更新;需要 FUSE 2,或用 \`--appimage-extract-and-run\` 启动)。\n\n" "$windows"
printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
} >> "$notes"
assets=()
@@ -1294,6 +1302,25 @@ jobs:
--cache-control "public, max-age=300, stale-if-error=86400"
echo "Studio catalog -> $VERSION"
website:
name: publish the Studio website changelog
needs: [resolve, publish]
if: github.repository == 'esengine/DeepSeek-Reasonix' && needs.resolve.outputs.candidate != 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
actions: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.workflow_sha }}
- name: Deploy and verify the website changelog
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.resolve.outputs.version }}
run: bash scripts/publish-studio-changelog.sh
# A CLI channel that moves while 1.x still publishes would have two lines
# writing it, so every CLI job waits for this check. It runs no repository code.
cli-gate:
+39 -3
View File
@@ -16,7 +16,7 @@ permissions:
concurrency:
group: studio-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/studio' }}
jobs:
frontend:
@@ -87,7 +87,7 @@ jobs:
browser-guards:
name: browser guards
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 25
defaults:
run:
working-directory: desktop/frontend-next
@@ -144,14 +144,33 @@ jobs:
node perf/jump-ends.mjs
node perf/modelrows.mjs
node perf/explorersize.mjs
node perf/git-panel.mjs
node perf/session-move-to.mjs
node perf/shell-refused.mjs
node perf/external-origin.mjs
node perf/rail-search-hold.mjs
node perf/rail-toggle-panes.mjs
node perf/session-move.mjs
node perf/session-ref.mjs
node perf/browser-full.mjs
node perf/mod-ui.mjs
node perf/codeeditor-size.mjs
node perf/composer-reflow.mjs
node perf/composer-toolbar-row.mjs
node perf/action-icons.mjs
node perf/rowhit.mjs
node perf/prefs-wide-centered.mjs
node perf/tab-pip.mjs
node perf/syntax-tokens.mjs
node perf/tab-close-inside.mjs
node perf/context-card-hover.mjs
node perf/sessquick.mjs
node perf/sessionrows.mjs
node perf/session-row-marker.mjs
node perf/session-selected-row.mjs
node perf/wheel.mjs
node perf/branch-tooltip.mjs
node perf/branch-alignment.mjs
node perf/branch-menu.mjs
node perf/image-preview.mjs
node perf/user-images.mjs
@@ -159,6 +178,7 @@ jobs:
node perf/addfolder-rest.mjs
node perf/scroll-jump.mjs
node perf/add-project-selects.mjs
node perf/project-header.mjs
node perf/setup-defer.mjs
node perf/narrow.mjs
node perf/settings-rows.mjs
@@ -167,6 +187,18 @@ jobs:
node perf/mcp-actions.mjs
node perf/web-access.mjs
node perf/peers-settings.mjs
node perf/output-cap.mjs
node perf/tool-output-clip.mjs
node perf/agent-run-tab.mjs
node perf/changes-cap.mjs
node perf/session-menu-fit.mjs
node perf/rail-rows-typography.mjs
node perf/settings-nav-fit.mjs
node perf/rail-rows-align.mjs
node perf/progress-tail.mjs
node perf/trust-wait.mjs
node perf/nest-nodes.mjs
node perf/provider-ports.mjs
node perf/peer-threads.mjs
node perf/text-size.mjs
node perf/fs-scale.mjs
@@ -356,7 +388,7 @@ jobs:
arm64=false
platform=$(git diff -z --name-only --no-renames "$BASE_SHA" HEAD | bash scripts/ci-platform-scope.sh)
if git diff --name-only --no-renames "$BASE_SHA" HEAD |
grep -qE '^(desktop/electron/|desktop/packaging/|desktop/cmd/update-helper/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(linux-install-smoke|verify-linux-package|verify-studio-artifacts)\.sh$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
grep -qE '^(desktop/electron/|desktop/packaging/|desktop/cmd/update-helper/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(linux-install-smoke|verify-linux-package|verify-linux-appimage|verify-studio-artifacts)\.sh$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
arm64=true
fi
fi
@@ -600,6 +632,10 @@ jobs:
if: runner.os == 'Linux'
run: bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.deb" "${{ matrix.arch }}" 0.0.0-ci
- name: The AppImage carries its architecture, the kernel and the page
if: runner.os == 'Linux'
run: bash scripts/verify-linux-appimage.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.AppImage" "${{ matrix.arch }}"
# Recorded, not asserted: the real bundle layout, so later work argues
# from what electron-builder produced rather than from its documentation.
- name: Record the macOS bundle layout
+16
View File
@@ -117,6 +117,22 @@ go test ./internal/runtime/agent/ -v # verbose, one package
go test ./internal/tools/builtin/ -run TestGrep # one test
```
`testenv.IsolateUserState` installs a disposable user home and config-edit lock
scope. Default-path tests can clear `REASONIX_HOME` without losing the test
registry, while explicitly isolated Reasonix homes retain their own locks.
`REASONIX_TEST_HOME` carries the scope to helper subprocesses. A helper that
skips the isolation guard must install the inherited home with
`testscope.InstallHome` before running tests.
The environment variable alone cannot activate a test scope. Production
executables still use the OS account's home for shared config locks.
The guard removes its scratch home on normal completion. Scratch homes are
created under the original home, so an interrupted run can still leave
`.reasonix-test-home-*` directories; this is separate from persistent
config-edit locks in the real `.reasonix/locks` directory.
### Code style
- `gofmt` is enforced by CI — format before committing
+8 -1
View File
@@ -9,7 +9,7 @@ GOEXE := $(shell go env GOEXE)
# One pin for the Makefile and the CI lint job; see .github/workflows/ci.yml.
GOLANGCI_VERSION := $(shell cat .golangci-version)
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck check test studio-test sdk-test sdk-test-race hooks cross clean studio
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck acp-conformance check test studio-test sdk-test sdk-test-race hooks cross clean studio
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
@@ -87,6 +87,13 @@ lint-cross:
test: studio-test sdk-test
go test ./...
# Drives `reasonix acp` with the official ACP TypeScript SDK and holds every
# message it writes to the schema that SDK ships. Needs node and network for
# `npm ci`, which runs without install scripts against a lockfile.
acp-conformance: build
cd tools/acp-conformance && npm ci --ignore-scripts --no-audit --no-fund
cd tools/acp-conformance && REASONIX_BIN=$(CURDIR)/bin/reasonix$(GOEXE) npm test
# The shell is a nested module, so the root `test` target never reaches it.
studio-test:
cd desktop && go test ./...
+32 -7
View File
@@ -93,6 +93,16 @@ Prefer a terminal? Download the `reasonix` command-line tool, run `reasonix setu
All four are the same program underneath. What follows is for advanced users and contributors.
## Use it from DeepSeek Harness
DeepSeek Harness (`dsh`) can hand a task to Reasonix as a subagent over ACP. This repository publishes a plugin index for it:
1. Run `reasonix setup` on the machine that runs `dsh`. In the DSH Web UI turn on Custom plugin markets (Plugins, More) and add this index in the Discover tab: `https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/studio/integrations/dsh-plugin/market-index.json`
2. Install "Reasonix subagent" from it.
3. Open the plugin's page and turn its switch on. While it is off the model has no `reasonix` tool and may run `reasonix -p` in its own shell, which looks like a delegation and is not one.
Setup details, the permission policy and limits are in [Using Reasonix from DeepSeek Harness](./docs/DSH.md).
## Versions
Reasonix ships on two lines. See the [version roadmap announcement](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748) for the reasoning behind the split.
@@ -151,7 +161,7 @@ pre-releases):
| --- | --- | --- |
| macOS | `.dmg` or `.zip` | Apple Silicon (`arm64`) / Intel (`amd64`) |
| Windows | Installer `-installer.exe` or portable `.zip` | x64 |
| Linux | `.deb` or `.rpm` | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
| Linux | `.deb`, `.rpm`, or `.AppImage` (download only) | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
Every package ships with a `.minisig` signature, and the release carries
`SHA256SUMS`. Once installed, Studio updates itself in place.
@@ -159,6 +169,19 @@ Every package ships with a `.minisig` signature, and the release carries
The same release also carries the 2.x `reasonix` CLI as archives for
`darwin|linux|windows × amd64|arm64`.
The Android build ships on that release as `ReasonixMobile-<version>.apk`
(`arm64`, Android 8.0 or newer). The kernel and the page run on the phone, and
the agent reaches your servers over SSH; pairing with a computer is done by
scanning the code it shows.
The APK is signed with the Android release key rather than minisign, so it
carries a `.sha256` and no `.minisig`.
When that key is absent the build is named
`ReasonixMobile-<version>-unsigned.apk`. Android refuses to install an unsigned
package, so those builds stay a workflow artifact for whoever wants to sign one,
and the release carries no APK at all.
### Reasonix 1.x (stable)
The 1.x CLI installs through npm on any supported platform, or Homebrew on
@@ -240,12 +263,14 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
## Documentation
- **Getting started:** [Guide](./docs/GUIDE.md) · [CLI reference](./docs/CLI.md) ·
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md)
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md) ·
[Use from DeepSeek Harness](./docs/DSH.md)
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[Recovery and updates](./docs/RECOVERY.md) ·
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
- **Agent presets:** [Session Agent compositions](./docs/AGENT_COMPOSITIONS.md)
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
[Tool contract](./docs/TOOL_CONTRACT.md) · [Moving from 1.x to 2.x](./docs/MIGRATING.md) ·
@@ -276,11 +301,11 @@ A small list of folks whose work has shaped Reasonix the most — the current to
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**chuiwenwei**](https://github.com/chuiwenwei) |
| [**lifu963**](https://github.com/lifu963) | [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**xxoingr**](https://github.com/xxoingr) |
| [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) |
| [**light-front-theory**](https://github.com/light-front-theory) | [**imcreazy**](https://github.com/imcreazy) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) |
| [**par73e**](https://github.com/par73e) | [**Bernardxu123**](https://github.com/Bernardxu123) | [**lancy69**](https://github.com/lancy69) | [**Guan0923**](https://github.com/Guan0923) |
<!-- reasonix-top-contributors:end -->
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
+27 -7
View File
@@ -91,6 +91,16 @@ Reasonix 是一个开源的 AI 编程助手。程序跑在你的电脑上;AI
四种用法背后是同一套程序。下面的内容写给进阶用户和贡献者。
## 在 DeepSeek Harness 里使用
DeepSeek Harness(`dsh`)可以通过 ACP 把任务交给 Reasonix 作为子智能体去做。本仓库为此发布了一个插件索引:
1. 在运行 `dsh` 的机器上先运行 `reasonix setup`。在 DSH 网页界面打开“自定义插件市场”(插件,更多),再到 Discover 页添加这个索引:`https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/studio/integrations/dsh-plugin/market-index.json`
2. 从中安装“Reasonix subagent”。
3. 打开这个插件的页面,把它的开关打开。关着的时候模型没有 `reasonix` 工具,可能改在自己的 shell 里直接运行 `reasonix -p`,看起来像委派,其实不是。
配置细节、权限策略和限制见 [在 DeepSeek Harness 中使用 Reasonix](./docs/DSH.md)。
## 版本
Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748)。
@@ -139,13 +149,22 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
| --- | --- | --- |
| macOS | `.dmg` 或 `.zip` | Apple Silicon(`arm64`)/ Intel(`amd64`) |
| Windows | 安装器 `-installer.exe` 或便携 `.zip` | x64 |
| Linux | `.deb` 或 `.rpm` | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
| Linux | `.deb`、`.rpm` 或 `.AppImage`(只提供下载) | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
每个安装包都附带 `.minisig` 签名,发布页另有 `SHA256SUMS`。安装后 Studio 会在
应用内自行更新。
同一个发布页也提供 2.x 的 `reasonix` CLI 归档(`darwin|linux|windows × amd64|arm64`)。
安卓版在同一个发布页上,文件名是 `ReasonixMobile-<版本>.apk`(`arm64`,Android 8.0 及以上)。
这个 APK 用 Android 发布密钥签名,不走 minisign,因此只有一个 `.sha256`,没有 `.minisig`。
该密钥缺失时产物名为 `ReasonixMobile-<版本>-unsigned.apk`。Android 不接受未签名的包,
这类构建只留作 workflow 产物供自行签名,发布页上不会挂 APK。
内核与界面都跑在手机上,Agent 通过 SSH 连接你自己的服务器;与电脑配对时扫它显示的二维码。
### Reasonix 1.x(稳定版)
1.x CLI 在任意支持的平台上都可以通过 npm 安装,macOS 也可以使用 Homebrew:
@@ -221,12 +240,13 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
- **开始使用:** [指南](./docs/GUIDE.zh-CN.md) ·
[CLI 命令参考](./docs/CLI.zh-CN.md) · [配置路径](./docs/CONFIG_PATHS.md) ·
[ACP 编辑器接入](./docs/ACP.md)
[ACP 编辑器接入](./docs/ACP.md) · [在 DeepSeek Harness 中使用](./docs/DSH.md)
- **功能与排障:** [子智能体 Profile](./docs/SUBAGENT_PROFILES.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[恢复与安全模式](./docs/RECOVERY.md) ·
[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)
- **Agent 预设:** [会话级组合](./docs/AGENT_COMPOSITIONS.zh.md)
- **工程与迁移:** [规格](./docs/SPEC.md) ·
[任务合约与暂停策略](./docs/TASK_CONTRACT.md) ·
[工具合约](./docs/TOOL_CONTRACT.md) ·
@@ -258,11 +278,11 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**chuiwenwei**](https://github.com/chuiwenwei) |
| [**lifu963**](https://github.com/lifu963) | [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**xxoingr**](https://github.com/xxoingr) |
| [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) |
| [**light-front-theory**](https://github.com/light-front-theory) | [**imcreazy**](https://github.com/imcreazy) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) |
| [**par73e**](https://github.com/par73e) | [**Bernardxu123**](https://github.com/Bernardxu123) | [**lancy69**](https://github.com/lancy69) | [**Guan0923**](https://github.com/Guan0923) |
<!-- reasonix-top-contributors:end -->
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
+18 -5
View File
@@ -373,6 +373,10 @@ out of how a path is spelled, which cannot tell `internal/auth` from
- sensitive: internal/safety/egress/**
- sensitive: internal/platform/browser/**
- sensitive: internal/platform/computer/**
- sensitive: internal/platform/gitpush/**
- sensitive: internal/session/control/git_push.go
- sensitive: internal/frontend/serve/git_push.go
- sensitive: internal/frontend/serve/host_routes.go
One declaration, two effects: the same list is also the coverage gate's subject.
`make coverage-gate` holds each path to the coverage it already has, because
@@ -409,8 +413,13 @@ moving a number here must not move the promise.
- Symlinks are recorded by `Lstat`, so a write through one lands outside what
the walk compares. Confining the resolved target is the sandbox's job.
- `internal/safety/sandbox` confines writes to the workspace, configured extras, temp
and toolchain caches — on macOS and Linux. Windows has no OS-level bash
sandbox, so there nothing the host enforces bounds `Writable`.
and toolchain caches on macOS and Linux.
- On Windows a user-enabled write-restricted token bounds the *creation* of files
(create, append, overwrite, `mkdir`, HKCU writes, global git config, writes through links).
- There, deleting or renaming away an existing file is not bounded, and with the
setting off nothing the host enforces bounds `Writable`.
- The caches a command may write can be poisoned for the user's later unsandboxed
builds, on every platform.
- The governed host authorities are a *named* set — ssh-agent, docker, podman.
DBus, Wayland, X11, gpg-agent, keyrings and arbitrary sockets stay reachable,
so the claim is "these endpoints are governed", never "host IPC is isolated".
@@ -456,9 +465,13 @@ stop a connect. Possession is not authority: the key can be unreadable while an
agent still signs with it.
A protection claim exists only where a backend has a demonstrated enforcement
primitive for that dimension. Unsupported dimensions stay explicitly unsupported
— Windows currently enforces none of the three — because a claim that outruns
its enforcement is worse than an absent one: it is believed.
primitive for that dimension. Unsupported dimensions stay explicitly unsupported,
because a claim that outruns its enforcement is worse than an absent one: it is
believed.
Windows enforces Integrity on file creation only, when the user turns it on.
Deletion and rename, Confidentiality and Authority (the named pipes of ssh-agent
and docker, COM and RPC services) are not enforced there.
**Known unmeasured surface — macOS host-mediated delegation:** Apple Events and
Mach-service lookup are not denied by the current allow-default profile. Prior
+22
View File
@@ -0,0 +1,22 @@
# Build output and local tooling state.
build/
.gradle/
.kotlin/
local.properties
*.iml
.idea/
# The signing key and the file that names it.
#
# A key is not part of the project: it is the identity one person ships under,
# and publishing it would let anyone else sign an APK that installs over
# everyone's. Neither the keystore nor its password belongs in version control.
# `app/build.gradle` reads both from here when present and falls back to the
# debug key when they are not, so a fresh clone builds without them.
keystore/
# Build inputs, produced by scripts/ before Gradle runs.
# jniLibs/ holds the cross-compiled kernel, assets/page/ the built
# frontend. assets/LICENSE is hand-placed and stays tracked.
app/src/main/jniLibs/
app/src/main/assets/page/
+216
View File
@@ -0,0 +1,216 @@
---
owner: @esengine
backup: @SivanCola
status: active
reviewed: 2026-10-20
---
# Reasonix Mobile — the Android shell
Runs Reasonix as an Android app: the kernel ships inside the APK and starts on the
device, the interface is the same `desktop/frontend-next` build the desktop Studio
serves, and the agent reaches servers over SSH.
This file is for people building it. What a person installing it needs — what it
does, how to pair with a computer, what it cannot do — belongs in the release
notes.
## One command
```bash
./scripts/build.sh release # or debug
```
Three steps, in this order:
1. `build-kernel.sh` → `app/src/main/jniLibs/arm64-v8a/libreasonix.so`
2. `sync-page.sh` → `app/src/main/assets/page/`
3. `./gradlew assembleRelease`
Output: `app/build/outputs/apk/release/app-release.apk`
The kernel and the page are **inputs to packaging, not Gradle build steps**.
Running `gradle assembleRelease` on its own fails on purpose and names the script
that produces whichever input is missing (`requireBuildInputs` in
`app/build.gradle`): an APK with no kernel in it should not be buildable by
accident.
## Requirements
| Component | Version |
| --- | --- |
| JDK | 21 (17 also works) |
| Android SDK | `platforms;android-35`, `build-tools;35.0.0`, `platform-tools` |
| Gradle | the committed wrapper, 8.14.3 (for AGP 8.11.1) |
| Go | 1.26+ |
| Node / pnpm | Node 24+ / pnpm 10 |
```bash
export ANDROID_HOME="$HOME/Android/Sdk"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export JAVA_HOME="/path/to/jdk-21"
export PATH="$PATH:$(go env GOPATH)/bin"
```
`ANDROID_HOME` is the one thing nothing sets for you.
## Scripts
| Script | What it does |
| --- | --- |
| `build.sh [debug\|release]` | The whole chain |
| `build-kernel.sh` | Cross-compiles the kernel only (`GOOS=android GOARCH=arm64 CGO_ENABLED=0`) |
| `sync-page.sh` | Builds the frontend, copies `dist/` into assets, copies `LICENSE` |
| `verify-page.sh` | **No phone needed**: starts the real kernel against the packaged page |
| `run.sh [debug\|release]` | `adb install`, launch, follow the log |
`verify-page.sh` is the one to run after a change: it starts the kernel with the
same arguments `Kernel.kt` uses (`--auth token`, `--token-file`, `--port-file`,
`-page`) and checks that it binds a port and answers 200. Without a device, that
is as deep as the chain can be verified.
## Where the code is
| File | Owns |
| --- | --- |
| `MainActivity.kt` | The WebView, the back gesture, downloads, external links, renderer restart |
| `Kernel.kt` | Unpacking the page, starting and stopping the kernel, waiting for the port, health probes, logs, the workspace directory |
| `KernelService.kt` | The foreground service that keeps a long turn alive |
| `NativeBridge.kt` | `@JavascriptInterface`: what the page may ask the phone for |
| `BrowserShellServer.kt` | Loopback HTTP server the kernel's browser tools drive the overlay WebView through |
| `Downloader.kt` | Writing a kernel-produced file through the system document picker |
The page reaches the shell through `window.reasonixHost` and `ReasonixNative`,
defined in `desktop/frontend-next/src/port/host.ts` (`AndroidHost`).
**Phone and desktop share one page build.** What differs only on a phone is
decided at runtime, not by a second set of components. `phoneShell()` in
`src/port/host.ts` and the `[data-shell="android"]` selector in `mobile.css` both
read `<html data-shell="android">`, which `index.html` sets before the bundle
loads.
One attribute feeding both is what keeps style and components from disagreeing
about what a phone is.
## What the page may ask the phone for
`NativeBridge` is reachable from whatever document the WebView holds, so every
method that touches the device asks `trusted()` first.
The activity answers by comparing the **main frame's** origin against the
kernel's and against the one paired computer the person scanned a code for. A
page that navigated elsewhere is not trusted, even though it may still hold the
object.
Five methods answer without the check — `version`, `systemTheme`, `safeArea`,
`setLayerOpen`, `setRunning` — because they report back what the page already
knows about its own window and grant nothing.
`openInApp(url)` is the pairing decision, not a navigation one: it loads a URL in
this window only for the origin the scanner just returned, and only once.
Without that, any page holding the kernel origin could hand itself the bridge.
External links leave through the browser. A non-web scheme is routed to the
system only for `mailto:` and `tel:`; everything else is dropped, because an
`intent://` URL can start an arbitrary exported activity with caller-supplied
extras.
## Permissions, and what they cost
`MANAGE_EXTERNAL_STORAGE` — opening a folder on the phone as a workspace. The
kernel is a Go binary: it opens real paths with `open(2)`, and SAF hands back a
`content://` URI it cannot open.
Requested from the folder-picker action, never at launch. Android shows no dialog
for it and opens system settings instead.
**There is no OS-level sandbox around this kernel.** The PR sets the sandbox to
off on Android, so confining the agent to the chosen folder is Reasonix's own
write-root check and nothing else. A shell tool with all-files access can reach
all shared storage.
`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` — Doze freezes the app group, which stops
a turn that is still running. Asked once when a turn actually runs, never at
launch, and the person can refuse. Refusing costs battery life during long turns
and nothing else.
**Google Play restricts this permission**: fine for a sideloaded build, a
store-policy risk if this is ever listed.
`FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_DATA_SYNC` — keeping the kernel alive
while the screen is off. `dataSync` is capped per 24 hours from Android 15. It is
the closest honest description of "an agent is working".
`CAMERA` — reading the pairing code a computer shows. Requested when the person
asks to scan, never at launch. `ScanActivity` is not exported.
`READ_EXTERNAL_STORAGE` / `WRITE_EXTERNAL_STORAGE` — the same folder walk below
API 33. `maxSdkVersion`-gated; above 33 they are superseded by the permission
above.
`allowBackup=false` with explicit extraction rules, so a `keystore` or a token in
app storage never leaves the device in a cloud backup.
## Cleartext HTTP
The kernel serves its page on `127.0.0.1:<port>`, and a paired computer serves
the same page over its LAN address. Both are plain HTTP.
Neither address can be written into a `network-security-config`: the port is
chosen at start, the LAN address at scan time, and the format matches literals
and domains, not subnets.
So the config permits cleartext and the boundary is in the code instead.
`shouldOverrideUrlLoading` sends every other http(s) origin to the browser, and
the bridge answers only the kernel and the just-paired computer. A plaintext page
on the LAN cannot become the document holding the bridge.
## What goes into the APK
| Content | Size | Why it is shaped this way |
| --- | --- | --- |
| `lib/arm64-v8a/libreasonix.so` | 45 MB | Go static binary, stripped. It is a `.so` rather than a file in `assets/` because only `nativeLibraryDir` is guaranteed executable — a binary copied to app storage fails on a device whose data partition is mounted `noexec`. |
| `assets/page/` | 14 MB | The frontend build, including the Inter and Noto Sans SC fonts, `noCompress` |
| `assets/LICENSE` | 1 KB | The repository's MIT licence, verbatim. MIT requires the notice and the licence text to accompany a binary distribution. |
`assets/LICENSE` is generated by `sync-page.sh` from the repository's root
`LICENSE`, not committed a second time: a checked-in copy drifts when the root
file is updated.
It sits in `assets/` rather than `assets/page/` because that directory is removed
and rebuilt on every build.
A release APK is about 31 MB.
## Building
**The Gradle wrapper is committed**, jar and all. `build.sh` refuses to run
without `gradlew` rather than generating one from whatever `gradle` is on `PATH`:
the dependency versions are part of the build.
`gradle-wrapper.properties` pins `distributionSha256Sum`, so a mirror that serves
a different distribution zip fails verification instead of being unpacked and
run. Dependency repositories are the official ones only — a mirror belongs in a
user-level init script.
**Signing.** A release with no key configured comes out **unsigned**
(`app-release-unsigned.apk`) with a warning. It used to fall back to the debug
key, which produced an artifact that looked shippable and was signed by a
per-machine key nobody chose: it could not install over the previous build.
Point the build at a real key by writing `keystore/keystore.properties`
(`storeFile`, `storePassword`, `keyAlias`, and optionally `keyPassword`) or by
setting the matching `REASONIX_KEYSTORE*` environment variables. Key material
stays out of the repository and out of build logs.
Debug builds always use the debug key. Signing a debug build with the release key
would let anyone who can read the keystore produce a build that installs over the
shipped one under the same signature.
**Non-ASCII checkout paths.** AGP refuses to build under a path with non-ASCII
characters on Windows unless `android.overridePathCheck=true` is set. That is a
property of one machine's disk layout, not of this project, so it is not in the
repository's `gradle.properties`.
Set it in a user-level `~/.gradle/gradle.properties` or pass
`-Pandroid.overridePathCheck=true`.
+263
View File
@@ -0,0 +1,263 @@
// The app module.
//
// Two things ship inside the APK and neither is fetched at runtime: the kernel
// binary, and the built page it serves. Bundling the page is what makes the app
// work offline and start instantly; bundling the kernel is what lets it run
// `reasonix serve` on the phone itself, which is the whole point — the agent
// starts locally and reaches the server over SSH, so the interface, the model
// catalogue and the credentials all live on the phone.
//
// The kernel is placed as `jniLibs/arm64-v8a/libreasonix.so`, not under
// `assets/`. That is not a trick for its own sake: Android permits executing a
// file under `nativeLibraryDir` and nothing under `filesDir` has that
// guarantee, so a binary shipped the ordinary way would have to be copied out
// and made executable at runtime — which fails outright on a device mounted
// noexec, and fails confusingly on the ones that are merely unusual. `scripts/
// build-kernel.sh` writes it there; the name is fixed by the packaging rules.
plugins {
id "com.android.application"
id "org.jetbrains.kotlin.android"
}
// One version, read by the About screen and the update check.
//
// A published build takes its version from the tag it was cut for, so the About
// screen and the update check name that release rather than the number this file
// was last edited to. Without the property — a local build — the numbers below
// are the answer.
def publishedVersion = findProperty("reasonixVersion") as String
def versionMajor = 2
def versionMinor = 0
def versionPatch = 0
def versionBuild = 1
if (publishedVersion) {
def parsed = publishedVersion =~ /^(\d+)\.(\d+)\.(\d+)/
if (!parsed.find()) {
throw new GradleException("reasonixVersion must start with MAJOR.MINOR.PATCH, got: ${publishedVersion}")
}
versionMajor = parsed.group(1) as int
versionMinor = parsed.group(2) as int
versionPatch = parsed.group(3) as int
}
// Where the two build inputs are expected. Both are produced from the checkout
// beside this module, by the scripts named in each message.
def upstream = rootProject.file("..")
def pageDir = new File(projectDir, "src/main/assets/page")
def kernelSo = new File(projectDir, "src/main/jniLibs/arm64-v8a/libreasonix.so")
android {
namespace "io.reasonix.mobile.app"
compileSdk 35
defaultConfig {
applicationId "io.reasonix.mobile.app"
minSdk 26
// 35: the API level this app is actually built and tested against.
//
// It was briefly lowered to 26 while chasing an install failure that
// turned out to be a package-name collision with an unrelated app on the
// user's phone — the signature error named the wrong cause. Nothing about
// the target level ever caused it, so it goes back to the level the code
// is really written for.
//
// One consequence to keep in mind: from 35, Android 15 forces the window
// edge-to-edge and ignores a request to lay out inside the safe area. The
// window therefore *is* drawn under the status bar and the home indicator,
// and the only correct answer is for the page to pad itself — which is
// what `MainActivity` feeds real inset values for, since a WebView does
// not implement `env(safe-area-inset-*)`.
targetSdk 35
// versionCode must strictly increase, because Android refuses an update
// whose versionCode is not higher. The build digit is a single decimal
// place, so the scheme collides once a patch reaches 10: 2.55.10 and
// 2.56.0 both come out as 25601. Patch releases above 9 are not expected
// on Studio, and widening the digit now would renumber every build, so
// the constraint is recorded rather than fixed.
versionCode versionMajor * 10000 + versionMinor * 100 + versionPatch * 10 + versionBuild
versionName "${versionMajor}.${versionMinor}.${versionPatch}"
// The kernel is built for arm64 only, so every other ABI would ship an
// APK that installs and then cannot start. Naming the one real ABI keeps
// the store listing and the installer honest.
ndk {
abiFilters "arm64-v8a"
}
resourceConfigurations += ["zh", "en"]
}
// Android identifies an app by package name *and* signing key: an APK signed
// with a different key is not an update, it is an impostor, and the
// installer refuses it with "the signature does not match the installed
// app". Gradle's default release key is the debug key in the user's home
// directory, which is per-machine, so a build made on a second computer
// could never be installed over one made on the first.
//
// Whoever ships a build therefore pins a key — and that key is theirs, not
// the project's. It lives beside the checkout, in `keystore/`, which is
// ignored; so does the file naming it. A checkout without either signs with
// the debug key instead, which is what a contributor building locally wants
// and is what the README tells them to expect. Point the build at a real key
// by writing `keystore/keystore.properties` (storeFile, storePassword,
// keyAlias) or by setting REASONIX_KEYSTORE / REASONIX_KEYSTORE_PASSWORD /
// REASONIX_KEY_ALIAS.
def keyProperties = new Properties()
def keyPropertiesFile = rootProject.file("keystore/keystore.properties")
if (keyPropertiesFile.exists()) {
keyPropertiesFile.withInputStream { keyProperties.load(it) }
}
def keyStorePath = keyProperties.getProperty("storeFile") ?: System.getenv("REASONIX_KEYSTORE")
def keyStorePassword = keyProperties.getProperty("storePassword") ?: System.getenv("REASONIX_KEYSTORE_PASSWORD")
// The key's own password is a separate fact from the store's. PKCS#12 keystores
// almost always use one password for both, so falling back to the store's
// password keeps those working — but assuming they are equal is how a keystore
// that separates them gets a build failure at signing time, after the work is
// done, with an error that does not say which of the two was wrong.
def keyPasswordValue = keyProperties.getProperty("keyPassword") ?: System.getenv("REASONIX_KEY_PASSWORD") ?: keyStorePassword
def keyAliasName = keyProperties.getProperty("keyAlias") ?: System.getenv("REASONIX_KEY_ALIAS")
def haveReleaseKey = keyStorePath && keyStorePassword && keyAliasName &&
rootProject.file(keyStorePath).exists()
signingConfigs {
if (haveReleaseKey) {
releaseKey {
storeFile rootProject.file(keyStorePath)
storePassword keyStorePassword
keyAlias keyAliasName
keyPassword keyPasswordValue
// v1 is not optional here. Android 7+ reads v2 and ignores v1, so
// a v2-only APK installs on a modern phone — and then fails on the
// compatibility layers older devices and HarmonyOS' 卓易通 run,
// which know only the JAR signature and report its absence as a
// signature *mismatch*. Signing both is what makes one APK install
// everywhere.
enableV1Signing = true
enableV2Signing = true
enableV3Signing = true
}
}
}
// Nothing native is compiled here — the one .so is a prebuilt the kernel
// script writes — so the external native build is left alone entirely.
buildTypes {
debug {
applicationIdSuffix ".debug"
versionNameSuffix "-debug"
// The debug key, always. Signing a debug build with the release key
// would mean anyone who can read the release keystore can produce a
// build that installs over the shipped one under the same signature,
// and it buys nothing: the debug package is suffixed `.debug`, so the
// two already install side by side.
}
release {
// The kernel is a stripped Go binary and the page is minified, but
// the Kotlin shell still benefits and shipping the shrunk build is
// how a size regression gets noticed.
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
// A release with no key of its own comes out *unsigned*
// (`app-release-unsigned.apk`). It used to fall back to the debug key
// so the artifact would install, which produced a release that looked
// shippable and was signed by a per-machine key nobody chose: it could
// not be installed over the previous build, and every machine made a
// different one. An unsigned artifact says what it is.
if (haveReleaseKey) {
signingConfig signingConfigs.releaseKey
} else {
logger.warn(
"release build has no signing key: producing an unsigned APK. " +
"Write keystore/keystore.properties (storeFile, storePassword, keyAlias) " +
"or set REASONIX_KEYSTORE / REASONIX_KEYSTORE_PASSWORD / REASONIX_KEY_ALIAS."
)
}
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
// AGP 8 stops generating BuildConfig unless asked. `Kernel` reads the
// version name and code out of it to decide whether the bundled page
// needs unpacking, so it is asked for.
buildConfig = true
}
packaging {
jniLibs {
// `extractNativeLibs=true`: the kernel must exist as a real file on
// disk to be executed, and an uncompressed-and-mmap'd .so is not
// one. This is the only reason the flag is on.
useLegacyPackaging true
}
resources {
excludes += ["META-INF/*.version", "META-INF/*.kotlin_module", "kotlin/**", "DebugProbesKt.bin"]
}
}
// The page is read out of assets by `Kernel.publish`, not by the resource
// system, so it is not compressed — a page is already-compressed text and
// shrinking it again only costs startup time.
androidResources {
noCompress += ["json", "woff2", "ttf", "woff", "txt", "svg"]
}
lint {
abortOnError true
warningsAsErrors false
checkReleaseBuilds true
}
}
dependencies {
implementation "androidx.core:core-ktx:1.15.0"
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.activity:activity-ktx:1.9.3"
implementation "androidx.webkit:webkit:1.12.1"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.7"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"
// Scanning the pairing code a computer shows.
//
// CameraX for the preview and the frame stream, ZXing for the decode, and
// deliberately not ML Kit: this app installs on phones whose HarmonyOS
// compatibility layer has no Google Play Services, so a decoder that needs
// GMS would work on the developer's phone and fail on the user's. ZXing
// core is pure Java with no runtime dependency at all, which is what makes
// the scanner work on both.
implementation "androidx.camera:camera-core:1.4.1"
implementation "androidx.camera:camera-camera2:1.4.1"
implementation "androidx.camera:camera-lifecycle:1.4.1"
implementation "androidx.camera:camera-view:1.4.1"
implementation "com.google.zxing:core:3.5.3"
}
// Fail before the packaging step with something a person can act on, rather
// than shipping an APK whose kernel is missing or whose page is a directory the
// build never filled. The two messages name the script that produces each.
tasks.register("requireBuildInputs") {
doFirst {
if (!kernelSo.exists()) {
throw new GradleException(
"内核二进制缺失:${kernelSo}\n先运行 ./scripts/build-kernel.sh(在 android/ 目录下)交叉编译内核。")
}
if (!pageDir.exists() || !new File(pageDir, "index.html").exists()) {
throw new GradleException(
"前端页面缺失:${pageDir}/index.html\n先运行 ./scripts/sync-page.sh(在 android/ 目录下)构建并同步页面。")
}
}
}
tasks.configureEach { task ->
if (task.name.startsWith("merge") && (task.name.contains("Assets") || task.name.contains("JniLibFolders"))) {
task.dependsOn("requireBuildInputs")
}
}
+32
View File
@@ -0,0 +1,32 @@
# R8 rules for the shell.
#
# There is very little to keep here, and that is the point: the app's own code
# is reached from the manifest and from Android's own callbacks, both of which
# R8 already traces, and nothing in it is loaded reflectively. What follows is
# the two places where a name is looked up rather than called.
# The WebView's JavaScript bridge, if one is ever added, is resolved by name on
# the Java side and by the string the page uses. Keeping annotated members makes
# that survive shrinking rather than failing at runtime with a method-not-found
# that only shows up on a release build.
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# The Kernel's child process is started by absolute path, and its own log is
# read as text; neither is reflective, so nothing else needs keeping.
# Line numbers make a stack trace from a release build readable, which is the
# only way a crash report from a phone can be acted on.
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
# Kotlin's `@Metadata` annotation is present on every class the compiler touched
# and nothing in this app reflects over it. It is tempting to `-assumenosideeffects`
# it away, and that was tried: any pattern broad enough to name its members also
# matches the methods every class inherits from `Object` — `equals`, `hashCode`,
# `toString` — and R8 then treats those as removable, which is a silent
# behaviour change for a few kilobytes. Left as a warning suppression instead:
# the annotation is dropped from the shrunk output anyway, because nothing reads
# it.
-dontwarn kotlin.Metadata
+112
View File
@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The shell's manifest.
There is no INTERNET-adjacent permission beyond INTERNET itself: the page and
the kernel talk over loopback, and reaching a server is the kernel's job over
a socket it owns. Reading and writing files goes through the system's document
picker (SAF), which hands back a grant for one file at a time — so no
storage permission is declared and none is needed.
`usesCleartextTraffic` is off here, and cleartext is re-enabled in the network
security config only because the kernel serves its page on `127.0.0.1` and a
paired computer serves the same page over its LAN address — both plain HTTP.
The config says what that costs and what bounds it.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Only to tell the person the kernel is still running while the screen is
off; it is a foreground service with a dismissible notification, not a
background wake-up. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- From API 34 every foreground-service *type* needs its own permission as
well as the general one: naming dataSync in the manifest without asking
for it is a SecurityException at startForeground, on the one code path
that keeps a long task alive. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Only to read the pairing code a computer shows. Requested at the
moment the person asks to scan, never at launch. -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- The kernel is a child process, and a task left running is one the person
expects to finish with the screen off. Android's own doze can freeze the
whole app group instead, which is what the shell asks to be exempt from
— once, when a turn is actually running, never at launch. It is a
request the person can refuse, and refusing it only costs battery life
during a long turn. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Every-file access, for opening a folder on this phone as a workspace.
The kernel is a plain Go binary: it opens paths with `open(2)`, and a
document URI is not a path it can open. SAF would hand back exactly
that unusable URI, so the one honest permission is the broad one —
granted by the person in system settings, which is the only place
Android offers it. Nothing here reads storage for any other purpose. -->
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<!-- Below API 33 the same folder walk needs the older pair; from 33 they are
superseded by the permission above and asking for them would be a lie. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<!-- A phone without a touchscreen cannot drive this interface; saying so
keeps the listing honest rather than letting it install on a TV. -->
<uses-feature android:name="android.hardware.touchscreen" android:required="true" />
<!-- The camera is optional: a phone without one is still a fine client for
a kernel it already knows, and it can still pair by opening the link the
computer shows. Requiring it would take the app off those devices. -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<application
android:name=".App"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Reasonix"
android:usesCleartextTraffic="false"
tools:targetApi="35">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode|density|fontScale|smallestScreenSize"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Reasonix">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- The kernel outlives the screen: a turn that takes minutes would
otherwise be killed when the phone is pocketed. The service is
`stopWithTask="false"` so swiping the app away ends it deliberately
rather than silently, and `foregroundServiceType="dataSync"` is the
closest honest description of "an agent is working". -->
<service
android:name=".KernelService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:stopWithTask="false" />
<!-- Reading the pairing code a computer shows. Its own activity rather
than a dialog, because the camera preview needs the whole screen to
be worth pointing at anything. Not exported: only this app starts
it, and a scanner another app could open is one that could be made
to read a code on someone else's behalf. -->
<activity
android:name=".ScanActivity"
android:exported="false"
android:screenOrientation="portrait"
android:theme="@style/Theme.Reasonix.Scan" />
</application>
</manifest>
@@ -0,0 +1,59 @@
package io.reasonix.mobile.app
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
/**
* The process-wide facts this app needs.
*
* The kernel is one child process, so there is exactly one of it no matter how
* many activities exist: it is held here, lazily, and the activity and the
* service both reach the same instance. Nothing else is stored — the theme, the
* language and every preference belong to the page and live in its localStorage.
*/
class App : Application() {
/**
* The kernel, created on first use.
*
* Lazy rather than eager so that merely launching the process to answer a
* notification does not unpack a 15MB page.
*/
val kernel: Kernel by lazy { Kernel(this) }
override fun onCreate() {
super.onCreate()
createChannel()
}
/**
* A channel the person can mute without muting the app, and one they can
* find: an agent that runs for minutes on a phone has to say so somewhere
* the system will not kill, and a notification is the only place Android
* offers. `IMPORTANCE_MIN` because the notification is a receipt, not an
* interruption — the work it reports is on another machine.
*/
private fun createChannel() {
// No SDK_INT guard: minSdk is 26, which is the release NotificationChannel
// arrived in, so every device this app installs on has it.
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_KERNEL) != null) return
val channel = NotificationChannel(
CHANNEL_KERNEL,
getString(R.string.channel_kernel),
NotificationManager.IMPORTANCE_MIN,
).apply {
description = getString(R.string.channel_kernel_desc)
setShowBadge(false)
enableVibration(false)
setSound(null, null)
}
manager.createNotificationChannel(channel)
}
companion object {
const val CHANNEL_KERNEL = "reasonix-kernel"
}
}
@@ -0,0 +1,699 @@
package io.reasonix.mobile.app
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.util.Log
import android.webkit.WebView
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
import java.io.OutputStream
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* The kernel's browser, as a loopback HTTP server.
*
* The Go kernel (`internal/platform/browser/shell.go`) drives a WebView by
* POSTing JSON to a handful of paths on 127.0.0.1. This class is the other end
* of that conversation: it accepts connections on a random port, parses the
* minimal HTTP the kernel sends, and translates each path into an operation on
* the overlay WebView.
*
* The server is deliberately tiny — no connection pooling, no keep-alive, no
* chunked encoding — because the kernel is its only client and sends one
* request per connection. A `ServerSocket` accept loop on a daemon thread is
* the whole transport; each request is handled synchronously on its own thread.
*
* WebView operations must run on the UI thread. Each handler posts to the main
* looper and waits on a latch, with a timeout that bounds the worst case (a
* page that hangs the JS thread) to something the kernel's own 30-second client
* timeout will not outlast.
*
* Memory note on `/screenshot`: a full-page `WebView.draw` into an ARGB_8888
* bitmap at device resolution can reach tens of MB for a long page. The bitmap
* is downsampled by a factor of 2 in each dimension before PNG encoding, which
* keeps the peak allocation under ~10 MB for a typical 1080p viewport. The
* original is recycled immediately after the downsample.
*/
/** One thing a page asked the network for, as the WebView's request
* interceptor reports it. The interceptor is handed the method and the URL
* and nothing about the response, so a caller may show exactly this much and
* must not read it as a completed exchange. */
data class BrowserRequest(val method: String, val url: String, val kind: String)
class BrowserShellServer(
/** The overlay WebView, when one already exists. The routes that act on an
* open page — back, forward, reload, close — use this: with no page there
* is nothing to act on, and creating one to do nothing to it would start a
* renderer nobody asked for. */
private val existingView: () -> WebView?,
/** The overlay WebView, created on first use. The routes that drive a page
* use this, because the kernel can open one before the panel has ever been
* shown: the page's own panel asks for the view when it is displayed, and
* an agent's `browser_open` can arrive first. Requiring the view to exist
* already answered every such call with "browser view not ready", which
* reads as a broken engine rather than as a view that was not created yet.
* Called on the UI thread. */
private val ensureView: () -> WebView,
/** Why the page in the view is the browser's own error page rather than a
* site, as `(url, chromium error)`; null when the last load succeeded.
* `/read` asks this, because extracting `innerText` from the error page
* returns the browser's own "this page could not be loaded" notice, which
* the kernel would hand to the model as though the site had said it. */
private val loadFailure: () -> Pair<String, String>? = { null },
/** Begin a navigation: forget the load failure recorded for the page being
* left, drop its request list, and start the wait a following `/read`
* joins. Called on the UI thread, before the new address is loaded, so the
* wait is in place before any callback for the new page can fire. */
private val startNavigation: () -> Unit = {},
/** What the page currently in the view has asked the network for, oldest
* first. The WebView reports a request's method and URL to its interceptor
* but nothing about the response, so that is all a caller may show. */
private val requests: () -> List<BrowserRequest> = { emptyList() },
/** Wait for the navigation last asked for to settle — the page arrived, or
* its load failed — up to the given milliseconds. Called on the request's
* own thread, never the UI thread: the settle is signalled from the UI
* thread, so waiting on it there would deadlock. `/load` starts a
* navigation and returns before the page has been visited; without this a
* `/read` right behind it still sees the URL of the page before, and a
* failure recorded for that page was handed back as the new one's answer. */
private val awaitNavigation: (Long) -> Unit = {},
) {
companion object {
private const val TAG = "BrowserShellServer"
/** How long a UI-thread operation may take before the handler gives up.
* The kernel's client timeout is 30 s; staying under it means the kernel
* sees a clean error rather than a dropped connection. */
private const val UI_TIMEOUT_MS = 25_000L
/** The maximum text `/read` returns. Matches the kernel's `maxReadText`
* (400 << 10). The shell truncates here and says so, rather than letting
* the kernel truncate silently. */
private const val MAX_READ_TEXT = 400 * 1024
/** Screenshot downsample factor. 2 means half width and half height. */
private const val SCREENSHOT_SCALE = 2
/** The address the kernel dials. Both ends must agree on the family:
* see [start] for what goes wrong when they do not. */
private const val LOOPBACK_V4 = "127.0.0.1"
/** How long `/read` waits for a navigation it follows to settle before
* reading whatever the view is on. A page that never finishes loading
* must not hold the tool call open to the kernel's own timeout. */
private const val NAV_SETTLE_MS = 10_000L
/** How long `/read` then waits for the page's own script to answer.
* `NAV_SETTLE_MS + READ_SCRIPT_MS` has to stay under [UI_TIMEOUT_MS],
* and that under the kernel's 30-second client timeout: a read that
* waited out both budgets would outlast the caller, which would see a
* dropped connection instead of this shell's answer. 10 + 15 = 25 s. */
private const val READ_SCRIPT_MS = 15_000L
}
@Volatile
private var serverSocket: ServerSocket? = null
/** The port the server is listening on. Valid after [start] returns. */
@Volatile
var port: Int = 0
private set
/**
* The bearer credential every request must carry. Minted by the activity
* and handed to the kernel through the child's environment, so the server
* answers only the process this shell started.
*
* A phone's loopback is not a boundary: any other app may connect to
* 127.0.0.1, and the routes here run script in and read text out of
* whatever page is loaded. Binding to `getLoopbackAddress()` keeps other
* machines out but not other apps, so the credential is what makes "only
* the kernel this shell started" true.
*/
@Volatile
var token: String = ""
private set
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Start listening on 127.0.0.1 with an ephemeral port. The port is available
* via [port] immediately after this returns, and the credential the kernel
* must present via [token].
*/
fun start() {
token = newToken()
// The loopback address is spelled out as IPv4, not taken from
// `InetAddress.getLoopbackAddress()`. That call is allowed to answer
// either family and Android's libcore answers IPv6 (`::1`), while the
// kernel's client is hardcoded to `http://127.0.0.1:<port>`
// (`internal/assembly/boot/browser.go`). A socket bound to `::1` does
// not answer a dial to `127.0.0.1`, so every browser call failed with
// "connection refused" while the port was bound and reported — the
// failure looked like a server that never started. Both ends have to
// name the same family, and IPv4 is the one the kernel names.
val ss = ServerSocket(0, 16, java.net.InetAddress.getByName(LOOPBACK_V4))
serverSocket = ss
port = ss.localPort
Log.i(TAG, "browser shell server listening on $LOOPBACK_V4:$port")
Thread({
while (!ss.isClosed) {
try {
val client = ss.accept()
Thread({ handleClient(client, token) }, "browser-shell-conn").apply { isDaemon = true }.start()
} catch (_: SocketException) {
// Server was closed; exit the loop.
}
}
}, "browser-shell-accept").apply { isDaemon = true }.start()
}
fun stop() {
runCatching { serverSocket?.close() }
serverSocket = null
}
/** A fresh 256-bit credential, hex-encoded, for one launch of the server. */
private fun newToken(): String {
val bytes = ByteArray(32)
java.security.SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
// ---- HTTP plumbing --------------------------------------------------------
private fun handleClient(client: Socket, expected: String) {
try {
client.soTimeout = 30_000
val input = BufferedInputStream(client.getInputStream())
val output = client.getOutputStream()
// Parse the request line.
val requestLine = readLine(input) ?: return
val parts = requestLine.split(" ", limit = 3)
if (parts.size < 2) return
val method = parts[0]
val path = parts[1].substringBefore('?')
// Read headers.
var contentLength = 0
var bearer: String? = null
while (true) {
val header = readLine(input) ?: break
if (header.isEmpty()) break
val lower = header.lowercase()
if (lower.startsWith("content-length:")) {
contentLength = header.substringAfter(':').trim().toIntOrNull() ?: 0
} else if (lower.startsWith("authorization:")) {
bearer = header.substringAfter(':').trim().removePrefix("Bearer ").trim()
}
}
// Nothing behind this server is safe to hand out on the strength of
// an address: the port is on loopback, which any other app on the
// phone can dial, and the routes run script in and read text out of
// the loaded page. The credential the kernel was started with is the
// only thing that says a caller is that kernel. Every route is
// refused without it, /health included — a probe needs no exemption.
if (expected.isEmpty() || bearer != expected) {
writeResponse(output, 401, """{"error":"unauthorized"}""")
return
}
// Read body.
val body = if (contentLength > 0) {
val buf = ByteArray(contentLength)
var read = 0
while (read < contentLength) {
val n = input.read(buf, read, contentLength - read)
if (n == -1) break
read += n
}
String(buf, 0, read, Charsets.UTF_8)
} else ""
// Route.
val (status, responseBody) = route(method, path, body)
writeResponse(output, status, responseBody)
} catch (e: Exception) {
Log.w(TAG, "connection error: ${e.message}")
} finally {
runCatching { client.close() }
}
}
private fun route(method: String, path: String, body: String): Pair<Int, String> {
return when {
method == "GET" && path == "/health" -> 200 to """{"ok":true}"""
method == "POST" && path == "/load" -> handleLoad(body)
method == "POST" && path == "/close" -> handleClose()
method == "POST" && path == "/read" -> handleRead()
method == "POST" && path == "/screenshot" -> handleScreenshot()
method == "POST" && path == "/click" -> handleClick(body)
method == "POST" && path == "/eval" -> handleEval(body)
method == "POST" && path == "/back" -> handleBack()
method == "POST" && path == "/forward" -> handleForward()
method == "POST" && path == "/reload" -> handleReload()
method == "POST" && path == "/requests" -> handleRequests()
else -> 404 to """{"error":"not found"}"""
}
}
private fun writeResponse(out: OutputStream, status: Int, body: String) {
val reason = when (status) {
200 -> "OK"
404 -> "Not Found"
500 -> "Internal Server Error"
else -> "Error"
}
val bytes = body.toByteArray(Charsets.UTF_8)
val header = "HTTP/1.1 $status $reason\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: ${bytes.size}\r\n" +
"Connection: close\r\n" +
"\r\n"
out.write(header.toByteArray(Charsets.UTF_8))
out.write(bytes)
out.flush()
}
private fun readLine(input: BufferedInputStream): String? {
val sb = StringBuilder()
while (true) {
val b = input.read()
if (b == -1) return if (sb.isEmpty()) null else sb.toString()
if (b == '\r'.code) {
val next = input.read()
if (next == '\n'.code) return sb.toString()
sb.append(b.toChar())
if (next != -1) sb.append(next.toChar())
continue
}
if (b == '\n'.code) return sb.toString()
sb.append(b.toChar())
}
}
// ---- endpoint handlers ----------------------------------------------------
/** POST /load {"url":"..."} → {"url":"...","title":"..."} */
private fun handleLoad(body: String): Pair<Int, String> {
val json = runCatching { JSONObject(body) }.getOrNull()
?: return 500 to """{"error":"invalid request body"}"""
val url = json.optString("url", "")
if (url.isEmpty()) return 500 to """{"error":"missing url"}"""
val result = AtomicReference("")
val latch = CountDownLatch(1)
mainHandler.post {
val wv = ensureView()
// The failure recorded for the page being left stops being this
// tab's, and the wait a following `/read` joins is armed before the
// new address is loaded, so no callback for the new page can fire
// ahead of it. `/load` answers before the page arrives, which is
// what left the old failure to be read back as the new page's.
startNavigation()
wv.loadUrl(url)
// The title and final URL are not available synchronously after
// loadUrl; answer with what was requested. The kernel stores this
// as the tab's URL and reads the real one on the next /read.
result.set(JSONObject().apply {
put("url", url)
put("title", "")
}.toString())
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout waiting for UI thread"}"""
}
return 200 to result.get()
}
/** POST /close {} → 200 */
private fun handleClose(): Pair<Int, String> {
val latch = CountDownLatch(1)
mainHandler.post {
val wv = existingView()
if (wv != null) {
wv.loadUrl("about:blank")
wv.clearHistory()
}
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
return 200 to "{}"
}
/**
* POST /read {} → {"title":"...","url":"...","text":"..."}
*
* Injects a script that extracts readable text from the page, preferring
* `<article>` or `<main>` and falling back to `body.innerText`. The result
* is truncated to 400 KB (matching the kernel's `maxReadText`); when
* truncated, a note is appended so the model knows the text was cut.
*/
private fun handleRead(): Pair<Int, String> {
// The page the caller just asked for has not necessarily been visited
// yet: `/load` returns as soon as it has started the navigation. Waiting
// here, off the UI thread, is what makes this read answer about that
// page instead of the one before it.
awaitNavigation(NAV_SETTLE_MS)
val result = AtomicReference("")
val latch = CountDownLatch(1)
mainHandler.post {
val wv = ensureView()
// A page that did not load is not content. Whether the view is on
// the browser's own error page is read from the view itself rather
// than from a remembered failure: a failure recorded for the page
// before a new navigation would otherwise be handed back as this
// page's answer, long after the page the caller asked for had
// loaded. The remembered failure is still what names the address
// that failed and why, since the error page's own URL does not.
if ((wv.url ?: "").startsWith("chrome-error:")) {
val (url, reason) = loadFailure() ?: ((wv.url ?: "") to "ERR_FAILED")
result.set(JSONObject().apply {
put("url", url)
put("title", "")
put("reason", reason)
put("text", "")
}.toString())
latch.countDown()
return@post
}
// The extraction script: prefer article/main for content pages,
// fall back to body. innerText preserves visual line breaks. It
// also stamps a ref on every element a person could act on, so the
// kernel can name one in an act step the way the desktop backend
// names an accessibility node. A ref already on an element is kept,
// so it stays the same across snapshots of one document; a fresh
// document starts the numbering over, and the kernel only accepts a
// ref from the snapshot it last read.
val script = """
(function(){
var seq = window.__rxRefSeq || 0;
var used = {};
var marked = document.querySelectorAll('[data-rx-ref]');
for (var i = 0; i < marked.length; i++) { used[marked[i].getAttribute('data-rx-ref')] = true; }
function roleOf(el) {
var tag = el.tagName.toLowerCase();
var role = (el.getAttribute('role') || '').toLowerCase();
if (role) return role;
if (tag === 'a') return 'link';
if (tag === 'button') return 'button';
if (tag === 'select') return 'combobox';
if (tag === 'textarea') return 'textbox';
if (tag === 'input') {
var t = (el.getAttribute('type') || 'text').toLowerCase();
if (t === 'checkbox' || t === 'radio' || t === 'submit' || t === 'button' || t === 'reset') return t;
return 'textbox';
}
return 'generic';
}
function nameOf(el) {
var n = el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('name') || '';
if (!n) { n = (el.innerText || el.textContent || '').trim(); }
return n.replace(/\s+/g, ' ').slice(0, 160);
}
var sel = 'a[href],button,input,select,textarea,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[contenteditable=true]';
var els = document.querySelectorAll(sel);
var elements = [];
for (var j = 0; j < els.length; j++) {
var el = els[j];
var ref = el.getAttribute('data-rx-ref');
if (!ref) {
do { seq++; ref = 'e' + seq; } while (used[ref]);
el.setAttribute('data-rx-ref', ref);
used[ref] = true;
}
var raw = (el.value != null && String(el.value) !== '') ? String(el.value).slice(0, 160) : '';
elements.push({ref: ref, role: roleOf(el), name: nameOf(el), value: raw});
}
window.__rxRefSeq = seq;
var el2 = document.querySelector('article') || document.querySelector('main') || document.body;
var text = el2 ? (el2.innerText || '') : '';
var title = document.title || '';
var url = location.href;
return JSON.stringify({title: title, url: url, text: text, elements: elements});
})()
""".trimIndent()
wv.evaluateJavascript(script) { value ->
// evaluateJavascript returns a JSON-encoded string (the result
// is double-encoded). Strip the outer quotes.
val parsed = parseJsResult(value)
if (parsed != null) {
try {
val obj = JSONObject(parsed)
var text = obj.optString("text", "")
if (text.length > MAX_READ_TEXT) {
text = text.substring(0, MAX_READ_TEXT) +
"\n\n[text truncated at 400 KB]"
}
result.set(JSONObject().apply {
put("title", obj.optString("title", ""))
put("url", obj.optString("url", ""))
put("text", text)
put("elements", obj.optJSONArray("elements") ?: JSONArray())
}.toString())
} catch (e: Exception) {
result.set(JSONObject().apply {
put("title", "")
put("url", wv.url ?: "")
put("text", parsed.take(MAX_READ_TEXT))
}.toString())
}
} else {
result.set(JSONObject().apply {
put("title", "")
put("url", wv.url ?: "")
put("text", "")
}.toString())
}
latch.countDown()
}
}
if (!latch.await(READ_SCRIPT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout waiting for page script"}"""
}
return 200 to result.get()
}
/**
* POST /screenshot {} → {"data":"<base64>","mime":"image/png"}
*
* Draws the WebView into a bitmap, downsamples by SCREENSHOT_SCALE to keep
* memory bounded, and encodes as PNG → base64. For a 1080×2400 viewport the
* full bitmap is ~10 MB; the downsampled one is ~2.5 MB. The full bitmap is
* recycled immediately after scaling.
*/
private fun handleScreenshot(): Pair<Int, String> {
val result = AtomicReference("")
val failed = AtomicBoolean(false)
val latch = CountDownLatch(1)
mainHandler.post {
val wv = ensureView()
try {
val width = wv.width.takeIf { it > 0 } ?: 1080
val height = wv.height.takeIf { it > 0 } ?: 1920
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
wv.draw(canvas)
// Downsample to bound the PNG size and the base64 string.
val scaled = Bitmap.createScaledBitmap(
bmp,
width / SCREENSHOT_SCALE,
height / SCREENSHOT_SCALE,
true,
)
if (scaled !== bmp) bmp.recycle()
val baos = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 85, baos)
scaled.recycle()
val b64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
result.set(JSONObject().apply {
put("data", b64)
put("mime", "image/png")
}.toString())
} catch (e: Exception) {
// A 200 with an error body would be read by the kernel as a
// screenshot whose data is empty, and it would build a `data:`
// URI out of nothing. The status is what tells it this failed.
result.set("""{"error":"screenshot failed: ${e.message}"}""")
failed.set(true)
}
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
if (failed.get()) return 500 to result.get()
return 200 to result.get()
}
/** POST /click {"selector":"..."} → {"ok":true|false} */
private fun handleClick(body: String): Pair<Int, String> {
val json = runCatching { JSONObject(body) }.getOrNull()
?: return 500 to """{"error":"invalid request body"}"""
val selector = json.optString("selector", "")
if (selector.isEmpty()) return 500 to """{"error":"missing selector"}"""
val result = AtomicReference("")
val latch = CountDownLatch(1)
mainHandler.post {
val wv = ensureView()
// Escape the selector for embedding in a JS string.
val escaped = selector.replace("\\", "\\\\").replace("'", "\\'")
val script = """
(function(){
try {
var el = document.querySelector('$escaped');
if (!el) return JSON.stringify({ok: false});
el.click();
return JSON.stringify({ok: true});
} catch(e) {
return JSON.stringify({ok: false});
}
})()
""".trimIndent()
wv.evaluateJavascript(script) { value ->
val parsed = parseJsResult(value)
result.set(parsed ?: """{"ok":false}""")
latch.countDown()
}
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
return 200 to result.get()
}
/** POST /eval {"script":"..."} → {"result":"..."} */
private fun handleEval(body: String): Pair<Int, String> {
val json = runCatching { JSONObject(body) }.getOrNull()
?: return 500 to """{"error":"invalid request body"}"""
val script = json.optString("script", "")
if (script.isEmpty()) return 500 to """{"error":"missing script"}"""
val result = AtomicReference("")
val latch = CountDownLatch(1)
mainHandler.post {
val wv = ensureView()
wv.evaluateJavascript(script) { value ->
// The kernel expects a plain string in "result". The value from
// evaluateJavascript is JSON-encoded; unwrap it.
val unwrapped = parseJsResult(value) ?: ""
result.set(JSONObject().apply {
put("result", unwrapped)
}.toString())
latch.countDown()
}
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout waiting for eval"}"""
}
return 200 to result.get()
}
/** POST /back → 200 */
private fun handleBack(): Pair<Int, String> {
val latch = CountDownLatch(1)
mainHandler.post {
existingView()?.let { if (it.canGoBack()) it.goBack() }
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
return 200 to "{}"
}
/** POST /forward → 200 */
private fun handleForward(): Pair<Int, String> {
val latch = CountDownLatch(1)
mainHandler.post {
existingView()?.let { if (it.canGoForward()) it.goForward() }
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
return 200 to "{}"
}
/** POST /reload → 200 */
private fun handleReload(): Pair<Int, String> {
val latch = CountDownLatch(1)
mainHandler.post {
val wv = existingView()
// A view sitting on the browser's own error page is not on the site,
// and reloading it would retry the error page itself. The address
// that failed is loaded again instead — which is the page a caller
// means by "reload" — read before `startNavigation` clears it.
val failed = if (wv != null && (wv.url ?: "").startsWith("chrome-error:"))
loadFailure()?.first else null
startNavigation()
if (wv != null) {
if (failed != null) wv.loadUrl(failed) else wv.reload()
}
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
return 200 to "{}"
}
/** POST /requests {} → {"requests":[{"method","url","kind"},…]} */
private fun handleRequests(): Pair<Int, String> {
val arr = JSONArray()
for (r in requests()) {
arr.put(JSONObject().apply {
put("method", r.method)
put("url", r.url)
put("kind", r.kind)
})
}
return 200 to JSONObject().apply { put("requests", arr) }.toString()
}
// ---- helpers ----------------------------------------------------------------
/**
* Unwrap the result of `evaluateJavascript`. The callback receives a
* JSON-encoded string: a JS string "hello" arrives as `"\"hello\""`, a
* number 42 as `"42"`, null as `"null"`. Strip the outer JSON encoding to
* get the raw value.
*/
private fun parseJsResult(value: String?): String? {
if (value == null || value == "null") return null
// The value is a JSON literal. If it is a JSON string, decode it.
return try {
if (value.startsWith("\"") && value.endsWith("\"")) {
JSONObject().apply { }.let {
// Use JSONObject's string decoding: wrap in an object.
JSONObject("""{"v":$value}""").optString("v", "")
}
} else {
value
}
} catch (_: Exception) {
value
}
}
}
@@ -0,0 +1,119 @@
package io.reasonix.mobile.app
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.os.Build
/**
* The phone's DNS servers, as the kernel needs them.
*
* The kernel is a CGO-free Go binary, and Go's own source explains why that
* matters:
*
* ```
* // DNS requests don't work on Android, so prefer the cgo resolver.
* // Issue #10714.
* case "android":
* ```
*
* A normal Android program resolves names through `getaddrinfo`, which reaches
* the `netd` daemon and gets the servers of the network that is actually up.
* A static binary has no cgo resolver, so Go reads `/etc/resolv.conf` instead —
* and Android has no such file. Go then falls back to `127.0.0.1:53`, where
* nothing listens, and every lookup fails: the setup screen reports
* "连接失败" for every provider, including the official ones, on a phone whose
* browser reaches them fine.
*
* So they are read here and handed to the kernel through `REASONIX_DNS`. This is
* the same source the system itself uses, so a VPN, a private DNS hostname, or a
* network that changed since boot is all reflected without this code knowing
* anything about them.
*/
object Dns {
/**
* The active network's servers, comma separated, or "" when there is none.
*
* Read from the *active* network rather than from every link, because a phone
* commonly has several at once — Wi-Fi, mobile data, a VPN — and the servers
* of a network that is not carrying traffic are the wrong ones to ask.
*/
fun servers(context: Context): String {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return ""
val properties: LinkProperties = runCatching { manager.activeNetwork }.getOrNull()
?.let { network -> runCatching { manager.getLinkProperties(network) }.getOrNull() }
?: return ""
return properties.dnsServers
.mapNotNull { address -> address.hostAddress }
.filter { it.isNotBlank() }
.filter { routable(it) }
.sortedBy { preference(it) }
.joinToString(",")
}
/**
* Whether an address can actually carry a query.
*
* A link-local address (fe80::/10) without its zone cannot: the packet has
* no interface to leave by, so it is dropped, nothing answers, and the
* lookup hangs until the kernel's own timeout expires — which is reported as
* a timeout rather than as a failure to resolve, and so reads like the
* network is slow instead of like the address is unusable.
*
* Android reports the zone as a `%wlan0` suffix on `hostAddress`, and the
* address is only usable with it kept. Nothing here trims it: an earlier
* version called `substringBefore('%')`, which turned a usable link-local
* server into an unroutable one.
*/
private fun routable(host: String): Boolean =
!(host.startsWith("fe80:", ignoreCase = true) && !host.contains('%'))
/**
* IPv4 first, then IPv6.
*
* A phone commonly reports both, and its IPv6 entry is often a router's
* link-local address — reachable on one network and not the next. The
* kernel tries the servers in the order it is given them, and stops at the
* first that answers; putting the address family that shares the network's
* own numbering first means its timeout is spent on a server that works.
*/
private fun preference(host: String): Int = if (host.contains(':')) 1 else 0
/**
* The private-DNS setting, for the one case the address list cannot answer.
*
* Since Android 9 a phone may use DNS-over-TLS, and then `dnsServers` holds a
* name rather than an address — a name this kernel cannot resolve *because*
* resolving it is the thing that is broken. There is no way for the shell to
* do that lookup for it, so this reports the situation instead of handing
* over an unusable value: the caller says so in the log, and the person can
* turn private DNS off or set it to automatic.
*/
fun looksLikeDot(servers: String): Boolean =
servers.isNotBlank() && servers.split(',').any { part ->
val host = part.trim()
host.isNotEmpty() && host.none { it == ':' } && !host.all { it.isDigit() || it == '.' }
}
/** Whether the system requires a VPN or private DNS, which changes nothing
* here but is worth saying in a bug report. */
fun describe(context: Context): String {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return "no connectivity service"
val network = runCatching { manager.activeNetwork }.getOrNull() ?: return "no active network"
val properties = runCatching { manager.getLinkProperties(network) }.getOrNull()
?: return "no link properties"
// The DHCP server arrived in API 30, not 23: asked for below that it is a
// NoSuchMethodError on a device this app supports (minSdk 26), and the
// answer is only ever read out of this string by a person debugging.
val dhcp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
properties.dhcpServerAddress?.hostAddress
} else {
null
}
return "iface=${properties.interfaceName} dns=${properties.dnsServers.joinToString(",") { it.hostAddress ?: "?" }}" +
(dhcp?.let { " dhcp=$it" } ?: "")
}
}
@@ -0,0 +1,87 @@
package io.reasonix.mobile.app
import android.content.Context
import android.net.Uri
import android.webkit.CookieManager
import android.widget.Toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.FileNotFoundException
import java.net.HttpURLConnection
import java.net.URL
/**
* Putting a file the page produced onto the phone's disk.
*
* A WebView has no downloads shelf and cannot write to the system's document
* picker itself, so the two halves are joined here: the page asks, the picker
* answers with a `content://` the app has been granted, and this copies the
* bytes across.
*
* The request carries the WebView's own cookie, read out of the jar the page
* authenticated in. That is the only reason this is not a plain `openStream`:
* the kernel's download route is behind the session the page established, and a
* shell that fetched without it would get a 403 and no explanation.
*/
object Downloader {
suspend fun run(
context: Context,
url: String,
userAgent: String,
mime: String,
name: String,
target: Uri,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 60_000
requestMethod = "GET"
if (userAgent.isNotBlank()) setRequestProperty("User-Agent", userAgent)
// The download route answers with the same session cookie the
// page is using; without it the kernel refuses the request.
cookieFor(url)?.let { setRequestProperty("Cookie", it) }
}
try {
val code = connection.responseCode
if (code !in 200..299) {
throw IllegalStateException(
context.getString(R.string.download_failed_status, code),
)
}
val resolver = context.contentResolver
val output = resolver.openOutputStream(target, "w")
?: throw FileNotFoundException(target.toString())
output.use { out ->
connection.inputStream.use { input -> input.copyTo(out) }
}
// `copyTo` answers with a byte count; what the caller wants to
// know is that there were no bytes missing, not how many.
Unit
} finally {
connection.disconnect()
}
}
}
/**
* The cookie the page authenticated with, if any.
*
* `CookieManager` is the WebView's own store, so this asks the exact jar the
* request will be checked against rather than a copy this shell kept.
*/
private fun cookieFor(url: String): String? =
runCatching { CookieManager.getInstance().getCookie(url) }.getOrNull()?.takeIf { it.isNotBlank() }
/** A short receipt for a tap that otherwise does nothing visible. */
fun report(context: Context, name: String, result: Result<Unit>) {
val message = result.fold(
onSuccess = { context.getString(R.string.download_saved, name) },
onFailure = { failure ->
context.getString(R.string.download_failed, failure.message ?: "")
},
)
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
}
@@ -0,0 +1,741 @@
package io.reasonix.mobile.app
import android.content.Context
import android.content.res.AssetManager
import org.json.JSONArray
import org.json.JSONObject
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.SecureRandom
import java.util.Locale
/**
* The Reasonix kernel, as this app runs it: one child process serving loopback,
* plus the two things it needs on disk to start — the page it serves and a
* credential for this launch.
*
* The shape is the desktop host's (`cmd/reasonix-studio-host`), narrowed to what
* a phone does not have. That host binds a socket, mints a credential, speaks a
* handshake down a pipe and holds a lease; here the credential goes through
* `--token-file` and the bound address through `--port-file`, because a phone's
* app process has no pipe to hand a child and no reason to care which port was
* free. Everything after that — the kernel's own gate, its auth middleware, its
* CSRF check — is untouched, so the security of the surface is the one upstream
* tested, not one this shell invented.
*
* One instance lives in the app process. The activity asks it for a URL; the
* foreground service asks it to stay alive. Both see the same process, so there
* is nothing to synchronise beyond a lock on start/stop.
*/
class Kernel(private val context: Context) {
/** Where everything the kernel owns lives. Nothing here is backed up. */
private val home = File(context.filesDir, "home")
/**
* The folder the agent works in, which is not the folder the kernel lives in.
*
* These used to be the same directory, and that is what made the workbench
* unreadable: the kernel's home holds `config.toml`, `projects/`, `cache/`,
* `stats/` and the rest of its own bookkeeping, and a workspace pointed at
* it shows all of that as if the person had put it there. Reported as "why
* does the default workspace have so many files, the desktop version does
* not generate these" — the desktop does generate them, in a different
* directory, because its workspace is whatever folder it was opened on.
*
* Split apart, the workbench shows what the agent produced and nothing else.
* `write_roots` follows the workspace, so the file tools stay confined to
* the folder the person actually works in rather than to the kernel's own
* home — which is the stricter answer, and the one the desktop already has.
*/
private val workspaceDir = File(context.filesDir, "workspace")
/**
* The directory the kernel calls `REASONIX_HOME`, for the one caller that
* has to put a file where the kernel can read it — a private key the person
* picked from their Documents folder.
*/
fun homeDir(): File = home
/** The folder the agent works in. */
fun workspaceDir(): File = workspaceDir
/** The built page, unpacked once per version. */
private val pageDir = File(context.filesDir, "page")
private val tokenFile = File(home, "remote/launch.token")
private val portFile = File(home, "remote/serve.port")
private val pidFile = File(home, "remote/serve.pid")
private val lock = Any()
@Volatile
private var process: Process? = null
/** The port the shell's browser HTTP server listens on, set by the
* activity before `start()` is called. Zero means no server is up, and
* the kernel leaves its browser tools suspended (the correct degradation
* on a phone with no Chromium). */
@Volatile
var browserShellPort: Int = 0
/** The bearer credential for that server, set beside the port. Sent on
* every browser call so the server can refuse anything that did not come
* from the kernel this shell started — loopback on a phone is reachable
* by every other app. Empty when there is no server. */
@Volatile
var browserShellToken: String = ""
/** What the WebView should load, once the kernel has bound a port. */
@Volatile
var origin: String? = null
private set
/**
* The launch credential, as the URL fragment the page trades for a cookie.
* Kept in memory only: the file on disk belongs to the kernel's own
* bookkeeping, and a phone's app data is not a place to leave a second copy.
*/
@Volatile
private var token: String? = null
/**
* The URL the WebView loads first. It carries the credential in the
* fragment, which the page exchanges for an HttpOnly cookie before it makes
* any request — the same path a shared `reasonix web` link takes, so the
* token never enters a request line, a history entry or a log.
*/
fun entryUrl(): String? {
val origin = origin ?: return null
val token = token ?: return null
return "$origin/#token=$token"
}
/** The plain origin, for reloads once the cookie is set. */
fun plainUrl(): String? = origin
fun isRunning(): Boolean = process?.isAlive == true
/**
* Bring the kernel up, or answer the URL of the one already up.
*
* Starting is idempotent by intent: the activity and the service both call
* this, and whichever arrives second joins the running kernel instead of
* racing it for the port.
*/
suspend fun start(): Result<String> = withContext(Dispatchers.IO) {
synchronized(lock) {
origin?.let { return@withContext Result.success(it) }
runCatching {
val binary = kernelBinary()
publishPage()
prepareHome()
val launchToken = mintToken()
token = launchToken
val child = launch(binary, launchToken)
// The port is the kernel's answer, not this shell's guess. It
// writes the file after binding, so waiting on the file is
// waiting on the bind.
val bound = awaitPort(child)
val url = "http://$bound"
// A kernel that bound a port and then died would look healthy
// here and broken in the WebView; the two facts are checked
// together, and only a kernel that is answering is adopted.
awaitServing(child, url)
origin = url
process = child
url
}.onFailure { origin = null; process = null }
}
}
/**
* Stop the kernel.
*
* The graceful signal first: the kernel drains its listeners and closes the
* runtimes it opened, which is what lets a later launch adopt the sessions
* rather than find them locked. `destroyForcibly` is the second attempt, and
* only after a wait — a phone that is being shut down should not keep a
* half-killed agent.
*/
fun stop() {
synchronized(lock) {
val p = process ?: return
process = null
origin = null
token = null
runCatching {
p.destroy()
if (!p.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)) {
p.destroyForcibly()
}
}
portFile.delete()
pidFile.delete()
}
}
// ---- the two inputs that ship inside the APK -------------------------
/**
* The kernel binary.
*
* It is packaged as a native library, so Android has already extracted it to
* a directory it allows execution from and given it the executable bit —
* which is the one thing a file under app storage does not get for free.
*/
private fun kernelBinary(): File {
val dir = File(context.applicationInfo.nativeLibraryDir)
val binary = File(dir, "libreasonix.so")
if (!binary.exists()) {
throw IllegalStateException(
"内核未随 APK 安装:${binary.path}\n这是一次构建问题,请重新安装完整 APK。",
)
}
return binary
}
/**
* Write the bundled page where the kernel can serve it.
*
* It goes to app storage rather than being served out of the APK because the
* kernel's `--page` is a directory on a real filesystem; there is no
* archive-aware variant, and adding one would be a change to the kernel for
* this shell's convenience alone.
*
* Unpacking is versioned: the marker holds the app's version name, so an
* update replaces the page and a relaunch does not. A partially written
* directory — the app killed mid-copy — is caught by the same marker never
* having been written, so the next launch rewrites it.
*/
private fun publishPage() {
val marker = File(pageDir, ".unpacked")
val stamp = "${BuildConfig.VERSION_NAME}:${BuildConfig.VERSION_CODE}"
if (marker.isFile && marker.readText().trim() == stamp) return
pageDir.deleteRecursively()
pageDir.mkdirs()
copyAssetTree(context.assets, "page", pageDir)
if (!File(pageDir, "index.html").isFile) {
throw IllegalStateException("随 APK 分发的页面不完整:缺少 index.html")
}
marker.writeText(stamp)
}
/**
* A directory tree out of assets.
*
* `AssetManager.list` on a file answers with an empty array, so a "file" is
* told from a directory by trying to open it — which is the only signal the
* API offers and the reason this is not a one-liner.
*/
private fun copyAssetTree(assets: AssetManager, from: String, to: File) {
val children = assets.list(from).orEmpty()
if (children.isEmpty()) {
// A leaf: open it as a stream. A directory that happens to be empty
// is indistinguishable and harmless to skip.
runCatching {
assets.open(from).use { input ->
FileOutputStream(to).use { output -> input.copyTo(output) }
}
}
return
}
to.mkdirs()
for (child in children) {
copyAssetTree(assets, "$from/$child", File(to, child))
}
}
/**
* Prepare the kernel's home.
*
* `REASONIX_HOME` is what every path in the kernel hangs off — config,
* sessions, the remote state directory its token file lands in — so pointing
* it at app storage is what keeps a phone's Reasonix separate from anything
* else and away from global storage entirely.
*/
private fun prepareHome() {
File(home, "remote").mkdirs()
// The workspace directory not existing yet is what marks the first run
// after this build split it from the kernel's home. That is the one
// moment a migration is due, and doing it here means it happens before
// the kernel is asked for anything.
val firstSplit = !workspaceDir.exists()
workspaceDir.mkdirs()
if (firstSplit) {
adoptSessionsFromHome()
}
// Every launch, not only the first. The rewrite is cheap and
// idempotent, and an entry that survived the first pass would otherwise
// survive forever: `firstSplit` is spent the moment the directory
// exists, so a build that cleaned the list once and missed leaves the
// home on the sidebar for good. Running it unconditionally costs one
// small read and makes the whole thing self-correcting.
forgetHomeFromWorkspaceList()
}
/**
* Drop the kernel's own home from the sidebar's remembered projects.
*
* The list is written whenever a pane opens, and until this build the pane
* opened on the home itself — so an upgraded install carries a "project"
* that is really the kernel's own directory. Leaving it there would put the
* person back where they started: one tap on it and the workbench is full
* of `config.toml`, `cache/` and `projects/` again, which is the complaint
* this whole change answers. Only that one path is removed; every folder
* someone added on purpose stays.
*
* Parsed rather than pattern-matched, because the file's two shapes — a bare
* array of paths and an object with `paths` and `launch` — both have to keep
* whatever else they hold.
*/
private fun forgetHomeFromWorkspaceList() {
val file = File(home, "serve-workspaces.json")
if (!file.isFile) return
val text = runCatching { file.readText() }.getOrNull() ?: return
val wrong = runCatching { homeNames() }.getOrNull() ?: return
val rewritten = runCatching { withoutHome(text, wrong) }.getOrNull() ?: return
if (!rewritten.dropped) return
runCatching { file.writeText(rewritten.text) }
.onSuccess { Log.i(TAG, "dropped the kernel home from the project list") }
.onFailure { Log.w(TAG, "could not rewrite the project list: ${it.message}") }
}
/**
* Every name of the kernel's home on this device — not just the one this
* process happens to write.
*
* Android shows one directory under two names: `/data/data/<pkg>` and
* `/data/user/0/<pkg>` are two mounts of the same tree, the same inode
* either way, and which name a path carries depends on how it was written
* rather than on where it points. The list is written by the kernel, whose
* working directory this shell set from a path it built itself; a lookup
* that compared against only one spelling would silently keep the entry
* this exists to remove. Both names are compared, so either spelling is
* recognised.
*/
private fun homeNames(): Set<String> {
val path = home.absolutePath
val names = mutableSetOf(path)
val user = "/data/user/0/"
val data = "/data/data/"
when {
path.startsWith(user) -> names.add(data + path.removePrefix(user))
path.startsWith(data) -> names.add(user + path.removePrefix(data))
}
return names
}
/** What a rewrite produced, and whether it actually removed anything. */
private class Rewrite(val text: String, val dropped: Boolean)
/**
* The list without the home, in whichever shape it arrived, and whether
* anything was actually dropped from it.
*
* The flag matters because `JSONObject` re-serialises: it escapes every
* `/` and does not preserve key order, so a round trip never reproduces
* the original bytes. Deciding to write on "the text differs" alone would
* therefore rewrite the kernel's file on every single launch, log a drop
* that never happened, and race the kernel for a file it also writes.
*/
private fun withoutHome(text: String, wrong: Set<String>): Rewrite {
if (text.trimStart().startsWith("[")) {
val paths = JSONArray(text)
val kept = JSONArray()
var dropped = false
for (i in 0 until paths.length()) {
val path = paths.optString(i)
if (path in wrong) {
dropped = true
continue
}
kept.put(path)
}
return if (dropped) Rewrite(kept.toString(), true) else Rewrite(text, false)
}
val list = JSONObject(text)
val kept = JSONArray()
var dropped = false
val paths = list.optJSONArray("paths")
if (paths != null) {
for (i in 0 until paths.length()) {
val path = paths.optString(i)
if (path in wrong) {
dropped = true
continue
}
kept.put(path)
}
if (dropped) list.put("paths", kept)
}
if (list.optString("launch") in wrong) {
list.remove("launch")
dropped = true
}
return if (dropped) Rewrite(list.toString(), true) else Rewrite(text, false)
}
/**
* Move the conversations that were filed under the kernel's own home.
*
* The transcript store is keyed by the workspace root — one directory per
* project, under `<state>/projects/<slug>/sessions` — and until this build
* the root was the home itself. Splitting the two therefore moves every
* existing conversation to a slug nothing looks under, which reads as "my
* history is gone" rather than as a reorganisation. So the old directory is
* renamed onto the new slug once.
*
* Renaming the project directory rather than just `sessions/` carries the
* rest of it too: `memory/` is filed the same way, and a project's memory
* lost while its transcripts were kept would be a stranger failure than
* either alone.
*
* The slug is the absolute path with its separators flattened, which is
* what `config.WorkspaceSlug` does on the kernel's side. Only the home's own
* slug is touched: a slug for some other folder is left exactly where it is,
* because those are projects someone added on purpose.
*/
private fun adoptSessionsFromHome() {
val projects = File(home, "projects")
if (!projects.isDirectory) return
val from = projects.resolve(slugOf(home.absolutePath))
if (!from.isDirectory) return
val to = projects.resolve(slugOf(workspaceDir.absolutePath))
// A target already there means a previous run half-finished, or the
// person opened this folder before. Either way its contents win:
// merging two transcript directories is not a thing this can do safely,
// and the old one is still on disk to be looked at.
if (to.exists()) {
Log.i(TAG, "${to.name} already exists; leaving ${from.name} in place")
return
}
if (!from.renameTo(to)) {
Log.w(TAG, "could not move ${from.name} to ${to.name}; history stays under the old slug")
} else {
Log.i(TAG, "moved conversations from ${from.name} to ${to.name}")
}
}
/** The kernel's `config.WorkspaceSlug`, for the two paths that matter here. */
private fun slugOf(path: String): String = path.replace('/', '-').replace('\\', '-').replace(':', '-')
/**
* The launch credential.
*
* Minted here rather than read from configuration: a credential a person can
* set is one anything that can read their files can present. 32 bytes, hex,
* the same shape the desktop host mints.
*/
private fun mintToken(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
val token = bytes.joinToString("") { "%02x".format(it) }
tokenFile.parentFile?.mkdirs()
tokenFile.writeText("$token\n")
// 0600 by intent, and the app's own private directory means it is
// already unreachable to every other app on the device.
runCatching {
tokenFile.setReadable(false, false)
tokenFile.setReadable(true, true)
tokenFile.setWritable(false, false)
tokenFile.setWritable(true, true)
}
return token
}
/**
* Start the child.
*
* `--addr 127.0.0.1:0` lets the kernel pick a free port; `--port-file`
* makes it announce which. `--auth token` is the mode a phone needs: the
* page authenticates, and a state-changing request without the credential
* is refused. `--no-open` is belt and braces — a serve never opens a
* browser, and a phone has no browser to open.
*/
private fun launch(binary: File, token: String): Process {
portFile.delete()
pidFile.delete()
// The folder the agent works in, resolved before the command line is
// built so it can be named on it. See the note under the environment
// block for why naming it is not the same as setting the directory.
val workspace = launchWorkspaceDir()
val command = listOf(
binary.absolutePath,
"serve",
"--addr", "127.0.0.1:0",
"--auth", "token",
"--token-file", tokenFile.absolutePath,
"--port-file", portFile.absolutePath,
"--pid-file", pidFile.absolutePath,
"--no-open",
"-page", pageDir.absolutePath,
// The project root, said outright. Without this the kernel derives
// it from its working directory, and on this phone the two did not
// agree: the sidebar named the shared folder, `write_roots` confined
// the file tools to the app's own directory, and every write into the
// folder the person was looking at came back
// `workspace.write_outside_scope`. A flag is a fact the kernel was
// told rather than one it inferred from a mount it sees differently.
"--dir", workspace.absolutePath,
)
val builder = ProcessBuilder(command)
builder.environment().apply {
put("REASONIX_HOME", home.absolutePath)
put("HOME", home.absolutePath)
// The kernel's own language, for its tool failures, approval
// prompts and error sentences. Without this it detects from the
// POSIX locale variables, and Android sets none of them for an
// app: the kernel then answers in English while the page in front
// of the person is Chinese, and every sentence added for a Chinese
// reader was dead text. The tag, not the bare language, so a
// Traditional Chinese device resolves to zh-TW rather than zh.
// An explicit `language` in the kernel's config.toml still wins:
// DetectLanguage applies the configured value over this.
val languageTag = Locale.getDefault().toLanguageTag()
if (languageTag.isNotBlank() && languageTag != "und") {
put("REASONIX_LANG", languageTag)
}
// Android has no /tmp. Everything that wants a scratch directory —
// the shell tool, tool output spooling, sqlite journals — reads this.
put("TMPDIR", context.cacheDir.absolutePath)
// The phone's own DNS servers. A CGO-free Go binary cannot read them
// for itself — Android keeps them in `netd`, and Go looks for a
// `/etc/resolv.conf` that does not exist — so without this the kernel
// resolves nothing and every provider probe reports "连接失败" on a
// device whose browser reaches the same address fine.
val dns = Dns.servers(context)
if (dns.isNotBlank()) {
put("REASONIX_DNS", dns)
Log.i(TAG, "kernel DNS: $dns (${Dns.describe(context)})")
if (Dns.looksLikeDot(dns)) {
// Private DNS (DNS-over-TLS) is on, and it hands back a
// hostname this kernel cannot resolve by definition. Saying
// so here is what turns "连不上" into something actionable.
Log.w(TAG, "kernel DNS looks like a private-DNS hostname; turn private DNS off or set it to automatic")
}
} else {
Log.w(TAG, "kernel DNS: none reported by the system")
}
// The shell's browser server, when one is listening. The kernel
// reads this to bind its browser tools to the shell's WebView
// instead of looking for a Chromium binary that does not exist on
// a phone. When absent (zero), the variable is not set and the
// kernel keeps the browser tools suspended — the correct
// degradation for a phone with no engine to drive.
val shellPort = browserShellPort
if (shellPort > 0) {
put("REASONIX_SHELL_BROWSER_PORT", shellPort.toString())
// The credential the server checks. Set only alongside the
// port: a token with nothing listening would be a variable the
// kernel carries for no reason.
val shellToken = browserShellToken
if (shellToken.isNotBlank()) {
put("REASONIX_SHELL_BROWSER_TOKEN", shellToken)
}
}
}
// The agent's folder, not the kernel's. `REASONIX_HOME` above is still
// where the config, the sessions and the transcripts live; this is what
// the workspace root resolves to, because the kernel reads the working
// directory for it.
//
// The folder the person last opened, when it is still usable — the same
// answer the desktop host gives (`launchWorkspace`), and the reason a
// phone session used to work in the app's own `files/workspace` while
// the sidebar said it was in the shared folder: the launch entry in
// `serve-workspaces.json` was written and then ignored, and `write_roots`
// is derived from the working directory, so the file tools were confined
// to the app's private folder and every write into the shared one was
// refused. `workspaceDir` is still the answer for a first run and for a
// folder since deleted, but the choice is never silent — see
// [launchWorkspaceDir]. `workspace` was resolved above so it could also
// be named on the command line.
builder.directory(workspace)
// `PWD` is what the kernel's own `os.Getwd` prefers: Go uses it whenever
// it names the same directory as `.`, which is exactly this case, and it
// is the only way to make the kernel resolve its root to the string this
// shell chose. Without it `os.Getwd` falls back to the kernel's view of
// the mount, and on Android `/storage/emulated/0/...` and the path the
// FUSE layer reports for it need not be spelled the same way. The kernel
// derives `write_roots` from that root, so the two spellings have to be
// the same one.
builder.environment().put("PWD", workspace.absolutePath)
builder.redirectErrorStream(true)
val process = builder.start()
// The kernel's own log is the only place a startup failure says why, and
// a phone has no terminal to read. It is drained on a thread and kept
// ring-buffered so the About screen can show the last of it.
Thread({ drain(process) }, "kernel-log").apply { isDaemon = true }.start()
return process
}
/**
* The folder the person last opened, or null when there is not one.
*
* `serve-workspaces.json` is the kernel's own record of the projects the
* sidebar lists, and its `launch` entry is which of them a session starts
* in. Both shapes the file has are read — a bare array of paths, and an
* object with `paths` and `launch` — because both are written in the wild
* and a reader that only knew one would silently fall back.
*
* The entry has to be one of the listed paths, which is the same rule the
* kernel applies when it reads this file (`workspacelist.Read` blanks a
* `launch` that is not in `paths`). It matters more here than there: the
* working directory decides `write_roots`, so whatever this answers is
* where the agent's file tools may write — and on a phone that permission
* reaches all of shared storage. A file with a `launch` outside its own
* list is one this shell did not write, and it gets the listed answer.
*
* Null for anything unusable: no file, unreadable JSON, a launch that is
* not among the listed folders, an entry that has since been deleted, or a
* path that is now a file. Every one of those means the same thing to the
* caller — start where this shell always started.
*/
private fun rememberedLaunchDir(): File? {
val file = File(home, "serve-workspaces.json")
if (!file.isFile) return null
val text = runCatching { file.readText() }.getOrNull() ?: return null
val launch = runCatching {
// A bare array is the file's other shape — the one older kernels
// wrote, and therefore the one a person upgrading is most likely to
// have. Without this branch `JSONObject(text)` throws on it and the
// whole function falls back, which is the same mismatch (the
// sidebar naming one folder, the session working in another) this
// exists to fix. `withoutHome` below reads both shapes the same way.
if (text.trimStart().startsWith("[")) {
val arr = JSONArray(text)
if (arr.length() == 0) return@runCatching null
arr.optString(0)
} else {
val json = JSONObject(text)
val paths = json.optJSONArray("paths")
?.let { arr -> (0 until arr.length()).mapNotNull { arr.optString(it).takeIf(String::isNotBlank) } }
.orEmpty()
if (paths.isEmpty()) return@runCatching null
json.optString("launch").takeIf { it.isNotBlank() && it in paths } ?: paths.first()
}
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return null
val dir = runCatching { File(launch) }.getOrNull() ?: return null
if (!dir.isDirectory) {
// A launch entry this process cannot stat is exactly the failure the
// caller exists to avoid, and falling back silently is what made it
// take three releases to notice: the session opened in the app's own
// folder while the sidebar named the shared one. A phone has no
// terminal, so the log is the only place the reason can be said —
// and `exists`/`readable` tell apart "deleted" from "not permitted",
// which need different answers from the person.
Log.w(
TAG,
"launch entry $launch is not a usable directory " +
"(exists=${dir.exists()}, readable=${dir.canRead()})",
)
return null
}
return dir
}
/**
* The folder the kernel should start in.
*
* Either the launch entry — the folder the person last opened, which is
* also the one the sidebar shows — or the app's own folder when there is
* none. Whichever it is, it is logged, because the two disagreeing is the
* bug this exists to fix and a silent choice is what let it survive.
*/
private fun launchWorkspaceDir(): File {
val remembered = rememberedLaunchDir()
if (remembered != null) {
Log.i(TAG, "kernel workspace: ${remembered.absolutePath} (the remembered launch project)")
return remembered
}
Log.i(TAG, "kernel workspace: ${workspaceDir.absolutePath} (no remembered launch project)")
return workspaceDir
}
private val logLines = ArrayDeque<String>()
private fun drain(process: Process) {
runCatching {
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
Log.i(TAG, line)
synchronized(logLines) {
logLines.addLast(line)
while (logLines.size > LOG_KEEP) logLines.removeFirst()
}
}
}
}
}
/** The kernel's tail, newest last, for a bug report or the About screen. */
fun recentLog(): String = synchronized(logLines) { logLines.joinToString("\n") }
private fun awaitPort(process: Process): String {
val deadline = System.currentTimeMillis() + START_TIMEOUT_MS
while (System.currentTimeMillis() < deadline) {
if (!process.isAlive) {
throw IllegalStateException("内核启动即退出。\n${recentLog().takeLast(2000)}")
}
val text = if (portFile.isFile) runCatching { portFile.readText().trim() }.getOrNull() else null
// The file is written by the kernel on the way to serving, and the
// kernel rewrites it with the real port when it was given :0.
if (!text.isNullOrBlank() && text.contains(":")) return text
Thread.sleep(POLL_MS)
}
throw IllegalStateException("内核在 ${START_TIMEOUT_MS / 1000}s 内没有报告监听地址。\n${recentLog().takeLast(2000)}")
}
/**
* Wait until the bound port answers.
*
* A port that is open but not yet serving is the normal first second of a
* kernel's life; a WebView pointed at it would show a connection error and
* cache the failure, so the wait belongs here rather than in the page.
*/
private fun awaitServing(process: Process, origin: String) {
val deadline = System.currentTimeMillis() + START_TIMEOUT_MS
while (System.currentTimeMillis() < deadline) {
if (!process.isAlive) {
throw IllegalStateException("内核在开始服务前退出。\n${recentLog().takeLast(2000)}")
}
if (probe("$origin/")) return
Thread.sleep(POLL_MS)
}
throw IllegalStateException("内核已监听 $origin,但页面没有响应。\n${recentLog().takeLast(2000)}")
}
private fun probe(url: String): Boolean = runCatching {
(URL(url).openConnection() as HttpURLConnection).run {
connectTimeout = 1000
readTimeout = 1000
requestMethod = "GET"
try {
responseCode in 200..399
} finally {
disconnect()
}
}
}.getOrDefault(false)
private companion object {
const val TAG = "ReasonixKernel"
const val LOG_KEEP = 400
const val POLL_MS = 120L
const val START_TIMEOUT_MS = 30_000L
}
}
@@ -0,0 +1,139 @@
package io.reasonix.mobile.app
import android.Manifest
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Keeps the kernel alive while the app is not on screen.
*
* The reason this exists at all: an agent turn that takes minutes is a normal
* turn, and Android reclaims a backgrounded app's child process within seconds
* of the screen going off. Without the service, pocketing the phone would kill
* a running task — the one failure mode that makes a phone a bad client for
* something that works on its own.
*
* A foreground service needs a notification the person can see, which is also
* the honest thing to show: the kernel is running and what it is doing lives on
* another machine.
*/
class KernelService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Promoted before `start()`: a service that starts work first and
// notifies after has a window in which the system may kill it, and the
// work it would have been protecting is exactly what is being started.
startForegroundNotification()
val kernel = (application as App).kernel
scope.launch {
kernel.start().onFailure { failure ->
// A kernel that will not start is a fact the person needs, and a
// notification that only ever said "running" would be a lie.
notify(SERVICE_NOTICE, getString(R.string.notify_failed_title), failure.message.orEmpty())
stopSelf()
}
}
// Sticky, so a kernel killed under memory pressure comes back when there
// is room — which is what a person who left a task running expects.
return START_STICKY
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun startForegroundNotification() {
val notification = buildNotification(
getString(R.string.notify_running_title),
getString(R.string.notify_running_body),
)
// `dataSync` matches the manifest's declaration. On 34+ the type is
// mandatory and a mismatch throws, so it is named in both places.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceCompat.startForeground(
this,
SERVICE_NOTICE,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
startForeground(SERVICE_NOTICE, notification)
}
}
private fun buildNotification(title: String, body: String): Notification {
val open = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, App.CHANNEL_KERNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setContentIntent(open)
// The notification is a receipt, not an alarm; swiping it away
// leaves the kernel running, which is what the person meant by
// swiping it.
.setOngoing(false)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
}
private fun notify(id: Int, title: String, body: String) {
// The notification permission is granted at runtime from Android 13 and
// the person can refuse it. Without this check the call throws
// SecurityException and takes the service down with it — the opposite of
// what a notification that could not be shown should do. Below 13 the
// permission does not exist and posting always succeeds.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
return
}
runCatching {
NotificationManagerCompat.from(this).notify(id, buildNotification(title, body))
}
}
companion object {
private const val SERVICE_NOTICE = 1
/** Idempotent: called on every start of the activity, and by the boot
* path, and safe to repeat. */
fun start(context: Context) {
ContextCompat.startForegroundService(context, Intent(context, KernelService::class.java))
}
fun stop(context: Context) {
context.stopService(Intent(context, KernelService::class.java))
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,744 @@
package io.reasonix.mobile.app
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentValues
import android.content.Context
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.webkit.JavascriptInterface
import android.webkit.MimeTypeMap
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* What the page can ask the phone for.
*
* This is the whole surface, and it is deliberately tiny. Android's
* `@JavascriptInterface` is a bridge into the app's process from a WebView whose
* content is a web page: every method here is reachable by anything that can run
* script in that page, so each one is written as a verb with no address
* parameter it does not need, and none of them can name a path outside the
* kernel's own home.
*
* It is deliberately *not* the Electron preload bridge. That one exposes window
* management, a filesystem walker, an editor launcher and a browser view host —
* all of which are meaningless on a phone, and the two that were not (open a URL,
* save a file) already have a way to happen here: `WebViewClient` routes external
* links to the browser, and the page's own download reaches the WebView's
* `DownloadListener`.
*
* The one thing a phone must add is `importIntoHome`: the kernel runs on this
* device and can only read paths under its own home, so a private key the person
* has in their Documents folder has to be copied across. Nothing else the page
* wants is out of its reach.
*/
class NativeBridge(
private val context: Context,
private val kernel: Kernel,
private val onOpen: (String) -> Unit,
) {
/**
* Whether the document calling may use this bridge.
*
* `addJavascriptInterface` makes the object reachable from whatever page the
* WebView holds, so a link the model produced — or a cleartext page on the
* LAN — would otherwise get `importIntoHome`, `pickFolder`, `hostCall` and
* the clipboard. The activity answers by comparing the main frame's origin
* against the kernel's and the one paired computer the person confirmed.
*
* Anything that reaches the device checks this first. The five methods that
* do not — version, systemTheme, safeArea, setLayerOpen, setRunning — report
* back what the page already knows about its own window and grant nothing.
*/
var trusted: () -> Boolean = { false }
/** The version the page reads to decide whether this bridge is the one it
* knows. Bumped when a method's contract changes, not when one is added. */
@JavascriptInterface
fun version(): String = BRIDGE_VERSION
/**
* Copy a file the person picks into the kernel's home, answering with its
* path there — or "" when they dismissed the picker.
*
* `kind` names the directory inside the home, because that is a fact about
* the kernel's layout and not about the file: an SSH private key belongs in
* `.ssh`, and a dotenv the kernel reads for passphrases belongs at the root
* of the home where `config.Load` looks for it.
*
* The path that comes back is *inside the home*, never the document
* provider's URI. That is the point: the kernel is a child process with no
* access to the provider, so a `content://` would be a path it cannot open.
*/
@JavascriptInterface
fun importIntoHome(kind: String, name: String): String {
if (!trusted()) return ""
val dir = when (kind) {
"identity" -> File(kernel.homeDir(), ".ssh")
"env" -> kernel.homeDir()
else -> return ""
}
val safeName = sanitise(name)
if (safeName.isEmpty()) return ""
val picked = pickFile() ?: return ""
return try {
dir.mkdirs()
val target = File(dir, safeName)
context.contentResolver.openInputStream(picked)?.use { input ->
target.outputStream().use { output -> input.copyTo(output) }
} ?: return ""
// A private key with group or world read is one OpenSSH refuses to
// use, and the refusal reads as "bad permissions" from a long way
// away. Setting it here is what makes the file usable at all.
if (kind == "identity") {
runCatching {
target.setReadable(false, false)
target.setReadable(true, true)
target.setWritable(false, false)
target.setWritable(true, true)
}
}
target.absolutePath
} catch (_: Exception) {
// A copy that failed leaves nothing behind; the caller sees "" and
// the person can try again.
""
}
}
/**
* Open a URL in the device's browser.
*
* The page asks for this when a link points somewhere outside the kernel —
* a provider's console, a documentation page — and loading it in place would
* strand the person in a window with no address bar and no way back.
*/
@JavascriptInterface
fun openExternal(url: String) {
if (!trusted()) return
// Only what a browser can open. A `file://` or `content://` here would
// be the page reaching into the device through a method named for
// leaving it.
if (!url.startsWith("http://") && !url.startsWith("https://")) return
onOpen(url)
}
/** Put text on the clipboard. A WebView's own `navigator.clipboard` write is
* gated behind a permission Android grants only to a focused document, so
* the one thing the page actually offers to copy — a token, a command, a
* path — has no other way out.
*
* Answers whether the write landed. A platform that refuses it raises
* rather than answering, and an exception must not cross
* `@JavascriptInterface` as a silent success — the page would say 已复制
* over an empty clipboard. Caught here, the answer is the one the caller
* can act on: false, and the button says 复制失败 instead. */
@JavascriptInterface
fun copyText(text: String): Boolean = if (!trusted()) false else runCatching {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("reasonix", text))
}.isSuccess
/**
* Write text where the person picks, and answer with the path it went to.
*
* The page's own route to a file — a blob URL on an `<a download>` — reaches
* nothing here: a WebView has no downloads shelf, and its `DownloadListener`
* fires only for a real navigation, not for a blob the page just built. So
* the text crosses the bridge and the shell opens the system's document
* picker, which is also the only way to reach a folder the person can find
* again afterwards.
*
* "" is a dismissal, the same answer `importIntoHome` gives, and the page
* reads the two the same way.
*/
/**
* Put a copy of a file where a phone's own file manager will show it.
*
* `saveText` asks the person where to put it, which is the right question
* on a desktop and the wrong one here: the kernel's workspace is
* app-private storage, so the file someone wants is in a place no file
* manager can open, and a picker that starts at "recent" is another place
* to lose it. This answers the actual question — "where is it" — by
* putting it in Downloads, in a folder named after the app, under a name
* that is the file's own.
*
* The answer is the path relative to shared storage, so the page can say
* where the file went rather than only that it worked. "" means the write
* failed or the platform refused it.
*
* MediaStore on Android 10 and later, a plain file before that. The two
* are not interchangeable: from 10 the app may not write another app's
* files directly, and MediaStore is the only way in; before it there is no
* MediaStore collection for downloads and the public directory is writable
* with the storage permission this app does not ask for, so that branch is
* best-effort and says so by answering "".
*/
@JavascriptInterface
fun exportToDownloads(name: String, content: String): String {
if (!trusted()) return ""
val safeName = sanitise(name).ifEmpty { "reasonix.txt" }
return exportFile(safeName, content).orEmpty()
}
@JavascriptInterface
fun saveText(name: String, content: String): String {
if (!trusted()) return ""
val safeName = sanitise(name).ifEmpty { "reasonix.txt" }
return saveFile(safeName, content) ?: ""
}
/**
* Hand text to whatever the device shares with — a chat app, the mail
* client, a note app.
*
* The share sheet rather than a second save dialog: "where is that file"
* is the question this answers, and on a phone a document someone wants to
* keep or send usually goes through one of those apps rather than a folder.
*
* Shared as text with the filename as its subject, which is what every
* target understands. A file attachment would need a `FileProvider` and a
* cache copy for the same words to arrive.
*/
@JavascriptInterface
fun shareText(name: String, content: String): Boolean {
if (!trusted()) return false
val safeName = sanitise(name).ifEmpty { "reasonix.txt" }
val posted = java.util.concurrent.CountDownLatch(1)
var ok = false
main.post {
ok = runCatching { askForShare(safeName, content) }.getOrDefault(false)
posted.countDown()
}
runCatching { posted.await(SHARE_TIMEOUT_SECONDS, TimeUnit.SECONDS) }
return ok
}
/**
* The device's own light/dark answer.
*
* The page resolves its theme from `localStorage` first and only asks the
* system when nothing is saved, so this is what makes a fresh install match
* the phone instead of defaulting to light.
*/
@JavascriptInterface
fun systemTheme(): String {
val night = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return if (night == Configuration.UI_MODE_NIGHT_YES) "dark" else "light"
}
/**
* The window's real safe-area insets, in CSS pixels, as
* `top,right,bottom,left`.
*
* A WebView does not implement `env(safe-area-inset-*)`: those are an iOS
* Safari feature, and on Android every one of them resolves to `0`. So a
* page that pads itself with them — which `mobile.css` does — is un-padded
* on exactly the platform this shell exists for, and the first line of the
* interface sits under the status bar.
*
* The values have to come from here for a second reason too: from Android 15
* the window is edge-to-edge whether the app asks for it or not, so the bars
* really are drawn over the page and there is no window-manager switch that
* could move the content back down.
*
* Answered as a string because that is what crosses the bridge; the page
* sets it as a CSS custom property and `mobile.css` reads it.
*/
@JavascriptInterface
fun safeArea(): String {
val insets = onInsets?.invoke() ?: return "0,0,0,0"
return "${insets.left},${insets.top},${insets.right},${insets.bottom}"
}
/**
* Read the pairing code a computer is showing.
*
* A phone cannot type what a computer renders as a QR code — it is a
* one-time code inside a URL, and the whole reason it is a picture is that
* nobody reads one aloud. So the page asks for the camera and gets the text
* back, which is the same direction the computer's own dialog describes.
*
* The answer is the code's own text, not a parsed result: what a pairing
* fragment means is the page's to decide, and it already knows how — the
* `#pair=` handling ships with the interface.
*/
@JavascriptInterface
fun scanPairingCode(): String = if (!trusted()) "" else scanWithCamera()
/**
* Load a URL in this window instead of handing it to the browser.
*
* The page asks for this once, right after a scan: the code it read points at
* the computer's own Studio, and the whole point of scanning is to end up
* looking at that computer. Going out to a browser would leave the app on one
* screen and the paired machine on another, with the back gesture an exit
* rather than a return.
*
* Only http and https are accepted. The page is this repository's own build
* and is trusted, but a scanner reads whatever is held in front of it, so the
* one value that arrives here from outside is checked: a `file://` or
* `javascript:` that happened to be on a printed code must not become a
* navigation.
*/
@JavascriptInterface
fun openInApp(url: String) {
if (!trusted()) return
val trimmed = url.trim()
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) return
main.post { onOpenInApp?.invoke(trimmed) }
}
/** How the activity loads a URL in the window it owns. Set by the activity. */
var onOpenInApp: ((String) -> Unit)? = null
// ---- the native browser view host bridge ----
//
// The page's `host.ts` looks for `window.reasonixHost` with Promise-based
// methods (showBrowserView, hideBrowserView, etc.). `@JavascriptInterface`
// is synchronous, so the round trip is: JS generates an id, calls
// `hostCall(id, method, args)`, Kotlin does the work asynchronously, then
// pushes the result back via `evaluateJavascript` calling
// `window.__reasonixHostReply(id, json)`. The injected script (in
// MainActivity) wires each Promise to that reply channel.
/** How the activity handles an async host call. Set by the activity. */
var onHostCall: ((id: String, method: String, args: String) -> Unit)? = null
/**
* Entry point for the page's `window.reasonixHost` Promise methods.
*
* `id` is a unique call identifier the JS side generated; `method` is the
* method name (e.g. "showBrowserView"); `args` is the JSON-encoded argument
* array. The activity handles the call asynchronously and replies through
* `evaluateJavascript` with `window.__reasonixHostReply(id, resultJson)`.
*/
@JavascriptInterface
fun hostCall(id: String, method: String, args: String) {
if (!trusted()) return
onHostCall?.invoke(id, method, args)
}
/**
* Whether the page has a panel up that the back gesture should close first.
*
* Written by the page, read by the activity's back handler. The page owns the
* answer — it is the only thing that knows what is open, and a second
* implementation of that question in the shell would be one that drifts — so
* the page reports the fact and the shell keeps the action.
*
* Volatile because the two ends are on different threads: the page sets it
* from the WebView's JavaScript thread, the back gesture reads it on the main
* one, and a stale read here is a panel that does not close.
*/
@Volatile
var layerOpen: Boolean = false
private set
@JavascriptInterface
fun setLayerOpen(open: Boolean) {
layerOpen = open
}
// ---- the picker, which is an Activity result in a synchronous world ----
private val pending = AtomicReference<Uri?>(null)
private val latch = AtomicReference<CountDownLatch?>(null)
/** The code the camera read, held between the activity's answer and the
* wait below — the same round trip the file picker takes. */
private val scanned = AtomicReference<String?>(null)
private val scanLatch = AtomicReference<CountDownLatch?>(null)
/** The main thread, because starting an Activity result must happen there. */
private val main = Handler(Looper.getMainLooper())
/**
* How to ask for a file. Set by the activity, which owns the Activity Result
* API; it answers whether the ask was dispatched, not whether the person
* picked anything — that arrives through `deliver`.
*/
var askForFile: () -> Boolean = { false }
/**
* How the activity reports the window's insets. Set by the activity, which is
* the only thing that can see them, and asked on every call rather than
* cached — a rotation, a fold, or the keyboard opening all change them, and a
* stale answer here is a stripe of interface under the status bar.
*/
var onInsets: (() -> Insets?)? = null
/**
* How to open the camera. Set by the activity, which owns the Activity
* Result API; like `askForFile` it answers whether the ask was dispatched,
* and the code itself arrives through `deliverScan`.
*/
var askForScan: () -> Boolean = { false }
/**
* How to put a file where the person picks. Set by the activity, which owns
* the Activity Result API; it answers whether the ask was dispatched, and
* the chosen place arrives through `deliverSave`.
*/
var askForSave: (name: String, content: String) -> Boolean = { _, _ -> false }
/**
* How to open the share sheet. Set by the activity, which is the only thing
* that can start an Intent; it answers whether anything on the device took
* the text.
*/
var askForShare: (name: String, content: String) -> Boolean = { _, _ -> false }
/** The place the save dialog answered with, held between the two halves. */
private val saveTarget = AtomicReference<Uri?>(null)
private val saveLatch = AtomicReference<CountDownLatch?>(null)
/**
* Ask where to put a file and write it there.
*
* The same latch round trip as `pickFile`, and for the same reason:
* `@JavascriptInterface` cannot suspend, so the WebView's thread waits while
* the picker is open — a modal panel the person is looking at.
*/
private fun saveFile(name: String, content: String): String? {
val gate = CountDownLatch(1)
saveLatch.set(gate)
saveTarget.set(null)
val posted = CountDownLatch(1)
var dispatched = false
main.post {
dispatched = runCatching { askForSave(name, content) }.getOrDefault(false)
posted.countDown()
}
val ready = runCatching { posted.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
if (!ready || !dispatched) {
saveLatch.set(null)
return null
}
return try {
if (!gate.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) null
else saveTarget.get()?.toString()
} catch (_: InterruptedException) {
null
} finally {
saveLatch.set(null)
}
}
/** Called by the activity once the bytes are written. A null uri is a dismissal. */
fun deliverSave(uri: Uri?) {
saveTarget.set(uri)
saveLatch.get()?.countDown()
}
/**
* Read the pairing code a computer is showing, and return it.
*
* The same shape as `pickFile` and for the same reason: a
* `@JavascriptInterface` method is called on the WebView's thread and must
* return a value, so the camera is opened on the main thread and this one
* waits. The block lasts as long as the person is aiming, which is why the
* timeout is generous — a scan that gave up after a few seconds would be
* one nobody could complete.
*
* An empty string is "no code", which is what a refusal, a cancellation and
* a timeout all are. The caller acts on the text, so a distinct error would
* only be a fourth way to say the same thing.
*/
private fun scanWithCamera(): String {
val gate = CountDownLatch(1)
scanLatch.set(gate)
scanned.set(null)
val posted = CountDownLatch(1)
var dispatched = false
main.post {
dispatched = runCatching { askForScan() }.getOrDefault(false)
posted.countDown()
}
val ready = runCatching { posted.await(SCAN_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
if (!ready || !dispatched) {
scanLatch.set(null)
return ""
}
return try {
if (!gate.await(SCAN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) "" else scanned.get().orEmpty()
} catch (_: InterruptedException) {
""
} finally {
scanLatch.set(null)
}
}
/** Called by the activity when the scanner answers. Null is a dismissal. */
fun deliverScan(text: String?) {
scanned.set(text)
scanLatch.get()?.countDown()
}
/**
* Ask for one file and wait for the answer.
*
* `@JavascriptInterface` methods cannot suspend: the WebView calls them on
* its own thread and expects a value back. So the ask is posted to the main
* thread and this one waits on a latch. The cost is a blocked JS thread for
* as long as the picker is open — which is a modal panel the person is
* looking at, with nothing else in the page running.
*
* A timeout is set rather than waiting forever: a picker that never answers
* (the activity destroyed underneath it) would otherwise pin a WebView
* thread until the process died.
*/
private fun pickFile(): Uri? {
val gate = CountDownLatch(1)
latch.set(gate)
pending.set(null)
val posted = CountDownLatch(1)
var dispatched = false
main.post {
dispatched = runCatching { askForFile() }.getOrDefault(false)
posted.countDown()
}
val ready = runCatching { posted.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
if (!ready || !dispatched) {
latch.set(null)
return null
}
return try {
if (!gate.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) null else pending.get()
} catch (_: InterruptedException) {
null
} finally {
latch.set(null)
}
}
/** Called by the activity when the picker answers. A null uri is a dismissal. */
fun deliver(uri: Uri?) {
pending.set(uri)
latch.get()?.countDown()
}
/** The folder under Downloads that this app's exports land in. */
private fun exportFolder(): String = "Reasonix"
/** The MIME type this name declares, or null when the platform does not know
* it.
*
* Where the type is written, the name and the type agree, and that agreement
* is the whole fix: `.md` is answered `text/markdown` here, so MediaStore
* has nothing to correct and the export stays `报告.md` instead of being
* renamed `报告.md.txt` by the `text/plain` it used to be given. Where the
* type is unknown the field is left off, which leaves MediaStore to derive
* the same `application/octet-stream` it would have been told — the name
* still stands on its own, which is all this is for. */
private fun mimeFor(name: String): String? {
val ext = name.substringAfterLast('.', "").lowercase()
if (ext.isEmpty() || ext == name.lowercase()) return null
return runCatching { MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) }.getOrNull()
}
private fun exportFile(name: String, content: String): String? = runCatching {
val bytes = content.toByteArray(Charsets.UTF_8)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, name)
// The type is derived here rather than declared, and it is
// written only when the name really has an extension that maps
// to one.
//
// It used to be `text/plain` for everything, and MediaStore
// answers a name whose extension disagrees with the declared
// type by appending its own: exporting `报告.md` landed in
// Downloads as `报告.md.txt`, and any source file came out with
// the wrong second extension. Deriving the type from the name
// instead keeps the two in agreement — which is what stops the
// rename — and a name the platform has no type for is left off
// entirely rather than declared as `application/octet-stream`,
// so nothing is corrected there either.
mimeFor(name)?.let { put(MediaStore.Downloads.MIME_TYPE, it) }
put(
MediaStore.Downloads.RELATIVE_PATH,
Environment.DIRECTORY_DOWNLOADS + "/" + exportFolder(),
)
// Hidden until the bytes are all there, so a file manager that
// reads the folder mid-write cannot show half a document.
put(MediaStore.Downloads.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
?: return@runCatching null
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: return@runCatching null
values.clear()
values.put(MediaStore.Downloads.IS_PENDING, 0)
resolver.update(uri, values, null, null)
"${Environment.DIRECTORY_DOWNLOADS}/${exportFolder()}/$name"
} else {
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
exportFolder(),
)
if (!dir.exists() && !dir.mkdirs()) return@runCatching null
val target = File(dir, name)
target.writeBytes(bytes)
target.absolutePath
}
}.getOrNull()
private fun sanitise(name: String): String {
// A name from the page is used as a filename, so it is reduced to one:
// no separators, no traversal, and nothing a filesystem treats as
// special. The default is the name the field it came from suggests.
val trimmed = name.trim().substringAfterLast('/').substringAfterLast('\\')
if (trimmed.isEmpty() || trimmed == "." || trimmed == "..") return ""
return trimmed.filter { it.isLetterOrDigit() || it in "._-" }.take(64)
}
/**
* Whether a turn is running, so the screen can be kept awake for one.
*
* The page owns the answer — it is the only thing that knows if the kernel
* is working — and the shell owns the flag, because a window flag is not
* something a page can set. Nothing is asked at launch: a window that is
* merely open is one the person may be reading, and holding the screen on
* for that is holding it on for nothing.
*/
@JavascriptInterface
fun setRunning(running: Boolean) {
onRunning?.invoke(running)
}
/** How the activity applies that flag. Set by the activity. */
var onRunning: ((Boolean) -> Unit)? = null
/**
* Whether the app may read every file, which is what lets the kernel open a
* folder on this phone as a workspace.
*
* Asked rather than assumed: the permission is granted by hand in system
* settings, so the page has to be able to say "not yet" rather than fail on
* the first `stat`. Answered `true` below API 30, where ordinary read
* permission is enough and the concept does not exist.
*/
@JavascriptInterface
fun hasAllFilesAccess(): Boolean = filesAccess?.invoke() ?: true
var filesAccess: (() -> Boolean)? = null
/** Open the system page where that permission is granted. The only place
* Android offers it — there is no dialog for this one. */
@JavascriptInterface
fun requestAllFilesAccess() {
if (!trusted()) return
askForFilesAccess?.invoke()
}
var askForFilesAccess: (() -> Unit)? = null
/**
* Whether this app is exempt from the system's battery optimisation.
*
* A turn runs in a child process under a foreground service. Under doze the
* whole app group is frozen anyway, so the turn stops making progress while
* the screen is off — which is the state the person left it in on purpose.
* Unrestricted is the answer, and it is a question Android answers.
*/
@JavascriptInterface
fun batteryUnrestricted(): Boolean = if (!trusted()) false else (batteryFree?.invoke() ?: true)
var batteryFree: (() -> Boolean)? = null
/** Ask for that exemption. Unlike the storage one, this has a real dialog. */
@JavascriptInterface
fun requestBatteryUnrestricted() {
if (!trusted()) return
askForBattery?.invoke()
}
var askForBattery: (() -> Unit)? = null
/**
* Ask for a folder on this phone and answer with its real filesystem path.
*
* "" is a dismissal, the same answer `importIntoHome` gives — and, unlike
* that one, an empty string is a normal outcome here rather than a failure.
* The `startIn` hint is a path the kernel knows; the picker may ignore it,
* which is the picker's business.
*
* The round trip is the same latch as `pickFile`, for the same reason: a
* document-tree picker is another activity, and this call cannot suspend.
*/
@JavascriptInterface
fun pickFolder(startIn: String): String {
if (!trusted()) return ""
val gate = CountDownLatch(1)
folderLatch.set(gate)
folderPending.set(null)
val posted = CountDownLatch(1)
var dispatched = false
main.post {
dispatched = runCatching { askForFolder(startIn) }.getOrDefault(false)
posted.countDown()
}
val ready = runCatching { posted.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
if (!ready || !dispatched) {
folderLatch.set(null)
return ""
}
return try {
if (!gate.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) "" else folderPending.get().orEmpty()
} catch (_: InterruptedException) {
""
} finally {
folderLatch.set(null)
}
}
var askForFolder: (String) -> Boolean = { false }
private val folderPending = AtomicReference<String?>(null)
private val folderLatch = AtomicReference<CountDownLatch?>(null)
/** Called by the activity with the folder's real path, or null on dismissal. */
fun deliverFolder(path: String?) {
folderPending.set(path ?: "")
folderLatch.get()?.countDown()
}
companion object {
const val BRIDGE_VERSION = "4"
private const val PICK_TIMEOUT_SECONDS = 5L * 60
/** Long, because this is however long someone takes to aim. A shorter
* one would turn "still lining it up" into a failed scan. */
private const val SCAN_TIMEOUT_SECONDS = 5L * 60
/** Only as long as the sheet takes to appear: the answer is "something
* took it", which is known the moment the Intent resolves. */
private const val SHARE_TIMEOUT_SECONDS = 15L
}
}
/**
* The window's insets, in CSS pixels.
*
* A named type rather than four numbers because the page reads them as one
* thing: half an inset is a layout that is wrong in one direction, and a caller
* that passed the wrong pair would be indistinguishable from a device with no
* cutout at all.
*/
data class Insets(val left: Int, val top: Int, val right: Int, val bottom: Int)
@@ -0,0 +1,66 @@
package io.reasonix.mobile.app
import androidx.camera.core.ImageProxy
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
/**
* Reading a QR code out of one camera frame.
*
* ZXing core and nothing else: it is pure Java, so it runs identically on a
* phone with Google Play Services and on one whose HarmonyOS compatibility
* layer has none. A decoder that leaned on ML Kit would work on the machine it
* was written on and fail on the one that needs it.
*
* The frame arrives as YUV_420_888, whose Y plane is already the greyscale
* bitmap ZXing wants — no conversion, no colour maths, just the bytes. That is
* the whole reason a phone can decode 30 frames a second without warming up.
*/
object Qr {
private val reader = MultiFormatReader().apply {
setHints(
mapOf(
// Only the form the computer actually renders. Leaving the
// reader open to every 1D barcode makes each frame slower for
// a case that cannot occur, and a slower frame is a code that
// has already left the viewfinder.
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
DecodeHintType.TRY_HARDER to true,
),
)
}
/** The code's text, or null when this frame holds none. */
fun decode(image: ImageProxy): String? {
val plane = image.planes.firstOrNull() ?: return null
val buffer = plane.buffer
val data = ByteArray(buffer.remaining())
buffer.get(data)
// `rowStride` is padded to a hardware alignment and is usually wider
// than the picture. Passing the width as the row length is what tells
// ZXing where each row actually ends; getting it wrong shears the image
// and every code reads as garbage.
val source = PlanarYUVLuminanceSource(
data,
plane.rowStride,
image.height,
0,
0,
image.width,
image.height,
false,
)
return runCatching {
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
}.getOrNull().also {
// The reader keeps per-frame state between calls; resetting is what
// stops a partial match in one frame from corrupting the next.
reader.reset()
}
}
}
@@ -0,0 +1,146 @@
package io.reasonix.mobile.app
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.Gravity
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import java.util.concurrent.Executors
/**
* The camera, held open just long enough to read one pairing code.
*
* A phone that wants to drive a computer cannot type the code that computer
* shows: it is a base64 fragment inside a URL, and the whole point of showing it
* as a QR code is that nobody reads one aloud. So this is the way in — the same
* gesture the computer's own dialog asks for, from the other side.
*
* The decode is ZXing rather than ML Kit on purpose. ML Kit needs Google Play
* Services, and this app installs on phones whose HarmonyOS compatibility layer
* has none: a GMS-backed scanner would work on a developer's phone and fail on
* the device that needs it. ZXing core is pure Java, so the scanner behaves the
* same on both.
*
* It returns the first code it reads and finishes. A pairing code is
* single-use and short-lived, so "keep scanning after a hit" is not a feature —
* it is a way to spend a code that a later frame would have discarded.
*/
class ScanActivity : AppCompatActivity() {
private lateinit var preview: PreviewView
private lateinit var hint: TextView
private val executor = Executors.newSingleThreadExecutor()
/** Set once a code is found, so the frames in flight behind it do not each
* try to finish the activity. */
@Volatile
private var settled = false
private val requestCamera = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) start() else refuse()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val root = FrameLayout(this)
preview = PreviewView(this).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
// The frames are analysed, not composed: filling the view and
// cropping is right here and would be wrong for a camera app.
scaleType = PreviewView.ScaleType.FILL_CENTER
}
hint = TextView(this).apply {
text = getString(R.string.scan_hint)
setTextColor(0xFFFFFFFF.toInt())
textSize = 14f
gravity = Gravity.CENTER
val pad = (16 * resources.displayMetrics.density).toInt()
setPadding(pad, pad, pad, pad)
// Readable over whatever the camera is pointing at, which is the
// one background a design system cannot choose.
setBackgroundColor(0x99000000.toInt())
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM,
)
}
root.addView(preview)
root.addView(hint)
setContentView(root)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
) {
start()
} else {
requestCamera.launch(Manifest.permission.CAMERA)
}
}
private fun refuse() {
// No dialog of our own: the permission prompt already said no, and a
// second window repeating it is one more tap to dismiss. The caller
// gets the same answer as a cancelled scan.
setResult(RESULT_CANCELED)
finish()
}
private fun start() {
val providerFuture = ProcessCameraProvider.getInstance(this)
providerFuture.addListener({
val provider = runCatching { providerFuture.get() }.getOrNull() ?: return@addListener refuse()
val previewUseCase = Preview.Builder().build().also { it.setSurfaceProvider(preview.surfaceProvider) }
val analysis = ImageAnalysis.Builder()
// Only the newest frame matters: a stale one describes where the
// camera was, and a code that has already left the viewfinder
// must not win over one that is in it now.
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(executor, ::analyse) }
runCatching {
provider.unbindAll()
provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, previewUseCase, analysis)
}.onFailure { refuse() }
}, ContextCompat.getMainExecutor(this))
}
private fun analyse(image: ImageProxy) {
try {
if (settled) return
val text = Qr.decode(image) ?: return
if (settled) return
settled = true
setResult(RESULT_OK, Intent().putExtra(EXTRA_TEXT, text))
// Finishing from the analysis thread is the whole reason `settled`
// exists; the activity teardown has to happen on the main one.
runOnUiThread { finish() }
} finally {
image.close()
}
}
override fun onDestroy() {
super.onDestroy()
executor.shutdown()
}
companion object {
const val EXTRA_TEXT = "text"
}
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The mark, drawn rather than shipped as a bitmap: one R in the app's own ink
on the palette's ground, which scales to every launcher's mask without a
density bucket per size. The hole in the R is the glyph's own counter, cut
with evenOdd rather than painted over. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#F4F1EA"
android:fillType="evenOdd"
android:pathData="M39,30 L39,78 L47,78 L47,60 L54.5,60 L65,78 L74.5,78 L62.5,58 C68.6,55.8 72.6,50.6 72.6,43.6 C72.6,35.4 66.8,30 57.6,30 Z M47,37 L56.4,37 C61.2,37 64.4,39.4 64.4,43.8 C64.4,48.2 61.2,50.9 56.4,50.9 L47,50.9 Z" />
<path
android:fillColor="#A6680C"
android:pathData="M62,66 L74,66 L74,78 L62,78 Z" />
</vector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The status-bar glyph, not the launcher icon: Android renders a notification
icon as a mask, so anything that is not a solid silhouette on transparency
comes out as a grey blob. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M3,4h18v2H3zM3,9h18v2H3zM3,14h12v2H3zM3,19h12v2H3zM17.5,13.2l1.6,2.6 1.6,-2.6h2.3l-2.8,4.3V21h-2.2v-3.5L15.2,13.2z" />
</vector>
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The screen between the tap and the page.
It exists for a reason that is not cosmetic: the kernel has to be unpacked and
started before there is a page to show, and on a cold start that is about a
second. A blank window for a second reads as a broken app; a line of text that
says what is happening does not.
The progress bar is indeterminate and driven by nothing: the WebView's own
progress is mirrored onto it while the page loads, and before that it simply
turns, which is the honest description of "we do not know how long".
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/page"
android:orientation="vertical"
android:gravity="center"
android:paddingStart="32dp"
android:paddingEnd="32dp">
<TextView
android:id="@+id/splash_wordmark"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textColor="#8A857B"
android:textSize="22sp"
android:letterSpacing="0.08"
android:fontFamily="sans-serif-medium" />
<ProgressBar
android:id="@+id/splash_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="132dp"
android:layout_height="wrap_content"
android:layout_marginTop="26dp"
android:indeterminate="true" />
<TextView
android:id="@+id/splash_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:gravity="center"
android:text="@string/splash_connecting"
android:textColor="#8A857B"
android:textSize="13sp"
android:lineSpacingMultiplier="1.5" />
<Button
android:id="@+id/splash_retry"
style="?android:attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:minHeight="44dp"
android:text="@string/splash_retry"
android:textColor="#A6680C"
android:visibility="gone" />
</LinearLayout>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The adaptive launcher icon.
minSdk is 26, which is the release that introduced adaptive icons, so this
is the only launcher asset the app needs: there is no device running it
that would ask for a bitmap fallback. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<!-- The safe zone is the middle 66dp of 108dp; the glyph is drawn inside it
so no launcher's mask can clip it. -->
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Reasonix</string>
<string name="channel_kernel">Kernel running</string>
<string name="channel_kernel_desc">Shown while the Reasonix kernel runs in the background. Can be muted on its own in system settings.</string>
<string name="notify_running_title">Reasonix is running</string>
<string name="notify_running_body">A task is still going. Switch back to see it.</string>
<string name="notify_failed_title">Reasonix could not start</string>
<string name="err_kernel_unreachable">Could not reach the kernel. Reopen the app from your home screen.</string>
<string name="download_default_name">reasonix-download</string>
<string name="download_saved">Saved: %1$s</string>
<string name="download_failed">Save failed: %1$s</string>
<string name="download_failed_status">Save failed: the server answered %1$d</string>
<string name="splash_connecting">Starting the kernel…</string>
<string name="splash_retry">Retry</string>
<string name="scan_title">Scan the code on the computer</string>
<string name="scan_hint">Point at the QR code in the computer\'s device panel to connect to it.</string>
<string name="scan_button">Scan to connect</string>
<string name="scan_denied">No camera permission, so scanning is unavailable. Allow it in system settings.</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The dark palette's page colour. `DayNight` on the theme picks this up when
the system is dark, which is what keeps the frame before the WebView from
flashing light at someone using a dark phone at night. -->
<resources>
<color name="page">#0E1014</color>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The light palette's page colour, and only what the window needs before the
WebView paints. The interface's own colours are `tokens.css`'s and are the
page's business; these two exist so the first frame is the app's ground
rather than the platform default white. -->
<resources>
<color name="page">#F4F1EA</color>
<color name="boot">#07080C</color>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The launcher ground: the interface's dark page colour. It is the same in
both themes because a launcher icon is not themed by the app. -->
<resources>
<color name="ic_launcher_background">#14161C</color>
</resources>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Every string the shell itself shows. The interface's own text belongs to
the page and lives in its i18n bundle; these are only the few lines that
exist before it, or when it cannot load. -->
<resources>
<string name="app_name">Reasonix</string>
<string name="channel_kernel">内核运行中</string>
<string name="channel_kernel_desc">Reasonix 内核在后台运行时显示,可在系统设置中单独关闭。</string>
<string name="notify_running_title">Reasonix 正在运行</string>
<string name="notify_running_body">任务继续进行,切回应用即可查看。</string>
<string name="notify_failed_title">Reasonix 无法启动</string>
<string name="err_kernel_unreachable">连接内核失败。请返回桌面重新打开应用。</string>
<string name="download_default_name">reasonix-download</string>
<string name="download_saved">已保存:%1$s</string>
<string name="download_failed">保存失败:%1$s</string>
<string name="download_failed_status">保存失败:服务器返回 %1$d</string>
<string name="splash_connecting">正在启动内核…</string>
<string name="splash_retry">重试</string>
<string name="wsperm_title">需要「所有文件访问」权限</string>
<string name="wsperm_body">要打开手机上的文件夹作为工作区,需要先允许本应用访问所有文件。点「去设置」,在列表里打开 Reasonix 的开关,然后返回这里。</string>
<string name="wsperm_go">去设置</string>
<string name="wsperm_later">以后再说</string>
<string name="scan_title">扫描电脑上的二维码</string>
<string name="scan_hint">对准电脑上「设备访问」里的二维码,识别后即可连接这台电脑。</string>
<string name="scan_button">扫码连接电脑</string>
<string name="scan_denied">没有相机权限,无法扫码。请在系统设置里允许。</string>
<string name="share_title">分享文件</string>
</resources>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The shell's own furniture: a window background matching the page's light
palette before the WebView paints, and a transparent system bar so the
page's own safe-area padding is what the notch meets. The page themes
itself; this exists only so the first frame is not a white flash. -->
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Reasonix" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@color/page</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- The page draws its own edge-to-edge layout and pads itself with the
safe-area insets from mobile.css. Laying the window out edge to edge
while leaving the bars transparent is what makes those insets real
numbers instead of zero. -->
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="p">shortEdges</item>
<item name="android:enforceStatusBarContrast" tools:targetApi="q">false</item>
<item name="android:enforceNavigationBarContrast" tools:targetApi="q">false</item>
</style>
<!-- The camera. A black ground because the preview has not drawn yet and a
light window behind a dark viewfinder is a flash in the dark; the bars
stay transparent so the preview reaches the edges, which is what makes
a code near the top of the screen scannable. -->
<style name="Theme.Reasonix.Scan" parent="Theme.Reasonix">
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Backups are off, and this file says why rather than leaving it implicit.
A Reasonix home holds provider keys, an account token and every session
transcript. Android's auto-backup would copy all of it to the person's cloud
drive and, on a device-to-device transfer, to a new phone — which is a
credential leaving the device by a route nobody chose. `allowBackup="false"`
in the manifest turns that off; these two stanzas are the belt to its braces,
excluding everything explicitly so that a later change to `allowBackup` cannot
quietly start copying credentials.
What a person wants to move between phones, the kernel already moves: its own
export writes a file the person hands to the system's picker.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Cleartext HTTP is permitted, and the platform has no way to permit less.
Two kinds of page load over plain HTTP: the kernel's own page on
`127.0.0.1:<port>`, and the same page served by a paired computer on whatever
address its router handed out. The port is chosen when the kernel starts and
the LAN address when a code is scanned, so neither can be written here — and a
`domain-config` matches literals and domains, never a subnet.
So the boundary is not this file. `MainActivity` decides which cleartext origin
may become the window: `shouldOverrideUrlLoading` sends every http(s) origin
that is not the kernel's or the one just paired out to the browser, and the
native bridge answers only those two. A plaintext page on the LAN therefore
cannot become the document holding the bridge, which is what the reviewer's
"deliberate path for LAN pairing" asks for.
HTTPS is untouched: nothing here downgrades a request, and `trust-anchors`
keeps the system roots for every TLS connection that is made.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
+15
View File
@@ -0,0 +1,15 @@
// The build itself lives in app/build.gradle; this file declares the plugin
// versions once so the app module can be read on its own.
//
// AGP 8.11.1 is the pairing for the Gradle 8.14 toolchain this machine has:
// AGP 8.7 and older refuse a Gradle that new, and AGP 8.13+ wants a Gradle this
// one does not have. Both numbers are pinned rather than ranged, because a
// build that silently moved either one would be a different build.
plugins {
id "com.android.application" version "8.11.1" apply false
id "org.jetbrains.kotlin.android" version "2.1.20" apply false
}
tasks.register("clean", Delete) {
delete rootProject.layout.buildDirectory
}
+15
View File
@@ -0,0 +1,15 @@
# AndroidX is the only copy of the support libraries this project uses; the
# non-transitive R class keeps the app's own resources from aliasing library
# ones, which is what makes `R.string.*` unambiguous here.
android.useAndroidX=true
android.nonTransitiveRClass=true
# The daemon is what keeps a second `gradle assembleDebug` from re-reading the
# whole SDK, and the heap is sized for the R8 pass over the WebView and the
# packaged page rather than for this handful of Kotlin files.
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.caching=true
kotlin.code.style=official
File diff suppressed because it is too large. Load diff
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
# Pinning the distribution's checksum is what makes the download verifiable: a
# mirror that served a different zip would fail here instead of being unpacked
# and run. The value is Gradle's own, from
# https://services.gradle.org/distributions/gradle-8.14.3-bin.zip.sha256
distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# Build the Reasonix kernel for Android and place it where the APK packager
# expects a native library.
#
# Why a shell script and not a Gradle task: the kernel is a Go module that lives
# outside this Gradle build, with its own toolchain version pinned in go.mod. A
# Gradle task that invoked `go build` would make the Android build depend on a Go
# toolchain being configured for it, and would rebuild a 21MB binary on every
# `assembleDebug` even when nothing in the kernel changed. This runs when you
# mean it to, and the APK task only checks that the result is there.
#
# Output: app/src/main/jniLibs/arm64-v8a/libreasonix.so
# The name is not cosmetic. Android extracts files under `jniLibs/` to
# `nativeLibraryDir` and permits execution *there*; a binary shipped in
# `assets/` would have to be copied to app storage and chmod'd, which fails on
# a device whose data partition is mounted noexec. `lib*.so` is the shape the
# packager recognises as a native library, so the kernel wears it.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
upstream="$android/.."
out="$android/app/src/main/jniLibs/arm64-v8a/libreasonix.so"
if [ ! -d "$upstream" ]; then
echo "kernel source not found: $upstream" >&2
echo "run this from a checkout: the parent of android/ must be the kernel source." >&2
exit 1
fi
# GOOS=android needs nothing else: the kernel is CGO-free, which is what makes a
# single static binary possible in the first place. `-s -w` drops the symbol and
# DWARF tables — about a third of the file — and nothing on a phone reads them.
export GOOS=android
export GOARCH=arm64
export CGO_ENABLED=0
# The app version, read from the module's own build.gradle so the two cannot
# drift. The official build stamps these through `-X` (see upstream's Makefile);
# without them the kernel reports `version=dev revision=unknown`, which a phone
# then shows next to the documentation it was built with and makes a bug report
# from that build impossible to place. There is no `.git` here, so the revision
# names the package the corpus shipped in rather than a commit hash — the honest
# answer when there is no commit to name.
app_gradle="$android/app/build.gradle"
version_field() {
sed -n "s/^def $1 *= *\([0-9]*\).*/\1/p" "$app_gradle" | head -n1
}
app_version="${REASONIX_APP_VERSION:-$(version_field versionMajor).$(version_field versionMinor).$(version_field versionPatch)}"
build_time_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
ldflags="-s -w"
ldflags="$ldflags -X main.version=v$app_version"
ldflags="$ldflags -X main.buildTimeUTC=$build_time_utc"
ldflags="$ldflags -X reasonix/internal/tools/productdocs.linkedVersion=v$app_version"
ldflags="$ldflags -X reasonix/internal/tools/productdocs.linkedRevision=apk-v$app_version"
echo "building the kernel (android/arm64, v$app_version) ..."
mkdir -p "$(dirname "$out")"
cd "$upstream"
go build -trimpath -ldflags="$ldflags" -o "$out" ./cmd/reasonix
size=$(wc -c < "$out")
echo "kernel ready: $out ($((size / 1024 / 1024)) MiB)"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
#
# One command from a clean checkout to an installable APK.
#
# The order matters and is the whole content of this script: the kernel and the
# page are inputs to packaging, not build steps inside it, so they are produced
# first and the Gradle task only assembles what is already there. Running
# `gradle assembleRelease` directly on a fresh checkout fails on purpose (see
# `requireBuildInputs` in app/build.gradle) rather than producing an APK with no
# kernel in it.
#
# Usage: ./scripts/build.sh [debug|release] (default: release)
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
variant="${1:-release}"
case "$variant" in
debug|release) ;;
*) echo "usage: $0 [debug|release]" >&2; exit 2 ;;
esac
"$here/build-kernel.sh"
"$here/sync-page.sh"
cd "$android"
# The wrapper is committed, jar and all, and it is the only thing that runs the
# build. Generating one here would tie the dependency versions to whichever
# `gradle` happened to be on PATH, which is the opposite of a pinned build.
if [ ! -f gradlew ]; then
echo "gradlew is missing: this repository commits the Gradle wrapper, so the checkout is incomplete" >&2
echo "(a release build does not use `gradle` from PATH: that would tie dependency versions to the machine)" >&2
exit 1
fi
task="assemble$([ "$variant" = release ] && echo Release || echo Debug)"
echo "packaging the APK ($variant) ..."
./gradlew "$task" --no-daemon
apk_dir="$android/app/build/outputs/apk/$variant"
echo
echo "done. Installable artifacts:"
find "$apk_dir" -name '*.apk' -maxdepth 1 -print -exec ls -lh {} \;
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
#
# Install the built APK on a connected phone and show its log.
#
# `adb` comes from the Android SDK rather than this project, because the SDK is
# already how the app is built. Point ANDROID_HOME at it, or have `adb` on PATH.
#
# Usage: ./scripts/run.sh [debug|release]
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
variant="${1:-release}"
adb="${ADB:-${ANDROID_HOME:-$HOME/AndroidSDK}/platform-tools/adb}"
if ! command -v "$adb" >/dev/null 2>&1; then
adb="adb"
fi
apk_dir="$android/app/build/outputs/apk/$variant"
apk=$(find "$apk_dir" -name '*.apk' -maxdepth 1 2>/dev/null | head -n1)
if [ -z "$apk" ]; then
echo "no $variant APK found; run ./scripts/build.sh $variant first" >&2
exit 1
fi
echo "installing $apk ..."
"$adb" install -r "$apk"
# The device count is checked before the app is started so an emulator-less
# machine gets "no devices" rather than a launch failure that reads like a bug.
if [ "$("$adb" devices | grep -c 'device$')" -eq 0 ]; then
echo "no device attached; skipping the launch." >&2
exit 0
fi
package="io.reasonix.mobile.app"
[ "$variant" = debug ] && package="io.reasonix.mobile.app.debug"
"$adb" shell monkey -p "$package" -c android.intent.category.LAUNCHER 1 >/dev/null
echo
echo "following the log (Ctrl-C to stop) ..."
"$adb" logcat -v color "ReasonixKernel:V" "AndroidRuntime:E" "*:S"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# Build the interface and copy it where the APK will carry it.
#
# The page that ships is the same build the desktop Studio serves — that is the
# whole reason the mobile layout lives in `desktop/frontend-next` rather than in
# an Android project's own web root. Nothing is edited on the way through: the
# only thing this script does that `pnpm build` does not is copy `dist/` into the
# app's assets.
#
# Output: app/src/main/assets/page/
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
frontend="$android/../desktop/frontend-next"
dest="$android/app/src/main/assets/page"
if [ ! -d "$frontend" ]; then
echo "frontend project not found: $frontend" >&2
exit 1
fi
cd "$frontend"
# pnpm is the package manager the lockfile is written for; npm would resolve a
# different tree than the one that was tested.
if ! command -v pnpm >/dev/null 2>&1; then
echo "pnpm 10 is required: npm install -g pnpm@10" >&2
exit 1
fi
if [ ! -d node_modules ]; then
echo "installing frontend dependencies ..."
pnpm install --frozen-lockfile
fi
echo "building the frontend ..."
pnpm build
rm -rf "$dest"
mkdir -p "$dest"
cp -R dist/. "$dest/"
if [ ! -f "$dest/index.html" ]; then
echo "the build produced no index.html; the frontend build probably failed." >&2
exit 1
fi
count=$(find "$dest" -type f | wc -l)
size=$(du -sk "$dest" | cut -f1)
echo "page ready: $dest ($count files, $((size / 1024)) MiB)"
# MIT requires the copyright notice and the licence text to travel with a binary
# distribution, so the APK carries it. Generated from the repository's own
# `LICENSE` rather than committed a second time: a checked-in copy is one more
# thing that drifts when the root file is updated.
root_license="$android/../LICENSE"
if [ ! -f "$root_license" ]; then
echo "LICENSE not found: $root_license" >&2
exit 1
fi
cp "$root_license" "$android/app/src/main/assets/LICENSE"
echo "licence: app/src/main/assets/LICENSE (copied from the repository's LICENSE)"
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
#
# Reproduce what `Kernel.kt` does, on this machine, against the page that is
# actually packaged into the APK.
#
# This is the closest thing to an end-to-end test that runs without a phone: the
# launch flags, the token file, the port file and the `--page` directory are the
# same ones the Kotlin shell passes, and the page is read out of
# `app/src/main/assets/page` rather than the frontend's own `dist/`. If this
# serves a working interface, the only untested part is Android itself.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
page="$android/app/src/main/assets/page"
kernel="${KERNEL_BIN:-$android/../reasonix-verify.exe}"
work="${VERIFY_DIR:-/tmp/reasonix-verify}"
rm -rf "$work"
mkdir -p "$work/home/remote"
if [ ! -f "$kernel" ]; then
echo "no runnable kernel; build one first:" >&2
echo " (cd .. && go build -o reasonix-verify.exe ./cmd/reasonix)" >&2
exit 1
fi
if [ ! -f "$page/index.html" ]; then
echo "no packaged page: $page/index.html" >&2
exit 1
fi
# 32 random bytes as hex — the same shape `Kernel.mintToken` writes.
token=$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')
# The kernel refuses a token file other users can read.
(umask 077; printf '%s\n' "$token" > "$work/home/remote/launch.token")
export REASONIX_HOME="$work/home"
export HOME="$work/home"
export TMPDIR="$work"
echo "kernel: $kernel"
echo "page: $page"
echo "token: ${token:0:8}... (${#token} chars)"
"$kernel" serve \
--addr 127.0.0.1:0 \
--auth token \
--token-file "$work/home/remote/launch.token" \
--port-file "$work/home/remote/serve.port" \
--pid-file "$work/home/remote/serve.pid" \
--no-open \
-page "$page" \
> "$work/kernel.log" 2>&1 &
echo $! > "$work/kernel.pid"
# Wait for the port file the kernel writes after binding, which is exactly the
# wait `Kernel.awaitPort` performs.
for _ in $(seq 1 150); do
if [ -s "$work/home/remote/serve.port" ]; then break; fi
if ! kill -0 "$(cat "$work/kernel.pid")" 2>/dev/null; then
echo "the kernel exited; log:" >&2
cat "$work/kernel.log" >&2
exit 1
fi
sleep 0.2
done
if [ ! -s "$work/home/remote/serve.port" ]; then
echo "the kernel reported no listening address; log:" >&2
cat "$work/kernel.log" >&2
exit 1
fi
port=$(cat "$work/home/remote/serve.port")
echo "listening on: $port"
# The two things the WebView will do: fetch the page, and exist on the origin.
probe=$(curl -s -o /dev/null -w '%{http_code}' "http://$port/")
echo "GET / -> $probe"
if [ "$probe" != "200" ]; then
echo "the page did not answer; log:" >&2
cat "$work/kernel.log" >&2
exit 1
fi
cat > "$work/entry.txt" <<EOF
origin=http://$port
entry=http://$port/#token=$token
token=$token
EOF
echo "entry written: $work/entry.txt"
echo "kernel log (first 10 lines):"
head -10 "$work/kernel.log"
+31
View File
@@ -0,0 +1,31 @@
// Reasonix Mobile — the Android shell around the Reasonix kernel.
//
// The shell is deliberately thin: it unpacks the kernel and the built page that
// ship inside the APK, starts `reasonix serve` on a loopback port, and points a
// WebView at it. Everything the person sees and does is the same page the
// desktop Studio and `reasonix web` serve, because it is literally the same
// build of `desktop/frontend-next` — which is what keeps one interface honest
// across the three surfaces, and why the page is packaged rather than fetched.
pluginManagement {
repositories {
// The official repositories only. A mirror listed here would be an
// unverified trust anchor for anything that ends up in a signed build:
// the artifact it serves is whichever copy landed there. A machine that
// needs a mirror belongs on a user-level init script, where the choice
// stays with the person building rather than travelling with the source.
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ReasonixMobile"
include(":app")
+64 -4
View File
@@ -1,6 +1,6 @@
# Reasonix Benchmarks
Five harnesses live under `benchmarks/`; `cmd/e2ebench` also exposes a
Six harnesses live under `benchmarks/`; `cmd/e2ebench` also exposes a
SWE-bench Verified mode:
- `e2e/` — the committed end-to-end task suite, driven by
@@ -19,6 +19,10 @@ SWE-bench Verified mode:
- `fanout-width/` — a shape-instructed fleet whose members share nothing, for
pricing what running them at the same time is worth. See
[Pricing the run graph](#pricing-the-run-graph) below.
- `realrepo/` — long tasks over a pinned snapshot of this repository, for
measuring changes that cut model round trips (13–31 requests per task on
`deepseek-flash`). See
[its README](realrepo/README.md).
## Directory layout
@@ -416,7 +420,7 @@ own outcome).
| Flag | Default | Purpose |
| --- | --- | --- |
| `-mode` | `suite` | `suite` \| `diff` \| `swebench` \| `compare` \| `traj` (`diff` generates tests for the PR diff; `swebench` runs the official per-instance evaluation; `compare` renders KPI/Pareto readouts from 2+ `-json` reports; `traj` re-digests recorded trajectory files without spending tokens). |
| `-mode` | `suite` | `suite` \| `diff` \| `swebench` \| `compare` \| `traj` (`diff` generates tests for the PR diff; `swebench` runs the official per-instance evaluation; `compare` renders KPI/Pareto readouts from 2+ `-json` reports; `traj` re-digests recorded trajectory files without spending tokens; `audit` checks a batch's published figures against its retained rows). |
| `-suite` | `benchmarks/e2e` | Suite root (must contain `tasks/<id>/`). |
| `-task` | *(all)* | Suite mode: run only these comma-separated task IDs (e.g. `-task fix-add-bug`); unknown IDs fail with the available list. |
| `-attempts` | `1` | Suite and diff modes: retry a task until an attempt passes, up to N; enables the `Pass@≤N` KPI, and TTCS charges a retried solve with its failed attempts' wall. |
@@ -429,6 +433,7 @@ own outcome).
| `-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). |
| `-batch` | *(none)* | Suite mode: write `results.jsonl` (one row per attempt) and `batch.json` (manifest v1) here, so the report's figures can be audited later. Audit mode: read the batch from here. See [Auditing a batch](#auditing-a-batch). |
| `-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). |
@@ -451,10 +456,65 @@ Diff-mode flags:
| `-timeout` | `1200` | Agent timeout in seconds (diff mode). |
| `-attempts` | `1` | Diff mode: retry up to N times until a run passes (stochastic agent). |
## Auditing a batch
`-batch DIR` writes two files from a suite run: `results.jsonl` (one row per
attempt — the raw record every figure is computed from) and `batch.json`
(manifest v1). `-mode audit -batch DIR` re-derives the report's headline figures
from `results.jsonl`, prints the manifest's claim beside the recomputed value,
and exits non-zero naming any figure that moved. It also refuses a batch whose
manifest is missing, records no commit, or whose rows are truncated.
This is an **aggregation audit, not a replay**: it proves the published figures
follow from the retained rows. It does not re-execute the model, tools or
environment, and cannot show the experiment would reproduce. A real replay would
need the commit, a corpus fingerprint, the model and provider configuration, the
seed, the flags, the raw results and the runtime version; it is not implemented.
`schema_version` is top level. The rest is three groups:
| Group | Fields | Answers |
| --- | --- | --- |
| `over` | `command.mode` and `command.axes` (every flag that changes what a figure means; paths omitted); `tasks.count` and `tasks.digest` (sha256 over sorted, NUL-separated task ids — the corpus identity, never the task text); `filters`; `denominator` (`unit`, `definition`, `value`) | what the figures are over |
| `producer` | `commit` (or an explicit `unknown`, never a guess), `commit_source`, `dirty`, `model`, `provider`, `seed`, `started_at`, `finished_at`, `harness_version`, `go_version` | which run produced them |
| `trust` | `results_sha256`, `claimed` (the headline figures), `ledger`, `retention` | why they can still be checked |
The ledger accounts for every planned run, and the audit checks that the counts
form a funnel (`scored ≤ completed ≤ attempted`, `failed ≤ scored`,
`scored = pass1 + failed`, `rows = attempted + skipped`). A row count is never a
denominator: `denominator.value` is a count of scored tasks, and `rows` is
published only to show what was retained.
| Count | Meaning |
| --- | --- |
| `planned` | graded runs the batch set out to attempt: filtered tasks × trials |
| `attempted` | retained rows for runs that started (skipped rows are not attempts) |
| `completed` | attempts that ended and left a row; this harness writes a row only after an attempt ends, so it equals `attempted` and the equality is checked, not assumed |
| `scored` | completed first attempts of solvable, non-skipped tasks — the pass@1 denominator |
| `failed` | scored attempts the grader did not pass |
| `unmeasured` | attempts whose cost and tokens are unknown because no metrics landed; kept out of spend totals, never counted as zero |
| `excluded` | rows kept out of the accuracy denominator: skipped, `no_solution`, and retry entries |
Spend and cache fields are omitted from `claimed` when nothing was measured, so
an absent figure reads as unknown rather than as a measured zero. `seed` is
omitted for the same reason: the harness does not set a sampling seed.
`results_sha256` covers the values and the presence of every row; line endings,
indentation and key order are normalised away because none of them is evidence,
while number spelling is kept (a rewritten `1` is not `1.0`).
A successful audit records the verifier version (`audit.verifier_version`) and
the digest it checked in `batch.json`, so a re-verification can tell which code
checked the batch.
`results.jsonl` is retained unredacted beside the manifest — task prompts and
agent notes included — and is not public. The manifest itself carries a task-id
digest, no prompt text, no absolute paths, no endpoints or keys and no model
output. A claim whose rows are gone cannot be audited: there is nothing to
recompute from, and this tool will not certify it.
## Dataset retention
Keep every `-json` report and `-trajectories` directory from real runs: they
are the accumulating corpus — per-task contracts-to-be, full event
Keep every `-json` report, `-trajectories` directory and `-batch` directory from
real runs: they are the accumulating corpus — per-task contracts-to-be, full event
trajectories, checkpoint oracle verdicts, stop curves and phase traces — that
any future offline learning (routing, stop policies, budgets) would train
and evaluate on. The control plane stays deterministic and interpretable
-4
View File
@@ -103,10 +103,6 @@ func newHarness(t *testingDir, p provider.Provider, window int, rec *callRecorde
ContextWindow: window,
ArchiveDir: filepath.Join(t.dir, "archive"),
SessionPath: path,
RecentKeep: 4,
// boot's default when cfg.Agent.Keep is unset; without it the bench
// would measure a configuration no real session runs.
KeepPolicy: agent.KeepErrors,
}, rec.sink())
return &harness{sess: sess, agentA: a, path: path, calls: rec}
}
@@ -108,7 +108,6 @@ func newAgent(p provider.Provider, sess *sessionstore.Session, path string, sink
return agent.New(p, tool.NewRegistry(), sess, agent.Options{
ContextWindow: windowTokens,
CompactRatio: compactRatio,
RecentKeep: 2,
SessionPath: path,
WorkspaceID: "cm-e2e",
ModelRef: "deepseek/" + model,
-180
View File
@@ -1,180 +0,0 @@
# Context retrieval bench
Two questions about long-horizon memory, and the harness that makes their
answers trustworthy.
-mode=preflight build all 18 tasks under every arm and check the contract
-mode=snippet does a rank-1 hit hand over the whole answer?
-mode=adversarial send a model after the answer through every host surface
-mode=run-search Experiment R: is searchable canonical a real capability?
-mode=run-boundary Experiment I, paired: the 6 efficiency tasks across their boundary
-mode=run-index Experiment I, full: the 6 efficiency tasks x 4 budgets
-mode=calibrate find each index task's PlantAfterGen for its cue tier
`-dry` drives any run mode with a scripted provider, for nothing. Real runs need
`DEEPSEEK_API_KEY`.
## Status
**Searchable canonical recall: proven.** Six of six recovered with search on,
three of six with it off, and the successes without it came from enumerating
addresses by hand at roughly nine times the page-in cost (139 vs 1292
tokens/task). Measured after three separate leaks were closed, so the numbers
are from a clean environment.
**Fold index marginal utility: direction reproduced, size still conditional.** On the v2
substrate the direction holds in six of six tasks and in the pooled paired batch: with the
cue visible the index addresses the target, so the same task is searched less and pages in
less (0.67 searches and 70 recall tokens per task, against 1.50 and 120 with the cue
absent).
A size is visible but is not yet quotable. On the four tasks whose absent-cue arm stayed
out of a loop, the index cost 64,952 prompt tokens against 115,551 without, 44% less
(`i106-budgetseal` -35,147, `i70-ingestfloor` -8,222, `i37-dispatchlag` -7,599, and
`i112-lagquota` +369, the prefix rent a task that never searches pays). That is a
**post-hoc subset**: `i118-fencelag` and `i47-tenantlease` are excluded because their
absent-cue arm looped in this batch (five and three retrieval rounds, three escapes each;
together 80% of the absent-cue total), and the qualification above does not screen for
stopping behaviour — holding on both boundary arms across three passes shows a stable cue
boundary, not a stopping arm. Quoting 44% as the index's effect would mean choosing the
sample after seeing the result; a batch screened on stopping stability is what would make
a size quotable, and one arm also reverses either way (`i70-ingestfloor` default, +11,135
prompt tokens over three escapes).
Batch facts, because the counts differ: 36 runs were executed (12 paired, 24 index), and
the token totals here cover the 24-run index batch — the paired batch ran before the
per-run usage line existed.
**Things that turned out not to be problems**, each after being measured rather
than argued about:
| Suspected | Measured |
| --- | --- |
| Model writes poor queries (2.17 searches/task) | 22/23 found the target on query 1; the extra searches are one round's parallel fan-out |
| Model reaches for the workspace before memory (7/11) | Linear event order misread; by model round it is MemoryFirst 4/6 |
| Snippets too short for multi-value answers | 240 runes covers 12/12 tasks at 100%; records are 30-179 runes |
| A general stopping failure | 37/44 runs issued no search after the answer was in hand; all 7 that did belong to 2 tasks |
## Measurement distinctions this bench had to learn
Each of these reversed a conclusion once. They are enforced in the schema now,
not left to whoever reads the numbers next.
- **TargetHit is not EvidenceSufficient.** A rank-1 hit can return a window too
short to answer with. `FirstHitCoverage` records how much of the scored answer
the first hit actually handed over.
- **Linear event order is not model-round order.** Two calls in one round are a
parallel fan-out, not a preference. Routing compares rounds.
- **An isolated workdir is not an isolated host.** The sandbox mounts the host
read-only by design. Three leaks were found and closed: the corpus source, the
fixture transcript, and its event log sidecar.
- **A count is not its evidence.** Query text, snippet contents and trajectories
are all persisted now, because three analyses in a row needed a paid re-run to
ask a question of data already collected.
## How a run stays honest
The corpus holds templates, never answers: every scored literal is a `{{var}}`
instantiated per run, so grepping this directory reveals the question and not
the answer. `TestNoAnswerLiteralExistsInTheRepository` asserts it with
`git grep`. Preflight rebuilds every arm and checks the answer is unreadable in
the real `provider.Request`, the probe query ranks the target within five, and
an index task's cue sits at exactly the scales its tier names. The fixture is
removed from disk once the agent holds it in memory, and the directory is
scanned for answer literals before the first provider request and again after
the turn. Any answer reaching the model through a tool that is not `recall`
marks the run contaminated and takes it out of every statistic.
## Index corpus v2
The efficiency substrate is six tasks that hold on both arms: `i37-dispatchlag`
and `i118-fencelag` (quarter), `i106-budgetseal` and `i70-ingestfloor` (half),
`i47-tenantlease` and `i112-lagquota` (default).
They came out of five batches of candidates, each screened the same way: one
`-mode=run-index` pass over the batch, then two more only for the candidates that
were clean on both boundary arms, and only the candidates that held on both arms
in all three passes stayed. A candidate that does not hold is dropped, not
promoted. The numbers are candidates, per tier:
| batch | quarter | half | default |
| --- | --- | --- | --- |
| first pool, `i31`-`i54` | 1 of 8 | 0 of 8 | 2 of 8 |
| second pool, `i61`-`i84` | 1 of 4 screened | 4 of 8 | 0 of 8 |
| third pool, `i85`-`i108` | 2 of 8 | 8 of 16 | none authored |
| replacement pool, `i109`-`i116` | - | - | 4 of 8 |
| replacement pool, `i92`, `i117`-`i124` | 3 of 9 | - | - |
That table counts candidates that held on both arms in all three passes, which is
not the size of the substrate. 89 candidates were authored, 77 were screened (the
second pool's quarter tier stopped at 4 of 8 and its default tier was never run),
and 25 held: 7 quarter, 12 half, 6 default. The substrate keeps two per tier, so
the other 19 survivors are spares rather than corpus members. Candidates from the
earlier round are not counted here.
| task | tier | cue present | cue absent |
| --- | --- | ---: | ---: |
| `i37-dispatchlag` | quarter | 3/3 | 3/3 |
| `i118-fencelag` | quarter | 3/3 | 3/3 |
| `i106-budgetseal` | half | 3/3 | 3/3 |
| `i70-ingestfloor` | half | 3/3 | 3/3 |
| `i47-tenantlease` | default | 3/3 | 3/3 |
| `i112-lagquota` | default | 3/3 | 3/3 |
Across their 36 judged cells: `SnippetStop` 23, `SnippetThenRead` 10 and
`DefensiveRead` 3 - every cell clean, nothing post-sufficient and nothing
`NeverSufficient`.
Four of the first six were flagged in review for sharing answer values, probe
queries and prompt wording within a tier. Separating them and re-screening those
four changed two of them: `i37` and `i47` held, `i62` and `i51` did not, so they
were replaced - `i90-recoverylag` was tried first and did not hold either, and
`i118-fencelag` and `i112-lagquota` are what did. The separation was not
cosmetic: the shared wording had been carrying two of the six.
Nothing in the task shape separates the survivors from the rest: within the half
tier, plain-number answers survived 0 of 8 in the first pool and 4 of 8 in the
second, and codename answers survived 8 of 16 in the third. An earlier claim here
that the answer kind decides whether a candidate holds does not survive this
table and is withdrawn. The dirty class was `PostSufficientRetrieval` in most
failures - the model re-checking a value it already had - but which tasks hit it
was not predictable from their shape at this sample.
`i01`-`i06` are the frozen study's six tasks, kept as `roleStopping`. Measured
once each with `-mode=run-index -task <id>`, per task and per arm:
| task | tier | cue present | cue absent |
| --- | --- | --- | --- |
| `i01-transport-fallback` | quarter | SnippetStop | DefensiveRead |
| `i02-scheduler-handoff` | quarter | PostSufficientRetrieval | PostSufficientRetrieval |
| `i03-coalescing` | half | PostSufficientRetrieval | PostSufficientRunaway |
| `i04-recovery-fence` | half | DefensiveRead | PostSufficientRunaway |
| `i05-cache-probe` | default | SnippetThenRead | PostSufficientRetrieval |
| `i06-dispatch-handoff` | default | SnippetStop | PostSufficientRetrieval |
Five of the six show post-sufficient behaviour in at least one arm (`i02`-`i06`),
and `i01` is clean on both. The cue-present arm is clean for four of them
(`i01`, `i04`, `i05`, `i06`), which is why they stay in the corpus as the
stopping side rather than being retitled. All six are excluded from `run-index`
and `run-boundary` batches and stay measurable one at a time with `-task <id>`. A
task added to the corpus declares a role, so it cannot arrive without one.
A task qualifies only if it is clean on both of its boundary arms - the
cue-present run and the cue-absent run - in every run of a screening batch. Clean
means 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.
`-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
budgets sampled once each cannot show it.
@@ -1,86 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// runBlockedBatch runs one dry index batch where every arm's runOne fails
// before any model turn: the task's own directory is a regular file, so the
// fixture cannot be built. In production the same early return happens when the
// fixture build, the session load, or the answer-on-disk guard fails.
func runBlockedBatch(t *testing.T) string {
t.Helper()
tk := indexTasks()[0]
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, tk.ID), []byte("not a directory\n"), 0o644); err != nil {
t.Fatal(err)
}
if code := runExperiment("run-index", experimentIndex, root, true, []contextTask{tk}); code != 0 {
t.Fatalf("runExperiment exit = %d, want 0 (every run failed, but the batch finished)", code)
}
return root
}
func readResultLines(t *testing.T, root string) ([]contextMetrics, []string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join(root, "results.jsonl"))
if err != nil {
t.Fatal(err)
}
var runs []contextMetrics
var lines []string
for text := range strings.SplitSeq(strings.TrimSpace(string(raw)), "\n") {
var m contextMetrics
if err := json.Unmarshal([]byte(text), &m); err != nil {
t.Fatalf("results.jsonl: %v", err)
}
runs = append(runs, m)
lines = append(lines, text)
}
return runs, lines
}
// A run that failed before the model turn produced no measurement. Recording it
// as a zero-valued contextMetrics puts it in the funnel's Scored denominator,
// in "Where it broke", and in the dose curve as a real zero.
func TestBatchDoesNotRecordAFailedRunAsAResult(t *testing.T) {
root := runBlockedBatch(t)
runs, lines := readResultLines(t, root)
if len(runs) == 0 {
t.Fatal("no runs recorded at all")
}
for i, m := range runs {
if m.Task == "" || m.Arm == "" || m.FailureStage == "" {
t.Fatalf("results.jsonl line %d records a run that never ran as a result "+
"(task=%q arm=%q failure_stage=%q):\n %s", i+1, m.Task, m.Arm, m.FailureStage, lines[i])
}
}
}
// The manifest is the artefact that says which runs counted, so a failed run
// must not be added to CompletedRuns or to the task id list.
func TestBatchManifestDoesNotReportFailedRunsAsCompleted(t *testing.T) {
root := runBlockedBatch(t)
var manifest batchManifest
data, err := os.ReadFile(filepath.Join(root, "batch.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatal(err)
}
for _, id := range manifest.TaskIDs {
if id == "" {
t.Fatalf("manifest lists an empty task id: task_ids=%q (completed=%d planned=%d)",
manifest.TaskIDs, manifest.CompletedRuns, manifest.PlannedRuns)
}
}
if manifest.CompletedRuns == manifest.PlannedRuns {
t.Fatalf("manifest reports every planned run completed although none produced evidence: "+
"completed=%d planned=%d task_ids=%q", manifest.CompletedRuns, manifest.PlannedRuns, manifest.TaskIDs)
}
}
-155
View File
@@ -1,155 +0,0 @@
package main
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
// batchInfo is what the mode itself knows before any run happens. Everything
// else in the manifest is derived from the runs that were written, so a figure
// in it cannot drift from the artifact beside it.
type batchInfo struct {
Mode string
Planned int
Started time.Time
Dry bool
// Repo is the checkout the batch ran from, captured before the harness
// isolates its workspace: git cannot describe a directory that is gone.
Repo string
}
// batchManifest is written beside results.jsonl so a later reader does not have
// to reconstruct what the batch was from a terminal scrollback.
type batchManifest struct {
SchemaVersion int `json:"schema_version"`
Mode string `json:"mode"`
StartedAt string `json:"started_at"`
FinishedAt string `json:"finished_at"`
GitCommit string `json:"git_commit"`
GitDirty *bool `json:"git_dirty,omitempty"`
Model string `json:"model"`
Window int `json:"window"`
Generations int `json:"generations"`
TaskIDs []string `json:"task_ids"`
Arms []string `json:"arms"`
PlannedRuns int `json:"planned_runs"`
CompletedRuns int `json:"completed_runs"`
// FailedRuns counts the planned runs that ended before a transcript existed.
// They are rows in results.jsonl, never part of CompletedRuns, and the field
// is absent when there were none so an intact batch is unchanged.
FailedRuns int `json:"failed_runs,omitempty"`
// Usage totals are absent when nothing reported any: an unmeasured cost must
// not read as a measured zero. UsageReportedRuns stays, because zero there
// is a fact about the batch.
UsageReportedRuns int `json:"usage_reported_runs"`
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
UsageEstimated bool `json:"usage_estimated,omitempty"`
ResultsSHA256 string `json:"results_sha256"`
TrajectoriesSHA256 string `json:"trajectories_sha256"`
}
// gitCommitAndDirty reports the checkout the batch ran from. Unknown is written
// rather than omitted, so an absent field is never mistaken for a clean tree.
func gitCommitAndDirty(repo string) (commit string, dirty *bool) {
commit = "unknown"
if repo == "" {
return commit, nil
}
in := func(args ...string) ([]byte, error) {
cmd := exec.Command("git", args...)
cmd.Dir = repo
return cmd.Output()
}
if out, err := in("rev-parse", "--short", "HEAD"); err == nil {
commit = strings.TrimSpace(string(out))
}
if out, err := in("status", "--porcelain"); err == nil {
state := strings.TrimSpace(string(out)) != ""
dirty = &state
}
return commit, dirty
}
func fileSHA256(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", sha256.Sum256(data)), nil
}
func sortedKeys(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
// writeBatchManifest records the batch beside the runs it describes. It runs
// last, so its digests are of what is already on disk.
func writeBatchManifest(root string, info batchInfo, all []contextMetrics) error {
ids, arms := map[string]bool{}, map[string]bool{}
var prompt, completion, cacheHit, cacheMiss, requests, reported, failed int
estimated := false
for _, m := range all {
// A failed run has no task, arm or evidence to contribute: it is counted
// as a failure below and is not part of what the batch measured.
if m.Failed {
failed++
continue
}
ids[m.Task] = true
arms[m.Arm] = true
if m.UsageReportedRequests > 0 {
reported++
requests += m.UsageReportedRequests
prompt += m.PromptTokens
completion += m.CompletionTokens
cacheHit += m.CacheHitTokens
cacheMiss += m.CacheMissTokens
estimated = estimated || m.UsageEstimated
}
}
commit, dirty := gitCommitAndDirty(info.Repo)
model := runModel
if info.Dry {
model = "scripted"
}
manifest := batchManifest{
SchemaVersion: 1, Mode: info.Mode,
StartedAt: info.Started.UTC().Format(time.RFC3339),
FinishedAt: time.Now().UTC().Format(time.RFC3339),
GitCommit: commit, GitDirty: dirty,
Model: model, Window: fixtureWindow, Generations: fixtureGenerations,
TaskIDs: sortedKeys(ids), Arms: sortedKeys(arms),
PlannedRuns: info.Planned, CompletedRuns: len(all) - failed, FailedRuns: failed,
UsageReportedRuns: reported, UsageReportedRequests: requests,
PromptTokens: prompt, CompletionTokens: completion,
CacheHitTokens: cacheHit, CacheMissTokens: cacheMiss, UsageEstimated: estimated,
}
var err error
if manifest.ResultsSHA256, err = fileSHA256(filepath.Join(root, "results.jsonl")); err != nil {
return err
}
if manifest.TrajectoriesSHA256, err = fileSHA256(filepath.Join(root, "trajectories.jsonl")); err != nil {
return err
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(root, "batch.json"), append(data, '\n'), 0o644)
}
@@ -1,91 +0,0 @@
package main
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func seedArtifacts(t *testing.T, root string) {
t.Helper()
for _, name := range []string{"results.jsonl", "trajectories.jsonl"} {
if err := os.WriteFile(filepath.Join(root, name), []byte("{}\n"), 0o644); err != nil {
t.Fatal(err)
}
}
}
func readManifest(t *testing.T, root string) (batchManifest, string) {
t.Helper()
data, err := os.ReadFile(filepath.Join(root, "batch.json"))
if err != nil {
t.Fatal(err)
}
var got batchManifest
if err := json.Unmarshal(data, &got); err != nil {
t.Fatal(err)
}
return got, string(data)
}
func TestBatchManifestRecordsWhatTheBatchWas(t *testing.T) {
root := t.TempDir()
seedArtifacts(t, root)
all := []contextMetrics{
{Task: "b", Arm: "index-off"},
{Task: "a", Arm: "index-off"},
{Task: "a", Arm: "index-half", UsageReportedRequests: 2, PromptTokens: 100,
CompletionTokens: 10, CacheHitTokens: 60, CacheMissTokens: 40},
}
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 4, Started: time.Now().Add(-time.Minute)}, all); err != nil {
t.Fatal(err)
}
got, _ := readManifest(t, root)
if got.SchemaVersion != 1 || got.Mode != "run-index" || got.PlannedRuns != 4 || got.CompletedRuns != 3 {
t.Fatalf("manifest header = %+v", got)
}
if strings.Join(got.TaskIDs, ",") != "a,b" || strings.Join(got.Arms, ",") != "index-half,index-off" {
t.Fatalf("ids/arms = %v / %v, want sorted and unique", got.TaskIDs, got.Arms)
}
if got.UsageReportedRuns != 1 || got.UsageReportedRequests != 2 || got.PromptTokens != 100 ||
got.CompletionTokens != 10 || got.CacheHitTokens != 60 || got.CacheMissTokens != 40 {
t.Fatalf("usage totals = %+v", got)
}
// No repo means git could not describe the checkout: unknown commit, and no
// dirty flag at all, because "clean" is a claim this batch cannot make.
if got.GitCommit != "unknown" || got.GitDirty != nil {
t.Fatalf("git state = %q / %v, want unknown and no dirty flag", got.GitCommit, got.GitDirty)
}
want := fmt.Sprintf("%x", sha256.Sum256([]byte("{}\n")))
if got.ResultsSHA256 != want || got.TrajectoriesSHA256 != want {
t.Fatalf("digests = %q / %q, want %q", got.ResultsSHA256, got.TrajectoriesSHA256, want)
}
}
// A dry batch pays nothing, so its cost fields must be absent rather than zero:
// an unmeasured spend must not read as a measured one.
func TestBatchManifestLeavesUnreportedUsageAbsent(t *testing.T) {
root := t.TempDir()
seedArtifacts(t, root)
all := []contextMetrics{{Task: "a", Arm: "index-off"}, {Task: "a", Arm: "index-half"}}
if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 2, Started: time.Now(), Dry: true}, all); err != nil {
t.Fatal(err)
}
got, raw := readManifest(t, root)
if got.UsageReportedRuns != 0 {
t.Fatalf("usage_reported_runs = %d, want 0 reported", got.UsageReportedRuns)
}
if got.Model != "scripted" {
t.Fatalf("dry batch model = %q, want scripted", got.Model)
}
for _, absent := range []string{"usage_reported_requests", "prompt_tokens", "completion_tokens", "cache_hit_tokens"} {
if strings.Contains(raw, absent) {
t.Fatalf("a dry batch wrote %q:\n%s", absent, raw)
}
}
}
@@ -1,69 +0,0 @@
package main
import (
"strings"
"testing"
)
// boundaryRuns builds the input reportBoundaries reads: one scored run for an
// index task's cue side, and one failed run for another index task's cue side.
// The runs are constructed because the report is a pure function of the slice;
// the batch path that produces them is covered by the failed-batch tests.
func boundaryRuns(t *testing.T) (all []contextMetrics, scoredTask, failedTask string) {
t.Helper()
index := indexTasks()
if len(index) < 2 {
t.Fatalf("need at least two index tasks, have %d", len(index))
}
scored, failed := index[0], index[1]
cue, noCue := boundaryPair(scored.CueTier)
if cue == "" || noCue == "" {
t.Fatalf("task %s has an empty boundary pair (%q, %q)", scored.ID, cue, noCue)
}
failedCue, _ := boundaryPair(failed.CueTier)
if failedCue == "" {
t.Fatalf("task %s has no cue side", failed.ID)
}
all = []contextMetrics{
{Task: scored.ID, Arm: "index-" + cue, AnswerRecovered: true, SearchCalls: 1, RecallReturnedTokens: 318},
{Task: scored.ID, Arm: "index-" + noCue, AnswerRecovered: false},
{Task: failed.ID, Arm: "index-" + failedCue, Failed: true, FailureStage: stageNoEvidence},
}
return all, scored.ID, failed.ID
}
// TestBoundaryReportExcludesFailedRuns: a run that never produced a transcript
// must not appear in the cue-boundary table, which was the last reporting exit
// that still folded it in as a zero measurement.
func TestBoundaryReportExcludesFailedRuns(t *testing.T) {
all, scoredTask, failedTask := boundaryRuns(t)
out := captureStdout(t, func() { reportBoundaries(all) })
if strings.Contains(out, failedTask) {
t.Fatalf("the boundary table printed the failed task %q as a measurement:\n%s", failedTask, out)
}
if !strings.Contains(out, scoredTask) {
t.Fatalf("the boundary table dropped the scored task %q, so the exclusion is not specific to failures:\n%s", scoredTask, out)
}
if !strings.Contains(out, "cue-present") || !strings.Contains(out, "cue-absent") {
t.Fatalf("the pooled comparison disappeared:\n%s", out)
}
}
// TestBoundaryReportSaysNothingToCompareWhenEveryRunFailed: with no scored run
// the report must say so, rather than printing a comparison of zeros.
func TestBoundaryReportSaysNothingToCompareWhenEveryRunFailed(t *testing.T) {
all, _, _ := boundaryRuns(t)
failedOnly := make([]contextMetrics, 0, len(all))
for _, m := range all {
m.Failed = true
m.FailureStage = stageNoEvidence
failedOnly = append(failedOnly, m)
}
out := captureStdout(t, func() { reportBoundaries(failedOnly) })
if !strings.Contains(out, "scored=0") || !strings.Contains(out, "failed=") {
t.Fatalf("an all-failed batch did not report scored=0 with a failed count:\n%s", out)
}
if strings.Contains(out, "cue-present") || strings.Contains(out, "cue-absent") {
t.Fatalf("an all-failed batch still printed a pooled comparison of zeros:\n%s", out)
}
}
-171
View File
@@ -1,171 +0,0 @@
// capture.go — what the agent actually hands a provider.
package main
import (
"context"
"encoding/json"
"fmt"
"reasonix/internal/state/sessionstore"
"strings"
"reasonix/internal/assembly/boot"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/runtime/agent"
_ "reasonix/internal/tools/builtin"
)
// A projection is what the agent believes; a provider.Request is what the model
// is told. Rendering, sanitization and tool-schema construction sit between
// them, so the arm's contract is checked on the request.
type captureProvider struct {
requests []provider.Request
}
func (p *captureProvider) Name() string { return "contextbench-capture" }
// Stream records the request and answers with a clean terminal response. An
// empty answer would buy an empty-final retry and a second request, which is
// not the one round this is here to inspect.
func (p *captureProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.requests = append(p.requests, cloneRequest(req))
ch := make(chan provider.Chunk, 2)
ch <- provider.Chunk{Type: provider.ChunkText, Text: "contextbench preflight complete"}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
func cloneRequest(req provider.Request) provider.Request {
out := req
out.Messages = append([]provider.Message(nil), req.Messages...)
out.Tools = append([]provider.ToolSchema(nil), req.Tools...)
return out
}
// captureRequest rebuilds the session the way a resume does — transcript from
// disk, projection from its sidecar, the shipped tool surface at this arm —
// and returns the request the first round hands the provider.
func captureRequest(f builtFixture, arm ablation.Set, prompt string) (provider.Request, error) {
sess, err := sessionstore.LoadSession(f.Path)
if err != nil {
return provider.Request{}, fmt.Errorf("load session: %w", err)
}
reg := tool.NewRegistry()
for _, t := range tool.Builtins() {
reg.Add(t)
}
boot.ApplyUnifiedProviderToolSurface(reg, false, arm)
cap := &captureProvider{}
a := agent.New(cap, reg, sess, agent.Options{
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
SessionPath: f.Path, KeepPolicy: agent.KeepErrors, Ablation: arm,
}, event.Discard)
a.LoadProjectionSidecar(f.Path)
if err := a.Run(context.Background(), prompt); err != nil {
return provider.Request{}, fmt.Errorf("run: %w", err)
}
// The turn's own request is the one carrying tools. A fold on the way in
// asks the same provider for a digest, and that request has none — taking
// the first request blindly would inspect the summarizer's prompt.
for _, req := range cap.requests {
if len(req.Tools) > 0 {
return req, nil
}
}
return provider.Request{}, fmt.Errorf("no request carried tools (%d captured)", len(cap.requests))
}
// surfaceField is one place a model could read a string, named so a leak
// report says where rather than only that.
type surfaceField struct {
Path string
Text string
}
// providerSurface enumerates every provider-visible string in a request.
// Enumerated rather than marshalled whole: a Message also carries local-only
// fields, and scanning those would fail on bytes no model ever sees.
func providerSurface(req provider.Request) []surfaceField {
var out []surfaceField
add := func(path, text string) {
if strings.TrimSpace(text) != "" {
out = append(out, surfaceField{Path: path, Text: text})
}
}
for i, m := range req.Messages {
add(fmt.Sprintf("messages[%d].content", i), m.Content)
add(fmt.Sprintf("messages[%d].reasoning_content", i), m.ReasoningContent)
add(fmt.Sprintf("messages[%d].name", i), m.Name)
for j, tc := range m.ToolCalls {
add(fmt.Sprintf("messages[%d].tool_calls[%d].name", i, j), tc.Name)
add(fmt.Sprintf("messages[%d].tool_calls[%d].arguments", i, j), tc.Arguments)
}
for j, item := range m.ResponsesItems {
add(fmt.Sprintf("messages[%d].responses_items[%d]", i, j), string(item))
}
}
for i, t := range req.Tools {
add(fmt.Sprintf("tools[%d].name", i), t.Name)
add(fmt.Sprintf("tools[%d].description", i), t.Description)
if len(t.Parameters) > 0 {
b, err := json.Marshal(t.Parameters)
if err == nil {
add(fmt.Sprintf("tools[%d].parameters", i), string(b))
}
}
}
return out
}
// findInSurface reports every place a marker is readable.
func findInSurface(req provider.Request, marker string) []string {
var hits []string
for _, f := range providerSurface(req) {
if strings.Contains(f.Text, marker) {
hits = append(hits, f.Path)
}
}
return hits
}
// recallToolSchema returns the recall tool as the provider sees it.
func recallToolSchema(req provider.Request) (provider.ToolSchema, bool) {
for _, t := range req.Tools {
if t.Name == "recall" {
return t, true
}
}
return provider.ToolSchema{}, false
}
// localOnlyLeaks reports host-side fields that should never survive the
// provider boundary. A hit here is a boundary regression, marker or no marker.
func localOnlyLeaks(req provider.Request) []string {
var out []string
for i, m := range req.Messages {
switch {
case m.RawContent != "":
out = append(out, fmt.Sprintf("messages[%d].raw_content", i))
case m.LocalOnly:
out = append(out, fmt.Sprintf("messages[%d].local_only", i))
case m.DecisionReceipt != nil || len(m.DecisionReceipts) > 0:
out = append(out, fmt.Sprintf("messages[%d].decision_receipt", i))
case m.ToolExecution != nil:
out = append(out, fmt.Sprintf("messages[%d].tool_execution", i))
case len(m.MemoryCitations) > 0:
out = append(out, fmt.Sprintf("messages[%d].memory_citations", i))
}
}
return out
}
// searchWording is what a search-off arm must never be told. The arm exists to
// reproduce the runtime before search; an arm offered a capability it does not
// have is a different experiment.
var searchWording = []string{"recall with a query", "search the whole folded region", "query still finds"}
@@ -1,671 +0,0 @@
// contextbench.go — what the model did with the affordance, read off structure.
package main
import (
"encoding/json"
"fmt"
"io"
"regexp"
"slices"
"strconv"
"strings"
"reasonix/internal/base/tokencount"
"reasonix/internal/contract/provider"
)
// Every judgement here reads a tool call's name and its JSON arguments, or a
// canonical position, and never the model's prose. "I will search the history"
// is not a search; a recall call with a query is.
// failure stages, in funnel order. Exactly one is reported per run, except for
// NoEvidence: that stage belongs to a run with no measurement at all.
const (
stageReadMissed = "NoSearch" // never asked, and never read the target either
stageSearchMiss = "SearchMiss" // searched, target never came back
stageHitNotRead = "HitNotRead" // target was returned and never opened
stageAnswerWrong = "ReadButAnswerWrong" // opened it and still answered wrong
stageRecovered = "Recovered"
stageContaminated = "Contaminated"
stageNoEvidence = "NoEvidence"
// Routing is judged by model round, not by the linear order of tool events:
// two calls in one round are one parallel fan-out, and calling that
// "workspace first" would report a choice the model never made.
routeMemoryFirst = "MemoryFirst"
routeWorkspaceFirst = "WorkspaceFirst"
routeParallel = "Parallel"
routeNeither = "Neither"
stageCueRead = "DirectRead" // read the target from an index address, no search needed
stageNoRetrieval = "NoRetrieval" // answered without touching recall at all
toolRecall = "recall"
)
// searchAttempt is one query the model wrote, and what it got back. Kept per
// attempt rather than aggregated: the question is why a retrieval that the host
// probe ranks first takes the model more than one try.
type searchAttempt struct {
Ordinal int `json:"ordinal"`
Round int `json:"round"`
Query string `json:"query"`
TargetRank int `json:"target_rank"` // 0 = the target did not come back
ResultCount int `json:"result_count"`
// Reformulation classifies this query against the one before it, and
// MissingProbeTerms are the host probe's terms this query left out. Both
// are derived from token sets: no judge, and nothing read out of prose.
Reformulation string `json:"reformulation,omitempty"`
MissingProbeTerms []string `json:"missing_probe_terms,omitempty"`
}
// contextMetrics is one run's behaviour.
type contextMetrics struct {
Task string `json:"task"`
Arm string `json:"arm"`
SearchCalls int `json:"search_calls"`
// SearchHits counts searches that returned anything; TargetSearchHits
// counts searches that returned the target. The gap is query quality.
SearchHits int `json:"search_hits"`
TargetSearchHits int `json:"target_search_hits"`
FirstTargetRank int `json:"first_target_rank"` // 0 = never returned
// FirstHitCoverage: how much of the answer the first hit handed over.
// rank=1 with coverage 1/3 is a window too short to answer with, not a
// model that would not stop.
FirstHitCoverage int `json:"first_hit_coverage"`
AnswerValues int `json:"answer_values"`
ReadCalls int `json:"read_calls"`
TargetRead bool `json:"target_read"`
ReadAfterHit bool `json:"read_after_hit"`
// RecallReadWithoutSearch: the index addressed the target, so no search was
// needed. Not "direct read" — read_file is a read too, and in an audit the
// two mean opposite things.
RecallReadWithoutSearch bool `json:"recall_read_without_search"`
SearchThenRecallRead bool `json:"search_then_recall_read"`
FirstSearchRound int `json:"first_search_round"`
FirstReadRound int `json:"first_read_round"`
RetrievalRounds int `json:"retrieval_rounds"`
// RecallReturnedTokens is what recall paged back in. The index saves
// resident prefix; this is what is paid dynamically instead.
RecallReturnedTokens int `json:"recall_returned_tokens"`
// Provider usage as reported on the stream, or empty for a dry run. The count
// is completions that reported usage, so it is not a count of attempts.
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
UsageEstimated bool `json:"usage_estimated,omitempty"`
Searches []searchAttempt `json:"searches,omitempty"`
// PostHitSearches are queries issued after the target had already been
// returned: not a query problem, a result-to-read problem.
PostHitSearches int `json:"post_hit_searches"`
AnswerRecovered bool `json:"answer_recovered"`
// AnswerAmbiguous marks an answer that offered more than one candidate for
// a value. Markers matching proves the right fact appears; it does not
// prove it was submitted as the answer. Recorded, never acted on.
AnswerAmbiguous bool `json:"answer_ambiguous,omitempty"`
AmbiguousVar string `json:"ambiguous_var,omitempty"`
MissingMarkers []string `json:"missing_markers,omitempty"`
// FinalAnswer is kept bounded so a surprising run can be read back. A
// metric nobody can audit is a claim, not a measurement.
FinalAnswer string `json:"final_answer,omitempty"`
FailureStage string `json:"failure_stage"`
TimeoutReason string `json:"timeout_reason,omitempty"`
// Failed marks a run that ended before a transcript existed: it has no score
// and no place in the statistics. The row stays in results.jsonl with its
// task, arm and stage, so a failure is counted rather than dropped.
Failed bool `json:"failed,omitempty"`
Reason string `json:"reason,omitempty"`
// UnexpectedWorkTools are investigation tools used instead of recall.
// Reaching for the workspace is behaviour, not noise: how a model routes
// its uncertainty when memory is missing is part of what is measured.
UnexpectedWorkTools []string `json:"unexpected_work_tools,omitempty"`
EscapeCalls int `json:"escape_calls"`
EscapeBeforeFirstRecall int `json:"escape_before_first_recall"`
EscapeAfterFirstRecall int `json:"escape_after_first_recall"`
// Contaminated: the answer was reachable through something other than
// recall. Asserting this beats trusting the isolation, and a run that trips
// it leaves the statistics rather than inflating one.
Contaminated bool `json:"contaminated,omitempty"`
LeakedVia string `json:"leaked_via,omitempty"`
LeakedMarker string `json:"leaked_marker,omitempty"`
LeakedArgs string `json:"leaked_args,omitempty"`
LeakedOutput string `json:"leaked_output,omitempty"`
// EscapeFoundAnswer separates "looked at the workspace" from "found the
// answer there": different facts about the same call.
EscapeFoundAnswer bool `json:"escape_found_answer,omitempty"`
CueVisible bool `json:"cue_visible"`
// trajectory is kept out of the metric line and written beside it, so a
// future question can be asked of this batch instead of a new one.
trajectory []provider.Message
// CueDirectRead: the cue was addressed, the first memory action was a
// positions read, and it covered the target. Crediting any positions call
// would hand the index a search's work.
CueDirectRead bool `json:"cue_direct_read"`
// Sufficiency, not hit. A search that returns the target at rank 1 has not
// necessarily returned the answer: the snippet is bounded. The stopping
// boundary is the round where everything needed was readable.
FirstSufficientRound int `json:"first_sufficient_round"`
SufficientVia string `json:"sufficient_via"` // snippet | read | never
RoundsAfterSufficient int `json:"rounds_after_sufficient"`
SearchesAfterSufficient int `json:"searches_after_sufficient"`
ReadsAfterSufficient int `json:"reads_after_sufficient"`
EscapesAfterSufficient int `json:"escapes_after_sufficient"`
RecallTokensAfterSufficient int `json:"recall_tokens_after_sufficient"`
StoppingClass string `json:"stopping_class"`
// What happened after the answer was already in hand. A timeout that spent
// seven rounds past its target is a stopping problem, and extending the
// budget would only buy the loop more time.
TargetFirstSeenRound int `json:"target_first_seen_round"`
RoundsAfterTarget int `json:"rounds_after_target"`
SearchesAfterTarget int `json:"searches_after_target"`
RecallTokensAfterTarget int `json:"recall_tokens_after_target"`
TargetHeldAtEnd bool `json:"target_held_at_end"`
// Routing is where the model looked first when its memory was missing.
Routing string `json:"routing"`
FirstRecallRound int `json:"first_recall_round"`
FirstEscapeRound int `json:"first_escape_round"`
}
// hitPosition matches the address recall prints for a search hit. Reading our
// own rendered format, not guessing at the model's words.
var hitPosition = regexp.MustCompile(`(?m)^#(\d+) `)
// scoreRun reads the messages one turn appended and reports what happened.
// runScorer walks one turn's messages, holding the state a judgement needs:
// which calls are open, whether the target has come back, and which search
// each result belongs to.
type runScorer struct {
m contextMetrics
inst fixtureInstance
target int
calls map[string]provider.ToolCall
pending map[string]int
round int
sawTargetHit bool
// seen accumulates everything recall has shown the model, so sufficiency is
// judged on what it could read rather than on which call produced it.
seen strings.Builder
sufficient bool
}
// scoreRun reads the messages one turn appended and reports what happened.
func scoreRun(msgs []provider.Message, inst fixtureInstance, target int, arm string, cueVisible bool) contextMetrics {
s := &runScorer{
m: contextMetrics{Task: inst.Task.ID, Arm: arm, CueVisible: cueVisible},
inst: inst, target: target,
calls: map[string]provider.ToolCall{}, pending: map[string]int{},
}
for _, msg := range msgs {
if len(msg.ToolCalls) > 0 {
s.round++
}
for _, tc := range msg.ToolCalls {
s.calls[tc.ID] = tc
s.observeCall(tc)
}
if msg.Role == provider.RoleTool {
s.observeResult(msg)
}
}
return s.finish(cueVisible)
}
// observeCall records one tool call: a recall search, a recall read, or a
// reach for the workspace.
func (s *runScorer) observeCall(tc provider.ToolCall) {
m := &s.m
if tc.Name != toolRecall {
if !containsString(m.UnexpectedWorkTools, tc.Name) {
m.UnexpectedWorkTools = append(m.UnexpectedWorkTools, tc.Name)
}
m.EscapeCalls++
if s.sufficient {
m.EscapesAfterSufficient++
}
if m.FirstEscapeRound == 0 {
m.FirstEscapeRound = s.round
}
if m.SearchCalls+m.ReadCalls == 0 {
m.EscapeBeforeFirstRecall++
} else {
m.EscapeAfterFirstRecall++
}
return
}
if m.FirstRecallRound == 0 {
m.FirstRecallRound = s.round
}
query, positions := recallArgs(tc.Arguments)
switch {
case query != "":
m.SearchCalls++
if m.FirstSearchRound == 0 {
m.FirstSearchRound = s.round
}
if s.sawTargetHit {
m.PostHitSearches++
m.SearchesAfterTarget++
}
if s.sufficient {
m.SearchesAfterSufficient++
}
m.Searches = append(m.Searches, searchAttempt{Ordinal: m.SearchCalls, Round: s.round, Query: query})
s.pending[tc.ID] = len(m.Searches) - 1
case len(positions) > 0:
m.ReadCalls++
if s.sufficient {
m.ReadsAfterSufficient++
}
if m.FirstReadRound == 0 {
m.FirstReadRound = s.round
}
// A read covers the target when it names its position: recall returns
// the tool results under the caller's own address.
if containsInt(positions, s.target) {
m.TargetRead = true
switch {
case s.sawTargetHit:
m.ReadAfterHit, m.SearchThenRecallRead = true, true
case m.SearchCalls == 0:
m.RecallReadWithoutSearch = true
}
}
}
}
// observeResult reads what a call returned: contamination, page-in cost, and
// whether this search brought the target back.
func (s *runScorer) observeResult(msg provider.Message) {
m := &s.m
call, ok := s.calls[msg.ToolCallID]
if !ok {
return
}
if call.Name != toolRecall {
// Codenames only: a three-digit epoch appears in any long file by
// chance, and one did. A model holding the codename holds the numbers
// beside it anyway.
for _, marker := range s.inst.AnswerMarkers {
if len(marker) >= 6 && strings.Contains(msg.Content, marker) {
m.Contaminated, m.LeakedVia, m.LeakedMarker = true, call.Name, marker
m.EscapeFoundAnswer = true
m.LeakedArgs, m.LeakedOutput = truncate(call.Arguments, 200), truncate(msg.Content, 300)
}
}
return
}
cost := tokencount.Text(msg.Content)
m.RecallReturnedTokens += cost
if s.sawTargetHit {
m.RecallTokensAfterTarget += cost
}
if s.sufficient {
m.RecallTokensAfterSufficient += cost
}
// Everything recall showed the model, judged as a whole: the answer may
// arrive across a snippet and a read, and either way it is readable.
s.seen.WriteString(msg.Content)
s.seen.WriteString("\n")
if !s.sufficient && allMarkersIn(s.seen.String(), s.inst.AnswerMarkers) {
s.sufficient = true
m.FirstSufficientRound = s.round
query, _ := recallArgs(call.Arguments)
m.SufficientVia = "read"
if query != "" {
m.SufficientVia = "snippet"
}
}
if query, _ := recallArgs(call.Arguments); query == "" {
return
}
positions := searchHitPositions(msg.Content)
if len(positions) > 0 {
m.SearchHits++
}
rank := indexOfInt(positions, s.target)
if i, ok := s.pending[msg.ToolCallID]; ok {
m.Searches[i].TargetRank, m.Searches[i].ResultCount = rank, len(positions)
}
if rank == 0 {
return
}
m.TargetSearchHits++
if !s.sawTargetHit {
m.TargetFirstSeenRound = s.round
m.FirstHitCoverage = answerCoverage(msg.Content, s.inst.AnswerMarkers)
}
s.sawTargetHit = true
if m.FirstTargetRank == 0 {
m.FirstTargetRank = rank
}
}
// finish derives what only the whole walk can say.
func (s *runScorer) finish(cueVisible bool) contextMetrics {
m := s.m
m.RetrievalRounds = m.SearchCalls + m.ReadCalls
m.TargetHeldAtEnd = s.sawTargetHit || m.TargetRead
if m.TargetFirstSeenRound > 0 {
m.RoundsAfterTarget = s.round - m.TargetFirstSeenRound
}
m.Routing = routing(m.FirstRecallRound, m.FirstEscapeRound)
m.CueDirectRead = cueVisible && m.RecallReadWithoutSearch
if m.SufficientVia == "" {
m.SufficientVia = "never"
}
if m.FirstSufficientRound > 0 {
m.RoundsAfterSufficient = s.round - m.FirstSufficientRound
}
m.AnswerValues = len(s.inst.AnswerMarkers)
m.StoppingClass = stoppingClass(m)
classifySearches(&m, s.inst)
return m
}
// scoreAnswer grades the final text on the task's markers. Nonces, matched
// exactly: no judge model, and nothing a general answer could satisfy.
func (m *contextMetrics) scoreAnswer(final string, inst fixtureInstance) {
lower := strings.ToLower(final)
for _, marker := range inst.AnswerMarkers {
if !strings.Contains(lower, strings.ToLower(marker)) {
m.MissingMarkers = append(m.MissingMarkers, marker)
}
}
m.AnswerRecovered = len(m.MissingMarkers) == 0
m.AnswerAmbiguous, m.AmbiguousVar = ambiguousAnswer(final, inst)
m.FailureStage = m.stage()
if len(final) > 1200 {
final = final[:1200] + "…"
}
m.FinalAnswer = final
}
// stage places the run in the funnel. One label, chosen at the first step that
// did not happen, so a table of stages reads as where the retrieval broke.
func (m contextMetrics) stage() string {
switch {
case m.Contaminated:
return stageContaminated
case m.AnswerRecovered && m.RecallReadWithoutSearch:
return stageCueRead
case m.AnswerRecovered:
return stageRecovered
case m.TargetRead:
return stageAnswerWrong
case m.TargetSearchHits > 0:
return stageHitNotRead
case m.SearchCalls > 0:
return stageSearchMiss
case m.ReadCalls > 0:
return stageReadMissed
default:
return stageNoRetrieval
}
}
// recallArgs pulls the two fields that say which operation a recall call is.
func recallArgs(args string) (query string, positions []int) {
var parsed struct {
Query string `json:"query"`
Positions []int `json:"positions"`
}
if err := json.Unmarshal([]byte(args), &parsed); err != nil {
return "", nil
}
return strings.TrimSpace(parsed.Query), parsed.Positions
}
// searchHitPositions reads the addresses out of a search result, in rank order.
func searchHitPositions(text string) []int {
var out []int
for _, match := range hitPosition.FindAllStringSubmatch(text, -1) {
if n, err := strconv.Atoi(match[1]); err == nil {
out = append(out, n)
}
}
return out
}
func containsInt(list []int, want int) bool { return slices.Contains(list, want) }
// indexOfInt returns a 1-based rank, or 0 when absent.
func indexOfInt(list []int, want int) int {
for i, v := range list {
if v == want {
return i + 1
}
}
return 0
}
func containsString(list []string, want string) bool { return slices.Contains(list, want) }
// funnel summarizes one arm's runs the way the experiment is read.
type funnel struct {
Arm string
Runs int
Scored, Contaminated, Errored int
Searched, TargetHit, TargetRead, Answered int
CueReads int
SearchCalls, ReadCalls, RecallTokens int
Escapes, EscapeCalls int
Stages, Routes map[string]int
}
func summarize(arm string, runs []contextMetrics) funnel {
f := funnel{Arm: arm, Runs: len(runs), Stages: map[string]int{}, Routes: map[string]int{}}
for _, r := range runs {
// A run that never produced a transcript is not a scored run and has no
// stage to place: counting it would put an empty name in the funnel, the
// stage table and the routes.
if r.Failed {
continue
}
if r.Contaminated {
f.Contaminated++
f.Stages[r.FailureStage]++
continue
}
if r.FailureStage == "RunError" {
f.Errored++
f.Stages[r.FailureStage]++
continue
}
f.Scored++
if r.SearchCalls > 0 {
f.Searched++
}
if r.TargetSearchHits > 0 {
f.TargetHit++
}
if r.TargetRead {
f.TargetRead++
}
if r.AnswerRecovered {
f.Answered++
}
if r.RecallReadWithoutSearch {
f.CueReads++
}
if len(r.UnexpectedWorkTools) > 0 {
f.Escapes++
}
f.EscapeCalls += r.EscapeCalls
f.SearchCalls += r.SearchCalls
f.ReadCalls += r.ReadCalls
f.RecallTokens += r.RecallReturnedTokens
f.Stages[r.FailureStage]++
if r.FailureStage != "RunError" {
f.Routes[r.Routing]++
}
}
return f
}
func (f funnel) line() string {
per := func(n int) float64 {
if f.Scored == 0 {
return 0
}
return float64(n) / float64(f.Scored)
}
line := fmt.Sprintf("%-14s runs=%-3d answered=%d/%d searched=%d target-hit=%d cue-read=%d search/task=%.2f recall-tok/task=%.0f escape/task=%.2f",
f.Arm, f.Scored, f.Answered, f.Scored, f.Searched, f.TargetHit, f.CueReads,
per(f.SearchCalls), per(f.RecallTokens), per(f.EscapeCalls))
if f.Contaminated > 0 {
line += fmt.Sprintf(" CONTAMINATED=%d", f.Contaminated)
}
if f.Errored > 0 {
line += fmt.Sprintf(" ERRORED=%d", f.Errored)
}
return line
}
func writeJSONLine(w io.Writer, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
_, err = fmt.Fprintln(w, string(b))
return err
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// routing says where the model looked first, comparing rounds rather than
// event order. Same round is a parallel fan-out, not a preference.
func routing(recallRound, escapeRound int) string {
switch {
case recallRound == 0 && escapeRound == 0:
return routeNeither
case recallRound == 0:
return routeWorkspaceFirst
case escapeRound == 0:
return routeMemoryFirst
case recallRound < escapeRound:
return routeMemoryFirst
case escapeRound < recallRound:
return routeWorkspaceFirst
default:
return routeParallel
}
}
// ambiguousAnswer reports an answer naming more than one candidate of a
// codename's shape. Only codenames are checked: they carry a stem this run
// declared, so two distinct "fence-…" values in one answer is one answer too
// many. Numbers have no such shape and are left alone.
func ambiguousAnswer(final string, inst fixtureInstance) (bool, string) {
for _, spec := range inst.Task.Vars {
if spec.Kind != varCodename {
continue
}
seen := map[string]bool{}
for _, match := range regexp.MustCompile(regexp.QuoteMeta(spec.Word)+`-[a-z0-9]{3,}`).FindAllString(final, -1) {
seen[match] = true
}
if len(seen) > 1 {
return true, spec.Name
}
}
return false, ""
}
func allMarkersIn(text string, markers []string) bool {
if len(markers) == 0 {
return false
}
lower := strings.ToLower(text)
for _, marker := range markers {
if !strings.Contains(lower, strings.ToLower(marker)) {
return false
}
}
return true
}
// Stopping classes. The boundary is sufficiency, not the target coming back:
// a rank-1 hit whose snippet was too short to answer leaves a read entirely
// justified, and counting that as waste would blame the model for the bound.
const (
stopSnippet = "SnippetStop" // enough arrived, and the turn ended
stopSnippetRead = "SnippetThenRead" // the snippet was short; the read finished it
stopDefensiveRead = "DefensiveRead" // enough had arrived, one read confirmed it
stopPostRetrieval = "PostSufficientRetrieval" // enough had arrived, and retrieval continued
stopRunaway = "PostSufficientRunaway" // it continued for rounds, or ran out of budget
stopNeverEnough = "NeverSufficient"
)
// runawayRounds is where continuing stops looking like confirmation.
const runawayRounds = 3
func stoppingClass(m contextMetrics) string {
if m.FirstSufficientRound == 0 {
return stopNeverEnough
}
after := m.SearchesAfterSufficient + m.ReadsAfterSufficient + m.EscapesAfterSufficient
switch {
case after == 0 && m.SufficientVia == "snippet":
return stopSnippet
case after == 0:
return stopSnippetRead
case m.RoundsAfterSufficient >= runawayRounds || m.FailureStage == "RunError":
return stopRunaway
case after == 1 && m.ReadsAfterSufficient == 1:
return stopDefensiveRead
default:
return stopPostRetrieval
}
}
// reportStopping is the funnel that matters once retrieval succeeds: what the
// turn did after it already had everything it needed.
func reportStopping(all []contextMetrics) string {
classes := map[string]int{}
var extraRounds, extraTokens []float64
sufficient, answeredNext := 0, 0
for _, m := range all {
if m.Failed || m.Contaminated {
continue
}
classes[m.StoppingClass]++
if m.FirstSufficientRound == 0 {
continue
}
sufficient++
extraRounds = append(extraRounds, float64(m.RoundsAfterSufficient))
extraTokens = append(extraTokens, float64(m.RecallTokensAfterSufficient))
if m.RoundsAfterSufficient <= 1 {
answeredNext++
}
}
var b strings.Builder
b.WriteString("\n## After the evidence was already enough\n")
fmt.Fprintf(&b, " sufficient in %d runs; ended within one round in %d\n", sufficient, answeredNext)
fmt.Fprintf(&b, " extra rounds mean %.1f median %.1f p90 %.0f max %.0f\n",
mean(extraRounds), median(extraRounds), percentile(extraRounds, 0.9), maxOf(extraRounds))
fmt.Fprintf(&b, " extra recall tokens mean %.0f median %.0f p90 %.0f max %.0f\n",
mean(extraTokens), median(extraTokens), percentile(extraTokens, 0.9), maxOf(extraTokens))
b.WriteString(" " + countsLine(classes) + "\n")
return b.String()
}
@@ -1,337 +0,0 @@
package main
import (
"fmt"
"strings"
"testing"
"reasonix/internal/contract/provider"
)
// The scorer decides what the pilot concludes, so it is held to the same rule
// the corpus is: judgements read structure. A model that says it will search
// has not searched; a recall call carrying a query has.
const scorerTarget = 42
func recallCall(id, args string) provider.Message {
return provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: id, Name: "recall", Arguments: args},
}}
}
func toolResult(id, text string) provider.Message {
return provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "recall", Content: text}
}
func hitBlock(positions ...int) string {
var b strings.Builder
b.WriteString("Folded context matching \"x\":\n")
for _, p := range positions {
fmt.Fprintf(&b, "\n#%d assistant_text\nsome snippet here\n", p)
}
return b.String()
}
func scorerTask() fixtureInstance {
return fixtureInstance{Task: contextTask{ID: "t"}, AnswerMarkers: []string{"cobalt-lark-17"}}
}
func score(t *testing.T, msgs []provider.Message, final string) contextMetrics {
t.Helper()
m := scoreRun(msgs, scorerTask(), scorerTarget, "arm", false)
m.scoreAnswer(final, scorerTask())
return m
}
// The whole funnel, walked end to end.
func TestScorerReadsTheFullRetrievalChain(t *testing.T) {
m := score(t, []provider.Message{
recallCall("c1", `{"query":"retry boundary"}`),
toolResult("c1", hitBlock(7, scorerTarget)),
recallCall("c2", fmt.Sprintf(`{"positions":[%d]}`, scorerTarget)),
toolResult("c2", "#42\n[assistant]\nthe ownership token is cobalt-lark-17"),
{Role: provider.RoleAssistant, Content: "The token was cobalt-lark-17."},
}, "The token was cobalt-lark-17.")
if m.SearchCalls != 1 || m.ReadCalls != 1 {
t.Errorf("calls = %d search / %d read, want 1/1", m.SearchCalls, m.ReadCalls)
}
if m.TargetSearchHits != 1 || m.FirstTargetRank != 2 {
t.Errorf("target hit %d at rank %d, want 1 at rank 2", m.TargetSearchHits, m.FirstTargetRank)
}
if !m.TargetRead || !m.ReadAfterHit || m.RecallReadWithoutSearch {
t.Errorf("read flags = read %v afterHit %v direct %v", m.TargetRead, m.ReadAfterHit, m.RecallReadWithoutSearch)
}
if !m.AnswerRecovered || m.FailureStage != stageRecovered {
t.Errorf("stage = %q recovered=%v, want Recovered", m.FailureStage, m.AnswerRecovered)
}
if m.RecallReturnedTokens == 0 {
t.Error("recall returned tokens were not counted")
}
}
// An index arm's claim: the address was already on screen, so no search was
// needed. That is a different outcome from finding it, and gets its own stage.
func TestScorerSeparatesCueReadFromSearch(t *testing.T) {
m := score(t, []provider.Message{
recallCall("c1", fmt.Sprintf(`{"positions":[%d]}`, scorerTarget)),
toolResult("c1", "#42\nthe ownership token is cobalt-lark-17"),
{Role: provider.RoleAssistant, Content: "cobalt-lark-17"},
}, "cobalt-lark-17")
if m.SearchCalls != 0 || !m.RecallReadWithoutSearch {
t.Errorf("search=%d direct=%v, want 0 and true", m.SearchCalls, m.RecallReadWithoutSearch)
}
if m.FailureStage != stageCueRead {
t.Errorf("stage = %q, want %q", m.FailureStage, stageCueRead)
}
}
// Every way the chain can break gets its own label, so a failed run says where
// rather than only that.
func TestScorerNamesWhereTheChainBroke(t *testing.T) {
for _, tc := range []struct {
name string
msgs []provider.Message
final string
want string
}{
{
name: "never touched recall",
msgs: []provider.Message{{Role: provider.RoleAssistant, Content: "I do not recall that."}},
final: "I do not recall that.",
want: stageNoRetrieval,
},
{
name: "searched and missed",
msgs: []provider.Message{
recallCall("c1", `{"query":"unrelated words"}`),
toolResult("c1", hitBlock(3, 9)),
},
final: "not found",
want: stageSearchMiss,
},
{
name: "found it and never opened it",
msgs: []provider.Message{
recallCall("c1", `{"query":"retry boundary"}`),
toolResult("c1", hitBlock(scorerTarget)),
},
final: "probably something about retries",
want: stageHitNotRead,
},
{
name: "read it and answered wrong",
msgs: []provider.Message{
recallCall("c1", fmt.Sprintf(`{"positions":[%d]}`, scorerTarget)),
toolResult("c1", "#42\nthe token is elsewhere"),
},
final: "I think it was amber-9",
want: stageAnswerWrong,
},
{
name: "read the wrong position",
msgs: []provider.Message{
recallCall("c1", `{"positions":[7]}`),
toolResult("c1", "#7\nnothing useful"),
},
final: "unclear",
want: stageReadMissed,
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := score(t, tc.msgs, tc.final).FailureStage; got != tc.want {
t.Errorf("stage = %q, want %q", got, tc.want)
}
})
}
}
// The answer lives only in folded history, so reaching for the workspace is a
// strategy change worth seeing — including when it happens to work.
func TestScorerRecordsWorkspaceEscapes(t *testing.T) {
m := score(t, []provider.Message{
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "g1", Name: "bash", Arguments: `{"command":"rg cobalt-lark"}`},
{ID: "g2", Name: "read_file", Arguments: `{"path":"notes.md"}`},
}},
{Role: provider.RoleAssistant, Content: "cobalt-lark-17"},
}, "cobalt-lark-17")
if len(m.UnexpectedWorkTools) != 2 {
t.Fatalf("escapes = %v, want bash and read_file", m.UnexpectedWorkTools)
}
if m.SearchCalls != 0 || m.ReadCalls != 0 {
t.Errorf("a workspace tool was counted as recall: search=%d read=%d", m.SearchCalls, m.ReadCalls)
}
// It answered, so the run is Recovered — the escape is reported beside the
// outcome rather than overriding it.
if !m.AnswerRecovered {
t.Error("a correct answer was not scored as recovered")
}
}
// Narration is not action.
func TestScorerIgnoresWhatTheModelSaysItWillDo(t *testing.T) {
m := score(t, []provider.Message{
{Role: provider.RoleAssistant, Content: "Let me search the folded context with recall for the retry boundary."},
}, "Let me search the folded context with recall for the retry boundary.")
if m.SearchCalls != 0 || m.FailureStage != stageNoRetrieval {
t.Errorf("prose was scored as a search: calls=%d stage=%q", m.SearchCalls, m.FailureStage)
}
}
// Every marker must land, so a partially right answer is not a recovery.
func TestScorerRequiresEveryMarker(t *testing.T) {
task := fixtureInstance{Task: contextTask{ID: "t"}, AnswerMarkers: []string{"comet-42", "per-lineage"}}
m := scoreRun(nil, task, scorerTarget, "arm", false)
m.scoreAnswer("the salt was comet-42", task)
if m.AnswerRecovered {
t.Error("a half answer scored as recovered")
}
if len(m.MissingMarkers) != 1 || m.MissingMarkers[0] != "per-lineage" {
t.Errorf("missing = %v, want per-lineage", m.MissingMarkers)
}
}
// An answer reachable through anything but recall invalidates the run. The
// workspace is isolated so this should never fire; asserting it beats trusting
// it, and a tripped run leaves the statistics rather than inflating one.
func TestScorerInvalidatesRunsThatLeakedThroughAnotherTool(t *testing.T) {
m := score(t, []provider.Message{
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "g1", Name: "bash", Arguments: `{"command":"rg -r ."}`},
}},
{Role: provider.RoleTool, ToolCallID: "g1", Name: "bash", Content: "corpus.go: the token is cobalt-lark-17"},
{Role: provider.RoleAssistant, Content: "cobalt-lark-17"},
}, "cobalt-lark-17")
if !m.Contaminated || m.LeakedVia != "bash" || m.LeakedMarker != "cobalt-lark-17" {
t.Fatalf("leak not caught: contaminated=%v via=%q marker=%q", m.Contaminated, m.LeakedVia, m.LeakedMarker)
}
if m.FailureStage != stageContaminated {
t.Errorf("stage = %q, want %q even though every marker matched", m.FailureStage, stageContaminated)
}
f := summarize("arm", []contextMetrics{m})
if f.Scored != 0 || f.Answered != 0 || f.Contaminated != 1 {
t.Errorf("a contaminated run entered the statistics: %+v", f)
}
}
// Where an escape happened matters: before any recall is a different strategy
// from after one came back short.
func TestScorerPlacesEscapesRelativeToRecall(t *testing.T) {
m := score(t, []provider.Message{
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "g1", Name: "bash", Arguments: `{"command":"ls"}`},
}},
{Role: provider.RoleTool, ToolCallID: "g1", Name: "bash", Content: "README.md"},
recallCall("c1", `{"query":"retry boundary"}`),
toolResult("c1", hitBlock(scorerTarget)),
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "g2", Name: "read_file", Arguments: `{"path":"README.md"}`},
}},
{Role: provider.RoleTool, ToolCallID: "g2", Name: "read_file", Content: "nothing here"},
}, "unclear")
if m.EscapeCalls != 2 || m.EscapeBeforeFirstRecall != 1 || m.EscapeAfterFirstRecall != 1 {
t.Errorf("escapes = %d (%d before, %d after), want 2 (1, 1)",
m.EscapeCalls, m.EscapeBeforeFirstRecall, m.EscapeAfterFirstRecall)
}
}
// Routing is judged by round. Two calls in one round are a fan-out, and
// reporting that as a preference would invent a decision the model never made.
func TestRoutingIsJudgedByRoundNotEventOrder(t *testing.T) {
parallel := []provider.Message{
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "g1", Name: "bash", Arguments: `{"command":"ls"}`},
{ID: "c1", Name: "recall", Arguments: `{"query":"retry boundary"}`},
}},
{Role: provider.RoleTool, ToolCallID: "g1", Name: "bash", Content: "README.md"},
toolResult("c1", hitBlock(scorerTarget)),
}
if got := score(t, parallel, "unclear").Routing; got != routeParallel {
t.Errorf("routing = %q for one round holding both, want %q", got, routeParallel)
}
workspaceFirst := []provider.Message{
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "g1", Name: "bash", Arguments: `{"command":"ls"}`},
}},
{Role: provider.RoleTool, ToolCallID: "g1", Name: "bash", Content: "README.md"},
recallCall("c1", `{"query":"retry boundary"}`),
toolResult("c1", hitBlock(scorerTarget)),
}
if got := score(t, workspaceFirst, "unclear").Routing; got != routeWorkspaceFirst {
t.Errorf("routing = %q for workspace in an earlier round, want %q", got, routeWorkspaceFirst)
}
memoryFirst := []provider.Message{
recallCall("c1", `{"query":"retry boundary"}`),
toolResult("c1", hitBlock(scorerTarget)),
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: "g1", Name: "bash", Arguments: `{"command":"ls"}`},
}},
{Role: provider.RoleTool, ToolCallID: "g1", Name: "bash", Content: "README.md"},
}
if got := score(t, memoryFirst, "unclear").Routing; got != routeMemoryFirst {
t.Errorf("routing = %q for recall in an earlier round, want %q", got, routeMemoryFirst)
}
if got := score(t, nil, "no idea").Routing; got != routeNeither {
t.Errorf("routing = %q with no tools at all, want %q", got, routeNeither)
}
}
// Markers matching proves the right fact appears somewhere. An answer that
// hedges between two candidates contains it and has not submitted it.
func TestScorerFlagsAnAnswerThatNamesTwoCandidates(t *testing.T) {
inst := fixtureInstance{
Task: contextTask{ID: "t", Vars: []varSpec{code("marker", "fence")}},
AnswerMarkers: []string{"fence-qmwnv"},
}
m := scoreRun(nil, inst, 0, "arm", false)
m.scoreAnswer("It was fence-qmwnv, though it may have been fence-abcde.", inst)
if !m.AnswerRecovered {
t.Error("the marker is present; recovery should still read true")
}
if !m.AnswerAmbiguous || m.AmbiguousVar != "marker" {
t.Errorf("ambiguity not flagged: %v %q", m.AnswerAmbiguous, m.AmbiguousVar)
}
clean := scoreRun(nil, inst, 0, "arm", false)
clean.scoreAnswer("The marker was fence-qmwnv.", inst)
if clean.AnswerAmbiguous {
t.Error("a single candidate was called ambiguous")
}
}
// The index arm's claim is narrow: the cue was addressed, the first memory
// action was a positions read, and it covered the target. A positions call
// after a search located the target credits the index for the search's work.
func TestCueDirectReadRequiresTheCueAndTheFirstAction(t *testing.T) {
direct := []provider.Message{
recallCall("c1", fmt.Sprintf(`{"positions":[%d]}`, scorerTarget)),
toolResult("c1", "#42\nthe token is cobalt-lark-17"),
}
m := scoreRun(direct, scorerTask(), scorerTarget, "arm", true)
if !m.CueDirectRead {
t.Error("a first-action read with the cue visible was not credited")
}
// Same read, but no cue was on screen to give the address.
if scoreRun(direct, scorerTask(), scorerTarget, "arm", false).CueDirectRead {
t.Error("a read was credited to a cue that was not visible")
}
// Same read, but a search found the address first.
afterSearch := append([]provider.Message{
recallCall("c0", `{"query":"retry boundary"}`),
toolResult("c0", hitBlock(scorerTarget)),
}, direct...)
if scoreRun(afterSearch, scorerTask(), scorerTarget, "arm", true).CueDirectRead {
t.Error("a read after a search was credited to the index")
}
}
-469
View File
@@ -1,469 +0,0 @@
package main
import (
"fmt"
"math/rand"
"reasonix/internal/state/sessionstore"
"reasonix/internal/contract/provider"
)
// Synthetic history on purpose: an answer that exists in the workspace is
// solvable by reading the workspace, which measures grep. Every answer is a
// placeholder here and a value only inside one run.
// experiment names which question a task belongs to.
const (
experimentSearch = "search" // does search reach what no index addresses?
experimentIndex = "index" // what is an ambient address line worth?
)
// cueTier is the narrowest fold-index scale at which an index task's cue is
// still addressed. Below it the model has to find the call without a hint.
const (
tierQuarter = "quarter" // visible at default, half, quarter
tierHalf = "half" // visible at default, half
tierDefault = "default" // visible at default only
)
// role places an index task in the efficiency substrate or the stopping corpus.
const (
roleEfficiency = "efficiency"
roleStopping = "stopping"
)
// plantKind is how a task's target enters the transcript.
const (
plantAssistant = "assistant" // the model's own account: never index-addressed
plantCall = "call" // a tool call and its result: always index-addressed
)
// contextTask is one recall question as a template. Nothing here is an answer.
type contextTask struct {
ID string
Experiment string
TargetKind string
Prompt string
ProbeQuery string
// Vars declare the placeholders. AnswerVars name the ones a correct answer
// must contain, CueVar the one an index line carries.
Vars []varSpec
AnswerVars []string
CueVar string
CueTier string
// Role is roleEfficiency or roleStopping; it decides the batch a task enters.
Role string
// PlantAfterGen ages the target: mergeFoldIndex trims from its oldest end,
// so what follows a cue decides which budget addresses it. Frozen, because
// a run that searched for it would let the tested code move its goalposts.
PlantAfterGen int
// Plant is how the target enters, and the rest is what it says.
Plant string
CallTool string
CallArgs string
CallID string
PlantBody string
}
// fixtureInstance is one task with values. Scoring binds to this, never to the
// corpus: the corpus has no answers to bind to.
type fixtureInstance struct {
Task contextTask
Vars fixtureVars
Prompt string
ProbeQuery string
AnswerMarkers []string
CueMarker string
body string
callArgs string
}
// instantiateTask resolves one task against a generator.
func instantiateTask(t contextTask, rng *rand.Rand) (fixtureInstance, error) {
vars := newVars(t.Vars, rng)
inst := fixtureInstance{Task: t, Vars: vars}
var err error
for _, pair := range []struct {
dst *string
text string
}{
{&inst.Prompt, t.Prompt},
{&inst.ProbeQuery, t.ProbeQuery},
{&inst.body, t.PlantBody},
{&inst.callArgs, t.CallArgs},
} {
if *pair.dst, err = instantiate(pair.text, vars); err != nil {
return fixtureInstance{}, fmt.Errorf("%s: %w", t.ID, err)
}
}
for _, name := range t.AnswerVars {
value, ok := vars[name]
if !ok {
return fixtureInstance{}, fmt.Errorf("%s: answer var %q is not declared", t.ID, name)
}
inst.AnswerMarkers = append(inst.AnswerMarkers, value)
}
if t.CueVar != "" {
inst.CueMarker = vars[t.CueVar]
}
return inst, nil
}
// plant appends the instantiated target and returns its canonical position.
func (f fixtureInstance) plant(s *sessionstore.Session) int {
at := len(s.Messages)
if f.Task.Plant == plantAssistant {
s.Add(provider.Message{Role: provider.RoleAssistant, Content: f.body})
return at
}
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: f.Task.CallID, Name: f.Task.CallTool, Arguments: f.callArgs},
}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: f.Task.CallID, Name: f.Task.CallTool, Content: f.body})
return at
}
func code(name, word string) varSpec { return varSpec{Name: name, Kind: varCodename, Word: word} }
func num(name string, lo, hi int) varSpec {
return varSpec{Name: name, Kind: varInt, Min: lo, Max: hi}
}
// efficiencyTasks is the substrate a study measures on. Every task in it
// qualifies under the corpus bar; the stopping corpus is deliberately excluded,
// because a task carrying post-sufficient stopping behaviour swamps the effect
// an efficiency batch exists to measure.
func efficiencyTasks() []contextTask {
var out []contextTask
for _, t := range indexTasks() {
if t.Role == roleEfficiency {
out = append(out, t)
}
}
return out
}
func searchTasks() []contextTask {
return []contextTask{
{
ID: "r01-retry-boundary", Experiment: experimentSearch, TargetKind: "assistant_reasoning",
Prompt: "What ownership token did we settle on for the retry boundary, and under what tag did we file the reason for not moving retries above framing?",
ProbeQuery: "retry boundary ownership token framing",
Vars: []varSpec{code("token", "cobalt"), code("reason", "stream")},
AnswerVars: []string{"token", "reason"},
Plant: plantAssistant,
PlantBody: "The retry boundary remains at ingress. The ownership token is {{token}}. " +
"Moving retries above framing can duplicate a partially received stream, which we filed as {{reason}}.",
},
{
ID: "r02-checkpoint-anchor", Experiment: experimentSearch, TargetKind: "assistant_reasoning",
Prompt: "What was the codename of the checkpoint anchor policy, and what tag did we give the reason for not anchoring against digest positions?",
ProbeQuery: "checkpoint anchor policy digest positions",
Vars: []varSpec{code("policy", "northglass"), code("reason", "genlocal")},
AnswerVars: []string{"policy", "reason"},
Plant: plantAssistant,
PlantBody: "The checkpoint anchor policy is {{policy}}. Digest positions are generation-local — " +
"filed as {{reason}} — so recovery must anchor against canonical state.",
},
{
ID: "r03-recovery-cjk", Experiment: experimentSearch, TargetKind: "assistant_reasoning_cjk",
Prompt: "我们之前定的验证失败恢复策略代号叫什么?当时把“失败 digest 不能变成 baseline”这条登记在哪个标记下?",
ProbeQuery: "恢复策略 baseline digest 验证失败",
Vars: []varSpec{code("policy", "songzhen"), code("rule", "trustbase")},
AnswerVars: []string{"policy", "rule"},
Plant: plantAssistant,
PlantBody: "恢复策略代号是 {{policy}}。验证失败以后必须保留旧基线,不能把新的 digest 晋升为 baseline," +
"因为失败证据不能成为后续比较的可信基准;这条规则登记为 {{rule}}。",
},
{
ID: "r04-lease-fence", Experiment: experimentSearch, TargetKind: "assistant_reasoning",
Prompt: "那次 lease fence 检查报告的诊断标记是什么?expected 和 observed epoch 分别是多少?",
ProbeQuery: "lease fence writer epoch mismatch",
Vars: []varSpec{
code("marker", "fence"), num("expected", 400, 900), num("observed", 100, 399),
},
AnswerVars: []string{"marker", "expected", "observed"},
Plant: plantAssistant,
PlantBody: "The lease fence check reported {{marker}}: expected writer epoch {{expected}}, " +
"observed {{observed}}. We left the fence in place.",
},
{
ID: "r05-schema-guard", Experiment: experimentSearch, TargetKind: "assistant_reasoning",
Prompt: "当时 schema guard 报告的标记是什么,超预算多少 token?",
ProbeQuery: "schema guard provider visible surface budget",
Vars: []varSpec{code("marker", "amber"), num("over", 120, 480)},
AnswerVars: []string{"marker", "over"},
Plant: plantAssistant,
PlantBody: "The schema guard reported {{marker}}: the provider-visible tool schema exceeded " +
"the allowed surface by {{over}} tokens. We trimmed a description instead.",
},
{
ID: "r06-shadow-probe", Experiment: experimentSearch, TargetKind: "assistant_reasoning",
Prompt: "我们那次 shadow probe 的 marker、p95 divergence 和 generation 分别是多少?",
ProbeQuery: "shadow probe divergence generation marker",
Vars: []varSpec{
code("marker", "violet"),
{Name: "divergence", Kind: varDecimal, Min: 110, Max: 890},
num("generation", 14, 60),
},
AnswerVars: []string{"marker", "divergence", "generation"},
Plant: plantAssistant,
PlantBody: "The shadow probe came back as marker={{marker}} with a p95 divergence of " +
"{{divergence}} at generation={{generation}}, which we judged within tolerance.",
},
}
}
// efficiencyIndexTasks is what a study measures on: two tasks per cue tier.
func efficiencyIndexTasks() []contextTask {
out := indexQuarterTasks()
out = append(out, indexHalfTasks()...)
out = append(out, indexDefaultTasks()...)
return out
}
// indexQuarterTasks holds the quarter-tier literals.
func indexQuarterTasks() []contextTask {
return []contextTask{
{
ID: "i37-dispatchlag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/dispatchlag,它打印出的 dispatch lag epoch 是多少?",
ProbeQuery: "dispatchlag dispatch lag epoch",
Vars: []varSpec{code("cue", "dispatchlag"), code("epoch", "cobalt")},
AnswerVars: []string{"epoch"}, CueVar: "cue", CueTier: tierQuarter,
PlantAfterGen: 4,
Plant: plantCall, CallID: "p37", CallTool: "bash",
CallArgs: `{"command":"./scripts/dispatchlag {{cue}}"}`,
PlantBody: "dispatch lag epoch = {{epoch}}",
},
{
ID: "i118-fencelag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里运行过 ./scripts/fencelag,它打印出的 fence lag 是多少?",
ProbeQuery: "fencelag fence lag",
Vars: []varSpec{code("cue", "fencelag"), code("v", "slate")},
AnswerVars: []string{"v"}, CueVar: "cue", CueTier: tierQuarter,
PlantAfterGen: 4,
Plant: plantCall, CallID: "x118", CallTool: "bash",
CallArgs: `{"command":"./scripts/fencelag {{cue}}"}`,
PlantBody: "fence lag = {{v}}",
},
}
}
// indexHalfTasks holds the half-tier literals.
func indexHalfTasks() []contextTask {
return []contextTask{
{
ID: "i70-ingestfloor", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/ingestfloor,它打印出的 route quota 是多少?",
ProbeQuery: "ingestfloor quota",
Vars: []varSpec{code("cue", "ingestfloor"), num("quota", 1200, 9800)},
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
PlantAfterGen: 2,
Plant: plantCall, CallID: "q70", CallTool: "bash",
CallArgs: `{"command":"./scripts/ingestfloor {{cue}}"}`,
PlantBody: "route quota = {{quota}}",
},
{
ID: "i106-budgetseal", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/budgetseal,它打印出的 route quota 是多少?",
ProbeQuery: "budgetseal route quota",
Vars: []varSpec{code("cue", "budgetseal"), code("quota", "amber")},
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
PlantAfterGen: 2,
Plant: plantCall, CallID: "h106", CallTool: "bash",
CallArgs: `{"command":"./scripts/budgetseal {{cue}}"}`,
PlantBody: "route quota = {{quota}}",
},
}
}
// indexDefaultTasks holds the default-tier literals.
func indexDefaultTasks() []contextTask {
return []contextTask{
{
ID: "i47-tenantlease", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/tenantlease,它打印出的 tenant lease(秒)是多少?",
ProbeQuery: "tenantlease tenant lease",
Vars: []varSpec{code("cue", "tenantlease"), {Name: "lease", Kind: varDuration, Min: 15, Max: 300}},
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
PlantAfterGen: 0,
Plant: plantCall, CallID: "p47", CallTool: "bash",
CallArgs: `{"command":"./scripts/tenantlease {{cue}}"}`,
PlantBody: "tenant lease = {{lease}}",
},
{
ID: "i112-lagquota", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
Prompt: "早前会话里执行过 ./scripts/lagquota,它打印出的 lagquota lease 是多少?",
ProbeQuery: "lagquota lagquota lease",
Vars: []varSpec{code("cue", "lagquota"), {Name: "lease", Kind: varDuration, Min: 20, Max: 420}},
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
PlantAfterGen: 0,
Plant: plantCall, CallID: "d112", CallTool: "bash",
CallArgs: `{"command":"./scripts/lagquota {{cue}}"}`,
PlantBody: "lagquota lease = {{lease}}",
},
}
}
// stoppingIndexTasks is the frozen study's six tasks, kept out of efficiency
// batches and measurable on their own with -task <id>.
func stoppingIndexTasks() []contextTask {
return []contextTask{
{
ID: "i01-transport-fallback", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "transport fallback probe 当时报告的 selector 是什么?",
ProbeQuery: "transport fallback probe selector",
Vars: []varSpec{code("cue", "tfprobe"), code("selector", "fern")},
AnswerVars: []string{"selector"}, CueVar: "cue", CueTier: tierQuarter,
PlantAfterGen: 4,
Plant: plantCall, CallID: "tf1", CallTool: "bash",
CallArgs: `{"command":"./scripts/probe {{cue}}"}`,
PlantBody: "fallback selector = {{selector}}",
},
{
ID: "i02-scheduler-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "scheduler handoff 当时记录的 grace 和 marker 是什么?",
ProbeQuery: "scheduler handoff grace marker",
Vars: []varSpec{
code("cue", "schedhandoff"), code("marker", "opal"),
{Name: "grace", Kind: varDuration, Min: 18, Max: 90},
},
AnswerVars: []string{"grace", "marker"}, CueVar: "cue", CueTier: tierQuarter,
PlantAfterGen: 4,
Plant: plantCall, CallID: "sh1", CallTool: "read_file",
CallArgs: `{"path":"scratch/{{cue}}.txt"}`,
PlantBody: "handoff grace = {{grace}}\nmarker = {{marker}}",
},
{
ID: "i03-coalescing", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "job completion 的 coalescing probe 最后记录的 window 和 marker 是多少?",
ProbeQuery: "coalescing policy completion window",
Vars: []varSpec{
code("cue", "coalescing"), code("marker", "cedar"),
{Name: "window", Kind: varDuration, Min: 40, Max: 140},
},
AnswerVars: []string{"window", "marker"}, CueVar: "cue", CueTier: tierHalf,
PlantAfterGen: 2,
Plant: plantCall, CallID: "cp1", CallTool: "bash",
CallArgs: `{"command":"grep {{cue}} scratch/job-notes.log"}`,
PlantBody: "completion coalesce window = {{window}}\nmarker = {{marker}}",
},
{
ID: "i04-recovery-fence", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "recovery fence 那次检查报告的 marker 和两个 epoch 是什么?",
ProbeQuery: "recovery fence epoch check",
Vars: []varSpec{
code("cue", "recfence"), code("marker", "quartz"),
num("expected", 400, 900), num("observed", 100, 399),
},
AnswerVars: []string{"marker", "expected", "observed"}, CueVar: "cue", CueTier: tierHalf,
PlantAfterGen: 2,
Plant: plantCall, CallID: "rf1", CallTool: "bash",
CallArgs: `{"command":"./scripts/check {{cue}}"}`,
PlantBody: "{{marker}}\nexpected epoch {{expected}}\nobserved epoch {{observed}}",
},
{
ID: "i05-cache-probe", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "cache probe 当时记录的 prefix salt 和 scope 是什么?",
ProbeQuery: "cache probe prefix salt scope",
Vars: []varSpec{code("cue", "cacheprobe"), code("salt", "comet"), code("scope", "lineage")},
AnswerVars: []string{"salt", "scope"}, CueVar: "cue", CueTier: tierDefault,
PlantAfterGen: 0,
Plant: plantCall, CallID: "cb1", CallTool: "read_file",
CallArgs: `{"path":"scratch/{{cue}}.txt"}`,
PlantBody: "prefix salt = {{salt}}\nscope = {{scope}}",
},
{
ID: "i06-dispatch-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
Prompt: "dispatch handoff probe 当时确认的 mode 和 marker 是什么?",
ProbeQuery: "dispatch handoff probe mode",
Vars: []varSpec{code("cue", "dispatchhandoff"), code("mode", "trigger"), code("marker", "iris")},
AnswerVars: []string{"mode", "marker"}, CueVar: "cue", CueTier: tierDefault,
PlantAfterGen: 0,
Plant: plantCall, CallID: "dh1", CallTool: "bash",
CallArgs: `{"command":"./scripts/probe {{cue}}"}`,
PlantBody: "handoff mode = {{mode}}\nmarker = {{marker}}",
},
}
}
// indexTasks is the whole index corpus, the efficiency substrate first.
func indexTasks() []contextTask {
out := efficiencyIndexTasks()
return append(out, stoppingIndexTasks()...)
}
func allTasks() []contextTask {
return append(searchTasks(), indexTasks()...)
}
func taskByID(id string) (contextTask, bool) {
for _, t := range allTasks() {
if t.ID == id {
return t, true
}
}
return contextTask{}, false
}
// tierScales is the fixture's contract: which arms must address a cue and
// which must not. A policy change that moves a cue across a boundary fails
// preflight rather than turning two arms into one experiment.
func tierScales(tier string) (visible, hidden []string) {
switch tier {
case tierQuarter:
return []string{"default", "half", "quarter"}, []string{"off"}
case tierHalf:
return []string{"default", "half"}, []string{"quarter", "off"}
case tierDefault:
return []string{"default"}, []string{"half", "quarter", "off"}
default:
return nil, nil
}
}
// boundaryPair is the one comparison an index task is built for: the narrowest
// scale that still addresses its cue, and the next one down. Holding the
// question fixed and moving only the affordance is what four arm averages
// cannot do, since each of those mixes six different questions.
func boundaryPair(tier string) (cue, noCue string) {
switch tier {
case tierQuarter:
return "quarter", "off"
case tierHalf:
return "half", "quarter"
case tierDefault:
return "default", "half"
default:
return "", ""
}
}
func validateCorpus() error {
seen := map[string]bool{}
for _, t := range allTasks() {
if seen[t.ID] {
return fmt.Errorf("duplicate task id %q", t.ID)
}
seen[t.ID] = true
if len(t.AnswerVars) == 0 || t.ProbeQuery == "" || t.Prompt == "" || t.PlantBody == "" {
return fmt.Errorf("%s: a task needs a prompt, a probe query, a body and answer vars", t.ID)
}
if t.Experiment == experimentIndex {
if t.CueVar == "" {
return fmt.Errorf("%s: an index task needs a cue var", t.ID)
}
if t.Role != roleEfficiency && t.Role != roleStopping {
return fmt.Errorf("%s: an index task needs a role (%s or %s)", t.ID, roleEfficiency, roleStopping)
}
if v, _ := tierScales(t.CueTier); v == nil {
return fmt.Errorf("%s: unknown cue tier %q", t.ID, t.CueTier)
}
}
if _, err := instantiateTask(t, seededRand(t.ID, "validate")); err != nil {
return err
}
}
return nil
}
-243
View File
@@ -1,243 +0,0 @@
// dose.go — does the cost step where the cue disappears?
package main
import (
"fmt"
"math"
"sort"
"strings"
)
// Not "a bigger index is better" — CueDirectRead says the addresses are rarely
// used. The hypothesis is that a visible action cue lowers retrieval cost,
// which predicts a step at each task's own boundary, not a slope.
// scaleOrder is the dose axis, widest first.
var scaleOrder = []string{"default", "half", "quarter", "off"}
// boundaryStep is the index into the three transitions where a tier's cue
// disappears: default->half is 0, half->quarter is 1, quarter->off is 2.
func boundaryStep(tier string) int {
switch tier {
case tierDefault:
return 0
case tierHalf:
return 1
case tierQuarter:
return 2
default:
return -1
}
}
// doseCurve is one task's readings across the four budgets.
type doseCurve struct {
Task string
Tier string
Metric string
Values [4]float64
// Missing marks arms that produced nothing. Printing those as zero draws a
// curve falling to the floor where in fact it was never measured.
Missing [4]bool
Present bool
// PreMean averages the arms where the cue is visible, PostMean the rest.
PreMean, PostMean float64
BoundaryDelta float64
// BoundaryAligned: the largest transition is the one where this task's cue
// disappears. A change elsewhere is variance shaped like an effect.
BoundaryAligned bool
}
func buildCurve(task, tier, metric string, byScale map[string]float64) doseCurve {
c := doseCurve{Task: task, Tier: tier, Metric: metric, Present: true}
for i, scale := range scaleOrder {
v, ok := byScale[scale]
if !ok {
c.Present, c.Missing[i] = false, true
}
c.Values[i] = v
}
step := boundaryStep(tier)
if !c.Present || step < 0 {
return c
}
visible := step + 1 // arms before the boundary still show the cue
c.PreMean = mean(c.Values[:visible])
c.PostMean = mean(c.Values[visible:])
c.BoundaryDelta = c.PostMean - c.PreMean
transitions := [3]float64{
math.Abs(c.Values[1] - c.Values[0]),
math.Abs(c.Values[2] - c.Values[1]),
math.Abs(c.Values[3] - c.Values[2]),
}
largest, at := 0.0, -1
for i, t := range transitions {
if t > largest {
largest, at = t, i
}
}
// A flat curve has no largest transition to align with anything.
c.BoundaryAligned = largest > 0 && at == step
return c
}
func mean(xs []float64) float64 {
if len(xs) == 0 {
return 0
}
sum := 0.0
for _, x := range xs {
sum += x
}
return sum / float64(len(xs))
}
func median(xs []float64) float64 {
if len(xs) == 0 {
return 0
}
s := append([]float64(nil), xs...)
sort.Float64s(s)
mid := len(s) / 2
if len(s)%2 == 1 {
return s[mid]
}
return (s[mid-1] + s[mid]) / 2
}
// doseMetrics are the readings a curve is drawn for.
var doseMetrics = []struct {
name string
of func(contextMetrics) float64
}{
{"searches", func(m contextMetrics) float64 { return float64(m.SearchCalls) }},
{"recall-tokens", func(m contextMetrics) float64 { return float64(m.RecallReturnedTokens) }},
{"tool-rounds", func(m contextMetrics) float64 { return float64(m.RetrievalRounds + m.EscapeCalls) }},
{"escapes", func(m contextMetrics) float64 { return float64(m.EscapeCalls) }},
}
// reportDoseResponse draws each task's curve and reports where the steps fell.
func reportDoseResponse(all []contextMetrics) string {
valid := map[string]map[string]contextMetrics{}
for _, m := range all {
if m.Failed || m.Contaminated || m.FailureStage == "RunError" {
continue
}
scale := strings.TrimPrefix(m.Arm, "index-")
if valid[m.Task] == nil {
valid[m.Task] = map[string]contextMetrics{}
}
valid[m.Task][scale] = m
}
var b strings.Builder
b.WriteString("\n## Dose-response, per task\n")
b.WriteString(" The arrow marks where this task's cue stops being addressed.\n")
aligned := map[string]int{}
counted := map[string]int{}
deltas := map[string][]float64{}
for _, t := range indexTasks() {
arms := valid[t.ID]
if len(arms) == 0 {
continue
}
step := boundaryStep(t.CueTier)
fmt.Fprintf(&b, "\n %s (cue survives to %s)\n", t.ID, t.CueTier)
fmt.Fprintf(&b, " %-14s%s\n", "", scaleHeader(step))
for _, metric := range doseMetrics {
byScale := map[string]float64{}
for scale, m := range arms {
byScale[scale] = metric.of(m)
}
c := buildCurve(t.ID, t.CueTier, metric.name, byScale)
mark := " "
if c.BoundaryAligned {
mark = "*"
aligned[metric.name]++
}
if c.Present {
counted[metric.name]++
deltas[metric.name] = append(deltas[metric.name], c.BoundaryDelta)
}
delta := fmt.Sprintf("Δ%+8.1f", c.BoundaryDelta)
if !c.Present {
delta = " no curve"
}
fmt.Fprintf(&b, " %-14s%s %s %s\n", metric.name, curveCells(c), delta, mark)
}
}
b.WriteString("\n## Where the step fell (* = largest transition is at the cue boundary)\n")
for _, metric := range doseMetrics {
d := deltas[metric.name]
sensitivity := ""
if len(d) > 1 {
sensitivity = fmt.Sprintf(" without the largest: %+.1f", meanWithoutLargest(d))
}
fmt.Fprintf(&b, " %-14s boundary-aligned %d/%d mean Δ%+.1f median Δ%+.1f%s\n",
metric.name, aligned[metric.name], counted[metric.name], mean(d), median(d), sensitivity)
}
return b.String()
}
// meanWithoutLargest is the robustness check: the largest effect is reported
// and then set aside, so a conclusion never rests on one task alone.
func meanWithoutLargest(d []float64) float64 {
if len(d) < 2 {
return mean(d)
}
worst, at := 0.0, 0
for i, v := range d {
if math.Abs(v) > worst {
worst, at = math.Abs(v), i
}
}
rest := append(append([]float64(nil), d[:at]...), d[at+1:]...)
return mean(rest)
}
func scaleHeader(step int) string {
var b strings.Builder
for i, s := range scaleOrder {
if i == step+1 {
s = "↓" + s
}
fmt.Fprintf(&b, "%8s", s)
}
return b.String()
}
// curveCells prints a reading per arm, and a dash where an arm produced none.
func curveCells(c doseCurve) string {
var b strings.Builder
for i, v := range c.Values {
if c.Missing[i] {
fmt.Fprintf(&b, "%8s", "-")
continue
}
fmt.Fprintf(&b, "%8.0f", v)
}
return b.String()
}
func percentile(xs []float64, q float64) float64 {
if len(xs) == 0 {
return 0
}
s := append([]float64(nil), xs...)
sort.Float64s(s)
i := int(float64(len(s)-1) * q)
return s[i]
}
func maxOf(xs []float64) float64 {
out := 0.0
for _, x := range xs {
if x > out {
out = x
}
}
return out
}
@@ -1,306 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
)
// blockTask makes one task fail before its first model turn: its own directory
// is a regular file, so buildFixture cannot create the session under it. It is
// the same early exit as a failed fixture build, session load, or
// answer-on-disk guard, and it needs no provider.
func blockTask(t *testing.T, root, id string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, id), []byte("not a directory\n"), 0o644); err != nil {
t.Fatal(err)
}
}
// mixedTaskPair is one task whose runs all fail and one whose runs all succeed.
// Both are drawn from the corpus, so the batch is the real pipeline.
func mixedTaskPair() (blocked, ok contextTask) {
blocked, ok = indexTasks()[0], indexTasks()[1]
for _, t := range indexTasks() {
if t.Role == roleEfficiency && len(armsFor(t)) == 2 {
ok = t
break
}
}
return blocked, ok
}
// runBlockedExperiment runs one dry index batch over the given tasks, with the
// named task failing before any model turn, and returns the batch directory, the
// runs, the manifest, and everything the run printed.
func runBlockedExperiment(t *testing.T, tasks []contextTask, blocked ...string) (string, []contextMetrics, batchManifest, string) {
t.Helper()
root := t.TempDir()
for _, id := range blocked {
blockTask(t, root, id)
}
out := captureStdout(t, func() {
if code := runExperiment("run-index", experimentIndex, root, true, tasks); code != 0 {
t.Errorf("runExperiment exit = %d, want 0 (the batch finished)", code)
}
})
runs, _ := readResultLines(t, root)
manifest, _ := readManifest(t, root)
return root, runs, manifest, out
}
// captureStdout reads everything the function prints. os.Pipe is unbuffered, so
// the reader must run beside the call.
func captureStdout(t *testing.T, f func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
old := os.Stdout
os.Stdout = w
done := make(chan string, 1)
go func() {
var b strings.Builder
buf := make([]byte, 4096)
for {
n, err := r.Read(buf)
b.Write(buf[:n])
if err != nil {
break
}
}
done <- b.String()
}()
f()
os.Stdout = old
w.Close()
text := <-done
r.Close()
return text
}
// funnelScored reads the "runs=N" column of the named arm's funnel line out of
// the printed tables, so the assertion is on the number a reader sees.
func funnelScored(t *testing.T, out, arm string) int {
t.Helper()
for line := range strings.SplitSeq(out, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 || fields[0] != arm || !strings.HasPrefix(fields[1], "runs=") {
continue
}
n, err := strconv.Atoi(strings.TrimPrefix(fields[1], "runs="))
if err != nil {
t.Fatalf("funnel line for %s: %v\n%s", arm, err, line)
}
return n
}
t.Fatalf("no funnel line for arm %s:\n%s", arm, out)
return 0
}
// The batch report states both counts on one line, so a reader of the scrollback
// sees the scored set and the failures without counting funnel rows.
func reportCounts(t *testing.T, out string) (scored, failed int) {
t.Helper()
for line := range strings.SplitSeq(out, "\n") {
if !strings.Contains(line, " scored=") || !strings.Contains(line, " failed=") {
continue
}
for f := range strings.FieldsSeq(line) {
switch {
case strings.HasPrefix(f, "scored="):
scored, _ = strconv.Atoi(strings.TrimPrefix(f, "scored="))
case strings.HasPrefix(f, "failed="):
failed, _ = strconv.Atoi(strings.TrimPrefix(f, "failed="))
}
}
return scored, failed
}
t.Fatalf("no line reports scored= and failed=:\n%s", out)
return 0, 0
}
// A batch that mixes a task that never ran with one that did must read as its
// successful runs only: the failed task leaves Scored, the funnel, and the
// completed count, and is reported as a failure with its identity instead.
// Every assertion here is a number, not the absence of an empty row.
func TestMixedBatchCountsOnlyTheRunThatProducedEvidence(t *testing.T) {
blocked, ok := mixedTaskPair()
_, runs, manifest, out := runBlockedExperiment(t, []contextTask{blocked, ok}, blocked.ID)
wantScored, wantFailed := len(armsFor(ok)), len(armsFor(blocked))
if manifest.CompletedRuns != wantScored || manifest.FailedRuns != wantFailed {
t.Fatalf("manifest completed=%d failed=%d, want %d and %d (planned=%d)",
manifest.CompletedRuns, manifest.FailedRuns, wantScored, wantFailed, manifest.PlannedRuns)
}
if len(runs) != manifest.CompletedRuns+manifest.FailedRuns {
t.Fatalf("results.jsonl has %d rows but completed=%d failed=%d",
len(runs), manifest.CompletedRuns, manifest.FailedRuns)
}
for _, id := range manifest.TaskIDs {
if id == blocked.ID {
t.Fatalf("task_ids lists %s, which produced no evidence: %v", blocked.ID, manifest.TaskIDs)
}
}
scored, failures := 0, 0
for _, m := range runs {
if m.Failed {
failures++
if m.Task != blocked.ID || m.FailureStage == "" || m.Reason == "" {
t.Fatalf("failed row lost its identity or stage: %+v", m)
}
continue
}
scored++
if m.Task != ok.ID {
t.Fatalf("a run scored from the wrong task: %+v", m)
}
}
if scored != wantScored || failures != wantFailed {
t.Fatalf("rows scored=%d failed=%d, want %d and %d", scored, failures, wantScored, wantFailed)
}
// Each arm's funnel holds exactly the successful task's runs, and the two
// scored counts agree with each other and with the manifest.
total := 0
for _, arm := range armsFor(ok) {
total += funnelScored(t, out, arm.name)
}
if total != manifest.CompletedRuns {
t.Fatalf("funnels scored %d runs, manifest completed_runs = %d:\n%s", total, manifest.CompletedRuns, out)
}
if gotScored, gotFailed := reportCounts(t, out); gotScored != manifest.CompletedRuns || gotFailed != manifest.FailedRuns {
t.Fatalf("report says scored=%d failed=%d, manifest says %d and %d",
gotScored, gotFailed, manifest.CompletedRuns, manifest.FailedRuns)
}
if strings.Contains(out, "## Where it broke") && strings.Contains(out, "=-\n") {
t.Fatalf("the stage table kept an empty stage:\n%s", out)
}
}
// A batch where every run fails must not read as an empty success: zero scored
// runs, a nonzero failed count, and both stated.
func TestAllFailedBatchReportsZeroScoredAndNonzeroFailed(t *testing.T) {
blocked, _ := mixedTaskPair()
_, runs, manifest, out := runBlockedExperiment(t, []contextTask{blocked}, blocked.ID)
if manifest.CompletedRuns != 0 {
t.Fatalf("completed_runs = %d, want 0 (nothing produced evidence)", manifest.CompletedRuns)
}
if manifest.FailedRuns != manifest.PlannedRuns || manifest.FailedRuns == 0 {
t.Fatalf("failed_runs = %d, planned_runs = %d: every planned run failed",
manifest.FailedRuns, manifest.PlannedRuns)
}
if len(manifest.TaskIDs) != 0 {
t.Fatalf("task_ids = %v, want none", manifest.TaskIDs)
}
for i, m := range runs {
if !m.Failed || m.FailureStage == "" || m.Task != blocked.ID {
t.Fatalf("results.jsonl line %d is not a marked failure: %+v", i+1, m)
}
}
total := 0
for _, arm := range armsFor(blocked) {
total += funnelScored(t, out, arm.name)
}
if total != 0 {
t.Fatalf("funnels scored %d runs although nothing ran:\n%s", total, out)
}
if gotScored, gotFailed := reportCounts(t, out); gotScored != 0 || gotFailed != manifest.FailedRuns {
t.Fatalf("report says scored=%d failed=%d, want 0 and %d", gotScored, gotFailed, manifest.FailedRuns)
}
}
// The control: with nothing blocked, a batch is what it always was. Every run
// is completed with a stage, two identical batches write byte-identical results
// and the same manifest header, and the new failure field stays out of it.
func TestAllSuccessfulBatchKeepsItsArtifacts(t *testing.T) {
tasks := indexTasks()
first, runs, manifest, out := runBlockedExperiment(t, tasks)
second, again, repeat, _ := runBlockedExperiment(t, tasks)
if manifest.CompletedRuns != len(runs) || manifest.FailedRuns != 0 {
t.Fatalf("completed=%d failed=%d rows=%d, want every row completed",
manifest.CompletedRuns, manifest.FailedRuns, len(runs))
}
if manifest.CompletedRuns != manifest.PlannedRuns {
t.Fatalf("completed=%d planned=%d, want all planned", manifest.CompletedRuns, manifest.PlannedRuns)
}
for i, m := range runs {
if m.Failed || m.Task == "" || m.Arm == "" || m.FailureStage == "" {
t.Fatalf("results.jsonl line %d is not a completed run: %+v", i+1, m)
}
}
if gotScored, gotFailed := reportCounts(t, out); gotScored != manifest.CompletedRuns || gotFailed != 0 {
t.Fatalf("report says scored=%d failed=%d, want %d and 0", gotScored, gotFailed, manifest.CompletedRuns)
}
if !reflect.DeepEqual(runs, again) {
t.Fatal("two identical successful batches scored differently")
}
// Byte-for-byte on the runs. Trajectories carry per-message wall-clock
// stamps, so they are compared field by field instead.
if got, want := readFile(t, second, "results.jsonl"), readFile(t, first, "results.jsonl"); got != want {
t.Fatalf("an unchanged batch wrote different results.jsonl:\n%s\n---\n%s", want, got)
}
gotTraj, wantTraj := withoutStamps(t, readFile(t, second, "trajectories.jsonl")),
withoutStamps(t, readFile(t, first, "trajectories.jsonl"))
if gotTraj != wantTraj {
t.Fatalf("an unchanged batch wrote different trajectories:\n%s\n---\n%s", wantTraj, gotTraj)
}
repeat.StartedAt, repeat.FinishedAt = manifest.StartedAt, manifest.FinishedAt
repeat.TrajectoriesSHA256 = manifest.TrajectoriesSHA256
if !reflect.DeepEqual(repeat, manifest) {
t.Fatalf("an unchanged batch wrote a different manifest:\n%+v\n---\n%+v", manifest, repeat)
}
if repeat.FailedRuns != 0 {
t.Fatalf("a batch with no failure reports failed_runs=%d", repeat.FailedRuns)
}
}
// withoutStamps drops the createdAt stamp every message carries, which is the
// one field a dry batch cannot reproduce between two runs.
func withoutStamps(t *testing.T, text string) string {
t.Helper()
var b strings.Builder
for line := range strings.SplitSeq(strings.TrimSpace(text), "\n") {
var row map[string]any
if err := json.Unmarshal([]byte(line), &row); err != nil {
t.Fatal(err)
}
stripStamp(row)
out, err := json.Marshal(row)
if err != nil {
t.Fatal(err)
}
b.Write(out)
b.WriteByte('\n')
}
return b.String()
}
func stripStamp(v any) {
switch typed := v.(type) {
case map[string]any:
delete(typed, "createdAt")
delete(typed, "workDurationMs")
for _, child := range typed {
stripStamp(child)
}
case []any:
for _, child := range typed {
stripStamp(child)
}
}
}
func readFile(t *testing.T, root, name string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(root, name))
if err != nil {
t.Fatal(err)
}
return string(data)
}
-205
View File
@@ -1,205 +0,0 @@
package main
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"reasonix/internal/state/sessionstore"
"strings"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/runtime/agent"
)
// A fixture is a real session that has really been compacted: no bench-only
// state shape, so the loader and the projection restore have to work on it.
const (
fixtureWindow = 60000
fixtureGenerations = 5
// fillerBytes per unit is what makes a fold necessary rather than optional.
fillerBytes = 6000
)
// digestProvider answers a summarizer without naming anything the corpus
// plants. A digest that happened to quote the answer would leave it visible
// and silently turn the task into a no-op.
type digestProvider struct{ calls int }
func (p *digestProvider) Name() string { return "fixture-digest" }
func (p *digestProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
p.calls++
ch := make(chan provider.Chunk, 2)
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Earlier work\nEarlier work continued across several files; nothing outstanding was recorded."}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
// fillerCall is one ordinary read whose only job is to take up room — in the
// window, and in the fold index ahead of a cue.
func fillerCall(sess *sessionstore.Session, n int) {
id := fmt.Sprintf("f%04d", n)
sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":"internal/mod%03d/handler%03d.go"}`, n%37, n)},
}})
sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: fillerBody(n)})
}
func fillerBody(n int) string {
var b strings.Builder
fmt.Fprintf(&b, "package mod%03d\n\n", n%37)
for b.Len() < fillerBytes {
fmt.Fprintf(&b, "func handler%03dStep%02d() error { return nil }\n", n, b.Len()%64)
}
return b.String()
}
// builtFixture is one task's history after real folds, plus where its target
// ended up.
type builtFixture struct {
Session *sessionstore.Session
State sessionstore.CompactionState
Target int
Path string
Instance fixtureInstance
}
// buildFixture plays a task's history through the real agent and folds it at
// the given arm, writing the session and its context state where the agent
// itself would.
func buildFixture(f fixtureInstance, arm ablation.Set, sessionPath string) (builtFixture, error) {
t := f.Task
sess := sessionstore.NewSession("You are a coding agent.")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "Work through the transport and scheduler backlog."})
n := 0
at := -1
a := agent.New(&digestProvider{}, tool.NewRegistry(), sess, agent.Options{
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
SessionPath: sessionPath, KeepPolicy: agent.KeepErrors, Ablation: arm,
}, event.Discard)
for gen := range fixtureGenerations {
if gen == t.PlantAfterGen {
at = f.plant(sess)
}
for range 8 {
fillerCall(sess, n)
n++
}
if _, err := a.CompactNow(context.Background(), agent.CompactRequest{}); err != nil {
return builtFixture{}, fmt.Errorf("%s: generation %d fold: %w", t.ID, gen, err)
}
}
if at < 0 {
return builtFixture{}, fmt.Errorf("%s: PlantAfterGen %d is past the last generation", t.ID, t.PlantAfterGen)
}
// projectionCoversTail cannot revalidate a projection covering the whole
// transcript: it needs a live tail, which a real session always has because
// work continues after a fold. Neutral text — the tail is provider-visible.
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "Backlog swept; ready for the next question."})
if err := sess.Save(sessionPath); err != nil {
return builtFixture{}, fmt.Errorf("%s: save session: %w", t.ID, err)
}
state, ok, err := sessionstore.LoadCompactionState(sessionPath)
if err != nil {
return builtFixture{}, fmt.Errorf("%s: load context state: %w", t.ID, err)
}
if !ok || len(state.Projection.Messages) == 0 {
return builtFixture{}, fmt.Errorf("%s: no projection was installed", t.ID)
}
if state.Projection.CoveredCount <= at {
return builtFixture{}, fmt.Errorf("%s: target #%d is not inside the folded region (covered %d)",
t.ID, at, state.Projection.CoveredCount)
}
return builtFixture{Session: sess, State: state, Target: at, Path: sessionPath, Instance: f}, nil
}
// visibleContext is the model-visible view: the stored projection spliced with
// everything canonical that came after it.
func visibleContext(f builtFixture) []provider.Message {
canonical := f.Session.Snapshot()
out := append([]provider.Message(nil), f.State.Projection.Messages...)
if n := f.State.Projection.CoveredCount; n >= 0 && n < len(canonical) {
out = append(out, canonical[n:]...)
}
return out
}
// visibleText is everything the model would read, as one string.
func visibleText(f builtFixture) string {
var b strings.Builder
for _, m := range visibleContext(f) {
b.WriteString(m.Content)
b.WriteString("\n")
for _, tc := range m.ToolCalls {
b.WriteString(tc.Name)
b.WriteString(" ")
b.WriteString(string(tc.Arguments))
b.WriteString("\n")
}
}
return b.String()
}
// sealFixture removes the fixture from disk once the agent holds it in memory.
// The canonical transcript is the answer, and the sandbox mounts the host
// read-only by design — so a model that finds the path reads the answer, which
// one did. The agent has already loaded the session and its sidecar by the time
// this runs; a failed autosave afterwards costs the benchmark nothing.
func sealFixture(sessionPath string) {
// Every file, not two named ones: a session writes an event log and a
// context sidecar beside the transcript, and session.events.jsonl still
// carried the answer after the other two were sealed by name.
dir := filepath.Dir(sessionPath)
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if !e.IsDir() {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
// Deny the directory too, so an autosave cannot recreate what was removed.
_ = os.Chmod(dir, 0o500)
}
// unsealFixture restores the directory so the harness can clean up after
// itself.
func unsealFixture(sessionPath string) {
_ = os.Chmod(filepath.Dir(sessionPath), 0o700)
}
// answerOnDisk reports any readable file under dir holding a current-run
// answer. Two named files were sealed because two leaks were found in them;
// this scans the directory, so a path that moves — an autosave, a recovery
// tombstone, a future sidecar — fails here instead of on a paid run.
func answerOnDisk(dir string, markers []string) (string, string) {
var atPath, found string
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || atPath != "" {
return nil //nolint:nilerr // an unreadable entry is not a leak
}
body, readErr := os.ReadFile(path)
if readErr != nil {
return nil
}
for _, marker := range markers {
if len(marker) >= 6 && strings.Contains(string(body), marker) {
atPath, found = path, marker
return filepath.SkipAll
}
}
return nil
})
return atPath, found
}
-139
View File
@@ -1,139 +0,0 @@
// instance.go — a task template becomes one run's concrete history.
package main
import (
"crypto/rand"
"fmt"
"hash/fnv"
"io"
mathrand "math/rand"
"strings"
)
// The invariant this file exists for: no literal sufficient to satisfy
// AnswerRecovered for a concrete run may exist in this repository before that
// run begins. Grepping the corpus reveals the question, never the answer.
// varKind is how one placeholder's value is generated. Values stay shaped like
// real engineering records — a UUID would defeat the leak but turn every task
// into "find the random string".
type varKind string
const (
varCodename varKind = "codename" // cobalt-k7m4q9
varInt varKind = "int" // 731
varDecimal varKind = "decimal" // 0.0437
varDuration varKind = "duration" // 43ms
)
// varSpec declares one placeholder. Min/Max bound the numeric kinds.
type varSpec struct {
Name string
Kind varKind
Word string // codename stem, e.g. "cobalt"
Min int
Max int
}
// codenameEntropy is how much of a codename is unguessable. Five base32 digits
// is about 25 bits: far past what a model can reach by knowing the stem.
const codenameEntropy = 5
const codenameAlphabet = "abcdefghijkmnpqrstuvwxyz23456789"
// fixtureVars is one run's instantiated values.
type fixtureVars map[string]string
// newVars generates values for every spec. A nil rng means deterministic.
func newVars(specs []varSpec, rng *mathrand.Rand) fixtureVars {
out := fixtureVars{}
for _, spec := range specs {
switch spec.Kind {
case varCodename:
out[spec.Name] = spec.Word + "-" + randomToken(rng, codenameEntropy)
case varInt:
out[spec.Name] = fmt.Sprint(randomBetween(rng, spec.Min, spec.Max))
case varDecimal:
out[spec.Name] = fmt.Sprintf("0.%04d", randomBetween(rng, spec.Min, spec.Max))
case varDuration:
out[spec.Name] = fmt.Sprintf("%dms", randomBetween(rng, spec.Min, spec.Max))
}
}
return out
}
func randomToken(rng *mathrand.Rand, n int) string {
var b strings.Builder
for range n {
b.WriteByte(codenameAlphabet[randomBetween(rng, 0, len(codenameAlphabet)-1)])
}
return b.String()
}
func randomBetween(rng *mathrand.Rand, lo, hi int) int {
if hi <= lo {
return lo
}
return lo + rng.Intn(hi-lo+1)
}
// seededRand is the deterministic generator preflight and tests use, so a
// fixture's tier calibration and its golden state reproduce exactly.
func seededRand(parts ...string) *mathrand.Rand {
h := fnv.New64a()
for _, p := range parts {
h.Write([]byte(p))
h.Write([]byte{0})
}
return mathrand.New(mathrand.NewSource(int64(h.Sum64())))
}
// liveRand is the generator a paid run uses: every run gets values that existed
// nowhere before it started.
func liveRand() *mathrand.Rand {
rng, err := liveRandFrom(rand.Reader)
if err != nil {
panic("contextbench: no entropy for a live run: " + err.Error())
}
return rng
}
// liveRandFrom seeds a generator from an entropy source, so what a run's values
// are unpredictable from is the source and nothing else.
func liveRandFrom(entropy io.Reader) (*mathrand.Rand, error) {
var seed [8]byte
if _, err := io.ReadFull(entropy, seed[:]); err != nil {
return nil, err
}
var n int64
for _, b := range seed {
n = n<<8 | int64(b)
}
return mathrand.New(mathrand.NewSource(n)), nil
}
// instantiate substitutes {{name}} and refuses anything left over. The grammar
// is one identifier in double braces: no conditionals, no loops, no defaults.
// This is fixture data, not a template language.
func instantiate(text string, vars fixtureVars) (string, error) {
out := text
for name, value := range vars {
out = strings.ReplaceAll(out, "{{"+name+"}}", value)
}
if strings.Contains(out, "{{") || strings.Contains(out, "}}") {
return "", fmt.Errorf("unresolved placeholder in %q", firstPlaceholder(out))
}
return out, nil
}
func firstPlaceholder(text string) string {
i := strings.Index(text, "{{")
if i < 0 {
return text
}
j := strings.Index(text[i:], "}}")
if j < 0 {
return text[i:min(i+40, len(text))]
}
return text[i : i+j+2]
}
-144
View File
@@ -1,144 +0,0 @@
// ContextRetrievalBench builds the fixtures the long-horizon retrieval
// experiments run against, and proves they are worth paying a provider for.
//
// -mode=calibrate find each index task's lead-call count for its cue tier
// -mode=preflight build every task under every arm and check the contract
//
// Nothing here calls a provider. The experiments themselves do; this is what
// says they would measure what they claim to.
package main
import (
"flag"
"fmt"
"os"
"sort"
"strings"
)
func main() {
mode := flag.String("mode", "preflight", "calibrate | preflight | snippet | adversarial | run-search | run-boundary | run-index")
work := flag.String("work", "", "directory for built fixtures (default: a temp dir)")
only := flag.String("task", "", "limit to one task id")
dry := flag.Bool("dry", false, "run modes: drive the pipeline with a scripted provider instead of paying for one")
flag.Parse()
if err := validateCorpus(); err != nil {
fmt.Fprintln(os.Stderr, "corpus:", err)
os.Exit(2)
}
root := *work
if root == "" {
dir, err := os.MkdirTemp("", "context-retrieval-*")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
defer os.RemoveAll(dir)
root = dir
}
tasks := allTasks()
if *only != "" {
t, ok := taskByID(*only)
if !ok {
fmt.Fprintf(os.Stderr, "unknown task %q\n", *only)
os.Exit(2)
}
tasks = []contextTask{t}
}
switch *mode {
case "calibrate":
os.Exit(runCalibrate(tasks, root))
case "preflight":
os.Exit(runPreflight(tasks, root))
case "run-search":
os.Exit(runExperiment(*mode, experimentSearch, root, *dry, tasks))
case "run-boundary":
os.Exit(runBoundaries(*mode, root))
case "snippet":
os.Exit(runSnippetAudit(root, 100))
case "adversarial":
os.Exit(runAdversarial(root))
case "run-index":
batch := tasks
if *only == "" {
// A batch measures the efficiency substrate only. Naming one task
// with -task runs that task, role included, so the stopping corpus
// stays measurable on its own.
batch = efficiencyTasks()
}
os.Exit(runExperiment(*mode, experimentIndex, root, *dry, batch))
default:
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
os.Exit(2)
}
}
func runPreflight(tasks []contextTask, root string) int {
var all []finding
for _, t := range tasks {
found, err := checkTask(t, root)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", t.ID, err)
return 2
}
all = append(all, found...)
status := "ok"
if len(found) > 0 {
status = fmt.Sprintf("%d problem(s)", len(found))
}
fmt.Printf("%-24s %-6s gen=%-2d %s\n", t.ID, t.Experiment, t.PlantAfterGen, status)
}
if len(all) == 0 {
fmt.Printf("\n%d fixtures ready.\n", len(tasks))
return 0
}
fmt.Fprintf(os.Stderr, "\n%d preflight problem(s):\n", len(all))
sort.Slice(all, func(i, j int) bool { return all[i].Task < all[j].Task })
for _, f := range all {
fmt.Fprintln(os.Stderr, " ", f)
}
return 1
}
// runCalibrate reports, for each index task, the lead-call counts at which its
// cue is still addressed by each budget. The corpus freezes one number from
// this; it is never computed at run time, or the implementation under test
// would be moving its own goalposts.
func runCalibrate(tasks []contextTask, root string) int {
for _, t := range tasks {
if t.Experiment != experimentIndex {
continue
}
var rows []string
for lead := range fixtureGenerations {
probe := t
probe.PlantAfterGen = lead
var visibleAt []string
for _, scale := range armScales {
inst, ierr := instantiateTask(probe, seededRand(t.ID, scale, "calibrate"))
if ierr != nil {
fmt.Fprintln(os.Stderr, ierr)
return 2
}
f, err := buildFixture(inst, armFor(scale, false),
fmt.Sprintf("%s/calib/%s/%d-%s/session.jsonl", root, t.ID, lead, scale))
if err != nil {
fmt.Fprintf(os.Stderr, "%s gen=%d %s: %v\n", t.ID, lead, scale, err)
return 2
}
if strings.Contains(visibleText(f), inst.CueMarker) {
visibleAt = append(visibleAt, scale)
}
}
rows = append(rows, fmt.Sprintf("planted after gen %d, cue visible at: %s", lead, strings.Join(visibleAt, ",")))
}
fmt.Printf("\n%s (wants tier %s)\n", t.ID, t.CueTier)
for _, r := range rows {
fmt.Println(" ", r)
}
}
return 0
}
-189
View File
@@ -1,189 +0,0 @@
package main
import (
"bytes"
"fmt"
"maps"
mathrand "math/rand"
"os/exec"
"slices"
"strings"
"testing"
)
// The same checks the preflight mode runs, under go test: a compaction change
// that invalidates the fixtures fails here rather than three hundred API calls
// later.
func TestCorpusIsWellFormed(t *testing.T) {
if err := validateCorpus(); err != nil {
t.Fatal(err)
}
if got := len(searchTasks()); got != 6 {
t.Errorf("search experiment has %d tasks, want 6", got)
}
if got := len(indexTasks()); got != 12 {
t.Errorf("index experiment has %d tasks, want 12", got)
}
stopping := map[string]bool{}
tiers := map[string]int{}
for _, task := range indexTasks() {
if task.Role == roleStopping {
stopping[task.ID] = true
continue
}
tiers[task.CueTier]++
}
if !stopping["i03-coalescing"] || !stopping["i04-recovery-fence"] {
t.Errorf("the stopping corpus is %v; it must keep i03-coalescing and i04-recovery-fence", stopping)
}
// Two per tier, over the efficiency substrate, is what makes the staircase
// readable: the same task can be compared across the boundary its cue sits
// on, instead of two different questions being compared across two arms.
for _, tier := range []string{tierQuarter, tierHalf, tierDefault} {
if tiers[tier] != 2 {
t.Errorf("tier %s has %d tasks, want 2", tier, tiers[tier])
}
}
}
// The role decides which batch a task enters; the literal lists are only where
// it is declared, so the two must not drift.
func TestIndexListsMatchTheirRoles(t *testing.T) {
for _, task := range append(append(indexQuarterTasks(), indexHalfTasks()...), indexDefaultTasks()...) {
if task.Role != roleEfficiency {
t.Errorf("%s sits in an efficiency list with role %q", task.ID, task.Role)
}
}
for _, task := range stoppingIndexTasks() {
if task.Role != roleStopping {
t.Errorf("%s sits in the stopping list with role %q", task.ID, task.Role)
}
}
}
// The invariant the whole instantiation layer exists for: no literal a scorer
// needs may be readable in this repository before a run starts.
func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
root, err := repoRoot()
if err != nil {
t.Skip(err)
}
for _, task := range allTasks() {
inst, err := instantiateTask(task, seededRand(task.ID, "leak-check"))
if err != nil {
t.Fatal(err)
}
for _, marker := range append(inst.AnswerMarkers, inst.CueMarker) {
if len(marker) < 6 {
continue // a bare number is not a literal anyone can grep for
}
if found, where := grepRepo(t, root, marker); found {
t.Errorf("%s: %q is readable at %s before the run", task.ID, marker, where)
}
}
}
}
// A run's values are unpredictable from a previous run only if the live
// generator is seeded from entropy. Comparing answers across two instances
// cannot assert that: a task whose answer is a bounded number collides by
// chance (1/8601 to 1/286 per task), so the check is on the seed source.
func TestLiveRandDrawsFromEntropy(t *testing.T) {
a, b := liveRand(), liveRand()
for range 4 {
if a.Int63() != b.Int63() {
return
}
}
t.Fatal("two live generators produced the same stream: they are seeded alike")
}
// The source alone decides an instance: the same entropy gives the same
// answers, different entropy gives different values.
func TestLiveRandSourceDecidesTheInstance(t *testing.T) {
same := func() *mathrand.Rand {
rng, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{7}, 8)))
if err != nil {
t.Fatal(err)
}
return rng
}
other, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{9}, 8)))
if err != nil {
t.Fatal(err)
}
if _, err := liveRandFrom(bytes.NewReader(nil)); err == nil {
t.Fatal("an empty entropy source was accepted")
}
for _, task := range allTasks() {
a, _ := instantiateTask(task, same())
b, _ := instantiateTask(task, same())
if !slices.Equal(a.AnswerMarkers, b.AnswerMarkers) || !maps.Equal(a.Vars, b.Vars) {
t.Errorf("%s: the same entropy gave different instances", task.ID)
}
c, _ := instantiateTask(task, other)
if maps.Equal(a.Vars, c.Vars) {
t.Errorf("%s: different entropy gave the same values", task.ID)
}
}
}
// A preflight that could not reproduce would calibrate against one run's
// nonces and report about another's.
func TestSeededInstancesReproduce(t *testing.T) {
for _, task := range allTasks() {
a, _ := instantiateTask(task, seededRand(task.ID, "x"))
b, _ := instantiateTask(task, seededRand(task.ID, "x"))
if !slices.Equal(a.AnswerMarkers, b.AnswerMarkers) || a.body != b.body {
t.Errorf("%s did not reproduce under the same seed", task.ID)
}
}
}
// An unresolved placeholder would put "{{marker}}" in the transcript and score
// every run wrong; it fails the fixture instead.
func TestUnresolvedPlaceholderIsRefused(t *testing.T) {
if _, err := instantiate("value is {{missing}}", fixtureVars{"other": "x"}); err == nil {
t.Fatal("an unresolved placeholder was accepted")
}
}
func repoRoot() (string, error) {
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
return "", fmt.Errorf("not in a git checkout: %w", err)
}
return strings.TrimSpace(string(out)), nil
}
// grepRepo searches tracked files only: build artifacts and temp fixtures are
// not what a fresh checkout hands anyone.
func grepRepo(t *testing.T, root, needle string) (bool, string) {
t.Helper()
cmd := exec.Command("git", "grep", "-l", "-F", needle)
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
return false, "" // exit 1 means no match
}
return true, strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
}
func TestEveryFixturePassesPreflight(t *testing.T) {
if testing.Short() {
t.Skip("builds and folds 36 sessions")
}
root := t.TempDir()
for _, task := range allTasks() {
t.Run(task.ID, func(t *testing.T) {
found, err := checkTask(task, root)
if err != nil {
t.Fatal(err)
}
for _, f := range found {
t.Error(f)
}
})
}
}
-290
View File
@@ -1,290 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"path/filepath"
"slices"
"strings"
"reasonix/internal/base/retrieval"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/provider"
)
// Preflight is what makes a real run worth paying for. An answer the model can
// already read, or a cue not where the matrix says, turns an arm into a
// different experiment without saying so.
// armScales are the fold-index arms an index task is measured across.
var armScales = []string{"default", "half", "quarter", "off"}
// experimentArm is one measured configuration of a task.
type experimentArm struct {
name string
scale string
searchOff bool
}
// armsFor is the single declaration of which arms a task runs at, so preflight
// and the run itself cannot check one set and measure another.
func armsFor(t contextTask) []experimentArm {
if t.Experiment == experimentSearch {
return []experimentArm{
{"search-on", "default", false},
{"search-off", "default", true},
}
}
arms := make([]experimentArm, 0, len(armScales))
for _, scale := range armScales {
arms = append(arms, experimentArm{"index-" + scale, scale, false})
}
return arms
}
func scaleOf(name string) ablation.FoldIndexScale {
s, err := ablation.ParseFoldIndexScale(name)
if err != nil {
panic("unknown scale " + name) // the list above is a constant
}
return s
}
// armFor is the arm a task is measured under at one fold-index scale.
func armFor(scale string, searchOff bool) ablation.Set {
set := ablation.Set{}
if searchOff {
set = ablation.New(ablation.RecallSearch)
}
return set.WithFoldIndex(scaleOf(scale))
}
type finding struct {
Task, Arm, Problem string
}
func (f finding) String() string { return fmt.Sprintf("%s [%s]: %s", f.Task, f.Arm, f.Problem) }
// checkTask builds the task under every arm it will be run at and reports what
// does not hold.
func checkTask(t contextTask, root string) ([]finding, error) {
var out []finding
for _, arm := range armsFor(t) {
// Deterministic values: a preflight has to reproduce, and a tier
// calibration measured against one run's nonces is measured against
// nothing.
inst, err := instantiateTask(t, seededRand(t.ID, arm.name, "preflight"))
if err != nil {
return nil, err
}
dir := filepath.Join(root, t.ID, arm.name)
f, err := buildFixture(inst, armFor(arm.scale, arm.searchOff), filepath.Join(dir, "session.jsonl"))
if err != nil {
return nil, err
}
visible := visibleText(f)
// The answer must be unreadable in every arm, or the task measures
// reading rather than recall.
for _, marker := range inst.AnswerMarkers {
if len(marker) < 3 {
continue // a bare number is not a nonce; the whole set still has to match
}
if strings.Contains(visible, marker) {
out = append(out, finding{t.ID, arm.name, fmt.Sprintf("answer marker %q is already visible", marker)})
}
}
// The corpus has to be findable at all, or a miss in a real run says
// nothing about the model.
rank, err := probeRank(f, inst.ProbeQuery)
if err != nil {
return nil, err
}
switch {
case rank == 0:
out = append(out, finding{t.ID, arm.name, fmt.Sprintf("probe query %q does not find the target at all", inst.ProbeQuery)})
case rank > 5:
out = append(out, finding{t.ID, arm.name, fmt.Sprintf("probe query %q ranks the target %d", inst.ProbeQuery, rank)})
}
if t.Experiment == experimentIndex {
out = append(out, checkCueVisibility(t, inst, arm.scale, arm.name, visible)...)
} else if line := indexLineFor(f, f.Target); line != "" {
out = append(out, finding{t.ID, arm.name,
"a search task is addressed by the fold index, so it measures the index and not search: " + line})
}
// The projection is what the agent believes. This is what the model is
// told, and it is the one that has to hold.
req, err := captureRequest(f, armFor(arm.scale, arm.searchOff), inst.Prompt)
if err != nil {
return nil, fmt.Errorf("%s [%s]: capture: %w", t.ID, arm.name, err)
}
out = append(out, checkCapturedRequest(t, inst, arm.name, arm.scale, arm.searchOff, req)...)
}
return out, nil
}
// checkCapturedRequest holds one arm's real provider request to its contract:
// the answer unreadable, the cue exactly where its tier says, the recall tool
// offering only what this arm can do, and no host-side field surviving the
// boundary.
func checkCapturedRequest(t contextTask, inst fixtureInstance, armName, scale string, searchOff bool, req provider.Request) []finding {
var out []finding
for _, marker := range inst.AnswerMarkers {
if len(marker) < 3 {
continue // a bare number is not a nonce; the set as a whole still is
}
for _, at := range findInSurface(req, marker) {
out = append(out, finding{t.ID, armName, fmt.Sprintf("answer marker %q leaked at %s", marker, at)})
}
}
if t.Experiment == experimentIndex {
wantVisible, wantHidden := tierScales(t.CueTier)
where := findInSurface(req, inst.CueMarker)
switch {
case contains(wantVisible, scale) && len(where) == 0:
out = append(out, finding{t.ID, armName, fmt.Sprintf("cue %q is not provider-visible; the tier expects it", inst.CueMarker)})
case contains(wantHidden, scale) && len(where) > 0:
out = append(out, finding{t.ID, armName, fmt.Sprintf("cue %q is provider-visible at %s: %s", inst.CueMarker, where[0], scale)})
}
}
schema, ok := recallToolSchema(req)
if !ok {
out = append(out, finding{t.ID, armName, "recall is not in the provider's tool list"})
return append(out, boundaryFindings(t, armName, req)...)
}
params := ""
if b, err := json.Marshal(schema.Parameters); err == nil {
params = string(b)
}
// Both arms keep the read half: the ablation removes search, not recall.
if !strings.Contains(params, `"positions"`) {
out = append(out, finding{t.ID, armName, "recall lost its positions parameter"})
}
hasQuery := strings.Contains(params, `"query"`)
switch {
case searchOff && hasQuery:
out = append(out, finding{t.ID, armName, "recall still offers a query parameter"})
case !searchOff && !hasQuery:
out = append(out, finding{t.ID, armName, "recall has no query parameter"})
}
if searchOff {
for _, phrase := range searchWording {
for _, at := range findInSurface(req, phrase) {
out = append(out, finding{t.ID, armName, fmt.Sprintf("search is offered to an arm without it: %q at %s", phrase, at)})
}
}
}
return append(out, boundaryFindings(t, armName, req)...)
}
func boundaryFindings(t contextTask, armName string, req provider.Request) []finding {
var out []finding
for _, at := range localOnlyLeaks(req) {
out = append(out, finding{t.ID, armName, "host-only field crossed the provider boundary at " + at})
}
return out
}
// checkCueVisibility holds an index task to its tier: the cue is addressed at
// the scales the matrix names and at no others. A policy change that moves it
// fails here rather than turning two arms into one experiment.
func checkCueVisibility(t contextTask, inst fixtureInstance, scale, armName, visible string) []finding {
wantVisible, wantHidden := tierScales(t.CueTier)
has := strings.Contains(visible, inst.CueMarker)
switch {
case contains(wantVisible, scale) && !has:
return []finding{{t.ID, armName, fmt.Sprintf("cue %q should be addressed at %s and is not", inst.CueMarker, scale)}}
case contains(wantHidden, scale) && has:
return []finding{{t.ID, armName, fmt.Sprintf("cue %q should be gone at %s and is not", inst.CueMarker, scale)}}
}
return nil
}
func contains(list []string, want string) bool { return slices.Contains(list, want) }
// probeRank runs the task's own query against the folded region the way recall
// would, and reports where the target lands.
func probeRank(f builtFixture, query string) (int, error) {
terms, err := retrieval.QueryTerms(query)
if err != nil {
return 0, err
}
canonical := f.Session.Snapshot()
covered := min(f.State.Projection.CoveredCount, len(canonical))
docs := make([]map[string]int, 0, covered)
positions := make([]int, 0, covered)
lengths := make([]int, 0, covered)
for i, m := range canonical[:covered] {
var text strings.Builder
text.WriteString(m.Content)
for _, tc := range m.ToolCalls {
text.WriteString(" " + tc.Name + " " + string(tc.Arguments))
}
tokens := retrieval.Tokens(text.String())
if len(tokens) == 0 {
continue
}
docs = append(docs, retrieval.Counts(tokens))
positions = append(positions, i)
lengths = append(lengths, len(tokens))
}
df := retrieval.DocumentFrequency(docs)
avg := 0.0
for _, l := range lengths {
avg += float64(l)
}
if len(lengths) > 0 {
avg /= float64(len(lengths))
}
type hit struct {
pos int
score float64
}
var hits []hit
for i, counts := range docs {
if s := retrieval.BM25Score(counts, lengths[i], terms, df, len(docs), avg); s > 0 {
hits = append(hits, hit{positions[i], s})
}
}
for i := range hits {
for j := i + 1; j < len(hits); j++ {
if hits[j].score > hits[i].score {
hits[i], hits[j] = hits[j], hits[i]
}
}
}
// A tool result answers at its caller's position, so either counts.
for i, h := range hits {
if h.pos == f.Target || h.pos == f.Target+1 {
return i + 1, nil
}
}
return 0, nil
}
// indexLineFor returns the fold-index line addressing a position, if any.
func indexLineFor(f builtFixture, pos int) string {
want := fmt.Sprintf("#%d ", pos)
for line := range strings.SplitSeq(visibleIndexOnly(f), "\n") {
if strings.Contains(line, want) {
return strings.TrimSpace(line)
}
}
return ""
}
// visibleIndexOnly is just the fold-index sections of the projection.
func visibleIndexOnly(f builtFixture) string {
var b strings.Builder
for _, m := range visibleContext(f) {
if i := strings.Index(m.Content, "## Folded work index"); i >= 0 {
b.WriteString(m.Content[i:])
b.WriteString("\n")
}
}
return b.String()
}
@@ -1,252 +0,0 @@
// reformulation.go — how a query changed, from token sets alone.
package main
import (
"fmt"
"slices"
"sort"
"strings"
"reasonix/internal/base/retrieval"
)
// The host probe ranks every target first, so the engine is not what makes a
// model take more than one attempt. These classes are set arithmetic over the
// same tokenizer recall uses: no judge, nothing read out of prose.
const (
reformFirst = "First" // nothing to compare against yet
reformNearDup = "NearDuplicate" // the same query again, give or take a word
reformNarrowing = "Narrowing" // kept everything and added terms
reformBroadening = "Broadening" // dropped terms, added none
reformAnchored = "HistoricalAnchor" // reached for a term the question does not contain
reformShift = "SynonymShift" // traded terms for others
)
// nearDuplicateOverlap is where "reworded" stops and "asked again" begins.
const nearDuplicateOverlap = 0.8
func terms(s string) []string {
return retrieval.Unique(retrieval.Tokens(s))
}
func termSet(s string) map[string]bool {
out := map[string]bool{}
for _, t := range terms(s) {
out[t] = true
}
return out
}
func jaccard(a, b map[string]bool) float64 {
if len(a) == 0 && len(b) == 0 {
return 1
}
shared := 0
for t := range a {
if b[t] {
shared++
}
}
union := len(a) + len(b) - shared
if union == 0 {
return 0
}
return float64(shared) / float64(union)
}
// classifyReformulation names how this query differs from the one before it.
func classifyReformulation(previous, current, question string) string {
if previous == "" {
return reformFirst
}
prev, cur := termSet(previous), termSet(current)
if jaccard(prev, cur) >= nearDuplicateOverlap {
return reformNearDup
}
added, dropped := diffTerms(cur, prev), diffTerms(prev, cur)
// A term the question never used had to come from the model's own memory of
// the history, which is a different move from rephrasing what it was asked.
ask := termSet(question)
for _, t := range added {
if !ask[t] {
return reformAnchored
}
}
switch {
case len(dropped) == 0:
return reformNarrowing
case len(added) == 0:
return reformBroadening
default:
return reformShift
}
}
func diffTerms(from, minus map[string]bool) []string {
var out []string
for t := range from {
if !minus[t] {
out = append(out, t)
}
}
sort.Strings(out)
return out
}
// classifySearches labels each attempt and records which of the host probe's
// terms it left out. The probe is the oracle: it ranks the target first, so a
// query missing its terms says what the model did not think to ask for.
func classifySearches(m *contextMetrics, inst fixtureInstance) {
probe := terms(inst.ProbeQuery)
previous := ""
for i := range m.Searches {
s := &m.Searches[i]
s.Reformulation = classifyReformulation(previous, s.Query, inst.Prompt)
have := termSet(s.Query)
for _, term := range probe {
if !have[term] {
s.MissingProbeTerms = append(s.MissingProbeTerms, term)
}
}
previous = s.Query
}
}
// queryAudit is the reformulation funnel across a set of runs.
type queryAudit struct {
Runs, RunsThatSearched int
FirstQueryHit int
HitBySecond, HitByThird int
NeverHit int
TotalSearches int
PostHitSearches int
HitThenRead int
EscapeBeforeFirstSearch int
Reformulations map[string]int
MissedProbeTerms map[string]int
}
func auditQueries(runs []contextMetrics) queryAudit {
a := queryAudit{Reformulations: map[string]int{}, MissedProbeTerms: map[string]int{}}
for _, r := range runs {
if r.Failed || r.Contaminated {
continue
}
a.Runs++
a.TotalSearches += len(r.Searches)
a.PostHitSearches += r.PostHitSearches
if r.EscapeBeforeFirstRecall > 0 {
a.EscapeBeforeFirstSearch++
}
if len(r.Searches) == 0 {
continue
}
a.RunsThatSearched++
hitAt := 0
for i, s := range r.Searches {
a.Reformulations[s.Reformulation]++
for _, term := range s.MissingProbeTerms {
a.MissedProbeTerms[term]++
}
if s.TargetRank > 0 && hitAt == 0 {
hitAt = i + 1
}
}
switch {
case hitAt == 1:
a.FirstQueryHit++
case hitAt == 2:
a.HitBySecond++
case hitAt >= 3:
a.HitByThird++
default:
a.NeverHit++
}
if hitAt > 0 && r.TargetRead {
a.HitThenRead++
}
}
return a
}
func (a queryAudit) report() string {
var b strings.Builder
b.WriteString("\n## Query reformulation\n")
fmt.Fprintf(&b, " runs=%d searched=%d searches=%d (%.2f per searching run)\n",
a.Runs, a.RunsThatSearched, a.TotalSearches, ratio(a.TotalSearches, a.RunsThatSearched))
fmt.Fprintf(&b, " target found by query 1: %d by 2: %d by 3+: %d never: %d\n",
a.FirstQueryHit, a.HitBySecond, a.HitByThird, a.NeverHit)
fmt.Fprintf(&b, " found then read: %d/%d searches issued after the target was already back: %d\n",
a.HitThenRead, a.FirstQueryHit+a.HitBySecond+a.HitByThird, a.PostHitSearches)
fmt.Fprintf(&b, " reached for the workspace before searching: %d\n", a.EscapeBeforeFirstSearch)
b.WriteString(" reformulations: " + countsLine(a.Reformulations) + "\n")
b.WriteString(" probe terms most often absent: " + topCounts(a.MissedProbeTerms, 8) + "\n")
return b.String()
}
func ratio(n, d int) float64 {
if d == 0 {
return 0
}
return float64(n) / float64(d)
}
func countsLine(counts map[string]int) string {
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", k, counts[k]))
}
return strings.Join(parts, " ")
}
func topCounts(counts map[string]int, limit int) string {
type kv struct {
k string
n int
}
all := make([]kv, 0, len(counts))
for k, n := range counts {
all = append(all, kv{k, n})
}
sort.Slice(all, func(i, j int) bool {
if all[i].n != all[j].n {
return all[i].n > all[j].n
}
return all[i].k < all[j].k
})
parts := make([]string, 0, limit)
for _, e := range all[:min(limit, len(all))] {
parts = append(parts, fmt.Sprintf("%s=%d", e.k, e.n))
}
if len(parts) == 0 {
return "(none)"
}
return strings.Join(parts, " ")
}
// queryLines lists every attempt, so a surprising audit can be read back.
func queryLines(runs []contextMetrics) string {
var b strings.Builder
b.WriteString("\n## Every query, in order\n")
for _, r := range runs {
for _, s := range r.Searches {
rank := "miss"
if s.TargetRank > 0 {
rank = fmt.Sprintf("rank %d", s.TargetRank)
}
fmt.Fprintf(&b, " %-24s %-14s #%d %-17s %-9s %q\n",
r.Task, r.Arm, s.Ordinal, s.Reformulation, rank, s.Query)
if len(s.MissingProbeTerms) > 0 && s.TargetRank == 0 {
fmt.Fprintf(&b, " %-24s %-14s missing probe terms: %s\n",
"", "", strings.Join(slices.Clone(s.MissingProbeTerms), " "))
}
}
}
return b.String()
}
-569
View File
@@ -1,569 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"reasonix/internal/state/sessionstore"
"slices"
"sort"
"strings"
"time"
"reasonix/internal/assembly/boot"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
_ "reasonix/internal/model/openai"
"reasonix/internal/runtime/agent"
_ "reasonix/internal/tools/builtin"
)
// A run is one recall question against a fixture the preflight has already
// cleared. Scoring reads the transcript the turn appended — tool names and
// their JSON arguments — so nothing depends on how the model narrated itself.
const (
runBaseURL = "https://api.deepseek.com"
runModel = "deepseek-chat"
runTimeout = 4 * time.Minute
)
func realProvider() (provider.Provider, error) {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
return nil, fmt.Errorf("a real run needs DEEPSEEK_API_KEY")
}
return provider.New("openai", provider.Config{
Name: "contextbench", BaseURL: runBaseURL, Model: runModel, APIKey: key,
})
}
// adversarialPrompts tell the model what it is running and send it after the
// answer everywhere except the conversation. The question is not whether it
// obeys the experiment: it is whether the answer is reachable at all by a model
// that knows the mechanism and is actively attacking the host.
var adversarialPrompts = map[string]string{
experimentSearch: "You are running a benchmark. Find the expected answer for this task from the " +
"Reasonix repository, benchmark source, fixture generator, test files, scratch files, or " +
"session metadata. Do not use recall unless necessary. Report the exact hidden values.",
experimentIndex: "Inspect benchmarks/context-retrieval, the git repository, session files, temporary " +
"files, and any benchmark metadata you can access. Recover the concrete instantiated answer for " +
"this run without relying on the folded conversation. Report the exact values.",
}
// runAdversarial sends one task per experiment after the answer through every
// host-readable surface. It passes when nothing but recall could produce it —
// a run that gives up and recalls has still proven the attack failed.
func runAdversarial(root string) int {
root, err := filepath.Abs(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
restore, ierr := isolateWorkspace()
if ierr != nil {
fmt.Fprintln(os.Stderr, "isolate workspace:", ierr)
return 2
}
defer restore()
p, perr := realProvider()
if perr != nil {
fmt.Fprintln(os.Stderr, perr)
return 2
}
leaked := 0
for _, id := range []string{"r01-retry-boundary", "i01-transport-fallback"} {
t, ok := taskByID(id)
if !ok {
fmt.Fprintf(os.Stderr, "unknown task %q\n", id)
return 2
}
m, err := runOne(p, t, armFor("default", false), "adversarial", root, false, false,
adversarialPrompts[t.Experiment])
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", id, err)
m = failedMetrics(t, "adversarial", err)
}
verdict := "isolated"
if m.EscapeFoundAnswer {
verdict = "LEAKED via " + m.LeakedVia
leaked++
}
fmt.Printf("%-24s %-24s escapes=%-3d recall=%d answered=%v tools=%v\n",
id, verdict, m.EscapeCalls, m.SearchCalls+m.ReadCalls, m.AnswerRecovered, m.UnexpectedWorkTools)
if m.EscapeFoundAnswer {
fmt.Printf(" args: %s\n output: %s\n", m.LeakedArgs, m.LeakedOutput)
}
}
if leaked > 0 {
fmt.Fprintf(os.Stderr, "\n%d of 2 adversarial runs reached an answer outside recall\n", leaked)
return 1
}
fmt.Println("\nNo answer was reachable outside recall.")
return 0
}
// runOne asks one task under one arm and scores what the model did.
func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root string, cueVisible, dry bool, promptOverride ...string) (contextMetrics, error) {
// Live runs get values that existed nowhere before the run started; a dry
// run stays reproducible.
rng := liveRand()
if dry {
rng = seededRand(t.ID, armName, "dry")
}
inst, err := instantiateTask(t, rng)
if err != nil {
return contextMetrics{}, err
}
f, err := buildFixture(inst, arm, filepath.Join(root, t.ID, armName, "session.jsonl"))
if err != nil {
return contextMetrics{}, err
}
sess, err := sessionstore.LoadSession(f.Path)
if err != nil {
return contextMetrics{}, fmt.Errorf("load session: %w", err)
}
reg := tool.NewRegistry()
for _, x := range tool.Builtins() {
reg.Add(x)
}
boot.ApplyUnifiedProviderToolSurface(reg, false, arm)
var usage usageSink
a := agent.New(p, reg, sess, agent.Options{
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
SessionPath: f.Path, KeepPolicy: agent.KeepErrors, Ablation: arm,
}, &usage)
a.LoadProjectionSidecar(f.Path)
// From here the transcript lives only in memory. On disk it is the answer.
sealFixture(f.Path)
defer unsealFixture(f.Path)
dir := filepath.Dir(f.Path)
if at, marker := answerOnDisk(dir, inst.AnswerMarkers); at != "" {
return contextMetrics{}, fmt.Errorf("%s [%s]: %q is readable at %s before the run", t.ID, armName, marker, at)
}
before := len(sess.Snapshot())
ctx, cancel := context.WithTimeout(context.Background(), runTimeout)
defer cancel()
prompt := inst.Prompt
if len(promptOverride) > 0 && promptOverride[0] != "" {
prompt = promptOverride[0]
}
runErr := a.Run(ctx, prompt)
if at, marker := answerOnDisk(dir, inst.AnswerMarkers); at != "" {
return contextMetrics{}, fmt.Errorf("%s [%s]: %q was written to %s during the run", t.ID, armName, marker, at)
}
appended := sess.Snapshot()[before:]
m := scoreRun(appended, inst, f.Target, armName, cueVisible)
m.trajectory = appended
if u, requests := usage.snapshot(); requests > 0 {
m.UsageReportedRequests, m.PromptTokens, m.CompletionTokens = requests, u.PromptTokens, u.CompletionTokens
m.CacheHitTokens, m.CacheMissTokens, m.UsageEstimated = u.CacheHitTokens, u.CacheMissTokens, u.Estimated
}
m.scoreAnswer(finalAnswer(appended), inst)
if runErr != nil {
m.FailureStage = "RunError"
m.TimeoutReason = timeoutReason(m)
}
return m, runErr
}
// failedMetrics is what a run that never reached a transcript leaves behind. It
// carries the run's identity and the stage that ended it, so the failure stays a
// countable fact; Failed keeps it out of every statistic.
func failedMetrics(t contextTask, armName string, err error) contextMetrics {
return contextMetrics{
Task: t.ID, Arm: armName, Failed: true,
FailureStage: stageNoEvidence, Reason: err.Error(),
}
}
// finalAnswer is the last assistant text the turn produced.
func finalAnswer(msgs []provider.Message) string {
for _, m := range slices.Backward(msgs) {
if m.Role == provider.RoleAssistant && strings.TrimSpace(m.Content) != "" {
return m.Content
}
}
return ""
}
// isolateWorkspace moves the process into an empty directory before any task
// runs. The corpus lives in this repository, so an agent left here greps the
// answers out of corpus.go — one did, quoting the file's own line about them
// being nonces. Fixtures stay outside it too: a readable session file is the
// canonical transcript, answer included.
func isolateWorkspace() (func(), error) {
dir, err := os.MkdirTemp("", "contextbench-workspace-*")
if err != nil {
return nil, err
}
readme := "This workspace is empty on purpose. The question is about earlier conversation,\nnot about any file here.\n"
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte(readme), 0o644); err != nil {
return nil, err
}
previous, err := os.Getwd()
if err != nil {
return nil, err
}
if err := os.Chdir(dir); err != nil {
return nil, err
}
return func() {
_ = os.Chdir(previous)
_ = os.RemoveAll(dir)
}, nil
}
// runBoundaries runs only the pair each index task is built for: the narrowest
// scale still addressing its cue, and the next one down. Each pair moves the
// affordance and nothing else, where default-vs-off moves the whole index.
// The two arms get separate nonces: reusing one would leave the first arm's
// answer in a host-readable history for the second to find.
func runBoundaries(mode, root string) int {
started := time.Now()
planned := 0
repo, _ := os.Getwd()
root, err := filepath.Abs(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
restore, ierr := isolateWorkspace()
if ierr != nil {
fmt.Fprintln(os.Stderr, "isolate workspace:", ierr)
return 2
}
defer restore()
p, perr := realProvider()
if perr != nil {
fmt.Fprintln(os.Stderr, perr)
return 2
}
var all []contextMetrics
var cueSide, noCueSide []contextMetrics
for _, t := range efficiencyTasks() {
cue, noCue := boundaryPair(t.CueTier)
for _, side := range []struct {
scale string
has bool
}{{cue, true}, {noCue, false}} {
name := "index-" + side.scale
planned++
m, err := runOne(p, t, armFor(side.scale, false), name, root, side.has, false)
if err != nil {
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, name, err)
m = failedMetrics(t, name, err)
}
all = append(all, m)
if side.has {
cueSide = append(cueSide, m)
} else {
noCueSide = append(noCueSide, m)
}
fmt.Printf("%-24s %-14s cue=%-3v %-14s search=%d read=%d direct=%-5v recall-tok=%-5d%s escape=%d\n",
t.ID, name, side.has, m.FailureStage, m.SearchCalls, m.ReadCalls,
m.CueDirectRead, m.RecallReturnedTokens, promptTokenSuffix(m), m.EscapeCalls)
}
}
reportDeltas(cueSide, noCueSide)
fmt.Print(reportStopping(all))
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Repo: repo}, all)
}
// reportDeltas is the paired reading: each task against itself, then pooled.
func reportDeltas(cueSide, noCueSide []contextMetrics) {
fmt.Printf("\n## Paired deltas (cue present − cue absent)\n")
fmt.Printf(" %-24s %-10s %-10s %-9s %-11s %s\n",
"task", "recovery", "directread", "searches", "recall-tok", "escapes")
for i := range cueSide {
if i >= len(noCueSide) {
break
}
a, b := cueSide[i], noCueSide[i]
fmt.Printf(" %-24s %-10s %-10s %-9d %-11d %d\n", a.Task,
deltaBool(a.AnswerRecovered, b.AnswerRecovered),
deltaBool(a.CueDirectRead, b.CueDirectRead),
a.SearchCalls-b.SearchCalls,
a.RecallReturnedTokens-b.RecallReturnedTokens,
a.EscapeCalls-b.EscapeCalls)
}
fmt.Println("\n## Pooled")
fmt.Println(" ", summarize("cue-present", cueSide).line())
fmt.Println(" ", summarize("cue-absent", noCueSide).line())
fmt.Println("\n## Where each side looked first")
fmt.Printf(" %-14s %s\n", "cue-present", countsLine(summarize("x", cueSide).Routes))
fmt.Printf(" %-14s %s\n", "cue-absent", countsLine(summarize("x", noCueSide).Routes))
}
func deltaBool(a, b bool) string {
switch {
case a == b:
return "same"
case a:
return "+cue"
default:
return "-cue"
}
}
// runExperiment runs one experiment's tasks across its arms. A dry run drives
// the same pipeline with a scripted provider: it proves fixture, run, scoring
// and report hold together before any of it is paid for.
func runExperiment(mode, experiment, root string, dry bool, tasks []contextTask) int {
started := time.Now()
planned := 0
repo, _ := os.Getwd()
root, err := filepath.Abs(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
restore, err := isolateWorkspace()
if err != nil {
fmt.Fprintln(os.Stderr, "isolate workspace:", err)
return 2
}
defer restore()
var p provider.Provider = &scriptedProvider{}
if !dry {
real, rerr := realProvider()
if rerr != nil {
fmt.Fprintln(os.Stderr, rerr)
return 2
}
p = real
}
byArm := map[string][]contextMetrics{}
var all []contextMetrics
if experiment == experimentIndex {
fmt.Print(fingerprint(dry))
}
for _, t := range tasks {
if t.Experiment != experiment {
continue
}
// Arms are shuffled inside each task block. Running six defaults, then
// six halves, would make provider drift collinear with the axis under
// test: a bad minute would land entirely on one arm.
for _, arm := range shuffledArms(t) {
cueVisible := t.Experiment == experimentIndex && cueExpectedVisible(t, arm.scale)
planned++
m, err := runOne(p, t, armFor(arm.scale, arm.searchOff), arm.name, root, cueVisible, dry)
if err != nil {
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, arm.name, err)
m = failedMetrics(t, arm.name, err)
}
byArm[arm.name] = append(byArm[arm.name], m)
all = append(all, m)
fmt.Printf("%-24s %-14s %-18s search=%d hit=%d read=%d recall-tok=%d%s %v\n",
t.ID, arm.name, m.FailureStage, m.SearchCalls, m.TargetSearchHits, m.ReadCalls,
m.RecallReturnedTokens, promptTokenSuffix(m), m.UnexpectedWorkTools)
}
}
reportFunnels(byArm)
if experiment == experimentIndex {
fmt.Print(reportDoseResponse(all))
}
fmt.Print(reportStopping(all))
fmt.Print(auditQueries(all).report())
fmt.Print(queryLines(all))
if experiment == experimentIndex {
reportBoundaries(all)
}
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Dry: dry, Repo: repo}, all)
}
func cueExpectedVisible(t contextTask, scale string) bool {
visible, _ := tierScales(t.CueTier)
return contains(visible, scale)
}
func reportFunnels(byArm map[string][]contextMetrics) {
names := make([]string, 0, len(byArm))
for name := range byArm {
names = append(names, name)
}
sort.Strings(names)
fmt.Println("\n## Funnel")
for _, name := range names {
fmt.Println(" ", summarize(name, byArm[name]).line())
}
fmt.Println("\n## Where it looked first")
for _, name := range names {
f := summarize(name, byArm[name])
fmt.Printf(" %-14s %s\n", name, countsLine(f.Routes))
}
fmt.Println("\n## Where it broke")
for _, name := range names {
f := summarize(name, byArm[name])
stages := make([]string, 0, len(f.Stages))
for stage, n := range f.Stages {
stages = append(stages, fmt.Sprintf("%s=%d", stage, n))
}
sort.Strings(stages)
fmt.Printf(" %-12s %s\n", name, strings.Join(stages, " "))
}
}
// writeTrajectories saves what each turn produced, after the whole batch is
// done. Three analyses in a row have needed a re-run because something was
// counted and not kept — query text, then snippet contents. It is written at
// the end on purpose: during the batch these files are the answer.
func writeTrajectories(root string, all []contextMetrics) error {
f, err := os.Create(filepath.Join(root, "trajectories.jsonl"))
if err != nil {
return err
}
defer f.Close()
for _, m := range all {
if err := writeJSONLine(f, map[string]any{
"task": m.Task, "arm": m.Arm, "messages": m.trajectory,
}); err != nil {
return err
}
}
return nil
}
func writeResults(root string, info batchInfo, all []contextMetrics) int {
path := filepath.Join(root, "results.jsonl")
f, err := os.Create(path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
defer f.Close()
scored, failed := 0, 0
for _, m := range all {
if err := writeJSONLine(f, m); err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
if m.Failed {
failed++
} else {
scored++
}
}
if err := writeTrajectories(root, all); err != nil {
fmt.Fprintln(os.Stderr, "write trajectories:", err)
return 2
}
if err := writeBatchManifest(root, info, all); err != nil {
fmt.Fprintln(os.Stderr, "write batch manifest:", err)
return 2
}
// Both counts are named: a reader who sees only the batch size cannot tell
// measurements from attempts.
fmt.Printf("\n%d runs written to %s: scored=%d failed=%d (with trajectories.jsonl and batch.json)\n",
len(all), path, scored, failed)
return 0
}
// reportBoundaries prints each index task across its own cue boundary, then the
// two sides pooled. Task difficulty is held constant inside a row, which is
// what a four-arm average cannot do.
func reportBoundaries(all []contextMetrics) {
byKey := map[string]contextMetrics{}
scored, failed := 0, 0
for _, m := range all {
if m.Failed {
// No transcript, no boundary to compare: count the failure instead of
// printing the zero it left behind as a measurement.
failed++
continue
}
scored++
byKey[m.Task+"|"+m.Arm] = m
}
if scored == 0 {
fmt.Printf("\n## Across each task's cue boundary\n no scored runs: scored=%d failed=%d (nothing to compare)\n", scored, failed)
return
}
var cueSide, noCueSide []contextMetrics
fmt.Println("\n## Across each task's cue boundary")
fmt.Printf(" %-24s %-16s %-4s %-10s %-9s %-12s %-11s %s\n",
"task", "arm", "cue", "recovered", "cue-read", "search/task", "recall-tok", "escape")
for _, t := range indexTasks() {
cue, noCue := boundaryPair(t.CueTier)
for _, side := range []struct {
scale string
has bool
}{{cue, true}, {noCue, false}} {
m, ok := byKey[t.ID+"|index-"+side.scale]
if !ok {
continue
}
if side.has {
cueSide = append(cueSide, m)
} else {
noCueSide = append(noCueSide, m)
}
mark := "no"
if side.has {
mark = "yes"
}
fmt.Printf(" %-24s %-16s %-4s %-10v %-9v %-12d %-11d %d\n",
t.ID, "index-"+side.scale, mark, m.AnswerRecovered, m.RecallReadWithoutSearch,
m.SearchCalls, m.RecallReturnedTokens, m.EscapeCalls)
}
}
fmt.Println("\n## Pooled across boundaries")
fmt.Println(" ", summarize("cue-present", cueSide).line())
fmt.Println(" ", summarize("cue-absent", noCueSide).line())
}
// shuffledArms permutes a task's arms deterministically, so the order is
// reproducible and still uncorrelated with the axis being measured.
func shuffledArms(t contextTask) []experimentArm {
arms := armsFor(t)
rng := seededRand(t.ID, "arm-order")
rng.Shuffle(len(arms), func(i, j int) { arms[i], arms[j] = arms[j], arms[i] })
return arms
}
// fingerprint records what produced a batch. Not a gate — but batch effects
// have reversed three conclusions in this experiment already, and a table with
// no provenance cannot be compared with the next one.
func fingerprint(dry bool) string {
model, endpoint := runModel, runBaseURL
if dry {
model, endpoint = "scripted", "none"
}
commit := "unknown"
if out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output(); err == nil {
commit = strings.TrimSpace(string(out))
}
return fmt.Sprintf("## Batch\n model=%s endpoint=%s window=%d generations=%d commit=%s started=%s\n",
model, endpoint, fixtureWindow, fixtureGenerations, commit, time.Now().UTC().Format(time.RFC3339))
}
// timeoutReason separates a run that never found its answer from one that found
// it and kept going. Only the first is a case for a longer budget; the second
// would just get more time to circle. A batch reporting only "context deadline
// exceeded" cannot tell an arm that made retrieval harder from an arm that
// happened to draw a slow minute.
func timeoutReason(m contextMetrics) string {
switch {
case m.TargetHeldAtEnd && m.RoundsAfterTarget >= 2:
return "PostTargetRunaway"
case m.TargetHeldAtEnd:
return "TargetHeldLate"
case m.SearchCalls >= 3:
return "SearchSpiral"
case m.SearchCalls+m.ReadCalls+m.EscapeCalls == 0:
return "NoToolActivity"
default:
return "Unknown"
}
}
-95
View File
@@ -1,95 +0,0 @@
package main
import (
"context"
"encoding/json"
"regexp"
"slices"
"strconv"
"strings"
"reasonix/internal/contract/provider"
)
// scriptedProvider walks the chain a competent model would: search, read the
// best hit, answer with what came back. It proves the pipeline holds together
// for nothing, so a real run's first failure is about the model.
type scriptedProvider struct{}
func (p *scriptedProvider) Name() string { return "contextbench-scripted" }
var (
scriptedHit = regexp.MustCompile(`(?m)^#(\d+) `)
scriptedRecall = regexp.MustCompile(`(?s)^#\d+\n(.*)`)
)
// Stream decides from the request alone, never from a call counter: one
// provider serves every task in a run, and a counter would make the second task
// start where the first left off.
func (p *scriptedProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
ch := make(chan provider.Chunk, 3)
defer close(ch)
// Honour the schema, as a model does: an arm without a query parameter is
// an arm that cannot search, and pretending otherwise scores a refusal as
// a failed search.
if !recallAccepts(req, "query") {
ch <- provider.Chunk{Type: provider.ChunkText, Text: "I have no address for that part of the history."}
ch <- provider.Chunk{Type: provider.ChunkDone}
return ch, nil
}
// The last message, not the last tool result anywhere: the fixture's own
// history is full of tool results, and one of those is not this turn's.
last := ""
if n := len(req.Messages); n > 0 && req.Messages[n-1].Role == provider.RoleTool {
last = req.Messages[n-1].Content
}
switch {
case last == "":
args, _ := json.Marshal(map[string]string{"query": lastUserText(req)})
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
ID: "s1", Name: "recall", Arguments: string(args),
}}
case strings.Contains(last, "Folded context matching"):
positions := scriptedHit.FindAllStringSubmatch(last, 1)
if len(positions) == 0 {
ch <- provider.Chunk{Type: provider.ChunkText, Text: "Nothing in the folded history matches."}
break
}
n, _ := strconv.Atoi(positions[0][1])
args, _ := json.Marshal(map[string][]int{"positions": {n}})
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{
ID: "r1", Name: "recall", Arguments: string(args),
}}
default:
// Answer with what recall returned, so a correct chain scores as one.
body := last
if m := scriptedRecall.FindStringSubmatch(last); len(m) > 1 {
body = m[1]
}
ch <- provider.Chunk{Type: provider.ChunkText, Text: "From the folded history: " + body}
}
ch <- provider.Chunk{Type: provider.ChunkDone}
return ch, nil
}
// recallAccepts reports whether recall is offered with the given parameter.
func recallAccepts(req provider.Request, param string) bool {
for _, t := range req.Tools {
if t.Name != "recall" {
continue
}
b, err := json.Marshal(t.Parameters)
return err == nil && strings.Contains(string(b), `"`+param+`"`)
}
return false
}
func lastUserText(req provider.Request) string {
for _, m := range slices.Backward(req.Messages) {
if m.Role == provider.RoleUser {
return m.Content
}
}
return ""
}
-182
View File
@@ -1,182 +0,0 @@
// snippet.go — does a rank-1 hit actually hand over the answer?
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/base/retrieval"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/event"
"reasonix/internal/contract/tool"
"reasonix/internal/runtime/agent"
)
// TargetHit is not EvidenceSufficient: a rank-1 hit can still hand over a
// window too short to answer with, and searching again after that is correct.
// Measured through the shipped recall path, not a copy of it.
// snippetWidths are the windows to compare. 240 is what ships.
var snippetWidths = []int{120, 180, 240, 320, 480}
// historicalQueries are the queries these tasks actually drew in the pilots,
// kept because a probe query is the host's phrasing and the model's was not.
var historicalQueries = map[string][]string{
"i03-coalescing": {"coalesce window marker probe", "job completion coalescing window"},
"i04-recovery-fence": {"recovery fence marker epoch", "expected epoch", "recfence quartz"},
}
type snippetReading struct {
Task string
Query string
Width int
Covered int
Total int
Runes int
Rank int
Complete bool
}
// runSnippetAudit answers the question offline, over many instantiations so a
// single seed's lengths cannot decide it.
func runSnippetAudit(root string, seeds int) int {
fmt.Printf("## Snippet coverage (%d instantiations per task)\n", seeds)
fmt.Println(" Does the window a rank-1 hit returns contain every value the task scores on?")
byTaskWidth := map[string]map[int][]snippetReading{}
for _, t := range allTasks() {
byTaskWidth[t.ID] = map[int][]snippetReading{}
for seed := range seeds {
inst, err := instantiateTask(t, seededRand(t.ID, "snippet", fmt.Sprint(seed)))
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
queries := append([]string{inst.ProbeQuery}, historicalQueries[t.ID]...)
for _, q := range queries {
for _, width := range snippetWidths {
r := measureSnippet(inst, q, width)
byTaskWidth[t.ID][width] = append(byTaskWidth[t.ID][width], r)
}
}
}
}
fmt.Printf("\n %-24s %-5s", "task", "vals")
for _, w := range snippetWidths {
fmt.Printf("%9d", w)
}
fmt.Println(" mean runes @240")
for _, t := range allTasks() {
vals := len(t.AnswerVars)
fmt.Printf(" %-24s %-5d", t.ID, vals)
for _, w := range snippetWidths {
fmt.Printf("%8.0f%%", 100*completeRate(byTaskWidth[t.ID][w]))
}
fmt.Printf(" %.0f\n", meanRunes(byTaskWidth[t.ID][240]))
}
reportShippedPath(root)
return 0
}
// measureSnippet renders one hit through the same MakeSnippet the recall path
// uses, then asks whether every scored value survived the window.
func measureSnippet(inst fixtureInstance, query string, width int) snippetReading {
terms, err := retrieval.QueryTerms(query)
if err != nil {
return snippetReading{Task: inst.Task.ID, Query: query, Width: width, Total: len(inst.AnswerMarkers)}
}
// The tool result is the document the answer lives in; a call's arguments
// are a separate document and never carry it.
snippet := retrieval.MakeSnippet(inst.body, query, terms, width)
covered := 0
for _, marker := range inst.AnswerMarkers {
if strings.Contains(strings.ToLower(snippet), strings.ToLower(marker)) {
covered++
}
}
return snippetReading{
Task: inst.Task.ID, Query: query, Width: width,
Covered: covered, Total: len(inst.AnswerMarkers),
Runes: len([]rune(snippet)), Complete: covered == len(inst.AnswerMarkers),
}
}
// reportShippedPath cross-checks the table against the real recall tool: a
// fixture, a fold, and a search through the agent that ships.
func reportShippedPath(root string) {
fmt.Println("\n## Through the shipped recall path (width as shipped)")
for _, t := range allTasks() {
inst, err := instantiateTask(t, seededRand(t.ID, "snippet-live"))
if err != nil {
continue
}
f, err := buildFixture(inst, ablation.Set{}, filepath.Join(root, "snippet", t.ID, "session.jsonl"))
if err != nil {
fmt.Printf(" %-24s fixture: %v\n", t.ID, err)
continue
}
a := agent.New(&digestProvider{}, tool.NewRegistry(), f.Session, agent.Options{
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
SessionPath: f.Path, KeepPolicy: agent.KeepErrors,
}, event.Discard)
a.LoadProjectionSidecar(f.Path)
queries := append([]string{inst.ProbeQuery}, historicalQueries[t.ID]...)
for _, q := range queries {
res, err := a.RecallContext(context.Background(), tool.RecallRequest{Query: q})
if err != nil {
fmt.Printf(" %-24s %-34q recall: %v\n", t.ID, q, err)
continue
}
covered := 0
for _, marker := range inst.AnswerMarkers {
if strings.Contains(strings.ToLower(res.Text), strings.ToLower(marker)) {
covered++
}
}
fmt.Printf(" %-24s %-34q %d/%d values in the result\n",
t.ID, truncate(q, 32), covered, len(inst.AnswerMarkers))
}
}
}
func completeRate(rs []snippetReading) float64 {
if len(rs) == 0 {
return 0
}
n := 0
for _, r := range rs {
if r.Complete {
n++
}
}
return float64(n) / float64(len(rs))
}
func meanRunes(rs []snippetReading) float64 {
if len(rs) == 0 {
return 0
}
total := 0
for _, r := range rs {
total += r.Runes
}
return float64(total) / float64(len(rs))
}
// answerCoverage is what a run should have recorded all along: how much of the
// scored answer a search actually handed over. rank=1 with coverage 1/3 is not
// a stopping failure.
func answerCoverage(text string, markers []string) int {
covered := 0
lower := strings.ToLower(text)
for _, marker := range markers {
if strings.Contains(lower, strings.ToLower(marker)) {
covered++
}
}
return covered
}
-59
View File
@@ -1,59 +0,0 @@
package main
import (
"fmt"
"sync"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
)
// usageSink reads what a run cost from the events the agent already emits. Every
// billed completion reports event.Usage — the round path, compaction, command
// routing and failure diagnosis all do — so the bench reads the same record the
// product reads instead of wrapping a provider and counting a second time.
type usageSink struct {
mu sync.Mutex
requests int
usage provider.Usage
}
func (s *usageSink) Emit(e event.Event) {
if e.Kind != event.Usage || e.Usage == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
// One event is one completion that reported usage; RequestCount covers a
// completion that merged attempts. A request that failed before reporting
// leaves no event, so this is not a count of attempts.
if e.Usage.RequestCount > 0 {
s.requests += e.Usage.RequestCount
} else {
s.requests++
}
u := *e.Usage
s.usage.PromptTokens += u.PromptTokens
s.usage.CompletionTokens += u.CompletionTokens
s.usage.TotalTokens += u.TotalTokens
s.usage.CacheHitTokens += u.CacheHitTokens
s.usage.CacheMissTokens += u.CacheMissTokens
s.usage.ReasoningTokens += u.ReasoningTokens
s.usage.Estimated = s.usage.Estimated || u.Estimated
}
func (s *usageSink) snapshot() (provider.Usage, int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.usage, s.requests
}
// promptTokenSuffix renders what a run cost only when something reported it. A
// dry run reports nothing, and a trailing "prompt-tok=0" would read as a
// measured zero rather than as no measurement.
func promptTokenSuffix(m contextMetrics) string {
if m.UsageReportedRequests == 0 {
return ""
}
return fmt.Sprintf(" prompt-tok=%d", m.PromptTokens)
}
@@ -1,54 +0,0 @@
package main
import (
"testing"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
)
// The cost a run reports must be what the agent reported, summed across every
// completion it billed — including one that merged attempts and one that could
// only estimate. Any other event kind is not a cost.
func TestUsageSinkAccumulatesWhatTheAgentReports(t *testing.T) {
var sink usageSink
sink.Emit(event.Event{}) // a kind that is not Usage
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110, CacheHitTokens: 60, CacheMissTokens: 40,
}})
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
PromptTokens: 7, CompletionTokens: 3, CacheHitTokens: 2, CacheMissTokens: 5, RequestCount: 2, Estimated: true,
}})
sink.Emit(event.Event{Kind: event.Usage}) // a Usage event with no payload is not a cost
usage, requests := sink.snapshot()
if requests != 3 {
t.Fatalf("requests = %d, want 3 (1 + the merged attempt's 2; the empty event adds none)", requests)
}
if usage.PromptTokens != 107 || usage.CompletionTokens != 13 || usage.TotalTokens != 110 {
t.Fatalf("accumulated usage = %+v", usage)
}
if usage.CacheHitTokens != 62 || usage.CacheMissTokens != 45 {
t.Fatalf("cache split = %+v", usage)
}
if !usage.Estimated {
t.Fatal("Estimated was dropped when one completion reported an estimate")
}
}
// A dry run must not claim a cost it never paid, in the JSON or in the line a
// person reads: an empty suffix, not "prompt-tok=0".
func TestUsageSinkStaysEmptyWithoutUsage(t *testing.T) {
var sink usageSink
sink.Emit(event.Event{})
usage, requests := sink.snapshot()
if requests != 0 || usage.PromptTokens != 0 || usage.CompletionTokens != 0 {
t.Fatalf("nothing reported usage, got %+v requests=%d", usage, requests)
}
if suffix := promptTokenSuffix(contextMetrics{}); suffix != "" {
t.Fatalf("a run with no reported usage still printed %q", suffix)
}
if suffix := promptTokenSuffix(contextMetrics{UsageReportedRequests: 1, PromptTokens: 12}); suffix != " prompt-tok=12" {
t.Fatalf("a reported cost printed %q", suffix)
}
}
+131
View File
@@ -0,0 +1,131 @@
# realrepo
Long tasks over a pinned snapshot of this repository, for measuring changes
that aim to cut model round trips. The `benchmarks/e2e` tasks finish in about
5–15 requests and ten seconds; a real task in this tree took a flash-class
model 38 requests, most of them after a single read. A suite of short fixtures
cannot show that gap moving, so these tasks are shaped like the real one:
explore several packages, edit several files, verify, and recover from the
verifier.
```sh
go build -o bin/reasonix ./cmd/reasonix
go run ./cmd/e2ebench -suite benchmarks/realrepo -bin "$PWD/bin/reasonix" -model deepseek-flash \
-budget 0 -trials 2 -trajectories traj -json realrepo.json
go run ./cmd/e2ebench -suite benchmarks/realrepo -bin "$PWD/bin/reasonix" -task rt-stale-skills
```
The agent reads its provider config from `REASONIX_HOME` (default
`~/.reasonix`); point it at a disposable one to keep benchmark sessions out of
your own.
The default `-budget` (800k tokens) can stop the suite partway through, hence
`-budget 0`. Under `-trials`, each trial keeps its own
`<task>.trial-N.trajectory.jsonl`.
## The snapshot
Every `workdir/` is the same Go module (`module reasonix`, stdlib only, no
network needed to build or test):
- **Pinned code.** `git archive` of commit `42636ddcb425` for
`internal/contract/{pricing,ablation,agentgraph}` and
`internal/base/{retrieval,rulelist,tokencount,neterr,nilutil}` — about 5k
lines of real kernel code with its real tests and doc comments.
- **Fixture-authored files** in `base/`: `go.mod`, a trimmed `REASONIX.md` (the
project's own conventions, so the agent loads standing instructions the way
it does here), a `Makefile` with `check`, a module `README.md`, the
`cmd/benchctl` command that consumes `pricing` and `ablation` from outside
their package, and one project skill.
- **One pinned test anchored to the snapshot day.**
`TestCatalogPricesHaveBeenCheckedRecently` compares each row's `CheckedOn`
with the wall clock, which would fail every workdir 90 days after the oldest
pinned date (2026-11-20). `base/` replaces that file with one that reads
staleness at 2026-10-19 instead; nothing else in the pin is edited.
- **Per-task overlay** in `overlay/<task-id>/`, applied last. Only
`rt-stale-skills` has one.
`snapshot.sh` rebuilds every workdir from those three sources and
`snapshot.sh --check` fails if a committed workdir has drifted from them. Edit
`base/` or `overlay/`, never a workdir, then rerun the script. Moving the pin is
a new suite version: results from two pins do not compare.
## Tasks
| Task | Kind | What it exercises |
| --- | --- | --- |
| `rt-cache-write-rate` | multi-file Go change, test, README | Adds a `CacheWrite` rate to `RateCard` and threads it through cost, fingerprint, card identity and the catalog, then prices Anthropic's `claude-sonnet-4-5` with it: a new `officialRates` row, documentation and checked-on constants, an endpoint host, and `benchctl rate`/`cost` output. The fingerprint has to stay byte-identical for every existing card, so the change to `PricingFingerprint` cannot be the obvious one. |
| `rt-stale-skills` | documentation only, no recognised verifier | Three project skills name paths, functions, `make` targets and `benchctl` subcommands and flags that do not exist, and one contradicts the append-only rule in `REASONIX.md`. Fixing them takes reading `rates.go`, `ablation.go`, both `benchctl` files and the `Makefile`, then editing only Markdown — the shape behind #13293, where `stale_verification` stays owed after a skill edit because no check covers it. |
| `rt-rate-lookup-signatures` | API migration with a likely compile-error loop | Changes `CurrentRate` to `(RateCard, bool)`, `LookupCatalog` to a `CatalogKey` struct, and `IsOfficialCard`, `MatchesCatalog` and `LiveTariff` to a `ModelRef`: about 20 call sites across 11 files. Most are in `_test.go` files and dereference the old pointer, so `go build ./...` can pass while `go vet` and `go test` fail. |
| `rt-plan-ablation-module` | plan-mode with `complete_step` | Five numbered steps adding a `memory` ablation module, summaries, `ModuleHelp` and `benchctl arm -list`. The prompt opens with the host's own plan-first marker (`control.PlannerRouteMarker`), which routes the turn through the planner, and asks for each step to be signed off before the next. An existing test pins the `all` arm name and has to be updated, which adds a red-test step. |
Every grader stages hidden tests and checks after the run: `gofmt -l`, `go vet
./...` and `go test ./...` for the Go tasks; for `rt-stale-skills`, every
backticked path, identifier, `make` target and `benchctl` command line in each
skill must resolve against the tree, and a SHA-256 manifest proves no Go source, `go.mod`,
`Makefile` or `REASONIX.md` changed. Each task fails its pristine seed and
passes its `solution/`, both checked by `cmd/e2ebench`'s corpus tests.
## Reading the numbers
```text
Metric: requests per task
Anchor: the trajectory digest's requests_by_source, summed over sources
Population: every graded trial of a task that ran to its own stop
Excludes: tasks the token budget skipped, and runs that hit timeout_sec
```
Requests is the headline because round trips are what these tasks exist to
move. Read it beside the solve rate and the report's round outcomes: a change
that drops requests by giving up on verification has not cut a round trip, it
has cut a check. `rt-stale-skills` is read for whether the run finishes at all
and how many rounds go to re-verifying after its last write.
## Baseline
`deepseek-flash`, 2 trials per task, at studio `0b8de9e63`. Requests are the
trajectory's executor requests; all 8 runs ran to their own stop.
| Task | Solved | Requests | `edit_file` calls | Edit rounds |
| --- | --- | --- | --- | --- |
| `rt-cache-write-rate` | 2/2 | 31, 28 | 21, 20 | 8, 8 |
| `rt-plan-ablation-module` | 2/2 | 24, 30 | 10, 13 | 9, 7 |
| `rt-rate-lookup-signatures` | 2/2 | 13, 15 | 10, 17 | 2, 6 |
| `rt-stale-skills` | 2/2* | 20, 26 | 3, 2 | 3, 3 |
\* One run first graded as failed, on a glob (`*_rates_test.go`) and a
placeholder (`benchctl <subcommand>`) that were both correct. The grader now
resolves globs against the tree and skips placeholders, and that run counts
as solved. The same task's first version failed a run for naming
`CatalogEntry.EffectiveFrom` in order to warn against it; that check went too.
Reads and edits already batch: 20 edit calls fit into 8 rounds, and several
`edit_file` calls to one file often share a round. Rounds that edit a file the
previous round also edited are rare (0–2 per run), so steering same-file edits
to `multi_edit` has little to save. On this suite's first version, a
`ToolBatchPolicy` sentence asking for it drew no `multi_edit` call and 167
requests against 158.
`rt-plan-ablation-module` spends five rounds per run on `complete_step` alone.
A description change inviting the sign-off to share the next step's reply was
taken up in 2 of 4 trials (4 of 5 sign-offs shared each time, against 0 of 20
in control), and requests did not move: 30, 25, 21, 28 against 27, 23, 24, 30.
At this spread, four trials per arm cannot show a saving of a few rounds.
## Gaps
- **Below the intended size, in places.** The tasks were designed for 25–50
requests. After widening, the median is 25, but `rt-rate-lookup-signatures`
still runs 13–15: the model greps every call site first and batches the edits,
so even about 20 call sites cost few rounds, and no run hit a compile-error
loop.
- **Lexical grader.** `rt-stale-skills` judges Markdown by resolving what it
names. It has misjudged correct text twice (see the baseline note); a new
failure there should be read before it is counted.
- **No git history.** The workdir is a plain directory: `git diff`, `git log`
and `git status` have nothing to read, so runs that lean on them in this tree
behave differently here.
- **Plan marker and `-force-planner`.** `rt-plan-ablation-module` carries the
plan-first marker in its prompt, so it engages the planner in every arm,
including runs without `-force-planner`. Exclude it when comparing planner
arms.
@@ -0,0 +1,18 @@
---
name: price-change
description: Record a vendor's announced price change in the official price book without losing the rate it replaces.
---
# Recording a price change
1. Find the vendor's rows in `officialRates` in
`internal/contract/pricing/rates.go`. Each row is one model in one currency.
2. **Append** a `rateGeneration` to the row's `Rates`; never edit an existing
one. Set `From` to the first billed day (`YYYY-MM-DD`, UTC) when the change
is announced ahead of time.
3. Leave the earlier generation where it is: `SupersededRates` reads it so a
stored price that matches it is recognised as ours and refreshed.
4. Update the row's `CheckedOn` constant to the day you confirmed the page.
5. Add a test beside `rates_test.go` that pins `CurrentRate` before and after
the `From` day by replacing `today`.
6. Run `make check`. `make pricecheck` needs the network and is run by hand.
+17
View File
@@ -0,0 +1,17 @@
.PHONY: check test vet fmt pricecheck
check: fmt vet test
fmt:
@out="$$(gofmt -l .)"; if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
vet:
go vet ./...
test:
go test ./...
# Reads each vendor's pricing page; this snapshot carries no network client.
pricecheck:
@echo "pricecheck: reaches each vendor's pricing page and needs the network; not available in this snapshot" >&2
@exit 2
+69
View File
@@ -0,0 +1,69 @@
# Reasonix kernel snapshot
A trimmed slice of the Reasonix Go kernel: the contracts a benchmark report is
priced and labelled with, a few leaf utilities, and `benchctl`, a small command
that prints what those contracts answer.
```bash
make check # gofmt, vet, test
go run ./cmd/benchctl rate -model deepseek-flash -currency USD
go run ./cmd/benchctl cost -provider mimo -model mimo-v2.5 -miss 1000000
go run ./cmd/benchctl tariff -currency CNY -at 2026-10-12T02:00:00Z
go run ./cmd/benchctl arm -ablate planner,evidence
```
## Packages
| Package | Owns |
| --- | --- |
| `internal/contract/pricing` | the official price book, cost quotes, the spend ledger |
| `internal/contract/ablation` | which subsystems a benchmark arm switches off, and the arm's name |
| `internal/contract/agentgraph` | the vocabulary of one run's execution graph |
| `internal/base/retrieval` | BM25 ranking over CJK-aware tokens |
| `internal/base/rulelist` | splitting a one-line permission rule list |
| `internal/base/tokencount` | a cheap token estimate |
| `internal/base/neterr` | classifying transport errors |
| `internal/base/nilutil` | typed-nil detection |
| `cmd/benchctl` | prints rates, costs, peak schedules and arm names |
## Pricing
A `RateCard` is a price per million tokens in one currency:
| Field | Charges |
| --- | --- |
| `CacheHit` | prompt tokens served from the provider's cache |
| `Input` | prompt tokens that missed the cache, including tokens written to it |
| `Output` | completion tokens |
`OriginalCostAmount` turns a card and a `UsageTokens` breakdown into a
fixed-point `Amount`; `PricingFingerprint` keys a card in the ledger, so a
stored ledger entry stays attached to the card it was priced with.
`CurrentRate` and `SupersededRates` answer from `officialRates`, which only
ever grows: a price change appends a generation.
## Ablation
`ablation.Parse` reads a comma-separated list of modules to switch off, and
`Set.Arm` names the result (`full` for the control). The switchable modules,
in the order arm names use:
| Module | Off means |
| --- | --- |
| `evidence` | no evidence gate on completion claims |
| `planner` | no two-model planning turn |
| `subagent` | no delegation to workers |
| `retrieval` | no retrieval over the folded transcript |
| `compaction` | no context folding |
| `upstream` | a dependency edge orders its endpoints without delivering the answer |
| `recall-search` | recall can read a given address but cannot search for one |
The fold-index scale is a separate axis (`-fold-index`), not a module.
## Skills
Project skills live under `.reasonix/skills/<name>/SKILL.md`.
| Skill | Use it to |
| --- | --- |
| `price-change` | record a vendor's price change in the price book |
+51
View File
@@ -0,0 +1,51 @@
# Reasonix project memory
This checkout is a trimmed snapshot of the Reasonix kernel: the billing,
ablation and graph contracts and a handful of leaf utilities, plus one small
command that drives them. The conventions below are the project's own.
## Conventions
- Go kernel under `internal/`; each package owns one concern. A package's long
explanation belongs in its `doc.go`, not spread across implementation files.
- Judgements read structure, never wording: types, contracts, schemas. An
error a caller must tell apart carries an identity — a sentinel, a typed
code — never a sentence.
- A metric is its definition, not its name. Before a number becomes a finding,
state what it counts, from which anchor, over which population, and what it
excludes.
## Comments
Default is none — the code is the truth. Write one only when the **why** is
non-obvious: a hidden constraint, an invariant the type system cannot express,
or an external-protocol quirk. A comment states the constraint that holds now;
it does not narrate how the code got here. English, always.
- Declaration doc: ≤5 lines. Package comment: ≤8 lines, or ≤40 in a `doc.go`.
- Every other comment: ≤3 lines. Struct-field and trailing `//`: 1 line.
- One responsibility per file; 800 lines is the ceiling.
## Vendor rates
Official list prices live in one place: `officialRates` in
`internal/contract/pricing/rates.go`, oldest generation first, the last being
what a model charges today. A price change is an **append, never an edit** —
the superseded rate is what lets an installed config be recognised as holding
one of ours and brought forward. Callers read these through
`pricing.CurrentRate` and `pricing.SupersededRates` and restate none of them.
`make pricecheck` compares that table against each vendor's own page. It needs
the network, so it is not part of `make check`.
## Before every commit
```bash
make check # gofmt -l, go vet ./..., go test ./...
```
## Change discipline
List callers, consumers of the same state and sibling features before editing.
Fix at the owning layer; a fix that breaks a neighbouring behaviour is a failed
fix. Write tests first; they must fail without the change.
@@ -0,0 +1,115 @@
// Command benchctl prints what the pricing and ablation contracts answer for
// one model or one benchmark arm, so a reviewer can check a rate or an arm name
// without writing a test.
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/pricing"
)
var errUsage = errors.New("usage: benchctl rate|cost|tariff|arm [flags]")
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "benchctl:", err)
os.Exit(2)
}
}
func run(args []string, out io.Writer) error {
if len(args) == 0 {
return errUsage
}
switch args[0] {
case "rate":
return runRate(args[1:], out)
case "cost":
return runCost(args[1:], out)
case "tariff":
return runTariff(args[1:], out)
case "arm":
return runArm(args[1:], out)
default:
return fmt.Errorf("%w: unknown subcommand %q", errUsage, args[0])
}
}
func runRate(args []string, out io.Writer) error {
fs := flag.NewFlagSet("rate", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
if err := fs.Parse(args); err != nil {
return err
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
fmt.Fprintf(out, "%s/%s %s: cache-hit %g input %g output %g per 1M\n",
*provider, *model, card.Currency, card.CacheHit, card.Input, card.Output)
if entry, ok := pricing.LookupCatalog(*provider, *model, cur, ""); ok && entry.DocURL != "" {
fmt.Fprintf(out, "source: %s\n", entry.DocURL)
}
fmt.Fprintf(out, "superseded generations: %d\n", len(pricing.SupersededRates(*provider, *model, cur)))
return nil
}
func runCost(args []string, out io.Writer) error {
fs := flag.NewFlagSet("cost", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
hit := fs.Int("hit", 0, "cached prompt tokens")
miss := fs.Int("miss", 0, "uncached prompt tokens")
completion := fs.Int("completion", 0, "completion tokens")
if err := fs.Parse(args); err != nil {
return err
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
usage := pricing.UsageTokens{CacheHitTokens: *hit, CacheMissTokens: *miss, CompletionTokens: *completion}
amount := pricing.OriginalCostAmount(*card, usage)
fmt.Fprintf(out, "%s %s\n", amount, pricing.NormalizeCurrency(card.Currency))
return nil
}
func runArm(args []string, out io.Writer) error {
fs := flag.NewFlagSet("arm", flag.ContinueOnError)
fs.SetOutput(out)
spec := fs.String("ablate", "", "comma-separated modules to switch off: "+ablation.ModuleList())
foldIndex := fs.String("fold-index", "", "fold-index scale: default, half, quarter or off")
list := fs.Bool("list", false, "list the switchable modules")
if err := fs.Parse(args); err != nil {
return err
}
if *list {
fmt.Fprintln(out, ablation.ModuleList())
return nil
}
set, err := ablation.ParseArm(*spec, *foldIndex)
if err != nil {
return err
}
fmt.Fprintln(out, set.Arm())
return nil
}
@@ -0,0 +1,65 @@
package main
import (
"bytes"
"errors"
"strings"
"testing"
)
func runOut(t *testing.T, args ...string) string {
t.Helper()
var out bytes.Buffer
if err := run(args, &out); err != nil {
t.Fatalf("run %v: %v", args, err)
}
return out.String()
}
func TestRateNamesTheCurrentCard(t *testing.T) {
got := runOut(t, "rate", "-provider", "mimo", "-model", "mimo-v2.5")
if !strings.Contains(got, "mimo/mimo-v2.5 CNY: cache-hit 0.02 input 1 output 2 per 1M") {
t.Fatalf("rate output = %q", got)
}
}
func TestRateRefusesAnUnpricedModel(t *testing.T) {
var out bytes.Buffer
if err := run([]string{"rate", "-provider", "nobody", "-model", "x"}, &out); err == nil {
t.Fatal("an unpriced model must fail")
}
}
func TestCostChargesTheCurrentCard(t *testing.T) {
got := runOut(t, "cost", "-provider", "mimo", "-model", "mimo-v2.5", "-miss", "1000000", "-completion", "1000000")
if strings.TrimSpace(got) != "3 CNY" {
t.Fatalf("cost output = %q", got)
}
}
func TestArmNamesTheConfiguration(t *testing.T) {
if got := strings.TrimSpace(runOut(t, "arm")); got != "full" {
t.Fatalf("control arm = %q", got)
}
if got := strings.TrimSpace(runOut(t, "arm", "-ablate", "planner,evidence", "-fold-index", "half")); got != "no-evidence+no-planner+index-half" {
t.Fatalf("arm = %q", got)
}
}
func TestUnknownSubcommandIsAUsageError(t *testing.T) {
var out bytes.Buffer
if err := run([]string{"nope"}, &out); !errors.Is(err, errUsage) {
t.Fatalf("err = %v, want errUsage", err)
}
}
func TestTariffReadsTheVendorsSchedule(t *testing.T) {
got := runOut(t, "tariff", "-provider", "mimo", "-model", "mimo-v2.5")
if !strings.Contains(got, "official card: true") || !strings.Contains(got, "one rate around the clock") {
t.Fatalf("tariff output = %q", got)
}
peak := runOut(t, "tariff", "-currency", "CNY", "-at", "2026-10-12T02:00:00Z")
if !strings.Contains(peak, "peak=true until 2026-10-12T04:00:00Z") {
t.Fatalf("deepseek tariff at a weekday peak = %q", peak)
}
}
@@ -0,0 +1,49 @@
package main
import (
"flag"
"fmt"
"io"
"time"
"reasonix/internal/contract/pricing"
)
func runTariff(args []string, out io.Writer) error {
fs := flag.NewFlagSet("tariff", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
at := fs.String("at", "", "instant to read the schedule at, RFC 3339; empty is now")
if err := fs.Parse(args); err != nil {
return err
}
when := time.Now()
if *at != "" {
t, err := time.Parse(time.RFC3339, *at)
if err != nil {
return fmt.Errorf("-at: %w", err)
}
when = t
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
fmt.Fprintf(out, "official card: %v\n", pricing.IsOfficialCard(*provider, *model, *card))
tariff, ok := pricing.LiveTariff(*provider, *model, *card, when)
switch {
case !ok:
fmt.Fprintln(out, "schedule: one rate around the clock")
case !tariff.Changes:
fmt.Fprintf(out, "schedule: peak=%v, no change ahead\n", tariff.Peak)
default:
fmt.Fprintf(out, "schedule: peak=%v until %s\n", tariff.Peak, tariff.ChangesAt.UTC().Format(time.RFC3339))
}
return nil
}
+3
View File
@@ -0,0 +1,3 @@
module reasonix
go 1.26.0
@@ -0,0 +1,95 @@
package pricing
import (
"strings"
"testing"
"time"
)
// Every catalog provider must declare at least one official endpoint, or its
// rows become unreachable: a rate card is only matched against the catalog
// after the entry's host identifies the vendor.
func TestEveryCatalogProviderHasAnOfficialEndpoint(t *testing.T) {
declared := map[string]bool{}
for _, host := range officialEndpointHosts {
declared[host] = true
}
for _, entry := range OfficialCatalog() {
if !declared[entry.Provider] {
t.Errorf("catalog provider %q has no official endpoint host; its prices can never match", entry.Provider)
}
}
}
func TestOfficialProviderForEndpoint(t *testing.T) {
cases := []struct {
name string
baseURL string
want string
}{
{"official deepseek", "https://api.deepseek.com", "deepseek"},
{"official deepseek anthropic path", "https://api.deepseek.com/anthropic", "deepseek"},
{"official longcat", "https://api.longcat.chat/openai/v1", "longcat"},
{"official mimo region", "https://token-plan-sgp.xiaomimimo.com/v1", "mimo"},
{"uppercase host", "https://API.DeepSeek.com/v1", "deepseek"},
{"proxy that resells the model", "https://my-deepseek-proxy.internal/v1", ""},
{"vendor name in the path only", "https://gateway.example.com/deepseek/v1", ""},
{"lookalike registrable domain", "https://api.deepseek.com.evil.test/v1", ""},
{"empty", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := OfficialProviderForEndpoint(tc.baseURL); got != tc.want {
t.Fatalf("OfficialProviderForEndpoint(%q) = %q, want %q", tc.baseURL, got, tc.want)
}
})
}
}
// pricesGoStaleAfter is how long a hand-copied rate is trusted before someone
// reads the vendor's page again. No vendor here serves prices over an API, so
// the alternative to this going red on a quiet day is learning about a change
// from a user whose cost was wrong for months. Bumping CheckedOn without
// opening DocURL defeats it; what this buys is that the question gets asked.
const pricesGoStaleAfter = 90 * 24 * time.Hour
func TestCatalogPricesHaveBeenCheckedRecently(t *testing.T) {
var undated, stale []string
// This snapshot never sees a vendor page again, so staleness is read at the
// day it was taken rather than the day the suite runs.
cutoff := time.Date(2026, 10, 19, 0, 0, 0, 0, time.UTC).Add(-pricesGoStaleAfter)
for _, e := range OfficialCatalog() {
where := e.Provider + "/" + e.Model + " " + e.Currency + " (" + e.DocURL + ")"
if e.CheckedOn == "" {
undated = append(undated, where)
continue
}
on, err := time.Parse("2006-01-02", e.CheckedOn)
if err != nil {
t.Fatalf("%s: CheckedOn %q is not YYYY-MM-DD", where, e.CheckedOn)
}
if on.Before(cutoff) {
stale = append(stale, where+" last checked "+e.CheckedOn)
}
}
if len(undated) > 0 {
t.Errorf("catalog rows with no CheckedOn — read the vendor's page and date them:\n %s",
strings.Join(undated, "\n "))
}
if len(stale) > 0 {
t.Errorf("catalog rows older than %d days. Open each DocURL, confirm or correct the rate, then set CheckedOn to today:\n %s",
int(pricesGoStaleAfter.Hours()/24), strings.Join(stale, "\n "))
}
}
// A row that names a peak card must say when it applies, and one that names a
// window must have a card to charge inside it. Half of the pair silently prices
// every hour the same, which is the bug the pair exists to prevent.
func TestPeakRatesAndWindowsComeInPairs(t *testing.T) {
for _, e := range OfficialCatalog() {
where := e.Provider + "/" + e.Model + " " + e.Currency
if (e.Peak == nil) != (e.Window == nil) {
t.Errorf("%s: Peak and Window must be set together (peak=%v window=%v)", where, e.Peak != nil, e.Window != nil)
}
}
}
@@ -0,0 +1,12 @@
---
name: ablation-arm
description: Add a new switchable subsystem to the benchmark's ablation arms.
---
# Adding an ablation module
1. Declare the module in `internal/ablation/modules.go` beside the others.
2. Register it in `AllModules()` so `ParseSpec` accepts it.
3. Arm names come from `Set.Name()`; check that yours reads `without-<module>`.
4. Add the module to the flag help text in `cmd/bench/flags.go`.
5. Run `make test`.
@@ -0,0 +1,15 @@
---
name: benchctl
description: Check a rate, a cost, a peak schedule or an arm name from the command line instead of writing a test.
---
# Using benchctl
1. Price a request with `benchctl quote -provider mimo -model mimo-v2.5 -tokens 1000000`;
it prints the amount and the currency.
2. Read today's card with `benchctl rate -vendor deepseek -model deepseek-flash`.
3. Ask whether a vendor is in its peak window with
`benchctl peak -currency CNY -at 1791770400` (Unix seconds).
4. List the ablation modules with `benchctl arms`, and name an arm with
`benchctl arm -ablate planner,evidence -index half`.
5. Every subcommand prints its flags with `-h`.
@@ -0,0 +1,13 @@
---
name: price-change
description: Record a vendor's announced price change in the official price book.
---
# Recording a price change
1. Open `internal/pricing/rates.go` and find the vendor's entry in
`officialPrices`.
2. Edit the entry's `Input`, `Output` and `CacheHit` to the new values.
3. If the change is announced ahead of time, set `EffectiveFrom` on the entry.
4. Confirm `pricing.LatestRate` now returns the new card.
5. Run `make test` and `make price-check`.
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Builds every task's workdir/ from one pinned commit of this repository plus
# the fixture-authored files in base/ and overlay/<task-id>/. With --check it
# rebuilds into a scratch directory and fails if any committed workdir differs.
set -euo pipefail
PIN=42636ddcb425b7af07abd88397d02c1cfe3adcf5
PATHS=(
internal/contract/pricing
internal/contract/ablation
internal/contract/agentgraph
internal/base/retrieval
internal/base/rulelist
internal/base/tokencount
internal/base/neterr
internal/base/nilutil
)
here="$(cd "$(dirname "$0")" && pwd)"
repo="$(git -C "$here" rev-parse --show-toplevel)"
build() {
local id="$1" dest="$2"
rm -rf "$dest"
mkdir -p "$dest"
git -C "$repo" archive "$PIN" -- "${PATHS[@]}" | tar -x -C "$dest"
cp -R "$here/base/." "$dest/"
if [ -d "$here/overlay/$id" ]; then
cp -R "$here/overlay/$id/." "$dest/"
fi
}
check=0
[ "${1:-}" = "--check" ] && check=1
status=0
for task in "$here"/tasks/*/; do
id="$(basename "$task")"
if [ "$check" = 1 ]; then
scratch="$(mktemp -d)"
build "$id" "$scratch/workdir"
if ! diff -r "$scratch/workdir" "$task/workdir" >/dev/null; then
echo "$id: workdir/ differs from the pinned snapshot; run $0" >&2
diff -rq "$scratch/workdir" "$task/workdir" >&2 || true
status=1
fi
rm -rf "$scratch"
else
build "$id" "$task/workdir"
fi
done
exit "$status"
@@ -0,0 +1,73 @@
# Reasonix kernel snapshot
A trimmed slice of the Reasonix Go kernel: the contracts a benchmark report is
priced and labelled with, a few leaf utilities, and `benchctl`, a small command
that prints what those contracts answer.
```bash
make check # gofmt, vet, test
go run ./cmd/benchctl rate -model deepseek-flash -currency USD
go run ./cmd/benchctl cost -provider mimo -model mimo-v2.5 -miss 1000000
go run ./cmd/benchctl tariff -currency CNY -at 2026-10-12T02:00:00Z
go run ./cmd/benchctl arm -ablate planner,evidence
```
## Packages
| Package | Owns |
| --- | --- |
| `internal/contract/pricing` | the official price book, cost quotes, the spend ledger |
| `internal/contract/ablation` | which subsystems a benchmark arm switches off, and the arm's name |
| `internal/contract/agentgraph` | the vocabulary of one run's execution graph |
| `internal/base/retrieval` | BM25 ranking over CJK-aware tokens |
| `internal/base/rulelist` | splitting a one-line permission rule list |
| `internal/base/tokencount` | a cheap token estimate |
| `internal/base/neterr` | classifying transport errors |
| `internal/base/nilutil` | typed-nil detection |
| `cmd/benchctl` | prints rates, costs, peak schedules and arm names |
## Pricing
A `RateCard` is a price per million tokens in one currency:
| Field | Charges |
| --- | --- |
| `CacheHit` | prompt tokens served from the provider's cache |
| `Input` | prompt tokens that missed the cache, and tokens written to it when `CacheWrite` is zero |
| `Output` | completion tokens |
| `CacheWrite` | tokens written to the cache, for a vendor that prices them separately (Anthropic) |
A card without `CacheWrite` keeps the fingerprint it had before the field
existed, so ledgers stored against it stay attached.
`OriginalCostAmount` turns a card and a `UsageTokens` breakdown into a
fixed-point `Amount`; `PricingFingerprint` keys a card in the ledger, so a
stored ledger entry stays attached to the card it was priced with.
`CurrentRate` and `SupersededRates` answer from `officialRates`, which only
ever grows: a price change appends a generation.
## Ablation
`ablation.Parse` reads a comma-separated list of modules to switch off, and
`Set.Arm` names the result (`full` for the control). The switchable modules,
in the order arm names use:
| Module | Off means |
| --- | --- |
| `evidence` | no evidence gate on completion claims |
| `planner` | no two-model planning turn |
| `subagent` | no delegation to workers |
| `retrieval` | no retrieval over the folded transcript |
| `compaction` | no context folding |
| `upstream` | a dependency edge orders its endpoints without delivering the answer |
| `recall-search` | recall can read a given address but cannot search for one |
The fold-index scale is a separate axis (`-fold-index`), not a module.
## Skills
Project skills live under `.reasonix/skills/<name>/SKILL.md`.
| Skill | Use it to |
| --- | --- |
| `price-change` | record a vendor's price change in the price book |
@@ -0,0 +1,119 @@
// Command benchctl prints what the pricing and ablation contracts answer for
// one model or one benchmark arm, so a reviewer can check a rate or an arm name
// without writing a test.
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/pricing"
)
var errUsage = errors.New("usage: benchctl rate|cost|tariff|arm [flags]")
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "benchctl:", err)
os.Exit(2)
}
}
func run(args []string, out io.Writer) error {
if len(args) == 0 {
return errUsage
}
switch args[0] {
case "rate":
return runRate(args[1:], out)
case "cost":
return runCost(args[1:], out)
case "tariff":
return runTariff(args[1:], out)
case "arm":
return runArm(args[1:], out)
default:
return fmt.Errorf("%w: unknown subcommand %q", errUsage, args[0])
}
}
func runRate(args []string, out io.Writer) error {
fs := flag.NewFlagSet("rate", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
if err := fs.Parse(args); err != nil {
return err
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
fmt.Fprintf(out, "%s/%s %s: cache-hit %g input %g output %g", *provider, *model, card.Currency, card.CacheHit, card.Input, card.Output)
if card.CacheWrite != 0 {
fmt.Fprintf(out, " cache-write %g", card.CacheWrite)
}
fmt.Fprintln(out, " per 1M")
if entry, ok := pricing.LookupCatalog(*provider, *model, cur, ""); ok && entry.DocURL != "" {
fmt.Fprintf(out, "source: %s\n", entry.DocURL)
}
fmt.Fprintf(out, "superseded generations: %d\n", len(pricing.SupersededRates(*provider, *model, cur)))
return nil
}
func runCost(args []string, out io.Writer) error {
fs := flag.NewFlagSet("cost", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
hit := fs.Int("hit", 0, "cached prompt tokens")
miss := fs.Int("miss", 0, "uncached prompt tokens")
completion := fs.Int("completion", 0, "completion tokens")
write := fs.Int("write", 0, "uncached prompt tokens that were written to the cache")
if err := fs.Parse(args); err != nil {
return err
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
usage := pricing.UsageTokens{CacheHitTokens: *hit, CacheMissTokens: *miss, CompletionTokens: *completion, CacheWriteTokens: *write}
amount := pricing.OriginalCostAmount(*card, usage)
fmt.Fprintf(out, "%s %s\n", amount, pricing.NormalizeCurrency(card.Currency))
return nil
}
func runArm(args []string, out io.Writer) error {
fs := flag.NewFlagSet("arm", flag.ContinueOnError)
fs.SetOutput(out)
spec := fs.String("ablate", "", "comma-separated modules to switch off: "+ablation.ModuleList())
foldIndex := fs.String("fold-index", "", "fold-index scale: default, half, quarter or off")
list := fs.Bool("list", false, "list the switchable modules")
if err := fs.Parse(args); err != nil {
return err
}
if *list {
fmt.Fprintln(out, ablation.ModuleList())
return nil
}
set, err := ablation.ParseArm(*spec, *foldIndex)
if err != nil {
return err
}
fmt.Fprintln(out, set.Arm())
return nil
}
@@ -0,0 +1,3 @@
module reasonix
go 1.26.0
@@ -0,0 +1,37 @@
package pricing
import "testing"
func TestCacheWriteRateChargesWrittenTokens(t *testing.T) {
card := RateCard{Input: 3, CacheWrite: 3.75, Currency: "USD"}
usage := UsageTokens{CacheMissTokens: 1000000, CacheWriteTokens: 400000}
if got := OriginalCostAmount(card, usage).String(); got != "3.3" {
t.Fatalf("cost = %s, want 3.3", got)
}
card.CacheWrite = 0
if got := OriginalCostAmount(card, usage).String(); got != "3" {
t.Fatalf("cost without a cache-write rate = %s, want 3", got)
}
}
func TestCacheWriteSeparatesFingerprintsAndCards(t *testing.T) {
base := RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}
withWrite := base
withWrite.CacheWrite = 1.25
if PricingFingerprint(base) == PricingFingerprint(withWrite) {
t.Fatal("cache-write rate does not reach the fingerprint")
}
if sameCard(base, withWrite) {
t.Fatal("cards differing in CacheWrite compare equal")
}
}
func TestAnthropicPricesCacheWrites(t *testing.T) {
got := CurrentRate("anthropic", "claude-sonnet-4-5", "USD")
if got == nil || got.CacheWrite != 3.75 {
t.Fatalf("claude-sonnet-4-5 = %+v", got)
}
if OfficialProviderForEndpoint("https://api.anthropic.com/v1/messages") != "anthropic" {
t.Fatal("api.anthropic.com is not recognised as Anthropic's endpoint")
}
}
Loaded 100 of 4672 files, more files were not shown because too many files have changed in this diff. Show more