Compare commits

...
789 Commits
Author SHA1 Message Date
YHH 5d784ed6d6 Merge pull request #13174 from esengine/release/2.41.0
docs(release): Studio 2.41.0 notes
2026-10-14 17:07:43 -07:00
esengine 3524a9fb6a docs(release): Studio 2.41.0 notes 2026-10-14 16:57:39 -07:00
YHH 16a07336fa Merge pull request #13173 from esengine/fix/stream-failure-recovery-flake
test(session): bound the stream-failure recovery turns by the hang budget
2026-10-14 15:40:44 -07:00
esengine 372acad69d test(session): bound the stream-failure recovery turns by the hang budget 2026-10-14 15:31:45 -07:00
YHH 0694a186ae Merge pull request #13172 from esengine/sweep/390-leftovers
fix(studio): narrow-width step rows, meter rail and config error block
2026-10-14 15:18:01 -07:00
esengine 690681194a fix(studio): narrow-width step rows, meter rail and config error block 2026-10-14 15:09:35 -07:00
YHH e5f5b75801 Merge pull request #13171 from esengine/docs/frag-13169
docs(release): fragment for the narrow-width layout fixes
2026-10-14 14:48:12 -07:00
YHH ca121eaf9b Merge pull request #13170 from esengine/sweep/tools-args
test(studio): i18n and fault sweeps take a viewport and wait for the settings control
2026-10-14 14:47:25 -07:00
esengine 90effbe6b2 docs(release): fragment for the narrow-width layout fixes 2026-10-14 14:40:43 -07:00
esengine 0d9e678ea8 test(studio): i18n and fault sweeps take a viewport, wait for the settings control and print it 2026-10-14 14:37:04 -07:00
YHH e0ca9a4779 Merge pull request #13169 from esengine/sweep/narrow
fix(studio): narrow-width layout and contrast defects found by the sweep
2026-10-14 14:29:57 -07:00
esengine 47831de6bf fix(studio): narrow-width layout and contrast defects found by the sweep 2026-10-14 14:22:05 -07:00
YHH 1345212f38 Merge pull request #13165 from esengine/fix/bwrap-probe-net
fix(sandbox): probe bubblewrap with the network flag the launch will use
2026-10-14 13:50:56 -07:00
YHH 34ebef5524 Merge pull request #13168 from esengine/perf/11885-effect-tests
test(boot): build SDK examples against the host go build cache
2026-10-14 13:08:40 -07:00
YHH b661446471 Merge pull request #13167 from esengine/docs/frag-13166
docs(release): fragment for keeping a folded paste as a file in the terminal
2026-10-14 13:05:50 -07:00
esengine ddedaffdf0 test(boot): build SDK examples against the host go build cache 2026-10-14 12:56:51 -07:00
esengine 0c45db5968 docs(release): fragment for keeping a folded paste as a file in the terminal 2026-10-14 12:55:33 -07:00
YHH 399e232141 Merge pull request #13166 from esengine/feat/13148-tui-paste
feat(tui): keep a folded paste as a file with Ctrl+G
2026-10-14 12:51:39 -07:00
esengine 108f41e16d feat(tui): keep a folded paste as a file with Ctrl+G 2026-10-14 12:43:24 -07:00
esengine 5418317cf8 test(plugin): pin that a confined MCP starts unconfined when bwrap cannot unshare the network 2026-10-14 12:37:47 -07:00
YHH ce7460d071 Merge pull request #13164 from esengine/docs/guide-allow-devices
docs(guide): document the sandbox device allowlist
2026-10-14 12:34:21 -07:00
esengine cda2915da4 fix(sandbox): probe bubblewrap with the network flag the launch will use 2026-10-14 12:32:52 -07:00
esengine 845edced30 docs(guide): document the sandbox device allowlist 2026-10-14 12:27:00 -07:00
YHH 4636880a69 Merge pull request #13163 from esengine/docs/frag-13162
docs(release): fragment for the keep-a-paste-as-a-file chip action
2026-10-14 12:02:55 -07:00
esengine 6dd6a23388 docs(release): fragment for the keep-a-paste-as-a-file chip action 2026-10-14 11:55:47 -07:00
YHH de12671c0f Merge pull request #13162 from esengine/feat/13148-paste-to-file
feat(studio): keep a long paste as a file on request
2026-10-14 11:37:55 -07:00
esengine d519e83100 test(paste): range over an integer 2026-10-14 11:26:20 -07:00
esengine abf8ec53b2 refactor(paste): move the paste chip's keep action into its own module 2026-10-14 11:26:20 -07:00
esengine 2fa2487e0d feat(paste): style the save action 2026-10-14 11:26:20 -07:00
esengine d6b5e34bc8 feat(paste): keep a long paste as a file on request 2026-10-14 11:26:20 -07:00
esengine 94dc1671c1 wip(paste): keep a long paste as a file on request 2026-10-14 11:26:20 -07:00
YHH 75f2d3c38f Merge pull request #13161 from esengine/feat/13157-allow-devices
feat(sandbox): bind user-allowed host device nodes into the Linux bash sandbox
2026-10-14 11:25:37 -07:00
YHH a7bf4b1205 Merge pull request #13160 from esengine/feat/13140-x25519-fallback
feat(remote): fall back to a software X25519 when the browser has none
2026-10-14 11:22:22 -07:00
esengine 9ed45e1e9f feat(sandbox): bind user-allowed host device nodes into the Linux bash sandbox 2026-10-14 11:11:32 -07:00
esengine 69400e57ac feat(remote): fall back to a software X25519 when the browser has none 2026-10-14 11:06:06 -07:00
YHH adb7bf8cc7 Merge pull request #13159 from esengine/fix/13152-say-which-path-check
fix(serve): resume says whether the transcript is gone, outside, or unreadable and why
2026-10-14 10:54:58 -07:00
YHH b3523730f5 Merge pull request #13158 from esengine/docs/frag-11851
docs(release): fragment for the naming model and per-role effort
2026-10-14 10:50:03 -07:00
YHH 922c1bfe85 Merge pull request #13155 from esengine/feat/13141-ideas-entry
feat(studio): point ideas and questions from the feedback dialog to Discussions
2026-10-14 10:46:18 -07:00
esengine a5e966e98e fix(serve): resume says whether the transcript is gone, outside, or unreadable and why 2026-10-14 10:44:14 -07:00
YHH f690a0537c Merge pull request #13156 from esengine/docs/release-fragments-2-41
docs(release): fragments for the user-visible changes merged since 2.40.0
2026-10-14 10:42:31 -07:00
esengine ffe680d3f5 docs(release): fragment for the naming model and per-role effort 2026-10-14 10:40:08 -07:00
YHH b9a87f8bda Merge pull request #11851 from Guan0923/codex/model-role-preferences
feat(studio): add an independent naming model and per-role reasoning effort
2026-10-14 10:37:43 -07:00
YHH 5247d0acdc Merge pull request #13154 from esengine/fix/oobe-skip-contrast
fix(studio): lift the intro hint and the changed-files counts over AA contrast
2026-10-14 10:37:18 -07:00
esengine 1ff55e8026 docs(release): fragments for the user-visible changes merged since 2.40.0 2026-10-14 10:32:41 -07:00
esengine e788d83765 feat(studio): point ideas and questions from the feedback dialog to Discussions 2026-10-14 10:28:02 -07:00
YHH fd9c4e44dd Merge branch 'studio' into codex/model-role-preferences 2026-10-14 10:23:10 -07:00
esengine 9d34d5ecab fix(studio): lift the intro hint and the changed-files counts over AA contrast 2026-10-14 10:18:47 -07:00
YHH f8c262da03 Merge pull request #13151 from esengine/feat/13148-precheck
feat(compaction): refuse a fold that cannot fit before paying for its summary
2026-10-14 10:04:07 -07:00
YHH be0305d665 Merge pull request #13150 from esengine/feat/13110-spec-name
feat(skill): report front matter that departs from the Agent Skills spec
2026-10-14 09:57:17 -07:00
esengine f5a442c133 feat(compaction): refuse a fold that cannot fit before paying for its summary 2026-10-14 09:55:00 -07:00
esengine 7d15f23c63 feat(skill): report front matter that departs from the Agent Skills spec 2026-10-14 09:47:08 -07:00
YHH 4bee13505c Merge pull request #13149 from esengine/fix/13148-compaction-accept
feat(compaction): say what holds a rejected candidate above the trigger
2026-10-14 09:41:47 -07:00
esengine ce60d21610 feat(compaction): say what holds a rejected candidate above the trigger 2026-10-14 09:33:56 -07:00
YHH 4db0f505d0 Merge pull request #13147 from esengine/fix/12948-recall-stale-fact
fix(memory): re-read the facts the last recall returned before recalling again
2026-10-14 09:29:39 -07:00
YHH bc6f415d98 Merge pull request #13144 from xxoingr/docs/guide-tui-keys-as-built
docs(guide): describe the terminal keys 2.x has
2026-10-14 09:27:59 -07:00
YHH 5d985236c2 Merge pull request #13146 from esengine/feat/bwrap-unrunnable
feat(sandbox): a bwrap that cannot be started is not reported as a denied namespace
2026-10-14 09:25:35 -07:00
esengine 99fe3dbbf1 fix(memory): re-read the facts the last recall returned before recalling again 2026-10-14 09:19:58 -07:00
YHH d7fa4c3b0f Merge pull request #13143 from esengine/feat/13052-context-parts
feat(studio): open a context class to the messages that made it heavy
2026-10-14 09:18:58 -07:00
YHH f1003c74a0 Merge pull request #13139 from esengine/feat/13108-list-ttl
feat(mcp): re-read a tool catalog when the server's ttlMs runs out
2026-10-14 09:10:19 -07:00
YHH 98ebd85258 Merge pull request #13145 from esengine/fix/13140-x25519-fallback
fix(remote): say the browser is too old when it has no X25519
2026-10-14 09:09:31 -07:00
esengine ee27c844d7 feat(sandbox): a bwrap that cannot be started is not reported as a denied namespace 2026-10-14 09:06:03 -07:00
YHH e591109795 Merge pull request #13138 from esengine/feat/bwrap-typed-reason
feat(sandbox): say why bubblewrap is unusable: missing, or installed but denied a namespace
2026-10-14 09:03:16 -07:00
esengine 612f4ad550 feat(mcp): re-read a tool catalog when the server's ttlMs runs out 2026-10-14 09:00:45 -07:00
esengine 7668609b08 fix(remote): say the browser is too old when it has no X25519 2026-10-14 08:59:37 -07:00
xxoingr 717baf5ecd docs(guide): describe the terminal keys 2.x has
The CLI/TUI shortcut tables in docs/GUIDE.md and docs/GUIDE.zh-CN.md still
described four 1.x behaviours the 2.x terminal UI does not have (#12144
lists them as missing):

- Up/Down "navigate queued follow-up feedback" while a turn runs; in 2.x
  they recall history there too (recallAtEdge has no running case), and
  queued items are managed with /queue.
- Esc "un-sends a just-submitted turn before any reply"; escape() cancels a
  running turn, clears input, or leaves ! shell mode, and nothing un-sends.
- Composer text selection by drag with replace-on-type; onMouse ignores a
  left click below the transcript viewport, so only transcript text is
  selectable, and Ctrl+C copies only that.
- The rewind picker's f (fork); rewindActions has b c d s u.

Two tests pin the rows as now written: Up recalls while a turn runs, and
Esc on an empty composer leaves shell mode.
2026-10-14 23:54:03 +08:00
esengine efdbf04694 feat(studio): open a context class to the messages that made it heavy 2026-10-14 08:54:02 -07:00
esengine 7d75708986 feat(sandbox): say why bubblewrap is unusable: missing, or installed but denied a namespace 2026-10-14 08:39:42 -07:00
YHH 33730cb13c Merge pull request #13137 from esengine/feat/13132-row-quick-actions
feat(studio): opt-in hover Archive and Delete buttons on session rows
2026-10-14 08:39:03 -07:00
YHH 258269c30c Merge pull request #13136 from esengine/perf/12533-migrations-once
perf(boot): read the user config once for the startup migrations and leave an untouched config unlocked
2026-10-14 08:29:34 -07:00
esengine 212388b682 feat(studio): opt-in hover Archive and Delete buttons on session rows 2026-10-14 08:28:25 -07:00
YHH 2af6ef5422 Merge pull request #13135 from esengine/feat/13110-allowed-tools-spec
feat(skill): accept space-separated allowed-tools through one shared splitter
2026-10-14 08:24:39 -07:00
esengine cc6c305649 perf(boot): read the user config once for the startup migrations, and leave an untouched config unlocked 2026-10-14 08:21:07 -07:00
YHH 351c1fa9a3 Merge pull request #13128 from esengine/feat/12927-side-session
feat(session): a read-only side question beside the main conversation
2026-10-14 08:18:21 -07:00
esengine ebca87f6ec feat(skill): accept space-separated allowed-tools through one shared splitter 2026-10-14 08:14:23 -07:00
YHH c7715a4a7a Merge pull request #13133 from esengine/fix/13125-missing-file
fix(studio): a requested file that is not there is said so, not opened as a blank preview
2026-10-14 08:04:44 -07:00
esengine d9424dacdc fix(side): refusal copy, dev proxy route and the direct-runner entry for side conversations 2026-10-14 08:03:53 -07:00
esengine c812fbc2e5 feat(session): a read-only side question beside the main conversation 2026-10-14 08:03:48 -07:00
YHH 59fc67f43a Merge pull request #13016 from SivanCola/fix/12950-memory-directory-errors
fix(memory): report unreadable store directories / 修复记忆目录读取错误被忽略
2026-10-14 07:58:17 -07:00
YHH fdee835f9e Merge pull request #13131 from esengine/fix/13129-wallpaper-side
fix(studio): the sidebar footer takes the rail's tint over a wallpaper instead of a plate of its own
2026-10-14 07:56:44 -07:00
esengine ecbbbcf428 fix(studio): a requested file that is not there is said so, not opened as a blank preview 2026-10-14 07:54:22 -07:00
YHH 38620addcc Merge pull request #13130 from esengine/feat/13114-acp-tool-name
feat(acp): name the invoked tool on tool_call updates
2026-10-14 07:47:15 -07:00
esengine 106ee3670a fix(studio): the sidebar footer takes the rail's tint over a wallpaper instead of a plate of its own 2026-10-14 07:46:30 -07:00
esengine 8f137304cf feat(acp): name the invoked tool on tool_call updates 2026-10-14 07:36:33 -07:00
YHH 20e300871f Merge pull request #13127 from esengine/fix/13107-home-root
fix(serve): do not list a launch folder nobody picked and nothing was said in
2026-10-14 07:32:13 -07:00
YHH 2688a5b34b Merge pull request #13123 from xxoingr/test/plugin-oauth-removal-budget
test(plugin): bound the mid-browser OAuth removal by the hang budget
2026-10-14 07:27:53 -07:00
YHH ebe1858ad2 Merge pull request #13122 from xxoingr/test/session-lock-release-budget
test(session): let a released save lock be waited for under load
2026-10-14 07:27:28 -07:00
YHH 6de37f872e Merge pull request #13121 from xxoingr/test/plugin-writable-roots-windows
test(plugin): give the writable-roots host-context tests absolute paths on Windows
2026-10-14 07:27:02 -07:00
esengine b3e683ad75 fix(serve): do not list a launch folder nobody picked and nothing was said in 2026-10-14 07:18:44 -07:00
YHH a12e814476 Merge pull request #13124 from esengine/fix/openwith-test-windows
test(openwith): skip fakes of a POSIX disk on a Windows host
2026-10-14 06:52:46 -07:00
esengine 5c7fc811a1 test(openwith): skip fakes of a POSIX disk on a Windows host 2026-10-14 06:43:23 -07:00
xxoingr c587f9e7dc test(plugin): bound the mid-browser OAuth removal by the hang budget
TestAuthorizeHTTPMCPDoesNotHoldStateLockDuringBrowser failed on Windows
CI (studio run 38416026313) with "removal during browser flow blocked on
OAuth state lock" after 1.09 s. The test runs ReconcileHTTPMCPOAuthAfterRemoval
from inside openURL and gave it one second. AuthorizeHTTPMCP holds no lock
there (captureMCPOAuthGeneration releases its lock before the browser
opens), so a slow removal on a loaded runner read as a held lock. Starting
the removal 1.2 s late reproduces the message.

The wait is now testenv.Budget(t). A lock really held across the browser
is still caught: the removal's own lock wait gives up after five seconds
and returns an error, which fails the test.
2026-10-14 21:39:58 +08:00
xxoingr d2bf651d53 test(session): let a released save lock be waited for under load
TestSaveSnapshotSucceedsWhenCrossProcessFileLockReleasesBeforeDeadline
failed on Windows CI (studio run 38502171680) with "lock session file:
session file lock held" after 1.28 s. A goroutine releases the competing
lock after 30 ms and the save waits at most 500 ms for it; on a loaded
runner the release came later than that. Releasing after 600 ms
reproduces the message.

The save returns as soon as the lock is free, so the wait now is
testenv.Budget(t). TestSaveSnapshotBoundsCrossProcessFileLockWait keeps
its 40 ms, since it asserts that a lock never released makes the save
fail.
2026-10-14 21:33:21 +08:00
xxoingr 64501d34bf test(plugin): give the writable-roots host-context tests absolute paths on Windows
TestHostContextCarriesTheExtraWritableFoldersToLocalServersOnly and
TestHostContextCapsHowManyWritableFoldersAreTold fail on every Windows run
since e6f70622b (studio run 38537039922): their roots are written as
"/data/shared" and "/data/000", which have no drive and so are not
absolute on Windows, and metaWritableRoots drops every root that is not.
The key is never sent and both tests see nil.

The roots are now built from the temp directory's volume, the repo's
usual way to spell an absolute path on any OS. The same change makes
TestHostContextSaysNothingOfWritableFoldersWhenThereAreNone exercise the
workspace exclusion on Windows: its "/w" was dropped as relative there,
so it passed whether or not the workspace was excluded.
2026-10-14 21:30:34 +08:00
YHH ce2a950525 Merge pull request #13112 from esengine/release/2.40.0
docs(release): Studio 2.40.0 notes
2026-10-14 05:55:26 -07:00
esengine f207c88732 docs(release): Studio 2.40.0 notes 2026-10-14 05:46:49 -07:00
YHH e89d7be726 Merge pull request #13101 from esengine/feat/12788-turn-narration
feat(agent): a setting for how much the model says between tool calls
2026-10-14 05:44:34 -07:00
esengine dc0274076d feat(agent): a setting for how much the model says between tool calls 2026-10-14 05:34:37 -07:00
Guan09 24f9b73cd7 feat(studio): add an independent naming model and per-role reasoning effort
Add a separately selectable naming model and adjustable reasoning preferences
for chat roles, with typed roles/efforts responses and user-only role settings.

Keep title generation fixed off with temperature zero and a 60-token budget;
reject explicit title effort instead of storing an unused setting. Use the
configured startup chat model when no naming model is selected, including
servers without a running controller.

Integrate the on-demand naming and rename paths from #11591 with grouped title
provider state. Preserve default-model persistence, per-profile model overrides,
busy-save notices, and inherited effort validation against the executing model.

Share the model-preference row's existing layout declarations to keep the CSS
line count below the studio baseline without changing rendered layout.

Preserve each provider's saved effort when a role has no stored preference;
use the model default only for explicitly selected auto. Add a real boot
assembly effect test for planner and guardian-derived reviewer/evaluator
providers, while leaving subagent effort inheritance unchanged.
2026-10-14 20:27:50 +08:00
YHH 3892810d1d Merge pull request #13100 from esengine/feat/12858-open-with
feat(studio): open a project in another program (#12858)
2026-10-14 05:17:26 -07:00
YHH d33f88bba5 Merge pull request #13104 from esengine/fix/13094-page-assets-fixed-mime
fix(serve): the page's files keep their declared type whatever the machine registered
2026-10-14 05:08:51 -07:00
esengine dcaa53ba07 Merge studio into feat/12858-open-with 2026-10-14 05:02:11 -07:00
YHH faa0d5bc6e Merge pull request #13103 from esengine/feat/12959-shared-meter
feat(studio): a shared meter, with the queue's capacity limits on it
2026-10-14 05:00:48 -07:00
esengine 0423298c95 fix(serve): the page's files keep their declared type whatever the machine registered 2026-10-14 04:59:05 -07:00
esengine 2e5e4f9bec feat(studio): a shared meter, with the queue's capacity limits on it 2026-10-14 04:50:40 -07:00
esengine 71f648e2b6 fix(openwith): resolve links before judging whether a program is inside a project 2026-10-14 04:50:23 -07:00
YHH 3df18bdb5f Merge pull request #13096 from esengine/feat/12736-local-config-export
feat(studio): export and import the configuration as an encrypted file (#12736)
2026-10-14 04:45:55 -07:00
YHH b9db827dd4 Merge pull request #13098 from esengine/feat/fault-report-prefill
feat(studio): report a page defect with its error name and where it came from (#13055)
2026-10-14 04:45:36 -07:00
YHH acd58cdcaf Merge pull request #13091 from esengine/feat/13077-scroll-jump
feat(studio): jump-to-top / jump-to-latest buttons in the conversation (#13077)
2026-10-14 04:45:26 -07:00
esengine 7f60b15571 Merge studio into feat/fault-report-prefill 2026-10-14 04:36:56 -07:00
esengine e6ac8c9add feat(studio): open a project in another program
Refs #12858
2026-10-14 04:32:29 -07:00
YHH 0633035468 Merge pull request #13097 from esengine/feat/12964-hide-legacy-sessions
feat(studio): a setting to keep 1.x sessions out of the sidebar lists
2026-10-14 04:25:29 -07:00
YHH 7b10eefb3d Merge pull request #13095 from esengine/feat/13067-one-workbench-entry
feat(studio): one way into the workbench (#13067)
2026-10-14 04:19:37 -07:00
esengine 298bb73476 test(configbackup): drop a fixture assumption 2026-10-14 04:19:13 -07:00
YHH dbf2764c14 Merge pull request #13093 from esengine/fix/13055-no-null-lists
fix(serve): answer an empty list as [] in branches, event replay and the queue (#13055)
2026-10-14 04:18:55 -07:00
esengine 9702283b4d fix(configbackup): ask the disk only about plain absolute words in a backup 2026-10-14 04:18:54 -07:00
esengine 61e43ef64d Merge studio into feat/13077-scroll-jump 2026-10-14 04:18:02 -07:00
esengine be15d7a6d0 feat(studio): report a page defect with its error name and where it came from
Refs #13055
2026-10-14 04:16:20 -07:00
esengine bbcf3dff69 feat(studio): a setting to keep 1.x sessions out of the sidebar lists 2026-10-14 04:12:30 -07:00
esengine 72e4c58b63 feat(studio): an imported file starts with only what this machine lacks ticked 2026-10-14 04:11:43 -07:00
esengine c88d079723 feat(studio): export and import the configuration as an encrypted file
Refs #12736
2026-10-14 04:10:39 -07:00
esengine c044e821c0 test(serve): build the read list in one append 2026-10-14 03:59:40 -07:00
YHH 4f7b9fd280 Merge pull request #13088 from esengine/feat/13076-add-project-opens-session
feat(studio): adding a project selects it and opens a new session (#13076)
2026-10-14 03:57:25 -07:00
esengine aeb46aeb4e feat(studio): one way into the workbench
The title-bar Browser button and the Workbench tab opened the same column but
the button also asked for a blank browser page. The tab is now the only entry.
2026-10-14 03:56:09 -07:00
YHH 4c10ec72de Merge pull request #13089 from esengine/feat/13075-ttft
feat(agent): report time to first token and show it in the speed detail (#13075)
2026-10-14 03:54:08 -07:00
YHH 32132d3bf2 Merge pull request #13090 from esengine/fix/13086-nil-transport-in-interface
fix(plugin): a failed transport build is a nil interface, not a typed nil
2026-10-14 03:52:43 -07:00
esengine 8bd7dccc14 fix(studio): keep the jump buttons and the dock tab off the scroll rail
The buttons sit left of the rail, and the shut dock's tab sits flush with the
window edge instead of over the rail's ticks. The browser guard asserts no two
of the buttons, rail, tab and composer intersect at four widths.
2026-10-14 03:46:54 -07:00
esengine 3dad9ceb74 fix(serve): answer an empty list as [] in branches, event replay and the queue
Refs #13055
2026-10-14 03:41:38 -07:00
esengine c34c9c31b4 Merge studio into feat/13076-add-project-opens-session 2026-10-14 03:35:00 -07:00
esengine 312e7e9247 feat(studio): floating jump-to-top / jump-to-latest buttons in the conversation
Refs #13077
2026-10-14 03:34:26 -07:00
YHH a22ee52eaa Merge pull request #13087 from esengine/fix/13055-list-reads-tolerate-missing
fix(studio): treat a missing list in three reads as empty (#13055)
2026-10-14 03:32:39 -07:00
esengine 06eb4c34a6 fix(plugin): a failed transport build is a nil interface, not a typed nil 2026-10-14 03:28:01 -07:00
esengine 1bd4ddfe38 feat(agent): report each round's time to first token and show it in the speed detail
Refs #13075
2026-10-14 03:24:38 -07:00
esengine 6ca9b32695 feat(studio): adding a project selects it and opens a new session in it
Refs #13076
2026-10-14 03:15:29 -07:00
YHH 7a92d0cb30 Merge pull request #13084 from esengine/feat/13065-skip-setup
feat(studio): let first-run setup be put off (#13065)
2026-10-14 03:12:19 -07:00
esengine 30d5487c71 docs(release-notes): keep the stack-logging note, add the list-read note separately 2026-10-14 03:08:07 -07:00
esengine 73e4709340 fix(studio): treat a missing list in three reads as empty
Refs #13055
2026-10-14 03:07:27 -07:00
YHH 9fe33e78b3 Merge pull request #13062 from esengine/feat/13047-corner-notices
feat(studio): show non-blocking failures as dismissible corner notices with a report action
2026-10-14 02:55:14 -07:00
esengine 80c2b8dea6 feat(studio): let first-run setup be put off, with a way back above the input box
Refs #13065
2026-10-14 02:47:15 -07:00
YHH 877f021c16 Merge pull request #13080 from esengine/feat/13056-chunked-upload
feat(remote): carry a request too large for one relay message as numbered parts
2026-10-14 02:40:24 -07:00
YHH 37f320a4ef Merge pull request #13082 from esengine/feat/release-notes-language-switch
feat(studio): switch a release's notes between Chinese and English
2026-10-14 02:37:35 -07:00
esengine 7959dfe77e Merge studio into feat/13047-corner-notices 2026-10-14 02:35:16 -07:00
YHH 60f28d0405 Merge pull request #13073 from esengine/feat/12052-wait-names-holder
feat(delegation): say whose write claim a queued sub-agent is waiting on
2026-10-14 02:34:40 -07:00
YHH 0f119c9c5c Merge pull request #13074 from esengine/fix/13043-add-folder-visible
fix(studio): keep the add-folder button on the machine row visible without hover
2026-10-14 02:32:51 -07:00
esengine 5436dc9ae1 feat(studio): switch a release's notes between Chinese and English 2026-10-14 02:28:39 -07:00
YHH 574f69bdde Merge pull request #13079 from esengine/fix/13068-session-row-quiet
fix(studio): drop the hover archive and delete icons from session rows
2026-10-14 02:14:27 -07:00
esengine 3dc9249204 feat(remote): carry a request too large for one relay message as numbered parts 2026-10-14 02:06:04 -07:00
esengine 6e2647b8f6 fix(studio): drop the hover archive and delete icons from session rows 2026-10-14 02:04:15 -07:00
esengine 5eb6691ae0 Merge studio into feat/13047-corner-notices
# Conflicts:
#	desktop/frontend-next/perf/harness.tsx
#	desktop/frontend-next/src/actions.ts
#	desktop/frontend-next/src/i18n/en_composer.ts
2026-10-14 01:56:48 -07:00
esengine 9be7ea6cbc fix(studio): keep the add-folder button on the machine row visible without hover 2026-10-14 01:51:47 -07:00
YHH 7b13eaef56 Merge pull request #13071 from esengine/chore/13055-fault-stack
chore(studio): log the stack of a page fault that reaches the pane's error bar
2026-10-14 01:42:31 -07:00
esengine 9eea85f892 feat(delegation): say whose write claim a queued sub-agent is waiting on 2026-10-14 01:39:40 -07:00
YHH 4ac6a344a8 Merge pull request #13070 from esengine/fix/13056-remote-upload-limit
fix(remote): refuse a request too large for the relay link instead of dropping the link
2026-10-14 01:37:06 -07:00
YHH 2701be6327 Merge pull request #13069 from esengine/feat/12291-chart-render-p0
feat(studio): write values beside sparse chart marks
2026-10-14 01:32:23 -07:00
esengine 478131f826 chore(studio): log the stack of a page fault that reaches the pane's error bar 2026-10-14 01:30:41 -07:00
esengine 566ff0121b fix(remote): refuse a request too large for the relay link instead of dropping the link 2026-10-14 01:26:58 -07:00
esengine 4ce75ea335 feat(studio): write values beside sparse chart marks 2026-10-14 01:22:55 -07:00
YHH e7f55a8415 Merge pull request #13064 from esengine/feat/12926-model-menu-filter
feat(studio): set provider headings apart and filter the model menu by provider
2026-10-14 01:16:14 -07:00
YHH cc5d129293 Merge pull request #13063 from esengine/fix/13060-outside-workspace-write-is-a-gap
fix(checkpoint): a write outside the workspace is a coverage gap, not a recording failure
2026-10-14 01:12:10 -07:00
esengine 18e2a47e19 fix(studio): anchor the corner notices above the composer and guard it in a browser 2026-10-14 01:10:25 -07:00
esengine 751a697589 feat(studio): show non-blocking failures as dismissible corner notices with a report action 2026-10-14 01:10:25 -07:00
YHH 4a4718c7bf Merge pull request #13058 from esengine/feat/13053-writable-roots-meta
feat(mcp): tell a local stdio server the extra writable folders
2026-10-14 01:04:33 -07:00
esengine 431a87652e feat(studio): set provider headings apart and filter the model menu by provider 2026-10-14 01:04:18 -07:00
YHH fb99d78c33 Merge pull request #13059 from esengine/fix/13047-errbar-clears
fix(studio): clear a status-read error when that read next succeeds
2026-10-14 01:04:05 -07:00
esengine 186b2ee2df fix(checkpoint): a write outside the workspace is a coverage gap, not a recording failure 2026-10-14 01:00:22 -07:00
YHH 79597b19db Merge pull request #13031 from esengine/feat/13027-open-agent-page-externally
feat(studio): open the page the agent is working on in the default browser
2026-10-14 00:54:31 -07:00
YHH 7a1113d5e6 Merge pull request #13018 from esengine/fix/context-calibration-per-model
fix(agent): the gauge follows the provider's count, not the larger estimate
2026-10-14 00:46:34 -07:00
esengine 426b16f95a fix(studio): clear a status-read error when that read next succeeds 2026-10-14 00:45:50 -07:00
esengine e6f70622b5 feat(mcp): tell a local stdio server the extra writable folders 2026-10-14 00:40:52 -07:00
esengine f757912b74 Merge studio into feat/13027-open-agent-page-externally
# Conflicts:
#	desktop/frontend-next/perf/harness.tsx
#	desktop/frontend-next/src/i18n/en.ts
2026-10-14 00:37:11 -07:00
esengine c0b0770a4a test(boot): report prompt tokens the way a provider counts them in the not-a-digest fixture 2026-10-14 00:36:47 -07:00
YHH e7ce2480c3 Merge pull request #13054 from esengine/fix/13044-branch-tip-over-menu
fix(studio): hide the branch hover card while the branch menu is open
2026-10-14 00:36:39 -07:00
YHH af6d91a9bb Merge pull request #13050 from esengine/fix/13045-settings-nav-quiet
fix(studio): quiet the settings sidebar's selected row and separate section titles from items
2026-10-14 00:32:12 -07:00
esengine cd2a779d3a Merge studio into fix/context-calibration-per-model 2026-10-14 00:31:54 -07:00
YHH cdf8fad699 Merge pull request #13049 from esengine/fix/13041-search-toggle
fix(studio): make the session-filter magnifier a real toggle
2026-10-14 00:23:25 -07:00
esengine b1d5f81459 fix(studio): quiet the settings sidebar's selected row and separate section titles from items 2026-10-14 00:22:29 -07:00
esengine a6eeef486b fix(studio): hide the branch hover card while the branch menu is open 2026-10-14 00:15:19 -07:00
YHH 9da4afb553 Merge pull request #13032 from esengine/codex/cua-installed-launch
fix(computer): recover hidden applications through CUA launch
2026-10-14 00:09:25 -07:00
esengine a318c609b2 fix(studio): make the session-filter magnifier a real toggle 2026-10-14 00:05:55 -07:00
YHH da5e098968 Merge pull request #13042 from esengine/fix/13036-rowcount-covers-buttons
fix(studio): let the faded row count pass the pointer through to the row actions
2026-10-13 23:56:44 -07:00
esengine 3bd031f4ea fix(studio): let the faded row count pass the pointer through to the row actions 2026-10-13 23:46:42 -07:00
esengine 820e25e333 fix(computer): compare launched executables by file, not by 8.3 or long spelling on Windows 2026-10-13 23:43:43 -07:00
YHH a88cb60824 Merge pull request #13040 from esengine/fix/13034-signal-rail-merge
fix(studio): merge crowded signal marks into one counted marker
2026-10-13 23:27:21 -07:00
YHH ab30b0c994 Merge pull request #13037 from esengine/fix/13013-stuck-compaction
feat(agent): say what holds an over-threshold context that cannot fold
2026-10-13 23:23:28 -07:00
YHH 829ca39c79 Merge pull request #12962 from hasandenizuk/fix/12915-remove-last-provider
test(config): cover removing the last provider
2026-10-13 23:14:28 -07:00
esengine 44761d3444 fix(studio): merge crowded signal marks into one counted marker 2026-10-13 23:10:09 -07:00
esengine 04d6f39378 test(computer): cover the abort transport, window and cancellation bounds of Act, and launchable listing 2026-10-13 23:05:01 -07:00
esengine d257e677c3 chore(memory): annotate the host-built directory reads for CodeQL 2026-10-13 23:01:37 -07:00
esengine 5d4e335e9e feat(agent): say what holds an over-threshold context that cannot fold 2026-10-13 22:53:44 -07:00
YHH 5ae84e2b89 Merge pull request #13033 from esengine/feat/11965-capture-window
feat(studio): capture the app window into a feedback report
2026-10-13 22:50:41 -07:00
esengine 16de6cfdde Merge studio into fix/12950-memory-directory-errors 2026-10-13 22:43:00 -07:00
esengine e0c8ac0723 fix(memory): report a regular file in a directory's place on Windows too
Windows answers "not found" for a file where a directory should be, or
beneath one, so the not-exist shortcut hid the very case the directory read
error exists for. The stat of the entry and its parent tells them apart.
2026-10-13 22:42:53 -07:00
YHH ebca63af9f Merge pull request #13028 from esengine/feat/12913-git-graph
feat(studio): show the commit graph in the workbench explorer
2026-10-13 22:42:29 -07:00
esengine 47c44e9298 feat(studio): capture the app window into a feedback report 2026-10-13 22:33:40 -07:00
esengine f4cdcba372 fix(studio): word the git.history_failed refusal in the kernel table 2026-10-13 22:33:01 -07:00
YHH 1c6550f75f Merge pull request #13029 from esengine/feat/12771-quote-file-selection
feat(studio): add text selected in an open file to the conversation from the right-click menu
2026-10-13 22:06:08 -07:00
yhh b0e85b06cf fix(computer): restore installed applications through CUA 2026-10-13 22:00:14 -07:00
esengine 2a8b0c4f03 feat(studio): open the page the agent is working on in the default browser 2026-10-13 21:58:49 -07:00
esengine 33b017c710 fix(studio): a quoted file's name cannot end the host line or close its bracket 2026-10-13 21:50:06 -07:00
esengine 32a641107f test(gitcmd): pin that the history log runs no repository-configured program 2026-10-13 21:48:04 -07:00
esengine 49b3aa4d53 feat(studio): add text selected in an open file to the conversation from the right-click menu 2026-10-13 21:44:13 -07:00
esengine 2b47ae1fbd feat(studio): show the commit graph in the workbench explorer
A read-only History read in gitstatus, a /workspace/git/log endpoint, and a
collapsed Commit history section that lays the commits out in lanes.
2026-10-13 21:29:23 -07:00
YHH 55ab4e8d92 Merge pull request #13021 from esengine/codex/cua-driver-performance
fix(computer): reduce CUA latency and normalize Windows app identities
2026-10-13 21:28:46 -07:00
YHH 5f8eae49ec Merge pull request #13022 from esengine/feat/image-preview-13012
fix(studio): share original image previews across conversation entrances
2026-10-13 21:25:55 -07:00
YHH 078359533b Merge pull request #13014 from esengine/fix/13002-subagent-row-title
feat(studio): name each sub-agent row by its task
2026-10-13 21:25:43 -07:00
YHH 8761026022 Merge pull request #13024 from esengine/feat/12300-project-order
feat(studio): reorder projects by dragging a row
2026-10-13 21:25:23 -07:00
YHH 62814aae7c Merge pull request #13026 from esengine/fix/13017-smaller-unread-dot
fix(desktop): draw the Windows unread dot smaller, smooth and ringed
2026-10-13 21:21:10 -07:00
YHH ebbddbac5a Merge pull request #13025 from esengine/fix/13020-explorer-bigger
fix(studio): make the workbench explorer's text and icons easier to read and hit
2026-10-13 21:19:04 -07:00
esengine f5867f5103 fix(desktop): draw the Windows unread dot smaller, smooth and ringed 2026-10-13 21:12:05 -07:00
esengine b98b4f7201 fix(studio): make the workbench explorer's text and icons easier to read and hit 2026-10-13 21:08:46 -07:00
esengine c5e6a5c3ae feat(studio): reorder projects by dragging a row
The move endpoint also takes a place to land before; the row turns pointer
events into that place and shows a landing line while dragging.
2026-10-13 21:07:29 -07:00
esengine b1a67e195d wip: workspace place test 2026-10-13 21:07:29 -07:00
esengine 4309227e31 fix(agent): the gauge follows the provider's count, not the larger estimate
With an anchor the reading is the reported prompt size plus the growth since.
The estimate can sit far above the provider's count for a whole session.
2026-10-13 21:00:42 -07:00
YHH 2bd6a19565 Merge pull request #13019 from esengine/fix/13011-surface-replay-truncation
fix(serve): a log kept to its cap is not a failed surface restore
2026-10-13 20:58:51 -07:00
esengine b522bd0b8b docs(studio): keep the image preview notes, drop the embedded screenshots
Screenshots belong in the pull request; add the release note.
2026-10-13 20:53:28 -07:00
yhh 65d6442012 fix(computer): reduce CUA discovery and observation overhead 2026-10-13 20:51:27 -07:00
SivanCola 5910c148f8 fix(studio): share original image previews across conversation entrances
Problem: Studio user-message and composer images only showed thumbnails,
while tool screenshots used a separate enlargement overlay.

Root cause: The three entrances did not share a preview interaction owner.

Fix: Use a common image trigger and body-portaled viewer with original
sources, keyboard access, focus restoration, safe double clicking, explicit
closing and unavailable-image handling. Remove the tool-only overlay and
document the restored attachment parity with main-v2.

Verification: 541 frontend test files and 4611 tests pass; typecheck,
production build, census verification, make lint, make check and go vet
pass. Builtin and boot Go tests pass. Chromium checks cover light/dark,
wide/narrow windows and 150% interface zoom. The browser guard is wired
into Studio CI. Correct the perf fixture to await committed language state.
2026-10-13 20:50:18 -07:00
esengine d0be627024 fix(serve): a log kept to its cap is not a failed surface restore
The wire log records whether a surface frame was dropped; the surfaces route
answers 200 with an incomplete mark only then, and an unreadable history has a
code of its own.
2026-10-13 20:48:11 -07:00
esengine 732ec45bfe feat(studio): name each sub-agent row by its task
The kernel puts the task description's first line on the delegation profile and
the popover row shows it, truncated, with the full text on hover.
2026-10-13 20:37:22 -07:00
YHH fcc9363a17 Merge pull request #13015 from esengine/carry/13004-black-gold-icons
fix(studio): restore black-and-gold application and tray icons (carries #13004)
2026-10-13 20:32:03 -07:00
SivanCola 39a1e8b432 fix(memory): surface unreadable store directories
Problem: permission and storage failures can hide existing memory revisions,
make restores report them as missing, and compute recovery revisions from an
incomplete history.

Root cause: directory discovery conflated every ReadDir error with absence.

Fix: unify all eleven store walks behind a typed reader that treats only
missing directories as empty. Propagate errors through reads, save guards,
recovery, recall, controller commands, and HTTP responses. Complete recovery
and legacy duplicate discovery before writing. Preserve persisted formats.

Verification: failing-first permission fixture; eleven-site permission,
I/O, and non-directory regressions; unchanged files on failed recovery or
save; missing-directory compatibility; tool/controller/HTTP and bilingual
error coverage; Go tests, vet, lint, repository checks, frontend tests and
typecheck, and Windows memory test cross-compilation.

Fixes #12950
2026-10-14 11:23:56 +08:00
SivanCola c184d41602 docs(studio): announce restored black-and-gold icons
Problem: the pending icon restoration was missing from the next-release
notes, so users would not be told why the application artwork changed.

Root cause: the original restoration commit only included icon assets.

Fix: add a bilingual release-note fragment covering the application,
taskbar/Dock, launcher and tray icons without changing any artwork.

Verification: the 16 release-note rendering/publication tests, repository
lint, Go vet, changed-file checks and diff whitespace checks pass.
2026-10-13 20:23:24 -07:00
SivanCola 0d92555c24 fix(studio): restore black-and-gold application and tray icons
Problem: Studio 2.39.0 replaced the expected black-and-gold application,
taskbar, Dock, launcher, and tray artwork with blue-and-yellow icons.

Root cause: the shared SVG and all generated platform resources were
recoloured together in #12897.

Fix: restore the black-and-gold SVG and its ten generated icon files,
preserving the platform crop, ICO/ICNS sizes, and Windows tray DPI scales.

Verification: regenerate all assets and compare their bytes with the
pre-#12897 artwork; decode and visually inspect all 26 raster sizes;
run the Electron unit suite, Go vet, repository lint, and changed-file
checks, plus the builtin and boot suites. No application or packaging
logic changes.
2026-10-13 20:23:24 -07:00
YHH 96c3da197f Merge pull request #13008 from esengine/fix/13001-degenerate-repeat
fix(agent): flag a stream that collapses to a few tiny lines
2026-10-13 20:11:02 -07:00
YHH d1eeef5318 Merge pull request #13003 from esengine/fix/12993-find-exec-readonly
fix(shellsafe): treat find -exec of a known read-only program as a read
2026-10-13 20:07:17 -07:00
YHH f4d88a3142 Merge pull request #13005 from esengine/fix/12747-commit-draft-failure
fix(studio): let a person write the commit message when the model cannot draft one
2026-10-13 20:06:07 -07:00
esengine 4c192248d5 fix(agent): flag a stream that collapses to a few tiny lines
The guard only saw byte-periodic blocks. A second structural reading of the same
tail - many short lines from very few distinct ones - catches a degraded stream
that never repeats a block.
2026-10-13 20:00:36 -07:00
esengine abf44246c5 fix(studio): let a person write the commit message when the model cannot draft one
ProposeCommit returns the staged set with a typed draftFailure instead of an
error, and the card opens its message box with the reason.
2026-10-13 19:49:59 -07:00
esengine 1dd0b8a247 fix(shellsafe): treat find -exec of a known read-only program as a read
The program find runs decides: a bare read-only name whose own arguments do not
write. Anything else, -ok, -delete and the -fprint family stay writers, so a
read-only find no longer takes the workspace write lease.
2026-10-13 19:43:39 -07:00
YHH ffd136f463 Merge pull request #12999 from esengine/feat/12985-tui-theme-preview
feat(tui): preview each theme as the /theme picker moves
2026-10-13 19:39:23 -07:00
YHH c1a243d46c Merge pull request #12998 from esengine/fix/npm-publish-wait-longer
fix(release): wait up to 15 minutes for npm to show a published CLI package
2026-10-13 19:32:13 -07:00
YHH 31ba7705ed Merge pull request #12996 from esengine/feat/12063-shadow-token
feat(theme): let a pack set the shadow menus, popovers and dialogs cast
2026-10-13 19:30:27 -07:00
esengine b5a79cda33 feat(tui): preview each theme as the /theme picker moves
A bare /theme opens the quick picker; moving repaints, Enter keeps and stores the
row, Escape restores the original. /theme list keeps the old listing.
2026-10-13 19:29:07 -07:00
YHH 903aa09cdc Merge pull request #12997 from esengine/fix/12925-files-toggle
fix(studio): fold the files panel when the Files button is pressed again
2026-10-13 19:27:43 -07:00
YHH a3ba9e4f5c Merge pull request #12995 from esengine/feat/12011-tree-last-active
feat(serve): carry each session's last-activity time on the tree row
2026-10-13 19:23:21 -07:00
esengine fb3a65146a fix(release): wait up to 15 minutes for npm to show a published CLI package 2026-10-13 19:23:02 -07:00
esengine 634f8c77d4 fix(theme): accept only ASCII in a shadow value
The stored string reaches CSS unchanged, which does not treat Unicode spaces as
separators, so a value that validated could paint nothing.
2026-10-13 19:21:20 -07:00
esengine 51446c4d27 fix(studio): fold the files panel when the Files button is pressed again
The workbench decides, since it owns whether its explorer is the open one.
2026-10-13 19:18:19 -07:00
YHH 9a0e636c3d Merge pull request #12994 from esengine/feat/12728-turn-times
feat(studio): say when a turn began beside how long it worked
2026-10-13 19:12:21 -07:00
esengine 40e06a52e2 feat(theme): let a pack set the shadow menus, popovers and dialogs cast
A shadowFloat token with a strict px/hex shadow grammar maps onto
--shadow-float, and onto --shadow-pack for the light menus that kept their
own default. Unset, nothing paints differently.
2026-10-13 19:07:36 -07:00
esengine 6d47f6c837 feat(serve): carry each session's last-activity time on the tree row
The tree already sorts by it; exposing it lets a client build a recency list.
2026-10-13 18:58:46 -07:00
esengine 12f018ffc5 feat(studio): say when a turn began beside how long it worked
TurnDone carries the turn's start time and /history carries when a user
message landed, so the worked line names the start live and after a reload.
2026-10-13 18:52:36 -07:00
YHH f289e3903f Merge pull request #12992 from esengine/release/2.39.0-notes-2
docs(release): add the last two changes to the 2.39.0 notes
2026-10-13 17:36:17 -07:00
esengine 40d577d9f6 docs(release): add the last two changes to the 2.39.0 notes 2026-10-13 17:28:17 -07:00
YHH 2234daf25f Merge pull request #12991 from esengine/fix/12988-gauge-reads-provider-floor
fix(agent): the context gauge reads the provider-reported size the fold trigger acts on
2026-10-13 17:23:54 -07:00
esengine 1d81b5b361 fix(agent): the context gauge reads the provider-reported size the fold trigger acts on 2026-10-13 17:13:45 -07:00
YHH 561f385778 Merge pull request #12990 from esengine/release/2.39.0-notes
docs(release): Studio 2.39.0 notes
2026-10-13 16:54:32 -07:00
YHH ac7c495553 Merge pull request #12138 from boscocp/feat/studio-11957-tools-list-changed
feat(plugin): refresh an MCP catalog on tools/list_changed / 收到 tools/list_changed 后刷新工具目录
2026-10-13 16:48:14 -07:00
esengine af03a12941 docs(release): Studio 2.39.0 notes 2026-10-13 16:46:14 -07:00
YHH 35011ad3d4 Merge pull request #12989 from esengine/fix/target-access-test-walk-race
test(agent): let the target-access wiring walk skip files that vanish mid-walk
2026-10-13 16:38:13 -07:00
esengine 60bee4b979 Merge remote-tracking branch 'origin/studio' into p12138
# Conflicts:
#	internal/ext/plugin/plugin.go
2026-10-13 16:37:36 -07:00
esengine a125fbc9b2 test(agent): let the target-access wiring walk skip files that vanish mid-walk 2026-10-13 16:27:35 -07:00
YHH d2d610d65b Merge pull request #12987 from esengine/fix/cua-fixture-socket-mode
test(desktop): create the CUA fixture socket at 0600 instead of chmod-ing it afterwards
2026-10-13 16:09:23 -07:00
esengine 7931e701ec test(desktop): create the CUA fixture socket at 0600 instead of chmod-ing it afterwards 2026-10-13 15:59:24 -07:00
YHH 6343236907 Merge pull request #12115 from Guan0923/codex/pretooluse-before-preview
fix(agent): gate file previews on target access and PreToolUse
2026-10-13 15:20:42 -07:00
esengine b520c59678 Merge studio into pr/12115; leave path-shape failures to the tool 2026-10-13 15:10:42 -07:00
YHH 3b9d7b3609 Merge pull request #12557 from SivanCola/fix/workbench-tab-menu-controller
feat(studio): use controller close in extracted tab menus / 标签菜单适配控制器关闭路径
2026-10-13 15:07:51 -07:00
esengine 6487ed6f21 Merge remote-tracking branch 'origin/studio' into p12557
# Conflicts:
#	desktop/frontend-next/src/i18n/en_settings.ts
2026-10-13 14:57:34 -07:00
Hasan Deniz fc23415309 test(config): cover removing the last provider
Two tests for the path #12966 settled: removing every provider saves an
empty list that loads back empty, and adding one afterwards replaces it.
Over HTTP, removing the last provider moves the runtime to the stand-in
and lists no provider.

Both fail without the providers = [] render line from #12966.

Refs #12915
2026-10-13 22:43:59 +01:00
esengine 1c23c7dcd6 Merge remote-tracking branch 'origin/studio' into p12557
# Conflicts:
#	desktop/frontend-next/src/i18n/en_kernel.ts
#	desktop/frontend-next/src/i18n/en_settings.ts
#	desktop/frontend-next/src/port/sse.ts
#	desktop/frontend-next/src/ui/BrowserTabMenu.tsx
#	desktop/frontend-next/src/ui/WorkbenchPanel.tsx
#	desktop/frontend-next/src/ui/browser-close.interaction.test.tsx
#	release-notes/studio/unreleased.md
2026-10-13 14:17:12 -07:00
YHH 4f2c5a2212 Merge pull request #12065 from KHG420/codex/studio-installed-capability-search
feat(studio): find installed packages by contributed capabilities
2026-10-13 14:14:24 -07:00
YHH 36de07d2fa Merge pull request #12896 from esengine/codex/intelligent-ui-12584-workspaces
feat(studio): add maps, comparisons and dashboards
2026-10-13 14:06:54 -07:00
esengine 91686f3c3f Merge studio into pr/12065
# Conflicts:
#	desktop/frontend-next/src/i18n/en_settings.ts
2026-10-13 14:02:13 -07:00
esengine 1ef2f9168d Merge studio into codex/intelligent-ui-12584-workspaces
Declares import_web_image's snip stance (a small JSON receipt takes the default) and regenerates
docs/TOOL_CONTRACT.md, the two contract tests that landed on studio after the branch point.
2026-10-13 13:56:03 -07:00
YHH 9d05547a00 Merge pull request #11591 from Guan0923/codex/session-rename-dialog
feat(studio): add a rename dialog with on-demand session auto-naming
2026-10-13 13:54:07 -07:00
esengine 82312cad7c Merge studio into codex/pretooluse-before-preview
move_file now asks for read access to its source on both paths (a forbidden-read source is refused
before the rename, not only when the copy fallback runs), and four agent tests built after the
studio branch point supply the permissive target-access callback the agent now requires.
2026-10-13 13:50:16 -07:00
esengine 6cd5d1a849 Merge studio into pr/12115 2026-10-13 13:45:26 -07:00
YHH a572624a6a Merge pull request #12513 from f1shsmell/feat/theme-pack-recipes
feat(theme): read the density and corner recipes a pack declares
2026-10-13 13:43:37 -07:00
esengine 8e65a19dde Merge studio into codex/session-rename-dialog
Keeps the row-level double-click rename from studio (and drops the title-level handler it replaced),
declares the dialog's title input as session.rename.title, and expects the inline mode in the
double-click test now that onRename carries it.
2026-10-13 13:41:48 -07:00
YHH 8730e31eab Merge pull request #12117 from KHG420/codex/studio-package-contribution-groups
feat(studio): identify installed package contribution kinds
2026-10-13 13:37:52 -07:00
esengine b678e56a32 Merge studio into feat/theme-pack-recipes 2026-10-13 13:32:09 -07:00
esengine 7da9815f14 Merge studio into codex/studio-package-contribution-groups 2026-10-13 13:26:38 -07:00
esengine a24430df38 Merge studio into pr/12117
Drops two blank lines from app.css so the two new contribution rules fit the recorded size.
2026-10-13 13:26:19 -07:00
YHH d97edf1631 Merge pull request #12329 from KHG420/codex/studio-skill-source-groups
fix(studio): group installed skills by source
2026-10-13 13:25:36 -07:00
esengine 1e4c8bd9c4 Merge studio into pr/12117 2026-10-13 13:24:41 -07:00
esengine ec668a1264 Merge studio into codex/intelligent-ui-12584-workspaces 2026-10-13 13:20:18 -07:00
YHH b1db7d8338 Merge pull request #12075 from KHG420/codex/studio-package-copy-invocations
feat(studio): copy package invocations and archive paths
2026-10-13 13:19:31 -07:00
YHH f2187c9d03 Merge pull request #12118 from xxoingr/feat/markdown-bidi
feat(studio): render right-to-left text blocks in their own direction
2026-10-13 13:15:24 -07:00
esengine 2367cd4d65 Merge studio into codex/studio-skill-source-groups 2026-10-13 13:14:22 -07:00
esengine fa22360007 Merge studio into feat/markdown-bidi 2026-10-13 13:02:27 -07:00
YHH e2bdbc322f Merge pull request #11735 from jf200s/fix/studio-live-turn-reducer
fix(session): keep the live turn through rebuilds and lost turn ends
2026-10-13 13:00:21 -07:00
YHH 43ba7f3b45 Merge pull request #12323 from KHG420/codex/studio-advanced-appearance-cards
fix(studio): align advanced appearance with settings cards
2026-10-13 12:54:41 -07:00
esengine 5207b65161 Merge studio into pr/12329 2026-10-13 12:54:19 -07:00
YHH 8d7a0189a6 Merge pull request #12313 from KHG420/codex/studio-chart-readouts
fix(studio): make chart values discoverable along dense lines
2026-10-13 12:50:47 -07:00
YHH ba2fb2e3e7 Merge pull request #12986 from esengine/fix/surface-form-test-race
test(studio): wait for the lazily loaded surface field instead of reading it at once
2026-10-13 12:47:52 -07:00
esengine ef0e34ccc4 Merge studio into codex/studio-advanced-appearance-cards
Keeps the new summary text, names the advanced-appearance disclosure in the settings catalogue (the catalogue test requires a titled block to name itself) and folds three selector lists onto one line each so app.css stays inside its recorded size.
2026-10-13 12:42:28 -07:00
esengine a32fe7f492 test(studio): wait for the lazily loaded surface field instead of reading it at once 2026-10-13 12:37:06 -07:00
YHH 46b22cf79b Merge pull request #12876 from esengine/codex/intelligent-ui-12584-actions
feat(studio): add nonblocking surface actions (RFC #12584 P2)
2026-10-13 12:34:08 -07:00
esengine f9d08b4811 Merge studio into codex/intelligent-ui-12584-actions 2026-10-13 12:26:29 -07:00
YHH 6f9643f976 Merge pull request #12854 from esengine/codex/intelligent-ui-12584-drafts
feat(studio): add editable drafts and styled text previews
2026-10-13 12:22:12 -07:00
esengine ec91de1853 Merge studio into fix/studio-live-turn-reducer
Folds the studio's retired-surface rows into the identity merge of a rebuild (rebuild.ts), and lets two
fixtures report the turn as running while it runs, since a status read now vouches for it.
2026-10-13 12:22:02 -07:00
YHH ab4dfc827a Merge pull request #12984 from esengine/refactor/saved-not-applied-note
refactor(studio): share the saved-not-applied predicate and the save note
2026-10-13 12:17:42 -07:00
esengine a3c43490fb Merge studio into codex/intelligent-ui-12584-drafts 2026-10-13 12:11:21 -07:00
esengine 783e56578b refactor(studio): share the saved-not-applied predicate and the save note
The same HttpError check and the same note block were copied into Compaction, Rules, Shell, Context,
Settings and EditConn. savedNotApplied lives next to SAVED_NOT_APPLIED and SaveNote draws the note once.
2026-10-13 12:07:11 -07:00
YHH cc0635b7c0 Merge pull request #12350 from KHG420/codex/studio-compaction-save-outcomes
fix(studio): distinguish settings save outcomes and allow retry
2026-10-13 12:06:56 -07:00
YHH ab7ded2e46 Merge pull request #12983 from esengine/fix/skillrow-tab-order
test(studio): expect the copy button first in a skill row's tab order
2026-10-13 12:01:03 -07:00
esengine e033f1bb11 Merge studio into codex/intelligent-ui-12584-drafts 2026-10-13 11:58:46 -07:00
YHH f5b89e246f Merge pull request #12830 from esengine/codex/intelligent-ui-12584-fields
feat(studio): add styled native fields and choice cards (RFC #12584 P1)
2026-10-13 11:53:48 -07:00
YHH 1d76e29525 Merge pull request #12314 from KHG420/codex/studio-refine-keyboard-help
fix(studio): keep prompt-refine help reachable and shortcut intact
2026-10-13 11:52:03 -07:00
esengine 9c44a56bbf style(studio): drop a blank line so app.css is back inside its budget
#12982 added one rule to a stylesheet held at its recorded size.
2026-10-13 11:51:35 -07:00
esengine 2d7cda8ef8 test(studio): expect the copy button first in a skill row's tab order 2026-10-13 11:50:36 -07:00
YHH 2f78502847 Merge pull request #12102 from KHG420/codex/studio-skill-execution-declarations
feat(studio): inspect subagent skill declarations
2026-10-13 11:41:06 -07:00
YHH 75552a6902 Merge pull request #12098 from KHG420/codex/studio-plugin-commit-preview
feat(studio): show resolved plugin commits in install previews
2026-10-13 11:35:58 -07:00
esengine 6fb3c3b65a test(studio): follow the refine button's aria-disabled selector 2026-10-13 11:30:13 -07:00
esengine 3228d69968 Merge studio into codex/studio-refine-keyboard-help 2026-10-13 11:30:05 -07:00
esengine 6a814cdc7c Merge studio into codex/intelligent-ui-12584-fields 2026-10-13 11:29:45 -07:00
xiexian 5dfb9a3eca feat(studio): copy installed package invocations 2026-10-14 02:28:40 +08:00
YHH eb7dd41e8f Merge pull request #12982 from esengine/fix/12945-fewer-version-rows
feat(studio): keep the newest earlier releases on the versions page and fold the rest
2026-10-13 11:23:26 -07:00
YHH 844aa7b04e Merge pull request #11589 from Guan0923/codex/studio-code-rewind-undo-entry
fix(studio): 从后端恢复撤销入口并持久化撤销失效状态
2026-10-13 11:16:08 -07:00
esengine 1c0c8e7d8a feat(studio): keep the newest earlier releases on the versions page and fold the rest 2026-10-13 11:12:57 -07:00
YHH 80e3680595 Merge pull request #12969 from esengine/feat/tool-json-highlight
feat(studio): colour the JSON input of a tool call
2026-10-13 11:12:02 -07:00
esengine a3420a00f2 Merge studio into codex/prompt-refine-help 2026-10-13 11:08:30 -07:00
YHH 4a78b32113 Merge pull request #12070 from KHG420/codex/studio-skill-copy-invocation
feat(studio): copy canonical skill invocations from Settings
2026-10-13 11:08:25 -07:00
YHH 5a25c18574 Merge pull request #12981 from esengine/fix/modelrows-guard-linux
fix(perf): open settings through its button in the model-rows guard
2026-10-13 11:07:50 -07:00
YHH 04329b5355 Merge pull request #12980 from esengine/fix/rewind-undo-test-race
test(studio): wait for the restore receipt instead of reading it at once
2026-10-13 10:57:40 -07:00
esengine 78c872fcf7 fix(perf): open settings through its button in the model-rows guard 2026-10-13 10:57:00 -07:00
YHH 706c2a16a4 Merge pull request #12595 from esengine/feat/remote-wsl-kind
feat(wsl): discover WSL distributions and probe one (RFC #12225, slice 1)
2026-10-13 10:56:58 -07:00
YHH 29ffc3d278 Merge pull request #12405 from keeyangyy/pr/legacy-session-badge
feat(studio): mark sessions kept in the 1.x store
2026-10-13 10:56:41 -07:00
esengine 4af61e3d92 fix(studio): give the surface submit button AA contrast in the light theme
Mantine's gray primary filled colour is #868e96, which is 3.4:1 against the button's white label.
The scoped provider now maps it to the app's text token.
2026-10-13 10:52:59 -07:00
esengine d72bc50a8f refactor(control): drop resolveAsk, replaced by resolveAskAnswer 2026-10-13 10:49:32 -07:00
esengine 3903638ded test(studio): wait for the restore receipt instead of reading it at once 2026-10-13 10:45:48 -07:00
YHH 68f01019f4 Merge pull request #12979 from esengine/fix/12920-one-scrollbar
fix(studio): one scrollbar on the provider model list, and a themed alias field
2026-10-13 10:45:14 -07:00
esengine 3a2a39528a chore: drop a scratch file 2026-10-13 10:40:25 -07:00
esengine fad9794bc9 fix(studio): keep the copy control inside the skill name column
In a narrow settings column the extra control pushed the scope and switch of the longest rows past the card edge. Next to the name it keeps the other columns where they were.
2026-10-13 10:39:26 -07:00
esengine 0a4908854f Merge studio into codex/studio-skill-copy-invocation 2026-10-13 10:38:35 -07:00
YHH 584eb2eadc Merge pull request #12978 from esengine/test/mcp-oauth-credential-binding
test(plugin): pin MCP OAuth credentials to the recorded authorization server
2026-10-13 10:35:10 -07:00
esengine 8cb659ef4a refactor(studio): build the launch pane's runtime options in one place
assemble sits at the 120-line ceiling; the native-surface option the surface work adds tips it over.
2026-10-13 10:34:17 -07:00
esengine 940e4f017e Merge studio into codex/intelligent-ui-12584-fields 2026-10-13 10:33:04 -07:00
esengine 7c7846d76c Merge studio into feat/tool-json-highlight
# Conflicts:
#	desktop/frontend-next/perf/harness.tsx
2026-10-13 10:31:47 -07:00
esengine a8b15e1f04 Merge studio into pr/legacy-session-badge 2026-10-13 10:31:05 -07:00
YHH 407b9bd0c1 Merge pull request #12977 from esengine/fix/12970-dblclick-rename
feat(studio): double-click a session row to rename it
2026-10-13 10:30:54 -07:00
esengine 9f7b4d1104 Merge studio into feat/remote-wsl-kind 2026-10-13 10:30:51 -07:00
esengine 824bb2faad fix(studio): one scrollbar on the provider model list, and a themed alias field 2026-10-13 10:30:00 -07:00
YHH e6db67a14d Merge pull request #12976 from esengine/feat/osc-7501-status
feat(tui): tell the terminal what the session is doing (OSC 7501)
2026-10-13 10:26:03 -07:00
esengine 8954d0302d Merge studio into codex/intelligent-ui-12584-fields
Keeps one /answer handler that serves both question engagement and surface answers, and
carries the computer driver and native-surface options through the workspace switch.
2026-10-13 10:17:58 -07:00
esengine f5ee8d785d feat(tui): tell the terminal what the session is doing (OSC 7501) 2026-10-13 10:16:47 -07:00
esengine 0d07d95c6c test(plugin): pin MCP OAuth credentials to the recorded authorization server 2026-10-13 10:15:57 -07:00
esengine 7641b1e249 feat(studio): double-click a session row to rename it 2026-10-13 10:15:20 -07:00
YHH 5186562d29 Merge pull request #12973 from esengine/fix/release-notes-tests-skip-fragments
fix: unbreak repolint and the release-notes script tests on studio
2026-10-13 10:13:35 -07:00
YHH 30d1638a52 Merge pull request #12825 from esengine/codex/intelligent-ui-12584
feat(studio): recover extension surfaces after reload (RFC #12584 P0)
2026-10-13 10:06:25 -07:00
YHH 5bcc7a4599 Merge pull request #12972 from esengine/feat/skill-chip-in-user-message
feat(studio): show a skill invocation as a compact chip in the conversation
2026-10-13 10:03:49 -07:00
YHH 87fd186f8f Merge pull request #12085 from KHG420/codex/studio-mcp-tool-search
feat(studio): search MCP server tool inventories
2026-10-13 10:01:40 -07:00
esengine 0ba5ac1f09 fix(config): tagged switch for the kind check 2026-10-13 09:57:43 -07:00
esengine 0dbc09fc1c fix(studio): drop the unused import 2026-10-13 09:56:38 -07:00
esengine da8d7944da refactor(studio): keep assemble and config.go inside their repolint budgets 2026-10-13 09:56:38 -07:00
esengine fd311693f6 test(release): read the fragment directory's notes too and skip directories 2026-10-13 09:56:38 -07:00
YHH 3f0463e2bf Merge pull request #12910 from KHG420/codex/studio-file-links
feat(studio): open conversation file links in the workbench
2026-10-13 09:55:58 -07:00
esengine 0ac031b168 Merge studio into pr/legacy-session-badge 2026-10-13 09:51:30 -07:00
esengine e4af6f2be7 fix(wsl): ask the OS for the system directory instead of reading the environment
repolint's process-input rule flags a kernel package that reads SystemRoot. GetSystemDirectory
is also the stronger answer: a changed environment cannot stand in for System32.
2026-10-13 09:49:58 -07:00
esengine 2baa0a1e19 Merge remote-tracking branch 'origin/studio' into wslfix 2026-10-13 09:49:31 -07:00
YHH cc73202dda Merge pull request #12971 from esengine/fix/composer-hover-radius
fix(studio): give the branch button the same hover shape as its neighbours
2026-10-13 09:47:53 -07:00
YHH 878b983b67 Merge pull request #12440 from keeyangyy/pr/remember-paired-devices
feat(serve): keep paired phones across restarts, with a settings switch
2026-10-13 09:47:48 -07:00
esengine 1597e64b40 feat(studio): show a skill invocation as a compact chip in the conversation
Fixes #12383
2026-10-13 09:45:47 -07:00
esengine 9432f13c4f test(serve): format the legacy-mark test and join its fixture lines with strings.Join 2026-10-13 09:38:40 -07:00
YHH 2211e22552 Merge pull request #12968 from esengine/chore/release-notes-per-file
docs(release): one release-note fragment per pull request
2026-10-13 09:37:13 -07:00
esengine 1cc5648b65 fix(studio): give the branch button the same hover shape as its neighbours
Fixes #12912
2026-10-13 09:34:45 -07:00
esengine 2e842b987a fix(wsl): an ended context is not an empty machine
List answered "no distributions" for any wsl.exe failure, so a probe that hit its
deadline or was cancelled read as a machine without WSL. The caller's ended context
is now returned as such.
2026-10-13 09:34:10 -07:00
YHH 0874955cff Merge pull request #12591 from esengine/feat/phone-page-installable
feat(studio): let the phone page be added to the home screen
2026-10-13 09:32:50 -07:00
YHH de2809cf64 Merge pull request #12587 from esengine/feat/tui-unviewed-mark
feat(tui): mark unviewed sessions in the resume picker and clear on view
2026-10-13 09:29:46 -07:00
esengine 7df54d6f91 Merge remote-tracking branch 'origin/studio' into HEAD 2026-10-13 09:26:37 -07:00
esengine 502e8f045c refactor(studio): keep the extension routes with the extension port
The studio merge left sse.ts at 803 lines, over the 800-line ceiling; the three extension calls belong to SseExtensions.
2026-10-13 09:24:08 -07:00
YHH d924132cc7 Merge pull request #12211 from KHG420/codex/studio-extension-form-result
fix(studio): keep failed extension forms editable
2026-10-13 09:22:45 -07:00
esengine 0a999b6857 feat(studio): colour the JSON input of a tool call
Fixes #12919
2026-10-13 09:22:39 -07:00
YHH c1e2840bab Merge pull request #12963 from esengine/fix/compaction-summarizer-budget
fix(compaction): carry the user's words through a degraded fold and cap the summarizer input
2026-10-13 09:17:26 -07:00
YHH 3a623c3ced Merge pull request #12937 from esengine/codex/cua-driver-migration
feat(studio): migrate computer use to CUA Driver
2026-10-13 09:13:17 -07:00
YHH b8f3ae549e Merge pull request #12967 from esengine/feat/mcp-meta-local-context
feat(plugin): tell local stdio MCP servers the workspace and the host's read-only posture
2026-10-13 09:12:23 -07:00
esengine dae22d82ce Merge remote-tracking branch 'origin/studio' into p0
# Conflicts:
#	desktop/frontend-next/src/port/sse.ts
2026-10-13 09:10:39 -07:00
esengine 0133febcf6 Merge studio and keep both release-note sets 2026-10-13 09:06:26 -07:00
esengine 2d9f6fd634 style(studio): drop the stylesheet comment
Stylesheets carry no comments in this repo; the selector and declarations are the statement.
2026-10-13 09:06:10 -07:00
esengine a2147d0474 docs(release): one release-note fragment per pull request 2026-10-13 09:05:16 -07:00
esengine db6c6a233b feat(plugin): tell local stdio MCP servers the workspace and the host's read-only posture
Fixes #12965
2026-10-13 09:02:44 -07:00
esengine aa7090472e Merge studio into codex/studio-extension-form-result 2026-10-13 08:55:02 -07:00
YHH 86c63d9ac6 Merge pull request #12214 from KHG420/codex/studio-extension-current-fields
fix(studio): submit only current extension form fields
2026-10-13 08:53:10 -07:00
YHH de6b70a6fd Merge pull request #12885 from par73e/fix/12764-sft-run-provenance
fix(e2ebench): bind SFT exports to graded run provenance
2026-10-13 08:51:55 -07:00
YHH c791b02d14 Merge pull request #12884 from par73e/fix/12862-ask-interaction-timeout
fix(studio): keep an engaged ask from timing out
2026-10-13 08:50:54 -07:00
YHH 44695d6106 Merge pull request #12966 from esengine/feat/start-without-provider
feat(config): start with no provider and open the connection guide
2026-10-13 08:49:19 -07:00
esengine d98a908641 test(agent): range over integers in the mechanical fold tests 2026-10-13 08:46:17 -07:00
yhh 88129d0f6a feat(studio): replace native computer helper with CUA Driver 2026-10-13 08:43:14 -07:00
esengine b84dfcc5cf feat(config): start with no provider and open the connection guide
Fixes #12915
2026-10-13 08:42:15 -07:00
esengine 698a963ab2 fix(compaction): carry the user's words through a degraded fold and cap the summarizer input
Refs #12961
2026-10-13 08:00:56 -07:00
YHH 25b996c08f Merge pull request #12955 from esengine/fix/crumb-title-beside-project
fix(studio): keep the session title beside the project name in the title bar
2026-10-13 07:44:49 -07:00
YHH 01161ca789 Merge pull request #12957 from esengine/feat/live-grace
feat(studio): keep finished sessions in the Running list for a chosen time (off by default)
2026-10-13 07:43:40 -07:00
YHH b795c20c93 Merge pull request #12960 from esengine/feat/disk-health
feat(notify): warn when the workspace or state volume is nearly full
2026-10-13 07:40:57 -07:00
esengine 2f9207b408 Merge studio and keep both release-note sets 2026-10-13 07:35:27 -07:00
esengine efa78dfd49 Merge studio and keep both release-note sets 2026-10-13 07:35:08 -07:00
esengine 5b15cd35d0 feat(notify): warn when the workspace or state volume is nearly full
Fixes #12958
2026-10-13 07:30:26 -07:00
Guan09 3ef990601f fix(checkpoint): make rewind undo lifecycle durable
Persist undo retirement before admitting new turns or tool writes, and refuse new activity when that persistence fails. Keep post-tool checkpoint-recording warnings separate from truthful tool results. Preserve the preceding undo after fully compensated rewind failures, and block activity until incomplete recovery settles. Reuse the existing Studio notice, availability route and change event; retain the failure-path tests.
2026-10-13 22:28:35 +08:00
xiexian 56378d2f33 feat(studio): search MCP server tool inventories 2026-10-13 22:26:40 +08:00
YHH d5a9618ffd Merge pull request #12956 from esengine/fix/notify-click-raises-window
fix(notify): log the toast click path end to end
2026-10-13 07:15:30 -07:00
esengine df95cc8535 feat(studio): keep finished sessions in the Running list for a chosen time (off by default)
Fixes #12312
2026-10-13 07:13:25 -07:00
esengine 357d8079a2 fix(notify): log the toast click path end to end
Refs #12817
2026-10-13 07:05:37 -07:00
YHH 05d65a05ed Merge pull request #12954 from esengine/fix/files-button-toggles
fix(studio): quiet the activity group so the reply stands out
2026-10-13 07:02:14 -07:00
esengine b575889b2f fix(studio): keep the session title beside the project name in the title bar
Fixes #12924
2026-10-13 06:54:33 -07:00
keeyang 5f667467e8 feat(studio): mark sessions kept in the 1.x store
A conversation whose log still lives in the 1.x store is read here and never
written, so it shares the sidebar with this build's own rows and cannot be told
apart. The tree now reports which store each row came from (the kernel decides
it through IsForeignSessionLog) and the row shows a small "Old" mark at its end,
which yields to the hover actions.
2026-10-13 21:50:36 +08:00
esengine 3bcbbf3dbc fix(studio): quiet the activity group so the reply stands out
Fixes #12953
2026-10-13 06:48:32 -07:00
keeyang 3b2b1f6e32 fix(studio): register the share.remember action and complete the phone-access mocks
The phone-access panel renders data-action="share.remember" and reads
hub.shareRemember(), but neither reached the action registry nor the test
hubs that build one by hand: actions.test.ts failed with "rendered but not
in src/actions.ts" (PhoneAccess.tsx:194), and PhonePop.interaction /
PhoneAccess.port failed with "hub.shareRemember is not a function".

Register the action beside the other share.* entries and give both test
hubs the hook the panel now reads.
2026-10-13 21:35:14 +08:00
keeyang 230bf814c7 feat(serve): keep paired phones across restarts, with a settings switch
A phone pairs by scanning a QR code, and the credential it keeps rides a
cookie scoped to the origin it scanned. The host held that pairing in memory
only: closing the share or restarting the process threw every device away, so
the next launch asked for a fresh scan.

[serve] remember_paired_devices, false by default and the user's alone (a
project reasonix.toml that sets it is ignored and reported), keeps three things
when it is on: the phones that paired (digests only, written to
<state>/device-trust.json and adopted at startup), the address the share last
listened on, and whether the share was open at all. The persist hooks read the
key when they run, so the panel switch takes effect without a restart, and
turning it off forgets on the spot: everything paired is revoked and both files
are removed. A snapshot taken before a later change is dropped by revision, so
a concurrent pairing write can never overwrite a revoke. Address ordering keeps
its upstream preference. A phone that paired never expires: the point of the
switch is that one's own LAN phone connects again next time, and that is
intentional.

Closing the share still unpairs everything; that is the person withdrawing it.
Only the process going away keeps the phones. The state directory is the
kernel's, outside every workspace, and agent-run commands have no path to it:
sandbox writes are confined to the workspace and ~/.reasonix, and the trust
file never enters them. A restart reopens the listener only when the switch
was on and the share was open when the process left — the state of things the
person chose, not a new one.

Fixes #11834
Refs #11878
2026-10-13 21:35:14 +08:00
YHH d00f905d4e Merge pull request #12952 from SivanCola/codex/html-preview-selection-settlement
test(studio): await HTML preview selection / 等待 HTML 预览选中状态落定
2026-10-13 06:24:28 -07:00
YHH f290a6377a Merge pull request #12946 from xxoingr/test/gitcommit-literal-path-windows
test(gitcommit): stage a literal glob name Windows can create
2026-10-13 06:24:18 -07:00
YHH a05b31f489 Merge pull request #12949 from xxoingr/test/acp-drain-prompt-budget
test(acp): bound drainPrompt's idle wait by the hang budget
2026-10-13 06:24:03 -07:00
YHH 33b725f56c Merge pull request #12921 from SivanCola/codex/studio-linux-arm64
test(studio): strengthen Linux package verification / 增强 Linux 包与发包验证
2026-10-13 05:59:00 -07:00
YHH 6fee91ef4e Merge pull request #12918 from SivanCola/codex/studio-sidebar-divider
fix(studio): use the divider as the desktop sidebar control / 桌面侧栏统一使用分隔条操作
2026-10-13 05:58:45 -07:00
SivanCola 9e6e1d1e28 test(studio): await settled HTML preview selection
Problem: frontend CI intermittently asserted that Files was hidden before a
fresh browser-tab follower had committed its selection.

Root cause: retiring the preview's request record does not settle the separate
Promise.allSettled callback that updates the visible workbench selection.

Fix: wait for the asserted workbench DOM state, as the sibling preview test
already does, while preserving the selected-tab and newer-intent guarantees.

Verification: all 49 HTML preview and neighbouring browser race/rejection tests,
frontend typecheck, and repository lint/check pass; prescribed Go preflight
checks run before commit. CI is monitored on the pushed head.
2026-10-13 20:56:05 +08:00
YHH e47cb489b7 Merge pull request #12936 from SivanCola/feat/studio-brand-wordmark
feat(studio): unify official brand marks / 四处品牌区统一到正式字标与产品符号
2026-10-13 05:50:33 -07:00
esengine 6b14a90f88 Merge studio and keep both release-note sets 2026-10-13 05:41:51 -07:00
xxoingr ced22c2265 test(acp): bound drainPrompt's idle wait by the hang budget
TestE2ESessionLoad failed on Windows CI on studio (run 38179453323) with
"session/prompt: timed out" after 7.64 s: drainPrompt gave up when five
seconds passed without a notification or the prompt's response. A full
prompt crosses the ACP server, controller, agent and transcript
persistence, and a loaded Windows runner can stay quiet that long without
anything being stuck. Pausing the scripted tool for 5.5 s reproduces it.

drainPrompt now waits rpcCallBudget(t), the testenv.Budget wait the same
file's other RPC helpers already use.
2026-10-13 20:41:01 +08:00
YHH 13e58f3ae8 Merge pull request #12947 from esengine/fix/remove-neutral-blue
revert(theme): drop the unreleased Neutral Blue pack in favour of Clear Blue
2026-10-13 05:39:48 -07:00
SivanCola 810156ad6e Merge studio and preserve sidebar and activity-header release notes
Problem: The latest activity-header note conflicts with this PR's sidebar
entry at the end of the unreleased notes.

Root cause: Both branches append their independent entries at one anchor.

Fix: Preserve all upstream notes and place the sidebar entries alongside the
session-list entries. Retain the narrow-only drawer control and the sticky
activity header in the auto-merged stylesheet.

Verification: Sidebar/divider, responsive layout and activity-fold tests;
the Chromium sticky-header guard in light and dark; production build,
repolint, release-note/reference tests, typecheck and UI census.
2026-10-13 20:34:18 +08:00
esengine f4acb67608 revert(theme): drop the unreleased Neutral Blue pack in favour of Clear Blue
Refs #12909
2026-10-13 05:30:22 -07:00
xxoingr 3a402a47d6 test(gitcommit): stage a literal glob name Windows can create
TestOnlyChangedPathsAreAcceptedAndTheyAreLiteral fails on every Windows
run since 40e097879 (studio run 38383059323): it writes a file named
"*.txt", and a Windows file name cannot hold "*" ("The filename,
directory name, or volume label syntax is incorrect.").

The name now is "[ot][nw][eo].txt". As a git pathspec glob it matches
one.txt and two.txt, as "*.txt" did, and every filesystem accepts it, so
the test still proves Stage passes each path as :(literal) on all three
platforms.
2026-10-13 20:26:00 +08:00
YHH b5b7cf49e6 Merge pull request #12569 from xxoingr/fix/skill-shadow-warning-literal
test(installsource): cover hostile skill paths in discovery warnings end to end
2026-10-13 05:13:15 -07:00
YHH 70025d2bf7 Merge pull request #12940 from chuiwenwei/fix/memory-suppressed-recall-audit
fix(memory): audit the recall a carried note suppresses
2026-10-13 05:08:47 -07:00
YHH 68801c9d0b Merge pull request #12939 from chuiwenwei/fix/memory-migrate-index-failure
fix(memory): repair the index when a migrated fact is revisited
2026-10-13 05:08:31 -07:00
YHH 80ea09baa3 Merge pull request #12938 from chuiwenwei/fix/memory-save-index-failure-recovery
fix(memory): withdraw a save whose index publication fails
2026-10-13 05:08:15 -07:00
YHH 5ba9a614f0 Merge pull request #12944 from esengine/fix/activity-group-sticky-header
fix(studio): keep an open activity group's header at the top while it scrolls
2026-10-13 05:07:33 -07:00
YHH 2d2988e92f Merge pull request #12899 from XTLine/fix/hook-backup-identity
fix(configbackup): preserve distinct native hook identities
2026-10-13 05:00:35 -07:00
esengine a5eab68235 fix(studio): keep an open activity group's header at the top while it scrolls
Fixes #12931
2026-10-13 04:55:54 -07:00
xiexian 472f1a89ca feat(studio): open conversation file links in the workbench 2026-10-13 19:52:54 +08:00
YHH 2f2919b55b Merge branch 'studio' into fix/skill-shadow-warning-literal 2026-10-13 04:52:44 -07:00
SivanCola 9e306466b0 Merge studio and preserve sidebar and session-list release notes
Problem: The latest studio session-list release notes conflict with the
sidebar-control entry in this PR.

Root cause: Both changes prepend their notes at the same unreleased anchor.

Fix: Preserve the upstream session-list and theme notes, append the sidebar
entries in the existing flat-list format, and retain narrow-only drawer
controls alongside the upstream spacing changes.

Verification: Sidebar/divider, session-list, remote-title and spacing tests;
production build, typecheck, repolint, UI census and fixture verification,
and release-note/reference tests.
2026-10-13 19:51:21 +08:00
YHH 02a9021e85 Merge pull request #12407 from f1shsmell/feat/spacing-scale
feat(styles): give layout spacing an eight-step scale
2026-10-13 04:49:15 -07:00
Will e0d4357c7c fix(memory): audit the recall a carried note suppresses
A turn that already carries a memory note skips automatic recall, because the
note says the same thing better. That decision was recorded for the session's own
diagnostics but never reached the audit channel, so a trajectory could not say
that a real recall was suppressed and why.

The suppression now goes through the same content-free audit as a recall that
returned hits. A turn whose retrieval is disabled by ablation is unchanged.
2026-10-13 19:41:18 +08:00
Will 1a0e08e79f fix(memory): withdraw a save whose index publication fails
The plain save path, which covers a new fact and an update that keeps its name,
wrote the fact and then published the index. A failed publication returned the
error with the fact already written, so a retry of the same intent either
advanced the revision again or was refused by the revision guard the caller had
just satisfied.

The write is now withdrawn when the publication fails: a new fact is removed, an
update is rewritten from the bytes and mode that were on disk — not re-rendered
from the parsed fact, which would silently drop frontmatter this code does not
know, comments and hand-written formatting. If the withdrawal itself fails the
error carries the state it left.
2026-10-13 19:40:31 +08:00
YHH 0876ccc422 Merge pull request #12935 from SivanCola/codex/studio-compact-session-list
fix(studio): compact session rows and relocate batch selection / 紧凑会话列表并迁移批量选择入口
2026-10-13 04:33:17 -07:00
SivanCola 9bda2b6328 fix(studio): integrate brand marks with current Studio contracts
Problem: the brand patch conflicted with Studio and failed the sidebar and
type-scale contracts; the empty-session symbol replayed an entrance on mount.

Root cause: the presentation change retained old fixture expectations, typed
subtitle sizes directly, and treated a mounted brand as a new event.

Fix: integrate the current Studio base, retain its sidebar controls, update the
wordmark assertion, reuse shared size tokens, keep the hero symbol still, and
document the final brand contract without stylesheet commentary.

Verification: frontend typecheck/build/census, affected tests, Chromium guards,
go vet, make lint/check, and builtin/boot tests passed. Full frontend validation
is recorded in the PR; CI is evaluated on this merge's head before delivery.
2026-10-13 19:27:05 +08:00
SivanCola 8d406040e3 test(studio): strengthen Linux package and release verification
Problem: Studio packages need payload and installed-shell acceptance, and a
complete artifact gate before publication. Running the Electron and matrix
contracts on Windows also exposed host-dependent test fixtures.

Root cause: #12914 supplies native Linux ARM64 packaging but checks only Go
payload architectures and selected artifact names. The macOS bundle fixture
used the host OS's temporary path, and the matrix fixture relied on PATH
lookup for a mock git command instead of binding it in the tested shell.

Fix: Keep upstream native runners and system FPM, consolidate ELF checks,
verify both Linux package formats, exercise the installed shell, and require
all twelve nonempty release artifacts. Guard installation acceptance with
GITHUB_ACTIONS=true. Use platform-specific workspace fixtures and a shell
function for the matrix's git fixture, keeping all Windows test coverage.
Rebase on current studio and preserve adjacent release notes.

Verification: Electron 143 tests and Linux/script 39 tests pass, along with
workflow contracts, desktop Go tests, builtin/boot regressions, vet, lint,
repository checks, shell syntax and Actionlint. Previous-head hosted native
Linux package verification and installed acceptance passed for amd64/arm64;
replacement-head Windows CI remains the platform acceptance gate.

Refs #12906
2026-10-13 19:18:07 +08:00
SivanCola e41eaa3720 Merge studio to resolve the sidebar PR release-note conflict
Problem: The sidebar-control change conflicts with the theme release notes
added to studio after the PR was opened.

Root cause: Both branches append their next-release entries at the same
location in the unreleased notes.

Fix: Preserve both sidebar and theme entries, use the accepted release-note
heading, and keep the bilingual sidebar entries within the prose budget.
The auto-merged stylesheet retains narrow-only drawer controls and current
theme foundations.

Verification: Full frontend tests, typecheck, production build, repolint,
UI census and fixture verification, and release-note/reference tests.
2026-10-13 19:15:44 +08:00
XTLine ece6e8d45f fix(configbackup): align restore identity and minimum format 2026-10-13 04:13:14 -07:00
SivanCola 4240e6cbf7 Merge studio to resolve release-note conflict
Problem: PR #12935 became conflicting after the Studio palette changes landed.
Root cause: Both branches appended bilingual entries to the same unreleased
notes, and both touched the shared Studio stylesheet.
Fix: Merge studio while preserving the session-list and palette release notes.
Keep the upstream theme tokens alongside the compact session-row rules.
Verification: gofmt, go vet, make lint and make check passed. Frontend typecheck,
production build, UI census, all 92 focused session and theme tests, and the
light/dark 34px row geometry guard passed. Builtin, boot and theme Go tests
passed. Hosted CI still requires upstream workflow approval.
2026-10-13 19:11:33 +08:00
SivanCola 94cac74614 feat(studio): 空会话态改用产品符号,RMark 退回它该干的活
RMark 是运行状态指示器,不是品牌:pane 会按状态把它染成 --net / --ok /
--accent-ink,stroke-dashoffset 说的是"这一轮还在跑"。可空会话态那个
78px 的 R 用的是同一个组件,等于让一枚状态灯去代表品牌。

换 SymbolMark —— logo-symbol.svg 的路径,也就是 main-v2 顶栏在 22px 上
用的那一枚。内联而非 <img>:这一处用 --faint 画,<img> 只会给我们文件
里写死的蓝。main-v2 的 channelIcons 也是这么内联的。

两处行为变化,都是换对方向的结果:

- 不再随运行状态变色。品牌标记不该在 pane 忙的时候变成 --net。
- 描边生长动画换成淡入升起。drawin 走的是 stroke-dashoffset,那是描边
  的把戏,填充没有。@keyframes drawin 随之删除 —— 它已无引用。

15px 那处(R 空闲)保留 RMark:尺寸上字标不可用,符号的装饰细节也会
糊,而且它本来就是在报状态。

reduced-motion 里补了 .hero .sym-mark —— motion.test.tsx 的原则是加了
动画就得同时声明它不许动。
2026-10-13 19:05:53 +08:00
YHH 98712b569b Merge pull request #12932 from SivanCola/codex/builtin-clear-blue-tea-green
feat(theme): add Clear Blue and Tea Green palettes / 新增清白蓝与茶绿内置配色
2026-10-13 04:05:03 -07:00
SivanCola 0b3698342f fix(studio): compact session rows and move batch selection into the machine menu
Problem: The session sidebar mixes one- and two-line titles and reserves too
much vertical space. The persistent local-selection entry adds another blank
row between the filters and the machine tree.

Root cause: Studio clamps titles to two lines with a 42px minimum row height
and vertical margins; SessionBatch renders its entry even outside selection.

Fix: Use 34px single-line rows with full-title hover hints, move local batch
selection into the machine menu, and restore focus to that menu on exit.
Keep batch archive/delete and neighbouring row actions intact. Add browser
geometry coverage to Studio CI, interaction regressions, parity/release notes,
and before/after screenshots for light/dark wide and narrow windows.

Verification: Go formatting, vet, lint, builtin/boot tests, frontend typecheck
and build, UI census, browser geometry and affected interactions passed.
The frontend full run passed 4156 of 4157 tests; the unchanged HTML-preview
failure passed all 26 tests on isolated replay. The optional translation scan
reports the same two missing test-only keys on the base and this branch.
2026-10-13 18:58:08 +08:00
SivanCola fc0a5c71f8 fix(studio): 欢迎页字标放大到 300px
200px 在整屏的空场里显得比改动前还弱 —— 原来那行 40px 等宽的
"Reasonix Studio" 约 310px 宽,换成字标后整体反而缩了一圈。

放大还有个附带好处:300 / 118(侧栏字标宽)≈ 2.5,副标 20px 正好
接近侧栏 7.5px 的等比放大值,比 200/20 的比例更协调。

窄屏折叠态同步从 168px 提到 240px。折叠断点是 640px,.oobe-scene
左右各 40px,内容区 560px,余量充足。
2026-10-13 18:56:58 +08:00
YHH 0261d13269 Merge pull request #12929 from xxoingr/test/plugin-reader-schema-race
test(plugin): wait for the handshake's cache write before changing a schema
2026-10-13 03:56:25 -07:00
YHH 9b91f76517 Merge pull request #12930 from xxoingr/test/survey-partial-count-budget
test(storage): give a stalled walk room to reach its first entry
2026-10-13 03:56:05 -07:00
SivanCola 5c1869a2c1 feat(studio): 欢迎页字标改用正式资产,保留品牌本色
原来那个大 R 是手写 SVG 描边路径,本仓库第四个各画各的 R。换用
logo-wordmark.svg,与侧栏、向导同一份资产。

这一处不 mask、不反白:整个产品里最该亮出品牌本色的就是这个开场,
染成场景的暖灰反而是退让。深色底上 #0153e5 约 2.5:1,低于非文本图形
3:1 的门槛,所以补一层稳定的 drop-shadow 托住。

原来 R 描边上那道暖色光痕(lume,1.25s 起 1.5s)保留成"点亮"的瞬间,
落在同一个节拍上。

删掉 oobedraw / oobetrace / oobesweep —— 前两个只服务那段描边路径,
第三个原本扫的是文字的渐变底,字标不再走那条路。

mono.test.ts 里 oobe-brand / oobe-sub 两条例外的说明随职责更新:字标
换成了品牌资产(自身不说话),留在等宽字体里的是 STUDIO 那行和标语。
2026-10-13 18:51:20 +08:00
YHH b22f8f5f44 Merge pull request #12934 from xxoingr/test/control-async-waits-budget
test(control): bound inbox and edit-persist waits by testenv.Budget
2026-10-13 03:49:04 -07:00
SivanCola 950a85f20b feat(studio): 向导顶栏同样改用正式字标
侧栏换掉之后,连接模型服务这一屏的顶栏还留着旧的字母 R 方块,两条
title bar 摆在一起时能看出是两个不同的品牌。用与侧栏相同的 lockup 结构
替换,尺寸、副标排版和深色主题处理都取同一组值。

.
2026-10-13 18:38:35 +08:00
xxoingr e55b9e40e6 test(control): bound inbox and edit-persist waits by testenv.Budget
Three control tests failed on Windows CI at a fixed one-second wait for
something the controller does asynchronously:

- TestInboxDispatchKickDuringEmptyScanIsNotLost (run 38178550657) and
  TestRepeatedJobCompletionIsIdempotent (run 38283959118): "timed out
  waiting for inbox dispatch" from waitForInboxDispatch.
- TestEditPromptPersistsOriginalPrompt (run 38367692250): "edited prompt
  was not persisted before deadline" after its one-second poll.

Delaying the scripted runner or the provider by 1.2 s reproduces all
three messages. These waits only catch a hang, which is what
testenv.Budget is for; every one-second wait in inbox_dispatch_test.go
and the edit-persist poll now uses it. The 150 ms waits that assert
nothing happened are unchanged.
2026-10-13 18:32:59 +08:00
SivanCola 0636552ae3 feat(theme): add Clear Blue and Tea Green foundation palettes
Problem: Studio offers no plain built-in alternatives matching the white/blue
and warm/tea-green directions requested in #12909.

Root cause: the default swatch inherits active pack colours, plain packs lose
the default reading surface and weight, and legacy Studio rules override
semantic accent colours on selected controls and dark send buttons.

Fix: ship two light/dark token-only packs, share canonical default preview
colours, preserve plain reading styles, and close the affected controls over
theme tokens. Add optional accentInk, localized labels and backdrop grouping.

Verification: 209 frontend tests, production build/typecheck, theme and HTTP
server tests, go vet ./..., make lint, make check, builtin and boot tests.
Browser checks cover system-following, default restoration, and light/dark
workbenches at desktop and phone widths; screenshots are included.
2026-10-13 18:27:58 +08:00
xxoingr 0f9c2195fb test(storage): give a stalled walk room to reach its first entry
TestSurveyReturnsPartialCountWhenAWalkStalls failed the coverage job on
studio (run 38341178074) with "the partial count must survive: 0 bytes,
0 files". It gave the walk a 50 ms budget and expected it to count the
one file before running out; the walk's callback checks the budget's
context first, so a walk goroutine scheduled late counts nothing. A
100 ms delay before the walk reproduces it, and the same delay fails
TestTruncatedRootKeepsTheErrorFoundBeforeTheBudgetRanOut, which relies on
the same 50 ms.

Both now use stalledWalkBudget, 500 ms: ten times what the failure needed,
while Survey's three stalled roots still return well inside the test's
5 s guard.
2026-10-13 18:27:37 +08:00
SivanCola 132696f9eb fix(studio): 字标与副标改为纵向堆叠
上一版把 <img> 和 <small> 都放在 .studio-brand 这个 flex 行里,STUDIO
被排到了字标右侧。真机跑起来才看出来 —— 单看代码不容易发现 flex 行里
多一个兄弟节点意味着什么。

套一层 .studio-brand-lockup 用 grid 排两行,字标在上、副标在下。
2026-10-13 18:26:25 +08:00
xxoingr dd498e42c2 test(plugin): wait for the handshake's cache write before changing a schema
TestReaderIntentRefusesDispatchAfterSafetyDrift failed the race job on
studio (run 38334190859): the test replaces rt.schema at
plugin_test.go:1765 while the handshake's detached cache write
(saveHandshakeSchema -> cacheableToolsOf, cache.go:44) can still be
reading it. Nothing in the product writes a remote tool's schema after the
handshake; only this test does.

The test now drains host.bgWrites, which every detached writer joins,
before replacing the schema. With the cache write delayed by 300 ms, the
cached schema was the test's replacement before this change and the
handshake's own after it.
2026-10-13 18:24:11 +08:00
YHH 90f6ccff18 Merge pull request #12928 from esengine/feat/builtin-neutral-theme
feat(theme): ship a neutral white and blue pack
2026-10-13 03:23:43 -07:00
SivanCola 429765e490 feat(studio): 侧栏品牌区改用正式字标,补回 STUDIO 副标
侧栏原来自己画了一套品牌:StudioIcon 里内联的描边 R,配 CSS 圆角方块和
HTML 排的 reasonix/STUDIO 文字。这个 R 和正式品牌资产没有任何关系,是本
仓库第四个各画各的 R(另三处在 Welcome.tsx、Onboarding.tsx 和托盘)。

改成用 logo-wordmark.svg —— 资产已经随 frontend-next 引进来了,内容与
main-v2 那份逐字节一致,只是之前没人接线,app.css:181-185 那条引用它的
.brand 规则一直是死的。

main-v2 的字标里没有 STUDIO,整块照搬会让这个界面不再自称 Studio,与
studio 作为独立品牌的前提冲突,所以副标用 HTML 补回,字标按 main-v2 的
尺寸(118×24)与负边距(-14px)照搬。

一并处理的:

- 深色主题沿用 main-v2 的 brightness(0) invert(1)。SVG 内写死了
  #0153e5,而 <img> 没有颜色接口,这是唯一能把它转白的手段 —— 代价是品牌
  色在深色下被整个作废。改走 CSS mask 可以避免,但那就不是照搬了。
- .studio-collapse 保留。studio 的品牌行右侧带这个按钮,main-v2 没有。
- .studio-brand-mark / -word / -accent 三组类删除,decorative-tokens.test.ts
  里那条断言 .studio-brand-accent 的用例随之移除(该测试对选择器找不到是
  直接失败的)。
- StudioIcon 里的 brand 路径随之失去唯一引用,一并删除。
2026-10-13 18:18:23 +08:00
esengine ace76d7fe2 feat(theme): ship a neutral white and blue pack
Refs #12909
2026-10-13 03:12:16 -07:00
YHH c027688879 Merge pull request #12922 from KHG420/codex/studio-completion-badge
feat(studio): badge unread task completions on the application icon
2026-10-13 03:08:14 -07:00
YHH 43a4bb1775 Merge pull request #12914 from esengine/feat/studio-linux-arm64
feat(release): ship Studio for Linux arm64 (.deb and .rpm)
2026-10-13 02:56:08 -07:00
esengine 22cfb53ac9 test(release): the publish gate now requires twelve packages, each Linux one by name 2026-10-13 02:46:47 -07:00
YHH 660e5a2e82 Merge pull request #12923 from esengine/feat/stage-from-changes
feat(studio): stage and unstage changes from the changes list
2026-10-13 02:46:06 -07:00
esengine 40e097879d feat(studio): stage and unstage changes from the changes list
The commit button only records what is staged, and Studio had no way to stage:
an empty index sent people to git add. The changes list now shows each file as
staged, partly staged or not, ticks and unticks them, and stages everything at
once; the kernel accepts only paths git itself lists.
2026-10-13 02:38:38 -07:00
SivanCola d097836293 fix(studio): use the divider as the desktop sidebar control
Problem: The desktop sidebar has a redundant top hamburger button alongside
its divider, making the sidebar affordances ambiguous.

Root cause: The drawer entry is mounted in all layouts, although desktop
layouts already expose a divider that toggles and resizes the workspace rail.

Fix: Mount the top entry only under the scene fold, remove its desktop chrome
spacing, and preserve the existing divider and remembered width. Document
the intentional control removal and include light/dark responsive screenshots.

Verification: 488 frontend test files and 4141 tests pass, including divider
interaction and responsive boundary coverage. Typecheck, production build,
repolint, UI census and fixture verification pass. Browser interactions and
an isolated macOS Electron window verify the retained controls.

Refs #12917
2026-10-13 17:23:23 +08:00
xiexian c6cf583607 feat(studio): badge unread task completions on the application icon 2026-10-13 17:17:59 +08:00
yhh 14f3d5c24c feat(studio): add rich task workspaces
Add linked maps, comparisons, dashboards and workspace-backed photo acquisition
and viewing for desktop task surfaces.

Certify Mantine viewer interactions and isolate Windows command-line and direct
DNS test fixtures from host process/proxy behaviour.

Cache impact: shortened write_file schema changes tool/prefix hashes once on
upgrade (5572 to 5524 schema tokens); system prompt stays unchanged. Update the
boot goldens for that deliberate contract change. Image discovery stays hidden.
2026-10-13 02:16:47 -07:00
esengine aebcc9ed26 feat(release): ship Studio for Linux arm64 (.deb and .rpm)
The release builds linux/arm64 on a native ubuntu-22.04-arm runner and requires
all four Linux packages by name. electron-builder's own fpm is x86_64-only, so
the arm64 leg installs one from RubyGems. The package job checks that the Go
binaries inside the .deb are built for the architecture the package is named for.
2026-10-13 02:03:57 -07:00
YHH a41ce9f980 Merge pull request #12904 from XTLine/fix/quiet-workbench-polls
fix(studio): avoid unchanged workbench polling renders
2026-10-13 01:53:42 -07:00
Guan09 9696ae040c feat(studio): add rename dialog and latest-message auto naming 2026-10-13 16:52:51 +08:00
YHH 6114a4f7ec Merge pull request #12889 from XTLine/fix/mcp-tool-name-collisions
fix(mcp): reject colliding model-visible tool names
2026-10-13 01:50:53 -07:00
YHH d1cc96d1c6 Merge pull request #12893 from XTLine/fix/studio-mcp-capability-identity
fix(capability): preserve raw MCP identities across runtime paths
2026-10-13 01:48:31 -07:00
YHH 1f2e9c9687 Merge pull request #12892 from XTLine/fix/mcp-lazy-raw-identity
fix(mcp): preserve cached raw identity during lazy dispatch
2026-10-13 01:44:32 -07:00
YHH 669720b3fc Merge pull request #12908 from esengine/fix/trust-level-bar
fix(studio): end a machine row's actions on the same edge as a project row's
2026-10-13 01:42:48 -07:00
esengine 7a1b699780 fix(studio): end a machine row's actions on the same edge as a project row's 2026-10-13 01:33:56 -07:00
YHH 181b0d8eed Merge pull request #12888 from esengine/fix/subagent-status-not-droppable
fix(serve): keep sub-agent status frames when a client falls behind
2026-10-13 01:19:17 -07:00
YHH 3897e34dc2 Merge pull request #12907 from esengine/fix/notification-click
fix(notify): hand the foreground right to the shell on a toast click, and stop discarding send failures
2026-10-13 01:17:23 -07:00
esengine 294da726f7 fix(serve): keep sub-agent status frames when a client falls behind
The status channel of a child's progress rides ToolProgress, which the
broadcaster treated as droppable. Its terminal phase has no successor, so a
backlogged client lost it and kept showing the child as running.
2026-10-13 01:12:15 -07:00
esengine 6896ae1040 fix(notify): hand the foreground right to the shell on a toast click, and stop discarding send failures
A click on a Windows toast grants the right to take the foreground to the
process it activates, which is the kernel host; the window to raise is the
shell's. The host now grants the right before forwarding the click, and the
shell lifts a window the platform refused to focus. A toast that could not be
delivered is written to the host log instead of being dropped.
2026-10-13 01:08:43 -07:00
YHH f88827ef93 Merge pull request #12905 from esengine/feat/session-rail-polish
feat(studio): tell the session tree's levels apart and make its row actions findable
2026-10-13 01:02:41 -07:00
XTLine 388ea158dd fix(configbackup): version distinct hook identity snapshots 2026-10-13 01:01:55 -07:00
esengine 09ce7f22c6 feat(studio): tell the session tree's levels apart and make its row actions findable
Machine rows become small section headers with counts in words, project rows keep
the strongest line with a numeric pill, scope filters hide zero counts and read at
12px, and the revealed + and ... buttons are outlined and share one hover, press,
expanded and busy state.
2026-10-13 00:53:52 -07:00
YHH 14c37fbc46 Merge pull request #12871 from xlyy1/codex/context-window-refresh-12752
fix(studio): refresh context after model settings change
2026-10-13 00:49:59 -07:00
XTLine dd7fe3cc0d fix(studio): keep unchanged file polls out of React commits 2026-10-13 00:49:53 -07:00
YHH 835977ec08 Merge pull request #12898 from XTLine/fix/preserve-workbench-file-drafts
fix(studio): preserve unsaved workbench file drafts
2026-10-13 00:37:39 -07:00
YHH ac006a6711 Merge pull request #12897 from esengine/feat/brand-app-icon
feat(studio): a flat, friendlier app icon, and the sidebar shows it
2026-10-13 00:30:26 -07:00
XTLine 3f00870fda test(configbackup): cover metadata and normalized hook imports 2026-10-13 00:29:01 -07:00
XTLine 7a112879d7 fix(configbackup): preserve distinct native hook identities 2026-10-13 00:28:32 -07:00
XTLine 54702a85d9 fix(mcp): classify and report rejected tool catalogs 2026-10-13 00:25:46 -07:00
XTLine f8e8d3add1 fix(studio): preserve file draft scope during initial status 2026-10-13 00:21:57 -07:00
XTLine d6b8820faf fix(studio): retain workbench drafts across file navigation 2026-10-13 00:21:46 -07:00
XTLine 7fdaf4ed2c fix(studio): scope file drafts and requests to their workspace 2026-10-13 00:21:37 -07:00
XTLine 6c9ba53a92 test(studio): reproduce unsaved workbench draft loss 2026-10-13 00:20:43 -07:00
esengine 0531f88217 feat(studio): a flat, friendlier app icon, and the sidebar shows it
The icon keeps the R-and-whale silhouette and drops the gold gradients for flat
colour: blue tile, yellow R, a blue whale with a pale belly, eye and cheek, and a
band of waves. The sidebar brand mark is the same file instead of a drawn glyph.
2026-10-13 00:20:07 -07:00
YHH 5c9e8cdb11 Merge pull request #12895 from esengine/feat/sync-reopen
feat(feedback): reopen the issue when a reporter replies to a fixed report
2026-10-12 23:59:39 -07:00
XTLine ccfe034d0a fix(mcp): reject unusable caches before registration 2026-10-12 23:53:42 -07:00
esengine 05a9bba1a0 feat(feedback): reopen the issue when a reporter replies to a fixed report
A reply to a fixed report means the fix did not hold. feedback-sync reopens an
issue closed as completed after mirroring the reply, and Studio offers the
reply box on fixed reports.
2026-10-12 23:47:19 -07:00
YHH 8ec2b41318 Merge pull request #12891 from esengine/feat/rail-new-session
feat(studio): start a new session from its own button on the icon rail
2026-10-12 23:40:28 -07:00
YHH 6811ad08c9 Merge pull request #12890 from esengine/fix/bare-skill-raw
fix(control): record the typed text as raw input for a bare skill invocation
2026-10-12 23:39:35 -07:00
XTLine 5626e1fccb fix(agent): require MCP bindings for callable grants 2026-10-12 23:36:46 -07:00
XTLine 9aa914a05c fix(agent): record the executed MCP capability identity 2026-10-12 23:36:35 -07:00
XTLine c7c15b1942 fix(mcp): preserve cached raw identity during lazy dispatch 2026-10-12 23:36:03 -07:00
XTLine 4e6207173e fix(capability): preserve MCP identities in catalog snapshots 2026-10-12 23:35:51 -07:00
YHH 8f010c8dce Merge pull request #12870 from esengine/fix/view-tab-slide
fix(studio): move one highlight between the pane's view tabs
2026-10-12 23:32:59 -07:00
esengine fc59f10772 feat(studio): start a new session from its own button on the icon rail 2026-10-12 23:32:36 -07:00
YHH 00aa05ccab Merge pull request #12887 from esengine/fix/git-bash-probe-timeout
fix(sandbox): retry a hung Git Bash probe once and print the fallback shell path unescaped
2026-10-12 23:28:56 -07:00
esengine 922fed0f32 fix(control): record the typed text as raw input for a bare skill invocation 2026-10-12 23:28:52 -07:00
XTLine 6348150ee7 fix(mcp): reject colliding model-visible tool names 2026-10-12 23:24:04 -07:00
esengine 1c24d30e41 fix(studio): move one highlight between the pane's view tabs
Switching views ran a view transition over the whole pane, so the tab bar was
cross-faded with it: the old tab's box faded out while the new one faded in,
read as two separate animations. The highlight is now one element that sits
under the chosen tab; during a switch the tab bar is not cross-faded and the
highlight slides and resizes to the new tab, with the labels above it.
2026-10-12 23:20:58 -07:00
yhh 1409a8ee35 feat(studio): add nonblocking surface actions 2026-10-12 23:16:33 -07:00
YHH df0d334dc5 Merge pull request #12886 from esengine/fix/close-blank-tab
fix(studio): close a browser tab without reading a body the kernel never sends
2026-10-12 23:15:26 -07:00
esengine 2679dcf7f6 fix(sandbox): keep control characters escaped in the fallback shell path 2026-10-12 23:14:21 -07:00
Will 3b8a9fcda5 fix(memory): repair the index when a migrated fact is revisited
MigrateV2 rewrote a legacy fact as V2 and then published its index line. If that
publication failed the migration reported the error with the fact already
rewritten, and the next run saw a fact that already had an id and a revision and
skipped it, so the index stayed stale with no path back.

A fact that already looks migrated is now reindexed instead of skipped, so the
second run converges on the index the first run could not publish.
2026-10-13 14:09:44 +08:00
YHH f00d90f4b5 Merge pull request #12877 from esengine/feat/file-add-to-chat
feat(studio): add a file or folder to the conversation from the Files menu
2026-10-12 23:08:22 -07:00
esengine 4f5a609bc9 fix(sandbox): retry a hung Git Bash probe once and print the fallback shell path unescaped 2026-10-12 23:07:04 -07:00
YHH 53774b03ef Merge pull request #12882 from esengine/feat/wait-streams-output
feat(tools): show what a waited-on job writes while wait runs
2026-10-12 23:06:24 -07:00
esengine 2705b57f2c fix(studio): close a browser tab without reading a body the kernel never sends
The kernel answers POST /browser/close with 204 and no body, and the port read
it as JSON, so every successful close reported "Unexpected end of JSON input".
2026-10-12 23:05:54 -07:00
esengine 3804d9f156 feat(studio): add a file or folder to the conversation from the Files menu 2026-10-12 23:00:08 -07:00
par73e 568b1792d7 fix(e2ebench): bind SFT exports to graded run provenance 2026-10-13 13:55:44 +08:00
par73e bd30cf6a5c fix(studio): keep an engaged ask from timing out 2026-10-13 13:55:37 +08:00
esengine d36858a9b1 feat(tools): show what a waited-on job writes while wait runs
wait now echoes the jobs' new output, as whole lines and capped at 64 KiB,
through the call's live progress, starting from their latest lines. The
frontend folded a progress frame like a dispatch, so the frame's empty name
blanked the step and each chunk replaced the last; it now appends the chunk
and keeps the call's name.
2026-10-12 22:53:03 -07:00
YHH d460758e7c Merge pull request #12878 from esengine/feat/ssh-hosts-group
feat(studio): gather remote machines under one foldable row in the sidebar
2026-10-12 22:30:46 -07:00
esengine 406e70378a feat(studio): gather remote machines under one foldable row in the sidebar 2026-10-12 22:21:00 -07:00
YHH 84c6690c61 Merge pull request #12875 from esengine/perf/workbench-switch
perf(studio): tween the workbench width on the pane, not on the whole app
2026-10-12 22:13:59 -07:00
esengine cb2552e5ae perf(studio): tween the workbench width on the pane, not on the whole app
--dock-w was a registered, inherited custom property tweened on .app, so every
frame of opening or closing the workbench restyled every node in the window.
It now does not inherit and lives on the pane whose grid reads it; the gutter
writes it there while dragging. Style recalculation while switching halves,
and the long frames on a throttled long transcript mostly go away.
2026-10-12 22:04:47 -07:00
YHH 861e35c3c6 Merge pull request #12874 from esengine/fix/hidden-folders-turn-currency
fix(studio): value a turn in the wallet's currency, and list hidden folders in the folder picker
2026-10-12 21:55:30 -07:00
esengine ab951a1bbf fix(studio): value a turn in the wallet's currency, and list hidden folders in the folder picker
With the display currency left automatic, the server adopted the wallet's
currency for its own ledger only: the quotes on usage events and the frontends
were never told, so the status bar showed this turn in US$ beside a CNY wallet.
The controller's Balance now adopts it and announces it as an operator notice,
the same path a chosen currency takes, so every quote and frontend follows;
a chosen currency still wins and nothing is written to the config.

The folder picker also shows hidden folders, where tools keep their state.
2026-10-12 21:44:49 -07:00
xlyy1 4d999069aa fix(studio): refresh context after model settings change 2026-10-13 12:38:23 +08:00
YHH d5fb91f607 Merge pull request #12869 from esengine/fix/window-edge-colour
fix(desktop): give the window frame the app's theme so Windows draws no light border
2026-10-12 21:38:17 -07:00
YHH 4041b48a8e Merge pull request #12865 from esengine/fix/session-tree-rows
fix(studio): one line per project in the session tree
2026-10-12 21:35:43 -07:00
Guan09 11b8ceb38d fix(agent): enforce file access before previews
Enforce workspace read policy before previews, diagnostics, hooks, and file execution.

Provide admitted previews to permission approval and Auto Guard, then capture fresh preimages after PreToolUse. Share target access checks across execution, planning, review, and delegated agents.

Keep genuinely write-only targets available, report existing-file overwrite refusals with write-specific messages, and avoid hook-write coverage gaps for read-only calls.

Preserve network-path protection before filesystem lookup while allowing authorized network roots and registered folder references.

Include baseline-compatible disclosure regressions, production target-access wiring checks, and localized tool-card tests.
2026-10-13 12:28:57 +08:00
esengine 1faa3c1bbd fix(desktop): give the window frame the app's theme so Windows draws no light border
Windows 11 draws a frameless window's border from the frame's scheme, which
Electron takes from nativeTheme. Studio never set it, so a dark theme chosen in
the app on a light system kept a light frame, seen as a light line around the
window. The frame now takes the stored theme (auto still follows the system),
at launch and whenever the preference is saved.
2026-10-12 21:28:15 -07:00
esengine 422715f257 fix(studio): one line per project in the session tree
A project row put its session count on a second line, so every project took
two rows. The count now sits where the row's actions are and gives way to them
on hover or keyboard focus, so a row is one line and the name keeps its width;
touch screens show the actions. A tag still takes a second line. The two add
buttons share one size and hover, and the filter labels and counts are larger.
2026-10-12 21:08:34 -07:00
YHH 40dd5cd9b2 Merge pull request #12856 from esengine/fix/sidebar-head-no-blank-strip
fix(studio): stop the sidebar head leaving a blank strip under the toggle
2026-10-12 20:59:02 -07:00
YHH fbd8d975b2 Merge pull request #12864 from esengine/fix/workbench-flush-divider
fix(studio): end the workbench card's tab strip on the pane's tab-row line
2026-10-12 20:55:47 -07:00
YHH 3e0b3a8aeb Merge pull request #12863 from esengine/fix/center-dialogs
fix(studio): centre dialogs on the window rather than below the title bar
2026-10-12 20:52:26 -07:00
esengine 90035ae541 fix(studio): end the workbench card's tab strip on the pane's tab-row line
The docked workbench card sat 8px into the pane with a 39px strip, so its
bottom line ran 7px below the conversation's tab row and the two read as
unrelated bars. The strip is now the shared tab-row height less the card's
inset and border, so both lines meet; the card keeps its corners and edge.
2026-10-12 20:45:43 -07:00
esengine a838930ad6 fix(studio): centre dialogs on the window rather than below the title bar
Settings, feedback, a full screenshot, a checkpoint preview and a sub-agent
transcript open in veils that start below the title bar, so centring inside
them put each dialog half a bar below the window's middle. Each veil now
carries a transparent bottom border as tall as the bar, which makes the box it
centres in symmetric; the phone layout keeps settings full screen.
2026-10-12 20:33:23 -07:00
yhh 6f7f0f8b78 feat(studio): add editable drafts and text diff previews 2026-10-12 20:20:12 -07:00
YHH 6e0e7f37cb Merge pull request #12860 from esengine/feat/verification-off-switch
feat(config): let the user turn off the checks the host asks of a changed turn
2026-10-12 20:19:38 -07:00
yhh 98395aafe4 feat(studio): add styled native surface forms and choices 2026-10-12 20:15:28 -07:00
YHH d15c8d5c89 Merge pull request #12859 from esengine/feat/esc-confirm-stop
feat(studio): ask for a second Escape before stopping a running turn
2026-10-12 20:14:34 -07:00
YHH 37760c66d3 Merge pull request #12857 from esengine/takeover/12288
feat(studio): open the project folder from the chrome folder mark
2026-10-12 20:13:57 -07:00
esengine f1a5eb9ebb feat(config): let the user turn off the checks the host asks of a changed turn
[agent] host_verification = "off" in the user file drops the check, review and
sign-off a turn that changed files owes before it may finish. The turn's own
unfinished plan and a capability the user required still hold it, and the
receipt still says what was and was not checked. A project file can neither
turn it off nor undo the user's choice.
2026-10-12 20:10:35 -07:00
esengine 3f3e3c9278 feat(studio): ask for a second Escape before stopping a running turn
Escape stopped a running turn at once, so a stray press ended a long task. The
first Escape now arms the stop and shows a hint for 2.5 seconds; a second one
inside that window stops. Appearance gets a per-machine choice to stop on the
first press again. The stop button is unchanged.
2026-10-12 19:55:47 -07:00
esengine 44e6f6adba fix(studio): show the folder button's pointer and hover only while it can be used 2026-10-12 19:41:29 -07:00
esengine 1b4792e8b0 Merge PR #12288 into studio and keep the project folder from launching an application bundle
openWorkspace now opens a folder only when it is a directory and, on macOS,
not an application bundle; anything else is selected in its parent as before.
2026-10-12 19:36:56 -07:00
YHH 2a951af216 Merge pull request #12855 from esengine/fix/sidebar-no-duplicate-nav
fix(studio): keep the sidebar toggle clickable in the desktop shell
2026-10-12 18:52:17 -07:00
esengine 5aeafaf9fa fix(studio): stop the sidebar head leaving a blank strip under the toggle
With the icon rail off or collapsed the whole sidebar head was indented 44px
further so the toggle had room beside the wordmark, which left a blank strip
beside the new-session, search and quick-entry rows. Only the wordmark row
makes room now; every other row sits at the same inset in all three modes.
2026-10-12 18:45:06 -07:00
esengine d0ec5b3d0b fix(studio): keep the sidebar toggle clickable in the desktop shell
The toggle was declared before the nav column, the sidebar head and the
chrome, all of which are window-drag regions in the desktop shell. Where drag
regions overlap, the one declared later wins, so a click on the toggle moved
the window instead. It is now the last child of the app; its z-index drops one
step so the settings sheet still covers it.
2026-10-12 18:38:24 -07:00
YHH c48ee0f80d Merge pull request #12852 from esengine/docs/release-2.38.0
docs(release): Studio 2.38.0 notes
2026-10-12 17:45:52 -07:00
esengine 10e929f3d5 docs(release): Studio 2.38.0 notes 2026-10-12 17:37:03 -07:00
YHH a750f83859 Merge pull request #12850 from esengine/fix/selection-from-data
fix(studio): hold the blocks a long selection passes through as plain text
2026-10-12 17:27:13 -07:00
YHH ff77cb75ff Merge pull request #12849 from esengine/fix/mcp-tool-name-128
fix(mcp): keep a model-visible tool name within the provider's 128-character limit
2026-10-12 17:26:10 -07:00
esengine 780d5e04fa fix(studio): hold the blocks a long selection passes through as plain text
A selection reaching into blocks that are not mounted mounted every block in
between in one synchronous render, so a drag across a long conversation froze
the window (22s at 1200 turns). A block the selection only passes through, with
neither end inside it, is now held as the conversation's own text at its
placeholder height; blocks with an end inside stay whole.
2026-10-12 17:14:37 -07:00
esengine b4060aa4fd fix(mcp): keep a model-visible tool name within the provider's 128-character limit
A tool whose prefixed name exceeded 128 characters made the provider refuse
every request in the session. toolName now shortens only the tool part,
keeping the server prefix and a hash of the full name so siblings stay
distinct and the name is deterministic for permission and hook rules.
2026-10-12 16:42:25 -07:00
Adalberto Pereira e66ef9f65a refactor(plugin): refresh a catalog through the one list-judge-build path
The refresh listed and built its own catalog, so it reached the model with
definitions the pin judgement #12597 added had never seen: a server could wait
until the session was running and announce a tool nobody approved. The judged
path now has one entry point for both listings — read, then publish — and the
refresh goes through it.

readToolCatalog takes no lock and publishes nothing, so the refresh keeps
building its candidate off the lock and swapping it in as one piece, and
publishToolCatalogLocked moves every field together: what was withheld was the
one a caller could forget, and it is the one that makes the judgement visible.
The digest covers the held set, so approving definitions the server had added
is itself a change the next listing publishes.

What the session does with that follows: the registry's held set is replaced
from the same judgement, as at every other registration site, and a pinned slot
whose tool is held says so instead of claiming the server dropped it.

hostNotices moves to sessionRuntime, which is its lifetime: a withdrawn slot
outlives the conversation that saw it. That also keeps agent.go inside its
file-size budget, and the catalog path moving to tool_catalog.go keeps
plugin.go inside its own, with no baseline spent.

Refs #11957
2026-10-12 19:33:59 -03:00
Adalberto Pereira ca39c4b7b3 fix(plugin): keep a pinned tool's schema when a refresh drops it / 刷新丢弃工具时保留已固定的 schema
A server that drops a tool it had pinned into the provider-visible array was
rewriting that array mid-session: the cache-stable prefix moved and every token
behind it was spent again. The entry now stays until the session ends, the call
it still invites fails with the live catalog's not-found, and the model is told
on the turn tail — the one place an append leaves the prefix byte-stable.

Registry.ReplacePrefix swaps a server's namespace under one lock. The removal
and the re-registration were two calls, so a turn composing its request between
them was built from a server that appeared to have no tools.
2026-10-12 19:08:21 -03:00
Adalberto Pereira d0c057dbd9 feat(plugin): refresh an MCP catalog on tools/list_changed / 收到 tools/list_changed 后刷新工具目录
A server that declared tools.listChanged may add, rename or drop tools after
startup and says so with notifications/tools/list_changed. All three transports
dispatched notifications/progress and dropped every other notification, so the
catalog stayed as first listed: a tool the server added was unreachable, and one
it removed was still offered to the model.

The transports now surface a notification by method, the client re-lists once a
burst settles, and the Host publishes a catalog that actually changed. The
session's registry re-registers it, so what the model can reach follows without
moving the cache-stable prefix.

Registrations live on the reconnecting transport and are rebound onto a
replacement connection, and a backend rolled in behind the stable proxy is
watched like any other, so a connection that dies does not leave the server
unheard for the rest of the session.
2026-10-12 19:08:21 -03:00
YHH ce84fe9ff8 Merge pull request #12848 from boscocp/fix/readiness-12567-unrecognized-command-write
fix(readiness): an unrecognised command clears the check only when it did not write / 未写入时才清除待验证
2026-10-12 15:03:15 -07:00
Adalberto Pereira 03aeb27e4e test(readiness): turn the shell jail off so the effect turn's commands run
The unrecognised-write effect test asserts on what the turn's commands
changed, but left the sandbox at its default. A runner that has no bwrap
refuses to run them at all, so the case that keeps the waiver owed the check
for a reason the test is not about: green on the `test` jobs, which install
bubblewrap, and red on `coverage`, which does not.

Every other effect test that needs a shell writes `[sandbox] bash = "off"`;
this one now does too. Reproduced with a PATH that hides bwrap, which fails
with the CI's exact line and passes with the fix.
2026-10-12 18:42:27 -03:00
Adalberto Pereira ea8f6c9ca6 fix(readiness): an unrecognised command clears the check only when it did not write / 未写入时才清除待验证
A turn that wrote code and then ran any command the classifier could not read
as a check was released as verified. The waiver was keyed on one fact — "not a
check" — while the receipt carried a second one it never read: whether the host
watched the command write. `echo … > generated.txt` after a code write is
graded MutationProven from the redirect itself, and the turn still finished
with the change unverified.

The waiver now also requires the grade the host already recorded, and requires
it of every unrecognised command after the write rather than the most recent
one: a quiet `ls` beside a writing command says nothing about that command. A
turn whose unreadable commands all left the tree as the host found it keeps the
waiver — that miss is the classifier's blind spot, not the turn's. A write the
host watched, and a change whose extent it could not establish, leave the check
owed: an incomplete observation never establishes scope, and unproven_mutation
is settled by observation rather than by a check that ran afterwards.

Ordinary turns only: Delivery gates verification through a corroborated
citation and never had this waiver.

Refs #12567
2026-10-12 17:37:55 -03:00
YHH 217702190c Merge pull request #12839 from esengine/takeover/12660
fix(theme): make wallpaper transcript glow configurable
2026-10-12 12:33:45 -07:00
YHH 71164ebf3c Merge pull request #12846 from xxoingr/test/session-viewed-late-heal
test(session): let the torn-sidecar view test outlast a late heal
2026-10-12 11:06:49 -07:00
YHH 057894edec Merge pull request #12847 from xxoingr/test/name-unsealed-evidence-cause
test: name the store's cause when a turn's evidence was not sealed
2026-10-12 11:04:48 -07:00
YHH 9f78a77e1c Merge pull request #12842 from XTLine/fix/studio-file-reference-boundaries
fix(studio): guard ambiguous file reference copies
2026-10-12 10:36:45 -07:00
YHH f9548ec3dd Merge pull request #12843 from thehui13/codex/fix-memory-recall-audit-12784
fix(memory): preserve recall omission causes in trajectories
2026-10-12 10:34:40 -07:00
xxoingr 1b6929aad4 test: name the store's cause when a turn's evidence was not sealed
Two tests that read a turn's sealed bundle have failed on Windows CI with
nothing that says why the bundle was missing:

- TestContractCriterionIsSatisfiedByItsOwnCheck (runs 38185765331 and
  38199681271): "trusted state tampered: malformed digest """, because the
  test read the empty Record of an unsealed audit as a digest.
- TestEffectSupportingWritesWithoutChecksMayFinish/markdown_only (run
  38252045191): "missing sealed evidence".

The audit already carries the store's typed cause. sealedRecord fails with
FailureCode and ContractFailure when the bundle was not sealed, and every
agent test that reads a sealed record goes through it; the boot test prints
the audits it found.
2026-10-13 01:30:28 +08:00
YHH cad221e1d6 Merge pull request #12845 from esengine/fix/pinned-sessions-lead
fix(studio): lead a project with its pinned conversations and mark them
2026-10-12 10:23:23 -07:00
xxoingr 458fbbcabf test(session): let the torn-sidecar view test outlast a late heal
TestSessionViewedToleratesASidecarCaughtMidReplace failed in the coverage
job (run 38264909143) with "a view that met a torn sidecar failed instead
of waiting it out" in 0.20 s. The view retries a torn sidecar for 20, 50
and 100 ms; the test heals it from a goroutine after 30 ms, so the heal had
140 ms of slack, and on a loaded runner the goroutine started later than
that. Delaying the heal to 250 ms reproduces the failure locally.

The test now appends a 5 s last retry to the product's schedule, the way
the meta-ledger tests already replace it, so a late heal is still waited
out. A reader that does not retry at all still fails the test.
2026-10-13 01:23:09 +08:00
yhh d68d34c811 fix(studio): lead a project with its pinned conversations and mark them
Pinning a conversation only fed the Pinned filter and the menu label, so in the
full list nothing moved and nothing showed it was pinned. Pinned conversations
now come first within their project, the kernel's order holding inside each
group, and each carries a small pin.
2026-10-12 10:14:40 -07:00
YHH 510915ba51 Merge pull request #11822 from esengine/feat/fix-11734-c
fix(plugins): publish a replaced plugin directory without renaming a watched tree on Windows
2026-10-12 10:14:13 -07:00
yhh 6db92a8904 refactor(plugins): keep the superseded-copy cleanup beside the copy helpers
plugin_package.go was over the 800-line ceiling.
2026-10-12 10:06:46 -07:00
YHH 586b3068c4 Merge pull request #12841 from esengine/takeover/11744
fix(studio): make session tabs opt-in
2026-10-12 10:04:37 -07:00
hashem e3985f1971 fix(memory): preserve recall omission causes in trajectories
Refs #12784
2026-10-13 00:52:29 +08:00
XTLine d9884c795c fix(studio): guard trailing backslash reference boundaries
(cherry picked from commit a33d4a2fe7af32b00bfd924e5897191e94911a48)
2026-10-13 00:52:12 +08:00
XTLine 6cc59ec78c fix(studio): avoid ambiguous workspace reference copies
(cherry picked from commit 1f547d9738e8bd030e84c8d2734ed25f79dac5c8)
2026-10-13 00:51:36 +08:00
yhh fbcc83cdc6 test(studio): turn the session tabs on in the tests that switch panes by tab
The tab strip is opt-in now, so tests written against an always-present strip
enable the setting first.
2026-10-12 09:47:02 -07:00
yhh 174571c2a9 Merge commit 'refs/pr11744' into takeover/11744
# Conflicts:
#	desktop/frontend-next/src/i18n/en_settings.ts
#	desktop/frontend-next/src/state/prefs.ts
#	desktop/frontend-next/src/ui/Appearance.tsx
#	desktop/frontend-next/src/ui/prefsnav.tsx
2026-10-12 09:41:51 -07:00
YHH d1f096b206 Merge pull request #12837 from esengine/takeover/12069
feat(studio): add a shared send shortcut preference
2026-10-12 09:26:35 -07:00
yhh 3bce33bff3 Merge commit 'refs/pr12660' into takeover/12660
# Conflicts:
#	desktop/frontend-next/src/port/look.ts
#	desktop/frontend-next/src/styles/TOKENS.md
#	internal/ext/theme/theme.go
2026-10-12 09:25:15 -07:00
YHH 2f27112578 Merge pull request #12836 from esengine/takeover/12311
feat(studio): quote selected reply text from the context menu
2026-10-12 09:18:47 -07:00
yhh a2ae61b9c0 Merge commit 'refs/pr12069' into takeover/12069
# Conflicts:
#	desktop/frontend-next/src/i18n/en_settings.ts
2026-10-12 09:17:26 -07:00
YHH 17589e87d5 Merge pull request #12835 from esengine/takeover/12349
fix(studio): batch-check models before adding a source
2026-10-12 09:09:26 -07:00
yhh 0818a41038 fix(studio): merge the selection menu with the reply branch action and copy label
CopyButton gained both a menuitem role and an aria label on studio, the
actions list gained reply.branch, and the new selection tests build a reply
action set that now also needs the branch pair.
2026-10-12 09:08:38 -07:00
yhh 4473c23aa5 fix(studio): batch-check models on the protocol being picked
The edit form can change a connection's protocol, so the shared batch
hook is given the draft protocol rather than the stored entry's.
2026-10-12 08:59:48 -07:00
yhh 0a647e12ff Merge commit 'refs/pr12349' into takeover/12349
# Conflicts:
#	desktop/frontend-next/src/ui/AddProvider.tsx
#	desktop/frontend-next/src/ui/EditConn.tsx
2026-10-12 08:55:21 -07:00
YHH f43cc2af32 Merge pull request #12818 from esengine/takeover/12808
feat(studio): add file path and reference copy actions
2026-10-12 08:46:09 -07:00
yhh 101e82b5bb fix(plugins): removing a plugin reclaims the copies a replacement left beside it
A replacement published beside the old tree keeps the old tree, so an
uninstall that removed only the active root left the managed directory
behind. Removal now also deletes the canonical copy and earlier staged
copies, skipping any directory the registry still points at.
2026-10-12 08:42:16 -07:00
yhh 21219f6890 test(plugins): compare the publication fault with errors.Is 2026-10-12 08:38:14 -07:00
yhh 02a8abeec3 Merge remote-tracking branch 'origin/studio' into own/12818
# Conflicts:
#	desktop/frontend-next/src/ui/WorkbenchPanel.tsx
2026-10-12 08:36:07 -07:00
YHH 7fbcc02b92 Merge pull request #12832 from esengine/takeover/12824
fix(studio): open the focused session files from navigation
2026-10-12 08:34:04 -07:00
YHH 17af885a67 Merge pull request #12831 from esengine/takeover/12814
feat(studio): add persistent event-driven rewind notice
2026-10-12 08:22:35 -07:00
yhh a81212d745 Merge commit 'refs/pr12824' into takeover/12824
# Conflicts:
#	desktop/frontend-next/src/ui/WorkbenchPanel.tsx
#	desktop/frontend-next/src/ui/browser-missed-read.interaction.test.tsx
#	desktop/frontend-next/src/ui/browser-open.test.ts
#	desktop/frontend-next/src/ui/html-preview.interaction.test.tsx
#	desktop/frontend-next/src/ui/useColumnOpening.ts
2026-10-12 08:21:06 -07:00
yhh cf1ca1ff06 fix(studio): classify the rewind change event and register the dismiss action
rewind_changed is a content-free invalidation a reopened pane replaces with
a read of /rewind/undo, so the wire log skips it, and the new close button
declares its action id.
2026-10-12 08:13:21 -07:00
YHH 7ee7a7fe8e Merge pull request #12826 from XTLine/fix/studio-workbench-launcher
fix(studio): offer a launcher from the workbench new-tab button
2026-10-12 08:05:40 -07:00
yhh c430ae5fe3 Merge commit 'refs/pr12814' into takeover/12814 2026-10-12 08:03:47 -07:00
YHH 06ff93fda2 Merge pull request #12829 from esengine/feat/mcp-held-approval
feat(studio): approve MCP tools whose definition changed
2026-10-12 07:54:54 -07:00
XTLine 9ea570231a test(studio): cover missed and superseded browser observations 2026-10-12 22:44:43 +08:00
XTLine ba06cdd16f fix(studio): reconcile browser navigation read boundaries 2026-10-12 22:44:33 +08:00
yhh 470fcfea8b feat(studio): approve MCP tools whose definition changed
A server whose tool definitions differ from what the user approved has those
tools withheld from the model, and the kernel already had an approval route
for them, but the settings row never said so and offered no way to approve.
The row now carries the withheld tools with how each differs, and a button
that sends back the digest of exactly that set, then reconnects.
2026-10-12 07:42:27 -07:00
XTLine 79f5f1f90b fix(studio): follow fresh activation after a missed tab read 2026-10-12 22:38:57 +08:00
XTLine 5a1e3655d4 fix(studio): preserve the latest eligible browser follow 2026-10-12 22:25:34 +08:00
XTLine c58f236dd8 fix(studio): track browser navigation read boundaries 2026-10-12 22:25:09 +08:00
yhh 50014383fb style(plugins): iterate directories backwards with slices.Backward 2026-10-12 07:13:53 -07:00
YHH 47056a93ee Merge pull request #11761 from esengine/feat/web-search-model
feat(studio): web search model setting in model preferences
2026-10-12 07:08:21 -07:00
yhh 6d98fbe7dd Merge remote-tracking branch 'origin/studio' into own/11822
# Conflicts:
#	desktop/frontend-next/src/port/plugin.ts
#	internal/ext/installsource/plugin_package.go
#	internal/ext/installsource/types.go
2026-10-12 07:06:41 -07:00
yhh 4395969694 fix(config): match the provider removal signature in the web search model test 2026-10-12 07:00:57 -07:00
YHH d48ba5e65b Merge pull request #11762 from esengine/feat/browser-fullscreen
feat(studio): wide mode for the browser pane that keeps the conversation visible
2026-10-12 06:57:23 -07:00
yhh 8931d3e612 Merge remote-tracking branch 'origin/studio' into own/11761
# Conflicts:
#	internal/model/anthropic/anthropic.go
2026-10-12 06:54:13 -07:00
XTLine c51e648adf test(studio): cover preview ownership and real browser closing 2026-10-12 21:50:46 +08:00
XTLine 6dd1650dca test(studio): cover launcher choices and interrupted browser opens 2026-10-12 21:50:34 +08:00
XTLine 0ee194932c fix(studio): open the workbench launcher from the new-tab button 2026-10-12 21:50:23 +08:00
yhh bcfbe73bc0 test(studio): identify the browser page by its address bar in the wide-mode tests
The page's iframe now exists only after a navigation and the browser opens
from the workbench launcher, so the tests open the launcher and compare the
address bar, which stays mounted across the toggle.
2026-10-12 06:49:46 -07:00
XTLine 06009af71b fix(studio): track browser opening and preparation lifetimes 2026-10-12 21:49:38 +08:00
YHH a0a0763fdd Merge pull request #12819 from chuiwenwei/fix/memory-archive-index-consistency
fix(memory): keep the index and the archive consistent when a publish fails
2026-10-12 06:46:13 -07:00
YHH 9ea250b5bf Merge pull request #12816 from chuiwenwei/fix/memory-restore-archived-retryable
fix(memory): keep a failed archived-memory recovery retryable
2026-10-12 06:44:48 -07:00
yhh 68eaa5069e Merge remote-tracking branch 'origin/studio' into own/11762 2026-10-12 06:41:44 -07:00
yhh 139554c91a feat(studio): recover extension surfaces and count question interactions 2026-10-12 06:37:28 -07:00
XTLine 6a30c3e3c6 test(studio): cover operation preparation, observation and reuse lifetimes 2026-10-12 21:34:55 +08:00
XTLine 0461622f70 test(studio): cover tab-bearing refusals and hidden preview reconciliation 2026-10-12 21:34:39 +08:00
XTLine 67da7b1930 test(studio): cover event and explicit file selection races 2026-10-12 21:34:21 +08:00
XTLine d5b7dd6809 fix(studio): route Files entries into the focused session explorer 2026-10-12 21:34:06 +08:00
XTLine 4357242a98 fix(studio): keep explicit selection ahead of superseded browser requests 2026-10-12 21:33:50 +08:00
XTLine 26768e54d6 fix(studio): track full browser-opening operations with API controls 2026-10-12 21:32:16 +08:00
YHH 8734ee8d7a Merge pull request #12821 from esengine/takeover/12805
feat(studio): add per-model display aliases
2026-10-12 06:19:36 -07:00
yhh 38e66576fc Merge remote-tracking branch 'origin/studio' into takeover/12805 2026-10-12 06:11:30 -07:00
yhh 49d8c158bc Merge commit 'refs/pr12805' into takeover/12805 2026-10-12 06:01:33 -07:00
yhh 931dda7061 fix(studio): drop the import the file menu extraction left unused 2026-10-12 05:55:20 -07:00
YHH abc23f9fea Merge pull request #12815 from esengine/takeover/12804
fix(studio): make crowded timeline signals inspectable
2026-10-12 05:54:39 -07:00
yhh 0626f197ff Merge commit 'refs/pr12808' into takeover/12808
# Conflicts:
#	desktop/frontend-next/src/ui/WorkbenchPanel.tsx
2026-10-12 05:52:09 -07:00
yhh ee85dd3481 Merge remote-tracking branch 'origin/studio' into takeover/12804 2026-10-12 05:44:22 -07:00
YHH 75910feefc Merge pull request #12803 from esengine/takeover/12328
feat(studio): show the current plan beside conversation navigation
2026-10-12 05:43:35 -07:00
Will 4f548643b0 fix(memory): keep the index and the archive consistent when a publish fails
Archive moved the active file into .archive and left the index flush to its
caller, so a failed flush kept the fact archived while MEMORY.md still named it,
with no way for a retry by stable id to tell which state the store was in.

The move is now undone when the flush fails, and a retry by stable id finds the
archived copy and drops the stale line. The test seam this needs is the one the
rename fix already added to the mainline.
2026-10-12 20:43:23 +08:00
YHH 113a01e1dc Merge pull request #12813 from esengine/fix/phone-quiet-keeps-branch-chip
fix(studio): keep the branch chip when the phone composer is quiet
2026-10-12 05:42:59 -07:00
yhh 9a26301d83 Merge commit 'refs/pr12804' into takeover/12804 2026-10-12 05:41:29 -07:00
XTLine 2e2b580bd8 merge(studio): retain aliases with browser and session controls 2026-10-12 20:41:01 +08:00
Will fa818864ee fix(memory): keep a failed archived-memory recovery retryable
RestoreArchived wrote the active file and reindexed it before removing the
archived copy, so a failure in either later step left the same id active and
archived at once, and the identity guard refused every retry.

Both steps now undo the restored file, so a failed recovery never leaves a double
identity and the same call succeeds once the obstruction is gone. If the undo's
own index write fails, a line naming the fact can remain; the retry rewrites it.
2026-10-12 20:39:50 +08:00
yhh c67fc94eb9 fix(studio): keep the branch chip when the phone composer is quiet
The quiet phone composer hid the branch pop along with the refine entry,
so the no-repository chip, which explains why git features are missing,
was unreachable at 390px and the branch-tooltip browser guard failed on
studio. Only the refine entry is hidden now; the composer guard asserts
the branch pop stays.
2026-10-12 05:35:31 -07:00
yhh 7e71b2dce4 fix(studio): translate the plan peek goal label
The English catalogue test fails on the untranslated PaneNav key.
2026-10-12 05:33:55 -07:00
YHH 2140e55491 Merge pull request #12809 from esengine/chore/perf-harness-styles
chore(perf): load the same stylesheets as the app
2026-10-12 05:27:55 -07:00
Guan09 7c93b587bf feat(studio): add persistent event-driven rewind notice 2026-10-12 20:26:26 +08:00
YHH 53a9bb6dc1 Merge pull request #12807 from esengine/takeover/12347
feat(studio): select local sessions for batch actions
2026-10-12 05:12:21 -07:00
yhh 0fbcd55ebd chore(perf): load the same stylesheets as the app
The perf page imported fewer stylesheets than main.tsx, so controls such
as the changed-files pill and the session row actions rendered unstyled
in anything captured from it.
2026-10-12 05:09:39 -07:00
XTLine fa278aedd5 test(studio): guard copied references and workspace changes 2026-10-12 20:09:13 +08:00
XTLine 567816d7d3 test(studio): cover file copy menu behavior 2026-10-12 20:09:04 +08:00
XTLine cfa255cc70 feat(studio): add file context copy actions 2026-10-12 20:08:36 +08:00
XTLine 459bc7f4f7 fix(studio): preserve activity duration while zooming 2026-10-12 20:06:11 +08:00
yhh be06337aa0 fix(studio): select the rows the paged sidebar actually shows
The sidebar no longer slices each project to a fixed 30 rows: it asks the
kernel for pages and draws every loaded row. Batch selection now takes
all displayed rows, the selection pruning moves into a hook beside the
toolbar so Workspaces.tsx stays under its ceiling, and the two tests that
assumed the old 30-row cut assert the loaded rows instead.
2026-10-12 05:04:28 -07:00
YHH d21d91f6b3 Merge pull request #12806 from esengine/takeover/12067
feat(studio): close browser pages individually or in bulk
2026-10-12 05:02:54 -07:00
yhh 6766cc0042 Merge commit 'refs/pr12347' into takeover/12347
# Conflicts:
#	desktop/frontend-next/src/actions.ts
#	desktop/frontend-next/src/ui/Workspaces.tsx
2026-10-12 04:59:51 -07:00
XTLine abbd487b95 feat(studio): edit and show model display aliases 2026-10-12 19:51:17 +08:00
XTLine 62ddce6804 test(studio): guard alias identity and persistence boundaries 2026-10-12 19:51:04 +08:00
XTLine f9e3f3a5e5 feat(serve): expose scoped model aliases in provider editing 2026-10-12 19:50:52 +08:00
XTLine a3518cc60a feat(config): persist exact per-model display aliases 2026-10-12 19:50:18 +08:00
XTLine b92becac71 fix(studio): make crowded timeline signals inspectable 2026-10-12 19:45:58 +08:00
yhh 91ceafc038 Merge commit 'refs/pr12067' into takeover/12067 2026-10-12 04:42:31 -07:00
yhh 8cb7d82c01 fix(studio): carry the plan peek rules in their own stylesheet
The rules pushed studio.css over its css-size budget, so they move to
planpeek.css, imported after studio.css.
2026-10-12 04:36:49 -07:00
YHH 1682ab7fad Merge pull request #12802 from esengine/takeover/12310
feat(studio): fold feedback conversations and preserve unread state
2026-10-12 04:36:37 -07:00
yhh a4f1729c22 Merge commit 'refs/pr12328' into takeover/12328 2026-10-12 04:34:00 -07:00
yhh 0ea0c0e0fc fix(studio): open feedback conversations that wait on the reporter
A folded thread hid the maintainer's question while the status said
the report needed an answer. A report with needsInput now starts open
and is acknowledged as seen like any opened thread. The existing
entry-badge test opens the threads it expects to be read, and the
needs-input test no longer clicks a thread that is already open.
2026-10-12 04:29:05 -07:00
YHH dfe972034d Merge pull request #12801 from esengine/takeover/12327
fix(studio): put the current model first in the composer picker
2026-10-12 04:24:16 -07:00
yhh d7b42cc072 Merge commit 'refs/pr12310' into takeover/12310 2026-10-12 04:23:49 -07:00
YHH 95822af03a Merge pull request #12800 from esengine/takeover/12331
fix(studio): compact the untouched phone composer
2026-10-12 04:15:49 -07:00
yhh a1e598fee9 Merge commit 'refs/pr12327' into takeover/12327
# Conflicts:
#	desktop/frontend-next/src/ui/modelmenu.ts
2026-10-12 04:12:34 -07:00
yhh 2ea1d8e01c fix(studio): carry the phone composer rules in their own stylesheet
The six rules move out of studio.css into phonecomposer.css, without
the two !important the budget does not have room for: the attach
button's display no longer forces itself, so the quiet-state hiding wins
by specificity. The quiet toolbar also stops wrapping its model group.
The composer guard clears the draft left by its earlier steps before the
phone section and the guard page imports the new stylesheet.
2026-10-12 04:07:10 -07:00
yhh 2b97f139d8 Merge commit 'refs/pr12331' into takeover/12331 2026-10-12 03:59:16 -07:00
YHH 9c371349a1 Merge pull request #12798 from esengine/fix/rail-toggle-in-settings
fix(studio): keep the sidebar toggle usable while a settings page is open
2026-10-12 03:51:55 -07:00
yhh 3478a1dc6e fix(studio): keep the sidebar toggle usable while a settings page is open
The toggle was disabled whenever the settings sheet was open, so after
pressing any icon in the rail the top-left button looked live and did
nothing. It now opens and closes the workspace column in settings too.
2026-10-12 03:40:37 -07:00
YHH 55d3ca45c1 Merge pull request #12785 from chuiwenwei/fix/memory-rename-active-identity
fix(memory): retire the old name before publishing a renamed fact
2026-10-12 03:35:45 -07:00
YHH fddc6512a8 Merge pull request #12793 from esengine/takeover/12466
fix(studio): refine default light theme hierarchy
2026-10-12 03:03:14 -07:00
yhh 5fb8925ca2 chore: keep the before/after screenshots out of the repository 2026-10-12 02:51:27 -07:00
yhh c8d2bac6fb Merge commit 'refs/pr12466' into takeover/12466 2026-10-12 02:51:26 -07:00
YHH f14e75f9b5 Merge pull request #12783 from esengine/fix/compaction-live-line
fix(studio): stop the transcript jumping while a conversation is being compacted
2026-10-12 02:51:06 -07:00
yhh 5b4f8f073a fix(studio): keep the live compaction progress line on one reserved line
The streaming summary line wrapped to a different height on every token
while the transcript was pinned to the bottom, so the card and everything
above it moved up and down for the length of the fold. The line is now
always drawn, one line high, truncated with an ellipsis.
2026-10-12 02:41:53 -07:00
YHH 7340ac3a68 Merge pull request #12782 from esengine/fix/tabs-blend
fix(studio): let the view tabs share the page's tone
2026-10-12 02:38:46 -07:00
yhh 22491271c0 fix(studio): let the view tabs share the page's tone
The conversation, run analysis and workbench tabs sat on their own lighter
band with a divider under it. The row now takes the page's tone and drops the
divider; with a wallpaper the existing wallpaper rule still gives it a stable
surface.
2026-10-12 02:28:11 -07:00
YHH 191abea93c Merge pull request #12778 from esengine/fix/browser-view-repaint
fix(electron): attach a parked browser page again when it is shown
2026-10-12 02:08:54 -07:00
YHH 2e46375e4f Merge pull request #12776 from esengine/takeover/12340
feat(studio): add project folders by dropping onto the sidebar
2026-10-12 02:03:13 -07:00
yhh 75a08f128c fix(electron): attach a parked browser page again when it is shown
Switching away from a built-in browser tab and back drew a white page until the
window was resized: a view parked offscreen and moved back with setBounds alone
keeps a widget that does not repaint. The shell already knows that attaching a
view again wakes its widget (it does so on minimize and restore); a page that
was parked now gets the same when it is shown.
2026-10-12 01:57:50 -07:00
esengine cbd9038b07 Merge studio into takeover/12340 2026-10-12 01:55:46 -07:00
YHH fe7c6c44e6 Merge pull request #12777 from esengine/takeover/12343
feat(studio): compact context from the ring and inspector
2026-10-12 01:54:05 -07:00
YHH 2874af8b0e Merge pull request #12775 from esengine/takeover/12318
feat(studio): offer quick archive and delete on session rows
2026-10-12 01:38:31 -07:00
esengine 6b9e50246a Merge branch 'pr12343' into verify/12343 2026-10-12 01:38:04 -07:00
esengine 091c1b2865 feat(studio): keep the folder-drop styles in their own stylesheet
#12340 added 18 lines to studio.css, which must not grow. The drop-hint rules
move to styles/sidebardrop.css, imported with the other feature sheets, and
the sidebar-drop fixture gains the Sidebar props that became required after the
branch point (navShown, paging) so the file typechecks again.
2026-10-12 01:32:44 -07:00
esengine bf0505e70e Merge branch 'pr12340' into takeover/12340 2026-10-12 01:30:21 -07:00
esengine 5851b802ad Merge studio into takeover/12318 2026-10-12 01:29:12 -07:00
YHH 970c91d138 Merge pull request #12774 from esengine/refactor/workspaces-extract
refactor(studio): draw the session menu in its own component
2026-10-12 01:22:10 -07:00
yhh 859ef4782c feat(studio): keep the quick-action styles in their own stylesheet
#12318 added 26 lines to studio.css, which must not grow. The quick-action
rules move to styles/sessionquick.css, imported with the other feature
sheets, and the paging test finds its filter chips inside the filter group so
the new archive and delete buttons, whose names also start with 归档, are not
mistaken for them.
2026-10-12 01:18:55 -07:00
verify 7f79763026 Merge branch 'pr12318' into takeover/12318 2026-10-12 01:15:47 -07:00
yhh 6d98474fdf refactor(studio): draw the session menu in its own component
Workspaces.tsx held the session row's menu inline, 63 lines of JSX that only
report which action was chosen. It is its own component now, with the same
markup and the same handlers, so the sidebar file has room for the quick
actions, folder drop and batch selection that are waiting to land in it.
2026-10-12 01:14:03 -07:00
YHH 8d1f46cb50 Merge pull request #12324 from KHG420/codex/studio-project-context-menu
feat(studio): open project actions from context menus
2026-10-12 01:10:58 -07:00
Will 58aef50d47 fix(memory): retire the old name before publishing a renamed fact
SaveWithOptions wrote the new active file, then removed the old one, then
repaired the index. A failure in either later step left one id readable as two
active facts or left MEMORY.md naming a file that no longer existed, and a retry
could not tell which state it was in.

The rename path now stages the new content, retires the old name out of the
active namespace, flushes the old index line, publishes the staged file, and
reindexes; every step before publication is undone from the retired copy. The
helper that removed the old file in place has no callers left and is deleted.
2026-10-12 16:04:52 +08:00
YHH f3287f7dfc Merge pull request #12772 from esengine/feat/write-lease-optimistic
feat(config): an optimistic write lease for sessions sharing a workspace
2026-10-12 00:46:21 -07:00
yhh 25c6d4c10d feat(config): an optimistic write lease for sessions sharing a workspace
Every writer that declared no extent (bash, MCP, a sub-agent given no
write_paths) held the whole workspace, so two sessions on one project queued
behind each other even when nothing they wrote overlapped. [agent] write_lease
= "optimistic" in the user file makes such a writer claim nothing; a writer
that declared paths, including paths that cannot be resolved, still excludes
other sessions. The mode is carried by each session's lease owner, so sessions
in one process keep their own, and a project file cannot change it.
2026-10-12 00:35:29 -07:00
YHH 00d7ece65a Merge pull request #12559 from XTLine/fix/studio-restore-undo
fix(studio): preserve restore undo across reload / 保留重载后的还原撤销入口
2026-10-12 00:33:10 -07:00
YHH fba020d9bb Merge pull request #12770 from esengine/feat/branch-from-reply
feat(studio): branch a conversation from any reply into its own session
2026-10-11 23:44:32 -07:00
yhh 4f4b43978b feat(studio): branch a conversation from any reply into its own session
Studio had no click entry for branching: /branch copied only the current tip and
swapped the pane onto it. A finished reply now carries a 'Branch into a new
conversation' button that copies the conversation up to the end of that reply's
turn into a new session, titled '<title> - branch' so it never reads as a second
copy of its source, and opens it while the original stays as it was.

Controller.BranchAt cuts at a live turn boundary and shares the file-and-meta
writing with Branch. POST /branch/at answers with the new path and refuses with
typed codes (running, unavailable, failed).
2026-10-11 23:36:04 -07:00
YHH e67f256d5b Merge pull request #12769 from esengine/feat/withdrawn-version-notice
feat(update): tell a user whose running version was withdrawn
2026-10-11 23:24:29 -07:00
yhh ee40a3b90e feat(update): tell a user whose running version was withdrawn
The catalog can now pull a published release, but a user already running it
saw an ordinary update. The version hub carries a withdrawn note for the
running version, mirrored on the desktop side, and the version panel leads
with it: which version, the publisher's reason, and where to move to.
2026-10-11 23:15:05 -07:00
YHH 0968731518 Merge pull request #12768 from esengine/feat/open-logs-menu
feat(studio): open the logs folder from the feedback form
2026-10-11 23:12:35 -07:00
yhh 3b131d66a4 feat(studio): open the logs folder from the feedback form
A report that needs a log (it will not start, the kernel keeps restarting) had
no way to say where the log is: Studio writes shell.log and host.log under the
user data folder but nothing opened it, and the Windows and Linux builds have
no menu bar to put an entry in. The feedback form now offers 'Open logs
folder' when the shell can open it; the shell names the folder, the page does
not.
2026-10-11 23:03:29 -07:00
YHH b71837c1e5 Merge pull request #12765 from xxoingr/test/goal-resume-notice-race
test(control): fix the resumed-goal notice before the goal can complete
2026-10-11 22:36:47 -07:00
YHH b88596a4fc Merge pull request #12763 from chuiwenwei/fix/e2ebench-analysis-failure-reporting
fix(e2ebench): restore before checking the base side and name what is missing
2026-10-11 22:34:49 -07:00
YHH 9290b3bc0d Merge pull request #12761 from xxoingr/fix/tui-esc-menu-cancels-turn
fix(tui): Esc in the completion menu also stops a running turn
2026-10-11 22:30:12 -07:00
YHH fb77904a9f Merge pull request #12762 from xxoingr/test/split-jump-resolve-real-ssh
test(remote): split jump resolution from the installed ssh
2026-10-11 22:29:46 -07:00
YHH 0522cd92e6 Merge pull request #12757 from chuiwenwei/feat/contextbench-batch-manifest
feat(bench): persist what a context-retrieval batch was beside its results
2026-10-11 22:22:13 -07:00
YHH 50e25025a0 Merge pull request #12563 from chuiwenwei/fix/e2ebench-tape-integrity
fix(e2ebench): record and report incomplete tape recordings
2026-10-11 22:21:06 -07:00
YHH 0c9d2455be Merge pull request #12767 from esengine/perf/rail-thin-marks
perf(studio): draw one conversation-rail mark per distinguishable position
2026-10-11 22:13:11 -07:00
YHH dc921776c3 Merge pull request #12682 from esengine/dependabot/go_modules/go-412d5d9764
build(deps): bump the go group with 2 updates
2026-10-11 22:10:50 -07:00
yhh 912659f0dd perf(studio): draw one conversation-rail mark per distinguishable position
A 2000-turn conversation drew 2000 buttons per rail, 0.27 px apart, and every
pointer move rewrote all of them. Marks closer than 2 px are not told apart by
eye or pointer, so only one of each cluster is drawn; the rest stay addressable
through their positions, so the pointer and the keys still land on every
message.
2026-10-11 22:06:15 -07:00
YHH 4ef274d02e Merge pull request #12766 from esengine/perf/noto-studio-slice
perf(studio): draw the first screen from a 1.9 MB core CJK face
2026-10-11 22:03:20 -07:00
yhh 79af107700 perf(studio): draw the first screen from a 1.9 MB core CJK face
The first screen fetched the 7.8 MB full Noto Studio font because every Chinese
character resolved to it. A core face with the common simplified-Chinese set,
Latin, punctuation and symbols now comes first, and the full font stays as a
later family limited to CJK ranges, so a character the core lacks still finds
its glyph there and only then pays for the download.
2026-10-11 21:55:36 -07:00
xxoingr c6a134cfa1 test(control): fix the resumed-goal notice before the goal can complete
TestGoalResumeCommandContinuesTheGoalAndSaysSo failed in the race job on
studio (run 38188087939) with "resuming the goal said nothing" in 0.00s,
while the turn ran and the goal completed. The test built the expected
notice from c.Goal() as it read each event, and completing a goal clears
its text; the buffered events channel lets the controller send the notice,
finish the turn and complete the goal before the test reads anything, so
the real notice was compared with one built from an empty goal.

The expected line is now built once, before /goal resume is submitted.
2026-10-12 12:46:20 +08:00
YHH 97b21b6ed7 Merge pull request #12759 from esengine/feat/withdraw-version
feat(update): withdraw a published Studio version from the catalog
2026-10-11 21:42:48 -07:00
xxoingr 8e9227ceff test(remote): split jump resolution from the installed ssh
TestResolveJumpHostsUsesConfiguredAliasesAndSSHConfig bound two things:
that ResolveJumpHosts reads ssh_config aliases and configured hosts, and
that this machine's real `ssh -G` answers. The second failed on Windows CI
again, twice on studio (runs 38179453323 and 38193303449), both at 15.0 s
with `ssh -G "bastion": exit status 1`: the product's own subprocess
timeout. The test's comment asked to split the two if it recurred rather
than shorten that timeout.

The resolution test now supplies the `ssh -G` answer through the source's
resolveOpenSSH seam, as the sshconfig tests already do, and keeps every
assertion. TestResolveJumpHostsThroughTheInstalledOpenSSH runs the same
alias through the real client, so a starved ssh fails under its own name.
2026-10-12 12:40:12 +08:00
Will 3b1a88db12 fix(e2ebench): restore before checking the base side and name what is missing
Reviewing #12562 left three points open in this code.

differentialPerTest checked the base side before registering the defer that
restores the source, so a failure from that check returned with the tree still on
base - the state the restore exists to prevent. The defer is now registered
before the check, and treeMatches is a variable so a test can inject the failure;
TestDifferentialPerTestRestoresWhenTheBaseCheckFails asserts the PR's content is
back after that failure, and deleting the reorder makes it fail.

The report printed one sentence for every analysis failure, claiming the pin and
mutation figures were both absent even when the pin table had already been
produced and only the mutation sweep failed. analysisFailureLine now says which
figures are missing, and a unit test pins both cases.

The comment above the restore helpers described what the code used to do; it now
states only the constraint it enforces.
2026-10-12 12:35:39 +08:00
YHH 900aa3aa3a Merge branch 'studio' into dependabot/go_modules/go-412d5d9764 2026-10-11 21:32:30 -07:00
YHH d18b248992 Merge pull request #12684 from esengine/dependabot/npm_and_yarn/desktop/electron/npm-c28431c45a
build(deps-dev): bump the npm group in /desktop/electron with 2 updates
2026-10-11 21:31:38 -07:00
YHH a6facc0adf Merge pull request #12758 from esengine/chore/update-acknowledgments
Update acknowledgments contributors / 更新致谢贡献者
2026-10-11 21:29:58 -07:00
xxoingr c9d1517846 fix(tui): Esc in the completion menu also stops a running turn
1.x closed an open slash or @ menu on Esc and, while a turn ran, cancelled
the turn as well. 2.x's menuKey consumed the key after closing the menu, so
a user who had started typing a follow-up during a run had to press Esc a
second time to stop it, while the thinking line said Esc stops it.

menuKey now cancels the running turn after closing the menu, except in vi
mode, where Esc never interrupts a stream. Idle, Esc on the menu still only
closes it and keeps the draft.
2026-10-12 12:27:58 +08:00
YHH 2e2c9248f5 Merge pull request #12760 from esengine/docs/sponsor-register-links
docs: add an inline registration link to each sponsor's paragraph
2026-10-11 21:21:30 -07:00
yhh 36d69bf9e1 docs: add an inline registration link to each sponsor's paragraph
The sponsor logos link out, but the paragraph beside them read as plain text,
so the entry point was easy to miss. Each paragraph now ends with a visible
link to the same registration address its logo uses.
2026-10-11 21:17:15 -07:00
yhh 8a4281abfb feat(update): withdraw a published Studio version from the catalog
A release found broken after publication could only be fixed by shipping a
newer one, and installs kept being offered it meanwhile. The catalog now
carries a withdrawn list: a withdrawn version leaves the list old clients
read, a new client never offers or installs it and names it when it is the
one running, and a rerun of its own publication cannot list it again.
scripts/withdraw-version.sh edits the catalog; a manual workflow runs it
against the Studio catalog and keeps the GitHub release off the latest slot.
2026-10-11 20:57:15 -07:00
SivanCola 268ccef0a8 docs: update acknowledgments contributors 2026-10-12 03:52:27 +00:00
Will 9eb14cc8cc fix(e2ebench): record and report incomplete tape recordings
A tape is recorded as three files per provider exchange, and every write
discarded its error. A recording that lost a file therefore looked recorded, and
replay could not tell the difference: with the recorded request missing,
requestDiff compared against nothing and reported a divergence, blaming the
harness for a tape that was never complete. Nothing in the run's own report
mentioned it either, so a run whose tape can never be replayed read as a success.

save now returns its error and writes request.json last, so that file's presence
marks an exchange complete; load reports a missing recorded request as an
incomplete tape instead of a divergence; and the meter counts failed writes and
names the first error, which the report prints.

Two review findings in the same boundary are fixed here too. The run path read
the meter through record, which snapshotted before the final handler's deferred
save could run; stopping the meter is what waits for those handlers, so record
now drains first and the last exchange cannot be missing from the report. And a
re-recording over an existing tape left that tape's completeness markers in
place, so a failure partway through read as an old request beside a new partial
response; recording now clears those markers before it starts.

The healthy path is pinned as well, so the failure cases are not the only ones
covered: a complete recording reports nothing incomplete and prints no line.
Deleting any of the four checks reproduces the old behaviour.
2026-10-12 11:45:41 +08:00
YHH 59b2b75e62 Merge pull request #12753 from esengine/fix/rail-icons-match-settings
fix(studio): draw each rail entry with the mark of the settings section it opens
2026-10-11 20:43:56 -07:00
Will 1724a154f8 feat(bench): persist what a context-retrieval batch was beside its results
A batch printed its own provenance to the terminal and kept the per-run records
on disk, so the two could not be read together afterwards. The fingerprint
(model, endpoint, window, generations, commit, start time) existed only in
scrollback, and every aggregate the report is read for — funnels, dose response,
pooled deltas — was printed rather than stored. Reconstructing a batch after the
fact, or attributing a figure in the README to the runs that produced it, meant
trusting a terminal transcript; the 24-run and 36-run totals in the last batch
are exactly that failure.

Each paying mode now writes batch.json beside results.jsonl: mode, start and
finish, the checkout's commit and dirty state, model, window, generations, the
task ids and arms the batch covered, planned against completed runs, the summed
usage of the runs that reported any, and the sha256 of both artifacts so a
manifest and the records it describes cannot drift apart.

Two things stay honest. Usage totals are absent when nothing reported any,
because an unmeasured cost must not read as a measured zero, while
usage_reported_runs is written even when it is zero, because that is a fact. And
a checkout git cannot describe is recorded as an unknown commit with no dirty
flag at all, rather than as a clean tree.
2026-10-12 11:38:25 +08:00
yhh 4c3eabc0ab fix(studio): draw each rail entry with the mark of the settings section it opens
The icon rail kept its own icon set, so the same destination showed one mark
on the rail and another on the page it opens. Entries that open a section now
read that section's mark from the settings table; only the conversation item
keeps its own.
2026-10-11 20:33:09 -07:00
YHH 4a0b3888cb Merge pull request #12734 from jf200s/fix/studio-win11-osversion
fix(feedback): report the Windows 11 product line in the environment version
2026-10-11 20:25:16 -07:00
YHH 6f29d3d960 Merge branch 'studio' into codex/12026-browser-close 2026-10-11 17:04:14 -07:00
YHH b7ee3207da Merge branch 'studio' into codex/studio-feedback-thread-folding 2026-10-11 17:04:00 -07:00
YHH 80552ff2ff Merge branch 'studio' into codex/studio-refine-keyboard-help 2026-10-11 17:03:47 -07:00
YHH f808837d43 Merge branch 'studio' into codex/studio-plan-peek 2026-10-11 17:03:34 -07:00
YHH f08973d82e Merge branch 'studio' into codex/studio-skill-source-groups 2026-10-11 17:03:20 -07:00
YHH b69d956bc3 Merge branch 'studio' into feat/tui-unviewed-mark 2026-10-11 15:28:25 -07:00
YHH 163828b644 Merge branch 'studio' into feat/phone-page-installable 2026-10-11 15:28:12 -07:00
YHH 953c592d74 Merge branch 'studio' into feat/remote-wsl-kind 2026-10-11 15:27:18 -07:00
XTLine a201497806 fix(studio): preserve restore through transcript updates
Merge Studio 0ac9826a9e while retaining Pane-owned undo and upstream work folding, running-tail handling and repeated-speaker grouping. Adapt three imported transcript fixtures to the existing undo ownership contract and cover folding, attachments and restore/undo together through the real Pane.

Ordinary upstream integration: 238 files, 7968 insertions and 704 deletions. Manual resolution and regression delta: 41 insertions and 9 deletions. Both histories are preserved.
2026-10-12 02:06:39 +08:00
XTLine 92339c8580 merge(studio): align restore branch with CI fixes
Import Studio 71650fe7, including upstream desktop lint fix 85460ae and test-only sandbox portability fix 2b5f6448. Keep the running-jobs fixture deterministic by selecting its scripted provider explicitly and asserting the background launch.
2026-10-11 23:07:03 +08:00
F 1ca349830d fix(feedback): report the Windows 11 product line in the environment version
Windows 11 kept the kernel's 10.0 major, so a build like 10.0.26200 read
as Windows 10 (issue #12696). Builds from 22000 report as 11.0.build,
matching the product version the darwin path already reports.
2026-10-11 22:06:05 +08:00
XTLine beb1b0082e Merge studio into fix/studio-restore-undo
Preserve pane-owned restore undo with canonical message attachments. Add a regression for attachment previews through restore and undo.
2026-10-11 21:44:54 +08:00
xxoingr a88eec08d1 test(installsource): cover hostile skill paths in discovery warnings end to end
The skill-shadowing warning after an install and the not-selected warning
after a root is registered now render their paths through hostLiteral
(60f37ee7c), checked by a unit test that feeds an unterminated OSC to the
two sentence helpers. #12547 also asked for the newline, bidi and
zero-width cases.

Two end-to-end tests add them: a skill is installed, or a root
registered, while another skill under a configured path whose name holds
the character keeps the selection, and the warning must show the
character escaped for every path rather than carry it raw. The newline
case is skipped on Windows, which cannot hold one in a file name, and
runs on the Linux and macOS jobs.

Fixes #12547
2026-10-11 21:38:41 +08:00
yhh 76552fb5a4 build(desktop): tidy go.mod and go.sum for the root module's dependency bump 2026-10-11 05:22:05 -07:00
dependabot[bot] beeb2865f0 build(deps-dev): bump the npm group in /desktop/electron with 2 updates
Bumps the npm group in /desktop/electron with 2 updates: [electron](https://github.com/electron/electron) and [playwright](https://github.com/microsoft/playwright).


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

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

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-11 11:54:59 +00:00
dependabot[bot] ded403ab37 build(deps): bump the go group with 2 updates
Bumps the go group with 2 updates: [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) and [golang.org/x/sys](https://github.com/golang/sys).


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

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

---
updated-dependencies:
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: golang.org/x/sys
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-11 11:53:35 +00:00
Guan09 1d3267a44c fix(studio): make session tabs opt-in
Add a default-off Appearance preference for session tabs, persist the choice locally, and show the existing strip only when multiple session panes are open. Preserve single-session behavior and cover preference changes, persistence, and unread tab marks with interaction tests.
2026-10-11 17:00:56 +08:00
XTLine 95de31ed20 style(studio): keep restore actions visibly clickable at rest 2026-10-11 16:29:04 +08:00
par73e 9add93a143 fix(theme): make wallpaper transcript glow configurable 2026-10-11 12:59:54 +08:00
par73e 1c1c18bd39 feat(studio): add a shared send shortcut preference 2026-10-11 12:45:59 +08:00
yhh 46efe8eba4 feat(studio): web search model setting in model preferences 2026-10-10 08:36:00 -07:00
yhh 4ee29685ef feat(studio): let the phone page be added to the home screen
Add a web app manifest, icons and the touch-icon and theme-color tags to the
page so a phone can install it and open it full screen. No service worker is
registered, so nothing cached can outlive a pairing or a credential. The serve
host now types .webmanifest as application/manifest+json.
2026-10-10 08:23:17 -07:00
yhh 783524cfac feat(tui): mark sessions that finished unseen in the resume picker and clear the mark on view
The picker leads a row with an accent dot when the kernel reports it unread.
The TUI posts /sessions/viewed when it binds a conversation (launch, resume)
and when a turn ends in the one on screen while the terminal has focus; a turn
that ends behind another window is marked when focus returns.
2026-10-10 08:11:18 -07:00
yhh 248071332a feat(studio): wide mode for the browser pane that keeps the conversation visible 2026-10-10 07:56:47 -07:00
yhh 8b5b05a8e4 feat(wsl): discover WSL distributions and probe one before installing anything
First slice of treating a WSL distribution as a machine a kernel can run on.
Adds a read-only utility package that lists distributions from the structure of
wsl -l -v and checks one by its architecture and user, plus
reasonix remote wsl list/test. Nothing is installed or started.
2026-10-10 07:52:44 -07:00
F e976667e4f fix(session): key the turn's seal on identity and end turns from /status
A re-delivered turn_started naming the turn already in front of us is the
same open twice. The transport folds the duplicate seq, but the reducer is
fed raw sequences, and sealing on order marked its live calls "no result"
and dropped an unanswered approval. The seal now keys the start's msgIndex
against the turn the live side holds; naming still runs on the duplicate,
because the row may have been sent between the two deliveries.

The record cannot end a turn. The kernel commits each text-only assistant
round before handleFinalResponse decides to continue it — baseline
criteria, executor-handoff nudge, empty-final retry, steer drain — and
/history carries no end marker, so rebuild stopped inferring "over" from a
record ending on a say. The typed fact rides every status read: Pane's
applyStatus dispatches __running into the reducer, equal answers fold to
the same state, and a payload that cannot say leaves the wire's running
standing. The identity merge stays; the record no longer decides the
turn's life. A lost turn_done self-corrects at the next status answer.

The rebase folds studio's extension retention into rebuild(): an
extension is not in the record either, so the tail keeps it the way the
old __restore filter did.

The status-inflight fixture now answers running over an emitted
turn_started: the pane reads that fact, and a port contradicting its own
wire is not a kernel these reads can be reasoned against.
2026-10-10 21:03:47 +08:00
par73e 6646758aad feat(studio): close browser pages individually or in bulk 2026-10-10 12:59:17 +00:00
XTLine 8d5655f96d Merge studio into fix/studio-restore-undo
Preserve upstream committed-file counts at the Pane-owned restore receipt.
2026-10-10 20:58:14 +08:00
F afa0a33442 fix(session): reconcile the running tail with the record on rebuild
A __restore re-read taken mid-turn — by hand, or by the stream-gap
rebuild — dropped the cards being written, letting the next delta mint
the answer anew: new identity, the entrance replayed over half a reply,
the text before the gap gone until the message frame. The tail now
merges with the record by identity instead of riding after it: the
kernel commits each assistant round at its boundary, before its calls
run, so the record can already hold a call the live side holds open —
the record wins where it is complete, the live card where it is not.

A record ending on the committed answer, with no call of ours still
out, is a turn_done the wire lost: the answer wins and the turn is
over, clearing a running flag nothing else would clear.

A turn whose end never arrived is sealed the way turn_done would have
sealed it when the next one starts, so its calls stop spinning through
a turn that has nothing to do with them.

session.ts stays under the 800-line ceiling: the merge lives in
state/rebuild.ts.
2026-10-10 20:13:48 +08:00
XTLine 2ca8e60a01 style(studio): theme restore actions and bound error details 2026-10-10 17:06:53 +08:00
XTLine ac3673ff72 fix(studio): recover failed rewind presentation refreshes 2026-10-10 17:06:32 +08:00
XTLine 7613fd5d3e fix(studio): separate rewind mutation and receipt lifetimes 2026-10-10 17:06:18 +08:00
XTLine 172d61bba6 test(studio): cover restore reload and lifecycle boundaries 2026-10-10 14:53:14 +08:00
XTLine 0d85a11055 fix(studio): keep restore undo outside transcript cards 2026-10-10 14:52:40 +08:00
XTLine 29fb53efca fix(studio): own rewind receipts at conversation scope 2026-10-10 14:51:36 +08:00
1919833c48 feat(studio): route extracted tab menus through controller close
Problem: PR #12348's browser menu dismissed agent-owned UI entries instead
of closing the underlying pages, overlapping the controller close work in
PR #12067.

Root cause: The tab strip owned a presentation-only close path separate
from the controller's browser lifecycle.

Fix: Integrate the latest studio base with #12067, retain its useTabClose
owner, and adapt #12348's WorkbenchTabs extraction, close-to-the-right menu
and keyboard access to that path. Keep close-all, preserve file editors,
and retain failed pages while disabling repeated closes in flight.

Verification: 99 focused frontend tests, TypeScript/production and perf
builds, census and its 10 fixture comparisons, make check, go vet, make
lint, builtin/boot and browser/serve/control tests, and 11 release-note
tests passed. Browser keyboard/menu acceptance and eight fixture screenshots
cover light/dark and wide/narrow layouts. Intrinsic, contrast and side
guards passed; wheel exits successfully with its two existing advisory
findings. Native Electron acceptance and hosted CI remain outstanding.

Co-authored-by: KHG420 <[email protected]>
Co-authored-by: par73e <[email protected]>
2026-10-10 14:39:25 +08:00
xiexian 58ab25039e feat(studio): inspect subagent skill declarations 2026-10-10 11:33:12 +08:00
xiexian 36c3719cb4 feat(studio): show resolved plugin commits in install previews 2026-10-10 11:25:06 +08:00
f1shsmell fde3581c08 feat(theme): read the density and corner recipes a pack declares
A pack could declare recipes.density and recipes.corners, the schema
published both tiers and eight of the nine shipped packs carried them,
while nothing in the tree read either one. A pack asking for compact
density loaded clean, warned about nothing, and did nothing.

The reader keeps the two tiers now and reports a value outside them the
way it reports a token that fails its grammar, so an author sees the
problem in the picker rather than a pack that stays default in silence.

A recipe is a factor, not a size: the stylesheet owns what a radius or a
gutter measures, and a pack only says how far it steps. corners writes
--r-scale and density --d-scale, and each declaration that takes one keeps
its own value as the default, so a window with no pack on it is laid out
at exactly the numbers tokens.css writes.

Recipes is a hand-written wire mirror like the pack's other blocks, so it
joins the pairs declared in tools/repolint/wireparity.go: a field added on
one side alone then fails the gate at the line that cannot read it.
2026-10-10 00:26:02 +08:00
f1shsmell 9357a0ae36 feat(styles): give layout spacing an eight-step scale
Layout spacing carried 2364 distances across 48 distinct values, 1046 of them
on no scale at all, 10px 220 times and 6px 214 times. A pixel is not a level,
and those two are what a six-step scale could not hold: they are the values a
migration would have had to move 63% of the file's distances to reach. Eight
steps leave 44% off scale instead.

The tie rule is arithmetic rather than taste. 3, 5, 7, 9, 11, 14 and 20 each
sit exactly between two steps, and each sits above the step below it, so a tie
returns to that step and gives up only the air that was added by hand.

The scale is declared here; one surface draws from it. The activity group's
fold header takes --sp-6 for its inset and --sp-4 between the mark and its
label, and both were already those numbers: the two new steps renumbered the
ones below 12px, so the surface was renumbered with them and nothing moves.

TOKENS.md carries the scale, the step each role takes, and the tie table. Its
Space items were rewritten to fit the file's prose budget, which the first
draft of this change went one unit over.
2026-10-09 23:46:29 +08:00
xxoingr c21474751a feat(studio): render right-to-left text blocks in their own direction
A reply in Persian or Arabic was laid out left to right, and a sentence that
mixes in Latin words such as npm or a variable name came out jumbled, because
every block inherited the window's LTR base direction.

Markdown paragraphs, headings, lists, blockquotes and table cells now carry
dir="auto", so the browser takes each block's base direction from its own
first strong character: an RTL paragraph reads right to left and aligns to
the right beside English ones, and the bidi algorithm orders embedded Latin
runs correctly. Code blocks are left out and keep the document's direction.
The user's own message row and its edit box get the same treatment.

List indentation and the blockquote rule use logical properties
(padding-inline-start, border-inline-start), so they move to the right edge
for an RTL block and are unchanged for LTR.

Fixes #12114
2026-10-09 23:41:32 +08:00
yhh 987f44cafa test(serve): locate the installed plugin through its recorded root 2026-10-09 04:48:27 -07:00
yhh 0eb5a7ca2c test(plugins): cover the re-check after a blocked rename without a new exported seam 2026-10-09 04:38:49 -07:00
yhh c32b46c9fd fix(plugins): re-check the approved bytes before every publication attempt and keep the in-place swap off Windows 2026-10-09 04:28:55 -07:00
yhh 51f3100546 test(plugins): assert the planning-stage refusal of an unmaterializable symlink and an unchanged install after a refused replace 2026-10-09 03:45:57 -07:00
keeyang eb3f823174 feat(studio): open the project folder from the chrome folder mark
The folder mark beside the project name is decoration: it carries no click and
no meaning. A project can already be shown in the file manager, but showing it
reveals it in its parent folder; entering the folder itself needs a second
step.

This adds that step and wires it to the mark. The kernel answer and its refusal
are the ones reveal already asks for (/host/workspaces/locate), so the page
still names no location of its own and only the shell verb differs: openPath,
called on a folder the kernel itself named, instead of showItemInFolder. The
mark stays disabled when the shell cannot open a folder, or when no pane names
a workspace.
2026-10-09 17:06:49 +08:00
yhh 8aed0bc8ab Merge studio into feat/fix-11734-c 2026-10-09 01:53:14 -07:00
SivanCola 467707ce4b fix(studio): clarify default light theme surfaces and reading weight
Problem: Default light Studio uses nearly identical frame and reading surfaces,
medium-weight prose, and a broad composer shadow, making long answers feel dense.

Root cause: Reading panes share the page token, replies inherit the small-UI
weight, and the composer uses the generic light surface and shadow recipe.

Fix: Separate the default light reading and composer surfaces, use regular
weight for default reply prose, and shorten the composer shadow. Preserve
explicit weight choices, installed theme packs, wallpaper surface overrides,
dark styling, and layout. Consolidate the duplicate light palette and document
the tokens. Include mock-fixture before/after screenshots at both viewport sizes.

Verification: 68 focused frontend tests; TypeScript/Vite build; browser theme,
weight, wallpaper, draft-input, and geometry checks; gofmt; go vet ./...;
make lint; make check; go test ./internal/tools/builtin/ ./internal/assembly/boot/.
2026-10-09 15:02:36 +08:00
xiexian c69e53e528 fix(studio): distinguish settings save outcomes and allow retry 2026-10-08 20:14:26 +08:00
xiexian de06daf75a feat(studio): compact context from the ring and inspector 2026-10-08 17:06:23 +08:00
xiexian fd6b917c80 fix(studio): batch-check models before adding a source 2026-10-08 14:53:59 +08:00
xiexian dce96d1c5c fix(studio): keep prompt-refine help reachable and shortcut intact 2026-10-08 10:13:54 +08:00
xiexian 22ca8f3f17 feat(studio): select local sessions for batch actions 2026-10-08 09:12:57 +08:00
xiexian bb7d51e7a9 feat(studio): add project folders by dropping onto the sidebar 2026-10-08 07:45:55 +08:00
xiexian 8c644fa972 fix(studio): compact the untouched phone composer 2026-10-07 23:52:54 +08:00
xiexian 6545e66e36 fix(studio): group installed skills by source 2026-10-07 23:29:02 +08:00
xiexian 504101cb17 feat(studio): show current plan beside conversation navigation 2026-10-07 23:20:11 +08:00
xiexian 2a383830b6 fix(studio): put the current model first in the composer picker 2026-10-07 22:58:53 +08:00
xiexian 119dbd72ce feat(studio): open project actions from context menus 2026-10-07 22:32:30 +08:00
xiexian f11cd2226b fix(studio): align advanced appearance with settings cards 2026-10-07 22:09:54 +08:00
xiexian 2b23801457 feat(studio): offer quick archive and delete on session rows 2026-10-07 21:36:48 +08:00
xiexian 1e0043d817 fix(studio): make chart values discoverable along dense lines 2026-10-07 21:09:38 +08:00
xiexian 69a046fed3 feat(studio): quote selected reply text from the context menu 2026-10-07 20:54:23 +08:00
xiexian ec6ffb4887 feat(studio): fold feedback conversations and preserve unread replies 2026-10-07 20:36:51 +08:00
par73e f5dc84673a feat(studio): close browser pages individually or in bulk 2026-10-07 20:09:22 +08:00
xiexian c8abba049a fix(studio): keep failed extension forms editable 2026-10-06 22:18:13 +08:00
xiexian 645abda947 fix(studio): submit only current extension form fields 2026-10-06 21:51:43 +08:00
xiexian 4881f167dc feat(studio): identify installed package contribution kinds 2026-10-06 00:59:45 +08:00
xiexian bb760c6684 feat(studio): copy canonical skill invocations from Settings 2026-10-05 14:47:08 +08:00
xiexian c756ff26e7 feat(studio): find installed packages by contributed capabilities 2026-10-05 14:25:33 +08:00
esengine 78069f638a fix(plugins): re-verify the approved bytes at publication and keep non-Windows replacement semantics 2026-10-03 02:22:21 -07:00
esengine f54da89d15 fix(plugins): bind the published plugin bytes to the approved preview and keep the publication failure typed 2026-10-03 00:31:46 -07:00
esengine 0b76ca0f63 fix(plugins): publish a replaced plugin directory without renaming a watched tree on Windows 2026-10-02 21:58:24 -07:00
1561 changed files with 69791 additions and 10618 deletions

No files matched your search

+2 -1
View File
@@ -88,7 +88,7 @@ jobs:
with:
go-version-file: go.mod
cache: true
- run: node --test scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
- run: node --test scripts/linux-install-smoke.test.mjs scripts/studio-artifacts.test.mjs scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/withdraw-version.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
@@ -515,6 +515,7 @@ jobs:
run: |
go run github.com/rhysd/actionlint/cmd/[email protected] \
-ignore 'label "windows-11-arm" is unknown' \
-ignore 'label "ubuntu-22.04-arm" is unknown' \
.github/workflows/release-studio.yml \
.github/workflows/studio-certum-signing-smoke.yml \
.github/workflows/ci.yml
+37 -30
View File
@@ -175,6 +175,9 @@ jobs:
- platform: linux/amd64
runner: ubuntu-22.04
artifact: studio-build-linux-amd64
- platform: linux/arm64
runner: ubuntu-22.04-arm
artifact: studio-build-linux-arm64
steps:
# Real-time scanning inspects every file the job writes, and packaging
# writes a quarter of a gigabyte twice. The runner is thrown away after
@@ -216,7 +219,16 @@ jobs:
# not this shell's.
- name: Install Linux packaging deps
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm cpio xvfb xauth policykit-1
# The fpm electron-builder carries is an x86_64 program, so on an ARM64
# runner the .deb and .rpm are built with one installed from RubyGems.
- name: Use a native fpm on ARM64 Linux
if: runner.os == 'Linux' && runner.arch == 'ARM64'
run: |
sudo apt-get install -y ruby ruby-dev build-essential
sudo gem install --no-document fpm -v 1.16.0
echo "USE_SYSTEM_FPM=true" >> "$GITHUB_ENV"
# The same Developer ID and notary credentials the desktop line uses: one
# certificate signs any bundle identifier its team owns, so Studio does not
@@ -360,6 +372,23 @@ jobs:
pnpm exec electron-builder --prepackaged "../../$app" $arch \
-c.extraMetadata.version="${version#v}"
- name: Verify both Linux package formats
if: runner.os == 'Linux'
shell: bash
run: |
platform="${{ matrix.platform }}"
arch="${platform#linux/}"
bash scripts/verify-linux-package.sh "dist/ReasonixStudio-linux-$arch.deb" "dist/ReasonixStudio-linux-$arch.rpm" "$arch"
- name: Accept the installed Linux package
if: runner.os == 'Linux'
shell: bash
run: |
platform="${{ matrix.platform }}"
arch="${platform#linux/}"
version="${{ needs.resolve.outputs.version }}"
bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-$arch.deb" "$arch" "${version#v}"
# 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
@@ -986,6 +1015,11 @@ jobs:
name: studio-build-linux-amd64
path: staging/studio-build-linux-amd64
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-linux-arm64
path: staging/studio-build-linux-arm64
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
@@ -1068,34 +1102,7 @@ jobs:
done < <(find staging/studio-* -type f -name 'ReasonixStudio-*' -print0
find staging/cli-archives -type f -print0)
ls -la dist
# Two Windows artifacts (installer, portable zip) per architecture,
# two per macOS architecture (dmg, zip) and the Linux .deb and .rpm. Fewer
# means an artifact was lost between jobs, and a partial release is
# worse than a failed one.
count=$(find dist -type f -name 'ReasonixStudio-*' | wc -l)
if [ "$count" -lt 10 ]; then
echo "::error::expected 10 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb and .rpm), found $count"
exit 1
fi
# Each platform's update channel, and there is exactly one per
# platform: dpkg on Linux, the next installer on Windows, and the
# bundle swapped out of the archive on macOS. Missing one degrades the
# release silently instead of failing it — it strands that platform on
# the version it is running.
# Each macOS architecture by name, not darwin-*: a glob passes on
# whichever one shipped, and the missing one is a platform left on
# the version it is running with nothing saying so.
# Each Windows architecture by name for the same reason: a glob passes
# on whichever installer shipped.
for required in 'ReasonixStudio-linux-*.deb' 'ReasonixStudio-linux-*.rpm' \
'ReasonixStudio-windows-amd64-installer.exe' 'ReasonixStudio-windows-arm64-installer.exe' \
'ReasonixStudio-windows-amd64.zip' 'ReasonixStudio-windows-arm64.zip' \
'ReasonixStudio-darwin-amd64.zip' 'ReasonixStudio-darwin-arm64.zip'; do
find dist -type f -name "$required" | grep -q . || {
echo "::error::no $required in the release; its packager did not run"
exit 1
}
done
bash scripts/verify-studio-artifacts.sh dist
# The same minisign key the desktop line uses, because the public key that
# verifies it is compiled into the client (update.PublicKey).
@@ -1183,7 +1190,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\`)。\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 的包。\n\n" "$windows"
printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
} >> "$notes"
assets=()
+68 -79
View File
@@ -136,9 +136,30 @@ jobs:
python3 -m http.server 4399 --directory dist-perf >/dev/null 2>&1 &
for _ in $(seq 30); do curl -sf http://localhost:4399/perf.html >/dev/null && break; sleep 1; done
node perf/intrinsic.mjs
node perf/firstscreen-fonts.mjs
node perf/contrast.mjs
node perf/stickyfold.mjs
node perf/crumb.mjs
node perf/hoverradius.mjs
node perf/modelrows.mjs
node perf/explorersize.mjs
node perf/rowhit.mjs
node perf/sessquick.mjs
node perf/sessionrows.mjs
node perf/wheel.mjs
node perf/branch-tooltip.mjs
node perf/branch-menu.mjs
node perf/image-preview.mjs
node perf/toast-anchor.mjs
node perf/addfolder-rest.mjs
node perf/scroll-jump.mjs
node perf/add-project-selects.mjs
node perf/setup-defer.mjs
node perf/search-toggle.mjs
node perf/narrow.mjs
node perf/settings-rows.mjs
node perf/narrow-steps.mjs
node perf/narrow-rail.mjs
node perf/selfcheck.mjs branch-tooltip
# A pull request with no platform-relevant change (scripts/ci-platform-scope.sh)
@@ -180,6 +201,31 @@ jobs:
if: env.RUN_STEPS == 'true'
run: go test ./...
- if: env.RUN_STEPS == 'true'
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
- name: CUA process and packaging contracts
if: env.RUN_STEPS == 'true'
timeout-minutes: 2
run: node --test --test-name-pattern=CUA electron/test/unit.mjs
- name: Install Linux native CUA fixture dependencies
if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb openbox dbus-x11 libgtk-3-dev at-spi2-core
- name: Native CUA application acceptance
if: env.RUN_STEPS == 'true'
timeout-minutes: 6
shell: bash
run: |
if [ "$RUNNER_OS" = Linux ]; then
dbus-run-session -- xvfb-run -a sh -c 'openbox >/tmp/reasonix-openbox.log 2>&1 & export NO_AT_BRIDGE=0 GTK_MODULES=gail:atk-bridge; node electron/test/cua-native.mjs'
else
node electron/test/cua-native.mjs
fi
# The host sits in the main module and nothing else builds it, so its
# dependency closure is checked here: it must stay CGO-free and
# platform-neutral or the Electron bundle cannot ship it. Cross-compiling
@@ -189,7 +235,9 @@ jobs:
working-directory: .
run: |
for os in windows linux darwin; do
CGO_ENABLED=0 GOOS=$os GOARCH=amd64 go build -o /dev/null ./cmd/reasonix-studio-host
for arch in amd64 arm64; do
CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -o /dev/null ./cmd/reasonix-studio-host
done
done
# The ARM64 leg queues on a scarcer runner pool, so a pull request runs it
@@ -219,7 +267,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/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
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
arm64=true
fi
fi
@@ -231,7 +279,7 @@ jobs:
if [ "$platform" = false ]; then
legs="$(jq -c 'map(select(.platform == "linux"))' <<<"$legs")"
elif [ "$arm64" = true ]; then
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"}]' <<<"$legs")"
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"},{"os":"ubuntu-22.04-arm","arch":"arm64","platform":"linux"}]' <<<"$legs")"
else
legs="$(jq -c . <<<"$legs")"
fi
@@ -281,7 +329,16 @@ jobs:
# to rpmbuild for the .rpm.
- name: Install Linux packaging deps
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm cpio xvfb xauth policykit-1
# The fpm electron-builder carries is an x86_64 program, so on an ARM64
# runner the .deb and .rpm are built with one installed from RubyGems.
- name: Use a native fpm on ARM64 Linux
if: runner.os == 'Linux' && runner.arch == 'ARM64'
run: |
sudo apt-get install -y ruby ruby-dev build-essential
sudo gem install --no-document fpm -v 1.16.0
echo "USE_SYSTEM_FPM=true" >> "$GITHUB_ENV"
- name: Build the SPA
working-directory: desktop/frontend-next
@@ -294,7 +351,8 @@ jobs:
working-directory: desktop/electron
env:
REASONIX_GOARCH: ${{ matrix.platform == 'windows' && matrix.arch || '' }}
run: pnpm install --frozen-lockfile && pnpm run build:host
REASONIX_VERSION: v0.0.0-ci
run: pnpm install --frozen-lockfile && pnpm test && pnpm run build:host
# The .deb's absolute-path payload. A no-op off Linux, so it is not
# guarded here: the one place it must not be forgotten is the one place
@@ -310,7 +368,7 @@ jobs:
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.platform == 'windows' && matrix.arch == 'arm64' && 'BCJ' || '' }}
run: pnpm run build:dist ${{ matrix.ebarch && format('--{0}', matrix.ebarch) || '' }}
run: pnpm run build:dist ${{ matrix.ebarch && format('--{0}', matrix.ebarch) || '' }} -c.extraMetadata.version=0.0.0-ci
# The release vocabulary is GOARCH. electron-builder's own is not, and the
# rename that reconciles them runs here rather than in the manifest.
@@ -445,82 +503,13 @@ jobs:
echo "page: ${page:-<missing>}"
test -n "$host" -a -n "$page"
# Read off the package rather than off the config that describes it. Both
# of these went wrong the same way once: electron-builder names a package
# after package.json unless told, and fpm records the owner it finds on
# disk. Either one leaves a Studio that cannot update itself on Linux and
# says nothing about it until a user asks.
- name: The .deb is Studio's own package and carries what updates it
- name: Linux packages carry their declared architecture and update payload
if: runner.os == 'Linux'
run: |
deb=$(ls dist/ReasonixStudio-linux-*.deb)
name=$(dpkg-deb -f "$deb" Package)
if [ "$name" != "reasonix-studio" ]; then
echo "::error::the package is $name; dpkg holds that beside the installed reasonix-studio instead of upgrading it, and update.StudioLine asks for it by name"
exit 1
fi
# The helper in the package accepts one package name, compiled in. It has
# to be the one the package carries, or every in-app update is refused
# as a different package after the download and signature succeed.
dpkg-deb --fsys-tarfile "$deb" | tar -xO ./usr/lib/reasonix-studio/reasonix-studio-update-helper > "$RUNNER_TEMP/update-helper"
chmod +x "$RUNNER_TEMP/update-helper"
accepts=$("$RUNNER_TEMP/update-helper" package)
if [ "$accepts" != "$name" ]; then
echo "::error::the update helper in the package accepts $accepts but the package is $name"
exit 1
fi
dpkg-deb -c "$deb" > contents.txt
# The path is the polkit action's exec.path annotation, so it is the
# only one authorization is granted for; root because polkit will not
# run a helper the invoking user could have written. Either spelling
# of root: dpkg-deb prints the numeric ids it cannot map, and which
# one it prints is a property of the machine reading the package
# rather than of the package.
if ! grep -qE '^-rwxr-xr-x (root/root|0/0) +[0-9]+ .* \./usr/lib/reasonix-studio/reasonix-studio-update-helper$' contents.txt; then
echo "::error::no root-owned executable update helper at the path the polkit action names"
grep reasonix-studio-update-helper contents.txt || echo "(not in the package at all)"
exit 1
fi
if ! grep -q './usr/share/polkit-1/actions/io.reasonix.studio.update.policy$' contents.txt; then
echo "::error::the polkit action is not in the package, so pkexec has nothing to authorize against"
exit 1
fi
run: bash scripts/verify-linux-package.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.deb" "dist/ReasonixStudio-linux-${{ matrix.arch }}.rpm" "${{ matrix.arch }}"
# The .rpm is read back for the same two properties as the .deb: it is the
# package update.StudioLine names, and the helper pkexec runs as root is
# owned by root and writable by no one else, or any local user could
# replace it.
- name: The .rpm is Studio's own package and carries what updates it
- name: The Linux package installs, runs, reinstalls and uninstalls
if: runner.os == 'Linux'
run: |
rpm=$(ls dist/ReasonixStudio-linux-*.rpm)
name=$(rpm -qp --qf '%{NAME}' "$rpm")
if [ "$name" != "reasonix-studio" ]; then
echo "::error::the package is $name; rpm holds that beside the installed reasonix-studio instead of upgrading it"
exit 1
fi
rpm -qp --qf '[%{FILEMODES:perms} %{FILEUSERNAME} %{FILEGROUPNAME} %{FILENAMES}\n]' "$rpm" > contents.txt
if ! grep -qxE -- '-rwxr-xr-x root root /usr/lib/reasonix-studio/reasonix-studio-update-helper' contents.txt; then
echo "::error::no root-owned, executable, not-writable update helper at the path the polkit action names"
grep reasonix-studio-update-helper contents.txt || echo "(not in the package at all)"
exit 1
fi
if ! grep -qE -- '^-r[w-][x-]r-[x-]r-[x-] root root /usr/share/polkit-1/actions/io.reasonix.studio.update.policy$' contents.txt; then
echo "::error::the polkit action is missing, or is not a root-owned file no one else can write"
grep polkit contents.txt || echo "(not in the package at all)"
exit 1
fi
# The Electron shell sets the package's glibc floor; a Go binary linked
# with cgo would raise it to the runner's own.
- name: The .deb's Go binaries are statically linked
if: runner.os == 'Linux'
run: |
deb=$(ls dist/ReasonixStudio-linux-*.deb)
dpkg-deb -x "$deb" "$RUNNER_TEMP/deb"
host=$(find "$RUNNER_TEMP/deb" -type f -path '*/resources/bin/reasonix-studio-host')
test -n "$host"
node desktop/electron/packaging/elf.js "$host" "$RUNNER_TEMP/deb/usr/lib/reasonix-studio/reasonix-studio-update-helper"
run: bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.deb" "${{ matrix.arch }}" 0.0.0-ci
# Recorded, not asserted: the real bundle layout, so later work argues
# from what electron-builder produced rather than from its documentation.
@@ -0,0 +1,84 @@
name: Withdraw a Studio version
# Pulls a published Studio release out of the update catalog (or puts it back),
# so installs stop being offered it without waiting for a new release. Only the
# catalog changes: the release's files stay where they are, and a withdrawn
# release can be restored with the same run.
on:
workflow_dispatch:
inputs:
version:
description: "Version to withdraw or restore, for example 2.36.0"
required: true
type: string
reason:
description: "Why it is withdrawn (shown to users on that version); ignored when restoring"
required: false
type: string
restore:
description: "Put a withdrawn version back instead of withdrawing it"
required: false
type: boolean
default: false
concurrency:
group: studio-catalog
cancel-in-progress: false
jobs:
withdraw:
name: ${{ inputs.restore && 'restore' || 'withdraw' }} ${{ inputs.version }}
runs-on: ubuntu-latest
timeout-minutes: 10
environment: studio-release
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: studio
persist-credentials: false
- name: Update the Studio catalog
env:
VERSION: ${{ inputs.version }}
REASON: ${{ inputs.reason }}
RESTORE: ${{ inputs.restore }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
if [ "$HAS_R2" != "true" ]; then
echo "::error::R2 credentials are not configured, so the catalog cannot be changed"
exit 1
fi
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
echo "::error::version must look like 2.36.0"
exit 1
fi
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set default.region auto
endpoint="https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
current=/tmp/studio-versions-current.json
next=/tmp/studio-versions-next.json
aws s3 cp "s3://${R2_BUCKET}/studio/versions.json" "$current" --endpoint-url "$endpoint"
if [ "$RESTORE" = "true" ]; then
bash scripts/withdraw-version.sh "$current" --restore "$VERSION" "$at" > "$next"
jq -e --arg v "$VERSION" '.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 1' "$next" >/dev/null
else
bash scripts/withdraw-version.sh "$current" "$VERSION" "$REASON" "$at" > "$next"
jq -e --arg v "$VERSION" '(.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 0) and (.withdrawn | map(select((.version | ltrimstr("v")) == $v)) | length == 1)' "$next" >/dev/null
fi
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
--endpoint-url "$endpoint" \
--content-type "application/json; charset=utf-8" \
--cache-control "public, max-age=300, stale-if-error=86400"
echo "Studio catalog updated for $VERSION (restore=$RESTORE)"
- name: Keep the GitHub release off the latest slot
if: ${{ !inputs.restore }}
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: gh release edit "studio-v${VERSION}" --prerelease --latest=false --repo "$GITHUB_REPOSITORY"
+5 -5
View File
@@ -59,7 +59,7 @@
## ❤️Sponsor
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code.</td></tr></table>
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support. Register via <a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code. Register via <a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr></table>
## What it is
@@ -151,7 +151,7 @@ pre-releases):
| --- | --- | --- |
| macOS | `.dmg` or `.zip` | Apple Silicon (`arm64`) / Intel (`amd64`) |
| Windows | Installer `-installer.exe` or portable `.zip` | x64 |
| Linux | `.deb` | x64 |
| Linux | `.deb` or `.rpm` | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
Every package ships with a `.minisig` signature, and the release carries
`SHA256SUMS`. Once installed, Studio updates itself in place.
@@ -278,9 +278,9 @@ A small list of folks whose work has shaped Reasonix the most — the current to
| --- | --- | --- | --- |
| [**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) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**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) |
<!-- reasonix-top-contributors:end -->
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
+5 -5
View File
@@ -57,7 +57,7 @@
## ❤️赞助商
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。</td></tr></table>
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。通过<a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。通过<a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr></table>
## 它是什么
@@ -139,7 +139,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
| --- | --- | --- |
| macOS | `.dmg` 或 `.zip` | Apple Silicon(`arm64`)/ Intel(`amd64`) |
| Windows | 安装器 `-installer.exe` 或便携 `.zip` | x64 |
| Linux | `.deb` | x64 |
| Linux | `.deb` 或 `.rpm` | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
每个安装包都附带 `.minisig` 签名,发布页另有 `SHA256SUMS`。安装后 Studio 会在
应用内自行更新。
@@ -260,9 +260,9 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
| --- | --- | --- | --- |
| [**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) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**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) |
<!-- reasonix-top-contributors:end -->
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
+145
View File
@@ -0,0 +1,145 @@
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"`
// 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 int
estimated := false
for _, m := range all {
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),
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)
}
@@ -0,0 +1,91 @@
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)
}
}
}
+3 -3
View File
@@ -54,9 +54,9 @@ func main() {
case "preflight":
os.Exit(runPreflight(tasks, root))
case "run-search":
os.Exit(runExperiment(experimentSearch, root, *dry, tasks))
os.Exit(runExperiment(*mode, experimentSearch, root, *dry, tasks))
case "run-boundary":
os.Exit(runBoundaries(root))
os.Exit(runBoundaries(*mode, root))
case "snippet":
os.Exit(runSnippetAudit(root, 100))
case "adversarial":
@@ -69,7 +69,7 @@ func main() {
// stays measurable on its own.
batch = efficiencyTasks()
}
os.Exit(runExperiment(experimentIndex, root, *dry, batch))
os.Exit(runExperiment(*mode, experimentIndex, root, *dry, batch))
default:
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
os.Exit(2)
+18 -6
View File
@@ -216,7 +216,10 @@ func isolateWorkspace() (func(), error) {
// 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(root string) int {
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)
@@ -243,6 +246,7 @@ func runBoundaries(root string) int {
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)
@@ -260,7 +264,7 @@ func runBoundaries(root string) int {
}
reportDeltas(cueSide, noCueSide)
fmt.Print(reportStopping(all))
return writeResults(root, 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.
@@ -302,7 +306,10 @@ func deltaBool(a, b bool) string {
// 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(experiment, root string, dry bool, tasks []contextTask) int {
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)
@@ -338,6 +345,7 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
// 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)
@@ -359,7 +367,7 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
if experiment == experimentIndex {
reportBoundaries(all)
}
return writeResults(root, all)
return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Dry: dry, Repo: repo}, all)
}
func cueExpectedVisible(t contextTask, scale string) bool {
@@ -414,7 +422,7 @@ func writeTrajectories(root string, all []contextMetrics) error {
return nil
}
func writeResults(root string, all []contextMetrics) int {
func writeResults(root string, info batchInfo, all []contextMetrics) int {
path := filepath.Join(root, "results.jsonl")
f, err := os.Create(path)
if err != nil {
@@ -432,7 +440,11 @@ func writeResults(root string, all []contextMetrics) int {
fmt.Fprintln(os.Stderr, "write trajectories:", err)
return 2
}
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl)\n", len(all), path)
if err := writeBatchManifest(root, info, all); err != nil {
fmt.Fprintln(os.Stderr, "write batch manifest:", err)
return 2
}
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl and batch.json)\n", len(all), path)
return 0
}
+87
View File
@@ -0,0 +1,87 @@
# Desktop surface evaluation
This protocol supplies the fixed-task trajectory comparison required by
[RFC #12584](https://github.com/esengine/DeepSeek-Reasonix/issues/12584).
There are no measured results yet. Component tests and scripted providers prove
delivery, validation and replay; they do not measure a model's choice of interface
or a person's effort.
## Paired runs
Run each task twice in fresh desktop sessions: ordinary text/ask interaction and
native surfaces. Use the same model, sampling settings, initial workspace,
permission mode and task prompt. Record commit, model, configuration and the
native-surface capability setting. Alternate condition order across repetitions.
Keep the hidden tool out of the provider prefix in both conditions; only its
availability through capability discovery differs.
The baseline may batch questions or accept a single text reply. Never force it to
ask one question per field. Do not tell either model how many turns to use or
which component to render. If the surface-enabled model chooses text, retain
that run in the surface condition. Interface choice is part of the experiment.
The operator knows the private values below but does not volunteer them before
the model requests the corresponding information. Supply all requested values
together, regardless of presentation. Allow inspection and local editing before
submission. Save the trajectory and an observer ledger locally; raw transcripts
and draft content must not enter the aggregate report.
## Fixed tasks
| Task | Initial prompt, identical in both conditions | Private operator input | Completion criterion |
| --- | --- | --- | --- |
| `release-brief` | Prepare a release brief. Ask me for the audience, release date and whether this is a staged rollout before writing it. | audience: existing desktop users; date: 2026-11-03; staged rollout: yes | Final brief contains all three values and adds no release commitment. |
| `storage-choice` | Help me choose storage for a small desktop app. Present reasonable choices, ask me to choose and ask whether offline operation is required. Then summarize the decision. | storage: SQLite; offline: yes | Final decision states SQLite and offline operation, with no installation or file changes. |
| `draft-revision` | Draft a two-line release announcement and let me revise it before returning the final version verbatim. | Replace with the exact string ` Desktop release ready.\nKeep existing settings.\n` (decode the escapes). | Final returned string has exactly the specified leading spaces and newlines. Formatting wrappers are excluded from the comparison. |
| `preview-decision` | Show a plain-text before/after preview changing retry count from 2 to 3. Ask whether to keep that change and whether to include a release note. Return a decision summary; do not change files. | keep change: yes; release note: no | Preview shows 2 to 3; final summary preserves both decisions; workspace is unchanged. |
Freeze this task set before collecting results. Record a new protocol version if
prompts, private inputs, completion criteria or metric definitions change.
Neither the model's final claim nor a successful input acknowledgment establishes
completion. The observer checks the resulting output against the criterion.
## Metric definitions (version 1)
The anchor is acknowledgment of the initial task prompt. The observation ends
when the criterion is verified, the operator abandons the task, or the run fails.
The initial prompt is excluded from subsequent-input counts because both
conditions start from the same request.
| Metric | Count and population | Exclusions |
| --- | --- | --- |
| `acknowledged_inputs` | Distinct operator submissions acknowledged after the anchor: chat replies, ask answers, surface answers and surface actions. Includes acknowledged input later withdrawn or superseded. | Initial task prompt, local draft edits, retries with the same acknowledged request identity, automatic dispatch, replay and observer grading. This counts input exchanges, not provider rounds. |
| `submission_attempts` | Explicit operator send attempts, including rejected submissions and manual retries. | Automatic transport retries, replay, local edits and model tool calls. |
| `withdrawals` | Successful operator withdrawals of admitted input. | Failed withdrawals, automatic cancellation and replay. |
| `completed` | Runs whose output meets the fixed criterion. Denominator: every started run in each condition. | None; failures and abandoned runs remain in the denominator. |
Record these in a content-free ledger: task id, repetition, condition, observer
event ordinal, event type, opaque request identity, acknowledgment status and
completion verdict. Record the source trajectory reference and observed start
and end anchors alongside it. Distinguish manual observation from host-recorded
events. Do not copy labels, prompts, private values, drafts or answer strings into
the ledger. Identities are local correlation handles, not account identifiers.
The current pane counters and rendered trajectory export cannot reconstruct all
acknowledgments, refusals and withdrawals. Do not infer them from `ask` calls,
tool-result counts, final prose, message wording or card mounts. Until structural
acknowledgment records cover every channel, an observer ledger is required.
## Report and decision
Report the number started, completed, failed and abandoned for each condition,
then paired input-count differences for repetitions where **both** conditions
meet the same criterion. List excluded pairs and their reason beside that
comparison. Also report submission attempts and withdrawals; fewer admitted
inputs with more failures is not automatically less effort.
Keep per-task results visible. Do not combine scripted fixtures with observed
model/operator runs or describe this small task set as an account-wide result.
Report model/configuration differences and missing evidence as limitations.
The RFC's benefit remains unproven until observed trajectories show fewer input
exchanges to the same verified outcome. These results alone do not justify P3;
adaptive layout or MCP Apps needs a measured problem in addition to this result.
The owner-requested map, comparison and dashboard workspaces extend the delivery
scope independently of this study. Their scripted acceptance fixtures verify
rendering and interaction only; they do not count as observed benefit evidence.
+5
View File
@@ -203,6 +203,11 @@ func answerLeakDetected(r result) bool {
}
func recordTaskTrajectory(r *result, path string, t task) {
if r.SFT != nil && r.Segments <= 1 {
if err := bindSFTTrajectory(path, r.SFT); err != nil {
r.Note = appendNote(r.Note, "SFT provenance: "+err.Error())
}
}
if summary, err := summarizeTrajectory(path); err == nil {
r.Trajectory = summary
}
+18 -5
View File
@@ -290,8 +290,8 @@ func renderDiff(r diffReport) string {
if r.runErr != nil {
fmt.Fprintf(&b, "\n<sub>agent run note: %v</sub>\n", r.runErr)
}
if r.analysisErr != nil {
fmt.Fprintf(&b, "\n**Analysis did not finish: %v.** Pin and mutation figures are absent, not zero.\n", r.analysisErr)
if line := analysisFailureLine(r); line != "" {
b.WriteString(line)
}
fmt.Fprintf(&b, "\n<sub>Pass = the agent added ≥1 test, the affected packages are green, AND ≥1 new test fails when the PR's source is reverted. \"By assertion\" pins are strong (they check changed behavior); \"by compile only\" pins just need a PR-added symbol — and since Go compiles per package, one compile-coupled test marks every test in its package that way. Mutation is the behavioral signal for additive PRs: each changed function's return is replaced with zero values and the new tests are re-run; \"caught\" means a test asserts that output, \"survived\" means it doesn't. Read the generated tests above to judge the rest.</sub>\n")
return b.String()
@@ -342,9 +342,6 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) (
_ = os.Remove(filepath.Join(repo, filepath.FromSlash(f)))
}
}
if err := treeMatches(repo, base, srcFiles); err != nil {
return nil, err
}
// Restore source on the way out; a tree left on `base` would mask the PR for
// later steps, so a failed restore is this analysis failing, not a note.
restored := false
@@ -359,6 +356,9 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) (
err = terr
}
}()
if err := treeMatches(repo, base, srcFiles); err != nil {
return nil, err
}
out = make([]pinResult, 0, len(refs))
for _, r := range refs {
@@ -681,3 +681,16 @@ func passFail(ok bool) string {
}
return "fail"
}
// analysisFailureLine says which figures a failed analysis leaves absent. A pin
// table that was already produced still stands, so claiming both are missing
// would be wrong whenever only the mutation sweep failed.
func analysisFailureLine(r diffReport) string {
if r.analysisErr == nil {
return ""
}
if len(r.pins) > 0 {
return fmt.Sprintf("\n**Analysis did not finish: %v.** The mutation figures are absent, not zero; the pin table above still stands.\n", r.analysisErr)
}
return fmt.Sprintf("\n**Analysis did not finish: %v.** Pin and mutation figures are absent, not zero.\n", r.analysisErr)
}
+6 -11
View File
@@ -17,7 +17,6 @@ import (
fileencoding "reasonix/internal/base/fileutil/encoding"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/config"
"reasonix/internal/session/control"
)
type task struct {
@@ -121,7 +120,8 @@ type sourceUsage struct {
type result struct {
task
runMetrics
Profile string `json:"profile"`
SFT *sftProvenance `json:"sft,omitempty"`
Profile string `json:"profile"`
// Arm is the ablation arm the harness requested, not the arm the child
// reported, so a run that died before writing metrics is still attributable.
Arm string `json:"arm"`
@@ -518,15 +518,10 @@ func runSuite(cfg suiteConfig, tasks []task) []result {
// then drops in verify.sh and runs it as the grader. The grader is added only
// after the run so the agent can't read the answer key.
func runTask(cfg suiteConfig, t task) result {
r := result{task: t, Profile: cfg.profile, CacheArm: cfg.cacheArm, Effort: cfg.effort, ReplyLanguage: cfg.replyLanguage}
r.Arm = cfg.arm.Arm()
r.Anchor = cfg.anchor
t.Prompt = anchorPrompt(cfg.anchor, t)
if cfg.forcePlanner {
// The host's own routing marker, so the two-model turn engages without
// putting an English sentence in front of every prompt.
t.Prompt = control.PlannerRouteMarker + "\n\n" + t.Prompt
r.PlanForced = true
t, r, err := prepareRunTask(cfg, t)
if err != nil {
r.Note = "SFT provenance: " + err.Error()
return r
}
// The per-leg file names are decided in runSegments; only the directory has
+14 -1
View File
@@ -53,6 +53,10 @@ type meterUsage struct {
DivergedAt int `json:"diverged_at,omitempty"`
Divergence string `json:"divergence,omitempty"`
TapeMissing int `json:"tape_missing,omitempty"`
// TapeIncomplete counts recorded exchanges that could not be written, so a
// recording that cannot be replayed is reported rather than assumed.
TapeIncomplete int `json:"tape_incomplete,omitempty"`
TapeError string `json:"tape_error,omitempty"`
}
// faultScript decides which requests fail. Absolute indices pin a failure to
@@ -220,7 +224,16 @@ func (m *meter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
src := io.Reader(resp.Body)
if m.tape != nil {
src = io.TeeReader(resp.Body, &captured)
defer func() { m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()) }()
defer func() {
if err := m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()); err != nil {
m.mu.Lock()
m.TapeIncomplete++
if m.TapeError == "" {
m.TapeError = err.Error()
}
m.mu.Unlock()
}
}()
}
if strings.Contains(resp.Header.Get("Content-Type"), "event-stream") {
m.pipeStream(w, src)
+4 -2
View File
@@ -47,12 +47,14 @@ func (rm runMeter) close() {
}
}
// record attaches what the proxy observed. It stays separate from close so the
// snapshot is taken while the run's numbers are still being assembled.
// record attaches what the proxy observed. It stops the meter first, because a
// handler's deferred write can land after the last response is read and stopping
// is what waits for those handlers; a snapshot taken before that misses them.
func (rm runMeter) record(r *result) {
if rm.m == nil {
return
}
rm.close()
observed := rm.m.snapshot()
r.Meter = &observed
}
+1
View File
@@ -202,6 +202,7 @@ func renderBody(results []result) string {
b.WriteString(renderAuxiliarySpend(s.bySource, s.currency))
b.WriteString(renderMeterAccounting(results))
b.WriteString(renderTapeReplay(results))
b.WriteString(renderTapeRecord(results))
b.WriteString(renderFaultRecovery(results))
b.WriteString(renderTimeAttribution(results))
b.WriteString(renderSolveProfiles(results))
+6 -4
View File
@@ -8,13 +8,15 @@ import (
"strings"
)
// Restoring the source is I/O like any other step, but a failure used to read as
// success: the analysis went on to report a verdict about a tree that was still
// mutated. These helpers make it loud, and are variables so tests can inject one.
// Restoring the source is I/O like any other step, and an analysis must not
// report a verdict about a tree it failed to restore. These helpers make that
// failure loud, and are variables so a test can inject one.
var (
writeFile = os.WriteFile
readFile = os.ReadFile
// treeMatches is a variable so a test can inject the mismatch a restore guards.
treeMatches = treeMatchesTree
)
// restoreSource writes the original bytes back and proves they are back.
@@ -34,7 +36,7 @@ func restoreSource(abs string, src []byte) error {
// treeMatches checks files against a revision. A checkout that exited zero is
// not evidence the tree moved; the tree is.
func treeMatches(repo, rev string, files []string) error {
func treeMatchesTree(repo, rev string, files []string) error {
args := append([]string{"-C", repo, "diff", "--exit-code", rev, "--"}, files...)
out, err := exec.Command("git", args...).CombinedOutput()
if err != nil {
+67
View File
@@ -192,3 +192,70 @@ func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
t.Fatal("a failed restore still produced pin results")
}
}
// The base side is checked before any pin runs, and that check can fail too: the
// tree must not be left on base just because the failure happened early. The
// defer that restores is registered before the check for exactly this path.
func TestDifferentialPerTestRestoresWhenTheBaseCheckFails(t *testing.T) {
repo := t.TempDir()
run := func(args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
run("init", "-q")
if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("base\n"), 0o644); err != nil {
t.Fatal(err)
}
run("add", "f.go")
run("commit", "-qm", "base")
base := strings.TrimSpace(gitOut(repo, "rev-parse", "HEAD"))
if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("head\n"), 0o644); err != nil {
t.Fatal(err)
}
run("commit", "-aqm", "head")
orig := treeMatches
treeMatches = func(r, rev string, files []string) error {
if rev == base {
return errors.New("injected: base side does not match")
}
return treeMatchesTree(r, rev, files)
}
t.Cleanup(func() { treeMatches = orig })
out, err := differentialPerTest(repo, base, []string{"f.go"}, nil)
if err == nil {
t.Fatalf("a failed base check reported success with %d pins", len(out))
}
got, rerr := os.ReadFile(filepath.Join(repo, "f.go"))
if rerr != nil {
t.Fatal(rerr)
}
if strings.TrimSpace(string(got)) != "head" {
t.Fatalf("the tree was left on base: f.go = %q, want the PR's content", strings.TrimSpace(string(got)))
}
}
// A pin table that was already produced still stands, so the failure line must
// not claim both figures are missing when only the mutation sweep failed.
func TestAnalysisFailureLineNamesWhatIsMissing(t *testing.T) {
only := analysisFailureLine(diffReport{analysisErr: errors.New("boom"), pins: []pinResult{{}}})
if !strings.Contains(only, "mutation figures are absent") || !strings.Contains(only, "pin table above still stands") {
t.Fatalf("with pins present the line reads %q", only)
}
if strings.Contains(only, "Pin and mutation figures are absent") {
t.Fatalf("with pins present the line still claims both are absent: %q", only)
}
both := analysisFailureLine(diffReport{analysisErr: errors.New("boom")})
if !strings.Contains(both, "Pin and mutation figures are absent") {
t.Fatalf("with no pins the line reads %q", both)
}
if analysisFailureLine(diffReport{}) != "" {
t.Fatal("a finished analysis produced a failure line")
}
}
+25
View File
@@ -0,0 +1,25 @@
package main
import "reasonix/internal/session/control"
func prepareRunTask(cfg suiteConfig, t task) (task, result, error) {
r := result{task: t, Profile: cfg.profile, CacheArm: cfg.cacheArm, Effort: cfg.effort, ReplyLanguage: cfg.replyLanguage}
r.Arm = cfg.arm.Arm()
r.Anchor = cfg.anchor
t.Prompt = anchorPrompt(cfg.anchor, t)
if cfg.forcePlanner {
// The host's own routing marker, so the two-model turn engages without
// putting an English sentence in front of every prompt.
t.Prompt = control.PlannerRouteMarker + "\n\n" + t.Prompt
r.PlanForced = true
}
if cfg.trajDir != "" {
source, err := newSFTProvenance(t)
if err != nil {
return t, r, err
}
r.SFT = source
}
return t, r, nil
}
+55 -38
View File
@@ -4,8 +4,11 @@ package main
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"slices"
@@ -44,8 +47,9 @@ type sftToolFunction struct {
// from trajectoryRecord because that one deliberately omits tool output: the
// analysis passes would otherwise hold every result of every run in memory.
type sftRecord struct {
RunHeader *sftRunHeader `json:"run_header"`
Event *struct {
BenchmarkRun *sftProvenance `json:"benchmark_run"`
RunHeader *sftRunHeader `json:"run_header"`
Event *struct {
Kind string `json:"kind"`
Text string `json:"text"`
Reasoning string `json:"reasoning"`
@@ -162,22 +166,28 @@ func convertSFT(records []sftRecord) []sftMessage {
}
func readSFTRecords(path string) ([]sftRecord, error) {
records, _, err := readSFTSnapshot(path)
return records, err
}
func readSFTSnapshot(path string) ([]sftRecord, string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
return nil, "", err
}
defer f.Close()
var out []sftRecord
sc := bufio.NewScanner(f)
h := sha256.New()
sc := bufio.NewScanner(io.TeeReader(f, h))
sc.Buffer(make([]byte, 0, 1<<20), 64<<20)
for sc.Scan() {
var rec sftRecord
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
continue
return nil, "", err
}
out = append(out, rec)
}
return out, sc.Err()
return out, hex.EncodeToString(h.Sum(nil)), sc.Err()
}
// verifyPrefix reports the header and how many of the run's rounds it provably
@@ -213,6 +223,10 @@ func buildSFTSample(id, prompt, path string) (*sftSample, string) {
if err != nil {
return nil, "unreadable: " + err.Error()
}
return buildSFTSampleRecords(id, prompt, records)
}
func buildSFTSampleRecords(id, prompt string, records []sftRecord) (*sftSample, string) {
hdr, covered, total := verifyPrefix(records)
if hdr == nil {
return nil, "no run header"
@@ -267,36 +281,27 @@ func runSFTMode(dir, suite, reportPath, out string) error {
return fmt.Errorf("no *.trajectory.jsonl files under %s", dir)
}
slices.Sort(paths)
prompts, err := sftPrompts(suite)
if err != nil {
return err
}
passed, err := sftVerdicts(reportPath)
inputs, err := sftInputs(paths, suite, reportPath)
if err != nil {
return err
}
f, err := os.Create(out)
f, err := os.CreateTemp(filepath.Dir(out), ".sft-export-*")
if err != nil {
return err
}
defer f.Close()
defer os.Remove(f.Name())
buf := bufio.NewWriter(f)
enc := json.NewEncoder(buf)
kept := 0
for _, p := range paths {
id := strings.TrimSuffix(filepath.Base(p), ".trajectory.jsonl")
switch {
case !passed[id]:
fmt.Fprintf(os.Stderr, "skip %s: the grader did not pass it\n", id)
continue
case prompts[id] == "":
fmt.Fprintf(os.Stderr, "skip %s: no task prompt under %s\n", id, suite)
continue
for input, err := range inputs {
if err != nil {
return err
}
sample, why := buildSFTSample(id, prompts[id], p)
sample, why := buildSFTSampleRecords(input.id, input.prompt, input.records)
if sample == nil {
fmt.Fprintf(os.Stderr, "skip %s: %s\n", id, why)
fmt.Fprintf(os.Stderr, "skip %s: %s\n", input.id, why)
continue
}
if err := enc.Encode(sample); err != nil {
@@ -307,23 +312,17 @@ func runSFTMode(dir, suite, reportPath, out string) error {
if err := buf.Flush(); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
if err := os.Rename(f.Name(), out); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "wrote %d of %d runs to %s\n", kept, len(paths), out)
return nil
}
func sftPrompts(suite string) (map[string]string, error) {
tasks, err := loadTasks(suite)
if err != nil {
return nil, err
}
out := make(map[string]string, len(tasks))
for _, t := range tasks {
out[t.ID] = t.Prompt
}
return out, nil
}
func sftVerdicts(path string) (map[string]bool, error) {
func sftVerdicts(path string) (map[string]map[string]result, error) {
if path == "" {
return nil, fmt.Errorf("-report is required: it names the run report whose grader verdicts decide what is kept")
}
@@ -335,9 +334,27 @@ func sftVerdicts(path string) (map[string]bool, error) {
if err := json.Unmarshal(raw, &results); err != nil {
return nil, err
}
out := make(map[string]bool, len(results))
out := make(map[string]map[string]result, len(results))
for _, r := range results {
out[r.ID] = r.Passed
if r.Skipped {
continue
}
if r.Segments > 1 {
return nil, fmt.Errorf("%s: SFT export does not support segmented runs", r.ID)
}
if r.SFT == nil || r.SFT.RunID == "" || r.SFT.TaskSHA256 == "" || r.SFT.TrajectorySHA256 == "" || r.SFT.Prompt == "" {
if !r.Passed {
continue
}
return nil, fmt.Errorf("%s: report lacks SFT provenance; regenerate it with trajectories", r.ID)
}
if out[r.ID] == nil {
out[r.ID] = make(map[string]result)
}
if _, exists := out[r.ID][r.SFT.TrajectorySHA256]; exists {
return nil, fmt.Errorf("%s: duplicate verdict for one trajectory", r.ID)
}
out[r.ID][r.SFT.TrajectorySHA256] = r
}
return out, nil
}
+194
View File
@@ -0,0 +1,194 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"iter"
"os"
"path/filepath"
)
type sftProvenance struct {
RunID string `json:"run_id"`
TaskSHA256 string `json:"task_sha256"`
Prompt string `json:"prompt"`
TrajectorySHA256 string `json:"trajectory_sha256,omitempty"`
}
func newSFTProvenance(t task) (*sftProvenance, error) {
digest, err := sftTaskDigest(t)
if err != nil {
return nil, err
}
return &sftProvenance{RunID: rand.Text(), TaskSHA256: digest, Prompt: t.Prompt}, nil
}
func sftTaskDigest(t task) (string, error) {
h := sha256.New()
enc := json.NewEncoder(h)
err := filepath.WalkDir(t.dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if path == t.dir {
return nil
}
rel, err := filepath.Rel(t.dir, path)
if err != nil {
return err
}
if rel == "solution" && d.IsDir() {
return filepath.SkipDir
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("task input is not a regular file: %s", path)
}
digest, err := sftFileDigest(path)
if err != nil {
return err
}
return enc.Encode(struct {
Path string
Executable uint32
Digest string
}{filepath.ToSlash(rel), uint32(info.Mode().Perm() & 0o111), digest})
})
if err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func sftFileDigest(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// The harness binds its inputs after the child closes the trajectory. Event
// bytes stay untouched; the unique run header makes repeated attempts distinct.
func bindSFTTrajectory(path string, source *sftProvenance) error {
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
out, err := os.CreateTemp(filepath.Dir(path), ".sft-trajectory-*")
if err != nil {
return err
}
defer os.Remove(out.Name())
defer out.Close()
header := *source
header.TrajectorySHA256 = ""
if err := json.NewEncoder(out).Encode(struct {
Source sftProvenance `json:"benchmark_run"`
}{header}); err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
return err
}
if err := in.Close(); err != nil {
return err
}
if err := out.Close(); err != nil {
return err
}
if err := os.Rename(out.Name(), path); err != nil {
return err
}
source.TrajectorySHA256, err = sftFileDigest(path)
return err
}
type sftInput struct {
id, prompt string
records []sftRecord
}
func sftInputs(paths []string, suite, report string) (iter.Seq2[sftInput, error], error) {
tasks, err := loadTasks(suite)
if err != nil {
return nil, err
}
byTask := make(map[string]task, len(tasks))
for _, t := range tasks {
byTask[t.ID] = t
}
verdicts, err := sftVerdicts(report)
if err != nil {
return nil, err
}
return func(yield func(sftInput, error) bool) {
for _, path := range paths {
input, err := sftInputFor(path, suite, byTask, verdicts)
if err != nil {
yield(sftInput{}, err)
return
}
if input != nil && !yield(*input, nil) {
return
}
}
}, nil
}
func sftInputFor(path, suite string, tasks map[string]task, verdicts map[string]map[string]result) (*sftInput, error) {
id := filepath.Base(path)
id = id[:len(id)-len(".trajectory.jsonl")]
records, digest, err := readSFTSnapshot(path)
if err != nil {
return nil, err
}
r, ok := verdicts[id][digest]
if !ok {
return nil, fmt.Errorf("%s: report does not identify this trajectory; regenerate the report and trajectories together", id)
}
t, ok := tasks[id]
if !ok {
return nil, fmt.Errorf("%s: task is absent from %s", id, suite)
}
taskDigest, err := sftTaskDigest(t)
if err != nil {
return nil, err
}
if taskDigest != r.SFT.TaskSHA256 {
return nil, fmt.Errorf("%s: task files differ from the graded run", id)
}
var source *sftProvenance
for _, rec := range records {
if rec.BenchmarkRun == nil {
continue
}
if source != nil {
return nil, fmt.Errorf("%s: multiple benchmark run headers", id)
}
source = rec.BenchmarkRun
}
if source == nil || source.RunID != r.SFT.RunID || source.TaskSHA256 != r.SFT.TaskSHA256 || source.Prompt != r.SFT.Prompt {
return nil, fmt.Errorf("%s: benchmark run header does not match the report", id)
}
if !r.Passed || r.Skipped {
return nil, nil
}
return &sftInput{id, source.Prompt, records}, nil
}
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
func TestSFTRejectsAMixedBatchBeforeReplacingTheOutput(t *testing.T) {
dir := testenv.TempDir(t)
writeTraj(t, dir, "fizzbuzz", trajHeader, `{"event":{"kind":"message","text":"failed attempt"}}`, trajUsage)
report := filepath.Join(dir, "report.json")
if err := os.WriteFile(report, []byte(`[{"ID":"fizzbuzz","Passed":true,"sft":{"trajectory_sha256":"another-run","task_sha256":"another-corpus","prompt":"another prompt"}}]`), 0o644); err != nil {
t.Fatal(err)
}
out := filepath.Join(dir, "sft.jsonl")
if err := os.WriteFile(out, []byte("existing export\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := runSFTMode(dir, corpusDir, report, out); err == nil {
t.Fatal("a verdict from another run labelled this failed trajectory as passed")
}
got, err := os.ReadFile(out)
if err != nil || string(got) != "existing export\n" {
t.Fatalf("refused export replaced the existing output: %q, %v", got, err)
}
}
func gradedSFTResult(t *testing.T, dir string, task task, passed bool) result {
t.Helper()
source, err := newSFTProvenance(task)
if err != nil {
t.Fatal(err)
}
if err := bindSFTTrajectory(filepath.Join(dir, task.ID+".trajectory.jsonl"), source); err != nil {
t.Fatal(err)
}
return result{task: task, Passed: passed, SFT: source}
}
func sftFixture(t *testing.T) (dir, suite, report string, rows []result) {
t.Helper()
dir = testenv.TempDir(t)
suite = filepath.Join(dir, "suite")
taskDir := filepath.Join(suite, "tasks", "demo")
if err := os.MkdirAll(filepath.Join(taskDir, "workdir"), 0o755); err != nil {
t.Fatal(err)
}
for name, body := range map[string]string{"task.toml": "prompt = 'original prompt'\n", "verify.sh": "exit 0\n", "workdir/seed.txt": "original seed"} {
if err := os.WriteFile(filepath.Join(taskDir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
tasks, err := loadTasks(suite)
if err != nil {
t.Fatal(err)
}
actual := tasks[0]
actual.Prompt = "planner directive\n\n" + actual.Prompt
writeTraj(t, dir, "demo", trajHeader, `{"event":{"kind":"message","text":"done"}}`, trajUsage)
rows = []result{gradedSFTResult(t, dir, actual, true)}
report = filepath.Join(dir, "report.json")
writeSFTReport(t, report, rows)
return
}
func writeSFTReport(t *testing.T, path string, rows []result) {
t.Helper()
raw, err := json.Marshal(rows)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, raw, 0o644); err != nil {
t.Fatal(err)
}
}
func TestSFTUsesTheRecordedPromptAndAValidatedSnapshot(t *testing.T) {
dir, suite, report, rows := sftFixture(t)
path := filepath.Join(dir, "demo.trajectory.jsonl")
inputs, err := sftInputs([]string{path}, suite, report)
if err != nil {
t.Fatal(err)
}
var captured sftInput
count := 0
for input, err := range inputs {
if err != nil {
t.Fatal(err)
}
captured = input
count++
}
if count != 1 {
t.Fatalf("got %d inputs", count)
}
if err := os.WriteFile(path, []byte("a later run replaced this file"), 0o600); err != nil {
t.Fatal(err)
}
sample, why := buildSFTSampleRecords(captured.id, captured.prompt, captured.records)
if sample == nil || sample.Messages[1].Content != rows[0].SFT.Prompt {
t.Fatalf("lost captured prompt or trajectory: %+v, %s", sample, why)
}
}
func TestSFTRejectsChangedTaskInputs(t *testing.T) {
for _, file := range []string{"task.toml", "verify.sh", "workdir/seed.txt"} {
t.Run(file, func(t *testing.T) {
dir, suite, report, _ := sftFixture(t)
if err := os.WriteFile(filepath.Join(suite, "tasks", "demo", file), []byte("prompt = 'changed'\n"), 0o644); err != nil {
t.Fatal(err)
}
err := runSFTMode(dir, suite, report, filepath.Join(dir, "out.jsonl"))
if err == nil || !strings.Contains(err.Error(), "task files differ") {
t.Fatalf("changed %s accepted: %v", file, err)
}
})
}
}
func TestSFTBindsRepeatedAttemptsToTheirOwnVerdicts(t *testing.T) {
dir, suite, report, rows := sftFixture(t)
writeTraj(t, dir, "demo", trajHeader, `{"event":{"kind":"message","text":"done"}}`, trajUsage)
failed := gradedSFTResult(t, dir, rows[0].task, false)
if failed.SFT.RunID == rows[0].SFT.RunID || failed.SFT.TrajectorySHA256 == rows[0].SFT.TrajectorySHA256 {
t.Fatal("identical event streams from separate attempts share an identity")
}
writeSFTReport(t, report, append(rows, failed))
out := filepath.Join(dir, "out.jsonl")
if err := runSFTMode(dir, suite, report, out); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(out)
if err != nil || len(data) != 0 {
t.Fatalf("the latest failed attempt borrowed a passing verdict: %q, %v", data, err)
}
}
func TestSFTRefusesLegacyAndDuplicateReports(t *testing.T) {
for _, kind := range []string{"legacy", "duplicate", "header"} {
t.Run(kind, func(t *testing.T) {
dir, suite, report, rows := sftFixture(t)
switch kind {
case "legacy":
rows[0].SFT = nil
case "duplicate":
rows = append(rows, rows[0])
case "header":
rows[0].SFT.RunID = "another-run"
}
writeSFTReport(t, report, rows)
if err := runSFTMode(dir, suite, report, filepath.Join(dir, "out.jsonl")); err == nil {
t.Fatal("unverifiable report accepted")
}
})
}
}
func TestRunTaskProducesProvenanceThatSFTCanValidate(t *testing.T) {
requireShellStub(t)
dir, suite, report, _ := sftFixture(t)
tasks, err := loadTasks(suite)
if err != nil {
t.Fatal(err)
}
bin := filepath.Join(dir, "agent")
script := "#!/usr/bin/env bash\nwhile [ $# -gt 0 ]; do\ncase \"$1\" in\n" +
"--trajectory) printf '%s\\n' '" + trajHeader + "' '" + trajUsage + "' '{\"event\":{\"kind\":\"message\",\"text\":\"done\"}}' > \"$2\"; shift 2;;\n" +
"--metrics) printf '%s\\n' '{\"steps\":1,\"complete\":true}' > \"$2\"; shift 2;;\n*) shift;;\nesac\ndone\n"
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
r := runTask(suiteConfig{bin: bin, trajDir: dir, forcePlanner: true}, tasks[0])
if !r.Passed || r.SFT == nil || r.SFT.TrajectorySHA256 == "" || r.Trajectory == nil {
t.Fatalf("runner lost provenance, grader, or digest: %+v", r)
}
writeSFTReport(t, report, []result{r})
out := filepath.Join(dir, "out.jsonl")
if err := runSFTMode(dir, suite, report, out); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
var sample sftSample
if err := json.Unmarshal(raw, &sample); err != nil {
t.Fatal(err)
}
if sample.Messages[1].Content != r.SFT.Prompt || !r.PlanForced || sample.Messages[1].Content == tasks[0].Prompt {
t.Fatal("export did not use the prompt actually passed to the child")
}
}
+11 -4
View File
@@ -122,10 +122,17 @@ func TestRunSFTModeKeepsOnlyGradedPasses(t *testing.T) {
writeTraj(t, traj, "palindrome", body...)
report := filepath.Join(dir, "r.json")
raw, _ := json.Marshal([]result{
{task: task{ID: "fizzbuzz"}, Passed: true},
{task: task{ID: "palindrome"}, Passed: false},
})
tasks, err := loadTasks(corpusDir)
if err != nil {
t.Fatal(err)
}
var results []result
for _, task := range tasks {
if task.ID == "fizzbuzz" || task.ID == "palindrome" {
results = append(results, gradedSFTResult(t, traj, task, task.ID == "fizzbuzz"))
}
}
raw, _ := json.Marshal(results)
if err := os.WriteFile(report, raw, 0o644); err != nil {
t.Fatal(err)
}
+54 -6
View File
@@ -107,6 +107,18 @@ func (c tapeConfig) forRun(taskID string, trial int) (*tape, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
// This run replaces the tape. Drop the old completeness markers first, or
// a failure here would leave the previous request beside this run's
// partial response and read as a complete exchange.
stale, err := filepath.Glob(filepath.Join(dir, "*.request.json"))
if err != nil {
return nil, err
}
for _, path := range stale {
if err := os.Remove(path); err != nil {
return nil, err
}
}
} else if _, err := os.Stat(dir); err != nil {
return nil, fmt.Errorf("no tape for %s: %w", taskID, err)
}
@@ -122,11 +134,26 @@ func (t *tape) file(index int, suffix string) string {
return filepath.Join(t.dir, fmt.Sprintf("%04d.%s", index, suffix))
}
func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) {
meta, _ := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
_ = os.WriteFile(t.file(index, "request.json"), request, 0o644)
_ = os.WriteFile(t.file(index, "response.json"), meta, 0o644)
_ = os.WriteFile(t.file(index, "response.body"), body, 0o644)
// tapeWriteFile is a variable so a test can inject a write failure.
var tapeWriteFile = os.WriteFile
// save records one exchange. request.json goes last: its presence is what tells
// a later replay the exchange is complete rather than truncated.
func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) error {
meta, err := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
if err != nil {
return fmt.Errorf("tape %d response meta: %w", index, err)
}
if err := tapeWriteFile(t.file(index, "response.json"), meta, 0o644); err != nil {
return fmt.Errorf("tape %d response.json: %w", index, err)
}
if err := tapeWriteFile(t.file(index, "response.body"), body, 0o644); err != nil {
return fmt.Errorf("tape %d response.body: %w", index, err)
}
if err := tapeWriteFile(t.file(index, "request.json"), request, 0o644); err != nil {
return fmt.Errorf("tape %d request.json: %w", index, err)
}
return nil
}
// load returns the recorded answer to request index, and where the request
@@ -142,7 +169,12 @@ func (t *tape) load(index int, request []byte) (resp tapeResponse, body []byte,
if body, err = os.ReadFile(t.file(index, "response.body")); err != nil {
return resp, nil, "", err
}
recorded, _ := os.ReadFile(t.file(index, "request.json"))
recorded, err := os.ReadFile(t.file(index, "request.json"))
if err != nil {
// Not a divergence: without the recorded request there is nothing to
// compare against, and reporting one would blame the harness.
return resp, nil, "", fmt.Errorf("tape %d is incomplete: %w", index, err)
}
return resp, body, requestDiff(recorded, request), nil
}
@@ -249,3 +281,19 @@ func renderTapeReplay(results []result) string {
}
return line + "\n\n"
}
// renderTapeRecord reports recordings that did not finish, so a run whose tape
// cannot be replayed says so instead of looking recorded.
func renderTapeRecord(results []result) string {
var incomplete []string
for _, r := range results {
if r.Meter == nil || r.Meter.TapeIncomplete == 0 {
continue
}
incomplete = append(incomplete, fmt.Sprintf("`%s` (%d exchanges): %s", r.ID, r.Meter.TapeIncomplete, r.Meter.TapeError))
}
if len(incomplete) == 0 {
return ""
}
return "**Tape record incomplete**: " + strings.Join(incomplete, " · ") + "\n\n"
}
+180
View File
@@ -1,11 +1,15 @@
package main
import (
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
const tapedStream = "data: {\"choices\":[{\"delta\":{\"content\":\"fixed\"}}]}\n\n" +
@@ -103,3 +107,179 @@ func TestRequestDiffReadsStructureBeforeBytes(t *testing.T) {
}
}
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
// A recording whose request file cannot be written is reported rather than
// treated as a tape: request.json is what marks the exchange complete.
func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
root := t.TempDir()
orig := tapeWriteFile
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
if strings.HasSuffix(path, "request.json") {
return errors.New("disk full")
}
return orig(path, body, mode)
}
t.Cleanup(func() { tapeWriteFile = orig })
var calls atomic.Int32
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
defer stop()
readAll(t, post(t, base, "/chat/completions", tapedRequest))
var got meterUsage
waitFor(t, "the failed write to be reported", func() bool {
got = m.snapshot()
return got.TapeIncomplete > 0
})
if !strings.Contains(got.TapeError, "disk full") {
t.Fatalf("incomplete recording = %+v, want the write error named", got)
}
if markers, _ := filepath.Glob(filepath.Join(root, "bugfix-slug", "trial-1", "*.request.json")); len(markers) != 0 {
t.Fatalf("the completeness marker was written although the request write failed: %v", markers)
}
line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: &got}})
if !strings.Contains(line, "Tape record incomplete") || !strings.Contains(line, "bugfix-slug") {
t.Fatalf("report line = %q", line)
}
if renderTapeRecord(nil) != "" {
t.Fatal("an empty run set produced a tape line")
}
}
// A tape with no recorded request is incomplete, not a divergence: there is
// nothing to compare against, and blaming the harness would be wrong.
func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
root := t.TempDir()
var calls atomic.Int32
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
readAll(t, post(t, base, "/chat/completions", tapedRequest))
dir := filepath.Join(root, "bugfix-slug", "trial-1")
var markers []string
waitFor(t, "the recorded request", func() bool {
markers, _ = filepath.Glob(filepath.Join(dir, "*.request.json"))
return len(markers) > 0
})
stop()
if err := os.Remove(markers[0]); err != nil {
t.Fatal(err)
}
m, base, stop := tapedMeter(t, tapeReplay, root, &calls)
defer stop()
resp := post(t, base, "/chat/completions", tapedRequest)
readAll(t, resp)
got := m.snapshot()
if resp.StatusCode != http.StatusBadGateway || got.TapeMissing != 1 {
t.Fatalf("incomplete tape = %d, missing %d; want 502 and counted", resp.StatusCode, got.TapeMissing)
}
if got.DivergedAt != 0 || got.Divergence != "" {
t.Fatalf("an incomplete tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
}
}
// The run path snapshots the meter through record, which must stop the meter
// first: stopping is what waits for the handlers whose deferred writes carry the
// tape counters. Snapshotting before that misses the last exchange.
func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
root := t.TempDir()
var calls atomic.Int32
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
defer stop()
readAll(t, post(t, base, "/chat/completions", tapedRequest))
var r result
runMeter{m: m, stop: stop}.record(&r)
// No waitFor: the drain inside record is what makes this observable.
if r.Meter == nil {
t.Fatal("record attached no meter")
}
if resp, err := http.Post(base+"/chat/completions", "application/json", strings.NewReader(tapedRequest)); err == nil {
resp.Body.Close()
t.Fatal("the meter was still serving after record, so its handlers were not drained")
}
}
// Re-recording over an existing tape must not let the previous run's
// completeness marker make a partial new exchange look complete.
func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
root := t.TempDir()
var calls atomic.Int32
_, base, stop := tapedMeter(t, tapeRecord, root, &calls)
readAll(t, post(t, base, "/chat/completions", tapedRequest))
dir := filepath.Join(root, "bugfix-slug", "trial-1")
waitFor(t, "the first recording", func() bool {
m, _ := filepath.Glob(filepath.Join(dir, "*.request.json"))
return len(m) > 0
})
stop()
// The second recording fails while writing the body, after the request has
// already been replaced in memory.
orig := tapeWriteFile
tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
if strings.HasSuffix(path, "response.body") {
return errors.New("disk full")
}
return orig(path, body, mode)
}
t.Cleanup(func() { tapeWriteFile = orig })
var calls2 atomic.Int32
m2, base2, stop2 := tapedMeter(t, tapeRecord, root, &calls2)
defer stop2()
readAll(t, post(t, base2, "/chat/completions", tapedRequest))
var r result
runMeter{m: m2, stop: stop2}.record(&r)
if markers, _ := filepath.Glob(filepath.Join(dir, "*.request.json")); len(markers) != 0 {
t.Fatalf("a failed re-record left completeness markers: %v", markers)
}
// Replay must call the exchange incomplete, not compare a stale request with
// a partial response.
m3, base3, stop3 := tapedMeter(t, tapeReplay, root, &calls2)
defer stop3()
resp := post(t, base3, "/chat/completions", tapedRequest)
readAll(t, resp)
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("replay of a partial tape = %d, want 502", resp.StatusCode)
}
if got := m3.snapshot(); got.DivergedAt != 0 {
t.Fatalf("a partial tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
}
}
// The healthy path must stay quiet: a recording that completed reports no
// incomplete exchange and prints no line about one. Only the failure paths were
// pinned before.
func TestTapeCompleteRecordingReportsNothingIncomplete(t *testing.T) {
root := t.TempDir()
var calls atomic.Int32
m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
defer stop()
readAll(t, post(t, base, "/chat/completions", tapedRequest))
var r result
runMeter{m: m, stop: stop}.record(&r)
if r.Meter == nil {
t.Fatal("record attached no meter")
}
if r.Meter.TapeIncomplete != 0 || r.Meter.TapeError != "" {
t.Fatalf("a complete recording reported %d incomplete exchanges (%q)", r.Meter.TapeIncomplete, r.Meter.TapeError)
}
// renderTapeRecord is the printer behind the report's summary block, so an
// empty string here is what "no line is printed" means.
if line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: r.Meter}}); line != "" {
t.Fatalf("a complete recording printed %q, want no line", line)
}
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"context"
"time"
"reasonix/internal/base/i18n"
"reasonix/internal/contract/config"
"reasonix/internal/platform/diskhealth"
"reasonix/internal/platform/notify"
)
// diskWatchEvery is how often the volumes are looked at: a build fills a disk
// over minutes, and a statfs per folder is cheap.
const diskWatchEvery = 5 * time.Minute
// watchDisk looks at the volumes under dirs on a timer and says when one runs
// short of space. The switches are read each time, so turning the notice off or
// on in the window takes effect without a restart, and the language is the one
// the window is set to now.
func watchDisk(ctx context.Context, set *notify.Settings, sender notify.Sender, dirs []string, every time.Duration, read func(string) (diskhealth.Volume, bool)) {
monitor := diskhealth.NewMonitor()
tick := time.NewTicker(every)
defer tick.Stop()
for {
cfg := set.Load()
if cfg.Enabled && cfg.DiskLow {
for _, n := range monitor.Check(time.Now(), dirs, read) {
say := i18n.CatalogFor(config.LoadForEdit(config.UserConfigPath()).DesktopLanguage())
notify.SendDiskLow(sender, say, cfg, n)
}
}
select {
case <-ctx.Done():
return
case <-tick.C:
}
}
}
// startDiskWatch begins the watch and returns the sender it speaks through, so
// the hub's notices and the disk warning leave by the same door.
func startDiskWatch(ctx context.Context, set *notify.Settings, dirs []string) notify.Sender {
sender := notify.NewPlatformSender()
go watchDisk(ctx, set, sender, dirs, diskWatchEvery, diskhealth.Read)
return sender
}
@@ -0,0 +1,68 @@
package main
import (
"context"
"sync"
"testing"
"time"
"reasonix/internal/contract/config"
"reasonix/internal/platform/diskhealth"
"reasonix/internal/platform/notify"
)
type sentMessages struct {
mu sync.Mutex
sent []notify.Message
}
func (s *sentMessages) Send(m notify.Message) error {
s.mu.Lock()
defer s.mu.Unlock()
s.sent = append(s.sent, m)
return nil
}
func (s *sentMessages) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.sent)
}
const gigabyte = int64(1) << 30
func lowVolume(string) (diskhealth.Volume, bool) {
return diskhealth.Volume{Free: 2 * gigabyte, Total: 500 * gigabyte}, true
}
func runWatch(t *testing.T, cfg config.NotificationsConfig) int {
t.Helper()
sender := &sentMessages{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
watchDisk(ctx, notify.NewSettings(cfg), sender, []string{"/w", "/w/.state"}, time.Millisecond, lowVolume)
close(done)
}()
time.Sleep(50 * time.Millisecond)
cancel()
<-done
return sender.count()
}
// A volume that stays low is said once however many times it is looked at, and
// two folders on it do not make two notices.
func TestTheDiskWatchSaysALowVolumeOnce(t *testing.T) {
if got := runWatch(t, config.NotificationsConfig{Enabled: true, DiskLow: true}); got != 1 {
t.Fatalf("sent %d notices, want exactly one", got)
}
}
func TestTheDiskWatchHonoursTheSwitches(t *testing.T) {
if got := runWatch(t, config.NotificationsConfig{Enabled: true, DiskLow: false}); got != 0 {
t.Fatalf("own switch off sent %d, want none", got)
}
if got := runWatch(t, config.NotificationsConfig{Enabled: false, DiskLow: true}); got != 0 {
t.Fatalf("master switch off sent %d, want none", got)
}
}
+75 -40
View File
@@ -36,6 +36,7 @@ import (
"reasonix/internal/frontend/traystate"
"reasonix/internal/platform/account"
"reasonix/internal/platform/appupdate"
"reasonix/internal/platform/computer"
"reasonix/internal/platform/crashreport"
"reasonix/internal/platform/feedback"
"reasonix/internal/platform/instanceid"
@@ -129,7 +130,9 @@ func main() {
// replace it.
studioApp := flag.String("studio-app", "", "the application executable this host runs inside")
studioAppPID := flag.Int("studio-app-pid", 0, "the process id of that application")
computerHelper := flag.String("computer-helper", "", "the native helper that operates this machine's applications")
computerDriver := flag.String("cua-driver", "", "the pinned CUA Driver executable")
computerAbortSocket := flag.String("computer-abort-socket", "", "the private Studio CUA interruption endpoint")
computerSocket := flag.String("computer-socket", "", "the private embedded CUA socket owned by Studio")
crashDir := flag.String("crash-dir", "", "the directory a fatal runtime error is written to")
stripGrants := flag.Bool("strip-package-grants", false, "remove app-package grants from the directory -studio-app runs from, print what changed, and exit")
flag.Parse()
@@ -143,10 +146,18 @@ func main() {
fmt.Fprintln(os.Stdout, instanceid.Current())
return
}
boot.SetComputerHelper(*computerHelper)
var driver *computer.Driver
if *computerDriver != "" {
if *computerSocket != "" {
driver = computer.NewEmbeddedDriver(*computerDriver, *computerSocket, *computerAbortSocket, os.Environ())
} else {
driver = computer.NewDriver(*computerDriver, os.Environ())
}
}
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID, logsDir: logsDirOf(*crashDir)}
releaseFatalLog := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
code := run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell)
code := run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell, driver)
releaseFatalLog()
os.Exit(code)
}
@@ -171,7 +182,10 @@ func defaultVersion(stated string) string {
// the handshake first and then one line per act a handover asks of the shell,
// because that pipe is the only channel pointing that way; every log goes to
// logs, which is why no line there can be mistaken for one of these.
func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellIdentity) int {
func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellIdentity, driver *computer.Driver) int {
if driver != nil {
defer driver.Close()
}
phases := newStartupPhases(logs, time.Now)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -194,7 +208,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
fmt.Fprintln(logs, "reasonix-studio-host: no built page found; serving the kernel only")
}
hub, err := assemble(ctx, logs, handshakeTo, shell, served, phases)
hub, err := assemble(ctx, logs, handshakeTo, shell, served, phases, driver)
if err != nil {
fmt.Fprintln(logs, "reasonix-studio-host:", err)
return 1
@@ -312,7 +326,7 @@ func resolveKernelLanguage(cfg *config.Config) string {
// assemble builds the hub this host serves: one pane on the workspace it was
// launched in, carrying the capabilities a local window may exercise.
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS, phases *startupPhases) (*serve.Hub, error) {
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS, phases *startupPhases, driver *computer.Driver) (*serve.Hub, error) {
// The one-time upgrades belong to whichever entry point starts first; a
// person who only ever opens the window would otherwise never get them.
if _, err := config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath()); err != nil {
@@ -347,20 +361,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
phases.mark(startupSinks)
root := launchWorkspace(shell.exe)
phases.mark(startupWorkspace)
built, err := boot.BuildRuntime(ctx, boot.Options{
Version: version,
WorkspaceRoot: root,
SessionDir: serve.SessionDirFor(root),
Sink: paneSink,
Stderr: logs,
OnPhase: phases.boot,
StatsSource: surface.Desktop,
FeedbackSurface: feedback.SurfaceStudio,
// A stale default_model must not keep the window from opening to fix it.
OpenOnFallbackModel: true,
CleanupPendingReconciler: serve.BackgroundCleanupReconciler,
})
built, err := boot.BuildRuntime(ctx, runtimeOptions(root, paneSink, logs, phases, driver))
phases.mark(startupRuntime)
if err != nil {
return nil, err
@@ -373,37 +374,51 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
browserHost := serve.NewBrowserHost()
boot.SetBrowserHost(browserHost.Dial)
hubCfg := hostServeConfig(cfg.Serve)
// Shut until the person at the window opens it; the context ending closes
// it with the rest of the kernel, unpairing every device.
// Shut until the person at the window opens it; the context ending stops it
// with the rest of the kernel, keeping the phones this machine already adopted.
share := serve.NewDeviceShare(page)
share.RestorePort(cfg.SharePort())
go func() {
<-ctx.Done()
share.Close()
share.Shutdown()
}()
owner := &shellOwner{to: handshakeTo}
// A toast the user clicks is answered here, in the process that sent it, and
// the shell is the one that can bring its window forward.
notify.OnActivate(showOnToastClick(owner))
notify.OnActivate(showOnToastClick(owner, logs))
// A toast that did not go out is invisible to the person it was for, so the
// reason belongs in the log the shell keeps.
notify.OnSendFailure(func(err error) { fmt.Fprintf(logs, "notify: %v\n", err) })
hub := serve.NewHub(serve.HubOptions{
Serve: hubCfg,
Surface: surface.Desktop,
Page: page,
Grant: grantHostCapabilities,
DecorateSink: decorate,
Tray: &studioTray{tracker: tracker},
Notifications: notifySet,
NotifySender: notify.NewPlatformSender(),
BrowserHost: browserHost,
Asks: asks,
Remote: remotehost.New(ctx, version, asks),
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
Install: studioInstall(shell),
Update: studioUpdateHost(shell, owner),
Share: share,
LogsDir: shell.logsDir,
NativeSurfaces: true,
ComputerDriver: driver,
Serve: hubCfg,
Surface: surface.Desktop,
Page: page,
Grant: grantHostCapabilities,
DecorateSink: decorate,
Tray: &studioTray{tracker: tracker},
Notifications: notifySet,
NotifySender: startDiskWatch(ctx, notifySet, []string{root, serve.SessionDirFor(root)}),
BrowserHost: browserHost,
Asks: asks,
Remote: remotehost.New(ctx, version, asks),
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
Install: studioInstall(shell),
Update: studioUpdateHost(shell, owner),
Share: share,
LogsDir: shell.logsDir,
})
share.Attach(hub.Handler())
// A share the person had open reopens where it was: the phone that paired
// against that address keeps its cookie. An address that is gone falls back
// to one this machine now offers, at the cost of one fresh scan.
state := share.RestoreState()
if state.Open {
if _, err := share.Reopen(state.Address); err != nil {
fmt.Fprintln(logs, "reasonix-studio-host: could not reopen phone access:", err)
}
}
srv := serve.New(built.Controller, bc, hubCfg)
srv.SetPaneSink(paneSink)
srv.AdoptRuntime(built)
@@ -420,6 +435,26 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
return hub, nil
}
// runtimeOptions is what the one pane this host launches with is built from.
func runtimeOptions(root string, sink event.Sink, logs io.Writer, phases *startupPhases, driver *computer.Driver) boot.Options {
return boot.Options{
ComputerDriver: driver,
Version: version,
WorkspaceRoot: root,
SessionDir: serve.SessionDirFor(root),
Sink: sink,
NativeSurfaces: true,
Stderr: logs,
OnPhase: phases.boot,
StatsSource: surface.Desktop,
FeedbackSurface: feedback.SurfaceStudio,
// A stale default_model must not keep the window from opening to fix it.
OpenOnFallbackModel: true,
CleanupPendingReconciler: serve.BackgroundCleanupReconciler,
}
}
func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, tasks remotecloud.TaskService) {
httpClient, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{})
if err != nil {
+2 -2
View File
@@ -418,7 +418,7 @@ func TestRunServesUntilTheParentLetsGo(t *testing.T) {
go func() { _, _ = io.Copy(io.Discard, logs) }()
done := make(chan int, 1)
go func() { done <- run(lease, announced, logged, page, shellIdentity{version: "2.10.0"}) }()
go func() { done <- run(lease, announced, logged, page, shellIdentity{version: "2.10.0"}, nil) }()
line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil {
@@ -542,7 +542,7 @@ func TestAssembleAppliesTheStartupUpgrades(t *testing.T) {
t.Fatal(err)
}
t.Chdir(testenv.TempDir(t))
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now), nil)
if err != nil {
t.Fatalf("assemble: %v", err)
}
+7 -2
View File
@@ -20,8 +20,13 @@ const (
// showOnToastClick is what a clicked toast runs: ask the shell, which is the
// only process that can bring its window forward, to do so.
func showOnToastClick(o *shellOwner) func() {
return func() { _ = o.ask(actShow) }
func showOnToastClick(o *shellOwner, log io.Writer) func() {
return func() {
fmt.Fprintln(log, "notify: toast clicked")
if err := o.ask(actShow); err != nil {
fmt.Fprintf(log, "notify: toast click could not reach the shell: %v\n", err)
}
}
}
// shellOwner is the application this process was spawned by, reached over the
+23 -1
View File
@@ -3,6 +3,8 @@ package main
import (
"bytes"
"context"
"errors"
"io"
goruntime "runtime"
"strings"
"testing"
@@ -121,8 +123,28 @@ func TestTheInstallLayoutFollowsTheStatedApplication(t *testing.T) {
// in the same shape the other acts take.
func TestAToastClickAsksTheShellToShowItsWindow(t *testing.T) {
var out bytes.Buffer
showOnToastClick(&shellOwner{to: &out})()
showOnToastClick(&shellOwner{to: &out}, io.Discard)()
if got := out.String(); got != "{\"act\":\"show\"}\n" {
t.Fatalf("pipe carried %q, want the show act", got)
}
}
// The click is the first link of a chain that crosses two processes; when the
// window does not come forward, the log says whether the click arrived and
// whether the shell was reached.
func TestAToastClickIsWrittenToTheLog(t *testing.T) {
var log bytes.Buffer
showOnToastClick(&shellOwner{to: &bytes.Buffer{}}, &log)()
if !strings.Contains(log.String(), "toast clicked") {
t.Fatalf("log = %q, want the click recorded", log.String())
}
log.Reset()
showOnToastClick(&shellOwner{to: failingWriter{}}, &log)()
if !strings.Contains(log.String(), "could not reach the shell") {
t.Fatalf("log = %q, want the failed ask recorded", log.String())
}
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("pipe closed") }
@@ -47,7 +47,7 @@ func TestAssembledTrayLanguage(t *testing.T) {
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
hub, err := assemble(ctx, io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
hub, err := assemble(ctx, io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now), nil)
if err != nil {
t.Fatalf("assemble: %v", err)
}
+124
View File
@@ -0,0 +1,124 @@
package main
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestLinuxReleaseBuildsAndCollectsBothNativeArchitectures(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
jobs := jobsOf(t, root)
build := mappingValue(jobs, "build")
entries := mappingValue(mappingValue(mappingValue(build, "strategy"), "matrix"), "include")
want := map[string]string{"amd64": "ubuntu-22.04", "arm64": "ubuntu-22.04-arm"}
seen := map[string]bool{}
for _, entry := range entries.Content {
platform := mappingScalar(entry, "platform")
if !strings.HasPrefix(platform, "linux/") {
continue
}
arch := strings.TrimPrefix(platform, "linux/")
if seen[arch] || mappingScalar(entry, "runner") != want[arch] {
t.Fatalf("Linux %s does not have one native runner", arch)
}
seen[arch] = true
artifact := mappingScalar(entry, "artifact")
if artifact != "studio-build-linux-"+arch {
t.Fatalf("unexpected Linux artifact %q", artifact)
}
downloaded := false
for _, step := range mappingValue(mappingValue(jobs, "publish"), "steps").Content {
if mappingScalar(mappingValue(step, "with"), "name") == artifact {
downloaded = true
}
}
if !downloaded {
t.Errorf("publish never downloads %s", artifact)
}
}
if len(seen) != len(want) {
t.Fatalf("Linux release architectures = %v, want amd64 and arm64", seen)
}
step := stepNamed(t, build, "Use a native fpm on ARM64 Linux")
if mappingScalar(step, "if") != "runner.os == 'Linux' && runner.arch == 'ARM64'" || !strings.Contains(mappingScalar(step, "run"), "USE_SYSTEM_FPM=true") {
t.Fatal("Linux ARM64 must retain the upstream native FPM setup")
}
for _, name := range []string{"Verify both Linux package formats", "Accept the installed Linux package"} {
step := stepNamed(t, build, name)
if mappingScalar(step, "if") != "runner.os == 'Linux'" || mappingScalar(step, "continue-on-error") == "true" {
t.Errorf("%s must gate every Linux build", name)
}
}
collect := mappingScalar(stepNamed(t, mappingValue(jobs, "publish"), "Collect artifacts"), "run")
if !strings.Contains(collect, "bash scripts/verify-studio-artifacts.sh dist") {
t.Fatal("publication does not require the complete platform artifact set")
}
}
func TestLinuxCIPackageMatrixPreservesUpstreamScope(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/studio.yml")
job := mappingValue(jobsOf(t, root), "scope")
run := ""
for _, step := range mappingValue(job, "steps").Content {
if mappingScalar(step, "id") == "pick" {
run = mappingScalar(step, "run")
}
}
if run == "" {
t.Fatal("no executable CI matrix selector")
}
for _, tc := range []struct {
event, file string
arm64 bool
}{
{"push", "docs/test.md", true},
{"pull_request", "docs/test.md", false},
{"pull_request", "scripts/linux-install-smoke.sh", true},
{"pull_request", "scripts/verify-linux-package.sh", true},
{"pull_request", "scripts/verify-studio-artifacts.sh", true},
{"pull_request", "desktop/cmd/update-helper/main_linux.go", true},
{"pull_request", "desktop/electron/test/unit.mjs", true},
} {
t.Run(tc.event+"/"+tc.file, func(t *testing.T) {
dir := t.TempDir()
// A shell function owns command lookup on every host, including Git
// Bash on Windows; a PATH shim can resolve the runner's real git.
git := "git() { case \"$*\" in 'diff -z --name-only --no-renames fixture HEAD') printf '%s\\0' \"$MATRIX_TEST_FILE\" ;; 'diff --name-only --no-renames fixture HEAD') printf '%s\\n' \"$MATRIX_TEST_FILE\" ;; *) return 1 ;; esac; };\n"
output := filepath.Join(dir, "output")
cmd := exec.Command("bash", "-c", "set -euo pipefail\n"+git+run)
cmd.Dir = "../.."
cmd.Env = append(os.Environ(), "EVENT="+tc.event, "MATRIX_TEST_FILE="+tc.file, "BASE_SHA=fixture", "GITHUB_OUTPUT="+output)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("matrix selector: %v: %s", err, out)
}
body, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
var matrix struct {
Include []map[string]string `json:"include"`
}
for line := range strings.SplitSeq(string(body), "\n") {
if value, ok := strings.CutPrefix(line, "matrix="); ok {
if err := json.Unmarshal([]byte(value), &matrix); err != nil {
t.Fatal(err)
}
}
}
seen := map[string]string{}
for _, leg := range matrix.Include {
if leg["platform"] == "linux" {
seen[leg["arch"]] = leg["os"]
}
}
if seen["amd64"] != "ubuntu-22.04" || (seen["arm64"] == "ubuntu-22.04-arm") != tc.arm64 {
t.Fatalf("Linux CI matrix = %v; selector output: %s", seen, out)
}
})
}
}
+5 -10
View File
@@ -191,16 +191,11 @@ func TestPublishRefusesAPartialWindowsSet(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
publish := mappingValue(jobsOf(t, root), "publish")
run := mappingScalar(stepNamed(t, publish, "Collect artifacts"), "run")
if !strings.Contains(run, `"$count" -lt 10`) {
t.Error("publish does not require at least 10 ReasonixStudio-* packages")
if !strings.Contains(run, "bash scripts/verify-studio-artifacts.sh dist") {
t.Fatal("publish does not run the complete artifact-set verifier")
}
for _, name := range []string{
"ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-arm64-installer.exe",
"ReasonixStudio-windows-amd64.zip", "ReasonixStudio-windows-arm64.zip",
"ReasonixStudio-linux-*.deb", "ReasonixStudio-linux-*.rpm",
} {
if !strings.Contains(run, "'"+name+"'") {
t.Errorf("publish does not require %s by name", name)
}
ci := parseWorkflowFile(t, "../../.github/workflows/ci.yml")
if !nodeContains(ci, "scripts/studio-artifacts.test.mjs") {
t.Fatal("CI does not exercise missing Windows/Linux/macOS artifacts against the publication verifier")
}
}
+86
View File
@@ -0,0 +1,86 @@
---
status: active
owner: @esengine
backup: @SivanCola
reviewed: 2026-10-13
---
# Foundation palette verification
final result: passed
Clear Blue and Tea Green implement the two approved colour directions for
[#12909](https://github.com/esengine/DeepSeek-Reasonix/issues/12909).
The existing default remains the baseline. Layout, typography, copy and brand
assets follow the product rather than generated mockup variations.
## Screenshots
Captured from the actual Studio components with synthetic performance-harness
data, one conversation turn and an empty composer. Fixture workspace and host
names are not real user data. Screenshots use 1:1 density.
| Palette | Light, 1280 × 720 | Dark, 1280 × 720 |
| --- | --- | --- |
| Default baseline | [Light](docs/design/theme-palettes/default-light-wide.jpg) | [Dark](docs/design/theme-palettes/default-dark-wide.jpg) |
| Clear Blue | [Light](docs/design/theme-palettes/clear-blue-light-wide.jpg) | [Dark](docs/design/theme-palettes/clear-blue-dark-wide.jpg) |
| Tea Green | [Light](docs/design/theme-palettes/tea-green-light-wide.jpg) | [Dark](docs/design/theme-palettes/tea-green-dark-wide.jpg) |
| Palette | Light, 390 × 844 | Dark, 390 × 844 |
| --- | --- | --- |
| Default baseline | [Light](docs/design/theme-palettes/default-light-phone.jpg) | [Dark](docs/design/theme-palettes/default-dark-phone.jpg) |
| Clear Blue | [Light](docs/design/theme-palettes/clear-blue-light-phone.jpg) | [Dark](docs/design/theme-palettes/clear-blue-dark-phone.jpg) |
| Tea Green | [Light](docs/design/theme-palettes/tea-green-light-phone.jpg) | [Dark](docs/design/theme-palettes/tea-green-dark-phone.jpg) |
## Visual comparison
The approved light mockups were 1619 × 971 pixels, including a caption outside
the application. They were viewed together with the implementation screenshots.
Comparison excluded that caption and assessed the palette direction, rather
than treating generated layout drift as a product specification.
- Typography: existing families, size, line height and tracking retained. Plain
light palettes now share the default prose weight; user overrides still win.
- Spacing: existing grid, controls, corners and panel proportions retained.
- Colours: cool white/blue and warm white/tea-green surfaces match the approved
directions. Text, syntax and button contrast checks pass in both schemes.
- Assets: existing logos and icons retained; no added image or sky layers.
- Copy: existing workbench text retained; new palette names and descriptions
have Chinese and English labels.
Full views made the navigation, transcript and composer readable without
additional crops. Dark palettes extend the approved hues; the source mockups
did not specify a dark layout. Phone captures confirm no horizontal overflow.
## Findings and iteration history
1. Default preview inherited the selected pack, and plain packs changed reading
surfaces and weight. Shared canonical default variables and plain-surface
rules now keep the preview independent and typography consistent.
2. Initial comparison found neutral selected navigation and a dark send button
overridden by body colour. Shared pack selectors now use accent ink/wash and
the accent/foreground pair; disabled controls remain neutral.
3. Revised screenshots were compared with both approved directions. No
actionable P0/P1/P2 differences remain. Sky themes now join image themes in
the expandable collection; the active backdrop remains in the first group.
## Behavioural evidence
- Immediate selection, return to default, light/dark and system-following modes
verified in the browser; each preview follows its own scheme tokens.
- Default comparison: original and revised Studio CSS applied to the same DOM.
Fourteen style properties matched for 735 visible elements in each scheme
under a light system and 734 in each scheme under a dark system.
- Compared colour, background, border, shadow, font, size, weight, line height,
tracking, width, height, padding, margin and radius.
- Go integration tests cover selection, config persistence, server recreation
and reset to the default for both new built-in IDs.
- Frontend neighbourhood: 22 test files, 209 tests passed; production build and
typecheck passed. Browser warning/error logs were empty.
- Theme and HTTP server package tests, repository checks and whitespace checks
passed. Lint budgets were not increased.
## Remaining coverage
Windows native installer acceptance and hosted CI are separate from these
local results. The production build retains its existing large-chunk warning.
+54
View File
@@ -286,3 +286,57 @@ func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
t.Errorf("downloads = %v, want only the rpm added", after.Downloads)
}
}
// A release that ships Linux for both architectures keys each package under its
// own platform: an arm64 client must never be handed the amd64 .deb, and adding
// the second architecture must not move the first one's entries.
func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
dir := t.TempDir()
generate := func() update.Manifest {
if err := run(dir, "v2.39.0", "studio-v2.39.0", "", true); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
if err != nil {
t.Fatal(err)
}
var m update.Manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
write := func(arch string) {
for _, ext := range []string{"deb", "rpm"} {
name := "ReasonixStudio-linux-" + arch + "." + ext
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
}
write("amd64")
before := generate()
write("arm64")
after := generate()
for key, asset := range before.NativePackages {
if after.NativePackages[key] != asset {
t.Errorf("existing channel %s changed", key)
}
}
if len(after.NativePackages) != 4 || len(after.Downloads) != 4 || len(after.Platforms) != 0 {
t.Fatalf("manifest channels = %+v, want four native packages and human downloads", after)
}
for ext, key := range map[string]string{"deb": "linux-arm64", "rpm": "linux-arm64-rpm"} {
name := "ReasonixStudio-linux-arm64." + ext
asset, ok := after.NativePackages[key]
if !ok || after.Downloads[name] != asset {
t.Fatalf("%s does not resolve to its own download", key)
}
mirror := update.StudioMirror + "/studio-v2.39.0/" + name
github := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/studio-v2.39.0/" + name
if asset.URL != mirror || asset.Sig != mirror+".minisig" || asset.Fallback != github || asset.FallbackSig != github+".minisig" {
t.Errorf("%s loses its mirror, fallback or signature: %+v", key, asset)
}
}
}
@@ -1,348 +0,0 @@
import AppKit
import ApplicationServices
// Refs name elements for the model. One is issued per element and never reused,
// so a ref read earlier cannot come to mean something else; an element that has
// gone answers computer.stale_ref.
final class RefTable {
private var next = 0
private var byHash: [CFHashCode: [(ref: String, element: AXUIElement)]] = [:]
private var byRef: [String: (pid: pid_t, element: AXUIElement)] = [:]
func ref(for element: AXUIElement, pid: pid_t) -> String {
let hash = CFHash(element)
if let found = byHash[hash]?.first(where: { CFEqual($0.element, element) }) {
return found.ref
}
next += 1
let ref = "a\(next)"
byHash[hash, default: []].append((ref, element))
byRef[ref] = (pid, element)
return ref
}
func element(_ ref: String, pid: pid_t) throws -> AXUIElement {
guard let entry = byRef[ref] else {
throw Failure(code: "computer.unknown_ref", message: "\(ref) was never issued; refs come from a snapshot")
}
guard entry.pid == pid else {
throw Failure(code: "computer.unknown_ref", message: "\(ref) belongs to another application")
}
var role: AnyObject?
if AXUIElementCopyAttributeValue(entry.element, kAXRoleAttribute as CFString, &role) == .invalidUIElement {
throw Failure(code: "computer.stale_ref", message: "\(ref) is no longer on screen; take a new snapshot")
}
return entry.element
}
}
// Seen is one element's identity for a single walk: the accessibility API hands
// back a new reference for the same element, so equality is the API's, not the
// pointer's.
struct Seen: Hashable {
let element: AXUIElement
init(_ element: AXUIElement) { self.element = element }
static func == (a: Seen, b: Seen) -> Bool { CFEqual(a.element, b.element) }
func hash(into hasher: inout Hasher) { hasher.combine(CFHash(element)) }
}
// The action name for bringing an element into view. The SDK exports no
// constant for it, unlike press and show-menu.
let scrollToVisibleAction = "AXScrollToVisible"
enum Accessibility {
static let maxNodes = 1500
// How far the scroll bar is nudged to measure what the element does with it.
static let scrollProbe = 0.1
static let maxDepth = 40
// Containers that say nothing when unnamed: their children stand in for them.
static let transparent: Set<String> = ["AXGroup", "AXScrollArea", "AXSplitGroup", "AXLayoutArea", "AXUnknown"]
static func app(_ pid: pid_t) -> AXUIElement {
let element = AXUIElementCreateApplication(pid)
AXUIElementSetMessagingTimeout(element, 1.5)
return element
}
static func attr(_ element: AXUIElement, _ name: String) -> AnyObject? {
var value: AnyObject?
return AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success ? value : nil
}
static func string(_ element: AXUIElement, _ name: String) -> String {
switch attr(element, name) {
case let s as String: return s
case let n as NSNumber: return n.stringValue
default: return ""
}
}
static func actions(_ element: AXUIElement) -> [String] {
var names: CFArray?
return AXUIElementCopyActionNames(element, &names) == .success ? (names as? [String] ?? []) : []
}
static func snapshot(pid: pid_t, refs: RefTable) throws -> JSON {
try Permissions.requireAccessibility()
guard let running = NSRunningApplication(processIdentifier: pid) else {
throw Failure(code: "computer.no_app", message: "no application with pid \(pid) is running")
}
let root = app(pid)
// Chromium-based applications build their tree only for a client that
// asks; the switch is ignored by everything else.
AXUIElementSetAttributeValue(root, "AXManualAccessibility" as CFString, kCFBooleanTrue)
var windows = (attr(root, kAXWindowsAttribute) as? [AXUIElement]) ?? []
if let focused = attr(root, kAXFocusedWindowAttribute), CFGetTypeID(focused) == AXUIElementGetTypeID() {
let focusedWindow = focused as! AXUIElement
windows.removeAll { CFEqual($0, focusedWindow) }
windows.insert(focusedWindow, at: 0)
}
// An application can hand its own element back in place of every window
// it has; what lies under it is the menu bar and itself again. The menus
// are still worth reading, and the reader has to be told the rest of the
// application is not in here.
windows.removeAll { CFEqual($0, root) }
var note = ""
if windows.isEmpty {
note = Screen.locked()
? "The screen is locked. While it is, macOS exposes no application's windows through accessibility, so nothing inside this window can be read, clicked by ref or hit by point — only the menus below. Typing, keys and screenshots still reach it; unlocking the screen brings the window back."
: "This application exposes no window through accessibility right now, so nothing inside its window can be read, clicked by ref or hit by point — only its menus below. Typing and keys still reach it, a screenshot still shows it, and relaunching it usually brings the window back."
windows = [root]
}
var lines: [String] = []
var count = 0
// An application's tree is not always one: a window of Calculator lists
// the application among its children, and walking that lists the window
// again. Every element is walked once per snapshot.
var seen = Set<Seen>()
func walk(_ element: AXUIElement, _ depth: Int) {
if count >= maxNodes || depth > maxDepth { return }
if !seen.insert(Seen(element)).inserted { return }
count += 1
let role = string(element, kAXRoleAttribute)
var label = string(element, kAXTitleAttribute)
if label.isEmpty { label = string(element, kAXDescriptionAttribute) }
let value = string(element, kAXValueAttribute)
if label.isEmpty && role == "AXStaticText" { label = value }
let children = (attr(element, kAXChildrenAttribute) as? [AXUIElement]) ?? []
if transparent.contains(role) && label.isEmpty {
for child in children { walk(child, depth) }
return
}
var line = String(repeating: " ", count: depth) + "- " + roleName(role)
if !label.isEmpty { line += " \(quote(label))" }
line += " [\(refs.ref(for: element, pid: pid))]"
if !value.isEmpty && value != label && role != "AXStaticText" { line += " value=\(quote(value))" }
if let placeholder = attr(element, kAXPlaceholderValueAttribute) as? String, !placeholder.isEmpty, value.isEmpty {
line += " placeholder=\(quote(placeholder))"
}
if (attr(element, kAXFocusedAttribute) as? Bool) == true { line += " focused" }
if (attr(element, kAXEnabledAttribute) as? Bool) == false { line += " disabled" }
if (attr(element, kAXSelectedAttribute) as? Bool) == true { line += " selected" }
if actions(element).contains(kAXPressAction) { line += " pressable" }
lines.append(line)
for child in children { walk(child, depth + 1) }
}
for window in windows { walk(window, 0) }
return [
"app": running.localizedName ?? "",
"bundle": running.bundleIdentifier ?? "",
"lines": lines,
"truncated": count >= maxNodes,
"note": note,
]
}
static func roleName(_ role: String) -> String {
let bare = role.hasPrefix("AX") ? String(role.dropFirst(2)) : role
return bare.isEmpty ? "element" : bare.prefix(1).lowercased() + bare.dropFirst()
}
static func quote(_ s: String) -> String {
let clipped = s.count > 160 ? String(s.prefix(160)) + "…" : s
let data = try? JSONSerialization.data(withJSONObject: [clipped])
let json = data.flatMap { String(data: $0, encoding: .utf8) } ?? "[\"\"]"
return String(json.dropFirst().dropLast())
}
static func center(_ element: AXUIElement) -> CGPoint? {
guard let posValue = attr(element, kAXPositionAttribute), let sizeValue = attr(element, kAXSizeAttribute) else { return nil }
var pos = CGPoint.zero, size = CGSize.zero
guard AXValueGetValue(posValue as! AXValue, .cgPoint, &pos), AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) else { return nil }
return CGPoint(x: pos.x + size.width / 2, y: pos.y + size.height / 2)
}
static func perform(_ element: AXUIElement, _ action: String, what: String) throws {
switch AXUIElementPerformAction(element, action as CFString) {
case .success:
return
case .invalidUIElement:
throw Failure(code: "computer.stale_ref", message: "\(what) is no longer on screen; take a new snapshot")
case .actionUnsupported, .attributeUnsupported:
throw Failure(code: "computer.no_action", message: "\(what) takes no \(action)")
case let error:
throw Failure(code: "computer.failed", message: "\(action) on \(what) failed (AXError \(error.rawValue))")
}
}
static func press(pid: pid_t, ref: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
try Permissions.requireAccessibility()
let element = try refs.element(ref, pid: pid)
if let at = center(element) { cursor.move(to: at) }
try perform(element, kAXPressAction, what: ref)
return [:]
}
// menu asks an element for its context menu, which is what a right click is
// for: the menu belongs to the element, so no pointer has to be borrowed to
// open it. Read the application again afterwards to see the menu.
static func menu(pid: pid_t, ref: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
try Permissions.requireAccessibility()
let element = try refs.element(ref, pid: pid)
if let at = center(element) { cursor.move(to: at) }
guard actions(element).contains(kAXShowMenuAction) else {
throw Failure(code: "computer.no_action", message: "\(ref) has no context menu")
}
try perform(element, kAXShowMenuAction, what: ref)
return [:]
}
// reveal scrolls an element into view without a pointer. Where that is not
// offered, the wheel goes to the application itself, which scrolls whatever
// it considers in front — the same thing a wheel over its window would.
static func scroll(pid: pid_t, ref: String, amount: Double, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
try Permissions.requireAccessibility()
if !ref.isEmpty {
let element = try refs.element(ref, pid: pid)
if let at = center(element) { cursor.move(to: at) }
if actions(element).contains(scrollToVisibleAction) {
try perform(element, scrollToVisibleAction, what: ref)
return ["how": "revealed"]
}
if try revealBy(element, ref: ref) {
return ["how": "revealed"]
}
}
guard amount != 0 else {
throw Failure(code: "computer.bad_step", message: "a scroll needs a ref that can be revealed, or lines to turn the wheel by")
}
let source = CGEventSource(stateID: .privateState)
guard let event = CGEvent(scrollWheelEvent2Source: source, units: .line, wheelCount: 1, wheel1: Int32(amount), wheel2: 0, wheel3: 0) else {
throw Failure(code: "computer.failed", message: "the scroll could not be made")
}
event.postToPid(pid)
return ["how": "wheel"]
}
// revealBy brings an element into view in applications that offer no way for
// it to reveal itself. Their scroll areas advertise page actions that then
// refuse to run (measured: AXScrollDownByPage answers -25205 on AppKit),
// while the scroll bar's own value does move the view — so the geometry is
// solved on that: move the bar a little, see how far the element went with
// it, and put the bar where the element lands inside the area.
static func revealBy(_ element: AXUIElement, ref: String) throws -> Bool {
guard let area = enclosingScroller(element), let bar = verticalBar(area),
let want = frame(element), let have = frame(area) else { return false }
if have.intersects(want) { return true }
guard var at = attr(bar, kAXValueAttribute) as? Double else { return false }
let probe = at > 0.5 ? at - scrollProbe : at + scrollProbe
guard set(bar, probe), let moved = frame(element) else { return false }
let travel = moved.midY - want.midY
if abs(travel) < 1 {
_ = set(bar, at)
return false
}
// How far one unit of the bar carries the element, and the value that
// leaves it in the middle of what the area shows.
let perUnit = travel / (probe - at)
at = min(max(probe + (have.midY - moved.midY) / perUnit, 0), 1)
guard set(bar, at), let landed = frame(element) else { return false }
return have.intersects(landed)
}
static func verticalBar(_ area: AXUIElement) -> AXUIElement? {
for child in (attr(area, kAXChildrenAttribute) as? [AXUIElement]) ?? [] {
guard string(child, kAXRoleAttribute) == "AXScrollBar" else { continue }
if (attr(child, kAXOrientationAttribute) as? String) != "AXHorizontalOrientation" {
return child
}
}
return nil
}
static func set(_ element: AXUIElement, _ value: Double) -> Bool {
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, value as CFTypeRef) == .success
}
// enclosingScroller is the nearest ancestor that scrolls, which is what a
// scroll bar belongs to.
static func enclosingScroller(_ element: AXUIElement) -> AXUIElement? {
var at = element
for _ in 0..<maxDepth {
guard let parent = attr(at, kAXParentAttribute), CFGetTypeID(parent) == AXUIElementGetTypeID() else { return nil }
at = parent as! AXUIElement
if string(at, kAXRoleAttribute) == "AXScrollArea" { return at }
}
return nil
}
static func frame(_ element: AXUIElement) -> CGRect? {
guard let posValue = attr(element, kAXPositionAttribute), let sizeValue = attr(element, kAXSizeAttribute) else { return nil }
var origin = CGPoint.zero
var size = CGSize.zero
guard AXValueGetValue(posValue as! AXValue, .cgPoint, &origin), AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) else { return nil }
return CGRect(origin: origin, size: size)
}
// click is a press at a point: the element there is asked for its action, so
// the person's own pointer never moves. An element that has none cannot be
// clicked this way, and says so rather than being clicked some other way.
static func click(pid: pid_t, x: Double, y: Double, cursor: VirtualCursor) throws -> JSON {
try Permissions.requireAccessibility()
var hit: AXUIElement?
guard AXUIElementCopyElementAtPosition(app(pid), Float(x), Float(y), &hit) == .success, let element = hit else {
throw Failure(code: "computer.no_element", message: "nothing of this application is at (\(Int(x)), \(Int(y)))\(Screen.locked() ? "; the screen is locked, and a locked screen exposes no window to click in" : "")")
}
// Only an element of the application that was approved takes the click,
// whatever the hit test resolves the point to.
var owner: pid_t = 0
guard AXUIElementGetPid(element, &owner) == .success, owner == pid else {
throw Failure(code: "computer.no_element", message: "the element at (\(Int(x)), \(Int(y))) belongs to another application")
}
cursor.move(to: CGPoint(x: x, y: y))
let role = string(element, kAXRoleAttribute)
if actions(element).contains(kAXPressAction) {
try perform(element, kAXPressAction, what: "the \(roleName(role)) at that point")
} else if ["AXTextField", "AXTextArea", "AXComboBox", "AXSearchField"].contains(role) {
AXUIElementSetAttributeValue(element, kAXFocusedAttribute as CFString, kCFBooleanTrue)
} else {
throw Failure(code: "computer.no_action", message: "the \(roleName(role)) at that point takes no accessibility action\(Screen.locked() ? " — the screen is locked, which is why this application's window is not there to hit" : "; it needs the real pointer")")
}
return ["role": roleName(role)]
}
static func focus(pid: pid_t, ref: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
try Permissions.requireAccessibility()
let element = try refs.element(ref, pid: pid)
if let at = center(element) { cursor.move(to: at) }
let result = AXUIElementSetAttributeValue(element, kAXFocusedAttribute as CFString, kCFBooleanTrue)
if result != .success {
throw Failure(code: "computer.no_action", message: "\(ref) cannot take focus (AXError \(result.rawValue))")
}
return [:]
}
static func setValue(pid: pid_t, ref: String, text: String, refs: RefTable, cursor: VirtualCursor) throws -> JSON {
try Permissions.requireAccessibility()
let element = try refs.element(ref, pid: pid)
if let at = center(element) { cursor.move(to: at) }
let result = AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, text as CFString)
if result != .success {
throw Failure(code: "computer.no_action", message: "\(ref) has no value that can be set (AXError \(result.rawValue))")
}
return ["value": string(element, kAXValueAttribute)]
}
}
@@ -1,34 +0,0 @@
import AppKit
import CoreGraphics
enum Apps {
// The applications a person can see, with their windows on screen. Window
// names need screen recording; without it a window is listed untitled.
static func list() -> [JSON] {
let windows = (CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [JSON]) ?? []
var byPid: [pid_t: [JSON]] = [:]
for w in windows {
guard (w[kCGWindowLayer as String] as? Int) == 0,
let pid = w[kCGWindowOwnerPID as String] as? pid_t,
let id = w[kCGWindowNumber as String] as? Int,
let boundsDict = w[kCGWindowBounds as String] as? NSDictionary,
let bounds = CGRect(dictionaryRepresentation: boundsDict) else { continue }
byPid[pid, default: []].append([
"id": id,
"title": w[kCGWindowName as String] as? String ?? "",
"bounds": ["x": bounds.minX, "y": bounds.minY, "width": bounds.width, "height": bounds.height],
])
}
return NSWorkspace.shared.runningApplications
.filter { $0.activationPolicy == .regular }
.map { app in
[
"pid": app.processIdentifier,
"bundle": app.bundleIdentifier ?? "",
"name": app.localizedName ?? "",
"active": app.isActive,
"windows": byPid[app.processIdentifier] ?? [],
]
}
}
}
@@ -1,82 +0,0 @@
import AppKit
import ScreenCaptureKit
enum Capture {
// window captures an application's frontmost on-screen window by itself,
// whatever covers it, at the display's pixel scale. bounds is where it sits
// in global points, which is what a click at a point in the image maps to.
//
// The answer arrives on whichever thread ScreenCaptureKit finishes on: its
// continuations can need the main thread, so waiting for one there is a
// deadlock that reads as a capture that never finished.
static func window(pid: pid_t, answer: @escaping (Result<JSON, Failure>) -> Void) throws {
guard #available(macOS 14.0, *) else {
throw Failure(code: "computer.unsupported", message: "capturing one window needs macOS 14 or later")
}
try capture(pid: pid, answer: answer)
}
@available(macOS 14.0, *)
private static func capture(pid: pid_t, answer: @escaping (Result<JSON, Failure>) -> Void) throws {
try Permissions.requireScreenRecording()
let once = Once(answer)
Task.detached {
do {
let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
guard let target = content.windows
.filter({ $0.owningApplication?.processID == pid && $0.windowLayer == 0 && $0.frame.width > 1 })
.first else {
once.fire(.failure(Failure(code: "computer.no_window", message: "the application has no window on screen")))
return
}
let filter = SCContentFilter(desktopIndependentWindow: target)
let config = SCStreamConfiguration()
config.width = Int(target.frame.width * CGFloat(filter.pointPixelScale))
config.height = Int(target.frame.height * CGFloat(filter.pointPixelScale))
config.showsCursor = false
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)
let rep = NSBitmapImageRep(cgImage: image)
guard let jpeg = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.8]) else {
once.fire(.failure(Failure(code: "computer.capture_failed", message: "the capture could not be encoded")))
return
}
once.fire(.success([
"data": jpeg.base64EncodedString(),
"mime": "image/jpeg",
"width": image.width,
"height": image.height,
"window": target.windowID,
"bounds": ["x": target.frame.minX, "y": target.frame.minY, "width": target.frame.width, "height": target.frame.height],
]))
} catch {
once.fire(.failure(Failure(code: "computer.capture_failed", message: "\(error.localizedDescription)")))
}
}
DispatchQueue.global().asyncAfter(deadline: .now() + 10) {
var said = "the capture did not finish within 10s"
let others = Processes.siblings()
if !others.isEmpty {
said += "; another helper is running (pid \(others.map(String.init).joined(separator: ", "))) and while one of them has captured, the other's capture never returns — quit the other Studio"
}
once.fire(.failure(Failure(code: "computer.capture_failed", message: said)))
}
}
}
// Once carries a request's single answer: the capture and the deadline race for
// it, and the request is owed exactly one reply.
private final class Once {
private let answer: (Result<JSON, Failure>) -> Void
private let lock = NSLock()
private var fired = false
init(_ answer: @escaping (Result<JSON, Failure>) -> Void) { self.answer = answer }
func fire(_ outcome: Result<JSON, Failure>) {
lock.lock()
let first = !fired
fired = true
lock.unlock()
if first { answer(outcome) }
}
}
@@ -1,62 +0,0 @@
import AppKit
enum Clipboard {
// paste puts text where the person is typing without typing it: a long
// value arrives at once, and an application that completes or reformats
// what it is given sees one insertion instead of a hundred keystrokes.
//
// The clipboard is the person's. What was on it goes back, unless something
// else claimed it while this ran — then theirs is the newer one and stays.
static func paste(pid: pid_t, text: String) throws -> JSON {
try Permissions.requireAccessibility()
// Measured: an application that is not in front never handles the
// keystroke, and bringing it forward is the person's screen being taken
// over — which is answered for on its own, not folded into this.
guard NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else {
throw Failure(
code: "computer.needs_front",
message: "pasting needs this application in front; bring it forward with a pointer step, or use type or set_value, which work while it is behind"
)
}
let board = NSPasteboard.general
let saved = snapshot(board)
let written = board.clearContents()
board.setString(text, forType: .string)
_ = try Keyboard.press(pid: pid, chord: "cmd+v", times: 1)
// The application reads the clipboard when it gets round to handling the
// keystroke, and giving it back before then is what hands it the
// person's own clipboard to paste instead. So the wait is long, and it
// is not the step's to pay: a busy application still has it a second
// after this call has answered.
Thread.sleep(forTimeInterval: 0.25)
DispatchQueue.global().asyncAfter(deadline: .now() + 1.5) {
Clipboard.restore(board, saved, writtenAt: written)
}
return ["pasted": text.count]
}
private static func snapshot(_ board: NSPasteboard) -> [[NSPasteboard.PasteboardType: Data]] {
(board.pasteboardItems ?? []).map { item in
var kept: [NSPasteboard.PasteboardType: Data] = [:]
for type in item.types {
if let data = item.data(forType: type) {
kept[type] = data
}
}
return kept
}
}
fileprivate static func restore(_ board: NSPasteboard, _ saved: [[NSPasteboard.PasteboardType: Data]], writtenAt: Int) {
guard board.changeCount == writtenAt else { return }
board.clearContents()
guard !saved.isEmpty else { return }
board.writeObjects(saved.map { kept in
let item = NSPasteboardItem()
for (type, data) in kept {
item.setData(data, forType: type)
}
return item
})
}
}
@@ -1,79 +0,0 @@
import AppKit
// The agent's own pointer: drawn over everything, taking no clicks and no
// focus, so a person can see where it acts without it touching theirs.
final class VirtualCursor {
private let panel: NSPanel
private var hideTimer: Timer?
private static let size = NSSize(width: 120, height: 44)
init() {
panel = NSPanel(contentRect: NSRect(origin: .zero, size: Self.size), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: true)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = false
panel.ignoresMouseEvents = true
panel.level = .screenSaver
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
panel.contentView = CursorView(frame: NSRect(origin: .zero, size: Self.size))
// Escape while the agent's cursor is on screen is the person saying stop.
// Any other time it is their own keystroke and not this process's business.
NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self, event.keyCode == 53, self.panel.isVisible else { return }
reply(["event": "stop"])
self.hide()
}
}
// move glides the pointer to a point given in global top-left coordinates,
// the ones accessibility and window lists use.
func move(to point: CGPoint) {
guard let primary = NSScreen.screens.first else { return }
let origin = NSPoint(x: point.x - 2, y: primary.frame.height - point.y - Self.size.height + 2)
if !panel.isVisible {
panel.setFrameOrigin(origin)
panel.orderFrontRegardless()
} else {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.25
panel.animator().setFrameOrigin(origin)
}
}
RunLoop.main.run(until: Date().addingTimeInterval(0.28))
hideTimer?.invalidate()
hideTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: false) { [weak self] _ in self?.hide() }
}
func hide() {
hideTimer?.invalidate()
panel.orderOut(nil)
}
}
private final class CursorView: NSView {
override func draw(_ dirtyRect: NSRect) {
let top = bounds.height
let arrow = NSBezierPath()
arrow.move(to: NSPoint(x: 2, y: top - 2))
arrow.line(to: NSPoint(x: 2, y: top - 22))
arrow.line(to: NSPoint(x: 7.5, y: top - 17))
arrow.line(to: NSPoint(x: 11, y: top - 25))
arrow.line(to: NSPoint(x: 14, y: top - 23.5))
arrow.line(to: NSPoint(x: 10.5, y: top - 16))
arrow.line(to: NSPoint(x: 17, y: top - 16))
arrow.close()
NSColor.systemIndigo.setFill()
arrow.fill()
NSColor.white.setStroke()
arrow.lineWidth = 1.5
arrow.stroke()
let label = "Reasonix" as NSString
let attrs: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 11, weight: .semibold), .foregroundColor: NSColor.white]
let textSize = label.size(withAttributes: attrs)
let pill = NSRect(x: 16, y: top - 40, width: textSize.width + 12, height: textSize.height + 4)
NSColor.systemIndigo.setFill()
NSBezierPath(roundedRect: pill, xRadius: pill.height / 2, yRadius: pill.height / 2).fill()
label.draw(at: NSPoint(x: pill.minX + 6, y: pill.minY + 2), withAttributes: attrs)
}
}
@@ -1,96 +0,0 @@
import ApplicationServices
import Carbon.HIToolbox
// Keyboard input goes to one process, never to whatever is frontmost, so the
// person's own typing elsewhere is not interleaved with it.
enum Keyboard {
static let named: [String: Int] = [
"enter": kVK_Return, "return": kVK_Return, "tab": kVK_Tab, "escape": kVK_Escape, "space": kVK_Space,
"backspace": kVK_Delete, "delete": kVK_ForwardDelete,
"arrowleft": kVK_LeftArrow, "arrowright": kVK_RightArrow, "arrowup": kVK_UpArrow, "arrowdown": kVK_DownArrow,
"home": kVK_Home, "end": kVK_End, "pageup": kVK_PageUp, "pagedown": kVK_PageDown, "insert": kVK_Help,
"a": kVK_ANSI_A, "b": kVK_ANSI_B, "c": kVK_ANSI_C, "d": kVK_ANSI_D, "e": kVK_ANSI_E, "f": kVK_ANSI_F,
"g": kVK_ANSI_G, "h": kVK_ANSI_H, "i": kVK_ANSI_I, "j": kVK_ANSI_J, "k": kVK_ANSI_K, "l": kVK_ANSI_L,
"m": kVK_ANSI_M, "n": kVK_ANSI_N, "o": kVK_ANSI_O, "p": kVK_ANSI_P, "q": kVK_ANSI_Q, "r": kVK_ANSI_R,
"s": kVK_ANSI_S, "t": kVK_ANSI_T, "u": kVK_ANSI_U, "v": kVK_ANSI_V, "w": kVK_ANSI_W, "x": kVK_ANSI_X,
"y": kVK_ANSI_Y, "z": kVK_ANSI_Z,
"0": kVK_ANSI_0, "1": kVK_ANSI_1, "2": kVK_ANSI_2, "3": kVK_ANSI_3, "4": kVK_ANSI_4,
"5": kVK_ANSI_5, "6": kVK_ANSI_6, "7": kVK_ANSI_7, "8": kVK_ANSI_8, "9": kVK_ANSI_9,
"f1": kVK_F1, "f2": kVK_F2, "f3": kVK_F3, "f4": kVK_F4, "f5": kVK_F5, "f6": kVK_F6,
"f7": kVK_F7, "f8": kVK_F8, "f9": kVK_F9, "f10": kVK_F10, "f11": kVK_F11, "f12": kVK_F12,
"minus": kVK_ANSI_Minus, "equal": kVK_ANSI_Equal, "comma": kVK_ANSI_Comma, "period": kVK_ANSI_Period,
"slash": kVK_ANSI_Slash, "semicolon": kVK_ANSI_Semicolon, "quote": kVK_ANSI_Quote,
"bracketleft": kVK_ANSI_LeftBracket, "bracketright": kVK_ANSI_RightBracket,
"backslash": kVK_ANSI_Backslash, "backquote": kVK_ANSI_Grave,
]
static let modifiers: [String: CGEventFlags] = [
"shift": .maskShift, "control": .maskControl, "ctrl": .maskControl,
"alt": .maskAlternate, "option": .maskAlternate, "meta": .maskCommand, "cmd": .maskCommand,
]
static func type(pid: pid_t, text: String) throws -> JSON {
try Permissions.requireAccessibility()
let source = CGEventSource(stateID: .privateState)
let units = Array(text.utf16)
var at = 0
while at < units.count {
let chunk = Array(units[at..<min(at + 16, units.count)])
at += chunk.count
for down in [true, false] {
guard let event = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: down) else { continue }
chunk.withUnsafeBufferPointer { event.keyboardSetUnicodeString(stringLength: chunk.count, unicodeString: $0.baseAddress) }
event.postToPid(pid)
}
}
return [:]
}
// hold keeps a key down, which is what a game or a scrubbing control reads
// rather than a press.
static func hold(pid: pid_t, chord: String, seconds: Double) throws -> JSON {
let (code, flags) = try resolve(chord)
let source = CGEventSource(stateID: .privateState)
if let down = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(code), keyDown: true) {
down.flags = flags
down.postToPid(pid)
}
Thread.sleep(forTimeInterval: min(max(seconds, 0), 30))
if let up = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(code), keyDown: false) {
up.flags = flags
up.postToPid(pid)
}
return [:]
}
// resolve reads a chord like "meta+s" into the key and the modifiers held
// with it, and says what the vocabulary is when it cannot.
static func resolve(_ chord: String) throws -> (Int, CGEventFlags) {
try Permissions.requireAccessibility()
let parts = chord.split(separator: "+").map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
guard let last = parts.last, let code = named[last] else {
throw Failure(code: "computer.bad_step",
message: "\(chord) is not a key this can press. It presses named keys — enter, tab, escape, space, backspace, delete, insert, the arrows, home, end, pageup, pagedown, f1 to f12, a letter, a digit, or minus, equal, comma, period, slash, semicolon, quote, bracketleft, bracketright, backslash, backquote — with the modifiers shift, control, alt and meta. Text goes through the type action")
}
var flags = CGEventFlags()
for part in parts.dropLast() {
guard let flag = modifiers[part] else {
throw Failure(code: "computer.bad_step", message: "\(part) is not a modifier; use shift, control, alt or meta")
}
flags.insert(flag)
}
return (code, flags)
}
static func press(pid: pid_t, chord: String, times: Int) throws -> JSON {
let (code, flags) = try resolve(chord)
let source = CGEventSource(stateID: .privateState)
for _ in 0..<max(times, 1) {
for down in [true, false] {
guard let event = CGEvent(keyboardEventSource: source, virtualKey: CGKeyCode(code), keyDown: down) else { continue }
event.flags = flags
event.postToPid(pid)
}
}
return [:]
}
}
@@ -1,42 +0,0 @@
import AppKit
import ApplicationServices
import CoreGraphics
enum Permissions {
static func status() -> JSON {
["accessibility": AXIsProcessTrusted(), "screen_recording": CGPreflightScreenCaptureAccess(), "screen_locked": Screen.locked()]
}
// Asks the system to show its own prompt, and opens the pane where the
// switch lives: a prompt that was dismissed once is not shown again.
static func request(_ which: String) throws -> JSON {
let pane: String
switch which {
case "accessibility":
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
pane = "Privacy_Accessibility"
case "screen_recording":
_ = CGRequestScreenCaptureAccess()
pane = "Privacy_ScreenCapture"
default:
throw Failure(code: "computer.bad_request", message: "which must be accessibility or screen_recording")
}
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") {
NSWorkspace.shared.open(url)
}
return status()
}
static func requireAccessibility() throws {
if !AXIsProcessTrusted() {
throw Failure(code: "computer.permission_missing", message: "accessibility")
}
}
static func requireScreenRecording() throws {
if !CGPreflightScreenCaptureAccess() {
throw Failure(code: "computer.permission_missing", message: "screen_recording")
}
}
}
@@ -1,127 +0,0 @@
import AppKit
import CoreGraphics
// The person's own pointer, moved and clicked by the agent. Every other path in
// this helper works through accessibility and leaves the pointer where it is;
// this is the one that does not, so the kernel asks for it by name and it hands
// the pointer back where it found it.
//
// A point is acted on only while the window under it belongs to the application
// that was approved: on screen the agent has no window of its own to aim at.
enum Pointer {
private static var parked: CGPoint?
static func owner(of point: CGPoint) -> pid_t? {
let list = (CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]) ?? []
for window in list {
guard let layer = window[kCGWindowLayer as String] as? Int, layer == 0,
let pid = window[kCGWindowOwnerPID as String] as? pid_t, pid != getpid(),
let bounds = window[kCGWindowBounds as String] as? [String: CGFloat],
let x = bounds["X"], let y = bounds["Y"], let w = bounds["Width"], let h = bounds["Height"] else { continue }
if CGRect(x: x, y: y, width: w, height: h).contains(point) { return pid }
}
return nil
}
// front brings the application forward before the pointer acts on it. A
// click into a background window is spent on activating it — macOS gives the
// view nothing — so taking the pointer means taking the screen with it.
static func front(_ pid: pid_t) {
guard let app = NSRunningApplication(processIdentifier: pid), !app.isActive else { return }
app.activate(options: [.activateAllWindows])
Thread.sleep(forTimeInterval: 0.35)
}
static func check(_ pid: pid_t, _ point: CGPoint) throws {
guard let at = owner(of: point) else {
throw Failure(code: "computer.no_element", message: "no window is at (\(Int(point.x)), \(Int(point.y)))")
}
guard at == pid else {
throw Failure(code: "computer.no_element", message: "the window at (\(Int(point.x)), \(Int(point.y))) belongs to another application")
}
}
// park remembers where the person left their pointer, once per run of steps.
static func park() {
if parked == nil { parked = CGEvent(source: nil)?.location }
}
// release puts the pointer back where the person left it.
static func release() -> JSON {
defer { parked = nil }
guard let home = parked else { return [:] }
post(.mouseMoved, home, .left, 0)
return ["returned": true]
}
// position is where the pointer is now, which is what a run that has moved
// it has to be able to read back.
static func position() -> JSON {
let at = CGEvent(source: nil)?.location ?? .zero
return ["x": at.x, "y": at.y]
}
static func move(pid: pid_t, to point: CGPoint, cursor: VirtualCursor) throws -> JSON {
try Permissions.requireAccessibility()
try check(pid, point)
park()
cursor.move(to: point)
post(.mouseMoved, point, .left, 0)
return [:]
}
static func click(pid: pid_t, at point: CGPoint, button: String, clicks: Int, cursor: VirtualCursor) throws -> JSON {
try Permissions.requireAccessibility()
try check(pid, point)
park()
front(pid)
cursor.move(to: point)
let (down, up, which) = try events(for: button)
post(.mouseMoved, point, which, 0)
for click in 1...max(clicks, 1) {
post(down, point, which, click)
post(up, point, which, click)
}
return [:]
}
static func drag(pid: pid_t, from: CGPoint, to: CGPoint, cursor: VirtualCursor) throws -> JSON {
try Permissions.requireAccessibility()
try check(pid, from)
try check(pid, to)
park()
front(pid)
cursor.move(to: from)
post(.mouseMoved, from, .left, 0)
post(.leftMouseDown, from, .left, 1)
for step in 1...dragSteps {
let at = CGFloat(step) / CGFloat(dragSteps)
post(.leftMouseDragged, CGPoint(x: from.x + (to.x - from.x) * at, y: from.y + (to.y - from.y) * at), .left, 1)
Thread.sleep(forTimeInterval: 0.02)
}
cursor.move(to: to)
post(.leftMouseUp, to, .left, 1)
return [:]
}
private static let dragSteps = 12
private static func events(for button: String) throws -> (CGEventType, CGEventType, CGMouseButton) {
switch button {
case "", "left":
return (.leftMouseDown, .leftMouseUp, .left)
case "right":
return (.rightMouseDown, .rightMouseUp, .right)
case "middle":
return (.otherMouseDown, .otherMouseUp, .center)
default:
throw Failure(code: "computer.bad_step", message: "\(button) is not a mouse button; use left, right or middle")
}
}
private static func post(_ type: CGEventType, _ point: CGPoint, _ button: CGMouseButton, _ clicks: Int) {
guard let event = CGEvent(mouseEventSource: CGEventSource(stateID: .combinedSessionState), mouseType: type, mouseCursorPosition: point, mouseButton: button) else { return }
if clicks > 1 { event.setIntegerValueField(.mouseEventClickState, value: Int64(clicks)) }
event.post(tap: .cghidEventTap)
}
}
@@ -1,25 +0,0 @@
import Darwin
import Foundation
enum Processes {
// Other processes running this same executable. Measured on macOS 15: once
// one of them has captured a window, a capture in any other never returns,
// for any application and with no expiry, until that process exits. It is
// what a capture that ran out of time has to say for itself.
static func siblings() -> [pid_t] {
let me = getpid()
guard let mine = path(of: me) else { return [] }
var pids = [pid_t](repeating: 0, count: 8192)
let bytes = proc_listpids(UInt32(PROC_ALL_PIDS), 0, &pids, Int32(pids.count * MemoryLayout<pid_t>.size))
guard bytes > 0 else { return [] }
return pids[0..<(Int(bytes) / MemoryLayout<pid_t>.size)]
.filter { $0 > 0 && $0 != me && path(of: $0) == mine }
}
private static func path(of pid: pid_t) -> String? {
// PROC_PIDPATHINFO_MAXSIZE, which libproc.h does not export to Swift.
var buffer = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN))
guard proc_pidpath(pid, &buffer, UInt32(buffer.count)) > 0 else { return nil }
return String(cString: buffer)
}
}
@@ -1,14 +0,0 @@
import AppKit
import CoreGraphics
// Whether the person's screen is locked. While it is, macOS exposes no
// application window through accessibility — every application answers with its
// own element in place of its windows — so nothing can be read or clicked by
// ref or point, however healthy the application is. Typing, keys and screen
// captures still go through.
enum Screen {
static func locked() -> Bool {
guard let session = CGSessionCopyCurrentDictionary() as? [String: Any] else { return false }
return (session["CGSSessionScreenIsLocked"] as? Bool) ?? false
}
}
-149
View File
@@ -1,149 +0,0 @@
import AppKit
import Foundation
// The computer-use helper: the one process in Studio that reads other
// applications and puts input into them. It decides nothing. The kernel chooses
// every target and step after its own permission check, and this carries each
// out, answering one JSON line per request line on stdin.
struct Failure: Error {
let code: String
let message: String
}
typealias JSON = [String: Any]
let refs = RefTable()
let cursor = VirtualCursor()
func pointParam(_ params: JSON) throws -> CGPoint {
CGPoint(x: try numberParam(params, "x"), y: try numberParam(params, "y"))
}
func handle(_ method: String, _ params: JSON) throws -> JSON {
switch method {
case "status":
return Permissions.status()
case "request_permission":
return try Permissions.request(params["which"] as? String ?? "")
case "apps":
return ["apps": Apps.list()]
case "snapshot":
return try Accessibility.snapshot(pid: try pidParam(params), refs: refs)
case "press":
return try Accessibility.press(pid: try pidParam(params), ref: try stringParam(params, "ref"), refs: refs, cursor: cursor)
case "click":
return try Accessibility.click(pid: try pidParam(params), x: try numberParam(params, "x"), y: try numberParam(params, "y"), cursor: cursor)
case "menu":
return try Accessibility.menu(pid: try pidParam(params), ref: try stringParam(params, "ref"), refs: refs, cursor: cursor)
case "scroll":
return try Accessibility.scroll(pid: try pidParam(params), ref: (params["ref"] as? String) ?? "", amount: (params["amount"] as? Double) ?? 0, refs: refs, cursor: cursor)
case "focus":
return try Accessibility.focus(pid: try pidParam(params), ref: try stringParam(params, "ref"), refs: refs, cursor: cursor)
case "set_value":
return try Accessibility.setValue(pid: try pidParam(params), ref: try stringParam(params, "ref"), text: try stringParam(params, "text"), refs: refs, cursor: cursor)
case "type":
return try Keyboard.type(pid: try pidParam(params), text: try stringParam(params, "text"))
case "key":
return try Keyboard.press(pid: try pidParam(params), chord: try stringParam(params, "key"), times: (params["times"] as? Int) ?? 1)
case "paste":
return try Clipboard.paste(pid: try pidParam(params), text: try stringParam(params, "text"))
case "pointer_move":
return try Pointer.move(pid: try pidParam(params), to: try pointParam(params), cursor: cursor)
case "pointer_click":
return try Pointer.click(pid: try pidParam(params), at: try pointParam(params), button: (params["button"] as? String) ?? "left", clicks: (params["clicks"] as? Int) ?? 1, cursor: cursor)
case "pointer_drag":
return try Pointer.drag(pid: try pidParam(params), from: try pointParam(params), to: CGPoint(x: try numberParam(params, "to_x"), y: try numberParam(params, "to_y")), cursor: cursor)
case "pointer_position":
return Pointer.position()
case "pointer_release":
cursor.hide()
return Pointer.release()
case "hold_key":
return try Keyboard.hold(pid: try pidParam(params), chord: try stringParam(params, "key"), seconds: (params["seconds"] as? Double) ?? 1)
case "cursor_hide":
cursor.hide()
return [:]
default:
throw Failure(code: "computer.bad_request", message: "unknown method \(method)")
}
}
func pidParam(_ params: JSON) throws -> pid_t {
guard let n = params["pid"] as? NSNumber, n.int32Value > 0 else {
throw Failure(code: "computer.bad_request", message: "pid is required")
}
return n.int32Value
}
func stringParam(_ params: JSON, _ key: String) throws -> String {
guard let s = params[key] as? String else {
throw Failure(code: "computer.bad_request", message: "\(key) is required")
}
return s
}
func numberParam(_ params: JSON, _ key: String) throws -> Double {
guard let n = params[key] as? NSNumber else {
throw Failure(code: "computer.bad_request", message: "\(key) is required")
}
return n.doubleValue
}
// A capture answers from whichever thread it finished on, so the one stream
// every answer goes down is written under a lock of its own.
let stdoutLock = NSLock()
func reply(_ body: JSON) {
guard let data = try? JSONSerialization.data(withJSONObject: body), var line = String(data: data, encoding: .utf8) else { return }
line += "\n"
stdoutLock.lock()
FileHandle.standardOutput.write(line.data(using: .utf8)!)
stdoutLock.unlock()
}
func fail(_ id: Any, _ error: Error) {
if let failure = error as? Failure {
reply(["id": id, "error": ["code": failure.code, "message": failure.message]])
} else {
reply(["id": id, "error": ["code": "computer.failed", "message": "\(error)"]])
}
}
// Requests are read off the main thread and answered on it: the overlay and the
// accessibility calls it animates alongside both belong to the main run loop.
Thread.detachNewThread {
while let line = readLine() {
guard let data = line.data(using: .utf8),
let request = (try? JSONSerialization.jsonObject(with: data)) as? JSON,
let id = request["id"] else { continue }
let method = request["method"] as? String ?? ""
let params = request["params"] as? JSON ?? [:]
DispatchQueue.main.async {
do {
// A capture is the one operation this process waits on rather
// than performs, and the main thread is what the rest of them
// need: it answers when it is done, out of this order.
if method == "screenshot" {
try Capture.window(pid: try pidParam(params)) { outcome in
switch outcome {
case .success(let body): reply(["id": id, "result": body])
case .failure(let failure): fail(id, failure)
}
}
return
}
reply(["id": id, "result": try handle(method, params)])
} catch {
fail(id, error)
}
}
}
// Behind every request already queued: stdin closing is the kernel letting
// go, and what it asked before that is still owed an answer.
DispatchQueue.main.async { exit(0) }
}
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
app.run()
-41
View File
@@ -1,41 +0,0 @@
#include "helper.h"
#include <map>
// listApps answers the applications a person can see, each with its windows on
// screen in physical pixels. A minimized application is listed with none.
Json listApps() {
struct Walk { std::vector<DWORD> order; std::map<DWORD, bool> listed; } w;
EnumWindows([](HWND hwnd, LPARAM l) -> BOOL {
auto* w = reinterpret_cast<Walk*>(l);
if (!appListed(hwnd)) return TRUE;
DWORD pid = contentPid(hwnd);
if (pid == 0 || pid == GetCurrentProcessId() || w->listed.count(pid)) return TRUE;
w->listed[pid] = true;
w->order.push_back(pid);
return TRUE;
}, reinterpret_cast<LPARAM>(&w));
HWND fg = GetForegroundWindow();
DWORD active = fg ? contentPid(fg) : 0;
Json apps = Json::array();
for (DWORD pid : w.order) {
Json windows = Json::array();
for (HWND hwnd : appWindows(pid, false)) {
wchar_t title[512];
int n = GetWindowTextW(hwnd, title, ARRAYSIZE(title));
windows.push(Json::object()
.set("id", static_cast<long long>(reinterpret_cast<uintptr_t>(hwnd)))
.set("title", narrow(std::wstring(title, n > 0 ? n : 0)))
.set("bounds", rectJson(windowBounds(hwnd))));
}
apps.push(Json::object()
.set("pid", pid)
.set("bundle", identity(pid))
.set("exe", exeName(pid))
.set("name", displayName(pid))
.set("active", pid == active)
.set("windows", windows));
}
return apps;
}
-147
View File
@@ -1,147 +0,0 @@
#include "helper.h"
#include <wincodec.h>
#include <wrl/client.h>
#include <algorithm>
#ifndef PW_RENDERFULLCONTENT
#define PW_RENDERFULLCONTENT 0x00000002
#endif
using Microsoft::WRL::ComPtr;
namespace {
std::string base64(const BYTE* data, size_t n) {
static const char table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
out.reserve((n + 2) / 3 * 4);
for (size_t i = 0; i < n; i += 3) {
uint32_t v = data[i] << 16;
if (i + 1 < n) v |= data[i + 1] << 8;
if (i + 2 < n) v |= data[i + 2];
out += table[(v >> 18) & 63];
out += table[(v >> 12) & 63];
out += i + 1 < n ? table[(v >> 6) & 63] : '=';
out += i + 2 < n ? table[v & 63] : '=';
}
return out;
}
void check(HRESULT hr, const char* what) {
if (FAILED(hr)) {
char buf[96];
snprintf(buf, sizeof buf, "%s failed (HRESULT 0x%08lx)", what, static_cast<unsigned long>(hr));
throw Failure{"computer.capture_failed", buf};
}
}
// jpeg encodes BGRX rows at quality 0.8, the macOS helper's setting.
std::vector<BYTE> jpeg(const BYTE* pixels, UINT width, UINT height, UINT stride) {
ComPtr<IWICImagingFactory> factory;
check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&factory)), "creating the image encoder");
ComPtr<IWICBitmap> bitmap;
check(factory->CreateBitmapFromMemory(width, height, GUID_WICPixelFormat32bppBGR, stride, stride * (height - 1) + width * 4,
const_cast<BYTE*>(pixels), &bitmap), "reading the capture");
ComPtr<IWICFormatConverter> converted;
check(factory->CreateFormatConverter(&converted), "converting the capture");
check(converted->Initialize(bitmap.Get(), GUID_WICPixelFormat24bppBGR, WICBitmapDitherTypeNone, nullptr, 0,
WICBitmapPaletteTypeCustom), "converting the capture");
ComPtr<IStream> stream;
check(CreateStreamOnHGlobal(nullptr, TRUE, &stream), "allocating the image");
ComPtr<IWICBitmapEncoder> encoder;
check(factory->CreateEncoder(GUID_ContainerFormatJpeg, nullptr, &encoder), "creating the JPEG encoder");
check(encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache), "starting the JPEG");
ComPtr<IWICBitmapFrameEncode> frame;
ComPtr<IPropertyBag2> props;
check(encoder->CreateNewFrame(&frame, &props), "starting the JPEG");
PROPBAG2 option{};
option.pstrName = const_cast<LPOLESTR>(L"ImageQuality");
VARIANT quality;
VariantInit(&quality);
quality.vt = VT_R4;
quality.fltVal = 0.8f;
props->Write(1, &option, &quality);
check(frame->Initialize(props.Get()), "starting the JPEG");
check(frame->SetSize(width, height), "sizing the JPEG");
WICPixelFormatGUID format = GUID_WICPixelFormat24bppBGR;
check(frame->SetPixelFormat(&format), "formatting the JPEG");
check(frame->WriteSource(converted.Get(), nullptr), "encoding the JPEG");
check(frame->Commit(), "encoding the JPEG");
check(encoder->Commit(), "encoding the JPEG");
STATSTG stat{};
check(stream->Stat(&stat, STATFLAG_NONAME), "measuring the JPEG");
std::vector<BYTE> out(static_cast<size_t>(stat.cbSize.QuadPart));
LARGE_INTEGER zero{};
check(stream->Seek(zero, STREAM_SEEK_SET, nullptr), "reading the JPEG");
ULONG read = 0;
check(stream->Read(out.data(), static_cast<ULONG>(out.size()), &read), "reading the JPEG");
out.resize(read);
return out;
}
struct Dc {
HDC screen = GetDC(nullptr);
HDC mem = CreateCompatibleDC(screen);
HBITMAP bmp = nullptr;
~Dc() {
if (bmp) DeleteObject(bmp);
DeleteDC(mem);
ReleaseDC(nullptr, screen);
}
};
} // namespace
// capture renders an application's front window by itself, whatever covers
// it. bounds is where the visible frame sits on screen, which is what a point
// in the image maps back to.
Json capture(DWORD pid) {
std::vector<HWND> wins = appWindows(pid, false);
if (wins.empty()) throw Failure{"computer.no_window", "the application has no window on screen"};
HWND hwnd = wins[0];
RECT full{};
GetWindowRect(hwnd, &full);
RECT seen = windowBounds(hwnd);
int fw = full.right - full.left, fh = full.bottom - full.top;
if (fw <= 1 || fh <= 1) throw Failure{"computer.no_window", "the application has no window on screen"};
Dc dc;
BITMAPINFO bi{};
bi.bmiHeader.biSize = sizeof bi.bmiHeader;
bi.bmiHeader.biWidth = fw;
bi.bmiHeader.biHeight = -fh;
bi.bmiHeader.biPlanes = 1;
bi.bmiHeader.biBitCount = 32;
bi.bmiHeader.biCompression = BI_RGB;
void* bits = nullptr;
dc.bmp = CreateDIBSection(dc.mem, &bi, DIB_RGB_COLORS, &bits, nullptr, 0);
if (!dc.bmp) throw Failure{"computer.capture_failed", "no memory for a capture of this size"};
HGDIOBJ previous = SelectObject(dc.mem, dc.bmp);
BOOL printed = PrintWindow(hwnd, dc.mem, PW_RENDERFULLCONTENT);
SelectObject(dc.mem, previous);
if (!printed) {
throw Failure{"computer.capture_failed", screenLocked() ? "the screen is locked, and a locked screen renders no window"
: "the window would not render itself for a capture"};
}
int left = std::clamp<int>(seen.left - full.left, 0, fw - 1);
int top = std::clamp<int>(seen.top - full.top, 0, fh - 1);
int width = std::clamp<int>(seen.right - seen.left, 1, fw - left);
int height = std::clamp<int>(seen.bottom - seen.top, 1, fh - top);
UINT stride = static_cast<UINT>(fw) * 4;
const BYTE* origin = static_cast<const BYTE*>(bits) + static_cast<size_t>(top) * stride + static_cast<size_t>(left) * 4;
std::vector<BYTE> encoded = jpeg(origin, width, height, stride);
RECT placed{full.left + left, full.top + top, full.left + left + width, full.top + top + height};
return Json::object()
.set("data", base64(encoded.data(), encoded.size()))
.set("mime", "image/jpeg")
.set("width", width)
.set("height", height)
.set("window", static_cast<long long>(reinterpret_cast<uintptr_t>(hwnd)))
.set("bounds", rectJson(placed));
}
@@ -1,118 +0,0 @@
#include "helper.h"
#include <chrono>
#include <cstring>
#include <mutex>
#include <thread>
// paste puts text where the person is typing without typing it: a long value
// arrives at once, and an application that completes or reformats what it is
// given sees one insertion instead of a hundred keystrokes.
//
// The clipboard is the person's. What was on it goes back, unless something
// else claimed it while this ran — then theirs is the newer one and stays.
namespace {
struct Saved {
UINT format;
std::vector<BYTE> data;
};
std::mutex restoring;
std::thread pending;
bool open() {
for (int i = 0; i < 20; i++) {
if (OpenClipboard(nullptr)) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(25));
}
return false;
}
// Formats held as GDI handles rather than memory cannot be copied byte for
// byte; each has a memory twin Windows synthesises it back from.
bool copyable(UINT format) {
return format != CF_BITMAP && format != CF_ENHMETAFILE && format != CF_METAFILEPICT && format != CF_PALETTE &&
format != CF_OWNERDISPLAY && format != CF_DSPBITMAP && format != CF_DSPENHMETAFILE && format != CF_DSPMETAFILEPICT;
}
std::vector<Saved> save() {
std::vector<Saved> out;
for (UINT f = EnumClipboardFormats(0); f; f = EnumClipboardFormats(f)) {
if (!copyable(f)) continue;
HANDLE h = GetClipboardData(f);
if (!h) continue;
SIZE_T n = GlobalSize(h);
const BYTE* p = static_cast<const BYTE*>(GlobalLock(h));
if (!p) continue;
out.push_back({f, std::vector<BYTE>(p, p + n)});
GlobalUnlock(h);
}
return out;
}
void put(UINT format, const void* data, SIZE_T n) {
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, n ? n : 1);
if (!h) return;
void* p = GlobalLock(h);
if (n) memcpy(p, data, n);
GlobalUnlock(h);
if (!SetClipboardData(format, h)) GlobalFree(h);
}
// The agent's text stays out of the person's clipboard history and cloud sync.
void keepPrivate() {
DWORD no = 0;
put(RegisterClipboardFormatW(L"CanIncludeInClipboardHistory"), &no, sizeof no);
put(RegisterClipboardFormatW(L"CanUploadToCloudClipboard"), &no, sizeof no);
put(RegisterClipboardFormatW(L"ExcludeClipboardContentFromMonitorProcessing"), &no, sizeof no);
}
void restore(std::vector<Saved> saved, DWORD written) {
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
if (GetClipboardSequenceNumber() != written || !open()) return;
if (GetClipboardSequenceNumber() != written) {
CloseClipboard();
return;
}
EmptyClipboard();
for (const Saved& s : saved) put(s.format, s.data.data(), s.data.size());
CloseClipboard();
}
} // namespace
void clipboardSettle() {
std::lock_guard<std::mutex> hold(restoring);
if (pending.joinable()) pending.join();
}
Json paste(DWORD pid, const std::string& text) {
front(pid);
Landing at = landing(pid, true);
refuseHeldText(at);
clipboardSettle();
if (!open()) throw Failure{"computer.failed", "another application is holding the clipboard"};
std::vector<Saved> saved = save();
EmptyClipboard();
std::wstring w = widen(text);
put(CF_UNICODETEXT, w.c_str(), (w.size() + 1) * sizeof(wchar_t));
keepPrivate();
CloseClipboard();
// However this returns, the person's clipboard is owed back.
struct GiveBack {
std::vector<Saved> saved;
DWORD written;
~GiveBack() {
std::lock_guard<std::mutex> hold(restoring);
pending = std::thread(restore, std::move(saved), written);
}
} giveBack{std::move(saved), GetClipboardSequenceNumber()};
if (!isFront(pid)) throw Failure{"computer.needs_front", "the application left the front before the paste; nothing was pasted"};
sendChord("control+v");
// The application reads the clipboard when it gets round to the keystroke,
// and giving it back before then hands it the person's clipboard instead.
std::this_thread::sleep_for(std::chrono::milliseconds(250));
return typedEffect(at, w);
}
-185
View File
@@ -1,185 +0,0 @@
#include "helper.h"
#include <objidl.h>
#include <gdiplus.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <thread>
// The agent's own pointer: drawn over everything, taking no clicks and no
// focus, so a person can see where it acts without it touching theirs. It lives
// on a thread of its own because the window and the Escape hook both need a
// message loop, and the request loop is busy doing the work it shows.
namespace {
constexpr UINT moveMessage = WM_APP + 1;
constexpr UINT hideMessage = WM_APP + 2;
constexpr UINT stopMessage = WM_APP + 3;
constexpr UINT_PTR glideTimer = 1, hideTimer = 2;
constexpr int glideMs = 250;
HWND overlay = nullptr;
HHOOK escHook = nullptr;
std::atomic<bool> showing{false};
float scale = 1;
int width = 120, height = 44;
POINT at{}, from{}, to{};
ULONGLONG glideStart = 0;
void render() {
BITMAPINFO bi{};
bi.bmiHeader.biSize = sizeof bi.bmiHeader;
bi.bmiHeader.biWidth = width;
bi.bmiHeader.biHeight = -height;
bi.bmiHeader.biPlanes = 1;
bi.bmiHeader.biBitCount = 32;
bi.bmiHeader.biCompression = BI_RGB;
void* bits = nullptr;
HDC screen = GetDC(nullptr);
HDC mem = CreateCompatibleDC(screen);
HBITMAP dib = CreateDIBSection(mem, &bi, DIB_RGB_COLORS, &bits, nullptr, 0);
HGDIOBJ old = SelectObject(mem, dib);
{
Gdiplus::Bitmap canvas(width, height, width * 4, PixelFormat32bppPARGB, static_cast<BYTE*>(bits));
Gdiplus::Graphics g(&canvas);
g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
g.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit);
g.ScaleTransform(scale, scale);
Gdiplus::Color indigo(255, 88, 86, 214);
Gdiplus::PointF arrow[] = {{2, 2}, {2, 22}, {7.5f, 17}, {11, 25}, {14, 23.5f}, {10.5f, 16}, {17, 16}};
Gdiplus::SolidBrush fill(indigo);
g.FillPolygon(&fill, arrow, 7);
Gdiplus::Pen outline(Gdiplus::Color(255, 255, 255, 255), 1.5f);
g.DrawPolygon(&outline, arrow, 7);
Gdiplus::FontFamily family(L"Segoe UI");
Gdiplus::Font font(&family, 11, Gdiplus::FontStyleBold, Gdiplus::UnitPixel);
Gdiplus::RectF size;
g.MeasureString(L"Reasonix", -1, &font, Gdiplus::PointF(0, 0), &size);
Gdiplus::RectF pill(16, 23, size.Width + 12, size.Height + 2);
Gdiplus::GraphicsPath round;
float r = pill.Height / 2;
round.AddArc(pill.X, pill.Y, r * 2, pill.Height, 90, 180);
round.AddArc(pill.X + pill.Width - r * 2, pill.Y, r * 2, pill.Height, 270, 180);
round.CloseFigure();
g.FillPath(&fill, &round);
Gdiplus::SolidBrush white(Gdiplus::Color(255, 255, 255, 255));
g.DrawString(L"Reasonix", -1, &font, Gdiplus::PointF(pill.X + 6, pill.Y + 1), &white);
}
POINT origin{at.x - static_cast<LONG>(2 * scale), at.y - static_cast<LONG>(2 * scale)};
SIZE extent{width, height};
POINT zero{0, 0};
BLENDFUNCTION blend{AC_SRC_OVER, 0, 255, AC_SRC_ALPHA};
UpdateLayeredWindow(overlay, screen, &origin, &extent, mem, &zero, 0, &blend, ULW_ALPHA);
SelectObject(mem, old);
DeleteObject(dib);
DeleteDC(mem);
ReleaseDC(nullptr, screen);
}
void hide() {
KillTimer(overlay, glideTimer);
KillTimer(overlay, hideTimer);
ShowWindow(overlay, SW_HIDE);
showing = false;
if (escHook) {
UnhookWindowsHookEx(escHook);
escHook = nullptr;
}
}
// Escape while the agent's cursor is on screen is the person saying stop. Any
// other time it is their own keystroke, and one this helper injected never is.
LRESULT CALLBACK onKey(int code, WPARAM wParam, LPARAM lParam) {
if (code == HC_ACTION && (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN)) {
auto* k = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam);
// Answered off the hook: a hook that waits on stdout behind a large
// reply overruns its timeout, and Windows removes it for good.
if (k->vkCode == VK_ESCAPE && !(k->flags & LLKHF_INJECTED) && showing) PostMessageW(overlay, stopMessage, 0, 0);
}
return CallNextHookEx(nullptr, code, wParam, lParam);
}
LRESULT CALLBACK proc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
switch (msg) {
case moveMessage:
to = POINT{static_cast<LONG>(static_cast<int>(wParam)), static_cast<LONG>(static_cast<int>(lParam))};
if (!showing) {
at = from = to;
render();
ShowWindow(hwnd, SW_SHOWNOACTIVATE);
showing = true;
// Held only while the cursor shows: a system-wide hook costs every
// keystroke on the machine a hop through this thread.
if (!escHook) escHook = SetWindowsHookExW(WH_KEYBOARD_LL, onKey, GetModuleHandleW(nullptr), 0);
} else {
from = at;
glideStart = GetTickCount64();
SetTimer(hwnd, glideTimer, 15, nullptr);
}
SetTimer(hwnd, hideTimer, 4000, nullptr);
return 0;
case hideMessage:
hide();
return 0;
case stopMessage:
hide();
reply(Json::object().set("event", "stop"));
return 0;
case WM_TIMER:
if (wParam == hideTimer) {
hide();
} else if (wParam == glideTimer) {
double t = std::min(1.0, static_cast<double>(GetTickCount64() - glideStart) / glideMs);
double ease = t < 0.5 ? 2 * t * t : 1 - (2 - 2 * t) * (2 - 2 * t) / 2;
at = POINT{static_cast<LONG>(from.x + (to.x - from.x) * ease), static_cast<LONG>(from.y + (to.y - from.y) * ease)};
render();
if (t >= 1) KillTimer(hwnd, glideTimer);
}
return 0;
}
return DefWindowProcW(hwnd, msg, wParam, lParam);
}
void run(HANDLE ready) {
Gdiplus::GdiplusStartupInput input;
ULONG_PTR token = 0;
Gdiplus::GdiplusStartup(&token, &input, nullptr);
scale = GetDpiForSystem() / 96.0f;
width = static_cast<int>(120 * scale);
height = static_cast<int>(44 * scale);
WNDCLASSW wc{};
wc.lpfnWndProc = proc;
wc.hInstance = GetModuleHandleW(nullptr);
wc.lpszClassName = L"ReasonixAgentCursor";
RegisterClassW(&wc);
overlay = CreateWindowExW(WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
wc.lpszClassName, L"", WS_POPUP, 0, 0, width, height, nullptr, nullptr, wc.hInstance, nullptr);
SetEvent(ready);
MSG msg;
while (GetMessageW(&msg, nullptr, 0, 0) > 0) DispatchMessageW(&msg);
}
} // namespace
void cursorStart() {
HANDLE ready = CreateEventW(nullptr, TRUE, FALSE, nullptr);
std::thread(run, ready).detach();
WaitForSingleObject(ready, 5000);
CloseHandle(ready);
}
// cursorMove glides the agent's cursor to a point on screen and waits out the
// glide, so what the person sees lands before the action it shows.
void cursorMove(POINT point) {
if (!overlay) return;
PostMessageW(overlay, moveMessage, static_cast<WPARAM>(point.x), static_cast<LPARAM>(point.y));
std::this_thread::sleep_for(std::chrono::milliseconds(glideMs + 30));
}
void cursorHide() {
if (overlay) PostMessageW(overlay, hideMessage, 0, 0);
}
-107
View File
@@ -1,107 +0,0 @@
#pragma once
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#include <functional>
#include <string>
#include <vector>
#include "json.h"
// Failure is an operation that did not happen, answered with the same codes the
// macOS helper uses so the kernel reads both alike.
struct Failure {
std::string code;
std::string message;
Json blockedBy; // the modal that would have taken the input instead
};
std::string narrow(const std::wstring& w);
std::wstring widen(const std::string& s);
std::string lower(std::string s);
std::string num(double v);
DWORD pidParam(const Json& params);
std::string stringParam(const Json& params, const char* key);
double numberParam(const Json& params, const char* key);
double numberOr(const Json& params, const char* key, double fallback);
// main.cpp: one line to the kernel, from whichever thread has one to say.
void reply(const Json& body);
// cursor.cpp: the agent's own cursor, and Escape while it shows.
void cursorStart();
void cursorMove(POINT point);
void cursorHide();
// process.cpp: which application a process or window is, and its windows.
std::string identity(DWORD pid);
std::string exeName(DWORD pid);
std::string displayName(DWORD pid);
DWORD contentPid(HWND top);
std::vector<HWND> appWindows(DWORD pid, bool includeMinimized);
bool appListed(HWND top);
void requireOperable(DWORD pid);
bool ownsPoint(DWORD pid, POINT at);
bool isFront(DWORD pid);
HWND heldOwner(HWND window, DWORD pid);
void front(DWORD pid);
RECT windowBounds(HWND hwnd);
Json rectJson(const RECT& r);
// apps.cpp
Json listApps();
// screen.cpp
bool screenLocked();
Json status();
// uia.cpp
void uiaStart();
Json snapshot(DWORD pid);
Json press(DWORD pid, const std::string& ref);
Json clickAt(DWORD pid, double x, double y);
Json menu(DWORD pid, const std::string& ref);
Json scroll(DWORD pid, const std::string& ref, double amount);
Json focus(DWORD pid, const std::string& ref);
Json setValue(DWORD pid, const std::string& ref, const std::string& text);
// Landing is where keyboard input to an application goes, read after it is
// brought forward and before anything is sent: the modal holding it, if any,
// and, when withValue, the focused element with what it holds now.
struct Landing {
Json modal;
bool takesText = false;
bool readable = false;
std::wstring before;
std::function<bool(std::wstring&)> read;
};
Landing landing(DWORD pid, bool withValue);
// refuseHeldText refuses text that would land in a modal on an element that
// takes none, where a letter can be a dialog's mnemonic.
void refuseHeldText(const Landing& at);
// typedEffect grades typing on the focused element read again afterwards.
Json typedEffect(const Landing& at, const std::wstring& typed);
Json effect(const char* cls, const char* evidence, const Json& modal);
// keyboard.cpp
Json typeText(DWORD pid, const std::string& text);
Json pressKey(DWORD pid, const std::string& chord, int times);
Json holdKey(DWORD pid, const std::string& chord, double seconds);
void sendChord(const std::string& chord);
// pointer.cpp
Json pointerMove(DWORD pid, POINT to);
Json pointerClick(DWORD pid, POINT at, const std::string& button, int clicks);
Json pointerDrag(DWORD pid, POINT from, POINT to);
Json pointerPosition();
Json pointerRelease();
// capture.cpp
Json capture(DWORD pid);
// clipboard.cpp
Json paste(DWORD pid, const std::string& text);
void clipboardSettle();
-270
View File
@@ -1,270 +0,0 @@
#include "json.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
Json& Json::push(Json v) {
a_.push_back(std::move(v));
return *this;
}
Json& Json::set(const std::string& key, Json v) {
for (auto& [k, existing] : o_) {
if (k == key) {
existing = std::move(v);
return *this;
}
}
o_.emplace_back(key, std::move(v));
return *this;
}
const Json* Json::get(const std::string& key) const {
for (const auto& [k, v] : o_) {
if (k == key) return &v;
}
return nullptr;
}
std::string quote(const std::string& s) {
std::string out = "\"";
for (unsigned char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (c < 0x20) {
char buf[8];
std::snprintf(buf, sizeof buf, "\\u%04x", c);
out += buf;
} else {
out += static_cast<char>(c);
}
}
}
return out + "\"";
}
void Json::write(std::string& out) const {
switch (kind_) {
case Kind::Null: out += "null"; break;
case Kind::Bool: out += b_ ? "true" : "false"; break;
case Kind::Number: {
char buf[32];
if (!std::isfinite(n_)) {
out += "0";
} else if (n_ == std::floor(n_) && std::fabs(n_) < 1e15) {
std::snprintf(buf, sizeof buf, "%.0f", n_);
out += buf;
} else {
std::snprintf(buf, sizeof buf, "%.17g", n_);
out += buf;
}
break;
}
case Kind::String: out += quote(s_); break;
case Kind::Array:
out += '[';
for (size_t i = 0; i < a_.size(); i++) {
if (i) out += ',';
a_[i].write(out);
}
out += ']';
break;
case Kind::Object:
out += '{';
for (size_t i = 0; i < o_.size(); i++) {
if (i) out += ',';
out += quote(o_[i].first);
out += ':';
o_[i].second.write(out);
}
out += '}';
break;
}
}
std::string Json::dump() const {
std::string out;
write(out);
return out;
}
namespace {
struct Parser {
const std::string& t;
size_t i = 0;
int depth = 0;
void space() {
while (i < t.size() && (t[i] == ' ' || t[i] == '\t' || t[i] == '\n' || t[i] == '\r')) i++;
}
bool literal(const char* word) {
size_t n = std::char_traits<char>::length(word);
if (t.compare(i, n, word) != 0) return false;
i += n;
return true;
}
static void utf8(std::string& out, uint32_t cp) {
if (cp < 0x80) {
out += static_cast<char>(cp);
} else if (cp < 0x800) {
out += static_cast<char>(0xC0 | (cp >> 6));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else if (cp < 0x10000) {
out += static_cast<char>(0xE0 | (cp >> 12));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
} else {
out += static_cast<char>(0xF0 | (cp >> 18));
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
out += static_cast<char>(0x80 | (cp & 0x3F));
}
}
bool hex4(uint32_t& v) {
if (i + 4 > t.size()) return false;
v = 0;
for (int k = 0; k < 4; k++) {
char c = t[i++];
v <<= 4;
if (c >= '0' && c <= '9') v |= c - '0';
else if (c >= 'a' && c <= 'f') v |= c - 'a' + 10;
else if (c >= 'A' && c <= 'F') v |= c - 'A' + 10;
else return false;
}
return true;
}
bool str(std::string& out) {
if (i >= t.size() || t[i] != '"') return false;
i++;
while (i < t.size()) {
char c = t[i++];
if (c == '"') return true;
if (static_cast<unsigned char>(c) < 0x20) return false;
if (c != '\\') {
out += c;
continue;
}
if (i >= t.size()) return false;
switch (t[i++]) {
case '"': out += '"'; break;
case '\\': out += '\\'; break;
case '/': out += '/'; break;
case 'b': out += '\b'; break;
case 'f': out += '\f'; break;
case 'n': out += '\n'; break;
case 'r': out += '\r'; break;
case 't': out += '\t'; break;
case 'u': {
uint32_t cp;
if (!hex4(cp)) return false;
if (cp >= 0xD800 && cp < 0xDC00 && t.compare(i, 2, "\\u") == 0) {
size_t back = i;
i += 2;
uint32_t low;
if (hex4(low) && low >= 0xDC00 && low < 0xE000) {
cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00);
} else {
i = back;
cp = 0xFFFD;
}
} else if (cp >= 0xD800 && cp < 0xE000) {
cp = 0xFFFD;
}
utf8(out, cp);
break;
}
default: return false;
}
}
return false;
}
bool value(Json& out) {
if (++depth > 64) return false;
space();
if (i >= t.size()) return false;
char c = t[i];
bool ok = true;
if (c == '{') {
i++;
out = Json::object();
space();
if (i < t.size() && t[i] == '}') {
i++;
} else {
for (;;) {
space();
std::string key;
Json v;
if (!str(key)) { ok = false; break; }
space();
if (i >= t.size() || t[i++] != ':') { ok = false; break; }
if (!value(v)) { ok = false; break; }
out.set(key, std::move(v));
space();
if (i < t.size() && t[i] == ',') { i++; continue; }
if (i < t.size() && t[i] == '}') { i++; break; }
ok = false;
break;
}
}
} else if (c == '[') {
i++;
out = Json::array();
space();
if (i < t.size() && t[i] == ']') {
i++;
} else {
for (;;) {
Json v;
if (!value(v)) { ok = false; break; }
out.push(std::move(v));
space();
if (i < t.size() && t[i] == ',') { i++; continue; }
if (i < t.size() && t[i] == ']') { i++; break; }
ok = false;
break;
}
}
} else if (c == '"') {
std::string s;
ok = str(s);
out = Json(std::move(s));
} else if (literal("true")) {
out = Json(true);
} else if (literal("false")) {
out = Json(false);
} else if (literal("null")) {
out = Json();
} else {
const char* start = t.c_str() + i;
char* end = nullptr;
double n = std::strtod(start, &end);
ok = end != start;
i += end - start;
out = Json(n);
}
depth--;
return ok;
}
};
} // namespace
bool Json::parse(const std::string& text, Json& out) {
Parser p{text};
if (!p.value(out)) return false;
p.space();
return p.i == text.size();
}
-57
View File
@@ -1,57 +0,0 @@
#pragma once
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
// Json is the one value type the protocol needs: requests are parsed into it
// and every reply is built from it. Objects keep insertion order.
class Json {
public:
enum class Kind { Null, Bool, Number, String, Array, Object };
Json() = default;
Json(bool b) : kind_(Kind::Bool), b_(b) {}
Json(int n) : kind_(Kind::Number), n_(n) {}
Json(unsigned n) : kind_(Kind::Number), n_(n) {}
Json(long n) : kind_(Kind::Number), n_(n) {}
Json(unsigned long n) : kind_(Kind::Number), n_(n) {}
Json(long long n) : kind_(Kind::Number), n_(static_cast<double>(n)) {}
Json(unsigned long long n) : kind_(Kind::Number), n_(static_cast<double>(n)) {}
Json(double n) : kind_(Kind::Number), n_(n) {}
Json(const char* s) : kind_(Kind::String), s_(s) {}
Json(std::string s) : kind_(Kind::String), s_(std::move(s)) {}
static Json array() { Json j; j.kind_ = Kind::Array; return j; }
static Json object() { Json j; j.kind_ = Kind::Object; return j; }
Kind kind() const { return kind_; }
bool isNumber() const { return kind_ == Kind::Number; }
bool isString() const { return kind_ == Kind::String; }
bool isObject() const { return kind_ == Kind::Object; }
double number() const { return n_; }
const std::string& string() const { return s_; }
const std::vector<Json>& items() const { return a_; }
Json& push(Json v);
Json& set(const std::string& key, Json v);
const Json* get(const std::string& key) const;
std::string dump() const;
// parse reads one JSON text; false on anything malformed or trailing.
static bool parse(const std::string& text, Json& out);
private:
void write(std::string& out) const;
Kind kind_ = Kind::Null;
bool b_ = false;
double n_ = 0;
std::string s_;
std::vector<Json> a_;
std::vector<std::pair<std::string, Json>> o_;
};
// quote renders s as a JSON string literal.
std::string quote(const std::string& s);
@@ -1,201 +0,0 @@
#include "helper.h"
#include <algorithm>
#include <chrono>
#include <map>
#include <thread>
// Keyboard input on Windows goes wherever the foreground is, so every entry
// point brings the application forward first and checks it is still there
// between chunks: a person switching away must not receive the rest.
namespace {
struct Key {
WORD vk;
bool extended;
};
const std::map<std::string, Key> named = [] {
std::map<std::string, Key> keys = {
{"enter", {VK_RETURN, false}}, {"return", {VK_RETURN, false}}, {"tab", {VK_TAB, false}},
{"escape", {VK_ESCAPE, false}}, {"space", {VK_SPACE, false}}, {"backspace", {VK_BACK, false}},
{"delete", {VK_DELETE, true}}, {"insert", {VK_INSERT, true}}, {"arrowleft", {VK_LEFT, true}},
{"arrowright", {VK_RIGHT, true}}, {"arrowup", {VK_UP, true}}, {"arrowdown", {VK_DOWN, true}},
{"home", {VK_HOME, true}}, {"end", {VK_END, true}}, {"pageup", {VK_PRIOR, true}}, {"pagedown", {VK_NEXT, true}},
{"minus", {VK_OEM_MINUS, false}}, {"equal", {VK_OEM_PLUS, false}}, {"comma", {VK_OEM_COMMA, false}},
{"period", {VK_OEM_PERIOD, false}}, {"slash", {VK_OEM_2, false}}, {"semicolon", {VK_OEM_1, false}},
{"quote", {VK_OEM_7, false}}, {"bracketleft", {VK_OEM_4, false}}, {"bracketright", {VK_OEM_6, false}},
{"backslash", {VK_OEM_5, false}}, {"backquote", {VK_OEM_3, false}},
};
for (char c = 'a'; c <= 'z'; c++) keys[std::string(1, c)] = {static_cast<WORD>(c - 'a' + 'A'), false};
for (char c = '0'; c <= '9'; c++) keys[std::string(1, c)] = {static_cast<WORD>(c), false};
for (int n = 1; n <= 12; n++) keys["f" + std::to_string(n)] = {static_cast<WORD>(VK_F1 + n - 1), false};
return keys;
}();
const std::map<std::string, WORD> modifiers = {
{"shift", WORD{VK_SHIFT}}, {"control", WORD{VK_CONTROL}}, {"ctrl", WORD{VK_CONTROL}}, {"alt", WORD{VK_MENU}}, {"option", WORD{VK_MENU}},
};
struct KeyChord {
Key key;
std::vector<WORD> mods;
};
std::string trim(const std::string& s) {
size_t a = s.find_first_not_of(" \t"), b = s.find_last_not_of(" \t");
return a == std::string::npos ? "" : s.substr(a, b - a + 1);
}
KeyChord resolve(const std::string& chord) {
std::vector<std::string> parts;
size_t at = 0;
for (;;) {
size_t plus = chord.find('+', at);
parts.push_back(lower(trim(chord.substr(at, plus == std::string::npos ? std::string::npos : plus - at))));
if (plus == std::string::npos) break;
at = plus + 1;
}
auto found = named.find(parts.back());
if (found == named.end()) {
throw Failure{"computer.bad_step", chord + " is not a key this can press. It presses named keys — enter, tab, escape, space, backspace, delete, insert, the arrows, home, end, pageup, pagedown, f1 to f12, a letter, a digit, or minus, equal, comma, period, slash, semicolon, quote, bracketleft, bracketright, backslash, backquote — with the "
"modifiers shift, control and alt. Text goes through the type action"};
}
KeyChord c{found->second, {}};
for (size_t i = 0; i + 1 < parts.size(); i++) {
if (parts[i] == "meta" || parts[i] == "cmd" || parts[i] == "win") {
throw Failure{"computer.bad_step", "meta is the Windows key here, which the shell answers rather than the application; "
"Windows shortcuts use control, as in control+s"};
}
auto mod = modifiers.find(parts[i]);
if (mod == modifiers.end()) throw Failure{"computer.bad_step", parts[i] + " is not a modifier; use shift, control or alt"};
if (std::find(c.mods.begin(), c.mods.end(), mod->second) == c.mods.end()) c.mods.push_back(mod->second);
}
// These reach the shell's switcher and Start instead of the application.
bool alt = std::find(c.mods.begin(), c.mods.end(), VK_MENU) != c.mods.end();
bool ctrl = std::find(c.mods.begin(), c.mods.end(), VK_CONTROL) != c.mods.end();
if ((alt && (c.key.vk == VK_TAB || c.key.vk == VK_ESCAPE)) || (ctrl && c.key.vk == VK_ESCAPE)) {
throw Failure{"computer.bad_step", chord + " switches applications or opens Start; it is answered by Windows, not by this application"};
}
return c;
}
INPUT keyInput(WORD vk, bool extended, bool down) {
INPUT in{};
in.type = INPUT_KEYBOARD;
in.ki.wVk = vk;
in.ki.wScan = static_cast<WORD>(MapVirtualKeyW(vk, MAPVK_VK_TO_VSC));
in.ki.dwFlags = (down ? 0 : KEYEVENTF_KEYUP) | (extended ? KEYEVENTF_EXTENDEDKEY : 0);
return in;
}
INPUT unicodeInput(wchar_t unit, bool down) {
INPUT in{};
in.type = INPUT_KEYBOARD;
in.ki.wScan = unit;
in.ki.dwFlags = KEYEVENTF_UNICODE | (down ? 0 : KEYEVENTF_KEYUP);
return in;
}
void send(std::vector<INPUT>& inputs) {
if (inputs.empty()) return;
UINT sent = SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT));
inputs.clear();
if (sent == 0) {
throw Failure{"computer.failed", screenLocked() ? "the screen is locked, and Windows delivers no input while it is"
: "Windows refused the input"};
}
}
void chordDown(std::vector<INPUT>& out, const KeyChord& c) {
for (WORD m : c.mods) out.push_back(keyInput(m, false, true));
out.push_back(keyInput(c.key.vk, c.key.extended, true));
}
void chordUp(std::vector<INPUT>& out, const KeyChord& c) {
out.push_back(keyInput(c.key.vk, c.key.extended, false));
for (auto m = c.mods.rbegin(); m != c.mods.rend(); ++m) out.push_back(keyInput(*m, false, false));
}
void stillFront(DWORD pid, size_t done, size_t total, const char* unit) {
if (!isFront(pid)) {
throw Failure{"computer.needs_front", "the application left the front after " + std::to_string(done) + " of " +
std::to_string(total) + " " + unit + "; the rest did not go in"};
}
}
} // namespace
Json typeText(DWORD pid, const std::string& text) {
std::wstring units = widen(text);
front(pid);
Landing at = landing(pid, true);
refuseHeldText(at);
std::vector<INPUT> batch;
size_t chunk = 0;
for (size_t i = 0; i < units.size(); i++) {
if (chunk == 0) stillFront(pid, i, units.size(), "characters");
wchar_t u = units[i];
if (u == L'\r' && i + 1 < units.size() && units[i + 1] == L'\n') continue;
if (u == L'\n' || u == L'\r' || u == L'\t') {
WORD vk = u == L'\t' ? VK_TAB : VK_RETURN;
batch.push_back(keyInput(vk, false, true));
batch.push_back(keyInput(vk, false, false));
} else {
batch.push_back(unicodeInput(u, true));
batch.push_back(unicodeInput(u, false));
}
if (++chunk == 4) {
send(batch);
chunk = 0;
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
}
send(batch);
return typedEffect(at, units);
}
Json pressKey(DWORD pid, const std::string& chord, int times) {
KeyChord c = resolve(chord);
front(pid);
Json modal = landing(pid, false).modal;
std::vector<INPUT> batch;
for (int i = 0; i < std::clamp(times, 1, 200); i++) {
stillFront(pid, i, times, "presses");
chordDown(batch, c);
chordUp(batch, c);
send(batch);
}
return effect("unverifiable", nullptr, modal);
}
// holdKey keeps a key down, which is what a game or a scrubbing control reads
// rather than a press. The key always comes back up.
Json holdKey(DWORD pid, const std::string& chord, double seconds) {
KeyChord c = resolve(chord);
front(pid);
Json modal = landing(pid, false).modal;
std::vector<INPUT> batch;
chordDown(batch, c);
send(batch);
// Held keys belong to whatever is in front, so leaving the front ends the
// hold at once rather than adding modifiers to the person's own typing.
auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(static_cast<long long>(std::clamp(seconds, 0.0, 30.0) * 1000));
bool left = false;
while (std::chrono::steady_clock::now() < until && !(left = !isFront(pid))) {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
chordUp(batch, c);
send(batch);
if (left) throw Failure{"computer.needs_front", "the application left the front during the hold; the key was released early"};
return effect("unverifiable", nullptr, modal);
}
void sendChord(const std::string& chord) {
KeyChord c = resolve(chord);
std::vector<INPUT> batch;
chordDown(batch, c);
chordUp(batch, c);
send(batch);
}
-170
View File
@@ -1,170 +0,0 @@
// The computer-use helper for Windows: the one process in Studio that reads
// other applications and puts input into them. It decides nothing. The kernel
// chooses every target and step after its own permission check, and this
// carries each out, answering one JSON line per request line on stdin.
#include "helper.h"
#include <fcntl.h>
#include <io.h>
#include <objbase.h>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <iostream>
#include <mutex>
#include <set>
std::string narrow(const std::wstring& w) {
if (w.empty()) return {};
int n = WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast<int>(w.size()), nullptr, 0, nullptr, nullptr);
std::string out(n, '\0');
WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast<int>(w.size()), out.data(), n, nullptr, nullptr);
return out;
}
std::wstring widen(const std::string& s) {
if (s.empty()) return {};
int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), nullptr, 0);
std::wstring out(n, L'\0');
MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), out.data(), n);
return out;
}
std::string lower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return s;
}
std::string num(double v) {
char buf[32];
std::snprintf(buf, sizeof buf, "%.0f", v);
return buf;
}
DWORD pidParam(const Json& params) {
const Json* v = params.get("pid");
if (!v || !v->isNumber() || v->number() <= 0 || v->number() > 0xFFFFFFFF) {
throw Failure{"computer.bad_request", "pid is required"};
}
return static_cast<DWORD>(v->number());
}
std::string stringParam(const Json& params, const char* key) {
const Json* v = params.get(key);
if (!v || !v->isString()) throw Failure{"computer.bad_request", std::string(key) + " is required"};
return v->string();
}
double numberParam(const Json& params, const char* key) {
const Json* v = params.get(key);
if (!v || !v->isNumber()) throw Failure{"computer.bad_request", std::string(key) + " is required"};
return v->number();
}
double numberOr(const Json& params, const char* key, double fallback) {
const Json* v = params.get(key);
return v && v->isNumber() ? v->number() : fallback;
}
static POINT pointParam(const Json& params, const char* x, const char* y) {
return POINT{static_cast<LONG>(std::lround(numberParam(params, x))), static_cast<LONG>(std::lround(numberParam(params, y)))};
}
// The kernel's stop event and a request's answer can come from different
// threads, so the one stream every line goes down is written under a lock.
static std::mutex stdoutLock;
void reply(const Json& body) {
std::string line = body.dump() + "\n";
std::lock_guard<std::mutex> hold(stdoutLock);
std::fwrite(line.data(), 1, line.size(), stdout);
std::fflush(stdout);
}
static Json handle(const std::string& method, const Json& params) {
if (method == "status") return status();
if (method == "request_permission") {
std::string which = params.get("which") && params.get("which")->isString() ? params.get("which")->string() : "";
if (which != "accessibility" && which != "screen_recording") {
throw Failure{"computer.bad_request", "which must be accessibility or screen_recording"};
}
return status();
}
if (method == "apps") return Json::object().set("apps", listApps());
if (method == "pointer_position") return pointerPosition();
if (method == "pointer_release") {
cursorHide();
return pointerRelease();
}
if (method == "cursor_hide") {
cursorHide();
return Json::object();
}
static const std::set<std::string> perApp = {"snapshot", "screenshot", "press", "click", "menu", "scroll", "focus", "set_value",
"type", "key", "hold_key", "paste", "pointer_move", "pointer_click", "pointer_drag"};
if (!perApp.count(method)) throw Failure{"computer.bad_request", "unknown method " + method};
DWORD pid = pidParam(params);
requireOperable(pid);
if (method == "snapshot") return snapshot(pid);
if (method == "screenshot") return capture(pid);
if (method == "press") return press(pid, stringParam(params, "ref"));
if (method == "click") return clickAt(pid, numberParam(params, "x"), numberParam(params, "y"));
if (method == "menu") return menu(pid, stringParam(params, "ref"));
if (method == "scroll") {
std::string ref = params.get("ref") && params.get("ref")->isString() ? params.get("ref")->string() : "";
return scroll(pid, ref, numberOr(params, "amount", 0));
}
if (method == "focus") return focus(pid, stringParam(params, "ref"));
if (method == "set_value") return setValue(pid, stringParam(params, "ref"), stringParam(params, "text"));
if (method == "type") return typeText(pid, stringParam(params, "text"));
if (method == "key") return pressKey(pid, stringParam(params, "key"), static_cast<int>(numberOr(params, "times", 1)));
if (method == "hold_key") return holdKey(pid, stringParam(params, "key"), numberOr(params, "seconds", 1));
if (method == "paste") return paste(pid, stringParam(params, "text"));
if (method == "pointer_move") return pointerMove(pid, pointParam(params, "x", "y"));
if (method == "pointer_click") {
std::string button = params.get("button") && params.get("button")->isString() ? params.get("button")->string() : "left";
return pointerClick(pid, pointParam(params, "x", "y"), button, static_cast<int>(numberOr(params, "clicks", 1)));
}
return pointerDrag(pid, pointParam(params, "x", "y"), pointParam(params, "to_x", "to_y"));
}
int main() {
// Every coordinate this helper reads or writes is a physical pixel: window
// bounds, accessibility rectangles, captures and injected input all agree.
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
_setmode(_fileno(stdin), _O_BINARY);
_setmode(_fileno(stdout), _O_BINARY);
if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED))) return 1;
uiaStart();
cursorStart();
std::string line;
while (std::getline(std::cin, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
Json request;
if (!Json::parse(line, request) || !request.isObject()) continue;
const Json* id = request.get("id");
if (!id) continue;
const Json* m = request.get("method");
std::string method = m && m->isString() ? m->string() : "";
const Json* p = request.get("params");
Json params = p && p->isObject() ? *p : Json::object();
try {
reply(Json::object().set("id", *id).set("result", handle(method, params)));
} catch (const Failure& f) {
Json error = Json::object().set("code", f.code).set("message", f.message);
if (f.blockedBy.kind() != Json::Kind::Null) error.set("blocked_by", f.blockedBy);
reply(Json::object().set("id", *id).set("error", error));
} catch (const std::exception& e) {
reply(Json::object().set("id", *id).set("error", Json::object().set("code", "computer.failed").set("message", e.what())));
}
}
// stdin closing is the kernel letting go; a clipboard still borrowed goes
// back to the person before this process does.
clipboardSettle();
return 0;
}
-143
View File
@@ -1,143 +0,0 @@
#include "helper.h"
#include <chrono>
#include <cmath>
#include <optional>
#include <thread>
// The person's own pointer, moved and clicked by the agent. Every other path
// here works through accessibility and leaves the pointer alone; this one does
// not, so the kernel asks for it by name and it goes back where it was found.
//
// A point is acted on only while the window under it belongs to the approved
// application: on screen the agent has no window of its own to aim at.
namespace {
std::optional<POINT> parked;
constexpr int dragSteps = 12;
INPUT mouse(POINT at, DWORD flags) {
double vx = GetSystemMetrics(SM_XVIRTUALSCREEN), vy = GetSystemMetrics(SM_YVIRTUALSCREEN);
double vw = GetSystemMetrics(SM_CXVIRTUALSCREEN), vh = GetSystemMetrics(SM_CYVIRTUALSCREEN);
INPUT in{};
in.type = INPUT_MOUSE;
in.mi.dx = static_cast<LONG>(std::lround((at.x - vx) * 65535.0 / std::max(vw - 1, 1.0)));
in.mi.dy = static_cast<LONG>(std::lround((at.y - vy) * 65535.0 / std::max(vh - 1, 1.0)));
in.mi.dwFlags = flags | MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK;
return in;
}
void post(POINT at, DWORD flags) {
INPUT in = mouse(at, flags);
if (SendInput(1, &in, sizeof in) == 0) {
throw Failure{"computer.failed", screenLocked() ? "the screen is locked, and Windows delivers no input while it is"
: "Windows refused the pointer input"};
}
}
std::string where(POINT at) { return "(" + std::to_string(at.x) + ", " + std::to_string(at.y) + ")"; }
void check(DWORD pid, POINT at) {
HWND hit = WindowFromPoint(at);
if (!hit) throw Failure{"computer.no_element", "no window is at " + where(at)};
if (contentPid(GetAncestor(hit, GA_ROOT)) != pid) {
throw Failure{"computer.no_element", "the window at " + where(at) + " belongs to another application"};
}
}
// aimed checks again at the instant of input: between the first check and the
// press a window can come up over the point, and it would take the click.
void aimed(DWORD pid, POINT at) {
check(pid, at);
if (!isFront(pid)) throw Failure{"computer.needs_front", "the application left the front before the pointer acted"};
post(at, 0);
}
// park remembers where the person left their pointer, once per run of steps.
void park() {
if (parked) return;
POINT at;
if (GetCursorPos(&at)) parked = at;
}
void buttons(const std::string& button, DWORD& down, DWORD& up) {
if (button.empty() || button == "left") {
down = MOUSEEVENTF_LEFTDOWN, up = MOUSEEVENTF_LEFTUP;
} else if (button == "right") {
down = MOUSEEVENTF_RIGHTDOWN, up = MOUSEEVENTF_RIGHTUP;
} else if (button == "middle") {
down = MOUSEEVENTF_MIDDLEDOWN, up = MOUSEEVENTF_MIDDLEUP;
} else {
throw Failure{"computer.bad_step", button + " is not a mouse button; use left, right or middle"};
}
}
} // namespace
Json pointerMove(DWORD pid, POINT to) {
check(pid, to);
park();
cursorMove(to);
post(to, 0);
return Json::object();
}
// A click into a background window is spent activating it, so taking the
// pointer means taking the foreground with it. What is on top is checked once
// the application is: until then its own window may be covered.
Json pointerClick(DWORD pid, POINT at, const std::string& button, int clicks) {
DWORD down = 0, up = 0;
buttons(button, down, up);
front(pid);
check(pid, at);
park();
cursorMove(at);
for (int i = 0; i < std::max(clicks, 1); i++) {
aimed(pid, at);
post(at, down);
post(at, up);
}
return Json::object();
}
Json pointerDrag(DWORD pid, POINT from, POINT to) {
front(pid);
check(pid, from);
check(pid, to);
park();
cursorMove(from);
aimed(pid, from);
post(from, MOUSEEVENTF_LEFTDOWN);
// The button comes back up whatever happens on the way: left down, it is
// the person's next move that drags.
try {
for (int step = 1; step <= dragSteps; step++) {
double t = static_cast<double>(step) / dragSteps;
post(POINT{static_cast<LONG>(std::lround(from.x + (to.x - from.x) * t)), static_cast<LONG>(std::lround(from.y + (to.y - from.y) * t))}, 0);
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
aimed(pid, to);
} catch (...) {
INPUT release = mouse(to, MOUSEEVENTF_LEFTUP);
SendInput(1, &release, sizeof release);
throw;
}
post(to, MOUSEEVENTF_LEFTUP);
return Json::object();
}
Json pointerPosition() {
POINT at{};
GetCursorPos(&at);
return Json::object().set("x", static_cast<long>(at.x)).set("y", static_cast<long>(at.y));
}
// pointerRelease puts the pointer back where the person left it.
Json pointerRelease() {
if (!parked) return Json::object();
POINT home = *parked;
parked.reset();
post(home, 0);
return Json::object().set("returned", true);
}
-266
View File
@@ -1,266 +0,0 @@
#include "helper.h"
#include <appmodel.h>
#include <dwmapi.h>
#include <chrono>
#include <thread>
namespace {
struct Handle {
HANDLE h;
explicit Handle(HANDLE h) : h(h) {}
~Handle() { if (h) CloseHandle(h); }
Handle(const Handle&) = delete;
Handle& operator=(const Handle&) = delete;
};
std::wstring imagePath(DWORD pid) {
Handle p(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid));
if (!p.h) return {};
wchar_t buf[MAX_PATH * 4];
DWORD n = ARRAYSIZE(buf);
return QueryFullProcessImageNameW(p.h, 0, buf, &n) ? std::wstring(buf, n) : std::wstring();
}
std::wstring fileName(const std::wstring& path) {
size_t slash = path.find_last_of(L"\\/");
return slash == std::wstring::npos ? path : path.substr(slash + 1);
}
std::wstring packageFamily(DWORD pid) {
Handle p(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid));
if (!p.h) return {};
UINT32 len = 0;
if (GetPackageFamilyName(p.h, &len, nullptr) != ERROR_INSUFFICIENT_BUFFER || len == 0) return {};
std::wstring name(len, L'\0');
if (GetPackageFamilyName(p.h, &len, name.data()) != ERROR_SUCCESS) return {};
name.resize(wcslen(name.c_str()));
return name;
}
bool cloaked(HWND hwnd) {
DWORD c = 0;
return SUCCEEDED(DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, &c, sizeof c)) && c != 0;
}
std::wstring className(HWND hwnd) {
wchar_t buf[256];
int n = GetClassNameW(hwnd, buf, ARRAYSIZE(buf));
return std::wstring(buf, n > 0 ? n : 0);
}
// The shell's own surfaces are windows of explorer.exe like any other, and
// none of them is an application a person would name.
bool shellSurface(HWND hwnd) {
std::wstring c = className(hwnd);
return c == L"Progman" || c == L"WorkerW" || c == L"Shell_TrayWnd" || c == L"Shell_SecondaryTrayWnd" || c == L"#32768";
}
// A console window reports whichever program owns the console — cmd, python,
// wsl, ssh — rather than its host, so no name can refuse it. It is recognised
// by what it is and belongs to no application this helper will operate.
bool console(HWND hwnd) {
std::wstring c = className(hwnd);
return c == L"ConsoleWindowClass" || c == L"PseudoConsoleWindow" || c == L"CASCADIA_HOSTING_WINDOW_CLASS";
}
bool intact(HWND hwnd) {
if (!IsWindowVisible(hwnd) || cloaked(hwnd) || shellSurface(hwnd) || console(hwnd)) return false;
return (GetWindowLongPtrW(hwnd, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) == 0;
}
enum class Level { Known, Denied, Unknown };
DWORD integrity(HANDLE process, Level& known) {
known = Level::Unknown;
HANDLE token = nullptr;
if (!OpenProcessToken(process, TOKEN_QUERY, &token)) {
if (GetLastError() == ERROR_ACCESS_DENIED) known = Level::Denied;
return 0;
}
Handle t(token);
DWORD size = 0;
GetTokenInformation(token, TokenIntegrityLevel, nullptr, 0, &size);
std::vector<BYTE> buf(size);
if (size == 0 || !GetTokenInformation(token, TokenIntegrityLevel, buf.data(), size, &size)) return 0;
auto* label = reinterpret_cast<TOKEN_MANDATORY_LABEL*>(buf.data());
known = Level::Known;
return *GetSidSubAuthority(label->Label.Sid, *GetSidSubAuthorityCount(label->Label.Sid) - 1);
}
} // namespace
std::string identity(DWORD pid) {
std::wstring family = packageFamily(pid);
if (!family.empty()) return narrow(family);
return exeName(pid);
}
std::string exeName(DWORD pid) { return lower(narrow(fileName(imagePath(pid)))); }
std::string displayName(DWORD pid) {
std::wstring path = imagePath(pid);
DWORD ignored = 0;
DWORD size = GetFileVersionInfoSizeW(path.c_str(), &ignored);
if (size > 0) {
std::vector<BYTE> info(size);
struct Translation { WORD language, codepage; }* tr = nullptr;
UINT len = 0;
if (GetFileVersionInfoW(path.c_str(), 0, size, info.data()) &&
VerQueryValueW(info.data(), L"\\VarFileInfo\\Translation", reinterpret_cast<void**>(&tr), &len) && len >= sizeof *tr) {
wchar_t key[64];
swprintf_s(key, L"\\StringFileInfo\\%04x%04x\\FileDescription", tr->language, tr->codepage);
wchar_t* desc = nullptr;
if (VerQueryValueW(info.data(), key, reinterpret_cast<void**>(&desc), &len) && len > 1 && desc[0]) {
return narrow(desc);
}
}
}
std::wstring name = fileName(path);
size_t dot = name.find_last_of(L'.');
return narrow(dot == std::wstring::npos ? name : name.substr(0, dot));
}
// contentPid is the process a top-level window shows. A Store application's
// frame belongs to ApplicationFrameHost; what is inside it is the application's
// own CoreWindow, and a frame with none inside has nothing of it to show.
DWORD contentPid(HWND top) {
DWORD pid = 0;
if (console(top)) return 0;
GetWindowThreadProcessId(top, &pid);
if (className(top) != L"ApplicationFrameWindow") return pid;
struct Find { DWORD host; DWORD found; } f{pid, 0};
EnumChildWindows(top, [](HWND child, LPARAM l) -> BOOL {
auto* f = reinterpret_cast<Find*>(l);
if (className(child) != L"Windows.UI.Core.CoreWindow") return TRUE;
DWORD p = 0;
GetWindowThreadProcessId(child, &p);
if (p == f->host) return TRUE;
f->found = p;
return FALSE;
}, reinterpret_cast<LPARAM>(&f));
return f.found;
}
// appListed is whether a window makes its process an application a person
// can see: the windows the task switcher offers, minimized ones included.
bool appListed(HWND top) {
if (!intact(top)) return false;
LONG_PTR ex = GetWindowLongPtrW(top, GWL_EXSTYLE);
if (ex & WS_EX_APPWINDOW) return true;
return GetWindow(top, GW_OWNER) == nullptr && (ex & WS_EX_NOACTIVATE) == 0;
}
// appWindows lists a process's top-level windows front to back, dialogs it
// owns included, with the one in front of the person first.
std::vector<HWND> appWindows(DWORD pid, bool includeMinimized) {
struct Walk { DWORD pid; bool minimized; std::vector<HWND> out; } w{pid, includeMinimized, {}};
EnumWindows([](HWND hwnd, LPARAM l) -> BOOL {
auto* w = reinterpret_cast<Walk*>(l);
if (!intact(hwnd) || contentPid(hwnd) != w->pid) return TRUE;
if (IsIconic(hwnd)) {
if (w->minimized) w->out.push_back(hwnd);
return TRUE;
}
RECT r = windowBounds(hwnd);
if (r.right - r.left > 1 && r.bottom - r.top > 1) w->out.push_back(hwnd);
return TRUE;
}, reinterpret_cast<LPARAM>(&w));
HWND fg = GetForegroundWindow();
for (size_t i = 1; i < w.out.size(); i++) {
if (w.out[i] == fg) {
w.out.erase(w.out.begin() + i);
w.out.insert(w.out.begin(), fg);
break;
}
}
return w.out;
}
// requireOperable refuses a process that is gone, and one Windows keeps this
// helper from reaching: input and accessibility stop at a process running with
// more privilege, and failing there without a cause reads as a broken app.
void requireOperable(DWORD pid) {
Handle p(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid));
bool unreachable = !p.h && GetLastError() == ERROR_ACCESS_DENIED;
DWORD code = 0;
if (!unreachable && (!p.h || !GetExitCodeProcess(p.h, &code) || code != STILL_ACTIVE)) {
throw Failure{"computer.no_app", "no application with pid " + std::to_string(pid) + " is running"};
}
Level known = Level::Unknown, mineKnown = Level::Unknown;
DWORD theirs = p.h ? integrity(p.h, known) : 0;
DWORD mine = integrity(GetCurrentProcess(), mineKnown);
if (unreachable || known == Level::Denied || (known == Level::Known && mineKnown == Level::Known && theirs > mine)) {
throw Failure{"computer.elevated",
"this application runs as administrator and Reasonix Studio does not; Windows keeps a process from reading or "
"sending input to one with more privilege. Ask the person to restart it without administrator rights, or to do this step themselves"};
}
}
bool ownsPoint(DWORD pid, POINT at) {
HWND hit = WindowFromPoint(at);
return hit && contentPid(GetAncestor(hit, GA_ROOT)) == pid;
}
bool isFront(DWORD pid) {
HWND fg = GetForegroundWindow();
return fg && contentPid(fg) == pid;
}
// heldOwner is the window of the same process that window, as a modal dialog,
// has disabled somewhere up its owner chain; null when it holds none.
HWND heldOwner(HWND window, DWORD pid) {
if (!IsWindowEnabled(window)) return nullptr;
for (HWND o = GetWindow(window, GW_OWNER); o; o = GetWindow(o, GW_OWNER)) {
if (contentPid(o) == pid && !IsWindowEnabled(o)) return o;
}
return nullptr;
}
// front brings the application forward before input that goes wherever the
// foreground is. Windows grants the foreground only to a process that received
// the last input, so an input with no effect is injected first.
void front(DWORD pid) {
if (isFront(pid)) return;
std::vector<HWND> wins = appWindows(pid, true);
if (wins.empty()) throw Failure{"computer.no_window", "the application has no window to bring to the front"};
HWND target = wins[0];
if (IsIconic(target)) ShowWindow(target, SW_RESTORE);
INPUT nudge{};
nudge.type = INPUT_MOUSE;
nudge.mi.dwFlags = MOUSEEVENTF_MOVE;
SendInput(1, &nudge, sizeof nudge);
SetForegroundWindow(target);
for (int i = 0; i < 25 && !isFront(pid); i++) std::this_thread::sleep_for(std::chrono::milliseconds(20));
if (!isFront(pid)) {
DWORD fgThread = GetWindowThreadProcessId(GetForegroundWindow(), nullptr);
DWORD me = GetCurrentThreadId();
if (fgThread && fgThread != me && AttachThreadInput(me, fgThread, TRUE)) {
BringWindowToTop(target);
SetForegroundWindow(target);
AttachThreadInput(me, fgThread, FALSE);
}
for (int i = 0; i < 25 && !isFront(pid); i++) std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
if (!isFront(pid)) {
throw Failure{"computer.needs_front",
"Windows kept this application from coming to the front; ask the person to click it once, then try again"};
}
std::this_thread::sleep_for(std::chrono::milliseconds(150));
}
RECT windowBounds(HWND hwnd) {
RECT r{};
if (FAILED(DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, &r, sizeof r))) GetWindowRect(hwnd, &r);
return r;
}
Json rectJson(const RECT& r) {
return Json::object()
.set("x", static_cast<long>(r.left))
.set("y", static_cast<long>(r.top))
.set("width", static_cast<long>(r.right - r.left))
.set("height", static_cast<long>(r.bottom - r.top));
}
@@ -1,20 +0,0 @@
#include "helper.h"
// screenLocked is whether the input desktop is somewhere other than the
// person's own: the lock screen and the secure desktop both take it, and while
// either does no input or capture reaches an application.
bool screenLocked() {
HDESK desk = OpenInputDesktop(0, FALSE, DESKTOP_READOBJECTS);
if (!desk) return true;
wchar_t name[64] = {};
DWORD needed = 0;
bool own = GetUserObjectInformationW(desk, UOI_NAME, name, sizeof name, &needed) && _wcsicmp(name, L"Default") == 0;
CloseDesktop(desk);
return !own;
}
// Windows asks for no grant before one process reads another's accessibility
// tree or captures its window, so both are always on here.
Json status() {
return Json::object().set("accessibility", true).set("screen_recording", true).set("screen_locked", screenLocked());
}
-679
View File
@@ -1,679 +0,0 @@
#include "helper.h"
#include <ole2.h>
#include <UIAutomation.h>
#include <wrl/client.h>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <functional>
#include <future>
#include <map>
#include <memory>
#include <set>
#include <thread>
using Microsoft::WRL::ComPtr;
namespace {
constexpr int maxNodes = 1500;
constexpr int maxDepth = 40;
// A value longer than this is not read back: comparing it costs more than the
// step it would grade.
constexpr int maxReadback = 1 << 16;
constexpr HRESULT elementGone = static_cast<HRESULT>(0x80040201); // UIA_E_ELEMENTNOTAVAILABLE
constexpr HRESULT elementDisabled = static_cast<HRESULT>(0x80040200); // UIA_E_ELEMENTNOTENABLED
constexpr HRESULT notSupported = static_cast<HRESULT>(0x80040204); // UIA_E_NOTSUPPORTED
ComPtr<IUIAutomation> uia;
ComPtr<IUIAutomationTreeWalker> walker;
ComPtr<IUIAutomationCacheRequest> cache;
void need() {
if (!uia) throw Failure{"computer.unsupported", "UI Automation is not available on this system"};
}
std::string hresult(HRESULT hr) {
char buf[16];
snprintf(buf, sizeof buf, "0x%08lx", static_cast<unsigned long>(hr));
return buf;
}
std::string text(const VARIANT& v) {
return v.vt == VT_BSTR && v.bstrVal ? narrow(std::wstring(v.bstrVal, SysStringLen(v.bstrVal))) : "";
}
struct Cached {
VARIANT v;
Cached(IUIAutomationElement* el, PROPERTYID id) {
VariantInit(&v);
el->GetCachedPropertyValue(id, &v);
}
~Cached() { VariantClear(&v); }
bool flag() const { return v.vt == VT_BOOL && v.boolVal == VARIANT_TRUE; }
int integer() const { return v.vt == VT_I4 ? v.lVal : 0; }
std::string str() const { return text(v); }
};
// runtimeKey is an element's identity for as long as it exists: the API hands
// back a new pointer for the same element, but not a new runtime id.
std::string runtimeKey(IUIAutomationElement* el) {
Cached id(el, UIA_RuntimeIdPropertyId);
if (id.v.vt != (VT_I4 | VT_ARRAY) || !id.v.parray) return {};
LONG lo = 0, hi = -1;
SafeArrayGetLBound(id.v.parray, 1, &lo);
SafeArrayGetUBound(id.v.parray, 1, &hi);
std::string key;
for (LONG i = lo; i <= hi; i++) {
int part = 0;
SafeArrayGetElement(id.v.parray, &i, &part);
key += std::to_string(part) + ".";
}
return key;
}
// Refs name elements for the model. One is issued per element and never
// reused, so a ref read earlier cannot come to mean something else.
struct Entry {
DWORD pid;
ComPtr<IUIAutomationElement> el;
};
int nextRef = 0;
std::map<std::string, std::string> refByKey;
std::map<std::string, Entry> byRef;
std::string refFor(IUIAutomationElement* el, DWORD pid) {
std::string key = runtimeKey(el);
if (!key.empty()) {
auto found = refByKey.find(std::to_string(pid) + ":" + key);
if (found != refByKey.end()) return found->second;
}
std::string ref = "a" + std::to_string(++nextRef);
if (!key.empty()) refByKey[std::to_string(pid) + ":" + key] = ref;
byRef[ref] = Entry{pid, el};
return ref;
}
ComPtr<IUIAutomationElement> element(const std::string& ref, DWORD pid) {
auto found = byRef.find(ref);
if (found == byRef.end()) throw Failure{"computer.unknown_ref", ref + " was never issued; refs come from a snapshot"};
if (found->second.pid != pid) throw Failure{"computer.unknown_ref", ref + " belongs to another application"};
int alive = 0;
if (FAILED(found->second.el->get_CurrentProcessId(&alive))) {
throw Failure{"computer.stale_ref", ref + " is no longer on screen; take a new snapshot"};
}
return found->second.el;
}
std::string roleName(int type) {
static const char* names[] = {
"button", "calendar", "checkBox", "comboBox", "edit", "hyperlink", "image", "listItem", "list", "menu",
"menuBar", "menuItem", "progressBar", "radioButton", "scrollBar", "slider", "spinner", "statusBar", "tab",
"tabItem", "text", "toolBar", "toolTip", "tree", "treeItem", "custom", "group", "thumb", "dataGrid",
"dataItem", "document", "splitButton", "window", "pane", "header", "headerItem", "table", "titleBar",
"separator", "semanticZoom", "appBar",
};
int i = type - UIA_ButtonControlTypeId;
return i >= 0 && i < static_cast<int>(std::size(names)) ? names[i] : "element";
}
std::string clipQuote(const std::string& s) {
std::wstring w = widen(s);
if (w.size() > 160) {
size_t cut = 160;
if (IS_HIGH_SURROGATE(w[cut - 1])) cut--;
return quote(narrow(w.substr(0, cut)) + "\xE2\x80\xA6");
}
return quote(s);
}
bool pressable(IUIAutomationElement* el) {
return Cached(el, UIA_IsInvokePatternAvailablePropertyId).flag() || Cached(el, UIA_IsTogglePatternAvailablePropertyId).flag() ||
Cached(el, UIA_IsExpandCollapsePatternAvailablePropertyId).flag() ||
Cached(el, UIA_IsSelectionItemPatternAvailablePropertyId).flag() ||
!Cached(el, UIA_LegacyIAccessibleDefaultActionPropertyId).str().empty();
}
// Containers that say nothing when unnamed: their children stand in for them.
bool transparent(int type) {
return type == UIA_GroupControlTypeId || type == UIA_PaneControlTypeId || type == UIA_CustomControlTypeId;
}
std::string windowTitle(HWND hwnd) {
wchar_t buf[512];
int n = GetWindowTextW(hwnd, buf, ARRAYSIZE(buf));
return narrow(std::wstring(buf, n > 0 ? n : 0));
}
HWND cachedHandle(IUIAutomationElement* el) {
return reinterpret_cast<HWND>(static_cast<intptr_t>(Cached(el, UIA_NativeWindowHandlePropertyId).integer()));
}
// inWindowModal is a dialog drawn inside its window that declares itself
// modal. A top-level window's own root does not count: one holds input only
// through the owner it has disabled.
bool inWindowModal(IUIAutomationElement* el, HWND root) {
return Cached(el, UIA_ControlTypePropertyId).integer() == UIA_WindowControlTypeId &&
Cached(el, UIA_WindowIsModalPropertyId).flag() && cachedHandle(el) != root;
}
// modalJson names a modal for the model; the titles are shown, never read.
Json modalJson(IUIAutomationElement* el, DWORD pid, const std::string& blocks) {
return Json::object().set("ref", refFor(el, pid)).set("title", Cached(el, UIA_NamePropertyId).str()).set("blocks", blocks);
}
// win32Modal is the modal dialog hwnd is, when it holds another window of
// the process; null otherwise.
Json win32Modal(HWND hwnd, DWORD pid) {
HWND held = heldOwner(hwnd, pid);
if (!held) return Json();
ComPtr<IUIAutomationElement> el;
if (FAILED(uia->ElementFromHandleBuildCache(hwnd, cache.Get(), &el)) || !el) {
return Json::object().set("ref", "").set("title", windowTitle(hwnd)).set("blocks", windowTitle(held));
}
return modalJson(el.Get(), pid, windowTitle(held));
}
struct Walk {
DWORD pid;
HWND root = nullptr;
bool rootIsModal = false;
std::string rootTitle, heldTitle;
Json modals = Json::array();
std::set<std::string> modalRefs;
std::vector<std::string> lines;
std::set<std::string> seen;
int count = 0;
// A tree that hands back elements already seen, or nests unnamed
// containers without end, is bounded by work done, not by lines written.
int steps = 0;
int nesting = 0;
static constexpr int maxSteps = maxNodes * 4;
static constexpr int maxNesting = 200;
void children(IUIAutomationElement* el, int depth) {
ComPtr<IUIAutomationElement> child;
walker->GetFirstChildElementBuildCache(el, cache.Get(), &child);
while (child && count < maxNodes && ++steps < maxSteps) {
visit(child.Get(), depth);
ComPtr<IUIAutomationElement> next;
walker->GetNextSiblingElementBuildCache(child.Get(), cache.Get(), &next);
child = next;
}
}
void visit(IUIAutomationElement* el, int depth) {
if (count >= maxNodes || depth > maxDepth || nesting >= maxNesting) return;
struct Nest { int& n; Nest(int& n) : n(++n) {} ~Nest() { --n; } } nest(nesting);
std::string key = runtimeKey(el);
if (!key.empty() && !seen.insert(key).second) return;
count++;
int type = Cached(el, UIA_ControlTypePropertyId).integer();
std::string label = Cached(el, UIA_NamePropertyId).str();
bool password = Cached(el, UIA_IsPasswordPropertyId).flag();
std::string value = password ? "" : Cached(el, UIA_ValueValuePropertyId).str();
if (label.empty() && type == UIA_TextControlTypeId) label = value;
if (transparent(type) && label.empty()) {
children(el, depth);
return;
}
std::string line(static_cast<size_t>(depth) * 2, ' ');
line += "- " + roleName(type);
if (!label.empty()) line += " " + clipQuote(label);
line += " [" + refFor(el, pid) + "]";
if (!value.empty() && value != label && type != UIA_TextControlTypeId) line += " value=" + clipQuote(value);
std::string help = Cached(el, UIA_HelpTextPropertyId).str();
if (value.empty() && !help.empty() && (type == UIA_EditControlTypeId || type == UIA_ComboBoxControlTypeId)) {
line += " placeholder=" + clipQuote(help);
}
if (password) line += " password";
if (Cached(el, UIA_HasKeyboardFocusPropertyId).flag()) line += " focused";
if (!Cached(el, UIA_IsEnabledPropertyId).flag()) line += " disabled";
if (Cached(el, UIA_SelectionItemIsSelectedPropertyId).flag()) line += " selected";
if (Cached(el, UIA_ToggleToggleStatePropertyId).integer() == ToggleState_On) line += " checked";
if (pressable(el)) line += " pressable";
bool isRoot = cachedHandle(el) == root;
if ((isRoot && rootIsModal) || inWindowModal(el, root)) {
line += " modal";
Json m = modalJson(el, pid, isRoot ? heldTitle : rootTitle);
if (modalRefs.insert(m.get("ref")->string()).second) modals.push(m);
}
lines.push_back(line);
children(el, depth + 1);
}
};
// settle reads what an accessibility call's result says about the element.
void settle(HRESULT hr, const std::string& what, const char* action) {
if (SUCCEEDED(hr)) return;
if (hr == elementGone) throw Failure{"computer.stale_ref", what + " is no longer on screen; take a new snapshot"};
if (hr == elementDisabled) throw Failure{"computer.no_action", what + " is disabled"};
if (hr == notSupported) throw Failure{"computer.no_action", what + " takes no " + action};
throw Failure{"computer.failed", std::string(action) + " on " + what + " failed (HRESULT " + hresult(hr) + ")"};
}
// detached runs an action that can block until the application finishes what
// it starts — an invoke that opens a modal dialog returns when the dialog
// closes. Past a short wait the action has been delivered, which is the answer.
HRESULT detached(std::function<HRESULT()> action) {
auto done = std::make_shared<std::promise<HRESULT>>();
std::future<HRESULT> result = done->get_future();
std::thread([action = std::move(action), done] {
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
done->set_value(action());
CoUninitialize();
}).detach();
if (result.wait_for(std::chrono::milliseconds(1500)) == std::future_status::timeout) return S_OK;
return result.get();
}
template <typename P>
ComPtr<P> pattern(IUIAutomationElement* el, PATTERNID id) {
ComPtr<P> p;
el->GetCurrentPatternAs(id, IID_PPV_ARGS(&p));
return p;
}
// activate does what a click on the element would: invoke it, or toggle,
// expand, select, or its accessible default action, in that order.
void activate(IUIAutomationElement* el, const std::string& what) {
if (auto p = pattern<IUIAutomationInvokePattern>(el, UIA_InvokePatternId)) {
return settle(detached([p] { return p->Invoke(); }), what, "invoke");
}
if (auto p = pattern<IUIAutomationTogglePattern>(el, UIA_TogglePatternId)) {
return settle(detached([p] { return p->Toggle(); }), what, "toggle");
}
if (auto p = pattern<IUIAutomationExpandCollapsePattern>(el, UIA_ExpandCollapsePatternId)) {
ExpandCollapseState state = ExpandCollapseState_LeafNode;
p->get_CurrentExpandCollapseState(&state);
bool open = state == ExpandCollapseState_Collapsed;
return settle(detached([p, open] { return open ? p->Expand() : p->Collapse(); }), what, "expand");
}
if (auto p = pattern<IUIAutomationSelectionItemPattern>(el, UIA_SelectionItemPatternId)) {
return settle(detached([p] { return p->Select(); }), what, "select");
}
if (auto p = pattern<IUIAutomationLegacyIAccessiblePattern>(el, UIA_LegacyIAccessiblePatternId)) {
BSTR action = nullptr;
p->get_CurrentDefaultAction(&action);
bool has = action && SysStringLen(action) > 0;
SysFreeString(action);
if (has) return settle(detached([p] { return p->DoDefaultAction(); }), what, "default action");
}
throw Failure{"computer.no_action", what + " takes no press"};
}
// showAt puts the agent's cursor on an element before it is acted on.
void showAt(IUIAutomationElement* el) {
RECT r{};
if (SUCCEEDED(el->get_CurrentBoundingRectangle(&r)) && r.right > r.left && r.bottom > r.top) {
cursorMove(POINT{(r.left + r.right) / 2, (r.top + r.bottom) / 2});
}
}
void checkPoint(DWORD pid, POINT at) {
std::string where = "(" + std::to_string(at.x) + ", " + std::to_string(at.y) + ")";
HWND hit = WindowFromPoint(at);
if (!hit || contentPid(GetAncestor(hit, GA_ROOT)) != pid) {
throw Failure{"computer.no_element", hit ? "the element at " + where + " belongs to another application"
: "nothing of this application is at " + where};
}
}
// scrollable is the nearest element, from el outwards, that scrolls vertically.
ComPtr<IUIAutomationScrollPattern> scrollable(ComPtr<IUIAutomationElement> el) {
for (int i = 0; el && i < maxDepth; i++) {
if (auto p = pattern<IUIAutomationScrollPattern>(el.Get(), UIA_ScrollPatternId)) {
BOOL can = FALSE;
if (SUCCEEDED(p->get_CurrentVerticallyScrollable(&can)) && can) return p;
}
ComPtr<IUIAutomationElement> parent;
walker->GetParentElement(el.Get(), &parent);
el = parent;
}
return nullptr;
}
// wheel turns the wheel over the application's front window without the
// pointer: the message goes to the innermost window at its centre.
void wheel(DWORD pid, double amount) {
std::vector<HWND> wins = appWindows(pid, false);
if (wins.empty()) throw Failure{"computer.no_window", "the application has no window on screen to scroll"};
RECT r = windowBounds(wins[0]);
POINT centre{(r.left + r.right) / 2, (r.top + r.bottom) / 2};
HWND target = wins[0];
for (int i = 0; i < maxDepth; i++) {
POINT local = centre;
ScreenToClient(target, &local);
HWND child = ChildWindowFromPointEx(target, local, CWP_SKIPINVISIBLE | CWP_SKIPDISABLED | CWP_SKIPTRANSPARENT);
if (!child || child == target) break;
target = child;
}
int delta = static_cast<int>(amount * WHEEL_DELTA / 3);
if (delta > -WHEEL_DELTA && delta < WHEEL_DELTA) delta = amount < 0 ? -WHEEL_DELTA : WHEEL_DELTA;
PostMessageW(target, WM_MOUSEWHEEL, MAKEWPARAM(0, static_cast<SHORT>(delta)), MAKELPARAM(centre.x, centre.y));
}
// nativeRoot is the top-level window an element is drawn in.
HWND nativeRoot(IUIAutomationElement* el) {
ComPtr<IUIAutomationElement> at = el;
for (int i = 0; at && i < maxDepth; i++) {
UIA_HWND h = nullptr;
if (SUCCEEDED(at->get_CurrentNativeWindowHandle(&h)) && h) return GetAncestor(static_cast<HWND>(h), GA_ROOT);
ComPtr<IUIAutomationElement> parent;
walker->GetParentElement(at.Get(), &parent);
at = parent;
}
return nullptr;
}
// reachable refuses an element in a window the application has disabled,
// which a modal dialog does to the window it holds: the application has
// declared that window takes no input until the dialog is answered.
void reachable(IUIAutomationElement* el, DWORD pid, const std::string& what) {
HWND root = nativeRoot(el);
if (!root || IsWindowEnabled(root)) return;
for (HWND w : appWindows(pid, false)) {
for (HWND o = heldOwner(w, pid) ? GetWindow(w, GW_OWNER) : nullptr; o; o = GetWindow(o, GW_OWNER)) {
if (o == root) {
throw Failure{"computer.blocked", what + " is in a window a modal dialog holds; answer or dismiss the dialog first",
win32Modal(w, pid)};
}
}
}
throw Failure{"computer.blocked", what + " is in a window the application has disabled"};
}
// typedExactly is whether after is before with typed put in at one place,
// replacing whatever was selected there. Where repeated characters make the
// changed span ambiguous it is taken from either end, and any other change,
// such as autocomplete or a single-line field dropping a newline, is not it.
bool typedExactly(const std::wstring& before, const std::wstring& after, const std::wstring& typed) {
if (after == before || after.size() < typed.size()) return false;
auto span = [&](bool prefixFirst) {
size_t p = 0, s = 0, room = std::min(before.size(), after.size());
auto prefix = [&] { while (p < room - s && before[p] == after[p]) p++; };
auto suffix = [&] { while (s < room - p && before[before.size() - 1 - s] == after[after.size() - 1 - s]) s++; };
if (prefixFirst) {
prefix();
suffix();
} else {
suffix();
prefix();
}
return after.substr(p, after.size() - p - s);
};
return span(true) == typed || span(false) == typed;
}
std::wstring flat(std::wstring s) {
s.erase(std::remove(s.begin(), s.end(), L'\r'), s.end());
return s;
}
// readValue is what an element holds as text, for comparing before and after
// a step. A password, or a value too long to compare, is not read.
bool readValue(IUIAutomationElement* el, std::wstring& out) {
BOOL password = FALSE;
if (FAILED(el->get_CurrentIsPassword(&password)) || password) return false;
BSTR v = nullptr;
if (auto p = pattern<IUIAutomationValuePattern>(el, UIA_ValuePatternId)) {
if (FAILED(p->get_CurrentValue(&v))) return false;
} else if (auto t = pattern<IUIAutomationTextPattern>(el, UIA_TextPatternId)) {
ComPtr<IUIAutomationTextRange> doc;
if (FAILED(t->get_DocumentRange(&doc)) || !doc || FAILED(doc->GetText(maxReadback, &v))) return false;
} else {
return false;
}
out = flat(v ? std::wstring(v, SysStringLen(v)) : std::wstring());
SysFreeString(v);
return out.size() < static_cast<size_t>(maxReadback);
}
} // namespace
Json effect(const char* cls, const char* evidence, const Json& modal) {
Json e = Json::object().set("class", cls);
if (evidence) {
Json kinds = Json::array();
kinds.push(evidence);
e.set("evidence", kinds);
}
if (modal.kind() != Json::Kind::Null) e.set("blocked_by", modal);
return Json::object().set("effect", e);
}
Landing landing(DWORD pid, bool withValue) {
need();
Landing at;
ComPtr<IUIAutomationElement> focused;
int owner = 0;
if (FAILED(uia->GetFocusedElementBuildCache(cache.Get(), &focused)) || !focused || FAILED(focused->get_CurrentProcessId(&owner)) ||
static_cast<DWORD>(owner) != pid) {
focused = nullptr;
}
HWND root = focused ? nativeRoot(focused.Get()) : nullptr;
if (!root && isFront(pid)) root = GetForegroundWindow();
ComPtr<IUIAutomationElement> el = focused;
for (int i = 0; el && i < maxDepth && at.modal.kind() == Json::Kind::Null; i++) {
if (inWindowModal(el.Get(), root)) at.modal = modalJson(el.Get(), pid, windowTitle(root));
ComPtr<IUIAutomationElement> parent;
walker->GetParentElementBuildCache(el.Get(), cache.Get(), &parent);
el = parent;
}
if (at.modal.kind() == Json::Kind::Null && root) at.modal = win32Modal(root, pid);
if (!focused || !withValue) return at;
if (Cached(focused.Get(), UIA_IsValuePatternAvailablePropertyId).flag()) {
at.takesText = !Cached(focused.Get(), UIA_ValueIsReadOnlyPropertyId).flag();
} else {
int type = Cached(focused.Get(), UIA_ControlTypePropertyId).integer();
at.takesText = type == UIA_EditControlTypeId || type == UIA_DocumentControlTypeId;
}
at.read = [focused](std::wstring& out) { return readValue(focused.Get(), out); };
at.readable = at.read(at.before);
return at;
}
void refuseHeldText(const Landing& at) {
if (at.modal.kind() == Json::Kind::Null || at.takesText) return;
throw Failure{"computer.blocked",
"a modal dialog holds this application's input and its focused element takes no text, where a letter can answer the "
"dialog; answer or dismiss it first",
at.modal};
}
// The application takes typed characters one message at a time, so a value
// read part-way through holds some of them; it is read again until all are in.
Json typedEffect(const Landing& at, const std::wstring& typed) {
if (!at.readable) return effect("unverifiable", nullptr, at.modal);
std::wstring after, want = flat(typed);
for (int i = 0; i < 10; i++) {
if (!at.read(after)) return effect("unverifiable", nullptr, at.modal);
if (typedExactly(at.before, after, want)) return effect("confirmed", "value_readback", at.modal);
std::this_thread::sleep_for(std::chrono::milliseconds(40));
}
if (after == at.before) return effect("suspected_noop", "value_unchanged", at.modal);
return effect("unverifiable", nullptr, at.modal);
}
void uiaStart() {
if (FAILED(CoCreateInstance(__uuidof(CUIAutomation8), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&uia))) &&
FAILED(CoCreateInstance(__uuidof(CUIAutomation), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&uia)))) {
return;
}
// A hung application must not hang the helper with it.
ComPtr<IUIAutomation2> timed;
if (SUCCEEDED(uia.As(&timed))) {
timed->put_ConnectionTimeout(2000);
timed->put_TransactionTimeout(5000);
}
uia->get_ControlViewWalker(&walker);
uia->CreateCacheRequest(&cache);
for (PROPERTYID id : {UIA_RuntimeIdPropertyId, UIA_ControlTypePropertyId, UIA_NamePropertyId, UIA_ValueValuePropertyId,
UIA_IsPasswordPropertyId, UIA_HelpTextPropertyId, UIA_HasKeyboardFocusPropertyId, UIA_IsEnabledPropertyId,
UIA_SelectionItemIsSelectedPropertyId, UIA_ToggleToggleStatePropertyId, UIA_IsInvokePatternAvailablePropertyId,
UIA_IsTogglePatternAvailablePropertyId, UIA_IsExpandCollapsePatternAvailablePropertyId,
UIA_IsSelectionItemPatternAvailablePropertyId, UIA_LegacyIAccessibleDefaultActionPropertyId,
UIA_WindowIsModalPropertyId, UIA_NativeWindowHandlePropertyId, UIA_IsValuePatternAvailablePropertyId,
UIA_ValueIsReadOnlyPropertyId}) {
cache->AddProperty(id);
}
}
Json snapshot(DWORD pid) {
need();
Walk walk{pid};
std::string note;
std::vector<HWND> wins = appWindows(pid, false);
if (wins.empty()) {
note = screenLocked()
? "The screen is locked. While it is, no application's window can be read, clicked by ref or hit by point; unlocking the screen brings it back."
: "This application has no window on screen right now — it may be minimized. A type, key or pointer step brings it forward; take a new snapshot after.";
}
for (HWND hwnd : wins) {
walk.root = hwnd;
HWND held = heldOwner(hwnd, pid);
walk.rootIsModal = held != nullptr;
walk.rootTitle = windowTitle(hwnd);
walk.heldTitle = held ? windowTitle(held) : "";
ComPtr<IUIAutomationElement> root;
if (SUCCEEDED(uia->ElementFromHandleBuildCache(hwnd, cache.Get(), &root)) && root) walk.visit(root.Get(), 0);
}
Json lines = Json::array();
for (auto& l : walk.lines) lines.push(std::move(l));
return Json::object()
.set("app", displayName(pid))
.set("bundle", identity(pid))
.set("lines", lines)
.set("truncated", walk.count >= maxNodes)
.set("note", note)
.set("modals", walk.modals);
}
Json press(DWORD pid, const std::string& ref) {
need();
ComPtr<IUIAutomationElement> el = element(ref, pid);
reachable(el.Get(), pid, ref);
showAt(el.Get());
activate(el.Get(), ref);
return Json::object();
}
// clickAt is a press at a point: the element there is asked for its action,
// so the person's pointer never moves. One with no action says so rather than
// being clicked some other way.
Json clickAt(DWORD pid, double x, double y) {
need();
POINT at{static_cast<LONG>(x), static_cast<LONG>(y)};
cursorMove(at);
// Checked after the glide, just before the lookup: a window that came up
// over the point meanwhile is not the application that was approved.
checkPoint(pid, at);
ComPtr<IUIAutomationElement> el;
if (FAILED(uia->ElementFromPointBuildCache(at, cache.Get(), &el)) || !el) {
throw Failure{"computer.no_element", "nothing of this application is at (" + num(x) + ", " + num(y) + ")"};
}
int owner = 0;
DWORD framePid = 0;
GetWindowThreadProcessId(GetAncestor(WindowFromPoint(at), GA_ROOT), &framePid);
if (FAILED(el->get_CurrentProcessId(&owner)) || (static_cast<DWORD>(owner) != pid && static_cast<DWORD>(owner) != framePid)) {
throw Failure{"computer.no_element", "the element at (" + num(x) + ", " + num(y) + ") belongs to another application"};
}
int type = Cached(el.Get(), UIA_ControlTypePropertyId).integer();
std::string what = "the " + roleName(type) + " at that point";
reachable(el.Get(), pid, what);
if (pressable(el.Get())) {
activate(el.Get(), what);
} else if (type == UIA_EditControlTypeId || type == UIA_ComboBoxControlTypeId || type == UIA_DocumentControlTypeId) {
settle(el->SetFocus(), what, "focus");
} else {
throw Failure{"computer.no_action", what + " takes no accessibility action; it needs the real pointer"};
}
return Json::object().set("role", roleName(type));
}
// menu asks an element for its context menu, which is what a right click is
// for. Read the application again afterwards to see the menu.
Json menu(DWORD pid, const std::string& ref) {
need();
ComPtr<IUIAutomationElement> target = element(ref, pid);
reachable(target.Get(), pid, ref);
showAt(target.Get());
ComPtr<IUIAutomationElement3> el;
if (FAILED(target.As(&el))) throw Failure{"computer.no_action", ref + " has no context menu"};
HRESULT hr = detached([el] { return el->ShowContextMenu(); });
if (FAILED(hr) && hr != elementGone) throw Failure{"computer.no_action", ref + " has no context menu"};
settle(hr, ref, "context menu");
return Json::object();
}
Json scroll(DWORD pid, const std::string& ref, double amount) {
need();
ComPtr<IUIAutomationElement> from;
if (!ref.empty()) {
from = element(ref, pid);
reachable(from.Get(), pid, ref);
showAt(from.Get());
if (amount == 0) {
BOOL offscreen = TRUE;
if (SUCCEEDED(from->get_CurrentIsOffscreen(&offscreen)) && !offscreen) return Json::object().set("how", "revealed");
if (auto p = pattern<IUIAutomationScrollItemPattern>(from.Get(), UIA_ScrollItemPatternId)) {
settle(p->ScrollIntoView(), ref, "scroll into view");
return Json::object().set("how", "revealed");
}
}
}
if (amount == 0) {
throw Failure{"computer.bad_step", "a scroll needs a ref that can be revealed, or lines to turn the wheel by"};
}
if (!from) {
ComPtr<IUIAutomationElement> focused;
int owner = 0;
if (SUCCEEDED(uia->GetFocusedElement(&focused)) && focused && SUCCEEDED(focused->get_CurrentProcessId(&owner)) &&
static_cast<DWORD>(owner) == pid) {
from = focused;
}
}
if (auto p = scrollable(from)) {
ScrollAmount step = amount < 0 ? ScrollAmount_SmallIncrement : ScrollAmount_SmallDecrement;
for (int i = 0; i < static_cast<int>(std::abs(amount)); i++) settle(p->Scroll(ScrollAmount_NoAmount, step), "the view", "scroll");
} else {
wheel(pid, amount);
}
return Json::object().set("how", "wheel");
}
Json focus(DWORD pid, const std::string& ref) {
need();
ComPtr<IUIAutomationElement> el = element(ref, pid);
reachable(el.Get(), pid, ref);
showAt(el.Get());
HRESULT hr = el->SetFocus();
if (FAILED(hr) && hr != elementGone) throw Failure{"computer.no_action", ref + " cannot take focus (HRESULT " + hresult(hr) + ")"};
settle(hr, ref, "focus");
return Json::object();
}
Json setValue(DWORD pid, const std::string& ref, const std::string& value) {
need();
ComPtr<IUIAutomationElement> el = element(ref, pid);
reachable(el.Get(), pid, ref);
showAt(el.Get());
auto p = pattern<IUIAutomationValuePattern>(el.Get(), UIA_ValuePatternId);
BOOL readOnly = FALSE;
if (!p || (SUCCEEDED(p->get_CurrentIsReadOnly(&readOnly)) && readOnly)) {
throw Failure{"computer.no_action", ref + " has no value that can be set"};
}
std::wstring before, now;
bool hadBefore = readValue(el.Get(), before);
BSTR b = SysAllocString(widen(value).c_str());
HRESULT hr = p->SetValue(b);
SysFreeString(b);
if (FAILED(hr) && hr != elementGone) throw Failure{"computer.no_action", ref + " has no value that can be set (HRESULT " + hresult(hr) + ")"};
settle(hr, ref, "set value");
if (!readValue(el.Get(), now)) return effect("unverifiable", nullptr, Json());
if (now == flat(widen(value))) return effect("confirmed", "value_readback", Json());
if (hadBefore && now == before) return effect("suspected_noop", "value_unchanged", Json());
return effect("unverifiable", nullptr, Json());
}
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Cua AI, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+4 -2
View File
@@ -41,13 +41,15 @@ files:
- assets/**/*
- package.json
extraResources:
- from: ../../internal/platform/computer/cua-driver.lock.json
to: bin/cua-driver.lock.json
- from: bin
to: bin
filter:
- reasonix-studio-host
- reasonix-studio-host.exe
- reasonix-computer-helper
- reasonix-computer-helper.exe
- cua-driver
- cua-driver.exe
- from: ../frontend-next/dist
to: frontend-next/dist
+3 -3
View File
@@ -10,7 +10,7 @@
"start": "electron .",
"build:host": "node packaging/build-host.js",
"build:linux-payload": "node packaging/linux-payload.js",
"test": "node --test test/unit.mjs",
"test": "node --test test/unit.mjs test/linux-packaging.test.mjs",
"build:app": "electron-builder --dir",
"build:dist": "electron-builder",
"smoke": "electron test/smoke.js",
@@ -20,9 +20,9 @@
"computer-live": "electron test/computer_live.js"
},
"devDependencies": {
"electron": "44.5.1",
"electron": "44.7.0",
"electron-builder": "^26.15.3",
"playwright": "1.63.0"
"playwright": "1.64.0"
},
"pnpm": {
"onlyBuiltDependencies": [
+2 -87
View File
@@ -40,66 +40,8 @@ function go(args, env) {
}
}
// The Windows helper is C++ against the system's own UI Automation, input and
// imaging APIs, built with whichever MSVC vswhere finds. The CRT is linked in
// statically so the helper needs no redistributable on the person's machine.
const VS_INSTALLER = path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Microsoft Visual Studio", "Installer");
// The helper is built for the architecture the kernel is, with that
// architecture's own tools: the x64 toolset on an x64 machine, the ARM64 one on
// an ARM64 machine (vcvarsarm64 is the native environment there).
const WINDOWS_TOOLCHAIN = {
amd64: { component: "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", vars: "vcvars64.bat", label: "x64" },
arm64: { component: "Microsoft.VisualStudio.Component.VC.Tools.ARM64", vars: "vcvarsarm64.bat", label: "ARM64" },
};
function msvcVars(toolchain) {
const vswhere = path.join(VS_INSTALLER, "vswhere.exe");
if (!fs.existsSync(vswhere)) return "";
const found = spawnSync(vswhere, ["-latest", "-products", "*", "-requires", toolchain.component, "-property", "installationPath"], { encoding: "utf8" });
const root = (found.stdout || "").trim().split(/\r?\n/)[0];
const vars = root ? path.join(root, "VC", "Auxiliary", "Build", toolchain.vars) : "";
return vars && fs.existsSync(vars) ? vars : "";
}
function buildWindowsHelper(goarch) {
const toolchain = WINDOWS_TOOLCHAIN[goarch];
if (!toolchain) {
console.error(`no MSVC toolchain is declared for windows/${goarch}`);
process.exit(1);
}
const vars = msvcVars(toolchain);
if (!vars) {
// A release without the helper would ship computer use switched off with
// nothing saying why, so CI refuses; a dev build only loses the feature.
if (process.env.CI) {
console.error(`MSVC with the ${toolchain.label} C++ tools is required to build reasonix-computer-helper.exe`);
process.exit(1);
}
console.warn("MSVC not found: building without reasonix-computer-helper.exe, so computer use is unavailable in this build");
return;
}
const src = path.join(ROOT, "desktop", "computer-helper", "windows");
const objs = fs.mkdtempSync(path.join(require("node:os").tmpdir(), "reasonix-helper-"));
const out = path.join(OUT, "reasonix-computer-helper.exe");
const libs = "user32.lib gdi32.lib ole32.lib oleaut32.lib uiautomationcore.lib windowscodecs.lib dwmapi.lib version.lib advapi32.lib uuid.lib gdiplus.lib";
const script = path.join(objs, "build.cmd");
fs.writeFileSync(script, [
"@echo off",
// vcvars looks for vswhere on PATH and complains when it is not there.
`set "PATH=%PATH%;${VS_INSTALLER}"`,
`call "${vars}" >nul || exit /b 1`,
`cd /d "${src}"`,
`cl /nologo /std:c++17 /EHsc /O2 /MT /utf-8 /W4 /DUNICODE /D_UNICODE /Fo"${objs}\\\\" /Fe"${out}" *.cpp /link ${libs}`,
].join("\r\n"));
const built = spawnSync("cmd.exe", ["/d", "/c", script], { stdio: "inherit", env: { ...process.env, VSLANG: "1033" } });
fs.rmSync(objs, { recursive: true, force: true });
if (built.status !== 0) {
console.error("building reasonix-computer-helper.exe failed");
process.exit(built.status ?? 1);
}
console.log("built reasonix-computer-helper.exe");
}
const driver = spawnSync(process.execPath, [path.join(__dirname, "install-cua-driver.js")], { stdio: "inherit", windowsHide: true });
if (driver.status !== 0) process.exit(driver.status ?? 1);
if (process.platform !== "darwin") {
// go build names the binary after the package and adds .exe where it belongs.
@@ -109,7 +51,6 @@ if (process.platform !== "darwin") {
const goarch = (process.env.REASONIX_GOARCH || "").trim();
go(["build", "-ldflags", LDFLAGS, "-o", OUT + path.sep, PKG], goarch ? { CGO_ENABLED: "0", GOARCH: goarch } : { CGO_ENABLED: "0" });
console.log("built reasonix-studio-host");
if (process.platform === "win32") buildWindowsHelper(goarch || (process.arch === "arm64" ? "arm64" : "amd64"));
process.exit(0);
}
@@ -131,29 +72,3 @@ for (const slice of slices) {
fs.rmSync(slice, { force: true });
}
console.log("built reasonix-studio-host (universal)");
// The helper that operates other applications, universal for the same reason.
// It targets macOS 12, the oldest this app runs on; what needs a later system
// checks at run time and answers computer.unsupported.
const HELPER = path.join(ROOT, "desktop", "computer-helper", "Sources");
const sources = fs.readdirSync(HELPER).filter((f) => f.endsWith(".swift")).map((f) => path.join(HELPER, f));
const helperSlices = [["x86_64", "amd64"], ["arm64", "arm64"]].map(([target, arch]) => {
const out = path.join(OUT, `reasonix-computer-helper-${arch}`);
const built = spawnSync("swiftc", ["-O", "-target", `${target}-apple-macos12`, ...sources, "-o", out], { stdio: "inherit" });
if (built.status !== 0) {
console.error(`swiftc for ${target} failed`);
process.exit(built.status ?? 1);
}
return out;
});
const helper = path.join(OUT, "reasonix-computer-helper");
fs.rmSync(helper, { force: true });
const helperMerged = spawnSync("lipo", ["-create", "-output", helper, ...helperSlices], { stdio: "inherit" });
if (helperMerged.status !== 0) {
console.error("lipo failed for the computer-use helper");
process.exit(helperMerged.status ?? 1);
}
for (const slice of helperSlices) {
fs.rmSync(slice, { force: true });
}
console.log("built reasonix-computer-helper (universal)");
+23 -2
View File
@@ -6,7 +6,7 @@ const fs = require("node:fs");
// an older system, so the Linux package refuses one.
const PT_INTERP = 3;
function elfInterpreter(bytes) {
function elfHeader(bytes) {
if (bytes.length < 52 || bytes.readUInt32BE(0) !== 0x7f454c46) throw new Error("not an ELF file");
const wide = bytes[4] === 2;
const little = bytes[5] === 1;
@@ -15,6 +15,27 @@ function elfInterpreter(bytes) {
if (wide && bytes.length < 64) throw new Error("truncated ELF header");
const u16 = (at) => (little ? bytes.readUInt16LE(at) : bytes.readUInt16BE(at));
const u32 = (at) => (little ? bytes.readUInt32LE(at) : bytes.readUInt32BE(at));
return { wide, little, u16, u32 };
}
function elfArchitecture(bytes) {
const { wide, little, u16 } = elfHeader(bytes);
if (!wide || !little) throw new Error("Linux packages require little-endian ELF64");
const arch = { 62: "amd64", 183: "arm64" }[u16(18)];
if (!arch) throw new Error(`unsupported ELF machine ${u16(18)}`);
return arch;
}
function assertArchitecture(files, arch) {
if (!["amd64", "arm64"].includes(arch)) throw new Error(`unsupported release architecture ${arch}`);
for (const file of files) {
const actual = elfArchitecture(fs.readFileSync(file));
if (actual !== arch) throw new Error(`${file} is ${actual}, expected ${arch}`);
}
}
function elfInterpreter(bytes) {
const { wide, little, u16, u32 } = elfHeader(bytes);
const word = (at) => (wide ? Number(little ? bytes.readBigUInt64LE(at) : bytes.readBigUInt64BE(at)) : u32(at));
const phoff = word(wide ? 32 : 28);
const phentsize = u16(wide ? 54 : 42);
@@ -40,7 +61,7 @@ function dynamicallyLinked(files) {
return found;
}
module.exports = { elfInterpreter, dynamicallyLinked };
module.exports = { elfInterpreter, dynamicallyLinked, elfArchitecture, assertArchitecture };
if (require.main === module) {
const files = process.argv.slice(2);
@@ -0,0 +1,71 @@
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const os = require("node:os");
const crypto = require("node:crypto");
const { spawnSync } = require("node:child_process");
const lock = require("../../../internal/platform/computer/cua-driver.lock.json");
function artifact(platform, arch) {
const key = platform === "darwin" ? "darwin-universal" : `${platform}-${arch}`;
const selected = lock.artifacts[key];
if (!selected) throw new Error(`No pinned CUA artifact for ${key}`);
return selected;
}
async function locate(dir, name) {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
const candidate = path.join(dir, entry.name);
if (entry.isFile() && entry.name === name) return candidate;
if (entry.isDirectory()) { const found = await locate(candidate, name); if (found) return found; }
}
return null;
}
function targetArchitecture(goarch, nativeArch) {
if (!goarch) return nativeArch;
if (goarch === "amd64") return "x64";
if (goarch === "arm64") return "arm64";
throw new Error(`Unsupported CUA target architecture ${goarch}`);
}
async function install(platform = process.platform, arch = targetArchitecture(process.env.REASONIX_GOARCH, process.arch)) {
const selected = artifact(platform, arch);
const tempRoot = path.resolve(os.tmpdir());
const stage = await fs.mkdtemp(path.join(tempRoot, "reasonix-cua-"));
try {
const url = `https://github.com/${lock.repository}/releases/download/${lock.tag}/${selected.name}`;
const response = await fetch(url, { signal: AbortSignal.timeout(120000) });
if (!response.ok) throw new Error(`CUA download failed: HTTP ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length > 128 * 1024 * 1024) throw new Error("CUA artifact exceeds size limit");
if (crypto.createHash("sha256").update(bytes).digest("hex") !== selected.sha256) throw new Error("CUA artifact SHA-256 mismatch");
const archive = path.join(stage, selected.name);
await fs.writeFile(archive, bytes);
const extracted = path.join(stage, "extracted");
await fs.mkdir(extracted);
extract(archive, extracted);
const name = platform === "win32" ? "cua-driver.exe" : "cua-driver";
const source = await locate(extracted, name);
if (!source) throw new Error("CUA artifact contains no driver executable");
const bin = path.resolve(__dirname, "../bin");
await fs.mkdir(bin, { recursive: true });
const destination = path.join(bin, name);
await fs.copyFile(source, destination);
if (platform !== "win32") await fs.chmod(destination, 0o755);
return destination;
} finally {
if (!path.resolve(stage).startsWith(tempRoot + path.sep)) throw new Error("Invalid CUA staging path");
await fs.rm(stage, { recursive: true, force: true });
}
}
function extract(archive, extracted) {
const cwd = path.dirname(archive);
const tar = process.platform === "win32" ? path.join(process.env.SystemRoot || "C:/Windows", "System32", "tar.exe") : "tar";
const result = spawnSync(tar, ["-xf", path.basename(archive), "-C", path.relative(cwd, extracted)], { cwd, stdio: "inherit", windowsHide: true });
if (result.status !== 0) throw new Error("CUA extraction failed");
}
module.exports = { artifact, install, targetArchitecture, extract };
if (require.main === module) install().then(file => process.stdout.write(`Installed pinned CUA Driver ${lock.version}: ${file}\n`)).catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
+46
View File
@@ -0,0 +1,46 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { assertArchitecture, dynamicallyLinked } = require("./elf");
const { name } = require("../package.json");
function filesIn(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const file = path.join(dir, entry.name);
return entry.isDirectory() ? filesIn(file) : entry.isFile() ? [file] : [];
});
}
function verifyLinuxTree(root, arch) {
const files = filesIn(root);
const hosts = files.filter((file) => file.endsWith(path.join("resources", "bin", "reasonix-studio-host")));
if (hosts.length !== 1) throw new Error(`expected one packaged host, found ${hosts.length}`);
const shell = path.resolve(path.dirname(hosts[0]), "../..", name);
const helper = path.join(root, "usr/lib/reasonix-studio/reasonix-studio-update-helper");
for (const file of [shell, hosts[0], helper]) {
if (!fs.statSync(file).isFile()) throw new Error(`missing executable ${file}`);
}
const images = files.filter((file) => {
const fd = fs.openSync(file, "r");
try {
const magic = Buffer.alloc(4);
return fs.readSync(fd, magic, 0, 4, 0) === 4 && magic.readUInt32BE(0) === 0x7f454c46;
} finally {
fs.closeSync(fd);
}
});
for (const file of [shell, hosts[0], helper]) {
if (!images.includes(file)) throw new Error(`${file} is not an ELF executable`);
}
assertArchitecture(images, arch);
const dynamic = dynamicallyLinked([hosts[0], helper]);
if (dynamic.length) throw new Error(`Go binaries must be static: ${dynamic.map((item) => item.file).join(", ")}`);
return shell;
}
module.exports = { verifyLinuxTree };
if (require.main === module) {
if (process.argv.length !== 4) throw new Error("usage: node packaging/linux-tree.js <extracted-package> <amd64|arm64>");
console.log(verifyLinuxTree(path.resolve(process.argv[2]), process.argv[3]));
}
+18 -18
View File
@@ -9,14 +9,14 @@ importers:
.:
devDependencies:
electron:
specifier: 44.5.1
version: 44.5.1([email protected])
specifier: 44.7.0
version: 44.7.0([email protected])
electron-builder:
specifier: ^26.15.3
version: 26.15.3([email protected])([email protected])
playwright:
specifier: 1.63.0
version: 1.63.0
specifier: 1.64.0
version: 1.64.0
packages:
@@ -374,8 +374,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
electron@44.5.1:
resolution: {integrity: sha512-lx7AYoFIiyvEvgvpyWO9kOC60xtNPpiPZAth6Fmg9NutzpCAMLwpUlaBY2EXfZyIgUdzEmLMj9KvK0Vrokl1yQ==}
electron@44.7.0:
resolution: {integrity: sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==}
engines: {node: '>= 22.12.0'}
hasBin: true
@@ -762,13 +762,13 @@ packages:
resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
engines: {node: '>=16.0.0'}
[email protected]3.0:
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
[email protected]4.0:
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
engines: {node: '>=20'}
hasBin: true
[email protected]3.0:
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
[email protected]4.0:
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
engines: {node: '>=20'}
hasBin: true
@@ -1551,7 +1551,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron@44.5.1([email protected]):
electron@44.7.0([email protected]):
dependencies:
'@electron-internal/extract-zip': 1.0.5
'@electron/get': 5.1.0([email protected])
@@ -1696,7 +1696,7 @@ snapshots:
es6-error: 4.1.1
matcher: 3.0.0
roarr: 2.15.4
semver: 7.8.5
semver: 7.7.4
serialize-error: 7.0.1
optional: true
@@ -1883,11 +1883,11 @@ snapshots:
[email protected]:
dependencies:
semver: 7.8.5
semver: 7.7.4
[email protected]:
dependencies:
semver: 7.8.5
semver: 7.7.4
[email protected]:
dependencies:
@@ -1896,7 +1896,7 @@ snapshots:
graceful-fs: 4.2.11
nopt: 9.0.0
proc-log: 6.1.0
semver: 7.8.5
semver: 7.7.4
tar: 7.5.22
tinyglobby: 0.2.17
undici: 6.28.0
@@ -1942,11 +1942,11 @@ snapshots:
pvutils: 1.2.0
tslib: 2.8.1
[email protected]3.0: {}
[email protected]4.0: {}
[email protected]3.0:
[email protected]4.0:
dependencies:
playwright-core: 1.63.0
playwright-core: 1.64.0
[email protected]:
dependencies:
+2 -1
View File
@@ -7,8 +7,9 @@
//
// Nothing is answered. Each is performed by ending or by showing, so an
// acknowledgement would have to come from a process on its way out.
function actHandler({ app, showWindow }) {
function actHandler({ app, showWindow, log = () => {} }) {
return (act) => {
if (act === "show" || act === "relaunch" || act === "quit") log(`kernel act: ${act}`);
if (act === "relaunch") {
app.relaunch();
return;
+34
View File
@@ -0,0 +1,34 @@
"use strict";
const DOT_RADIUS = 4.5;
const DOT_RING = 5.75;
function setUnreadBadge(unread, win, electron = require("electron"), platform = process.platform) {
if (platform === "darwin") {
electron.app.dock.setBadge(unread ? "•" : "");
} else if (platform === "win32") {
let image = null;
if (unread) {
const pixels = Buffer.alloc(16 * 16 * 4);
for (let y = 0; y < 16; y++) {
for (let x = 0; x < 16; x++) {
const d = Math.hypot(x + 0.5 - 8, y + 0.5 - 8);
// A red dot inside a thin white ring, edges smoothed by coverage, so it
// reads on any taskbar colour and over any icon.
const outer = Math.min(Math.max(DOT_RING + 0.5 - d, 0), 1);
if (outer === 0) continue;
const inner = Math.min(Math.max(DOT_RADIUS + 0.5 - d, 0), 1);
// Native bitmaps are premultiplied BGRA, including on Windows ARM.
const mix = (red) => Math.round((255 + (red - 255) * inner) * outer);
pixels.set([mix(64), mix(64), mix(224), Math.round(255 * outer)], (y * 16 + x) * 4);
}
}
image = electron.nativeImage.createFromBitmap(pixels, { width: 16, height: 16 });
}
win.setOverlayIcon(image, unread ? "Reasonix Studio" : "");
} else {
electron.app.setBadgeCount(unread ? 1 : 0);
}
}
module.exports = { setUnreadBadge };
+4
View File
@@ -152,6 +152,10 @@ class BrowserViews {
if (id !== targetId) this.putAway(other.view, other);
}
this.shown = targetId;
// A page that was parked comes back as a view that is attached but whose
// widget never repaints until the window's geometry changes; attaching it
// again is what wakes it, the same as after a minimize.
if (!entry.at) this.win.contentView.removeChildView(entry.view);
this.win.contentView.addChildView(entry.view);
entry.view.setBounds(at);
entry.at = at;
+22
View File
@@ -0,0 +1,22 @@
"use strict";
// Pixels wider than this are scaled down before they leave the shell: a 4K
// window as a PNG overruns the feedback upload limit.
const MAX_WIDTH = 1920;
// The verb takes no argument and has no target of its own: the window is
// whichever one the asking page lives in, and fromWindow refuses any other
// sender. Nothing outside that window's own contents can be captured.
function captureHandler(fromWindow) {
return async (event) => {
const target = fromWindow(event);
if (!target) return null;
let image = await target.webContents.capturePage();
if (image.isEmpty()) return null;
const { width } = image.getSize();
if (width > MAX_WIDTH) image = image.resize({ width: MAX_WIDTH, quality: "good" });
return new Uint8Array(image.toPNG());
};
}
module.exports = { captureHandler, MAX_WIDTH };
+152
View File
@@ -0,0 +1,152 @@
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const net = require("node:net");
const { spawn } = require("node:child_process");
function metadata(socketPath) {
return new Promise((resolve, reject) => {
const connection = net.createConnection(socketPath);
let text = "";
const finish = (error, value) => { connection.destroy(); error ? reject(error) : resolve(value); };
connection.setTimeout(500, () => finish(new Error("CUA readiness timeout")));
connection.on("error", error => finish(error));
connection.on("connect", () => connection.write('{"method":"metadata"}\n'));
connection.on("data", bytes => {
text += bytes.toString("utf8");
if (Buffer.byteLength(text) > 16384) return finish(new Error("CUA readiness response exceeds limit"));
const end = text.indexOf("\n");
if (end < 0) return;
try { finish(null, JSON.parse(text.slice(0, end))); } catch (error) { finish(error); }
});
connection.on("end", () => finish(new Error("CUA readiness connection ended")));
});
}
function accepts(reply, pid, contract) {
const value = reply?.result;
return reply?.ok === true && value?.pid === pid && value.embedded === true && value.host_bundle_id === contract.host_bundle_id && value.driver_version === contract.version && value.contract_version === contract.contract_version && value.tools_list_schema_version === contract.tools_list_schema_version && value.capability_version === contract.capability_version && value.mcp_protocol_version === contract.protocol_version;
}
async function startEmbedded(executable, contract, onStderr = () => {}, spawnDriver = spawn) {
if (!path.isAbsolute(executable)) throw new Error("CUA executable must have an absolute path");
const directory = await fs.mkdtemp("/tmp/reasonix-cua-");
await fs.chmod(directory, 0o700);
const socket = path.join(directory, "driver.sock");
const abortSocket = path.join(directory, "abort.sock");
const peers = new Set();
let child = null;
let identity = null;
let stopping = null;
let aborting = null;
async function unlinkOwned() {
try {
const current = await fs.lstat(socket);
if (identity && current.dev === identity.dev && current.ino === identity.ino) await fs.unlink(socket);
} catch (error) { if (error.code !== "ENOENT") throw error; }
identity = null;
}
async function launch() {
child = spawnDriver(executable, ["serve", "--embedded", "--parent-liveness-stdio", "--no-permissions-gate", "--socket", socket, "--host-bundle-id", contract.host_bundle_id, "--permission-mode", "standard"], {
stdio: ["pipe", "ignore", "pipe"], windowsHide: true,
env: { ...process.env, CUA_DRIVER_EMBEDDED_HOST_PID: String(process.pid), CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", DO_NOT_TRACK: "1" },
});
let failure = null;
child.on("error", error => { failure = error; });
child.stderr.on("data", bytes => onStderr(bytes.toString("utf8")));
const deadline = Date.now() + 10000;
while (Date.now() < deadline) {
if (failure) throw failure;
if (child.exitCode !== null || child.signalCode !== null) throw new Error("Embedded CUA exited during startup");
try {
const current = await fs.lstat(socket);
if (!current.isSocket() || (current.mode & 0o777) !== 0o600) throw new Error("Invalid private CUA socket");
const reply = await metadata(socket);
if (!accepts(reply, child.pid, contract)) throw new Error("Embedded CUA identity/version mismatch");
identity = current;
return;
} catch (error) { if (!["ENOENT", "ECONNREFUSED"].includes(error.code)) throw error; }
await new Promise(resolve => setTimeout(resolve, 50));
}
throw new Error("Embedded CUA failed readiness");
}
async function abort() {
if (stopping) throw new Error("CUA is shutting down");
await stopProcess(child, true);
await unlinkOwned();
if (stopping) throw new Error("CUA is shutting down");
await launch();
}
const control = net.createServer(peer => {
peers.add(peer);
peer.on("close", () => peers.delete(peer));
peer.on("error", () => peer.destroy());
peer.setTimeout(15000, () => peer.destroy());
let text = "";
peer.on("data", bytes => {
text += bytes.toString("utf8");
if (Buffer.byteLength(text) > 1024) return peer.destroy();
const end = text.indexOf("\n");
if (end < 0) return;
peer.removeAllListeners("data");
try {
const request = JSON.parse(text.slice(0, end));
if (request.op !== "abort" || Object.keys(request).length !== 1) throw new Error("Invalid CUA control operation");
aborting ??= abort().finally(() => { aborting = null; });
aborting.then(() => peer.end('{"ok":true}\n'), () => peer.end('{"ok":false}\n'));
} catch { peer.end('{"ok":false}\n'); }
});
});
async function shutdown() {
for (const peer of peers) peer.destroy();
if (control.listening) await new Promise(resolve => control.close(resolve));
if (aborting) await aborting.catch(() => {});
if (child) await stopProcess(child);
await unlinkOwned();
await fs.rmdir(directory).catch(error => { if (!["ENOENT", "ENOTEMPTY"].includes(error.code)) throw error; });
}
const stop = () => { stopping ??= shutdown(); return stopping; };
try {
await launch();
await new Promise((resolve, reject) => { control.once("error", reject); control.listen(abortSocket, resolve); });
await fs.chmod(abortSocket, 0o600);
return { socket, abortSocket, stop };
} catch (error) { await stop(); throw error; }
}
async function stopProcess(child, force = false) {
if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return;
const exited = new Promise(resolve => child.once("close", resolve));
if (force) child.kill("SIGKILL");
else {
child.stdin.end();
let timer;
await Promise.race([exited, new Promise(resolve => { timer = setTimeout(resolve, 1500); })]);
clearTimeout(timer);
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}
await exited;
}
function createQuitGate(current, quit, report) {
let state = "idle";
return event => {
if (state === "finished") return;
if (state === "draining") { event.preventDefault(); return; }
const owned = current();
if (!owned) return;
event.preventDefault();
state = "draining";
Promise.resolve().then(() => owned.stop()).catch(report).finally(() => {
state = "finished";
quit();
});
};
}
module.exports = { accepts, metadata, startEmbedded, stopProcess, createQuitGate };
+8 -1
View File
@@ -12,4 +12,11 @@ function groundFor(prefs, systemDark) {
return dark ? GROUND.dark : GROUND.light;
}
module.exports = { GROUND, groundFor };
// The scheme the window frame takes. Windows draws a frameless window's border
// from it, so a dark page on a light system needs it set to stay borderless.
function themeSourceFor(prefs) {
const theme = prefs?.["rx-theme"];
return theme === "dark" || theme === "light" ? theme : "system";
}
module.exports = { GROUND, groundFor, themeSourceFor };
+10 -8
View File
@@ -15,13 +15,11 @@ function hostBinary({ packaged, resourcesPath, dirname, platform, env = {} }) {
return path.join(packaged ? resourcesPath : path.join(dirname, ".."), "bin", name);
}
// The helper that operates other applications exists on macOS and Windows. Its
// permissions are the application's that started it, so it runs as this
// shell's grandchild rather than being found anywhere else.
function computerHelper({ packaged, resourcesPath, dirname, platform, env = {} }) {
if (env.REASONIX_COMPUTER_HELPER) return env.REASONIX_COMPUTER_HELPER;
if (platform !== "darwin" && platform !== "win32") return "";
const name = platform === "win32" ? "reasonix-computer-helper.exe" : "reasonix-computer-helper";
// The host owns the packaged CUA Driver; macOS permissions belong to its shell.
function computerDriver({ packaged, resourcesPath, dirname, platform, env = {} }) {
if (env.REASONIX_CUA_DRIVER) return env.REASONIX_CUA_DRIVER;
if (!["darwin", "win32", "linux"].includes(platform)) return "";
const name = platform === "win32" ? "cua-driver.exe" : "cua-driver";
return path.join(packaged ? resourcesPath : path.join(dirname, ".."), "bin", name);
}
@@ -31,4 +29,8 @@ function pageDir({ packaged, resourcesPath, dirname, env = {} }) {
return path.join(root, "frontend-next", "dist");
}
module.exports = { hostBinary, computerHelper, pageDir };
function computerDriverContract({ packaged, resourcesPath, dirname }) {
return packaged ? path.join(resourcesPath, "bin", "cua-driver.lock.json") : path.resolve(dirname, "../../../internal/platform/computer/cua-driver.lock.json");
}
module.exports = { computerDriverContract, hostBinary, computerDriver, pageDir };
+45 -10
View File
@@ -8,6 +8,7 @@ const fs = require("node:fs/promises");
const { existsSync } = require("node:fs");
const path = require("node:path");
const { start, shouldRetry, HANDSHAKE_TIMEOUT_MS } = require("./host");
const { startEmbedded, createQuitGate } = require("./cua");
const { showStarting } = require("./starting");
const { StudioHost } = require("./hostclient");
const { installTray } = require("./tray");
@@ -17,20 +18,23 @@ const { uiLanguage } = require("./uilang");
const { installFullScreenKey } = require("./fullscreen");
const { installReload } = require("./reload");
const { externalTarget } = require("./links");
const { reveal, revealWorkspace } = require("./reveal");
const { reveal, revealWorkspace, revealLogs, openWorkspace, openFile } = require("./reveal");
const { appIcon } = require("./appicon");
const { setUnreadBadge } = require("./badge");
const layout = require("./layout");
const { offerCleanup } = require("./legacy");
const { stripPackageGrants, unpaintedWindowCause } = require("./packagegrants");
const { BrowserProtocol } = require("./browserprotocol");
const { BrowserViews } = require("./browserviews");
const { startBrowserRelay } = require("./browserrelay");
const { groundFor } = require("./ground");
const { groundFor, themeSourceFor } = require("./ground");
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
const { stateFile, openWindow } = require("./windowstate");
const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics");
const { captureHandler } = require("./capture");
const { createPowerGuard, keepsAwake } = require("./powerguard");
const { openLogs, redactArgv, failStartup } = require("./shelllog");
const { raise } = require("./raise");
const { actHandler } = require("./acts");
const { crashDir, recordHostExit, pendingHostExit, clearHostExit, hostExitNotice } = require("./hostexit");
@@ -63,11 +67,12 @@ const where = {
env: process.env,
};
const hostBinary = layout.hostBinary(where);
const computerHelper = layout.computerHelper(where);
const computerDriver = layout.computerDriver(where);
const pageDir = layout.pageDir(where);
const acts = actHandler({ app, showWindow: () => showWindow() });
const acts = actHandler({ app, showWindow: () => showWindow(), log: (line) => logs?.shell.line(line) });
let kernel = null;
let cua = null;
let win = null;
let origin = "";
let quitting = false;
@@ -156,7 +161,14 @@ async function boot() {
// falls back to Electron's own, which named a Studio that never shipped and
// ranked it ahead of every published release.
const args = ["-page", pageDir, "-crash-dir", crashDir(logs.dir)];
if (computerHelper && existsSync(computerHelper)) args.push("-computer-helper", computerHelper);
if (computerDriver && existsSync(computerDriver)) {
if (process.platform === "darwin") {
const contract = JSON.parse(await fs.readFile(layout.computerDriverContract(where), "utf8"));
cua = await startEmbedded(computerDriver, contract, text => logs.host.raw(text));
args.push("-computer-socket", cua.socket, "-computer-abort-socket", cua.abortSocket);
}
args.push("-cua-driver", computerDriver);
}
if (app.isPackaged) {
args.push("-studio-version", app.getVersion());
// The other half the kernel cannot work out: which file the application
@@ -183,6 +195,7 @@ async function boot() {
session.defaultSession.webRequest.onCompleted({ urls: [`${ready.origin}/*submit`] }, (details) => {
if (details.method === "POST" && details.statusCode < 300) void powerGuard?.refresh();
});
followTheme();
win = createWindow();
closeStarting();
guard(win.webContents);
@@ -293,8 +306,7 @@ function showWindow() {
if (quitting || !win || win.isDestroyed()) return;
if (win.isMinimized()) win.restore();
reload?.revive();
win.show();
win.focus();
logs?.shell.line(`shell: window raised; focused=${raise(win)}`);
}
// A window larger than the display it opens on puts its own title bar off
@@ -307,7 +319,11 @@ function fitted() {
};
}
const ground = () => groundFor(loadPrefs(prefsFile(app.getPath("userData"))), nativeTheme.shouldUseDarkColors);
const storedPrefs = () => loadPrefs(prefsFile(app.getPath("userData")));
const ground = () => groundFor(storedPrefs(), nativeTheme.shouldUseDarkColors);
const followTheme = () => {
nativeTheme.themeSource = themeSourceFor(storedPrefs());
};
function createWindow() {
const mac = process.platform === "darwin";
@@ -366,6 +382,7 @@ function fromWindow(event) {
const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), app.getPreferredSystemLanguages()[0] ?? app.getLocale());
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => {
installApplicationMenu(uiLang);
followTheme();
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
void powerGuard?.refresh();
});
@@ -375,6 +392,11 @@ nativeTheme.on("updated", () => {
ipcMain.handle("shell:graphics", (event) => graphicsHandler(app, fromWindow, gpuLaunch)(event));
ipcMain.handle("window:unread-badge", (event, unread) => {
const target = fromWindow(event);
if (target && typeof unread === "boolean") setUnreadBadge(unread, target);
});
ipcMain.handle("window:minimise", (event) => {
fromWindow(event)?.minimize();
});
@@ -385,6 +407,7 @@ ipcMain.handle("window:toggle-maximise", (event) => {
else target.maximize();
});
ipcMain.handle("window:is-maximised", (event) => fromWindow(event)?.isMaximized() ?? false);
ipcMain.handle("window:capture", (event) => captureHandler(fromWindow)(event));
ipcMain.handle("window:close", (event) => {
fromWindow(event)?.close();
});
@@ -415,9 +438,18 @@ ipcMain.handle("shell:open-external", (event, raw) => {
ipcMain.handle("shell:reveal-workspace", (event, root) =>
fromWindow(event) && client ? revealWorkspace(client, shell, String(root)) : { code: "", error: "no window" },
);
ipcMain.handle("shell:reveal-logs", (event) =>
fromWindow(event) ? revealLogs(shell, logs?.dir) : { error: "no window" },
);
ipcMain.handle("shell:open-workspace", (event, root) =>
fromWindow(event) && client ? openWorkspace(client, shell, String(root)) : { code: "", error: "no window" },
);
ipcMain.handle("shell:reveal", (event, base, rel) =>
fromWindow(event) && client ? reveal(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
);
ipcMain.handle("shell:open-file", (event, base, rel) =>
fromWindow(event) && client ? openFile(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
);
// A dismissed dialog answers with "", which is what the page reads as "they
// said no". Only a failure to write is an error.
@@ -446,7 +478,8 @@ ipcMain.handle("dialog:pick-folder", async (event, startIn) => {
if (!target) return "";
const picked = await dialog.showOpenDialog(target, {
defaultPath: startIn || undefined,
properties: ["openDirectory", "createDirectory"],
// Tools keep their working folders behind a leading dot, so those are listed too.
properties: ["openDirectory", "createDirectory", "showHiddenFiles"],
});
if (picked.canceled || !picked.filePaths.length) return "";
return picked.filePaths[0];
@@ -538,9 +571,11 @@ app.on("window-all-closed", () => {
// than a copy of it.
module.exports = { current: () => ({ win, tray, client, origin }) };
app.on("before-quit", () => {
const drainCua = createQuitGate(() => cua, () => app.quit(), error => logs.shell.line(`CUA cleanup: ${error.message}`));
app.on("before-quit", event => {
quitting = true;
logs.shell.line("shell: quitting");
drainCua(event);
browserRelay?.stop();
powerGuard?.close();
tray?.close();
+7
View File
@@ -21,17 +21,24 @@ contextBridge.exposeInMainWorld("reasonixHost", {
shell: "electron",
platform: process.platform,
titleBar,
setUnreadBadge: (unread) => ipcRenderer.invoke("window:unread-badge", unread),
minimiseWindow: () => ipcRenderer.invoke("window:minimise"),
toggleMaximiseWindow: () => ipcRenderer.invoke("window:toggle-maximise"),
isWindowMaximised: () => ipcRenderer.invoke("window:is-maximised"),
closeWindow: () => ipcRenderer.invoke("window:close"),
// A PNG of this window's own page, taking nothing: there is no way to name another.
captureWindow: () => ipcRenderer.invoke("window:capture"),
// What the shell's graphics are running as this launch.
graphics: () => ipcRenderer.invoke("shell:graphics"),
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
// A pane and a path inside its workspace; main asks the kernel where that is.
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
openFile: (base, rel) => ipcRenderer.invoke("shell:open-file", String(base), String(rel)),
// A project the sidebar lists, which may have no pane at all.
revealWorkspace: (root) => ipcRenderer.invoke("shell:reveal-workspace", String(root)),
revealLogs: () => ipcRenderer.invoke("shell:reveal-logs"),
// The same project, entered rather than selected in its parent folder.
openWorkspace: (root) => ipcRenderer.invoke("shell:open-workspace", String(root)),
// Where a dropped file lives. Resolved here rather than in the page: the
// renderer is handed a File and never a path, and a turn that has to work on
// the file itself cannot do it on a copy of the bytes.
+18
View File
@@ -0,0 +1,18 @@
"use strict";
// Windows refuses a background process the foreground, and a focus() it refused
// only flashes the taskbar button. A window that is topmost for one call is
// raised past that rule, and handed back to the normal stacking order at once.
// Answers whether the window ended up focused, so the caller can log what the
// platform actually allowed.
function raise(win, platform = process.platform) {
win.show();
win.focus();
if (platform !== "win32" || win.isFocused()) return win.isFocused();
win.setAlwaysOnTop(true);
win.focus();
win.setAlwaysOnTop(false);
return win.isFocused();
}
module.exports = { raise };
+64 -1
View File
@@ -1,9 +1,16 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
// A pane's base as the hub publishes it. Nothing else may steer a request this
// process sends with the credential, and the unprefixed root is no pane.
const PANE_BASE = /^\/rt\/[A-Za-z0-9_-]+$/;
const DOCUMENT_MEDIA = new Set([
".pdf", ".txt", ".md", ".csv", ".tsv", ".json", ".yaml", ".yml", ".xml", ".rtf",
".docx", ".xlsx", ".pptx", ".odt", ".ods", ".odp",
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff", ".avif", ".heic",
".mp3", ".wav", ".ogg", ".m4a", ".flac", ".mp4", ".mov", ".webm", ".mkv",
]);
function refused(error, code = "") {
return { code, error };
@@ -67,4 +74,60 @@ async function revealWorkspace(client, shell, root, platform = process.platform)
return null;
}
module.exports = { reveal, revealWorkspace };
// The shell's own logs folder. The shell names it, never the page, and it is
// opened rather than selected: it is a directory this app created. null means
// it was shown.
async function revealLogs(shell, dir) {
if (!dir) return { error: "the logs folder is unknown" };
const failed = await shell.openPath(String(dir));
return failed ? { error: failed } : null;
}
// A folder openPath may enter: a directory, and on macOS not an application
// bundle, which openPath would launch instead of showing.
async function isPlainFolder(target, platform, fsApi) {
let stat;
try {
stat = await fsApi.stat(target);
} catch {
return false;
}
if (!stat.isDirectory()) return false;
if (platform !== "darwin") return true;
try {
await fsApi.access(path.join(target, "Contents", "Info.plist"));
return false;
} catch {
return true;
}
}
// openWorkspace enters a folder the sidebar lists instead of selecting it in
// its parent. The kernel names the folder; anything that is not a plain folder
// is only selected, as revealWorkspace does.
async function openWorkspace(client, shell, root, platform = process.platform, fsApi = fs.promises) {
const found = await ask(client, `/host/workspaces/locate?root=${encodeURIComponent(String(root))}`, platform);
if (found.refusal) return found.refusal;
if (!(await isPlainFolder(found.target, platform, fsApi))) {
shell.showItemInFolder(found.target);
return null;
}
const why = await shell.openPath(found.target);
return why ? refused(why) : null;
}
async function openFile(client, shell, base, rel, platform = process.platform, fsApi = fs.promises) {
const found = await locate(client, String(base), String(rel), platform);
if (found.refusal) return found.refusal;
try {
if (!(await fsApi.stat(found.target)).isFile()) return refused("not a regular file");
const ext = (platform === "win32" ? path.win32 : path.posix).extname(found.target).toLowerCase();
if (!DOCUMENT_MEDIA.has(ext)) return refused("only documents and media can be opened");
const why = await shell.openPath(found.target);
return why ? refused(why) : null;
} catch (err) {
return refused(err.message);
}
}
module.exports = { reveal, revealWorkspace, revealLogs, openWorkspace, openFile };
+5 -9
View File
@@ -26,7 +26,7 @@ function buildTarget(dir) {
<key>CFBundleName</key><string>Computer Target</string>
<key>CFBundlePackageType</key><string>APPL</string>
</dict></plist>`);
const source = path.join(__dirname, "..", "..", "..", "internal", "computer", "testdata", "target.swift");
const source = path.join(__dirname, "..", "..", "..", "internal", "platform", "computer", "testdata", "target.swift");
const built = spawnSync("swiftc", ["-O", source, "-o", path.join(macos, "target")], { stdio: "inherit" });
if (built.status !== 0) throw new Error("the probe application did not build");
const log = path.join(dir, "target.log");
@@ -39,7 +39,7 @@ const logged = (log) => (fs.existsSync(log) ? fs.readFileSync(log, "utf8") : "")
function operatingModel() {
return scriptedModel((tools) => {
const last = tools.length ? tools[tools.length - 1] : "";
const ref = (role, name) => (last.match(new RegExp(`- ${role} "${name}" \\[(a\\d+)\\]`)) || [])[1];
const ref = (role, name) => (last.match(new RegExp(`- ${role} "${name}" \\[(r[A-Za-z0-9]+)\\]`)) || [])[1];
switch (tools.length) {
case 0:
return { name: "computer_read", arguments: { what: "apps" } };
@@ -47,10 +47,7 @@ function operatingModel() {
return { name: "computer_read", arguments: { what: "snapshot", app: BUNDLE } };
case 2:
return { name: "computer_act", arguments: { app: BUNDLE, steps: [
{ action: "set_value", ref: ref("textField", "Probe field"), text: "from-studio" },
{ action: "click", ref: ref("button", "Probe button") },
{ action: "focus", ref: ref("textField", "Probe field") },
{ action: "type", text: " 李雷" },
{ action: "set_value", ref: ref("textField", "Probe field"), text: "from-studio 李雷" },
] } };
case 3:
return { name: "computer_read", arguments: { what: "screenshot", app: BUNDLE } };
@@ -126,11 +123,10 @@ async function main() {
const results = (requests[5].messages || []).filter((m) => m.role === "tool").map((m) => String(m.content || ""));
check("the kernel was given computer use", results.length === 5, results.length);
check("the model saw the probe among the applications", (results[0] || "").includes(`${BUNDLE} — `), (results[0] || "").slice(0, 400));
check("the snapshot carried refs", /textField "Probe field" \[a\d+\]/.test(results[1] || ""), (results[1] || "").slice(0, 400));
check("every step ran", (results[2] || "").includes("Completed 4 of 4 step(s)."), (results[2] || "").slice(0, 600));
check("the snapshot carried refs", /textField "Probe field" \[r[A-Za-z0-9]+\]/.test(results[1] || ""), (results[1] || "").slice(0, 400));
check("the value change ran", (results[2] || "").includes("Completed 1 of 1 step(s)."), (results[2] || "").slice(0, 600));
await until("the input in the application", async () => logged(target.log).includes("text from-studio 李雷"), 10000).catch(() => {});
const received = logged(target.log);
check("the button was pressed", received.includes("button pressed"), received);
check("the text arrived", received.includes("text from-studio 李雷"), received);
check("the pointer is asked for although the application is already granted", person.asked.length === 3, person.asked);
check("a refused pointer never reached the application", !received.includes("mouseDown"), received);
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const contract = require("../../../internal/platform/computer/cua-driver.lock.json");
const { install } = require("../packaging/install-cua-driver");
const { startEmbedded, metadata } = require("../src/cua");
const executable = await install();
let owned;
try {
const env = { ...process.env, REASONIX_LIVE_COMPUTER: executable };
if (process.platform === "darwin") {
owned = await startEmbedded(executable, contract, text => process.stderr.write(text));
const before = await metadata(owned.socket);
assert.equal(before.result.embedded, true);
env.REASONIX_LIVE_COMPUTER_SOCKET = owned.socket;
env.REASONIX_LIVE_COMPUTER_ABORT_SOCKET = owned.abortSocket;
}
const child = spawn("go", ["test", "./internal/platform/computer", "-run", "^TestLive", "-count=1", "-v", "-timeout=3m"], { cwd: root, env, stdio: "inherit", windowsHide: true });
const code = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", resolve); });
if (code !== 0) process.exitCode = code ?? 1;
} finally { await owned?.stop(); }
@@ -0,0 +1,12 @@
"use strict";
const net = require("node:net");
const args = process.argv.slice(2);
const socket = args[args.indexOf("--socket") + 1];
// The socket must exist at 0600 from the first instant: a reader that looks between listen and a later chmod sees 0755.
process.umask(0o177);
const server = net.createServer(peer => {
peer.on("data", () => peer.end(JSON.stringify({ ok: true, result: { pid: process.pid, embedded: true, host_bundle_id: "io.reasonix.studio", driver_version: "0.34.1", contract_version: "0.8.0", tools_list_schema_version: "1", capability_version: "1", mcp_protocol_version: "2025-06-18" } }) + "\n"));
});
server.listen(socket);
process.stdin.resume();
process.stdin.on("end", () => server.close(() => process.exit(0)));
@@ -0,0 +1,82 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync, spawn } = require("node:child_process");
const { _electron } = require("playwright");
const [executablePath, arch, version] = process.argv.slice(2);
assert.ok(executablePath && ["amd64", "arm64"].includes(arch) && version, "usage: linux-package-smoke.js EXECUTABLE ARCH VERSION");
const home = fs.mkdtempSync(path.join(os.tmpdir(), "studio-installed-"));
const env = { ...process.env, REASONIX_HOME: home, XDG_CONFIG_HOME: path.join(home, "config"), XDG_CACHE_HOME: path.join(home, "cache") };
for (const key of ["REASONIX_STUDIO_HOST", "REASONIX_STUDIO_PAGE", "REASONIX_COMPUTER_HELPER", "REASONIX_DEV", "REASONIX_STATE_HOME", "ELECTRON_RUN_AS_NODE"]) delete env[key];
fs.writeFileSync(path.join(home, "config.toml"), 'default_model = "smoke/model"\n[desktop]\nwelcomed = true\n[[providers]]\nname = "smoke"\nkind = "openai"\nbase_url = "http://127.0.0.1:9/v1"\nmodels = ["model"]\ndefault = "model"\napi_key_env = "SMOKE_API_KEY"\n');
fs.writeFileSync(path.join(home, ".env"), "SMOKE_API_KEY=fixture\n");
async function until(check, message, timeout = 60000) {
const end = Date.now() + timeout;
while (Date.now() < end) {
if (await check()) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(message);
}
function alive(pid) {
try { process.kill(pid, 0); return true; } catch (error) {
if (error.code === "ESRCH") return false;
throw error;
}
}
async function run() {
let application;
let diagnostics = "";
try {
application = await _electron.launch({ executablePath, env, chromiumSandbox: true, timeout: 60000 });
application.process().stderr?.on("data", (chunk) => { diagnostics = (diagnostics + chunk).slice(-16384); });
const identity = await application.evaluate(({ app }) => ({ packaged: app.isPackaged, arch: process.arch, version: app.getVersion() }));
assert.deepEqual(identity, { packaged: true, arch: arch === "amd64" ? "x64" : arch, version });
let page;
await until(async () => {
page = application.windows().find((win) => win.url().startsWith("http://127.0.0.1:"));
return Boolean(page);
}, "the installed shell never loaded its bundled kernel's page");
await page.waitForFunction(() => document.getElementById("root")?.children.length > 0 && document.documentElement.dataset.shell === "electron");
const response = await page.evaluate(async () => {
const result = await fetch("/status", { credentials: "same-origin" });
return { status: result.status, body: await result.json() };
});
assert.equal(response.status, 200);
assert.equal(typeof response.body, "object");
const shell = application.process();
const children = execFileSync("ps", ["-o", "pid=", "--ppid", String(shell.pid)], { encoding: "utf8" }).trim().split(/\s+/).map(Number);
const hostPath = path.join(path.dirname(executablePath), "resources/bin/reasonix-studio-host");
const hosts = children.filter((pid) => {
try { return fs.readlinkSync(`/proc/${pid}/exe`) === hostPath; } catch { return false; }
});
assert.equal(hosts.length, 1, "the installed shell must own exactly one bundled host");
const second = spawn(executablePath, [], { env, stdio: "ignore" });
const result = await new Promise((resolve, reject) => {
const timer = setTimeout(() => { second.kill(); reject(new Error("second launch never yielded to the installed instance")); }, 30000);
second.once("error", (error) => { clearTimeout(timer); reject(error); });
second.once("exit", (code, signal) => { clearTimeout(timer); resolve({ code, signal }); });
});
assert.deepEqual(result, { code: 0, signal: null });
assert.equal(alive(shell.pid), true);
await application.close();
application = null;
assert.equal(shell.exitCode, 0, "the installed shell must exit normally");
await until(() => hosts.every((pid) => !alive(pid)), "the bundled kernel outlived the installed shell", 10000);
console.log(`installed ${arch} ${version}: packaged shell, renderer RPC, second launch and clean shutdown passed`);
} catch (error) {
console.error(diagnostics);
throw error;
} finally {
if (application) await application.close();
fs.rmSync(home, { recursive: true, force: true });
}
}
run().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { test } from "node:test";
const require = createRequire(import.meta.url);
const { elfArchitecture, elfInterpreter } = require("../packaging/elf.js");
const { verifyLinuxTree } = require("../packaging/linux-tree.js");
const { name } = require("../package.json");
function elf(arch, dynamic = false) {
const bytes = Buffer.alloc(160);
bytes.writeUInt32BE(0x7f454c46);
bytes[4] = 2;
bytes[5] = 1;
bytes[6] = 1;
bytes.writeUInt16LE(arch === "arm64" ? 183 : 62, 18);
if (dynamic) {
bytes.writeBigUInt64LE(64n, 32);
bytes.writeUInt16LE(56, 54);
bytes.writeUInt16LE(1, 56);
bytes.writeUInt32LE(3, 64);
bytes.writeBigUInt64LE(120n, 72);
bytes.writeBigUInt64LE(12n, 96);
bytes.write("/lib/ld.so\0", 120);
}
return bytes;
}
function fixture(t, arch) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "studio-linux-tree-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const app = path.join(root, "opt", "Reasonix Studio");
const files = {
shell: path.join(app, name),
host: path.join(app, "resources/bin/reasonix-studio-host"),
helper: path.join(root, "usr/lib/reasonix-studio/reasonix-studio-update-helper"),
library: path.join(app, "libEGL.so"),
};
for (const file of Object.values(files)) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, elf(arch));
}
return { root, ...files };
}
for (const arch of ["amd64", "arm64"]) {
test(`the ${arch} shell, host, helper and libraries agree on the package architecture`, (t) => {
const tree = fixture(t, arch);
assert.equal(verifyLinuxTree(tree.root, arch), tree.shell);
assert.equal(elfArchitecture(elf(arch)), arch);
assert.equal(elfInterpreter(elf(arch)), null);
});
for (const member of ["shell", "host", "helper", "library"]) {
test(`a ${arch} package refuses a ${member} from the other architecture`, (t) => {
const tree = fixture(t, arch);
fs.writeFileSync(tree[member], elf(arch === "arm64" ? "amd64" : "arm64"));
assert.throws(() => verifyLinuxTree(tree.root, arch), /expected/);
});
}
for (const member of ["host", "helper"]) {
test(`a ${arch} package refuses a dynamically linked ${member}`, (t) => {
const tree = fixture(t, arch);
fs.writeFileSync(tree[member], elf(arch, true));
assert.throws(() => verifyLinuxTree(tree.root, arch), /must be static/);
});
}
}
test("missing or non-ELF required executables cannot pass the package check", (t) => {
const tree = fixture(t, "arm64");
fs.writeFileSync(tree.helper, "#!/bin/sh\n");
assert.throws(() => verifyLinuxTree(tree.root, "arm64"), /not an ELF/);
fs.unlinkSync(tree.host);
assert.throws(() => verifyLinuxTree(tree.root, "arm64"), /expected one packaged host/);
});
test("unsupported ELF encodings and release architectures fail closed", (t) => {
const bytes = elf("arm64");
bytes[5] = 2;
assert.throws(() => elfArchitecture(bytes), /little-endian/);
const tree = fixture(t, "arm64");
assert.throws(() => verifyLinuxTree(tree.root, "aarch64"), /unsupported release architecture/);
});
test("ARM64 RPM naming is normalized before the manifest consumes it", async (t) => {
const { record, built } = require("../packaging/arch.js");
const hook = require("../packaging/canonical-artifact-names.js");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "studio-arm64-name-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const deb = path.join(dir, "ReasonixStudio-linux-arm64.deb");
const rpm = path.join(dir, "ReasonixStudio-linux-aarch64.rpm");
for (const file of [deb, rpm]) {
fs.writeFileSync(file, "fixture");
record(file, "arm64");
t.after(() => built.delete(path.resolve(file)));
}
const files = await hook({ artifactPaths: [deb, rpm] });
assert.deepEqual(files.map((file) => path.basename(file)), ["ReasonixStudio-linux-arm64.deb", "ReasonixStudio-linux-arm64.rpm"]);
assert.ok(fs.existsSync(files[1]));
});
+1 -1
View File
@@ -84,7 +84,7 @@ async function run() {
placed: typeof window.reasonixHost?.pathForFile(new File(['x'], 'x.txt')),
}))()`);
const verbs = ["answerBrowserLogin", "closeWindow", "controlBrowserView", "freezeBrowserView", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "pathForFile", "pickFolder", "platform", "revealPath", "saveBytes", "saveText", "shell", "showBrowserView", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
const verbs = ["answerBrowserLogin", "closeWindow", "controlBrowserView", "freezeBrowserView", "hideBrowserView", "isWindowMaximised", "minimiseWindow", "navigateBrowserView", "onBrowserLoadState", "onBrowserLogin", "openExternal", "openWorkspace", "pathForFile", "pickFolder", "platform", "revealPath", "saveBytes", "saveText", "shell", "showBrowserView", "titleBar", "toggleMaximiseWindow", "trustBrowserCertificate"];
check("the bridge exposes verbs and nothing else", JSON.stringify(seen.bridge) === JSON.stringify(verbs), seen.bridge);
check("the credential never reaches the page", !seen.cookie.includes("reasonix_token"), seen.cookie);
check("the renderer has no node of its own", seen.globals.every((t) => t === "undefined"), seen.globals);
+404 -12
View File
@@ -6,13 +6,14 @@ import os from "node:os";
import fs from "node:fs";
const require = createRequire(import.meta.url);
const cuaContract = require("../../../internal/platform/computer/cua-driver.lock.json");
const { parse, readActs, start } = require("../src/host.js");
const { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller } = require("../src/editmenu.js");
const { uiLanguage } = require("../src/uilang.js");
const { externalTarget } = require("../src/links.js");
const { offerCleanup, ownBundle } = require("../src/legacy.js");
const { stripPackageGrants, readReport, unpaintedWindowCause } = require("../src/packagegrants.js");
const { groundFor, GROUND } = require("../src/ground.js");
const { groundFor, themeSourceFor, GROUND } = require("../src/ground.js");
const { pick, loadPrefs, savePrefs, registerPrefs } = require("../src/prefs.js");
const TOKEN = "a".repeat(64);
@@ -205,7 +206,7 @@ test("an unreachable kernel is an answer, not a crash", async () => {
assert.equal(await dead.trayState(), null);
});
const { reveal, revealWorkspace } = require("../src/reveal.js");
const { reveal, revealWorkspace, revealLogs, openWorkspace } = require("../src/reveal.js");
// A kernel that answers /workspace/locate as the test says, and a shell that
// only records what it was asked to open.
@@ -224,11 +225,121 @@ async function revealRig(answer, platform = process.platform) {
openPath: async (p) => (opened.push(["open", p]), ""),
showItemInFolder: (p) => opened.push(["select", p]),
};
return { asked, opened, run: (base, rel) => reveal(client, shell, base, rel, platform), workspace: (root) => revealWorkspace(client, shell, root, platform), close: () => server.close() };
return {
asked, opened, close: () => server.close(),
run: (base, rel) => reveal(client, shell, base, rel, platform),
workspace: (root) => revealWorkspace(client, shell, root, platform),
open: (root, fsApi) => openWorkspace(client, shell, root, platform, fsApi),
};
}
// The sidebar's project entered rather than selected in its parent: the same
// kernel answer revealWorkspace asks for, through the folder-opening verb.
const folderFs = (over = {}) => ({
stat: async () => ({ isDirectory: () => true }),
access: async () => {
throw new Error("ENOENT");
},
...over,
});
test("openWorkspace enters the folder the kernel named", async (t) => {
for (const [platform, target] of [[process.platform, ROOT], ["darwin", "/Users/studio/workspace"], ["linux", "/home/studio/workspace"], ["win32", "C:\\Studio\\workspace"]]) {
await t.test(`${platform}: ${target}`, async () => {
const rig = await revealRig(() => [200, { path: target, dir: true }], platform);
try {
assert.equal(await rig.open("/w", folderFs()), null);
assert.deepEqual(rig.opened, [["open", target]]);
assert.equal(rig.asked[0], "/host/workspaces/locate?root=%2Fw");
} finally {
rig.close();
}
});
}
});
// openPath launches an application bundle instead of showing it, so a root
// that is one is only selected, and so is anything that is not a directory.
test("openWorkspace only selects an application bundle", async () => {
const bundle = "/Applications/Workspace.app";
const rig = await revealRig(() => [200, { path: bundle, dir: true }], "darwin");
try {
assert.equal(await rig.open("/w", folderFs({ access: async () => {} })), null);
assert.deepEqual(rig.opened, [["select", bundle]]);
} finally {
rig.close();
}
});
test("openWorkspace only selects what is not a directory or cannot be read", async () => {
for (const fsApi of [folderFs({ stat: async () => ({ isDirectory: () => false }) }), folderFs({ stat: async () => { throw new Error("EACCES"); } })]) {
const rig = await revealRig(() => [200, { path: ROOT, dir: true }]);
try {
assert.equal(await rig.open("/w", fsApi), null);
assert.deepEqual(rig.opened, [["select", ROOT]]);
} finally {
rig.close();
}
}
});
test("openWorkspace refuses a folder the hub does not list", async () => {
const rig = await revealRig(() => [404, { code: "host.unknown_workspace", error: "not listed" }]);
try {
const why = await rig.open("/nope");
assert.equal(why.code, "host.unknown_workspace");
assert.deepEqual(rig.opened, []);
} finally {
rig.close();
}
});
const ROOT = path.resolve(os.tmpdir(), "rx-workspace");
test("file opening refuses scripts, executables and unknown types on every platform", async () => {
const { openFile } = require("../src/reveal.js");
const opened = [];
const shell = { openPath: async (target) => { opened.push(target); return ""; } };
for (const platform of ["darwin", "win32", "linux"]) {
for (const name of ["task.command", "task.sh", "task.bat", "task.cmd", "task.ps1", "task.lnk", "task.jar", "task.desktop", "task.scpt", "task.exe", "task.EXE", "report.pdf.exe", "task", "task.unknown", "page.html", "image.svg"]) {
const target = platform === "win32" ? `C:\\work\\${name}` : `/work/${name}`;
const client = { request: async () => ({ status: 200, body: JSON.stringify({ path: target }) }) };
const why = await openFile(client, shell, "/rt/r1", "report.pdf", platform, { stat: async () => ({ isFile: () => true }) });
assert.ok(why?.error, `${platform}: ${name} must be refused`);
}
}
assert.deepEqual(opened, []);
});
test("file opening accepts kernel-located documents and media on every platform", async () => {
const { openFile } = require("../src/reveal.js");
for (const platform of ["darwin", "win32", "linux"]) {
for (const name of ["report.pdf", "notes.TXT", "report.docx", "table.xlsx", "slides.pptx", "data.csv", "notes.md", "image.PNG", "photo.jpg", "video.mp4", "audio.mp3"]) {
const target = platform === "win32" ? `C:\\work\\${name}` : `/work/${name}`;
const opened = [];
const shell = { openPath: async (p) => { opened.push(p); return ""; } };
const client = { request: async () => ({ status: 200, body: JSON.stringify({ path: target }) }) };
assert.equal(await openFile(client, shell, "/rt/r1", name, platform, { stat: async () => ({ isFile: () => true }) }), null);
assert.deepEqual(opened, [target]);
}
}
});
test("file opening uses only a kernel-located regular file and reports shell failures", async () => {
const { openFile } = require("../src/reveal.js");
const opened = [];
const shell = { openPath: async (target) => { opened.push(target); return "no application"; } };
const client = { request: async () => ({ status: 200, body: JSON.stringify({ path: path.join(ROOT, "report.pdf") }) }) };
assert.deepEqual(await openFile(client, shell, "/rt/r1", "report.pdf", process.platform, { stat: async () => ({ isFile: () => true }) }), { error: "no application", code: "" });
assert.deepEqual(opened, [path.join(ROOT, "report.pdf")]);
opened.length = 0;
assert.ok(await openFile(client, shell, "/rt/r1", "folder", process.platform, { stat: async () => ({ isFile: () => false }) }));
assert.ok(await openFile(client, shell, "https://attacker.test", "report.pdf"));
const refused = { request: async () => ({ status: 400, body: JSON.stringify({ code: "workspace.path_outside_tree", error: "outside" }) }) };
assert.equal((await openFile(refused, shell, "/rt/r1", "../secret.pdf")).code, "workspace.path_outside_tree");
assert.deepEqual(opened, []);
});
test("reveal opens only what the kernel located inside the pane's workspace", async () => {
const rig = await revealRig((url) => {
const rel = url.searchParams.get("path") ?? "";
@@ -324,7 +435,7 @@ test("on Windows a share or device path from the kernel is refused", async () =>
}
});
const { hostBinary, computerHelper, pageDir } = require("../src/layout.js");
const { hostBinary, computerDriver, pageDir } = require("../src/layout.js");
// Packaged, both live in resources/ beside app.asar. Reading them from inside
// it is the failure this pins: a child process cannot be spawned out of an
@@ -342,13 +453,13 @@ test("the kernel and the page are found in both layouts", () => {
}
});
test("the computer-use helper is found beside the kernel on macOS and Windows, and nowhere else", () => {
test("the CUA Driver is found beside the kernel on all supported platforms", () => {
const mac = { packaged: true, resourcesPath: "/res", dirname: "/d/src", platform: "darwin" };
assert.equal(computerHelper(mac), path.join("/res", "bin", "reasonix-computer-helper"));
assert.equal(computerHelper({ ...mac, packaged: false, dirname: path.join("/repo", "electron", "src") }), path.join("/repo", "electron", "bin", "reasonix-computer-helper"));
assert.equal(computerHelper({ ...mac, platform: "win32" }), path.join("/res", "bin", "reasonix-computer-helper.exe"));
assert.equal(computerHelper({ ...mac, platform: "linux" }), "");
assert.equal(computerHelper({ ...mac, platform: "linux", env: { REASONIX_COMPUTER_HELPER: "/custom/helper" } }), "/custom/helper");
assert.equal(computerDriver(mac), path.join("/res", "bin", "cua-driver"));
assert.equal(computerDriver({ ...mac, packaged: false, dirname: path.join("/repo", "electron", "src") }), path.join("/repo", "electron", "bin", "cua-driver"));
assert.equal(computerDriver({ ...mac, platform: "win32" }), path.join("/res", "bin", "cua-driver.exe"));
assert.equal(computerDriver({ ...mac, platform: "linux" }), path.join("/res", "bin", "cua-driver"));
assert.equal(computerDriver({ ...mac, platform: "linux", env: { REASONIX_CUA_DRIVER: "/custom/cua-driver" } }), "/custom/cua-driver");
});
test("Windows gets the suffix spawn needs, and an override wins over both", () => {
@@ -908,7 +1019,7 @@ test("only the Studio window reads or writes the preferences", () => {
fs.rmSync(dir, { recursive: true, force: true });
});
function elf64(interpreter) {
function elf64(interpreter, machine = 62) {
const phoff = 64, phentsize = 56, phnum = interpreter ? 2 : 1;
const dataOffset = phoff + phnum * phentsize;
const path = Buffer.from(interpreter ? `${interpreter}\0` : "", "latin1");
@@ -918,7 +1029,7 @@ function elf64(interpreter) {
bytes[5] = 1;
bytes[6] = 1;
bytes.writeUInt16LE(2, 16);
bytes.writeUInt16LE(62, 18);
bytes.writeUInt16LE(machine, 18);
bytes.writeBigUInt64LE(BigInt(phoff), 32);
bytes.writeUInt16LE(64, 52);
bytes.writeUInt16LE(phentsize, 54);
@@ -954,6 +1065,26 @@ test("a Linux Go binary that needs the build host's dynamic loader is refused",
}
});
test("a Linux package's Go binaries must be built for the architecture the package is named for", () => {
const { elfArchitecture, assertArchitecture } = require("../packaging/elf.js");
assert.equal(elfArchitecture(elf64(null, 62)), "amd64");
assert.equal(elfArchitecture(elf64(null, 183)), "arm64");
assert.throws(() => elfArchitecture(elf64(null, 40)), /unsupported ELF machine/);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-elf-"));
try {
const host = path.join(dir, "reasonix-studio-host");
const helper = path.join(dir, "reasonix-studio-update-helper");
fs.writeFileSync(host, elf64(null, 62));
fs.writeFileSync(helper, elf64(null, 183));
assert.throws(() => assertArchitecture([host, helper], "arm64"), /is amd64, expected arm64/);
assert.throws(() => assertArchitecture([host, helper], "amd64"), /is arm64, expected amd64/);
assert.doesNotThrow(() => assertArchitecture([host], "amd64"));
assert.throws(() => assertArchitecture([host], "riscv64"), /unsupported release architecture/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("a Wayland session with XWayland relaunches the shell on X11 before anything else starts", () => {
const { relaunchForOzonePlatform } = require("../src/ozone.js");
const X11 = "--ozone-platform=x11";
@@ -1722,6 +1853,16 @@ test("the window ground follows the stored theme and, for auto, the system", ()
assert.equal(groundFor(undefined, true), GROUND.dark);
});
// Windows draws a frameless window's border from the frame's scheme, so a dark
// page on a light system must set it, or the border is a light line.
test("the window frame takes the stored theme, and auto follows the system", () => {
assert.equal(themeSourceFor({ "rx-theme": "dark" }), "dark");
assert.equal(themeSourceFor({ "rx-theme": "light" }), "light");
assert.equal(themeSourceFor({ "rx-theme": "auto" }), "system");
assert.equal(themeSourceFor({ "rx-theme": "midnight" }), "system");
assert.equal(themeSourceFor(undefined), "system");
});
const { accelerationOff, shouldDisableGpu, graphicsReport, graphicsHandler, PREF_KEY: GPU_PREF } = require("../src/graphics.js");
test("hardware acceleration is off only when the saved preference says exactly that", () => {
@@ -1987,6 +2128,44 @@ test("kernel acts: show brings the window forward, relaunch arms the restart, qu
assert.deepEqual(calls, ["show", "relaunch", "quit"]);
});
test("kernel acts: a click that reaches the shell is written to its log, an unknown act is not", () => {
const { actHandler } = require("../src/acts.js");
const lines = [];
const handle = actHandler({ app: { relaunch() {}, quit() {} }, showWindow() {}, log: (line) => lines.push(line) });
for (const act of ["show", "unknown"]) handle(act);
assert.deepEqual(lines, ["kernel act: show"]);
});
test("raise: answers whether the window is focused once it has tried", () => {
const { raise } = require("../src/raise.js");
const make = (grants) => {
let focused = false;
return { show() {}, focus: () => { if (grants()) focused = true; }, isFocused: () => focused, setAlwaysOnTop() {} };
};
assert.equal(raise(make(() => true), "win32"), true);
assert.equal(raise(make(() => false), "win32"), false);
});
test("raise: a window the platform would not focus is lifted once and returned to normal stacking", () => {
const { raise } = require("../src/raise.js");
const run = (platform, focusedAfterFocus) => {
const calls = [];
let focused = false;
const win = {
show: () => calls.push("show"),
focus: () => { calls.push("focus"); if (focusedAfterFocus) focused = true; },
isFocused: () => focused,
setAlwaysOnTop: (on) => calls.push(on ? "top" : "untop"),
};
raise(win, platform);
return calls;
};
assert.deepEqual(run("win32", true), ["show", "focus"]);
assert.deepEqual(run("win32", false), ["show", "focus", "top", "focus", "untop"]);
assert.deepEqual(run("darwin", false), ["show", "focus"]);
assert.deepEqual(run("linux", false), ["show", "focus"]);
});
test("tray: the menu is replaced only when what it says changes, so an open menu is not torn down by the poll", async () => {
const { installTray } = require("../src/tray.js");
const menus = [];
@@ -2049,3 +2228,216 @@ test("tray: a refused close-to-tray save redraws the checkbox, and a menu that f
assert.equal(menus[1].template.find((item) => item.type === "checkbox").checked, false);
tray.close();
});
test("revealLogs opens only the folder the shell itself names, and reports why it could not", async () => {
const opened = [];
const shell = { openPath: async (target) => (opened.push(target), "") };
assert.equal(await revealLogs(shell, "/user/logs"), null);
assert.deepEqual(opened, ["/user/logs"]);
const refusing = { openPath: async () => "no application is associated with this folder" };
assert.deepEqual(await revealLogs(refusing, "/user/logs"), { error: "no application is associated with this folder" });
assert.deepEqual(await revealLogs(shell, ""), { error: "the logs folder is unknown" });
});
// The built-in browser drew a white page after switching away from a tab and
// back until the window was resized: a parked view moved onscreen with
// setBounds alone does not repaint. Bringing it back attaches it again.
test("a parked page is attached again when it is shown, and a page already on screen is left alone", () => {
const Module = require("node:module");
const load = Module._load;
Module._load = function (request, ...rest) {
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
return load.call(this, request, ...rest);
};
let BrowserViews;
try {
({ BrowserViews } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const calls = [];
const win = {
on() {},
isDestroyed: () => false,
getContentBounds: () => ({ width: 1200, height: 800 }),
contentView: { addChildView: () => calls.push("add"), removeChildView: () => calls.push("remove") },
};
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const view = { setBounds: (b) => calls.push("bounds " + b.x), webContents: { isDestroyed: () => false } };
views.entries.set("t1", { view, at: null });
const rect = { x: 10, y: 20, width: 600, height: 400 };
views.show("t1", rect);
assert.deepEqual(calls, ["remove", "add", "bounds 10"]);
calls.length = 0;
views.show("t1", rect);
assert.deepEqual(calls, [], "the same rectangle on a page already shown does no work");
views.hide();
calls.length = 0;
views.show("t1", rect);
assert.deepEqual(calls, ["remove", "add", "bounds 10"], "a page put away and shown again is attached again");
});
test("rewind notice dismissal survives the desktop preference disk round trip", (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rewind-notice-prefs-"));
const file = path.join(dir, "prefs.json");
t.after(() => { fs.rmSync(file, { force: true }); fs.rmdirSync(dir); });
const record = { "rx-rewind-notice-dismissed:tx-saved": "1" };
savePrefs(file, record);
assert.deepEqual(loadPrefs(file), record);
});
const { setUnreadBadge } = require("../src/badge.js");
test("macOS marks and clears the application Dock badge", () => {
const badges = [];
const app = { dock: { setBadge: (text) => badges.push(text) } };
setUnreadBadge(true, {}, { app }, "darwin");
setUnreadBadge(false, {}, { app }, "darwin");
assert.deepEqual(badges, ["•", ""]);
});
test("Windows uses a red dot overlay and removes it when all sessions are read", () => {
const calls = [];
const win = { setOverlayIcon: (...args) => calls.push(args) };
let pixels;
const nativeImage = { createFromBitmap: (buffer, size) => { pixels = buffer; assert.deepEqual(size, { width: 16, height: 16 }); return "dot"; } };
setUnreadBadge(true, win, { nativeImage }, "win32");
setUnreadBadge(false, win, { nativeImage }, "win32");
const at = (x, y) => [...pixels.subarray((y * 16 + x) * 4, (y * 16 + x) * 4 + 4)];
assert.equal(pixels[3], 0);
assert.deepEqual(at(8, 8), [64, 64, 224, 255]);
assert.equal(at(1, 8)[3], 0, "the dot stays well inside the 16px canvas");
assert.deepEqual(at(12, 10), [255, 255, 255, 255], "a white ring separates it from the icon");
assert.ok(at(13, 8)[3] > 0 && at(13, 8)[3] < 255, "the rim is smoothed, not jagged");
assert.deepEqual(calls, [["dot", "Reasonix Studio"], [null, ""]]);
});
test("Linux clears its launcher badge without requiring Dock APIs", () => {
const counts = [];
const app = { setBadgeCount: (count) => counts.push(count) };
setUnreadBadge(true, {}, { app }, "linux");
setUnreadBadge(false, {}, { app }, "linux");
assert.deepEqual(counts, [1, 0]);
});
test("CUA readiness refuses a different process, host or driver version", () => {
const { accepts } = require("../src/cua");
const result = { pid: 123, embedded: true, host_bundle_id: "io.reasonix.studio", driver_version: "0.34.1", contract_version: "0.8.0", tools_list_schema_version: "1", capability_version: "1", mcp_protocol_version: "2025-06-18" };
assert.equal(accepts({ ok: true, result }, 123, cuaContract), true);
for (const [key, value] of Object.entries({ pid: 124, embedded: false, host_bundle_id: "other", driver_version: "0.35.0", contract_version: "0.9.0", mcp_protocol_version: "2026-07-28" })) {
assert.equal(accepts({ ok: true, result: { ...result, [key]: value } }, 123, cuaContract), false);
}
assert.equal(accepts({ ok: false, result }, 123), false);
});
test("CUA interruption waits for the owned service to stop", async () => {
const { spawn } = require("node:child_process");
const { stopProcess } = require("../src/cua");
const child = spawn(process.execPath, ["-e", "process.stdin.resume();setInterval(()=>{},1000);process.stdin.on('end',()=>setTimeout(()=>process.exit(0),80))"], { stdio: ["pipe", "ignore", "ignore"], windowsHide: true });
await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
await stopProcess(child, true);
assert.ok(child.exitCode !== null || child.signalCode !== null);
});
test("CUA embedded control stops the old service before admitting a fresh one", { skip: process.platform === "win32" }, async () => {
const { spawn } = require("node:child_process");
const net = require("node:net");
const { startEmbedded, metadata } = require("../src/cua");
const fake = path.join(path.dirname(require("node:url").fileURLToPath(import.meta.url)), "cua-service-fixture.js");
const owned = await startEmbedded(process.execPath, cuaContract, () => {}, (_exe, args, options) => spawn(process.execPath, [fake, ...args], options));
try {
const before = await metadata(owned.socket);
const reply = await new Promise((resolve, reject) => {
const peer = net.createConnection(owned.abortSocket);
let text = "";
peer.once("error", reject);
peer.on("connect", () => peer.write('{"op":"abort"}\n'));
peer.on("data", bytes => { text += bytes; if (text.includes("\n")) { peer.destroy(); resolve(JSON.parse(text)); } });
});
assert.equal(reply.ok, true);
const after = await metadata(owned.socket);
assert.notEqual(after.result.pid, before.result.pid);
assert.throws(() => process.kill(before.result.pid, 0));
} finally { await owned.stop(); }
assert.equal(fs.existsSync(owned.socket), false);
assert.equal(fs.existsSync(owned.abortSocket), false);
});
test("CUA repeated quit waits for owned driver cleanup", async () => {
const { createQuitGate } = require("../src/cua");
let resolveStop;
const stopped = new Promise(resolve => { resolveStop = resolve; });
let stops = 0, quits = 0, prevented = 0;
const gate = createQuitGate(() => ({ stop: () => { stops++; return stopped; } }), () => { quits++; }, assert.fail);
const event = { preventDefault: () => { prevented++; } };
gate(event);
gate(event);
await Promise.resolve();
assert.equal(stops, 1);
assert.equal(prevented, 2);
assert.equal(quits, 0);
resolveStop();
await new Promise(resolve => setImmediate(resolve));
assert.equal(quits, 1);
gate(event);
assert.equal(prevented, 2);
});
test("CUA extraction handles ZIP archives when GNU tar precedes the Windows reader", { skip: process.platform !== "win32" || !fs.existsSync("C:/Program Files/Git/usr/bin/tar.exe") }, () => {
const { spawnSync } = require("node:child_process");
const tar = "C:/Program Files/Git/usr/bin/tar.exe";
const stage = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-cua-extract-"));
try {
fs.writeFileSync(path.join(stage, "driver.zip"), Buffer.from("UEsDBBQAAAAIANomTV3uQOUFCQAAAAcAAAAOAAAAY3VhLWRyaXZlci5leGVLy6woKS1KBQBQSwECFAAUAAAACADaJk1d7kDlBQkAAAAHAAAADgAAAAAAAAAAAAAAgAEAAAAAY3VhLWRyaXZlci5leGVQSwUGAAAAAAEAAQA8AAAANQAAAAAA", "base64"));
const target = path.join(stage, "extracted");
fs.mkdirSync(target);
const script = `require(${JSON.stringify(require.resolve("../packaging/install-cua-driver"))}).extract(process.argv[1], process.argv[2])`;
const unpacked = spawnSync(process.execPath, ["-e", script, path.join(stage, "driver.zip"), target], { env: { ...process.env, PATH: path.dirname(tar) + path.delimiter + process.env.PATH } });
assert.equal(unpacked.status, 0, unpacked.stderr.toString());
assert.equal(fs.readFileSync(path.join(target, "cua-driver.exe"), "utf8"), "fixture");
} finally {
assert.ok(path.resolve(stage).startsWith(path.resolve(os.tmpdir()) + path.sep));
fs.rmSync(stage, { recursive: true, force: true });
}
});
test("CUA layout resolves one canonical contract in development and packaging", () => {
const { computerDriverContract } = require("../src/layout");
const dirname = path.dirname(require("node:url").fileURLToPath(import.meta.url));
const source = computerDriverContract({ packaged: false, dirname: path.join(dirname, "../src") });
assert.deepEqual(JSON.parse(fs.readFileSync(source, "utf8")), cuaContract);
assert.equal(computerDriverContract({ packaged: true, resourcesPath: "/res" }), path.join("/res", "bin", "cua-driver.lock.json"));
});
const { captureHandler, MAX_WIDTH } = require("../src/capture.js");
function fakeImage(width, { empty = false } = {}) {
return {
isEmpty: () => empty,
getSize: () => ({ width, height: 10 }),
resize: (opts) => fakeImage(opts.width),
toPNG: () => Buffer.from(`png:${width}`),
};
}
test("the window capture answers only the studio window and takes no target", async () => {
let asked = 0;
const win = { webContents: { capturePage: async (...args) => (asked++, assert.equal(args.length, 0), fakeImage(800)) } };
const handle = captureHandler((event) => (event.sender === "studio" ? win : null));
const png = await handle({ sender: "studio" }, "some-other-window-id", { x: 0, y: 0 });
assert.equal(Buffer.from(png).toString(), "png:800");
assert.equal(await handle({ sender: "a browser page" }), null);
assert.equal(asked, 1);
});
test("the window capture is scaled down past the upload-friendly width and refuses an empty frame", async () => {
const wide = captureHandler(() => ({ webContents: { capturePage: async () => fakeImage(MAX_WIDTH * 2) } }));
assert.equal(Buffer.from(await wide({})).toString(), `png:${MAX_WIDTH}`);
const blank = captureHandler(() => ({ webContents: { capturePage: async () => fakeImage(0, { empty: true }) } }));
assert.equal(await blank({}), null);
});
+1
View File
@@ -2,4 +2,5 @@ node_modules
dist
dist-perf
*.png
!public/icons/*.png
tsconfig.tsbuildinfo
+7
View File
@@ -7,6 +7,13 @@
<link rel="preconnect" href="https://remote.reasonix.io" crossorigin />
<link rel="dns-prefetch" href="//id.reasonix.io" />
<link rel="dns-prefetch" href="//remote.reasonix.io" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<meta name="theme-color" content="#07080c" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Reasonix" />
<title>Reasonix Studio</title>
<script>
// The boot screen below is painted by this file, before the bundle that
+11 -1
View File
@@ -12,6 +12,8 @@
"census:verify": "node tools/ui-census/verify.mjs"
},
"dependencies": {
"@a2ui/react": "0.13.0",
"@a2ui/web_core": "0.13.0",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
@@ -30,18 +32,26 @@
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.14",
"@lezer/highlight": "^1.2.5",
"@mantine/charts": "9.7.2",
"@mantine/core": "9.7.2",
"@mantine/hooks": "9.7.2",
"@noble/curves": "2.4.0",
"codemirror": "^6.0.2",
"katex": "^0.19.0",
"maplibre-gl": "6.14.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-map-gl": "8.1.3",
"react-markdown": "^10.1.0",
"recharts": "3.10.1",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gemoji": "^8.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0"
"remark-math": "^6.0.0",
"zod": "3.25.76"
},
"devDependencies": {
"@babel/parser": "^8.0.7",
+5
View File
@@ -61,6 +61,7 @@ node perf/concurrent.mjs # 多会话并发的守卫:9 个会话同时流式,
node perf/wheel.mjs # 滚轮轨迹(只报告、不判红):向上滚时 scrollTop 不倒走、单步不超滚轮、scrollHeight 不缩水;WHEEL_STRICT=1 判红
node perf/idle.mjs # 闲置的守卫:看不见的东西不许动,闲着的窗口不许烧 CPU
node perf/effects.mjs # Effects guard: a sky pack idles quietly, a hidden window draws no sky, Reduced removes blur, sky and grain
node perf/text-glow.mjs # Transcript glow, pack switching and prose contrast across schemes, wallpapers, widths, effects and motion
node perf/shipped.mjs # 产物的守卫:dist 里的东西必须比源码新(构建可以成功而编的是别的树)
node perf/reason.mjs # 内核拒绝的双语落地:同一个码,中英各说各的
node perf/budget.mjs # 预算的守卫:输入框上下堆满东西时,转录还剩多少
@@ -74,6 +75,10 @@ node perf/dock.mjs # 停靠的浏览器列从 pane 顶部起、与导航行
node perf/signals.mjs # 运行分析的信号轨说明:它是什么、为什么标记不能拖(浅/深 × 宽/手机)
node perf/narrow.mjs # 窄窗口 + 1.8 倍缩放:标题栏与输入区的行互不重叠、不越出窗口(浅/深 × 390/320 × 1.8/1.3,另加宽窗口对照)
node perf/sidebar.mjs # 侧栏开关只有一个且位置不变(图标栏 开/仅收起时/关 × 浅/深);同一模型连续回复只在首条带头
node perf/stickyfold.mjs # 展开的长执行过程:滚动时头部吸在顶部、有自己的底(浅/深)
node perf/crumb.mjs # 标题栏里会话标题紧跟项目名,长名字也不溢出(宽/窄)
node perf/hoverradius.mjs # 输入框底行的按钮悬停时圆角和底色一致(浅/深)
node perf/sessionrows.mjs # 会话行样式:34px、无额外行间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
```
@@ -0,0 +1,38 @@
// Adding a project selects it and opens a new session there: the sidebar marks
// the new folder as the current one, and no other folder keeps that mark.
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.ADDPROJECT_SHOTS;
if (SHOTS) mkdirSync(SHOTS, { recursive: true });
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1280, height: 800 }, colorScheme: scheme });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(PAGE, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".machpick");
const current = () => page.evaluate(() => [...document.querySelectorAll(".wsnode[data-current]")].map((el) => el.textContent ?? ""));
const was = await current();
if (SHOTS) await page.screenshot({ path: join(SHOTS, `${scheme}-before.png`) });
await page.locator(".machpick").first().click();
await page.waitForFunction(() => [...document.querySelectorAll(".wsnode[data-current]")].some((el) => el.textContent?.includes("mock-workspace")), null, { timeout: 5000 }).catch(() => {});
const now = await current();
check(`${scheme}: the added project becomes the current one`, now.length === 1 && now[0].includes("mock-workspace"), now.join("|").slice(0, 120));
check(`${scheme}: it was not already current`, !was.some((text) => text.includes("mock-workspace")));
if (SHOTS) await page.screenshot({ path: join(SHOTS, `${scheme}-after.png`) });
await ctx.close();
}
await browser.close();
if (fails.length) {
console.error(`\n${fails.length} failed`);
process.exit(1);
}
@@ -0,0 +1,49 @@
// The "+" that opens a project folder sits on the machine row where a person
// looks for it, and is there before the row is hovered. It was drawn only on
// hover once, and two people on 2.39.0 reported that they could not choose a
// folder at all. Opacity and hit-testing need a real browser.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
try {
for (const theme of ["light", "dark"]) {
for (const width of [1280, 1600]) {
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: theme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".machrow[data-here]");
await page.mouse.move(width - 2, 790);
await page.waitForTimeout(300);
const g = await page.evaluate(() => {
const row = document.querySelector(".machrow[data-here]");
const add = row?.querySelector('[data-action="workspace.add"]');
if (!add) return null;
const r = add.getBoundingClientRect();
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
let seen = 1;
for (let el = add; el && el !== document.body; el = el.parentElement) seen *= Number(getComputedStyle(el).opacity);
return { opacity: seen, width: r.width, height: r.height, hits: hit === add || add.contains(hit), shown: row.matches(":hover") };
});
const where = `${theme} ${width}`;
check(`${where}: the machine row has the add-folder button`, g !== null);
if (!g) continue;
check(`${where}: it is visible without hovering the row`, !g.shown && g.opacity === 1 && g.width > 0 && g.height > 0, JSON.stringify(g));
check(`${where}: it takes the pointer`, g.hits);
await page.close();
}
}
} finally {
await browser.close();
}
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall passed");
@@ -0,0 +1,35 @@
// Opening the branch menu while the pointer is still over the branch chip must not leave the hover card showing:
// the two surfaces would stack their shadows. Visibility under :hover is a styling question, so it is asked of a real browser.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
try {
const page = await (await browser.newContext({ locale: "zh-CN", viewport: { width: 1280, height: 800 } })).newPage();
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 20000 });
const chip = page.locator('.studio-branch-pop .studio-branch').first();
await chip.waitFor();
const card = page.locator(".studio-branch-card").first();
const shown = () => card.evaluate((el) => getComputedStyle(el).visibility === "visible" && Number(getComputedStyle(el).opacity) > 0.5);
await chip.hover();
await page.waitForTimeout(400);
check("the hover card shows over a closed chip", await shown());
await chip.click();
await page.waitForTimeout(500);
check("the branch menu opened", (await chip.getAttribute("aria-expanded")) === "true");
check("the hover card is gone while the menu is open", !(await shown()));
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
+43
View File
@@ -146,6 +146,49 @@ for (const state of ["idle", "running"]) {
}
check("扫到的对话列覆盖 360–700px", [...swept].some((w) => w <= 360) && [...swept].some((w) => w >= 700), [...swept].sort((a, b) => a - b).slice(0, 1).concat([...swept].sort((a, b) => b - a).slice(0, 1)).join("–"));
// An untouched phone composer leaves room to read; focus or a draft restores
// the editing controls without replacing the textarea or its caret.
const phone = () => page.evaluate(() => {
const box = document.querySelector('textarea[role="combobox"]');
const shown = (selector) => document.querySelector(selector)?.getBoundingClientRect().height > 0;
const tools = [...document.querySelectorAll(".turntools > *")]
.filter((el) => el.getBoundingClientRect().height > 0);
const rows = new Set(tools.map((el) => {
const r = el.getBoundingClientRect();
return Math.round((r.top + r.height / 2) / 2);
}));
return { height: box.getBoundingClientRect().height, line: parseFloat(getComputedStyle(box).lineHeight),
refine: shown(".studio-refine"), branch: shown(".studio-branch-pop"), rows: rows.size };
});
await page.fill(BOX, "");
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); });
await page.setViewportSize({ width: 390, height: 760 });
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(BOX);
await page.click('[role="tab"][aria-selected="true"]');
await frame();
const quiet = await phone();
check("手机:空置输入只占一行", quiet.height <= quiet.line + 1);
check("手机:空置时收起优化提示,分支提示仍在", !quiet.refine && quiet.branch);
check("手机:空置工具栏只占一行", quiet.rows === 1);
await page.click(BOX);
await frame();
const focused = await phone();
check("手机:聚焦恢复编辑空间与优化入口", focused.height > quiet.height && focused.refine);
await page.fill(BOX, "保留草稿\n第二行\n第三行");
await page.click('[role="tab"][aria-selected="true"]');
await frame();
const draft = await phone();
check("手机:未聚焦草稿仍展开", draft.height >= draft.line * 3 - 1 && draft.refine);
check("手机:展开后草稿原样保留", await page.inputValue(BOX) === "保留草稿\n第二行\n第三行");
await page.fill(BOX, "");
await page.getByRole("button", { name: "引用到输入框", exact: true }).last().click();
await page.waitForSelector(".shots");
await page.click('[role="tab"][aria-selected="true"]');
await frame();
const quoted = await phone();
check("手机:仅有引用附件也保留编辑空间", quoted.height > quiet.height && quoted.refine);
await browser.close();
if (fails.length) {
console.error(`\n${fails.length} 项不合格:\n ` + fails.join("\n "));
+45
View File
@@ -0,0 +1,45 @@
// The session title sits right beside the project name in the title bar, at every width, and the
// project name still gives way (ellipsis) before the title is pushed off. Layout is a real-browser question.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=2";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
try {
for (const width of [1400, 1000, 700]) {
const page = await browser.newPage({ viewport: { width, height: 640 } });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".chrome .crumb-proj");
const at = await page.evaluate(() => {
const box = (sel) => document.querySelector(sel).getBoundingClientRect();
const sep = box(".chrome .crumbsep"), title = box(".chrome .crumb > b");
const projEl = document.querySelector(".chrome .crumb-proj");
const range = document.createRange();
range.selectNodeContents(projEl);
const text = range.getBoundingClientRect();
return { gap: Math.round(sep.left - text.right), titleRight: Math.round(title.right) };
});
check(`${width}px: the title follows the project name`, at.gap >= 0 && at.gap <= 12, `gap ${at.gap}px`);
check(`${width}px: the title stays on screen`, at.titleRight <= width, `right edge ${at.titleRight}px`);
const long = await page.evaluate(() => {
document.querySelector(".chrome .crumb-proj").textContent = "a-very-long-project-folder-name-".repeat(8);
document.querySelector(".chrome .crumb > b").textContent = "a very long session title ".repeat(8);
const crumb = document.querySelector(".chrome .crumb").getBoundingClientRect();
const title = document.querySelector(".chrome .crumb > b").getBoundingClientRect();
return { overflow: Math.round(title.right - crumb.right), titleWidth: Math.round(title.width) };
});
check(`${width}px: long names stay inside the title bar`, long.overflow <= 0 && long.titleWidth > 0, `title overflows by ${long.overflow}px`);
await page.close();
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
+47
View File
@@ -0,0 +1,47 @@
// Settings and feedback centre on the window, not on the area below the title
// bar; a phone-width window keeps settings full screen. Geometry needs a real browser.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const box = (page, sel) =>
page.evaluate((s) => {
const r = document.querySelector(s)?.getBoundingClientRect();
return r ? { top: Math.round(r.top), bottom: Math.round(innerHeight - r.bottom), height: Math.round(r.height) } : null;
}, sel);
const browser = await chromium.launch();
for (const [w, h] of [[1600, 1000], [1280, 760], [390, 800]]) {
const page = await browser.newPage({ viewport: { width: w, height: h }, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?turns=5&pref=en`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.keyboard.press("Meta+Comma");
await page.waitForSelector(".prefs-sheet");
await page.waitForTimeout(300);
const prefs = await box(page, ".prefs-sheet");
await page.keyboard.press("Escape");
await page.waitForSelector(".prefs-sheet", { state: "detached" });
await page.evaluate(() => document.querySelector('[data-action="feedback.open"]')?.click());
await page.waitForSelector(".fbk");
await page.waitForTimeout(300);
const fbk = await box(page, ".fbk");
const tag = `${w}x${h}`;
if (w > 760) {
check(`${tag}: settings sits in the window's vertical middle`, Math.abs(prefs.top - prefs.bottom) <= 2, JSON.stringify(prefs));
check(`${tag}: feedback sits in the window's vertical middle`, Math.abs(fbk.top - fbk.bottom) <= 2, JSON.stringify(fbk));
} else {
check(`${tag}: settings still reaches the bottom of a phone-width window`, prefs.bottom === 0, JSON.stringify(prefs));
}
await page.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+8 -4
View File
@@ -17,7 +17,9 @@ const geometry = (page) =>
const r = el?.getBoundingClientRect();
return r && r.width > 0 ? { left: r.left, right: r.right, top: r.top, bottom: r.bottom } : null;
};
return { pane: box(".pane"), tabs: box(".pane > .tabs"), side: box('.pane > .scroll[data-pane="browser"]'), body: box(".pane > .pbody") };
const card = document.querySelector('.pane > .scroll[data-pane="browser"] > .workbench');
const divider = card ? parseFloat(getComputedStyle(card).borderLeftWidth) : 0;
return { pane: box(".pane"), tabs: box(".pane > .tabs"), side: box('.pane > .scroll[data-pane="browser"]'), body: box(".pane > .pbody"), strip: box(".workbench-tabs"), divider };
});
const browser = await chromium.launch();
@@ -31,7 +33,7 @@ for (const scheme of ["light", "dark"]) {
for (const round of ["first open", "after a reload"]) {
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('[data-action="browser.open"]').first().click();
await page.locator('[data-action="pane.view"][data-value="browser"]').first().click();
await page.waitForTimeout(700);
const open = await geometry(page);
docked = open;
@@ -41,13 +43,15 @@ for (const scheme of ["light", "dark"]) {
check(`${tag} ${round}: it starts at the top of the pane, beside the navigation row`, Math.abs(open.side.top - open.pane.top) <= 1 && Math.abs(open.tabs.top - open.pane.top) <= 1, `pane ${open.pane.top} tabs ${open.tabs.top} column ${open.side.top}`);
check(`${tag} ${round}: it runs to the bottom of the pane`, Math.abs(open.side.bottom - open.pane.bottom) <= 1, `${open.side.bottom} against ${open.pane.bottom}`);
check(`${tag} ${round}: the navigation row stays left of it`, open.tabs.right <= open.side.left + 1, `${open.tabs.right} / ${open.side.left}`);
check(`${tag} ${round}: its strip ends on the navigation row's line, so the two rows read as one`, !!open.strip && Math.abs(open.strip.bottom - open.tabs.bottom) <= 1, JSON.stringify([open.tabs, open.strip]));
check(`${tag} ${round}: the workbench card's own edge separates it from the conversation`, open.divider >= 1, String(open.divider));
} else {
check(`${tag} ${round}: the navigation row stays above the page`, !!open.tabs && open.side.top >= open.tabs.bottom - 1, JSON.stringify([open.tabs, open.side]));
}
}
if (SHOTS && round === "first open") await page.screenshot({ path: `${SHOTS}/${process.env.DOCK_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
}
await page.locator('[data-action="browser.open"]').first().click();
await page.locator('[data-action="pane.view"][data-value="flow"]').first().click();
await page.waitForTimeout(700);
const shut = await geometry(page);
check(`${tag} undocked: the column is gone and the navigation row is as wide as the pane's body again`, (!shut.side || shut.side.left >= shut.pane.right - 1) && !!shut.tabs && shut.tabs.right - shut.tabs.left >= shut.pane.right - shut.pane.left - 24, JSON.stringify([shut.side, shut.tabs, shut.pane]));
@@ -63,7 +67,7 @@ for (const scheme of ["light", "dark"]) {
await page.setViewportSize({ width, height: 800 });
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('[data-action="browser.open"]').first().click();
await page.locator('[data-action="pane.view"][data-value="browser"]').first().click();
await page.waitForTimeout(400);
const g = await page.evaluate(() => {
const tabs = document.querySelector(".pane > .tabs");
+20
View File
@@ -134,6 +134,26 @@ const timeLoops = (page) =>
await ctx.close();
}
{
// The sidebar's footer is part of the rail: a plate of its own over the
// wallpaper reads as a second, whiter panel at the window's lower left.
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ viewport: { width: 1200, height: 760 }, colorScheme: scheme });
await ctx.addInitScript(() => localStorage.setItem("rx-nav-rail", "off"));
const page = await ctx.newPage();
await page.goto(`${BASE.replace(/\?.*/, "")}?pack=photo`, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 15000 });
await page.mouse.click(8, 407);
await page.waitForSelector(".railfoot", { timeout: 5000 });
const paint = await page.evaluate(() => {
const alpha = (c) => (c.startsWith("rgba(") ? Number(c.split(",")[3].replace(")", "")) : c.includes("/") ? Number(c.split("/")[1].replace(")", "")) : 1);
return alpha(getComputedStyle(document.querySelector(".railfoot")).backgroundColor);
});
check(`${scheme}: the sidebar footer paints no plate over the wallpaper`, paint === 0, `alpha ${paint}`);
await ctx.close();
}
}
await browser.close();
console.log(fails.length ? `\n${fails.length} failed:\n- ${fails.join("\n- ")}` : "\nall passed");
process.exit(fails.length ? 1 : 0);
@@ -0,0 +1,49 @@
// The explorer in the workbench is read at arm's length, next to a conversation set in a 14px reading size.
// Its header, search field, file rows and the icons beside the count must stay big enough to read and to hit.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=2";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1400, height: 900 } });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('.navbtn[aria-label="文件"]').first().click();
await page.waitForSelector(".workbench-explorer-head");
await page.waitForSelector(".workbench-filelist button");
const read = await page.evaluate(() => {
const px = (el, prop) => parseFloat(getComputedStyle(el)[prop]);
const head = document.querySelector(".workbench-explorer-head");
const btn = head.querySelector(".workbench-open-editor");
const row = document.querySelector(".workbench-filelist button");
const search = document.querySelector(".workbench-search input");
return {
head: px(head, "fontSize"),
button: btn.getBoundingClientRect().width,
icon: btn.querySelector("svg").getBoundingClientRect().width,
row: px(row, "fontSize"),
search: px(search, "fontSize"),
rowIcon: row.querySelector("svg").getBoundingClientRect().width,
};
});
check(`${scheme}: the header is at least 12px`, read.head >= 12, `${read.head}px`);
check(`${scheme}: the header buttons are at least 28px with 16px icons`, read.button >= 28 && read.icon >= 16, `${read.button}px / ${read.icon}px`);
check(`${scheme}: file rows are at least 12px with 15px icons`, read.row >= 12 && read.rowIcon >= 15, `${read.row}px / ${read.rowIcon}px`);
check(`${scheme}: the search field is at least 12px`, read.search >= 12, `${read.search}px`);
await page.close();
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
Loaded 100 of 1561 files, more files were not shown because too many files have changed in this diff. Show more