Compare commits

...
465 Commits
Author SHA1 Message Date
esengine 56795d8913 docs(release): studio 2.45.0 notes 2026-10-18 17:56:42 -07:00
YHH 5163088956 Merge pull request #13853 from esengine/fix/browser-fit-v2
fix(studio): a page with a viewport of its own is centred at its size or scaled to fit, and the agent's clicks are mapped by the scale
2026-10-18 17:55:29 -07:00
esengine a0589c8ad0 test(studio): pixel checks that local html, a 3200x1800 image document and a PDF fill the built-in browser, with and without an agent viewport 2026-10-18 17:37:07 -07:00
YHH 6318ab0ae4 Merge pull request #13840 from esengine/fix/13833-root-missing
fix(agent): refuse a call whose project or external folder is gone, with one typed cause
2026-10-18 16:56:47 -07:00
esengine d536418db3 refactor(agent): lift the workspace lease hold out of prepareToolExecution; drop the test-only IsRootMissing 2026-10-18 16:39:50 -07:00
esengine 77134b5ef0 Merge studio into fix/13833-root-missing 2026-10-18 16:27:14 -07:00
YHH 8d714ba296 Merge pull request #13825 from esengine/feat/13320-bound-candidate
fix(compaction): retry a refused summary request at a smaller size
2026-10-18 16:23:24 -07:00
esengine f73a5b1507 docs(release-notes): wrap the root-missing fragment under 200 columns 2026-10-18 16:08:32 -07:00
esengine 814dbd5402 Merge remote-tracking branch 'origin/studio' into feat/13320-bound-candidate 2026-10-18 16:08:06 -07:00
YHH fdaf05584f Merge pull request #13820 from imcreazy/fix/explorer-after-closing-a-file
fix(studio): closing a file opened from the explorer comes back to the explorer
2026-10-18 14:41:38 -07:00
YHH dfa8f70ea4 Merge pull request #13877 from esengine/a9/cost-effect
test(boot): a DeepSeek-shaped OpenAI-compatible usage reaches the quote split by cache
2026-10-18 14:31:52 -07:00
YHH 54ca8a7847 Merge pull request #13876 from esengine/fix/13642-subagent-images
fix(delegation): a delegate gets the pasted images its task names, and a skill sub-agent is routed to the vision model
2026-10-18 14:29:31 -07:00
YHH f2ac8905ad Merge pull request #13875 from esengine/a9/versions-refresh-watch
fix(studio): opening the Versions page checks for a release when automatic download is chosen
2026-10-18 14:25:46 -07:00
esengine 1b25fca477 test(boot): a DeepSeek-shaped OpenAI-compatible usage reaches the quote split by cache (local, not pushed) 2026-10-18 14:12:49 -07:00
esengine fe6c7c3a91 fix(studio): opening the Versions page checks for a release when automatic download is chosen
Refs #13683
2026-10-18 14:10:17 -07:00
YHH e741c70074 Merge pull request #13874 from esengine/a9/fix-feedbackmine-selector
test(feedback): read the status chips inside the list, not the first one on the page
2026-10-18 14:10:02 -07:00
esengine c0d08b437c fix(delegation): a delegate gets the pasted images its task names, and a skill sub-agent is routed to the vision model
A task text that names a file in the host's own .reasonix/attachments directory
now gives the delegate that image (judged by the resolved path, read through the
same checks an @-reference gets), on the vision role with the note that it rides
the message, including on a follow-up turn that carried no image. The
read_only_skill runner took the turn's candidates without routing the model or
saying so, so a text-only skill sub-agent dropped them silently.
2026-10-18 14:09:16 -07:00
esengine f800585f31 test(feedback): read the status chips inside the list, not the first one on the page 2026-10-18 13:55:04 -07:00
YHH 45c763ce15 Merge pull request #13814 from esengine/fix/shell-mode-stuck
fix(studio): a refused ! command stays selected in the box so the next message replaces it
2026-10-18 13:36:21 -07:00
YHH 8e1ea87adc Merge pull request #13873 from esengine/fix/13872-tui-stream-append
fix(tui): appending to a streamed answer, its thinking or a command's output no longer copies it whole
2026-10-18 13:09:42 -07:00
esengine 30c63e6023 fix(tui): appending to a streamed answer, its thinking or a command's output no longer copies it whole
Each delta did s += piece on the row's text, so a long stream cost time and
memory quadratic in its length: 20000 progress events of one command (4.5 MB)
took about 13 s of the UI's own time. The text now grows in a buffer kept beside
the row, trusted only while the row still holds exactly what it last handed out.
2026-10-18 12:53:36 -07:00
YHH 80fcd85b54 Merge pull request #13843 from esengine/feat/11411-effort-in-place
feat(effort): changing reasoning depth no longer rebuilds the session and works mid-turn
2026-10-18 11:54:24 -07:00
esengine 0ce5ce6436 Merge studio into feat/11411-effort-in-place 2026-10-18 11:40:16 -07:00
YHH 27ad7ded21 Merge pull request #13871 from esengine/fix/13684-open-lookup
fix(serve): match an open pane's row whatever the case of its transcript name
2026-10-18 11:04:42 -07:00
YHH fe0020056e Merge pull request #13869 from esengine/a9/usage-billing-mode
fix(usage): keep a subscription's estimate out of the paid total
2026-10-18 10:56:03 -07:00
YHH f5d2874d61 Merge pull request #13868 from esengine/fix/jump-resolve-flaky
test(remote): bound the real-ssh jump test by the run's budget, not the product's 15 s
2026-10-18 10:49:57 -07:00
YHH de2763b21e Merge pull request #13866 from esengine/feat/13142-board-studio
feat(feedback): the contribution board in Studio and the terminal, through the kernel
2026-10-18 10:49:14 -07:00
esengine 3b54ee9438 fix(serve): match an open pane's row whatever the case of its transcript name 2026-10-18 10:44:09 -07:00
YHH 0d50803ab1 Merge pull request #13842 from esengine/feat/11531-lease-release
feat(workspace): a session waiting on a person yields its write claim, and says so when another session took it
2026-10-18 10:41:02 -07:00
esengine d8abd5b5ce fix(usage): keep a subscription's estimate out of the paid total
Refs #13276
2026-10-18 10:32:04 -07:00
esengine e6ec598a58 feat(feedback): the contribution board in Studio and the terminal, through the kernel 2026-10-18 10:31:58 -07:00
YHH 78cb353fd8 Merge pull request #13865 from esengine/fix/13858-port-identity
fix(studio): two model services on one host and different ports are two services, and a duplicate is never dropped from the list
2026-10-18 10:25:50 -07:00
YHH bf1ff861ad Merge pull request #13864 from esengine/a9/price-provenance2
feat(billing): a model's price can say where it was read and when
2026-10-18 10:22:42 -07:00
YHH a925403b6f Merge pull request #13860 from esengine/a5/opencode-go-gateway
fix(providers): recognise OpenCode Go under its documented /inference/go prefix
2026-10-18 10:21:17 -07:00
YHH 38060091bd Merge pull request #13862 from esengine/test/control-git-push-trust
test(control): cover the push trust gate and hold control/git_push.go to it
2026-10-18 10:20:58 -07:00
esengine d4089914ea test(remote): the real-ssh jump resolution test bounds ssh by the run's budget, not the product's 15 s
The product keeps its 15 second bound on one ssh -G; the source now carries it
as a field with that default, and the test that runs the installed ssh sets it
to testenv.Budget, a bound that only reports a hang.
2026-10-18 10:17:52 -07:00
YHH 7b499ffaeb Merge pull request #13863 from esengine/a5/effort-undeclared
fix(effort): say a model's levels are undeclared, and where to declare them
2026-10-18 10:13:03 -07:00
YHH 5d6743aa2c Merge pull request #13844 from esengine/a9/acp-permission-mode
feat(acp): --permission-mode fixes the session posture and caps what a client can raise
2026-10-18 10:12:13 -07:00
esengine add01e5f04 fix(studio): a viewport override with a zero or non-finite size means no override 2026-10-18 10:11:53 -07:00
esengine 223a158a02 Merge origin/studio into feat/11531-lease-release 2026-10-18 10:06:32 -07:00
esengine ad7e7f835f fix(studio): two model services on one host and different ports are two services, and a duplicate is never dropped from the list 2026-10-18 09:58:38 -07:00
imcreazy 481aa9f9a6 fix(studio): closing a file opened from the explorer comes back to the explorer 2026-10-19 00:58:10 +08:00
esengine 1766c04904 feat(billing): a model's price can say where it was read and when
Refs #13276
2026-10-18 09:56:46 -07:00
YHH ea0538a72e Merge pull request #13851 from esengine/fix/13335-sibling-folders-domain
fix(workspace): a writer with no declared extent holds its own folder, not the whole repository
2026-10-18 09:56:07 -07:00
esengine 5888922ffc refactor(i18n): keep the /effort undeclared wording in the setting catalogue 2026-10-18 09:53:32 -07:00
esengine 94e236f203 docs(release): note the /effort wording fix 2026-10-18 09:51:17 -07:00
esengine 8ce086e1bc fix(effort): say a model's levels are undeclared, and where to declare them 2026-10-18 09:51:16 -07:00
esengine e1e5301681 test(control): cover the push trust gate and its refusals, and hold the file to it
internal/session/control/git_push.go measured 0 from the control package's own
tests, so declaring it sensitive recorded a floor of 0 that could never fail.
The tests cover the trust refusal for both calls, a repository that is not
there, a branch with no upstream, and the commit lock released after a refused
push; the floor is now 100.
2026-10-18 09:47:51 -07:00
esengine 7b8f7de8b0 docs(release): note the OpenCode Go endpoint fix 2026-10-18 09:45:27 -07:00
esengine fe45ff0a8c fix(providers): recognise OpenCode Go under its documented /inference/go prefix 2026-10-18 09:43:43 -07:00
esengine ae9fba3fd0 fix(workspace): a writer with no declared extent holds its own folder, not the whole repository
Two project folders under one git repository share a writer domain on purpose
(the lock and the visibility of each other's claims), but an undeclared writer
claimed the domain root, so sibling projects waited on each other for bash and
MCP writes, and the refusal read held X, requested X. It now claims the
selected folder; a session on the repository root still overlaps both, and a
declared path that cannot be resolved still claims the whole repository.
2026-10-18 09:36:18 -07:00
YHH ebe4f2dca2 Merge pull request #13850 from esengine/c5/sensitive-git-push
chore(security): declare the Git push path and the host-only route list sensitive
2026-10-18 09:31:49 -07:00
esengine 817e3fa03d fix(studio): a page with a viewport of its own is centred at its size or scaled to fit, and the agent's clicks are mapped by the scale 2026-10-18 09:31:00 -07:00
YHH 442f05cd11 Merge pull request #13846 from xxoingr/test/boot-catalog-close-budget
test(boot): bound the catalog close in cleanup by the hang budget
2026-10-18 09:24:13 -07:00
esengine 8126f5cf1e chore(security): declare the Git push path and the host-only route list sensitive
The push is the one git action Reasonix performs that leaves the machine, and
the host-only route list decides which requests a paired device may make. Both
now demand review plus security_review and are held to the coverage they have:
gitpush 84.0, serve/git_push.go 77.0, serve/host_routes.go 93.0, and
control/git_push.go 0 (its behaviour is covered by the serve and boot effect
tests, which the per-package gate does not count).
2026-10-18 09:17:17 -07:00
YHH 904ca4359b Merge pull request #13834 from esengine/feat/git-panel
feat(studio): one Git panel: a changes tree, discard, push with a preview, commit details and a section per repository
2026-10-18 09:10:38 -07:00
esengine c5e8080866 feat(workspace): a session waiting on a person yields its write claim, and says so when another session took it
An Auto Guard card and a call that asks before it writes now give the claim
back for the wait; the lease records granted claims so the resume can name
who took the ground, the model gets workspace.claim_contended and the receipt
workspace_claim_yielded. A blocked acquisition ends at a user-set maximum wait
with workspace.claim_timeout.
2026-10-18 09:08:45 -07:00
xxoingr e4a0841fec test(boot): bound the catalog close in cleanup by the hang budget
closeBootTestHistoryCatalog gave the history and usage catalog closes a
fixed 5 s. Closing a SQLite catalog, and waiting out an open still in
flight, can take longer on a loaded Windows runner, and the posture
effect test failed there in cleanup with "close usage catalogs: context
deadline exceeded". The wait only exists to report a hang, so use
testenv.Budget; a close that never returns still fails the same way.
2026-10-19 00:07:46 +08:00
esengine ab66297bab feat(acp): --permission-mode fixes the session posture and caps what a client can raise
Refs #13839
2026-10-18 09:00:03 -07:00
YHH 02341bf950 Merge pull request #13763 from esengine/a9/tools-disabled
feat(tools): turn tools off by name, one registry removal for every surface
2026-10-18 08:55:30 -07:00
esengine a52b35551f feat(session): a sub-agent and the status line follow an in-place depth change
A child that shares the parent's provider starts on the depth in force now, and one
that inherits the parent's model without an effort of its own is built for it, as a
rebuild would have given. The status line reads the depth the session is on.
2026-10-18 08:51:08 -07:00
esengine edc7e01bb4 feat(session): change reasoning depth in place when the provider takes it per request
A provider that exposes the depths it accepts in Request.EffortOverride
(provider.RequestEffortProvider; the OpenAI-shaped adapter does) lets a session change
depth without a rebuild: the controller sets a session depth every main request
carries, updates its running selection, and accepts the change mid-turn. Serve (and so
the terminal and the desktop) and ACP both apply it in place and keep their rebuild for
a depth the provider does not take per request. The level is still saved to the user's
config so a restart opens on it.
2026-10-18 08:51:08 -07:00
esengine a07ca77b69 fix(git): repositories are listed once and kept until the structure changes, reads are settled and not repeated, a kernel without the route keeps the old behaviour 2026-10-18 08:50:37 -07:00
esengine 6b653a7f32 fix(agent): refuse a call whose project or external folder is gone, with one typed cause 2026-10-18 08:46:27 -07:00
YHH d8f9eef43d Merge branch 'studio' into a9/tools-disabled 2026-10-18 08:42:35 -07:00
YHH 89d86f4d3f Merge pull request #13838 from esengine/a9/peak-indicator
feat(status): show which of the vendor's billing windows the model is in
2026-10-18 08:42:12 -07:00
esengine f8e16bfc29 feat(status): show which of the vendor's billing windows the model is in
The vendor's published schedule already prices every request; the status bar
now says, live, whether the model is billed at the peak or the off-peak rate
and when that changes, where the model is billed at the vendor's own rate.
2026-10-18 08:16:23 -07:00
esengine 30c6589451 wip peak indicator 2026-10-18 08:16:16 -07:00
esengine 0608ffeb63 fix(git): a repository name and a path are taken from a directory listing and from git's own list, never built from the request 2026-10-18 08:10:18 -07:00
YHH da9fbe7307 Merge pull request #13836 from esengine/a5/phone-push-service-error
fix(phone): say a browser that cannot reach its push service is that, not an account outage
2026-10-18 08:02:05 -07:00
YHH c38beaf09a Merge pull request #13835 from esengine/fix/13811-browser-fit-wide-viewport
fix(studio): show a page the agent laid out wider than the pane whole, scaled to fit
2026-10-18 08:01:11 -07:00
esengine a3a8c1ff79 fix(serve): host-only routes are one list the handlers and the remote proxy both read; windows fixtures and a HEAD read that failed is not a detached HEAD 2026-10-18 07:56:16 -07:00
esengine aa60b7f48b fix(serve): a paired device cannot preview or send a push or discard a change 2026-10-18 07:52:20 -07:00
esengine 040aa7dbde fix(phone): say a browser that cannot reach its push service is that, not an account outage 2026-10-18 07:48:27 -07:00
esengine ad9219543b fix(studio): show a page the agent laid out wider than the pane whole, scaled to fit 2026-10-18 07:44:17 -07:00
YHH 5f4c9ffad7 Merge pull request #13822 from esengine/a5/phone-push
feat(phone): remind this phone through Web Push, with its own key and a push-only service worker
2026-10-18 07:32:53 -07:00
YHH e5be3420b3 Merge pull request #13832 from esengine/a5/step-up-effect
test(mcp): hold step-up authorization at the boot boundary
2026-10-18 07:31:52 -07:00
YHH d130baa87c Merge pull request #13828 from esengine/a9/balance-format
feat(billing): a balance endpoint declares the format it answers in
2026-10-18 07:28:27 -07:00
esengine d294509df6 docs(release): the Git panel 2026-10-18 07:27:42 -07:00
esengine 18d679077d feat(studio): one Git panel: a changes tree to stage, discard and commit, push with a preview, commit details, and a section per repository 2026-10-18 07:27:41 -07:00
esengine a81f06208c feat(git): push the current branch to its upstream from a throwaway repository, with a preview and typed refusals 2026-10-18 07:27:41 -07:00
esengine 617a7c40d6 feat(git): a commit opens into its files and each diff, and one changed file can be discarded 2026-10-18 07:27:41 -07:00
esengine 344db1d16b feat(git): a workspace folder of projects lists its repositories, and every git read and write names which one 2026-10-18 07:27:41 -07:00
YHH aa34e20192 Merge pull request #13830 from esengine/fix/thought-time-test
test: three timing-dependent tests no longer depend on wall-clock thresholds or cleanup races
2026-10-18 07:25:46 -07:00
YHH 9f19b31fdf Merge pull request #13791 from esengine/a9/queue-paused
fix(peers): a message the person let in starts over a paused queue, by what paused it
2026-10-18 07:23:09 -07:00
esengine 8dcbc55218 test(mcp): gofmt 2026-10-18 07:12:58 -07:00
esengine 8993a8eec1 test(mcp): drop an unneeded Sprintf 2026-10-18 07:12:58 -07:00
esengine a40d996915 test(mcp): hold step-up authorization at the boot boundary 2026-10-18 07:12:58 -07:00
esengine 1119118780 Merge origin/studio into a5/phone-push 2026-10-18 07:12:44 -07:00
esengine 92b39e3ed0 feat(billing): a balance endpoint declares the format it answers in
A balance_url on a host that is not a vendor's own now names its format
(balance_format) or is not read, and the key is not sent to it. The connection
editor edits the address and format. Existing relay entries are declared as
deepseek once, and a format the 1.x line drops from the shared file is put back
at the next start.
2026-10-18 07:10:10 -07:00
YHH 6b8928a8c3 Merge pull request #13782 from esengine/a5/appimage
feat(release): build and verify an AppImage of Studio on Linux (download only)
2026-10-18 07:08:12 -07:00
YHH 6b0c9ef061 Merge pull request #13784 from esengine/a5/remote-agent-guard
feat(cli): refuse remote subcommands that change the book or open a link for an agent's shell
2026-10-18 07:07:31 -07:00
YHH 6e9294dd99 Merge branch 'studio' into a9/queue-paused 2026-10-18 07:07:27 -07:00
esengine 424343a8b4 test(boot): the mod-queued-prompt test waits for the turn it started before the temp dirs go
The queued prompt starts a turn of its own, still writing under the test's temp
directory when the test returned, so the directory's cleanup failed with 'directory not
empty'. Seen as 30 failures in 40 runs with eight test processes at once, 0 in 40 after.
2026-10-18 07:02:34 -07:00
esengine 118310d400 test(control): the heredoc cancel test waits for the shell to start, not for 2 seconds
The test gave a cold Git Bash two seconds to write the heredoc's file; on a shared
Windows runner the start can take longer, and the failure (4.25 s) was that wait plus
the cancel. The bound is now 15 seconds, only to keep a hang from lasting forever.
2026-10-18 07:02:34 -07:00
esengine 334598db44 test(boot): the thinking-time effect test asserts a measurement was taken, not a wall-clock size
The test compared the host's measured thought to the provider's 60 ms sleep minus 10,
which a loaded -race runner breaks (42 ms against 50). The size of the measurement is
the thought clock's own unit test with an injected clock; this test keeps its claim that
a measurement is taken, kept with the turn and framed identically.
2026-10-18 07:02:34 -07:00
YHH c58a1d7cb3 Merge pull request #13827 from esengine/a5/acp-sdk-conformance
test(acp): drive the agent with the official SDK and hold its output to the shipped schema
2026-10-18 07:01:43 -07:00
YHH 5b3968c6f5 Merge pull request #13795 from esengine/a5/push-host
feat(host): tell enrolled phones that something is waiting, and manage them from the desktop
2026-10-18 06:56:27 -07:00
YHH 26d7a21bf6 Merge pull request #13809 from esengine/chore/deps-go-group-with-desktop
chore(deps): bump the go group with 11 updates, including the desktop module
2026-10-18 06:56:15 -07:00
esengine f676e1d61a ci: run checks against studio 2026-10-18 06:48:32 -07:00
esengine 22bfd09c40 Merge remote-tracking branch 'origin/studio' into a5/remote-agent-guard 2026-10-18 06:48:32 -07:00
esengine 14ea889c81 Merge remote-tracking branch 'origin/studio' into a5/appimage 2026-10-18 06:46:34 -07:00
esengine d73352f4a6 test(acp): drive the agent with the official SDK and hold its output to the shipped schema 2026-10-18 06:44:42 -07:00
esengine 751def6d32 chore(deps): bump the go group with 11 updates, including the desktop module 2026-10-18 06:37:48 -07:00
YHH d8f84ed690 Merge pull request #13819 from esengine/fix/dispatch-profile-selection
fix(delegation): the dispatch profile stays the call's selection
2026-10-18 06:33:48 -07:00
esengine a26328f24b style(compaction): wrap the last refusal 2026-10-18 06:29:11 -07:00
esengine f7bc03da36 fix(compaction): retry a refused summary request at a smaller size
A summary request the provider rejects as larger than its context (the documented
context_length_exceeded code, nothing else) is retried at 70%, 50% and 35% of the
refused input, shortened by the same deterministic steps as before; a refused request
costs nothing, so the digest is not given up until three smaller inputs were refused.
A provider that refuses every size ends as summary_input_too_large.
2026-10-18 06:28:05 -07:00
esengine 79d971d607 feat(phone): remind this phone through Web Push, with its own key and a push-only service worker 2026-10-18 06:19:59 -07:00
YHH 97d7009cbb Merge pull request #13804 from esengine/dependabot/github_actions/actions-e40781d787
chore(deps): bump the actions group with 2 updates
2026-10-18 06:09:58 -07:00
esengine ba25d7c854 fix(peers): a message the person let in starts over a paused queue, by what paused it
The inbox manifest now records why a queue is paused, as a typed code beside
the old boolean. A message approved on its card is claimed over any pause; one
let in by a standing release or dispatch switch only over a pause the host set
to guard stale input, and stays queued over one the person set. The pause and
older queued items are left as they were.
2026-10-18 06:07:09 -07:00
esengine 002b09a921 fix(delegation): the dispatch profile stays the call's selection; the actual model rides the status events
#13812 made the dispatch-time profile the concrete runtime. The effort-switch audit
test (merged beside it) holds that profile to what the call selected (empty model and
effort when it inherits), so studio went red. The selection is audited as the model
asked it; what the child ran on is on its status events, which the window already
reads, so the dispatch profile goes back to the selection.
2026-10-18 06:05:44 -07:00
esengine 7f21f635ab feat(tools): turn tools off by name, one registry removal for every surface
[tools].disabled removes a tool from every request and refuses a call to it with
the reason; tools another feature requires declare so and stay on with a note;
a project file can only add names. Settings lists every tool with a switch.
2026-10-18 06:03:20 -07:00
YHH bda7ee6772 Merge pull request #13816 from esengine/a9/readme-dsh
docs: point to the DeepSeek Harness integration from the README and the guide
2026-10-18 05:53:30 -07:00
YHH 7df0c74c55 Merge pull request #13807 from esengine/dependabot/npm_and_yarn/desktop/electron/npm-76902424de
chore(deps-dev): bump the npm group in /desktop/electron with 2 updates
2026-10-18 05:53:07 -07:00
YHH 63cfcee555 Merge pull request #13799 from esengine/dependabot/go_modules/desktop/go-1d423e97b0
chore(deps): bump golang.org/x/mod from 0.41.0 to 0.42.0 in /desktop in the go group
2026-10-18 05:52:35 -07:00
YHH a151994365 Merge pull request #13798 from chuiwenwei/fix/compaction-stale-context-not-latched
fix(agent): release a stale-context maintenance block on the growth that caused it
2026-10-18 05:52:16 -07:00
YHH 5b1c282fed Merge pull request #13812 from esengine/feat/13640-child-model
feat(delegation): sub-agents show the model and effort they ran on
2026-10-18 05:47:23 -07:00
YHH 1b0b593f66 Merge pull request #13813 from esengine/a5/search-model-not-added
fix(settings): do not offer search models from connections that were never added
2026-10-18 05:44:33 -07:00
esengine edbe588256 docs: point to the DeepSeek Harness integration from the README and the guide
The integration lives in docs/DSH.md; the README and the guide now say it
exists and give the three steps and the index address in the README.
2026-10-18 05:43:50 -07:00
YHH ed9a4ad1c9 Merge pull request #13778 from esengine/feat/13694-mod-rulings
feat(extension): a mod answers a tool call, adds turn context, reads the session and queues a prompt
2026-10-18 05:42:40 -07:00
YHH 31d39290ec Merge pull request #13808 from esengine/dependabot/npm_and_yarn/desktop/frontend-next/npm-8a0a74f25b
chore(deps): bump the npm group in /desktop/frontend-next with 7 updates
2026-10-18 05:30:14 -07:00
YHH 245b85ec13 Merge pull request #13638 from SivanCola/codex/unified-project-sidebar
Unify local and SSH project navigation / 统一本机与 SSH 项目侧栏
2026-10-18 05:29:51 -07:00
YHH f43d985bda Merge pull request #13797 from chuiwenwei/fix/memory-finish-interrupted-rename
fix(memory): repair a rename interrupted after retiring the old file
2026-10-18 05:22:13 -07:00
esengine 0556027a94 fix(studio): a refused ! command stays selected in the box so the next message replaces it 2026-10-18 05:18:25 -07:00
esengine 44d3acfaf5 fix(desktop): the row keeps the model name and nothing in front of or behind it 2026-10-18 05:18:06 -07:00
esengine 8f691039d3 fix(desktop): the sub-agent row gives way on the provider, not the model name 2026-10-18 05:16:44 -07:00
esengine bcd54401f5 docs(release): shorten the search-model note 2026-10-18 05:09:12 -07:00
esengine 2f3ef27558 fix(settings): do not offer search models from connections that were never added 2026-10-18 05:04:45 -07:00
esengine e2c0aeb1a2 refactor(delegation): keep the child identity record in its own file 2026-10-18 05:00:56 -07:00
esengine b5c891a593 feat(desktop): sub-agents show the model and effort they ran on
The panel row, the full-record header and the nested card of the spawning call show
provider/model · effort, with the full reference as the title. The card takes it
from the child's status events, so a failed or interrupted run keeps it; a batch's
items each carry their own.
2026-10-18 05:00:56 -07:00
esengine 88982d21ad feat(delegation): every status of a child names the model and effort it ran on
The dispatch profile now names the concrete runtime a call resolves to (an alias
expanded, the parent's model when the call inherits), and each child's status events,
from queued to the terminal one, carry what its run actually started with, per item
for a batch. A failed or interrupted child therefore still says what it ran on.
2026-10-18 05:00:56 -07:00
dependabot[bot] 6e91c80dac chore(deps): bump the npm group in /desktop/frontend-next with 7 updates
Bumps the npm group in /desktop/frontend-next with 7 updates:

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


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

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

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

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

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

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

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

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

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


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

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

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

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


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

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

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

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


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

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-18 11:52:28 +00:00
YHH c88d1715d4 Merge pull request #13790 from esengine/fix/terminal-feedback-surface
fix(cli): name the terminal's feedback surface in its first build so /feedback works from the start
2026-10-18 04:49:39 -07:00
YHH 8078d62b03 Merge pull request #13789 from esengine/a9/dsh-enable-step
docs(dsh): an installed plugin starts off and must be switched on
2026-10-18 04:43:20 -07:00
Will 944c61f9e6 test(memory): cover the interrupted-rename pairing and name-conflict edges
Two controls the audit's contract implies but nothing pinned: a staged copy whose
retired twin came from a different publish is left untouched (no pair, no writes),
and a fact created at the old name after the kill is never overwritten — the retry
refuses and keeps both artifacts for a later attempt. The refusal only named the
conflict once the exist check unwrapped the error, so it now uses
errors.Is(err, fs.ErrExist).
2026-10-18 19:42:39 +08:00
YHH 3f98212608 Merge pull request #13246 from chuiwenwei/fix/sessioninbox-unreadable-blobs-not-empty
fix(sessioninbox): stop publishing an empty queue when the bodies cannot be read
2026-10-18 04:42:33 -07:00
esengine 37892a1738 test(release): the artifact check requires the AppImage for both architectures 2026-10-18 04:40:24 -07:00
YHH 2ce0c3f97a Merge pull request #13777 from esengine/a5/wsl-cli
feat(cli): add, test and connect a WSL machine from the command line
2026-10-18 04:29:46 -07:00
esengine 29220a5c18 Merge origin/studio into a5/push-host 2026-10-18 04:26:23 -07:00
esengine ffa3d3df29 feat(desktop): mount the phone notification block under Remote settings 2026-10-18 04:25:25 -07:00
Will 13938149d5 fix(agent): release the stale-context block on the growth that caused it
A fold that loses the transcript race records FailContextChanged, but no later
turn could release it: retriesOnGrowth did not name the code, the receipt holds
until the generation advances, and the generation only advances on a commit the
held receipt prevents. One racing append then latched automatic compaction off
for the rest of the session, across restarts, and the next overflow failed the
turn instead of folding.

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

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

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

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

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

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

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

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

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

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

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

Cause: (items, err) collapsed to (nil, nil) for directory-level and
per-entry read errors.
Blast radius: sessioninbox Open/Snapshot plus the commit path; new
additive InboxSnapshot.Unrecovered field, no on-disk schema change.
Neighbouring behaviours tested: readable-dir salvage, absent/empty blobs
dir, healthy manifest untouched, symlink blob refusal.
Why this layer: the store owns the manifest/blob transaction, so the
"unknown vs empty" distinction belongs where the read fails.
2026-10-18 18:57:20 +08:00
esengine 9e0c35100c docs(dsh): say that an installed plugin starts off and must be switched on
Found while demonstrating the install through Discover: until the switch and
both components read Running there is no reasonix tool, and the model falls
back to running reasonix -p in its own shell, which only looks like a
delegation.
2026-10-18 03:55:34 -07:00
esengine 5910c8ae10 fix(peers): the text of a message from another session is not read for file references
An @token in a peer message or a background-job notice was frozen into the
prompt as a file reference, so a sender could make the receiver read a file
into its prompt, and an unreadable one paused the queue behind it. Items the
host authored are data and are no longer scanned.
2026-10-18 03:53:52 -07:00
YHH 08f01f2d79 Merge pull request #13776 from esengine/feat/mcp-panel
feat(tui): add and remove MCP servers from the /mcp panel
2026-10-18 03:52:00 -07:00
esengine d226841381 fix(cli): name the terminal's feedback surface in its first build so /feedback works from the start 2026-10-18 03:48:37 -07:00
esengine 1b556ddbc8 feat(cli): refuse every remote subcommand that changes the book or opens a link when an agent tool started it 2026-10-18 03:33:32 -07:00
esengine 76e579923f fix(release): name the AppImage explicitly and make the verifier say what it wanted 2026-10-18 03:31:48 -07:00
esengine 97e4675986 Merge remote-tracking branch 'origin/studio' into a5/wsl-cli 2026-10-18 03:31:04 -07:00
esengine dd6b73380d Merge remote-tracking branch 'origin/studio' into a5/sched-pkg 2026-10-18 03:30:59 -07:00
esengine c4fa9c73f7 fix(schedule): the scheduled child drains the usage catalogs itself, with a test through the host's entry 2026-10-18 03:30:57 -07:00
esengine 3da8e6b23e test(agent): the origin ledger is on the restarted side of a new task 2026-10-18 03:30:51 -07:00
esengine 069e5b54f3 style(agent): use slices.Contains for the origin list 2026-10-18 03:30:51 -07:00
esengine cbeed94933 style(agent): keep execute_one and run_loop at the size ceiling 2026-10-18 03:30:51 -07:00
esengine 1de856a0f6 feat(agent): count a call whose arguments copy text an external result gave the task
The host keeps the text of a task's external results and counts, per turn, how
many were recorded and how many later non-read-only calls carried a verbatim
string from one. The numbers ride the completion-report audit; nothing is
blocked or shown to the model.
2026-10-18 03:30:51 -07:00
esengine d136ac2828 feat(release): build and verify an AppImage of Studio on Linux (download only) 2026-10-18 03:25:38 -07:00
YHH edcbc47c06 Merge pull request #13764 from esengine/feat/13694-mod-ui-3
feat(studio): Claude Code mods in the window: install preview, the band above the prompt, mod questions (settings block hidden)
2026-10-18 03:23:05 -07:00
SivanCola 17f816d11c Merge studio WSL transport and cover remote host boundaries
Problem: Review requested WSL boundary evidence and removal of the unrelated
design-qa.md diff after studio introduced non-SSH machines.

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

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

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

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

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

Verification: Frontend suite (5529 tests), production build, full serve,
sessionstore, config, builtin and boot tests; gofmt, root vet, make lint and
make check. Transport coverage exercises local project moves beside remote
flags; an interaction regression covers the shared keyboard menu and dialog.
An isolated development browser confirms menu access, project selection and
cancel with hover controls off. Hosted CI remains a separate observation.
2026-10-18 18:13:32 +08:00
esengine 8aa66a6521 Merge remote-tracking branch 'origin/studio' into a5/wsl-ui 2026-10-18 03:11:48 -07:00
esengine 447cc6ae39 ci: run checks against studio 2026-10-18 03:11:43 -07:00
esengine b67222e1a3 Merge remote-tracking branch 'origin/studio' into a5/wsl-cli 2026-10-18 03:11:43 -07:00
esengine 75d8b75051 refactor(schedule): the scheduled child runs from a small package the Studio host can import without the command line 2026-10-18 03:09:47 -07:00
YHH 0363720bd2 Merge pull request #13767 from esengine/a5/wsl-link
feat(remote): bring up a kernel in a WSL distribution and reach it over loopback
2026-10-18 03:08:05 -07:00
YHH 6693788cc4 Merge pull request #13771 from esengine/a9/dsh-integration-doc
feat(integrations): run Reasonix as a DeepSeek Harness subagent, with docs, a plugin package and a market index
2026-10-18 03:05:54 -07:00
YHH bc9d7a16ad Merge pull request #13773 from esengine/feat/13694-mod-receipt
feat(receipt): say when a Claude Code mod's effects were not observed
2026-10-18 03:03:11 -07:00
YHH 5375b9c8f8 Merge pull request #13772 from esengine/feat/12025-thread-badge
feat(feedback): show the reporter's level badge beside their replies in a thread
2026-10-18 03:01:01 -07:00
esengine 60938d36d9 feat(cli): add, test and connect a WSL machine from the command line 2026-10-18 03:00:14 -07:00
esengine d3139f5a4f fix(studio): the mod preview promises no re-confirmation the kernel does not enforce 2026-10-18 03:00:13 -07:00
esengine cebdb37431 fix(studio): the mock mods settings say the switch is not available yet 2026-10-18 03:00:12 -07:00
esengine 8951095309 test(desktop): settings search leaves out the hidden mod entry 2026-10-18 03:00:12 -07:00
esengine 01e775312e feat(studio): keep the mod host's settings out of sight until the kernel allows it 2026-10-18 03:00:12 -07:00
esengine 2cf8510775 test(desktop): the mod UI guard, release note and the strip's focus-ring room 2026-10-18 03:00:12 -07:00
esengine e2e642a488 feat(studio): the band above the prompt, mod questions with their source, and a button with no action id does nothing 2026-10-18 03:00:12 -07:00
esengine 61d2cfd5a8 feat(studio): the mod block in the install preview: full trust, static listing, hidden imports and not-previewed 2026-10-18 03:00:11 -07:00
esengine 721b61eb40 feat(studio): the Claude Code mod block in settings: switch, hook time limit and each mod's state by code 2026-10-18 03:00:11 -07:00
esengine 70d164489f feat(tui): add and remove MCP servers from the /mcp panel 2026-10-18 02:59:58 -07:00
YHH 598de997fa Merge pull request #13770 from esengine/feat/12073-union-list
feat(feedback): show linked devices' reports in My feedback and link from the terminal
2026-10-18 02:53:06 -07:00
YHH dcd4632f8f Merge pull request #13739 from esengine/feat/13693-subagent-steer
feat(delegation): guide a running sub-agent through task_id or from the window
2026-10-18 02:48:06 -07:00
esengine 84e70760b7 feat(feedback): show the reporter's level badge beside their replies in a thread 2026-10-18 02:31:55 -07:00
YHH 4014c87a38 Merge pull request #13768 from esengine/feat/11995-sync-close
feat(feedback): close the mirror issue of a withdrawn report
2026-10-18 02:31:46 -07:00
esengine f3bcb117f7 Merge remote-tracking branch 'origin/studio' into a5/wsl-ui 2026-10-18 02:31:25 -07:00
esengine 4f80c12e2b Merge branch 'a5/wsl-link' into a5/wsl-ui 2026-10-18 02:31:25 -07:00
esengine 8b58ce9eed feat(desktop): add a WSL distribution as a remote machine from Settings 2026-10-18 02:31:25 -07:00
esengine f02ef7d620 feat(integrations): DeepSeek Harness plugin market index for the Reasonix bundle
The index pins the commit that last changed the package; scripts/dsh-market-index.mjs
pins it and validates it with DSH's own validator. docs/DSH.md explains Discover
and the Studio release runbook gains a precondition that keeps the pin current.
2026-10-18 02:30:46 -07:00
esengine 79430cf8cf feat(integrations): website, author and doc links on the DeepSeek Harness bundle 2026-10-18 02:30:29 -07:00
esengine e22b515af1 feat(integrations): DeepSeek Harness plugin market index for the Reasonix bundle
A format-1 index pinned to the commit that last changed the package, and
scripts/dsh-market-index.mjs to pin and validate it with DSH's own validator.
2026-10-18 02:30:29 -07:00
esengine 8ba8c1cf72 feat(integrations): a ready-made DeepSeek Harness bundle that runs Reasonix as a subagent
A configuration-only package (no code) in integrations/dsh-plugin, installed
from this repository's subdirectory with pnpm's path: selector, plus the
integration guide docs/DSH.md.
2026-10-18 02:30:29 -07:00
esengine 34eb44fcd0 docs: using Reasonix from DeepSeek Harness as an ACP subagent
How to wire dsh's subagent-acp provider to reasonix acp, what each permission
policy does to Reasonix, what the parent sees, and the limits, verified against
dsh 0.2.1-rc.2.
2026-10-18 02:30:29 -07:00
esengine 957bdaa323 chore(i18n): keep the message tables at their size ceiling 2026-10-18 02:27:40 -07:00
esengine 2f28235ab6 feat(receipt): say that a Claude Code mod's effects were not observed, and count a live mod as an unseen writer 2026-10-18 02:27:40 -07:00
YHH 7c56994e82 Merge pull request #13705 from esengine/a9/acp-auth-required
fix(acp): a session that cannot open for want of credentials is the authentication-required error
2026-10-18 02:26:40 -07:00
esengine 84d16a1508 Merge remote-tracking branch 'origin/studio' into a5/wsl-link 2026-10-18 02:24:49 -07:00
esengine 36044d1515 style(remote): match exit errors with errors.As in the Linux-only tests 2026-10-18 02:24:47 -07:00
YHH 1a11a6691d Merge pull request #13766 from esengine/a9/rules-dir
feat(rules): rules directories, delivered on the turn and scoped by paths
2026-10-18 02:20:33 -07:00
esengine 119f392248 feat(delegation): guide a running sub-agent through task_id or from the window 2026-10-18 02:17:23 -07:00
YHH d4d7ef809e Merge pull request #13752 from esengine/a5/schedule-ticker
feat(schedule): the Studio host runs the ticker while supervised and visible
2026-10-18 02:16:16 -07:00
esengine 74b4c5d21b feat(feedback): show linked devices' reports in My feedback and link from the terminal 2026-10-18 02:15:42 -07:00
YHH d70edac126 Merge pull request #13737 from esengine/feat/session-move-import
feat(studio): move a conversation to another project, carrying the conversation and not what the old project allowed
2026-10-18 02:11:57 -07:00
esengine f9c6f37b78 fix(remote): stage WSL launches in a private home directory and bound the wait by the session 2026-10-18 02:04:21 -07:00
esengine ae0f9f8fee feat(feedback): close the mirror issue of a withdrawn report 2026-10-18 02:03:13 -07:00
esengine 057d8094ba fix(acp): a session that cannot open for want of credentials is the authentication-required error
session/new answered -32603 for a model with no API key or no configured
provider, although initialize advertises a setup auth method. The cause is now
a provider.ErrKeyMissing / ErrNoProvider identity and the frontend maps it to
the ACP schema's -32000.
2026-10-18 02:01:16 -07:00
esengine ab3f88ac9b Merge remote-tracking branch 'origin/studio' into a5/wsl-link 2026-10-18 02:00:31 -07:00
esengine 137a5831e9 feat(rules): rules directories, delivered on the turn and scoped by paths
Rule files under .reasonix/rules, .claude/rules and the user's rules folder are
read from disk each turn. Rules without paths: join the turn as a block owed
like the standing instructions; rules with paths: are owed once the session has
touched a matching file and retracted when they stop applying. Glob parsing and
matching stay with skills' paths: through one exported entry point. Neither layer
reaches the cache-stable prefix; capability diagnostics report what was skipped.
2026-10-18 01:57:13 -07:00
YHH 4d3c09211b Merge pull request #13725 from esengine/feat/13714-skill-remove
feat(skills): remove a skill from Settings, and never take its whole folder root with it
2026-10-18 01:56:43 -07:00
esengine 2bf3bd7b7e ci: run checks against studio 2026-10-18 01:56:02 -07:00
esengine 9e74ed1806 Merge remote-tracking branch 'origin/studio' into a5/schedule-ticker 2026-10-18 01:56:00 -07:00
esengine c013a4f20b fix(serve): the move target is matched against declared project roots and never reaches the file system 2026-10-18 01:55:42 -07:00
YHH 113523c5f8 Merge pull request #13686 from esengine/feat/11721-browser-full
feat(studio): the browser and workbench can be viewed full screen with the conversation folded away
2026-10-18 01:50:05 -07:00
YHH 0f13cf54c5 Merge pull request #13765 from esengine/a5/wsl-kind
feat(remote): a host entry has a kind, and a WSL distribution is one
2026-10-18 01:46:19 -07:00
YHH 5c99c45ded Merge pull request #13750 from esengine/feat/13693-subagent-rate
feat(delegation): report an estimated token rate while a sub-agent streams
2026-10-18 01:46:13 -07:00
YHH 92ca1b2180 Merge pull request #13753 from esengine/feat/11995-withdraw-client
feat(feedback): withdraw your own report from My feedback or /feedback withdraw
2026-10-18 01:42:08 -07:00
esengine dd9cf72abf style(remote): range over a count in the live test 2026-10-18 01:40:06 -07:00
esengine 6cd443fc94 Merge branch 'a5/wsl-kind' into a5/wsl-link 2026-10-18 01:38:43 -07:00
esengine 34c17c5abf style(config): range over the name's length 2026-10-18 01:38:40 -07:00
YHH ae0af1d5ed Merge pull request #13760 from esengine/a9/default-mode
feat(agent): start new conversations in plan mode when the user chose it
2026-10-18 01:35:23 -07:00
esengine ce73697390 Merge branch 'a5/wsl-kind' into a5/wsl-link 2026-10-18 01:33:54 -07:00
esengine 3ed0deb61d Merge remote-tracking branch 'origin/studio' into a5/wsl-kind 2026-10-18 01:33:39 -07:00
esengine f91eba7de2 fix(config): a WSL distribution name is checked against the characters WSL allows 2026-10-18 01:33:35 -07:00
esengine 89fbc59761 feat(remote): translate Windows workspaces for a WSL machine and refuse a distribution without ps 2026-10-18 01:31:49 -07:00
YHH c9c258067d Merge pull request #13761 from esengine/a5/bootstrap-fs
refactor(remote): bootstrap depends on the file calls it uses, not on SFTP
2026-10-18 01:31:33 -07:00
esengine d235c1fce8 feat(remote): bring up a kernel in a WSL distribution and reach it over loopback 2026-10-18 01:28:00 -07:00
esengine 03725a6939 test(desktop): the full-screen guard waits out the entrance and keeps the pointer off the rail before it shoots 2026-10-18 01:23:09 -07:00
esengine ff5242bcfc docs(release-notes): shorten the full-screen browser line 2026-10-18 01:23:09 -07:00
esengine 00ecd029d4 feat(studio): the browser and workbench can be viewed full screen with the conversation folded away 2026-10-18 01:23:09 -07:00
YHH 74772ab76a Merge pull request #13744 from esengine/fix/1q3k-moved-root-path
fix(hub): open a session by its pre-move path after the state root moves
2026-10-18 01:21:59 -07:00
esengine a67c05616d feat(feedback): withdraw your own report from My feedback or /feedback withdraw 2026-10-18 01:19:11 -07:00
esengine a280324b78 feat(agent): start new conversations in plan mode when the user chose it
agent.default_mode (user file only, off by default) is applied by the assembly
for builds that open a fresh conversation: Studio panes without a saved
session, the TUI without a resume, and ACP session/new. A resume, a headless
run and sub-agents never take it. Settings gets the switch.
2026-10-18 01:11:28 -07:00
YHH ddc01a3793 Merge pull request #13762 from xxoingr/fix/completion-receipt-cut-at-character
fix(completion): cut a declined check's command at a character boundary
2026-10-18 01:10:52 -07:00
esengine 764fad7299 fix(hub): open a session by its pre-move path after the state root moves 2026-10-18 01:04:35 -07:00
esengine 45f7b33165 Merge branch 'a5/bootstrap-fs' into a5/wsl-link 2026-10-18 01:04:11 -07:00
YHH fc4c0e8e5b Merge pull request #13757 from esengine/feat/7950-btw
feat(tui): /btw asks a read-only side question that is not saved to the session
2026-10-18 01:03:26 -07:00
esengine 4025ef24dc feat(remote): a host entry has a kind, and a WSL distribution is one 2026-10-18 01:01:42 -07:00
YHH ed1c127d65 Merge pull request #13747 from esengine/a5/schedule-tick
feat(schedule): a tick that runs what is due, and the schedule commands
2026-10-18 00:55:49 -07:00
xxoingr a94ba07bab fix(completion): cut a declined check's command at a character boundary
The unverified-change gap names the command the host declined, bounded
at 80 bytes by command[:80]. A check with non-ASCII paths, common in a
Chinese project, could be cut inside a character, and the delivery card
then showed a replacement glyph before the cut mark. Step back to the
start of the character instead, keeping the budget and the mark.
2026-10-18 15:54:15 +08:00
YHH cdb7d2318a Merge pull request #13724 from esengine/feat/13694-mod-host
feat(modhost): run Claude Code mods as tighten-only extension runtimes
2026-10-18 00:54:01 -07:00
esengine b39058d42b feat(tui): /btw asks a read-only side question that is not saved to the session 2026-10-18 00:50:18 -07:00
esengine d91e7f8d17 refactor(remote): bootstrap depends on the file calls it uses, not on SFTP 2026-10-18 00:48:06 -07:00
YHH d31d8daf07 Merge pull request #13743 from esengine/fix/dispatch-proof-replay
fix(peers): a dispatch proof is valid once per sequence number, and the adopt/spool boundaries are pinned
2026-10-18 00:46:31 -07:00
YHH 8b1895f05d Merge pull request #13746 from esengine/a5/acp-names-cancel
feat(acp): cancel one request, message ids, elicitation
2026-10-18 00:41:44 -07:00
esengine c52d0c325c Merge remote-tracking branch 'origin/a5/schedule-tick' into a5/schedule-ticker
# Conflicts:
#	docs/GUIDE.md
2026-10-18 00:41:40 -07:00
esengine 24c3fe0f87 feat(schedule): refuse agent-started commands, confirm by typing the plan id, record who created it
Subprocesses the host starts for the agent carry REASONIX_AGENT_CHILD=1, and the
person-only schedule commands refuse when it is set, so a pty opened by a tool
no longer passes the terminal check. add prints the whole plan and requires its
short id typed back; list shows how each schedule was confirmed. The Windows
preview test compares against the resolved workspace path.
2026-10-18 00:39:59 -07:00
esengine adee10bd33 fix(modhost): read the package through a root, and run the mod effect tests with the sandbox off 2026-10-18 00:35:49 -07:00
esengine 971346ff56 docs(release): the user-visible change in the mod host core is the MCP ruling, not a switch that is still withheld 2026-10-18 00:35:49 -07:00
esengine 58a6432e55 test(modhost): wait for the script's stderr line instead of reading it the instant the reply lands 2026-10-18 00:35:49 -07:00
esengine 035453edfb refactor(agent): one gate method for the trusted MCP path 2026-10-18 00:35:49 -07:00
esengine 92c7f9d107 fix(desktop): English wording for the withheld mods switch 2026-10-18 00:35:49 -07:00
esengine 338264c6b7 fix(modhost): an extension's ruling also reaches installed MCP tools, and the mods switch stays withheld until the gaps merge 2026-10-18 00:35:49 -07:00
esengine 905eb76143 refactor(modhost): satisfy the linters 2026-10-18 00:35:48 -07:00
esengine fea2a123f6 refactor(modhost): keep the touched files inside their size and comment budgets 2026-10-18 00:35:48 -07:00
esengine 7dad28cf6b fix(desktop): the mods routes and refusal wording the parity tests ask for 2026-10-18 00:35:48 -07:00
esengine 00d6b85d34 docs(mods): the mod host, its trust and what it serves 2026-10-18 00:35:40 -07:00
esengine 95428e8f17 feat(mods): the mods status and settings port, its routes, and the environment a host states 2026-10-18 00:35:39 -07:00
esengine ec49147dee feat(permission): a tighten-only extension's ask is a fresh human approval of the one call 2026-10-18 00:35:39 -07:00
esengine 2234a3d68b test(modhost): effect tests for the prefix, a refused allow and a mod that dies or hangs 2026-10-18 00:35:39 -07:00
esengine f2a2da4120 feat(modhost): a mod installs, previews and runs through the real boot assembly 2026-10-18 00:35:39 -07:00
esengine 2a035ffdb1 feat(config): the [mods] switch and hook budget, user file only 2026-10-18 00:35:39 -07:00
esengine 152db8582e feat(modhost): synthesise a runtime for an installed mod and supervise it through the sidecar client 2026-10-18 00:35:27 -07:00
esengine 3d624951db feat(ext): dynamic tools and kill-on-timeout for single-threaded runtimes; the mod host serves tools, commands and the band 2026-10-18 00:35:27 -07:00
esengine 047b9e96b0 style(modhost): gofmt the harness 2026-10-18 00:35:27 -07:00
esengine a21d8d8b64 feat(modhost): the host script and its protocol tests under a real node 2026-10-18 00:35:27 -07:00
esengine 3219135e7e feat(modhost): resolve the JavaScript runtime a mod runs on 2026-10-18 00:35:27 -07:00
esengine 674712eadf feat(ext): a static preview of a mod's hooks module from its syntax tree 2026-10-18 00:35:27 -07:00
esengine 0154163c84 feat(ext): tighten-only runtimes and the wire types for Claude Code mods 2026-10-18 00:35:27 -07:00
esengine e906f7b4d5 docs(peers): say what a dispatched session inherits, including YOLO's reach over ask rules 2026-10-18 00:32:28 -07:00
YHH 8d7a55e8ad Merge pull request #13742 from esengine/a5/otlp-export
feat(otlp): opt-in OpenTelemetry trace export, user config only
2026-10-18 00:29:50 -07:00
YHH c73eb851bf Merge pull request #13749 from esengine/docs/sandbox-in-containers
docs(sandbox): what the Linux sandbox does in a container, measured
2026-10-18 00:24:43 -07:00
esengine 92b2e46f69 docs(sandbox): what the Linux sandbox does in a container, measured 2026-10-18 00:08:54 -07:00
esengine 4cddc3390c feat(schedule): the Studio host runs the ticker while it is supervised and visible
The host also answers 'schedule exec' by running the CLI's entry, so the
supervisor can start it as the child. One kernel at a time ticks, under the
machine's ticker lock.
2026-10-18 00:08:22 -07:00
esengine e453fe539e Merge studio into a5/otlp-export 2026-10-18 00:01:14 -07:00
esengine 20d807cb80 Merge remote-tracking branch 'origin/studio' into a5/schedule-tick 2026-10-18 00:00:24 -07:00
esengine c85b77bee2 Split the ceilings in the scheduled runs guide 2026-10-18 00:00:06 -07:00
esengine da001ded22 Shorten the release note fragment 2026-10-17 23:58:24 -07:00
esengine 3d71e57313 Shorten the release note fragment 2026-10-17 23:58:20 -07:00
esengine fb99e0effd feat(schedule): a tick that claims and runs what is due, and the person-facing schedule commands
Scheduler.Tick reaps dead runs, claims at most one due slot inside its grace
and hands it to the supervisor; the schedule add, list, pause, resume, renew,
rm and tick commands drive it. Creating, resuming and deleting need a
terminal.
2026-10-17 23:58:11 -07:00
esengine 6d700b071b Merge remote-tracking branch 'origin/studio' into a5/acp-names-cancel 2026-10-17 23:57:46 -07:00
esengine b09691402d Keep ACP docs and protocol.go within the size standards 2026-10-17 23:57:44 -07:00
esengine b3ec1e2e87 feat(delegation): report an estimated token rate while a sub-agent streams 2026-10-17 23:56:26 -07:00
YHH 896552483e Merge pull request #13588 from SivanCola/feature/new_system_person_20261017
feat(persona): add a system persona to Studio / 新增 Studio 系统人设
2026-10-17 23:54:08 -07:00
esengine e6ba328ad4 Merge remote-tracking branch 'origin/studio' into a5/acp-names-cancel 2026-10-17 23:52:15 -07:00
esengine ba8900aba9 fix(peers): a dispatch proof is valid once per sequence number, and the adopt/spool boundaries are pinned 2026-10-17 23:48:36 -07:00
esengine 57ca36e635 fix(studio): the move dialog says checkpoints, task logs and queued messages stay behind 2026-10-17 23:47:27 -07:00
esengine 5fdd45af4b feat(acp): cancel one request, message ids, tool names on permission requests, elicitation
$/cancel_request cancels only the named request and answers -32800;
agent message chunks carry an opaque messageId; an MCP server's form or
page goes through elicitation/create when the client advertised the mode.
2026-10-17 23:43:20 -07:00
SivanCola cc629b6424 merge: isolate persona imports from evolving settings types
Problem: Studio's new SearXNG connection probe conflicts with the persona
types in three transport import lines, blocking this PR again.

Root cause: independent settings features append types to the same line.

Fix: normally merge Studio 6f68a14a9, preserving the search probe and persona
contracts. Keep persona-only imports separate so subsequent upstream type
additions do not repeatedly conflict. Remove one upstream trailing space.
Retain the upstream MCP scope upgrades, ACP session directories, Gemini
provider, session ordering and rail-search behavior without modifying their
implementations. Persona prompt, user-only scope and guarded writes remain
unchanged, as do the approved composition UI and default-off behavior.

Verification: three-way simulations of future settings imports merge cleanly
in all three files. Format, vet, lint, make check, required builtin/boot tests,
full root Go tests with atomic coverage, full CI race package group and Desktop
Go pass. Frontend typecheck/build and 662 files / 5425 tests pass. Chromium
checks cover persona navigation in both locales, light/dark, wide/390px;
rail search, session ordering, web access, settings rows and font scaling.
Local review and security review check the integration and fresh MCP consent;
they do not replace maintainer approval. No gate or budget was relaxed.
2026-10-18 14:37:59 +08:00
esengine 157ca8533c Merge studio into a5/otlp-export 2026-10-17 23:32:08 -07:00
SivanCola c190fe4bf4 Merge studio into unified project sidebar
Problem: the sidebar PR conflicts with studio's new per-session move actions.

Root cause: both branches extend the row key handler and the mock tree's
session projection. Sorting shortcuts also bubbled into tree navigation,
moving focus to a different session after a reorder.

Fix: retain keyboard context menus and session moves together, compose mock
placement with pin/archive decoration, and consume sorting keys at their
owning row. Keep the existing remote host and body-size guards unchanged.

Verification: 5478 frontend tests across 669 files; production frontend
build; full serve, sessionstore, builtin and boot suites; gofmt, root vet,
make lint and make check. A failing-first keyboard regression and isolated
browser checks prove reordered rows retain focus and keyboard menus still
expose pin, archive and move actions.
2026-10-18 14:32:00 +08:00
YHH 6943f966da Merge pull request #13718 from esengine/a5/video-url
feat(video): hand a public video address the user wrote to the model vendor (off by default)
2026-10-17 23:30:55 -07:00
esengine de67ca98e8 Merge remote-tracking branch 'origin/studio' into a5/otlp-export 2026-10-17 23:15:49 -07:00
esengine 436fa1754a Fix lint findings in the otlp package 2026-10-17 23:15:12 -07:00
esengine cf745b383c feat(studio): move a conversation to another project, carrying the conversation and not what the old project allowed 2026-10-17 23:13:36 -07:00
esengine 790943ac97 Merge studio into a5/video-url 2026-10-17 23:12:21 -07:00
YHH 6f68a14a9d Merge pull request #13732 from esengine/a5/tui-mcp-signin
feat(tui): sign in to an MCP server, or grant it more access, from /mcp
2026-10-17 23:12:14 -07:00
esengine 032e9b771d test(perf): the web access guard expects the video address as its own third list 2026-10-17 23:11:10 -07:00
esengine 089a348753 Merge studio into a5/otlp-export 2026-10-17 23:09:23 -07:00
YHH d87dc94c26 Merge pull request #13659 from esengine/a5/searxng-probe
feat(search): test a SearXNG address from settings and say why it does not answer
2026-10-17 23:07:45 -07:00
YHH 7c35e9bfab Merge pull request #13733 from esengine/feat/session-manual-order-2
feat(studio): move a conversation up or down in its project's list, kept by the kernel
2026-10-17 23:07:42 -07:00
esengine e667bbcd59 fix(desktop): declare the trace export block in the settings catalogue 2026-10-17 23:05:01 -07:00
esengine 9057c3211e feat(otlp): serve route, Studio settings group, docs 2026-10-17 23:04:15 -07:00
esengine b6dbbde594 feat(otlp): opt-in trace export from the user's config, with settings, boot wiring and effect tests 2026-10-17 22:55:38 -07:00
esengine 065d5200da feat(otlp): an exporter that turns a run into GenAI traces 2026-10-17 22:48:43 -07:00
YHH 8e14cdff21 Merge pull request #13731 from esengine/a5/acp-additional-dirs
feat(acp): session-scoped additionalDirectories as write roots
2026-10-17 22:45:40 -07:00
esengine 74e594faed Keep the i18n catalogs under the size ceiling 2026-10-17 22:43:43 -07:00
esengine 6a93a3d01e fix(sessionstore): the order index is a new format now that a row carries its rank 2026-10-17 22:40:36 -07:00
esengine f821e2be49 feat(studio): move a conversation up or down in its project's list, kept by the kernel 2026-10-17 22:40:36 -07:00
esengine 8ce6be77d8 feat(tui): sign in to an MCP server, or grant it more access, from the /mcp panel 2026-10-17 22:40:12 -07:00
esengine e60cc114e8 Merge remote-tracking branch 'origin/studio' into a5/video-url 2026-10-17 22:32:55 -07:00
YHH bbe38032c3 Merge pull request #13730 from esengine/a5/mcp-stepup
feat(mcp): let the user grant a server the access it asked for
2026-10-17 22:32:27 -07:00
esengine bb43735206 feat(acp): take additionalDirectories as session-scoped write roots
Each folder gets the vetting a user-granted root gets; one refused folder
refuses the request with a typed code. Nothing reaches the user's grants.
2026-10-17 22:31:06 -07:00
esengine 1571fe64dc feat(skills): remove a skill from Settings, and never take its whole folder root with it 2026-10-17 22:22:41 -07:00
YHH fc4626cf94 Merge pull request #13726 from esengine/fix/subagent-view-live-after-completion
fix(delegation): a run recorded complete is not live while its owner has yet to let go
2026-10-17 22:10:16 -07:00
YHH cbff25b46b Merge pull request #13715 from esengine/fix/rail-hit-first-click
fix(studio): the rail search box stays open while it holds a query, so the first press on a result is a click
2026-10-17 22:08:33 -07:00
esengine 33b05e79d8 Keep the OAuth authorization flow under the size limits 2026-10-17 22:05:28 -07:00
esengine cacc7c6789 Let the user grant an MCP server the access it asked for
A 403 insufficient_scope from a signed-in server now stops the model and
tells the user; granting more is a fresh approval asked every time, from
the server row or reasonix mcp auth --upgrade.
2026-10-17 22:05:28 -07:00
esengine 3f61753054 feat(mcp): type a 403 insufficient_scope answer, extend access by scope, tell the model by code and the user once 2026-10-17 22:05:28 -07:00
YHH 92d8795b31 Merge pull request #13721 from esengine/a9/acp-vendor-optin
fix(acp): vendor notifications reach only clients that declare them; transcriptPath rides _meta
2026-10-17 21:57:07 -07:00
SivanCola 576e89b111 merge: preserve persona alongside session references
Problem: the latest Studio session-reference settings overlap the persona
port imports and development proxy, leaving this PR in conflict.

Root cause: both features add settings types at the same transport boundary.

Fix: normally merge Studio 8c311b02b, retaining both Persona and SessionRef
contracts, Mock state, SSE methods, routes and settings panels. The upstream
dispatch-consent, bounded-fold and subagent-view implementations stay intact.
Persona prompt assembly, user-only scope, guarded settings and save timing
remain unchanged. The preceding test-only commit synchronizes cancellation
observation without changing the mock provider or relaxing error/close checks.

Verification: formatting, vet, lint, make check, required builtin/boot tests,
full root Go tests with atomic coverage, the full CI race package group and
Desktop Go tests. Frontend typecheck/build and 660 files / 5411 tests pass.
Chromium checks cover persona/settings navigation in Chinese and English,
light/dark and wide/390px layouts; upstream session-reference interactions,
settings rows, font scaling and explorer counts are covered as well.
2026-10-18 12:53:02 +08:00
YHH f249ab3e0e Merge pull request #13712 from esengine/a5/gemini-kind
feat(gemini): a native Gemini provider on the Interactions API, with video (experimental)
2026-10-17 21:50:26 -07:00
esengine 4618349369 Merge origin/studio into fix/rail-hit-first-click 2026-10-17 21:48:03 -07:00
esengine 27dbf237af Merge studio into a5/searxng-probe 2026-10-17 21:47:46 -07:00
esengine 83fde1922f fix(delegation): a run recorded complete is not live while its owner has yet to let go 2026-10-17 21:47:36 -07:00
SivanCola 8bebe521d6 test(agent): synchronize mock stream cancellation observation
Problem: the hosted race group failed the mock provider cancellation test
when the next received chunk was an already-pending tool call.

Root cause: cancellation does not prioritize the Done case over a send that
is also ready in select. The test canceled the context and immediately read
again without establishing that the producer had observed cancellation.

Fix: acknowledge cancellation through the test context's Err method before
draining the stream. Retain the strict cancellation-error and subsequent
channel-close assertions, plus the unchanged pre-canceled request test.
The hang guard uses the existing test budget. No mock/product behavior,
runtime prompt, UI, thresholds or workflow approvals change.

Verification: reproduced one failure in 10000 race-instrumented iterations
with GOMAXPROCS=2 before the change; 10000 final iterations pass afterward.
A temporary explicit-marker mutation that ignores cancellation is rejected.
The complete CI race package group passes, alongside gofmt, vet, lint,
make check and builtin/boot tests. Prior frontend/coverage evidence applies
to the unchanged product tree.
2026-10-18 12:39:10 +08:00
esengine eacf0618e8 fix(acp): vendor notifications reach only clients that declare them; transcriptPath rides _meta
The prompt result carried a top-level transcriptPath the ACP schema does not
allow, and status_update / mcpStatus_update went to every client, which
standard SDK clients answer with a method-not-found error each. The two
notifications now need clientCapabilities._meta["reasonix.io"].statusUpdates
and the path moves under _meta["reasonix.io"].
2026-10-17 21:28:25 -07:00
YHH 8c311b02bd Merge pull request #13708 from esengine/feat/13687-session-reference
feat(studio): reference a past conversation of this workspace from the message box
2026-10-17 21:27:41 -07:00
esengine 7b775ed15e test(video): an address another session delivered is not the user's, and a reopened conversation keeps who wrote what 2026-10-17 21:23:55 -07:00
YHH 64c5ff3273 Merge pull request #13717 from esengine/feat/13693-subagent-view
feat(delegation): read a sub-agent run's conversation while it runs
2026-10-17 21:19:13 -07:00
esengine 291d02c884 Merge remote-tracking branch 'origin/studio' into a5/searxng-probe 2026-10-17 21:14:41 -07:00
SivanCola f40ab35660 merge: reconcile persona with the settings interface
Problem: newer Studio settings UI and persona translations both appended to
the shared English catalog, leaving the PR conflicting. Its new setting-open
notices also lacked a destination for the guarded persona fields.

Root cause: translations for an independent settings domain shared an append
point, and the newly introduced frontend routing table did not know persona.

Fix: normally merge Studio 15de9ec33, preserve both sides' translations and
extract persona's unchanged entries into its own catalog module. Route the
three guarded persona setting ids to their editor and cover actual notice
clicks. Preserve the persona's user scope, supplemental prompt semantics,
save timing, settings navigation and existing authorization boundaries.

Verification: format, vet, lint, make check, complete root Go tests with
atomic coverage, Desktop Go, frontend typecheck/build and 657 files / 5400
tests. Verified all 25 extracted translations unchanged. Chromium navigation
passed in Chinese/English, light/dark and wide/390px layouts; settings rows
and font scaling pass. The later dispatch config/catalog combination also
passes an explicit-marker build overlay. No thresholds or approvals changed.
2026-10-18 12:06:50 +08:00
esengine b96ad582a6 feat(video): the Settings switch for handing a video address to the vendor, with docs 2026-10-17 21:04:31 -07:00
YHH 42c505b50e Merge pull request #13711 from esengine/feat/13320-bound-fold
fix(agent): give [[keep]] turns a budget of their own and stop the fold ceiling where the send-time output check stops
2026-10-17 21:03:07 -07:00
YHH 5c38676052 Merge pull request #13709 from esengine/feat/dispatch-open-switch
feat(peers): the dispatch switch is the consent between a session and the sessions it opens
2026-10-17 21:01:08 -07:00
esengine 58e2da8db2 feat(video): read_video takes an address the user wrote, when the user turned that on 2026-10-17 20:58:33 -07:00
esengine 4b483d3897 fix(control): cut a referenced conversation at a character boundary and quote its attributes with %q 2026-10-17 20:57:29 -07:00
esengine 085f20c576 feat(video): an address the vendor visits, on the Zhipu and Gemini wires 2026-10-17 20:54:12 -07:00
esengine 3e10e6faf6 Merge branch 'a5/gemini-kind' into a5/video-url 2026-10-17 20:52:28 -07:00
esengine 8f1fe5965d fix(gemini): follow only an upload address on the configured host and only Files-shaped names; say experimental in the config 2026-10-17 20:52:08 -07:00
esengine f1fa8538d5 wip(video-url): the user-only switch in the config 2026-10-17 20:49:18 -07:00
esengine 4d2c7cd4d0 fix(studio): the rail search box stays open while it holds a query, so the first press on a result is a click 2026-10-17 20:48:09 -07:00
esengine 863ed17e70 feat(delegation): read a sub-agent run's conversation while it runs 2026-10-17 20:47:52 -07:00
esengine a2aef0b953 fix(agent): give [[keep]] turns a budget of their own and stop the fold ceiling where the send-time output check stops 2026-10-17 20:42:45 -07:00
YHH 15de9ec334 Merge pull request #13706 from esengine/a9/acp-readiness-pause
fix(acp): a host-paused turn ends with end_turn, a paused outcome and a receipt
2026-10-17 20:41:07 -07:00
YHH ec207aabf7 Merge pull request #13707 from esengine/feat/13697-user-message-time
feat(chat): show when your own message was sent in its action row
2026-10-17 20:40:46 -07:00
YHH bec46d99ab Merge pull request #13704 from esengine/fix/13699-user-action-icons
fix(chat): the action icons under your own message match the reply's size
2026-10-17 20:40:35 -07:00
esengine 992e0155a5 Merge remote-tracking branch 'origin/studio' into a5/gemini-kind 2026-10-17 20:36:53 -07:00
esengine 3b5ef3dff1 style(gemini): satisfy the linters in the tests 2026-10-17 20:36:43 -07:00
YHH 33bb46e95a Merge pull request #13698 from esengine/a5/settings-ui
feat(settings): draw the setting card and point at a setting only the user may change
2026-10-17 20:30:15 -07:00
esengine 14206bc978 test(settings): the dispatch ceilings share the words of the dispatch switch 2026-10-17 20:29:24 -07:00
esengine da0c5c6a9f test(peers): range over SplitSeq with one variable 2026-10-17 20:29:24 -07:00
esengine 038732325c fix(settings): declare the two dispatch ceilings 2026-10-17 20:29:24 -07:00
esengine ffe30b62bf test(peers): range over SplitSeq 2026-10-17 20:29:24 -07:00
esengine eb48decd12 fix(desktop): the peers settings fixtures carry the dispatch ceilings 2026-10-17 20:29:24 -07:00
esengine e7d19e3d65 chore(peers): fit the repolint budgets 2026-10-17 20:29:24 -07:00
esengine 2002ef72ea feat(peers): ceilings, error identities and the dispatch tool's own words follow the switch being the consent 2026-10-17 20:29:24 -07:00
esengine ddba148a36 feat(peers): the dispatch switch is the consent between a session and the ones it opens 2026-10-17 20:29:24 -07:00
esengine 8720820542 Merge studio into a5/gemini-kind 2026-10-17 20:28:35 -07:00
esengine ec8a91e1e1 feat(studio): reference a past conversation of this workspace from the message box 2026-10-17 20:22:18 -07:00
esengine 25d9f7f028 style(gemini): split the stream loop and trim comments to the repo's limits 2026-10-17 20:19:25 -07:00
esengine aa80cdce2e feat(gemini): effect test, docs, release note and the desktop kind label 2026-10-17 20:18:28 -07:00
esengine 5b4f35502f fix(acp): a host-paused turn ends with end_turn, a paused outcome and a receipt
The final-readiness pause answered session/prompt with the non-standard
stopReason "error", so a client that reads only message text took unverified
work for finished. The turn now ends with end_turn, the typed outcome in _meta
and a host-written [paused] line at the end of the streamed text.
2026-10-17 20:16:53 -07:00
SivanCola fbdb85dc4e merge: retain guarded persona behavior with the settings tool
Problem: newer Studio introduces the model-facing setting tool across the
same catalog, boot assembly and controller used by the persona feature.

Root cause: validating only the earlier base would leave their combined
authorization and request contracts untested.

Fix: normally merge Studio 4840df982 and retain the existing guarded persona
entries, user-only loading and supplemental prompt assembly. Add a real
boot.Build effect test for all three persona fields: even in YOLO a model
proposal receives a guarded refusal, produces no approval card, preserves
the user file and discloses no value through refusal or settings listing.

Verification: gofmt, vet, lint, make check, full root Go tests with atomic
coverage, Desktop Go and SDK suites, frontend typecheck/build and 654 files
/ 5384 tests; Chromium settings navigation, row layout and font-scaling
checks in light/dark and wide/narrow layouts. Earlier Electron checks apply
unchanged. No baselines, thresholds, workflow approvals or draft state change.
2026-10-18 11:16:47 +08:00
esengine 006616829a feat(chat): show when your own message was sent in its action row 2026-10-17 20:16:31 -07:00
esengine b9510a1cc2 Merge remote-tracking branch 'origin/studio' into a5/settings-ui 2026-10-17 20:13:06 -07:00
YHH 0d9bae435a Merge pull request #13703 from esengine/a9/dsh-acp
fix(acp): a failed session/prompt turn is a JSON-RPC error with a typed code
2026-10-17 20:12:47 -07:00
esengine fbf2495a4c feat(gemini): the native preset, with only models whose pages were read 2026-10-17 20:08:07 -07:00
SivanCola 724d94af8c merge: reconcile persona with current Studio contracts
Problem: the approved persona head failed hosted Go tests and coverage, and
the combined Settings/config files exceeded their line budgets.

Root cause: CI tested synthetic merge 6309314327 against newer Studio
5e53be32a. Its attachment ceiling key had no settings declaration, while
independent additions accumulated in the shared settings/config files.

Fix: normally merge Studio 667f2ca16, including the upstream guarded
attachment-key declaration and ProviderEntry method extraction. Keep all
three guarded persona keys and their user-scope/prompt behavior. Extract
the unchanged settings navigation into SettingsNav, retaining keyboard
navigation, status values and the new version dot. Assert that the
attachment storage ceiling stays guarded.

Verification: gofmt, vet, lint, make check, complete root Go tests with
atomic coverage, independent Desktop Go tests, Electron tests, frontend
typecheck/build and 654 files / 5381 tests. Chromium fs-scale and settings
row guards pass, with light/dark 1280px/390px navigation checks. No budgets,
test thresholds, workflow approvals or draft state are changed.
2026-10-18 11:07:54 +08:00
esengine e24b481e40 fix(chat): the action icons under your own message match the reply's size 2026-10-17 20:05:22 -07:00
YHH 4840df9825 Merge pull request #13690 from esengine/a5/settings-tool
feat(settings): the model can propose a setting change and the user confirms it
2026-10-17 20:03:48 -07:00
esengine 8c4e0ef1e8 feat(gemini): video through inline data or the Files API, with WebM and MOV sniffed natively 2026-10-17 20:02:52 -07:00
YHH fadd893983 Merge pull request #13701 from esengine/fix/search-button-toggle
fix(find): the title bar search button closes an open find bar
2026-10-17 20:02:14 -07:00
esengine 12fb14abb6 feat(pricing): Google's Gemini rates, with a dated generation for the announced step 2026-10-17 19:57:22 -07:00
esengine 001d33f232 fix(acp): a failed session/prompt turn is a JSON-RPC error with a typed code
stopReason "error" is not in the ACP prompt-turn enumeration, and the cause
travelled only on a vendor notification, so a text-only client saw an
unexplained failure. The turn now ends with -32603, the redacted cause as the
message and data.code turn_failed.
2026-10-17 19:57:07 -07:00
YHH 667f2ca166 Merge pull request #13688 from SivanCola/fix/skill-spec-info-key
fix(studio): key each skill metadata row / 技能元数据行补上 key
2026-10-17 19:56:36 -07:00
esengine 33219e58c5 feat(gemini): a native provider kind on the Interactions API 2026-10-17 19:53:46 -07:00
esengine f6c18ac5a3 Merge branch 'a5/settings-tool' into a5/settings-ui 2026-10-17 19:50:31 -07:00
esengine fc80457d14 fix(settings): the card shows the whole value or the value is refused, no proposals in plan mode, no values for boundary rows 2026-10-17 19:50:12 -07:00
esengine 5d99b3a628 fix(find): the title bar search button closes an open find bar 2026-10-17 19:44:03 -07:00
esengine 96d21061db Merge remote-tracking branch 'origin/studio' into a5/settings-tool 2026-10-17 19:43:10 -07:00
SivanCola 7392199b3c Merge remote-tracking branch 'origin/studio' into fix/skill-spec-info-key 2026-10-18 10:41:01 +08:00
YHH 1d1a8c7ad8 Merge pull request #13691 from esengine/fix/config-size-budget
refactor(config): move ProviderEntry's model and key methods out of config.go
2026-10-17 19:40:28 -07:00
SivanCola fb84c5af25 fix(studio): refuse unknown SSH hosts and keep hover actions opt-in
Problem: remote sidebar mutations could dial an unconfigured SSH target,
accept unbounded JSON, and enable hover actions without a user choice.

Root cause: a missing host resolved to an empty workspace without an error;
remote JSON handlers lacked a shared size boundary, and the preference
treated every value except explicit off as enabled.

Fix: classify unknown hosts before link reuse or acquisition, share a
64 KiB single-body decoder across remote writes, and restore explicit-on
hover preferences with a safe storage-error default. Preserve configured
hosts, far-kernel refusals, menu access and declared workspace resolution.
Align synchronization fixtures and keep translated directory prose in the
interface font.

Verification: failing-first unknown-host and oversized-body regressions;
full serve, builtin and boot tests; 5389 frontend tests; production build;
gofmt, root go vet, make lint and make check; isolated browser interaction
checks for default-off, opt-in persistence, menu access and opt-out.
2026-10-18 10:32:18 +08:00
esengine c24492d2c0 feat(settings): draw the setting card, point at a setting only the user may change, and offer the choice over ACP 2026-10-17 19:29:39 -07:00
esengine c1c7d99ecb Merge remote-tracking branch 'origin/fix/declare-attachments-key' into fix/config-size-budget 2026-10-17 19:24:54 -07:00
esengine 02ff3c3352 fix(settings): the receipt notice names the setting and carries no machine payload 2026-10-17 19:23:30 -07:00
SivanCola cc14bcb75e fix: display the server-owned remote workspace default
Problem: The SSH project menu re-derived a default from the project list when
the host response omitted workspace.

Root cause: A client fallback duplicated the kernel's configured-default rule.

Fix: Pass the host response's workspace directly to the menu. Align the fixture
with the server response and cover both an explicit default that differs from
list order and an omitted default that must stay unset.

Verification: The omitted-default regression failed before repair. All 17
remote project/worktree interaction tests and the production build passed.
gofmt, root go vet, make lint, make check and builtin/boot tests passed.
2026-10-18 10:16:54 +08:00
esengine 14c5dcbcee style(search): slices helpers in the dial guard, tidy the probe tests 2026-10-17 19:15:00 -07:00
esengine 623e792df3 fix(search): probe only the saved SearXNG address and never a link-local or metadata one 2026-10-17 19:13:09 -07:00
esengine a6329ea5f4 fix(settings): declare attachments.max_stream_bytes as Guarded
The key landed with the streaming attachment ceiling without a class, so the
settings package panics at registration and every test importing it fails.
2026-10-17 19:11:19 -07:00
esengine a8dc464e9a refactor(config): move ProviderEntry's model and key methods out of config.go
config.go is over its file-size budget on studio after two merged changes,
which fails lint for every new pull request. The model-price/override and
API-key methods belong with ProviderEntry; the one function that reads the
process environment stays in config.go so no process-input debt moves.
2026-10-17 19:09:37 -07:00
SivanCola 321e3bbaf0 fix(studio): scale explorer counts with interface text
Problem: the Studio browser guard on the persona PR measured file and
change counts staying at 10px when interface text was enlarged.

Root cause: both explorer headers share a small element without an explicit
font size, leaving the size response to the browser's smaller-text rules.
The stylesheet is inherited from studio; macOS Chromium did not reproduce
the Linux hosted failure.

Fix: give the shared counter rule an explicit 10px base multiplied by the
existing interface scale. Preserve the default appearance and the persona
and composition behavior. Add a bilingual release note.

Verification: gofmt, vet, lint, make check, builtin/boot tests, frontend
typecheck, production/perf builds and 651 files / 5370 tests; Chromium
fs-scale, text-size and explorer guards. In light and dark, both counts
measure 10px, 11.5px and 13px across the three interface size steps.
2026-10-18 10:05:34 +08:00
esengine 5393e818e2 Merge remote-tracking branch 'origin/studio' into a5/searxng-probe 2026-10-17 19:04:52 -07:00
YHH 5e53be32ab Merge pull request #13685 from esengine/fix/13682-versions-nav-update-dot
fix(settings): mark the Version row when an update is known
2026-10-17 19:00:48 -07:00
esengine aab760b0b2 feat(settings): declare attachments.max_stream_bytes as a guarded size limit 2026-10-17 18:59:07 -07:00
SivanCola a72134c0ef fix(studio): key each skill metadata row
The metadata list mapped entries to an unkeyed fragment, so React warned about the missing key when Settings -> Extensions rendered a skill's spec. Give the fragment the entry key and add a regression test that fails without it.
2026-10-18 09:58:59 +08:00
esengine 530ccbf688 style(config): fold two comments so config.go is back inside its size budget 2026-10-17 18:57:53 -07:00
esengine 9c6c91da9c style(settings): errors.As in tests, slices.Contains, keep config.go within its size budget 2026-10-17 18:57:27 -07:00
esengine c7e6ef9a52 feat(settings): offer the setting tool, name the switch on a disabled dispatch, and document both 2026-10-17 18:57:27 -07:00
esengine f3a5a6bcce feat(settings): the setting tool, the host that confirms a change, and the structured remedy on refusals 2026-10-17 18:57:27 -07:00
YHH 2d6656cd14 Merge pull request #13544 from SivanCola/fix/native-file-streaming-studio
fix(studio): reference native files and stream attachment uploads / 大文件路径引用与流式上传
2026-10-17 18:49:56 -07:00
SivanCola 6fe0fd6867 fix(persona): declare guarded keys in the settings catalog
Problem: the new studio settings catalog rejects config fields without an
explicit classification, including the three persona fields added here.

Root cause: the catalog foundation landed after the persona implementation.

Fix: integrate studio and classify persona prefix, suffix and optional runtime
context as user-owned Guarded settings. Plain file writes do not rebuild a
runtime. Reject model proposals before any persona value can be changed.

Verification: root Go tests including settings, boot and builtin pass; gofmt,
vet, lint and make check pass; frontend typecheck/build and 5,370 tests pass.
2026-10-18 09:46:59 +08:00
YHH ce7c69ca75 Merge pull request #13681 from esengine/fix/browser-minimized-click
fix(desktop): raise a minimized window unseen so the agent's clicks reach the built-in browser
2026-10-17 18:42:53 -07:00
esengine edaccd5f4c fix(settings): mark the Version row when an update is known 2026-10-17 18:37:55 -07:00
YHH 8dbf869fa8 Merge pull request #13680 from esengine/a5/settings-catalog
feat(settings): declare every user-config key with a class, an effect timing and a secret flag
2026-10-17 18:37:34 -07:00
YHH 0ea504ea77 Merge pull request #13679 from esengine/fix/dispatch-switch-off-identity
fix(peers): say that the dispatch switch is off, apart from a host that cannot open panes
2026-10-17 18:30:26 -07:00
YHH e88e02556a Merge pull request #13676 from esengine/fix/11577-editor-text-size
fix(desktop): the code editor's text and find panel follow the interface text size
2026-10-17 18:24:46 -07:00
esengine 2529f757f4 test(desktop): restore the focused-window assertion 2026-10-17 18:22:57 -07:00
SivanCola e7d50cf0e9 fix(persona): preserve first-party guidance and user scope
Problem: project configuration could replace the user persona, and complete
mode could suppress built-in guidance. The UI misstated rebuild semantics.

Root cause: persona was absent from the user-scope hold, replacement was a
supported runtime option, and settings metadata described only new sessions.

Fix: hold and clone the user's persona across project decoding, retire complete
throughout config/runtime/API/UI, and describe next-build activation. Integrate
current studio without dropping its settings or transport contracts.

Verification: gofmt, vet, lint, make check, root and Desktop Go suites, boot
scope/prompt regressions, frontend typecheck/build and 5,369 tests pass.
2026-10-18 09:22:31 +08:00
esengine 466e44779b fix(desktop): a window that fails to raise gets its opacity and mouse back 2026-10-17 18:22:18 -07:00
esengine f9714be60d feat(settings): a writable value is judged by the config's own setter, a bound, or a stated freedom 2026-10-17 18:21:48 -07:00
esengine 8ae413c9ef fix(settings): never read a structured key, and declare protection-relaxing keys Elevated 2026-10-17 18:20:05 -07:00
esengine 6efead8677 fix(settings): read and write an optional key through its pointer 2026-10-17 18:19:40 -07:00
esengine 792c1a0fcd fix(settings): declare only effect timings a plain file write delivers 2026-10-17 18:17:16 -07:00
esengine 8a968bb900 test(desktop): the live browser check submits each turn after the last has ended 2026-10-17 18:16:55 -07:00
esengine 2180caa3d8 test(desktop): the person who restores the window also activates it 2026-10-17 18:16:55 -07:00
esengine 8e105ba1fd fix(desktop): raise a minimized window unseen for the agent's input to the built-in browser 2026-10-17 18:16:55 -07:00
esengine 245928be33 test(desktop): the live browser check uses the controls the panel has now 2026-10-17 18:16:55 -07:00
esengine 0b3c709ed1 style(settings): use reflect.TypeFor and range over int 2026-10-17 18:16:09 -07:00
esengine 32a79410d2 feat(settings): declare every user-config key with a class, an effect timing and a secret flag 2026-10-17 18:15:44 -07:00
esengine aa67e223cc fix(peers): say that the dispatch switch is off, apart from a host that cannot open panes 2026-10-17 18:12:06 -07:00
YHH bafb4c0818 Merge pull request #13674 from esengine/fix/flaky-frontend-tests
fix(desktop): the branch menu survives the chip settling, and its list is re-read when the root moves
2026-10-17 18:03:41 -07:00
YHH b79d5d0836 Merge pull request #13675 from esengine/a5/parity-exemptions
chore(repolint): scope five frontend-parity edges where a frontend reaches the same behaviour another way
2026-10-17 17:40:31 -07:00
esengine 7a1bbc51b9 fix(desktop): the code editor's text and find panel follow the interface text size 2026-10-17 17:37:37 -07:00
esengine 791c00f496 chore(repolint): scope five frontend-parity edges where a frontend reaches the same behaviour another way 2026-10-17 17:29:55 -07:00
esengine f6a3680d09 docs(release-notes): name the pull request in the fragment 2026-10-17 16:19:00 -07:00
esengine 4fd0dab507 fix(desktop): the branch menu survives the chip settling, and its list is re-read when the root moves 2026-10-17 16:09:42 -07:00
esengine e858a46f70 feat(search): say why a SearXNG instance does not answer, and test the address from settings 2026-10-17 10:54:09 -07:00
SivanCola eebc6d2a3c fix: make remote workspace binding explicit before link reuse
Problem: Host reads could borrow another pane's workspace or reuse an existing
link after its host was disabled. Review also requested explicit defaults,
boundary regressions and removal of development notes from the tree.

Root cause: remoteLink returned the first host pane before reading current
user configuration, so neither workspace identity nor the disabled policy
governed the reuse path.

Fix: Resolve the user-host default before reuse and accept only a matching
host/workspace pane. Share the declared-default-first rule with host views,
show actual and default connection paths separately, and document the rule
in both GUIDE languages. Remove three root QA notes and seven captures;
preserve their useful context in the PR discussion.

Verification: Reproduced both incorrect read behaviors before repair. Added
HTTP regressions for declared defaults, fallback, empty configuration,
reordered extras, explicit opens, project injection and disabled hosts with
existing panes. Focused frontend tests (80), production build, owning Go
serve/config and builtin/boot tests, gofmt, go vet, lint and make check passed.
2026-10-18 00:19:23 +08:00
SivanCola 8709b556f0 merge: integrate current studio project contracts
Problem: The unified sidebar conflicted with newer Studio worktree grouping
and text scaling changes.

Root cause: Both branches evolved project identity and row presentation from
the same baseline, including helpers extracted by the sidebar change.

Fix: Preserve workspaceContains across grouped local and SSH projects, keep
isolated session directories in open and palette routes, retain isolation and
peer-thread markers, and preserve font scaling. Include reviewed synthetic UI
captures and a release note for the relocated controls.

Verification: The integrated frontend suite passed 5381 tests. Four focused
grouped SSH/palette regressions and the production build passed. gofmt, go vet,
make lint, make check and builtin/boot tests passed without widening baselines.
2026-10-18 00:02:12 +08:00
SivanCola da051abb2c feat: unify local and SSH project navigation
Problem: Machine-grouped navigation made project switching cumbersome, and
session hover actions had inconsistent selection and keyboard behavior.

Root cause: Local and remote lists owned separate presentation and opening
flows. Remote defaults, paused stream costs, mutation deduplication and focus
restoration did not consistently survive asynchronous navigation.

Fix: Present one project list with source metadata, a common project picker,
fold-all control and bounded recent-session previews. Share neutral selection,
leading pin and trailing archive actions across local and SSH conversations.
Route new sessions and quick switching through the active environment, retain
latest navigation intent, and guard pending mutations and keyboard focus.
Forward remote session flags to the owning kernel and preserve typed errors,
configured workspaces and dedicated-stream connection costs.

Verification: 5263 frontend tests passed, production TypeScript/Vite build
passed, and focused browser and real SSH navigation checks are documented in
the QA reports. Owning Go serve/remotehost/attach suites passed. Pre-commit
gofmt, go vet, make lint, make check and builtin/boot tests passed without
widening the repository lint baseline.
2026-10-17 23:48:21 +08:00
SivanCola 18eeb0c8ff fix(attachments): enforce a user-owned streaming disk ceiling
Problem: ordinary binary uploads can consume unbounded workspace disk, and the
review needs direct coverage of file versus directory grants at read consumers.

Root cause: the streaming saver copies until EOF without a byte policy; the
project-config merge must not become an authority for a user disk safeguard.

Fix: enforce a named 1 GiB default while counting real bytes, retain rooted
cleanup on overflow, and return typed ceiling/source facts. Allow user config
to set the limit or explicit zero, hold it across project merges, render it
only at user scope, and strictly read it without degraded config fallback.
Cover selected-file siblings and directory link escape through actual reference
and tool consumers. Incorporate current Studio without rewriting history.

Verification: the former 8-byte/9-byte acceptance fails first and now refuses
cleanly. Full config race and focused control/serve/builtin races, frontend
tests/build, actual Electron rejection/retry, native Windows ARM64 boundary
and cleanup regressions, pinned lint and repolint pass.
2026-10-17 23:38:14 +08:00
SivanCola 8b94e2d5da merge: reconcile attachment intake with current Studio composer
Problem: the attachment PR conflicts with Studio's extracted composer helpers
and the newer worktree setup surface.

Root cause: both branches changed the Composer's chip type and helper ownership.

Fix: merge origin/studio, use its shared composerChips owner and worktree setup
flow, and retain cancellable attachment uploads and native path references.
Remove the redundant local chip type instead of duplicating the shared owner.

Verification: TypeScript checking, 155 focused frontend tests, production build,
attachment/CSRF/cloud/control/fileutil Go regressions, 169 Electron unit and
packaging tests (one existing conditional skip), pinned lint and repolint pass.
2026-10-17 22:59:32 +08:00
SivanCola ce29aae765 docs(release): note for the Studio persona page 2026-10-17 17:03:44 +08:00
SivanCola 27434c885f feat(studio): a persona page that writes the user config for new sessions 2026-10-17 17:01:53 +08:00
SivanCola 21d8cc2fa9 feat(persona): compose a user persona into the cache-stable system prompt 2026-10-17 17:01:51 +08:00
SivanCola f18883380c fix(attachments): retain rooted storage ownership through upload cleanup
Problem: CodeQL reports uncontrolled paths in streamed upload cleanup and
attachment creation. A replaced storage directory can redirect failed-upload
cleanup to an external same-name file; a linked .reasonix parent can escape too.

Root cause: filename validation and path-based writes/removals do not preserve
directory identity across filesystem changes during I/O.

Fix: use os.Root for workspace-contained directory/file creation, share an owned
file lifecycle between streamed and legacy byte writes, and retain the directory
handle for failure cleanup. Publish the existing untracked marker atomically
through the same rooted directory rather than reopening its pathname.

Verification: full control/serve/fileutil suites, focused race tests, vet,
pinned linter and repolint passed. Native Windows 11 ARM64 regressions passed
with directory junctions and protected cleanup, plus large bounded streaming.
The independent replay test failure also reproduces with the PR's Go changes
removed through a base-source overlay; no replay behavior or CI gate is changed.
2026-10-17 13:08:06 +08:00
SivanCola fc65229584 test(studio): validate Windows intake and document streaming bounds
Problem: Review requested a user-visible release fragment and clearer memory
bounds. The live regression also assumed new-session activation must create
a second runtime, which is false when an existing blank pane is reused.

Root cause: A workspace may already own a reusable blank pane, particularly
when native Windows roots have matching canonical spelling. The old setup
waited for a POST and a new pane identity instead of editor readiness.

Fix: Wait for boot completion at a stable viewport, allow a blank-pane reuse,
and preserve assertions for the captured workspace, one shared intake route,
saved upload size and four ready attachments. Allow an explicit test host
binary. Add bilingual release notes and state that memory, not total disk
consumption, is bounded by streaming.

Verification: Windows 11 ARM64 control, stream, HTTP upload/image-limit and
exact-file read-tool regressions passed. Electron 44.2.0 exercised picker,
paste and drop of a 111 MiB file and a 65 MiB streamed File with four ready
attachments. Release-note tests, repository checks and script syntax passed.
2026-10-17 11:42:29 +08:00
SivanCola 6f1e59b750 test(studio): certify upload cancellation signal listeners
Problem: The full frontend suite rejects the upload abort listener as an
unresolved interaction registration.

Root cause: The syntax-based census does not infer a Request.signal member's
AbortSignal type and cannot distinguish it from a possible user-input target.

Fix: Keep an explicitly typed upload signal and teach the shared receiver
certifier to recognize scoped AbortSignal bindings. Preserve unresolved
receivers and refuse borrowing an outer signal type across shadowing.
Do not add a file exception or raise any census or lint ceiling.

Verification: Abort receiver, actions census, cloud upload and attachment
regressions, frontend typecheck, production build and repository checks pass.
2026-10-17 11:15:10 +08:00
SivanCola 7f53665cd0 fix(studio): reference native files and stream attachment uploads
Problem: Large ordinary file attachments cause avoidable whole-file
allocations and exceed the legacy JSON attachment transport limits.

Root cause: Picker and paste intake Base64-encode files even when the shell
already knows their native paths. Pathless uploads also pass through JSON.

Fix: Share native path references across picker, paste and drop. Stream raw
ordinary-file bodies to bounded disk writes and clean up failed or cancelled
uploads. Grant external-file access only to the selected file. Preserve
image, legacy JSON and cloud relay limits; bound cloud intake before
materializing an oversized body. Document the transport and live regression.

Verification: Owning Go package suites, focused race tests, go vet, lint,
repository checks, frontend typecheck, production build and attachment
regressions passed on the original tested baseline. Real macOS Electron
exercised native picker, paste and drop with a 111 MiB file and an exact
65 MiB pathless upload. Windows native runtime verification remains open.
2026-10-17 11:08:58 +08:00
1184 changed files with 63832 additions and 3290 deletions

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
name: acp-conformance
# The agent is driven over stdio by the official ACP TypeScript SDK, and every
# message it writes is held to the schema that SDK ships. Not a required check:
# it runs when the ACP frontend or the harness changes, and on request.
on:
pull_request:
branches: [main-v2, studio]
paths:
- "internal/frontend/acp/**"
- "tools/acp-conformance/**"
- ".github/workflows/acp-conformance.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: acp-conformance-${{ github.ref }}
cancel-in-progress: true
jobs:
conformance:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "22"
- run: make acp-conformance
+3 -3
View File
@@ -43,20 +43,20 @@ jobs:
if: matrix.branch != ''
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Initialize CodeQL
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
- name: Perform CodeQL Analysis
if: matrix.branch == ''
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
category: "/language:${{ matrix.language }}"
# Results from a checked-out line are filed under that line's ref, not the
# default branch the schedule happened to run from.
- name: Perform CodeQL Analysis (${{ matrix.branch }})
if: matrix.branch != ''
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
category: "/language:${{ matrix.language }}"
ref: refs/heads/${{ matrix.branch }}
+9 -1
View File
@@ -389,6 +389,14 @@ jobs:
version="${{ needs.resolve.outputs.version }}"
bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-$arch.deb" "$arch" "${version#v}"
- name: Verify the AppImage
if: runner.os == 'Linux'
shell: bash
run: |
platform="${{ matrix.platform }}"
arch="${platform#linux/}"
bash scripts/verify-linux-appimage.sh "dist/ReasonixStudio-linux-$arch.AppImage" "$arch"
# With signing on, this leg only hands the bundle on: the signing jobs
# run nothing but the signer, and windows-package packages in between.
- name: Upload the unsigned Windows bundle for signing
@@ -1199,7 +1207,7 @@ jobs:
fi
{
printf '%s\n\n' '---'
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。x86_64 用文件名带 amd64 的包,aarch64(统信 UOS、麒麟等)用带 arm64 的包。\n\n" "$windows"
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。x86_64 用文件名带 amd64 的包,aarch64(统信 UOS、麒麟等)用带 arm64 的包。也提供 \`.AppImage\`(只提供下载,不会自更新;需要 FUSE 2,或用 \`--appimage-extract-and-run\` 启动)。\n\n" "$windows"
printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
} >> "$notes"
assets=()
+16 -1
View File
@@ -144,6 +144,16 @@ jobs:
node perf/jump-ends.mjs
node perf/modelrows.mjs
node perf/explorersize.mjs
node perf/git-panel.mjs
node perf/session-move-to.mjs
node perf/shell-refused.mjs
node perf/rail-search-hold.mjs
node perf/session-move.mjs
node perf/session-ref.mjs
node perf/browser-full.mjs
node perf/mod-ui.mjs
node perf/codeeditor-size.mjs
node perf/action-icons.mjs
node perf/rowhit.mjs
node perf/tab-pip.mjs
node perf/context-card-hover.mjs
@@ -166,6 +176,7 @@ jobs:
node perf/mcp-actions.mjs
node perf/web-access.mjs
node perf/peers-settings.mjs
node perf/provider-ports.mjs
node perf/peer-threads.mjs
node perf/text-size.mjs
node perf/fs-scale.mjs
@@ -355,7 +366,7 @@ jobs:
arm64=false
platform=$(git diff -z --name-only --no-renames "$BASE_SHA" HEAD | bash scripts/ci-platform-scope.sh)
if git diff --name-only --no-renames "$BASE_SHA" HEAD |
grep -qE '^(desktop/electron/|desktop/packaging/|desktop/cmd/update-helper/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(linux-install-smoke|verify-linux-package|verify-studio-artifacts)\.sh$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
grep -qE '^(desktop/electron/|desktop/packaging/|desktop/cmd/update-helper/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(linux-install-smoke|verify-linux-package|verify-linux-appimage|verify-studio-artifacts)\.sh$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
arm64=true
fi
fi
@@ -599,6 +610,10 @@ jobs:
if: runner.os == 'Linux'
run: bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.deb" "${{ matrix.arch }}" 0.0.0-ci
- name: The AppImage carries its architecture, the kernel and the page
if: runner.os == 'Linux'
run: bash scripts/verify-linux-appimage.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.AppImage" "${{ matrix.arch }}"
# Recorded, not asserted: the real bundle layout, so later work argues
# from what electron-builder produced rather than from its documentation.
- name: Record the macOS bundle layout
+8 -1
View File
@@ -9,7 +9,7 @@ GOEXE := $(shell go env GOEXE)
# One pin for the Makefile and the CI lint job; see .github/workflows/ci.yml.
GOLANGCI_VERSION := $(shell cat .golangci-version)
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck check test studio-test sdk-test sdk-test-race hooks cross clean studio
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck acp-conformance check test studio-test sdk-test sdk-test-race hooks cross clean studio
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
@@ -87,6 +87,13 @@ lint-cross:
test: studio-test sdk-test
go test ./...
# Drives `reasonix acp` with the official ACP TypeScript SDK and holds every
# message it writes to the schema that SDK ships. Needs node and network for
# `npm ci`, which runs without install scripts against a lockfile.
acp-conformance: build
cd tools/acp-conformance && npm ci --ignore-scripts --no-audit --no-fund
cd tools/acp-conformance && REASONIX_BIN=$(CURDIR)/bin/reasonix$(GOEXE) npm test
# The shell is a nested module, so the root `test` target never reaches it.
studio-test:
cd desktop && go test ./...
+13 -2
View File
@@ -93,6 +93,16 @@ Prefer a terminal? Download the `reasonix` command-line tool, run `reasonix setu
All four are the same program underneath. What follows is for advanced users and contributors.
## Use it from DeepSeek Harness
DeepSeek Harness (`dsh`) can hand a task to Reasonix as a subagent over ACP. This repository publishes a plugin index for it:
1. Run `reasonix setup` on the machine that runs `dsh`. In the DSH Web UI turn on Custom plugin markets (Plugins, More) and add this index in the Discover tab: `https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/studio/integrations/dsh-plugin/market-index.json`
2. Install "Reasonix subagent" from it.
3. Open the plugin's page and turn its switch on. While it is off the model has no `reasonix` tool and may run `reasonix -p` in its own shell, which looks like a delegation and is not one.
Setup details, the permission policy and limits are in [Using Reasonix from DeepSeek Harness](./docs/DSH.md).
## Versions
Reasonix ships on two lines. See the [version roadmap announcement](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748) for the reasoning behind the split.
@@ -151,7 +161,7 @@ pre-releases):
| --- | --- | --- |
| macOS | `.dmg` or `.zip` | Apple Silicon (`arm64`) / Intel (`amd64`) |
| Windows | Installer `-installer.exe` or portable `.zip` | x64 |
| Linux | `.deb` or `.rpm` | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
| Linux | `.deb`, `.rpm`, or `.AppImage` (download only) | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
Every package ships with a `.minisig` signature, and the release carries
`SHA256SUMS`. Once installed, Studio updates itself in place.
@@ -240,7 +250,8 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
## Documentation
- **Getting started:** [Guide](./docs/GUIDE.md) · [CLI reference](./docs/CLI.md) ·
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md)
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md) ·
[Use from DeepSeek Harness](./docs/DSH.md)
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
+12 -2
View File
@@ -91,6 +91,16 @@ Reasonix 是一个开源的 AI 编程助手。程序跑在你的电脑上;AI
四种用法背后是同一套程序。下面的内容写给进阶用户和贡献者。
## 在 DeepSeek Harness 里使用
DeepSeek Harness(`dsh`)可以通过 ACP 把任务交给 Reasonix 作为子智能体去做。本仓库为此发布了一个插件索引:
1. 在运行 `dsh` 的机器上先运行 `reasonix setup`。在 DSH 网页界面打开“自定义插件市场”(插件,更多),再到 Discover 页添加这个索引:`https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/studio/integrations/dsh-plugin/market-index.json`
2. 从中安装“Reasonix subagent”。
3. 打开这个插件的页面,把它的开关打开。关着的时候模型没有 `reasonix` 工具,可能改在自己的 shell 里直接运行 `reasonix -p`,看起来像委派,其实不是。
配置细节、权限策略和限制见 [在 DeepSeek Harness 中使用 Reasonix](./docs/DSH.md)。
## 版本
Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748)。
@@ -139,7 +149,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
| --- | --- | --- |
| macOS | `.dmg` 或 `.zip` | Apple Silicon(`arm64`)/ Intel(`amd64`) |
| Windows | 安装器 `-installer.exe` 或便携 `.zip` | x64 |
| Linux | `.deb` 或 `.rpm` | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
| Linux | `.deb`、`.rpm` 或 `.AppImage`(只提供下载) | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
每个安装包都附带 `.minisig` 签名,发布页另有 `SHA256SUMS`。安装后 Studio 会在
应用内自行更新。
@@ -221,7 +231,7 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
- **开始使用:** [指南](./docs/GUIDE.zh-CN.md) ·
[CLI 命令参考](./docs/CLI.zh-CN.md) · [配置路径](./docs/CONFIG_PATHS.md) ·
[ACP 编辑器接入](./docs/ACP.md)
[ACP 编辑器接入](./docs/ACP.md) · [在 DeepSeek Harness 中使用](./docs/DSH.md)
- **功能与排障:** [子智能体 Profile](./docs/SUBAGENT_PROFILES.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.md) ·
+4
View File
@@ -373,6 +373,10 @@ out of how a path is spelled, which cannot tell `internal/auth` from
- sensitive: internal/safety/egress/**
- sensitive: internal/platform/browser/**
- sensitive: internal/platform/computer/**
- sensitive: internal/platform/gitpush/**
- sensitive: internal/session/control/git_push.go
- sensitive: internal/frontend/serve/git_push.go
- sensitive: internal/frontend/serve/host_routes.go
One declaration, two effects: the same list is also the coverage gate's subject.
`make coverage-gate` holds each path to the coverage it already has, because
+22
View File
@@ -0,0 +1,22 @@
package main
import (
"os/exec"
"strings"
"testing"
)
// The host runs a scheduled child through the small schedulerun package. The
// command line is not part of what it ships, and importing it again for one
// entry point would bring back every command it carries.
func TestTheHostDoesNotCarryTheCommandLine(t *testing.T) {
out, err := exec.Command("go", "list", "-deps", ".").Output()
if err != nil {
t.Skipf("go list is not available: %v", err)
}
for pkg := range strings.FieldsSeq(string(out)) {
if pkg == "reasonix/internal/frontend/cli" {
t.Fatal("the studio host depends on the command line package")
}
}
}
+25 -5
View File
@@ -18,6 +18,7 @@ import (
"os"
"os/signal"
"path/filepath"
"reasonix/internal/ext/modhost"
"strings"
"sync"
"syscall"
@@ -33,6 +34,7 @@ import (
"reasonix/internal/contract/provider"
"reasonix/internal/contract/surface"
"reasonix/internal/frontend/remotehost"
"reasonix/internal/frontend/schedulerun"
"reasonix/internal/frontend/serve"
"reasonix/internal/frontend/traystate"
"reasonix/internal/platform/account"
@@ -48,6 +50,7 @@ import (
// Kinds register from init, so a binary builds only what it links. Without
// these every Anthropic model answers "unknown kind" at switch time.
_ "reasonix/internal/model/anthropic"
_ "reasonix/internal/model/gemini"
_ "reasonix/internal/model/openai"
_ "reasonix/internal/model/responses"
)
@@ -121,6 +124,10 @@ func main() {
if handled, code := update.MaybeRunTreeHandoff(os.Args[1:]); handled {
os.Exit(code)
}
// A scheduled run is this same binary started by the in-kernel ticker.
if scheduleChildArgs(os.Args[1:]) {
os.Exit(schedulerun.Exec(os.Args[3:]))
}
page := flag.String("page", "", "directory holding the built Studio page")
identity := flag.Bool("instance-id", false, "print the Studio instance this data home belongs to, and exit")
// The shell around this process knows which build it is; this process does
@@ -140,6 +147,8 @@ func main() {
if *stripGrants {
os.Exit(stripPackageGrants(os.Stdout, os.Stderr, *studioApp))
}
// Studio's own binary runs a Claude Code mod's module as Node.
modhost.SetEmbedder(*studioApp, os.Environ())
// Which launches are the same Studio is Reasonix's question, not a shell's:
// the answer is the canonicalized data home, and a shell asks for it rather
// than working it out from an environment it does not resolve.
@@ -250,6 +259,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
// After the socket has drained, never before: a pane torn down under a live
// request answers it with a half-closed kernel.
defer hub.Shutdown()
startScheduleTicker(ctx, lease != nil, hub, logs)
bound, err := bind(hub.Handler())
phases.mark(startupBind)
@@ -376,7 +386,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
// This window is the only client of its kernel, so a system notification
// reaches the person who asked for it. Every pane gets the same wrapper,
// not just the one the launch started with.
notifications, notifySet := hostNotifications(cfg)
notifications, notifySet, phoneLink := hostNotifications(cfg)
// One fold behind the status icon: every pane's events on the way through,
// so the tray surface answers from what the panes did rather than from a
// count the shell kept for itself.
@@ -464,7 +474,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
return nil, err
}
hub.StartRecoveryGC(ctx)
startCloudRemote(ctx, cfg, logs, hub)
startCloudRemote(ctx, cfg, logs, hub, phoneLink)
phases.mark(startupBackground)
return hub, nil
}
@@ -490,7 +500,7 @@ func runtimeOptions(root string, sink event.Sink, logs io.Writer, phases *startu
}
}
func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, tasks remotecloud.TaskService) {
func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, tasks remotecloud.TaskService, phones *notify.PhoneLink) {
httpClient, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{})
if err != nil {
fmt.Fprintf(logs, "remote cloud: %v\n", err)
@@ -519,6 +529,12 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
}
})
}
// Phone notifications leave through this connection, under this Studio's own
// device credential.
phones.Set(host)
if registrar, ok := tasks.(interface{ SetCloudPhones(serve.CloudPhones) }); ok {
registrar.SetCloudPhones(host)
}
go host.Run(ctx)
}
@@ -563,13 +579,17 @@ func hostServeConfig(cfg config.ServeConfig) config.ServeConfig {
// says today, because deciding here would make "notify me" mean "notify me
// after a restart". The shared [notifications] config is the one setting this
// window and the CLI both answer to.
func hostNotifications(cfg *config.Config) (func(event.Sink) event.Sink, *notify.Settings) {
func hostNotifications(cfg *config.Config) (func(event.Sink) event.Sink, *notify.Settings, *notify.PhoneLink) {
set := notify.NewSettings(config.NotificationsConfig{})
if cfg != nil {
set.Store(cfg.Notifications)
}
sender := notify.NewPlatformSender()
return func(sink event.Sink) event.Sink { return notify.NewSink(sink, sender, i18n.M, set) }, set
// One reporter for every runtime, so a burst across panes folds into one
// report; its sender is the account connection, attached once that is up.
link := &notify.PhoneLink{}
phone := notify.NewPhone(link)
return func(sink event.Sink) event.Sink { return notify.NewSink(sink, sender, i18n.M, set).WithPhone(phone) }, set, link
}
// studioTray answers for a shell that can put an icon back whenever the setting
+43 -1
View File
@@ -480,7 +480,7 @@ func TestHostNotificationsCarryTheSharedSettingLive(t *testing.T) {
{Notifications: config.NotificationsConfig{Enabled: true}},
nil,
} {
wrap, set := hostNotifications(cfg)
wrap, set, _ := hostNotifications(cfg)
if got := wrap(plain); got == event.Sink(plain) {
t.Fatalf("runtime was left unwrapped, so the switch could never reach it (cfg %+v)", cfg)
}
@@ -551,3 +551,45 @@ func TestAssembleAppliesTheStartupUpgrades(t *testing.T) {
t.Fatalf("after the window started, the desktop posture is %q, want the shipped auto released", got)
}
}
type phoneReports chan [2]any
func (p phoneReports) NotifyPhones(_ context.Context, kind string, count int) error {
p <- [2]any{kind, count}
return nil
}
// A runtime's own events, through the wrapper every pane gets, reach the account
// connection as a kind and a count and nothing else, and only once the switches
// ask for it.
func TestAnApprovalOnAPaneReachesThePhoneLinkAsATypeAndACount(t *testing.T) {
cfg := &config.Config{Notifications: config.NotificationsConfig{Phone: config.PhoneNotificationsConfig{Enabled: true, Approval: true}}}
wrap, set, link := hostNotifications(cfg)
reports := make(phoneReports, 4)
link.Set(reports)
sink := wrap(serve.NewBroadcaster())
sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "1", Tool: "bash", Subject: "git push --force"}})
select {
case got := <-reports:
if got != [2]any{"approval", 1} {
t.Fatalf("reported %v, want a type and a count and no text", got)
}
case <-time.After(2 * time.Second):
t.Fatal("an approval on a pane did not reach the phone link")
}
sink.Emit(event.Event{Kind: event.AskRequest})
select {
case got := <-reports:
t.Fatalf("reported %v for a kind whose switch is off", got)
case <-time.After(100 * time.Millisecond):
}
set.Store(config.NotificationsConfig{})
sink.Emit(event.Event{Kind: event.ApprovalRequest})
select {
case got := <-reports:
t.Fatalf("reported %v after the master switch was turned off", got)
case <-time.After(100 * time.Millisecond):
}
}
@@ -0,0 +1,63 @@
package main
import (
"context"
"fmt"
"io"
"path/filepath"
"reasonix/internal/base/filelock"
"reasonix/internal/frontend/serve"
"reasonix/internal/runtime/schedrun"
"reasonix/internal/runtime/scheduler"
)
// scheduleChildArgs says argv is a supervised scheduled run: the supervisor
// starts this binary as `schedule exec <id>`, and this host has no CLI of its own.
func scheduleChildArgs(args []string) bool {
return len(args) >= 2 && args[0] == "schedule" && args[1] == "exec"
}
// scheduleEligible is when this kernel may start scheduled runs: it has a
// supervised lifetime (a parent that ends it) and something the person can see.
// An orphan, or a kernel nobody can see, never spends on anyone's behalf.
func scheduleEligible(supervised, iconLive bool, panes int) bool {
return supervised && (iconLive || panes > 0)
}
// startScheduleTicker runs the in-kernel driver of scheduled runs. Every kernel
// on the machine may run it; the ticker lock lets one tick at a time.
func startScheduleTicker(ctx context.Context, supervised bool, hub *serve.Hub, logs io.Writer) {
if !supervised {
return
}
loop := &scheduler.Loop{
Eligible: func() bool { return scheduleEligible(supervised, (&studioTray{}).IconLive(), len(hub.Runtimes())) },
Lock: func() (func(), error) {
store, _, err := scheduler.OpenUserStore(nil)
if err != nil {
return nil, err
}
return filelock.TryAcquire(filepath.Join(store.Dir(), "scheduler.lock"))
},
Tick: func(ctx context.Context) (scheduler.Result, error) {
store, policy, err := scheduler.OpenUserStore(nil)
if err != nil {
return scheduler.Result{}, err
}
return (&scheduler.Scheduler{Store: store, Policy: policy, Runner: &schedrun.Supervisor{Store: store, Policy: policy}}).Tick(ctx)
},
Done: func(res scheduler.Result, err error) {
for _, r := range res.Reports {
fmt.Fprintf(logs, "schedule: ran %s: %s %s\n", r.TriggerID, r.State, r.Code)
}
for _, s := range res.Skipped {
fmt.Fprintf(logs, "schedule: skipped %s: %s\n", s.ScheduleID, s.Reason)
}
if err != nil {
fmt.Fprintln(logs, "schedule:", err)
}
},
}
go loop.Run(ctx)
}
@@ -0,0 +1,31 @@
package main
import (
"strings"
"testing"
)
func TestAScheduleChildIsRecognisedByItsArguments(t *testing.T) {
for args, want := range map[string]bool{"schedule exec sch_x/1": true, "schedule exec": true, "schedule list": false, "exec schedule": false, "": false} {
if got := scheduleChildArgs(strings.Fields(args)); got != want {
t.Errorf("%q = %v, want %v", args, got, want)
}
}
}
// An orphan, or a kernel nobody can see, never starts a scheduled run.
func TestSchedulesStartOnlyForASupervisedKernelThatCanBeSeen(t *testing.T) {
cases := []struct {
supervised, icon bool
panes int
want bool
}{
{true, true, 0, true}, {true, false, 1, true}, {true, true, 3, true},
{true, false, 0, false}, {false, true, 2, false}, {false, false, 0, false},
}
for _, c := range cases {
if got := scheduleEligible(c.supervised, c.icon, c.panes); got != c.want {
t.Errorf("%+v = %v", c, got)
}
}
}
+1
View File
@@ -12,6 +12,7 @@ import (
// Blank imports wire compile-time built-ins into their registries.
_ "reasonix/internal/model/anthropic"
_ "reasonix/internal/model/gemini"
_ "reasonix/internal/model/openai"
_ "reasonix/internal/model/responses"
_ "reasonix/internal/tools/builtin"
+6 -4
View File
@@ -135,14 +135,16 @@ func releaseAsset(repo, tag, name string, mirrored bool) update.Asset {
}
}
// installable reports whether an artifact installs itself rather than expecting
// the reader to place it: a disk image to drag from, an installer to run, a
// package for dpkg.
// installable reports whether an artifact is a download a person is offered: a
// disk image to drag from, an installer to run, a package for dpkg or rpm, or an
// AppImage to run in place. The AppImage is a download only; no native_packages
// key names it, so no client replaces one.
func installable(name string) bool {
return strings.HasSuffix(name, ".dmg") ||
strings.HasSuffix(name, "-installer.exe") ||
strings.HasSuffix(name, ".deb") ||
strings.HasSuffix(name, ".rpm")
strings.HasSuffix(name, ".rpm") ||
strings.HasSuffix(name, ".AppImage")
}
// macArchiveKeys maps a macOS archive to the platforms it can install. The
+33
View File
@@ -340,3 +340,36 @@ func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
}
}
}
// An AppImage is a download a person is offered and nothing an updater
// replaces: it must not appear as a platform asset or a native package, and
// adding it must not move any entry a shipped client reads.
func TestAnAppImageIsOfferedButNeverResolvedAsAnUpdate(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.AppImage", "ReasonixStudio-linux-arm64.AppImage"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", true); err != nil {
t.Fatalf("run: %v", err)
}
var m update.Manifest
raw, _ := os.ReadFile(filepath.Join(dir, "latest.json"))
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, name := range []string{"ReasonixStudio-linux-amd64.AppImage", "ReasonixStudio-linux-arm64.AppImage"} {
a, ok := m.Downloads[name]
if !ok || a.Sig != a.URL+".minisig" {
t.Errorf("downloads[%s] = %+v, %v; want a signed download", name, a, ok)
}
}
if len(m.NativePackages) != 1 || !strings.HasSuffix(m.NativePackages["linux-amd64"].URL, "linux-amd64.deb") {
t.Errorf("native packages = %+v, want only the .deb", m.NativePackages)
}
if len(m.Platforms) != 0 {
t.Errorf("platforms = %+v: an AppImage must not resolve as an update", m.Platforms)
}
}
+8
View File
@@ -99,11 +99,19 @@ linux:
target:
- deb
- rpm
# Download only: it does not update itself, and nothing here installs it.
- AppImage
icon: assets/icon.png
category: Development
maintainer: esengine <[email protected]>
artifactName: ReasonixStudio-linux-${arch}.${ext}
appImage:
# Stated rather than inherited: the release vocabulary is the same for every
# Linux file, and canonical-artifact-names.js turns whatever architecture
# spelling electron-builder puts here (x64, x86_64) into amd64 or fails.
artifactName: ReasonixStudio-linux-${arch}.${ext}
deb:
# Named, not defaulted. electron-builder takes the package name from
# package.json's `name` unless told otherwise, which would publish
+5 -2
View File
@@ -15,6 +15,9 @@
"build:dist": "electron-builder",
"smoke": "electron test/smoke.js",
"browser-live": "electron test/browser_live.js",
"browser-resize-live": "electron test/browser_resize_live.js",
"browser-fit-live": "electron test/browser_fit_live.js",
"browser-fit-pages": "electron test/browser_fit_pages.js",
"image-live": "electron test/image_live.js",
"image-pane-live": "electron test/image_pane_live.js",
"sidepanel-live": "electron test/sidepanel_live.js",
@@ -25,9 +28,9 @@
"computer-live": "electron test/computer_live.js"
},
"devDependencies": {
"electron": "44.7.0",
"electron": "44.7.2",
"electron-builder": "^26.15.3",
"playwright": "1.64.0"
"playwright": "1.64.1"
},
"pnpm": {
"onlyBuiltDependencies": [
+14 -14
View File
@@ -9,14 +9,14 @@ importers:
.:
devDependencies:
electron:
specifier: 44.7.0
version: 44.7.0([email protected])
specifier: 44.7.2
version: 44.7.2([email protected])
electron-builder:
specifier: ^26.15.3
version: 26.15.3([email protected])([email protected])
playwright:
specifier: 1.64.0
version: 1.64.0
specifier: 1.64.1
version: 1.64.1
packages:
@@ -374,8 +374,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
[email protected].0:
resolution: {integrity: sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==}
[email protected].2:
resolution: {integrity: sha512-u3Pbz0Hi1sSSwBMz2v8LiagsfUG4QURPUe/qphM+g4THKP8o4BYdnuboyhlLO8HIC/KfV5rvltyacbP4jEq9yw==}
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].0:
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
[email protected].1:
resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
engines: {node: '>=20'}
hasBin: true
[email protected].0:
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
[email protected].1:
resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==}
engines: {node: '>=20'}
hasBin: true
@@ -1551,7 +1551,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected].0([email protected]):
[email protected].2([email protected]):
dependencies:
'@electron-internal/extract-zip': 1.0.5
'@electron/get': 5.1.0([email protected])
@@ -1942,11 +1942,11 @@ snapshots:
pvutils: 1.2.0
tslib: 2.8.1
[email protected].0: {}
[email protected].1: {}
[email protected].0:
[email protected].1:
dependencies:
playwright-core: 1.64.0
playwright-core: 1.64.1
[email protected]:
dependencies:
+101 -7
View File
@@ -3,6 +3,7 @@ const crypto = require("node:crypto");
const { WebContentsView, session } = require("electron");
const { installPaneReload } = require("./reload");
const { guestNavigationAllowed, typed } = require("./browserguard");
const { InputWake } = require("./inputwake");
const { KeyboardKeeper } = require("./keyboard");
// The size a page lays out at while nobody is looking at it: the kernel drives
@@ -38,6 +39,7 @@ class BrowserViews {
this.trusted = new Set();
this.shown = "";
this.keyboard = new KeyboardKeeper({ win });
this.wake = new InputWake({ win });
// Moves with every show and hide, so a freeze that finishes after one does
// not put away a page shown since.
this.placed = 0;
@@ -114,7 +116,7 @@ class BrowserViews {
}
onEvent(method, params);
});
const entry = { view, onDownload, typed: 0, synthUntil: 0, attached: false };
const entry = { view, onDownload, typed: 0, synthUntil: 0, attached: false, panel: null, override: null, scale: 1 };
this.keyboard.watch(contents, () => Date.now() < entry.synthUntil);
this.watchLoads(targetId, entry);
let closed = false;
@@ -138,8 +140,18 @@ class BrowserViews {
return {
targetId,
send: (method, params) => {
if (method.startsWith("Input.")) entry.synthUntil = Date.now() + 250;
return contents.debugger.sendCommand(method, params);
if (method === "Emulation.setDeviceMetricsOverride") return this.emulate(entry, params);
if (method === "Emulation.clearDeviceMetricsOverride") return this.emulate(entry, null);
try {
params = scaleInput(method, params, entry.scale);
} catch (e) {
return Promise.reject(e);
}
// A capture puts the page's emulation back without its scale.
if (method === "Page.captureScreenshot") return contents.debugger.sendCommand(method, params).finally(() => void this.fit(entry, true));
if (!method.startsWith("Input.")) return contents.debugger.sendCommand(method, params);
entry.synthUntil = Date.now() + 250;
return this.wake.run(() => contents.debugger.sendCommand(method, params));
},
close,
mainFrame: () => mainFrame,
@@ -161,7 +173,7 @@ class BrowserViews {
// which during a turn is constantly, and nearly always the same rectangle:
// re-adding the view and setting the bounds it already has is work for
// every one of those.
if (this.shown === targetId && sameRect(entry.at, at)) return;
if (this.shown === targetId && sameRect(entry.panel, at)) return;
this.placed++;
for (const [id, other] of this.entries) {
if (id !== targetId) this.putAway(other.view, other);
@@ -173,9 +185,49 @@ class BrowserViews {
if (entry.attached && !entry.at) this.win.contentView.removeChildView(entry.view);
this.win.contentView.addChildView(entry.view);
entry.attached = true;
entry.panel = at;
this.fit(entry);
this.keepKeyboard();
}
// fit puts the view where its page is drawn whole and tells the page how it is
// scaled; a page parked out of sight is not scaled. force sends the scale again
// though it has not changed, for a capture that put the page's emulation back.
fit(entry, force = false) {
if (!entry.panel) return Promise.resolve();
const { at, scale } = placement(entry.panel, entry.override && { width: entry.override.width, height: entry.override.height });
entry.view.setBounds(at);
entry.at = at;
this.keepKeyboard();
return this.scaled(entry, scale, force);
}
scaled(entry, scale, force = false) {
if (!entry.override || (entry.scale === scale && !force)) return Promise.resolve();
entry.scale = scale;
return this.override(entry).catch(() => {});
}
override(entry) {
return entry.view.webContents.debugger.sendCommand("Emulation.setDeviceMetricsOverride", { ...entry.override, scale: entry.scale });
}
// emulate records the viewport a page was told to lay out in, which the view
// is then fitted to; null is the page going back to the size of its view.
async emulate(entry, params) {
entry.override = sized(params) ? { ...params } : null;
if (!entry.override) {
entry.scale = 1;
if (entry.panel) this.fit(entry);
return entry.view.webContents.debugger.sendCommand("Emulation.clearDeviceMetricsOverride", {});
}
entry.scale = 1;
if (entry.panel) {
const { at, scale } = placement(entry.panel, entry.override);
entry.view.setBounds(at);
entry.at = at;
entry.scale = scale;
}
return this.override(entry);
}
// Attaching a view can leave nobody holding the keyboard, and the platform
@@ -250,7 +302,11 @@ class BrowserViews {
putAway(view, entry) {
const { height } = this.win.getContentBounds();
view.setBounds({ x: 1 - AGENT_VIEWPORT.width, y: height - 1, ...AGENT_VIEWPORT });
if (entry) entry.at = null;
if (entry) {
entry.at = null;
entry.panel = null;
void this.scaled(entry, 1);
}
}
control(targetId, action) {
@@ -417,6 +473,44 @@ async function loadResult(contents, url) {
}
}
// Where a page's view sits in the rectangle the panel gave it. A page laid out
// in a viewport of its own is shown whole: scaled down to fit and never up,
// centred, with the view sized to exactly what it draws.
const sized = (v) => !!v && Number.isFinite(v.width) && Number.isFinite(v.height) && v.width > 0 && v.height > 0;
function placement(panel, emulated) {
if (!sized(emulated)) return { at: panel, scale: 1 };
const scale = Math.min(1, panel.width / emulated.width, panel.height / emulated.height);
const width = Math.max(1, Math.round(emulated.width * scale));
const height = Math.max(1, Math.round(emulated.height * scale));
const x = panel.x + Math.round((panel.width - width) / 2);
const y = panel.y + Math.round((panel.height - height) / 2);
return { at: { x, y, width, height }, scale };
}
// Input that carries a point. The page reads it in the viewport it was told it
// has, the view draws that viewport scaled, and a point meant for the page lands
// where it is drawn only after the same scaling. The mouse is measured to; the
// rest are not, and a point that silently lands elsewhere is worse than a
// refusal, so they are refused while the page is scaled.
const POINTED_INPUT = new Set([
"Input.dispatchTouchEvent",
"Input.dispatchDragEvent",
"Input.emulateTouchFromMouseEvent",
"Input.synthesizeTapGesture",
"Input.synthesizeScrollGesture",
"Input.synthesizePinchGesture",
]);
function scaleInput(method, params, scale) {
if (scale === 1) return params;
if (method === "Input.dispatchMouseEvent") return { ...params, x: params.x * scale, y: params.y * scale };
if (POINTED_INPUT.has(method)) {
throw Object.assign(new Error(`${method} is not available while the page is scaled to fit the panel`), { code: "input_unscaled" });
}
return params;
}
function sameRect(a, b) {
return !!a && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
}
@@ -425,4 +519,4 @@ function validRect(rect) {
return rect && [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) && rect.width > 0 && rect.height > 0;
}
module.exports = { BrowserViews, AGENT_VIEWPORT, LEAVE_LIMIT_MS };
module.exports = { BrowserViews, AGENT_VIEWPORT, LEAVE_LIMIT_MS, placement, scaleInput };
+64
View File
@@ -0,0 +1,64 @@
"use strict";
// A page in a minimized Windows window has no surface to hit-test, so the mouse
// input the agent sends it is dropped while the call still answers and typed
// text still lands. Input is therefore sent with the window raised without
// activation, fully transparent and deaf to the mouse, and the window is
// minimized again once the agent has been quiet for a moment.
const SETTLE_MS = 300;
const HOLD_MS = 500;
class InputWake {
constructor({ win, platform = process.platform, settleMs = SETTLE_MS, holdMs = HOLD_MS }) {
this.win = win;
this.enabled = platform === "win32";
this.settleMs = settleMs;
this.holdMs = holdMs;
this.raised = null;
this.running = 0;
this.release = this.release.bind(this);
}
// run performs one input call with the window able to take it.
async run(call) {
this.running++;
clearTimeout(this.timer);
try {
await this.raise();
return await call();
} finally {
if (--this.running === 0) this.timer = setTimeout(this.release, this.holdMs);
}
}
raise() {
const win = this.win;
if (!this.enabled || win.isDestroyed()) return Promise.resolve();
if (this.raised) return this.raised;
if (!win.isMinimized()) return Promise.resolve();
this.raised = new Promise((resolve) => setTimeout(resolve, this.settleMs));
try {
win.setOpacity(0);
win.setIgnoreMouseEvents(true);
win.showInactive();
} catch (err) {
this.release();
throw err;
}
return this.raised;
}
release() {
if (!this.raised) return;
this.raised = null;
const win = this.win;
if (win.isDestroyed()) return;
const theirs = win.isFocused();
win.setIgnoreMouseEvents(false);
win.setOpacity(1);
if (!theirs) win.minimize();
}
}
module.exports = { InputWake, SETTLE_MS, HOLD_MS };
+146
View File
@@ -0,0 +1,146 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const require = createRequire(import.meta.url);
const { _electron } = require(path.join(root, "desktop/frontend-next/node_modules/playwright"));
const executablePath = process.env.REASONIX_TEST_ELECTRON || require("electron");
const home = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-attachments-live-"));
const workspace = path.join(home, "workspace");
const nativeBytes = 111 * 1024 * 1024;
const streamBytes = 65 * 1024 * 1024;
const artifacts = process.env.REASONIX_ATTACHMENT_ARTIFACTS;
let app;
let passed = false;
try {
fs.mkdirSync(workspace);
fs.writeFileSync(path.join(home, "config.toml"), [
'default_model = "smoke/smoke-model"', "[desktop]", "welcomed = true",
"[[providers]]", 'name = "smoke"', 'kind = "openai"',
'base_url = "http://127.0.0.1:9/v1"', 'models = ["smoke-model"]',
'default = "smoke-model"', 'api_key_env = "SMOKE_API_KEY"', "",
].join("\n"));
fs.writeFileSync(path.join(home, ".env"), "SMOKE_API_KEY=fixture\n");
const apk = path.join(home, "large.apk");
const file = fs.openSync(apk, "w");
fs.ftruncateSync(file, nativeBytes);
fs.closeSync(file);
app = await _electron.launch({
executablePath,
cwd: workspace,
args: [path.join(root, "desktop/electron"), "--user-data-dir=" + path.join(home, "electron-profile")],
env: {
...process.env, REASONIX_HOME: home, REASONIX_STATE_HOME: home,
REASONIX_CACHE_HOME: path.join(home, "cache"),
REASONIX_STUDIO_HOST: process.env.REASONIX_STUDIO_HOST || path.join(root, "desktop/electron/bin/reasonix-studio-host" + (process.platform === "win32" ? ".exe" : "")),
REASONIX_STUDIO_PAGE: path.join(root, "desktop/frontend-next/dist"),
},
});
let page;
for (let i = 0; i < 200; i++) {
page = app.windows().find(candidate => candidate.url().startsWith("http://127.0.0.1:"));
if (page) break;
await new Promise(resolve => setTimeout(resolve, 200));
}
assert.ok(page, "shell must open its loopback page");
await page.waitForLoadState("domcontentloaded");
await page.setViewportSize({ width: 1440, height: 900 });
await page.locator("#boot").waitFor({ state: "hidden", timeout: 60000 });
const status = await page.evaluate(async root => {
const response = await fetch("/runtimes", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ root }) });
return response.status;
}, workspace);
assert.equal(status, 200);
const project = page.locator(".wsrow").filter({ has: page.locator(".wsname").filter({ hasText: /^workspace$/ }) });
await project.locator("button.wsadd").click();
// A blank pane in this workspace may be reused without creating a runtime.
await page.waitForFunction(() => document.querySelector('.pane[data-active] input[type=file][data-action-change="session.attach"]') && !document.querySelector('.panes[aria-busy="true"]'));
const traffic = [];
page.on("request", request => {
if (/\/(drop|attachments)/.test(request.url())) traffic.push({ method: request.method(), url: request.url(), contentType: request.headers()["content-type"] });
});
const ready = count => page.waitForFunction(n => document.querySelectorAll('.pane[data-active] .shots .shot[data-state="ready"]').length >= n, count);
await page.locator('.pane[data-active] input[type=file][data-action-change="session.attach"]').setInputFiles(apk);
await ready(1);
assert.equal(traffic.length, 1);
assert.ok(traffic[0].url.endsWith("/drop"));
await page.evaluate(() => {
const source = document.createElement("input"); source.type = "file"; source.id = "stream-source"; document.body.append(source);
});
await page.locator("#stream-source").setInputFiles(apk);
for (const [index, kind] of ["paste", "drop"].entries()) {
const answer = page.waitForResponse(response => response.url().endsWith("/drop"));
await page.evaluate(kind => {
const transfer = new DataTransfer(); transfer.items.add(document.querySelector("#stream-source").files[0]);
const target = document.querySelector('.pane[data-active] textarea[data-action-paste="session.attach"]');
const event = kind === "paste" ? new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true }) : new DragEvent("drop", { dataTransfer: transfer, bubbles: true });
target.dispatchEvent(event);
}, kind);
assert.equal((await answer).status(), 200);
await ready(index + 2);
}
assert.equal(traffic.length, 3);
assert.ok(traffic.every(request => request.url.endsWith("/drop")));
assert.equal(new Set(traffic.map(request => request.url)).size, 1);
const uploaded = page.waitForResponse(response => response.url().includes("/attachments/upload"));
await page.evaluate(bytes => {
const source = document.querySelector("#stream-source").files[0];
const file = new File([source.slice(0, bytes)], "stream.apk", { type: "application/vnd.android.package-archive" });
if (window.reasonixHost.pathForFile(file)) throw Error("virtual file unexpectedly has a native path");
const transfer = new DataTransfer(); transfer.items.add(file);
const input = document.querySelector('.pane[data-active] input[type=file][data-action-change="session.attach"]');
input.files = transfer.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
}, streamBytes);
assert.equal((await uploaded).status(), 200);
await ready(4);
const saved = fs.readdirSync(path.join(workspace, ".reasonix/attachments")).find(file => file.endsWith(".apk"));
assert.ok(saved, "streamed attachment must be saved");
assert.equal(fs.statSync(path.join(workspace, ".reasonix/attachments", saved)).size, streamBytes);
assert.equal(traffic.length, 4);
assert.equal(traffic[3].contentType, "application/octet-stream");
const configPath = path.join(home, "config.toml");
fs.appendFileSync(configPath, "\n[attachments]\nmax_stream_bytes = 4\n");
const directory = path.join(workspace, ".reasonix/attachments");
const before = fs.readdirSync(directory).sort();
const refused = page.waitForResponse(response => response.url().includes("/attachments/upload") && response.status() === 413);
await page.evaluate(() => {
const transfer = new DataTransfer();
transfer.items.add(new File(["12345"], "over-limit.apk"));
const input = document.querySelector('.pane[data-active] input[type=file][data-action-change="session.attach"]');
input.files = transfer.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
});
const rejection = await (await refused).json();
assert.equal(rejection.code, "attachment.too_large");
assert.equal(rejection.params.limit_bytes, 4);
assert.equal(rejection.params.limit_source, "user");
const failedChip = page.locator('.pane[data-active] .shot[data-state="failed"]');
await failedChip.locator(".why").waitFor({ state: "visible" });
assert.ok((await failedChip.locator(".why").textContent()).includes("MB"));
assert.deepEqual(fs.readdirSync(directory).sort(), before);
fs.writeFileSync(configPath, fs.readFileSync(configPath, "utf8").replace("max_stream_bytes = 4", "max_stream_bytes = 0"));
const retried = page.waitForResponse(response => response.url().includes("/attachments/upload"));
await failedChip.locator("button.retry").click();
assert.equal((await retried).status(), 200);
await ready(5);
assert.ok(await page.locator('.pane[data-active] textarea[data-action-paste="session.attach"]').isVisible());
const result = { nativeBytes, nativeEntries: ["picker", "paste", "drop"], streamBytes, readyAttachments: 5, limitRejection: rejection, unlimitedRetry: true, traffic };
if (artifacts) {
fs.mkdirSync(artifacts, { recursive: true });
await page.screenshot({ path: path.join(artifacts, "attachments-live.png") });
fs.writeFileSync(path.join(artifacts, "attachments-live.json"), JSON.stringify(result, null, 2) + "\n");
}
console.log(JSON.stringify(result));
passed = true;
} finally {
await app?.evaluate(({ app }) => app.exit(0)).catch(() => {});
await app?.close();
if (passed) fs.rmSync(home, { recursive: true, force: true });
else console.error("Failed run retained fixtures at " + home);
}
+106
View File
@@ -0,0 +1,106 @@
"use strict";
// A page the agent lays out in a viewport of its own (a resize) is shown whole in the panel, not cropped to its
// top-left corner: scaled down to fit and centred, with the agent's clicks still landing where they were aimed.
// Run with `pnpm browser-fit-live`; it needs no network. SHOTS=<dir> also writes pictures of the window.
const fs = require("node:fs");
const path = require("node:path");
const http = require("node:http");
const { execFileSync } = require("node:child_process");
const { app, BrowserWindow } = require("electron");
const { BrowserViews } = require("../src/browserviews");
const { checker } = require("./livekit");
const PAGE = `<!doctype html><title>wide</title><style>body{margin:0;font:20px sans-serif}.bar{display:flex;justify-content:space-between;background:#1a56db;color:#fff;padding:14px;width:1280px;box-sizing:border-box}.c{width:1280px;height:600px;background:linear-gradient(90deg,#fde68a,#a7f3d0);position:relative}.r,.l{position:absolute;top:10px;background:#111;color:#fff;padding:8px}.r{right:10px}.l{left:10px;top:60px}</style><div class=bar><b>LEFT edge</b><b>RIGHT edge</b></div><div class=c><span class=r id=rc onclick="document.title='hit'">right column</span><span class=l id=lc onclick="document.title='hit'">left column</span></div>`;
// Its own profile, so another Electron run on this machine does not hold this one back.
app.setPath("userData", fs.mkdtempSync(path.join(require("node:os").tmpdir(), "rx-fit-live-")));
const { check, failures } = checker();
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
app.whenReady().then(async () => {
const server = http.createServer((_q, s) => { s.writeHead(200, { "Content-Type": "text/html" }); s.end(PAGE); });
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const win = new BrowserWindow({ width: 1000, height: 760, show: true });
await win.loadURL("data:text/html,<body style='margin:0;background:%23ddd;font:14px sans-serif'><div style='position:absolute;left:20px;top:20px'>app chrome</div>");
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const page = views.create({ partition: "fit", url: `http://127.0.0.1:${server.address().port}/`, onEvent() {}, onClosed() {}, onPopup() {}, onDownload() {} });
const view = views.entries.get(page.targetId).view;
await wait(1500);
const evalIn = (expression) => page.send("Runtime.evaluate", { expression, returnByValue: true }).then((r) => r.result.value);
const shot = (name) => {
if (!process.env.SHOTS) return;
fs.mkdirSync(process.env.SHOTS, { recursive: true });
win.focus();
const b = win.getBounds();
execFileSync("screencapture", ["-x", "-R", `${b.x},${b.y},${b.width},${b.height}`, `${process.env.SHOTS}/${name}.png`]);
};
const click = async (id = "rc") => {
await evalIn("document.title='reset'");
const r = JSON.parse(await evalIn(`JSON.stringify(document.getElementById('${id}').getBoundingClientRect())`));
for (const type of ["mouseMoved", "mousePressed", "mouseReleased"]) {
await page.send("Input.dispatchMouseEvent", { type, x: r.x + r.width / 2, y: r.y + r.height / 2, button: "left", clickCount: 1 });
}
await wait(250);
return evalIn("document.title");
};
const panel = { x: 380, y: 60, width: 560, height: 620 };
views.show(page.targetId, panel);
await wait(600);
check("no resize: the page fills the panel as before", JSON.stringify(view.getBounds()) === JSON.stringify(panel));
await page.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 0, mobile: false });
await wait(600);
const fit = view.getBounds();
check("a 1280x900 viewport in a 560x620 panel: the view is scaled to 560x394 and centred", fit.width === 560 && fit.height === 394 && fit.x === 380 && fit.y === 173, JSON.stringify(fit));
check("the page still lays out at the viewport it was given", (await evalIn("innerWidth")) === 1280);
check("an agent click aimed at the right column lands on it", (await click()) === "hit");
shot("fit-in-panel");
views.show(page.targetId, { x: 380, y: 60, width: 400, height: 500 });
await wait(600);
const narrower = view.getBounds();
check("a narrower panel re-fits the view", narrower.width === 400 && narrower.height === 281, JSON.stringify(narrower));
check("and the click still lands", (await click()) === "hit");
views.show(page.targetId, { x: 20, y: 20, width: 1400, height: 1000 });
await wait(600);
const roomy = view.getBounds();
check("a panel larger than the viewport shows it at full size, centred, never enlarged", roomy.width === 1280 && roomy.height === 900 && roomy.x === 80 && roomy.y === 70, JSON.stringify(roomy));
check("and the click lands at full size", (await click()) === "hit");
views.hide();
await wait(600);
check("parked, the page is not scaled and the click lands", (await click()) === "hit");
views.show(page.targetId, panel);
await wait(600);
check("shown again it is fitted again", view.getBounds().width === 560 && (await click()) === "hit");
const small = { x: 20, y: 20, width: 900, height: 700 };
views.show(page.targetId, small);
await page.send("Emulation.setDeviceMetricsOverride", { width: 390, height: 600, deviceScaleFactor: 0, mobile: false });
await wait(600);
const phone = view.getBounds();
check("a viewport smaller than the panel is shown at its own size, centred, not in the panel's corner", phone.width === 390 && phone.height === 600 && phone.x === 20 + (900 - 390) / 2 && phone.y === 20 + (700 - 600) / 2, JSON.stringify(phone));
check("and a click on it lands", (await click("lc")) === "hit");
views.show(page.targetId, panel);
await page.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 0, mobile: false });
await wait(600);
const taken = await page.send("Page.captureScreenshot", { format: "png" });
const png = Buffer.from(taken.data, "base64");
check("the agent's screenshot is the size of the viewport it asked for", png.readUInt32BE(16) === 1280 && png.readUInt32BE(20) === 900, `${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`);
await wait(600);
check("after the agent takes a screenshot the page is still fitted and a click still lands", (await click()) === "hit");
await page.send("Emulation.setDeviceMetricsOverride", { width: 0, height: 0, deviceScaleFactor: 0, mobile: false });
await wait(600);
check("a zero-sized override is no override: the page fills the panel and a click lands", JSON.stringify(view.getBounds()) === JSON.stringify(panel) && (await evalIn("innerWidth")) === 560 && (await click("lc")) === "hit", JSON.stringify(view.getBounds()));
await page.send("Emulation.clearDeviceMetricsOverride", {});
await wait(600);
check("clearing the viewport gives the page its panel back", JSON.stringify(view.getBounds()) === JSON.stringify(panel) && (await evalIn("innerWidth")) === 560);
shot("cleared");
console.log(failures.length ? `\n${failures.length} failed` : "\nall passed");
app.exit(failures.length ? 1 : 0);
});
@@ -0,0 +1,80 @@
"use strict";
// Local html previews, an oversized image document and a PDF in the built-in browser, each with and without an
// agent viewport: the view must be filled by the page, measured on the pixels, not just sized right.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { app, BrowserWindow } = require("electron");
const { BrowserViews } = require("../src/browserviews");
const { checker } = require("./livekit");
app.setPath("userData", fs.mkdtempSync(path.join(os.tmpdir(), "rx-fit-pages-")));
const { check, failures } = checker();
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const PANEL = { x: 380, y: 60, width: 560, height: 620 };
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-pages-"));
function png(w, h, rgb) {
const zlib = require("node:zlib");
const crc = (buf) => { let c, t = []; for (let n = 0; n < 256; n++) { c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } let r = 0xffffffff; for (const b of buf) r = t[(r ^ b) & 255] ^ (r >>> 8); return (r ^ 0xffffffff) >>> 0; };
const chunk = (type, data) => { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(body)); return Buffer.concat([len, body, c]); };
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
const row = Buffer.concat([Buffer.from([0]), Buffer.from(Array.from({ length: w }, () => rgb).flat())]);
return Buffer.concat([Buffer.from("\x89PNG\r\n\x1a\n", "binary"), chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(Buffer.concat(Array.from({ length: h }, () => row)))), chunk("IEND", Buffer.alloc(0))]);
}
fs.writeFileSync(path.join(dir, "big.png"), png(3200, 1800, [37, 99, 235]));
fs.copyFileSync("/tmp/fl/preview.html", path.join(dir, "preview.html"));
fs.copyFileSync("/tmp/fl/page.pdf", path.join(dir, "page.pdf"));
// Share of the view's sampled pixels that are the near-white or light-grey empty background of an unfilled view.
async function emptyShare(view) {
const img = await view.webContents.capturePage();
const { width, height } = img.getSize();
const bm = img.toBitmap();
let empty = 0, blue = 0, n = 0;
for (let y = 2; y < height - 2; y += Math.max(1, Math.floor(height / 40))) {
for (let x = 2; x < width - 2; x += Math.max(1, Math.floor(width / 40))) {
const i = (y * width + x) * 4;
const b = bm[i], g = bm[i + 1], r = bm[i + 2];
n++;
if (Math.min(r, g, b) > 225 && Math.max(r, g, b) - Math.min(r, g, b) < 12) empty++;
if (b > 180 && r < 90 && g < 150) blue++;
}
}
return { share: empty / n, blue: blue / n, width, height };
}
app.whenReady().then(async () => {
const win = new BrowserWindow({ width: 1000, height: 760, show: true });
await win.loadURL("data:text/html,<body style='margin:0;background:%23ddd'>app");
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const cases = [
["local html preview, no override", "preview.html", null],
["local html preview, 1280x900 override", "preview.html", { width: 1280, height: 900 }],
["local html preview, oversized 3200x1800 override", "preview.html", { width: 3200, height: 1800 }],
["3200x1800 image document, no override", "big.png", null],
["3200x1800 image document, 3200x1800 override", "big.png", { width: 3200, height: 1800 }],
["PDF, no override", "page.pdf", null],
["PDF, 1280x900 override", "page.pdf", { width: 1280, height: 900 }],
];
for (const [name, file, override] of cases) {
const page = views.create({ partition: "p" + Math.random(), url: "file://" + path.join(dir, file), onEvent() {}, onClosed() {}, onPopup() {}, onDownload() {} });
const entry = views.entries.get(page.targetId);
await wait(file.endsWith(".pdf") ? 3500 : 1500);
views.show(page.targetId, PANEL);
await wait(500);
if (override) await page.send("Emulation.setDeviceMetricsOverride", { ...override, deviceScaleFactor: 0, mobile: false });
await wait(file.endsWith(".pdf") ? 2500 : 1200);
const at = entry.view.getBounds();
if (process.env.SHOTS) { fs.mkdirSync(process.env.SHOTS, { recursive: true }); fs.writeFileSync(path.join(process.env.SHOTS, name.replace(/[^a-z0-9]+/gi, "-") + ".png"), (await entry.view.webContents.capturePage()).toPNG()); }
const m = await emptyShare(entry.view);
const inside = at.x >= PANEL.x && at.y >= PANEL.y && at.x + at.width <= PANEL.x + PANEL.width && at.y + at.height <= PANEL.y + PANEL.height;
const shown = file.endsWith(".html") ? m.share < 0.15 : m.share < 0.15 && m.blue >= (file.endsWith(".png") ? 0.5 : 0.05);
const detail = `view ${JSON.stringify(at)}, empty ${(m.share * 100).toFixed(0)}%, page-coloured ${(m.blue * 100).toFixed(0)}% of ${m.width}x${m.height}`;
console.log(" measured:", name, detail);
check(`${name}: the view sits inside the panel and the page is drawn in it`, inside && shown, detail);
views.close?.(page.targetId);
}
console.log(failures.length ? `\n${failures.length} failed` : "\nall passed");
app.exit(failures.length ? 1 : 0);
});
+40 -8
View File
@@ -95,15 +95,41 @@ async function main() {
const width = () => guest.getBounds().width;
// Watching the agent browse and reading what it says are one activity: the
// page is beside the conversation from the moment it opens.
const aside = await until("the page beside the conversation", async () => (!beneath() ? guest.getBounds() : null));
await until("the page beside the conversation", async () => (!beneath() ? guest.getBounds() : null));
// The conversation's column settles after the page first appears, so the place
// "beside" is read once the page has stopped moving.
const still = (a, b) => ["x", "y", "width", "height"].every((k) => Math.abs(a[k] - b[k]) <= 1);
let seen = guest.getBounds();
const aside = await until("the page to settle beside the conversation", async () => {
await wait(300);
const now = guest.getBounds();
const steady = still(now, seen);
seen = now;
return steady ? now : null;
});
check("the page is drawn beside the conversation as soon as the agent opens one",
aside.width < win.getContentBounds().width, { aside, content: win.getContentBounds() });
const js = (src) => win.webContents.executeJavaScript(src);
const reserved = () => js(`(() => { const e = document.querySelector('.bview'); if (!e) return null; const b = e.getBoundingClientRect(); return { x: b.left, y: b.top, width: b.width, height: b.height }; })()`);
await until("the browser tab in the pane bar", () => js(`!!document.querySelector('[data-action="pane.view"][data-value="browser"]')`));
await js(`document.querySelector('[data-action="pane.view"][data-value="browser"]').click()`);
const placed = await until("the page drawn full width", async () => (width() > aside.width ? guest.getBounds() : null));
// The browser tab beside the conversation is a switch for that view itself, so
// the page is widened with the browser's own wide control, which is how the
// person makes it the whole pane.
await until("the wide control on the browser panel", () => js(`!!document.querySelector('[data-action="browser.wide"]')`));
await js(`document.querySelector('[data-action="browser.wide"]').click()`);
// A page that is put away is as wide as an agent's viewport, so wide means drawn
// and wider than it was beside the conversation.
await until("the page drawn full width", async () => (!beneath() && width() > aside.width ? guest.getBounds() : null));
// The panel widens over a moment; the rectangle is read once it has stopped.
const same = still;
let last = guest.getBounds();
const placed = await until("the page to stop moving", async () => {
await wait(300);
const now = guest.getBounds();
const still = same(now, last);
last = now;
return still ? now : null;
});
const slot = await reserved();
const close = (a, b) => Math.abs(a - b) <= 1;
// The rectangle itself has to be the pane: with the conversation's column
@@ -123,11 +149,14 @@ async function main() {
process.stdout.write(` (no screenshot: ${err.message})\n`);
}
await js(`document.querySelector('[data-action="pane.view"][data-value="flow"]').click()`);
const back = await until("the page beside the conversation again", async () => (width() < placed.width ? guest.getBounds() : null));
check("leaving the full-width view puts the page back beside the conversation", back.width === aside.width && !beneath(), { back, aside });
await js(`document.querySelector('[data-action="browser.wide"]').click()`);
const back = await until("the page beside the conversation again", async () => (!beneath() && width() < placed.width ? guest.getBounds() : null));
// The column settles a pixel or two after the first measurement, so beside means
// the same place and size to within a few pixels.
check("leaving the full-width view puts the page back beside the conversation", Math.abs(back.width - aside.width) <= 3 && Math.abs(back.x - aside.x) <= 3 && !beneath(), { back, aside });
await js(`document.querySelector('[data-action="pane.dock"]').click()`);
// The browser tab is the switch for the side-by-side view.
await js(`document.querySelector('[data-action="pane.view"][data-value="browser"]').click()`);
await until("the page put away", async () => beneath());
check("turning the side-by-side view off stops drawing the page", beneath());
@@ -144,6 +173,8 @@ async function main() {
// One request per call plus the one that reads the last result.
const calls = act === "network" ? 3 : 2;
await until(`the turn that would ${act}`, async () => requests.length > before + calls, 90000);
// The next turn is submitted only once this one has ended, or it is queued behind it.
await until(`the ${act} turn to end`, async () => ((await js(`!document.querySelector('[data-action="session.stop"]')`)) ? true : null), 30000);
return (requests.at(-1).messages || []).filter((m) => m.role === "tool").map((m) => String(m.content || "")).join("\n");
};
const askFor = async (name) => {
@@ -159,6 +190,7 @@ async function main() {
check("the agent's input reaches the page while the window is minimized", await askFor("韩梅梅"), await greeting());
win.restore();
win.focus();
const cover = new BrowserWindow({ width: 1600, height: 1000, x: 0, y: 0, alwaysOnTop: true });
await cover.loadURL("data:text/html,<h1>cover</h1>");
cover.focus();
@@ -0,0 +1,89 @@
"use strict";
// A page the agent lays out at a viewport wider than the pane has to stay whole for the
// person watching: the view shows all of it, scaled to the pane, not its left part.
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const { app } = require("electron");
const { checker, listen, scriptedModel, seedHome, settledWindow, until, wait } = require("./livekit");
const PAGE = `<!doctype html><title>Wide</title><body style="margin:0">
<div style="width:100vw;height:100vh;background:linear-gradient(90deg,#e33,#33e);color:#fff;font:48px sans-serif">
<span style="position:absolute;left:12px;top:12px">LEFT</span>
<span style="position:absolute;right:12px;top:12px">RIGHT</span>
<button id="go" style="position:absolute;right:40px;top:300px;font:32px sans-serif" onclick="document.title='clicked'">Go</button></div>`;
const { check, failures } = checker();
const SIZE = { width: 1440, height: 1000 };
async function main() {
const page = await listen((req, res) => {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(PAGE);
});
let calls = 0;
const { handler, requests } = scriptedModel((tools) => {
calls++;
if (calls === 1) return { name: "browser_open", arguments: { url: `${page.url}/` } };
if (calls === 2) return { name: "browser_act", arguments: { tab: "t1", steps: [{ action: "resize", ...SIZE }] } };
if (calls === 3) return { name: "browser_read", arguments: { tab: "t1", what: "screenshot" } };
if (calls === 4) {
const ref = (tools.join("\n").match(/button "Go" \[(e\d+)\]/) || [])[1];
return { name: "browser_act", arguments: { tab: "t1", steps: [{ action: "click", ref }] } };
}
return null;
});
const model = await listen(handler);
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-resize-live-"));
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "rx-resize-ws-"));
process.env.REASONIX_HOME = home;
seedHome(home, model.url, workspace);
const { current } = require("../src/main.js");
try {
const win = await settledWindow();
const { client } = current();
const runtimes = await until("a pane", async () => {
const list = await client.json("GET", "/runtimes");
return Array.isArray(list) && list.length ? list : null;
});
await client.request("POST", `${runtimes[0].base}/submit`, { input: "open the wide page and lay it out wide" });
await until("the scripted turn", async () => requests.length >= 5, 60000);
const guests = () => win.contentView.children.filter((v) => v.webContents && v.webContents !== win.webContents);
const guest = await until("a page view", async () => guests()[0]);
await until("the page beside the conversation", async () => (guest.getBounds().x >= 0 ? true : null));
await wait(1500);
const bounds = guest.getBounds();
const inner = await guest.webContents.executeJavaScript("[innerWidth, innerHeight]");
check("the page is laid out at the viewport the agent asked for", inner[0] === SIZE.width && inner[1] === SIZE.height, inner);
const draw = (name) => {
const file = path.join(os.tmpdir(), name);
try {
execFileSync("screencapture", ["-x", "-o", "-l", win.getMediaSourceId().split(":")[1], file]);
process.stdout.write(` screenshot: ${file}\n`);
} catch (err) {
process.stdout.write(` (no screenshot: ${err.message})\n`);
}
};
const shown = await guest.webContents.capturePage();
const size = shown.getSize();
const px = shown.toBitmap();
const at = (x, y) => { const i = (y * size.width + x) * 4; return { b: px[i], g: px[i + 1], r: px[i + 2] }; };
const edge = at(size.width - 6, 6);
check("the right edge of the page is what the view draws at its right edge", edge.b > edge.r, edge);
check("the agent's click on a control at the right of the page landed", (await guest.webContents.getTitle()) === "clicked", await guest.webContents.getTitle());
draw("rx-resize-live.png");
const taken = await guest.webContents.debugger.sendCommand("Page.captureScreenshot", { format: "png" });
const dims = require("electron").nativeImage.createFromBuffer(Buffer.from(taken.data, "base64")).getSize();
process.stdout.write(` agent screenshot ${JSON.stringify(dims)}\n`);
check("a screenshot taken the way the agent takes one shows the page at the size it asked for", dims.width === SIZE.width && dims.height === SIZE.height, dims);
process.stdout.write(` bounds ${JSON.stringify(bounds)} inner ${JSON.stringify(inner)}\n`);
} catch (err) {
check("the run completed", false, String(err && err.message ? err.message : err));
}
process.stdout.write(failures.length ? `${failures.length} check(s) failed\n` : "all checks passed\n");
app.exit(failures.length ? 1 : 0);
}
app.whenReady().then(main);
@@ -0,0 +1,62 @@
"use strict";
// With the project folder gone, a tool call is refused with the typed cause and the
// window says so above the composer, with a way to choose the folder again.
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const { app } = require("electron");
const { checker, listen, scriptedModel, seedHome, settledWindow, until, wait } = require("./livekit");
const { check, failures } = checker();
async function main() {
let calls = 0;
const { handler, requests } = scriptedModel(() => {
calls++;
if (calls === 1) return { name: "grep", arguments: { pattern: "hello" } };
return null;
});
const model = await listen(handler);
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-root-live-"));
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "rx-root-ws-"));
process.env.REASONIX_HOME = home;
seedHome(home, model.url, workspace);
const { current } = require("../src/main.js");
try {
const win = await settledWindow();
const { client } = current();
const runtimes = await until("a pane", async () => {
const list = await client.json("GET", "/runtimes");
return Array.isArray(list) && list.length ? list : null;
});
process.chdir(os.tmpdir());
const gone = `${workspace}-moved`;
fs.renameSync(workspace, gone);
await client.request("POST", `${runtimes[0].base}/submit`, { input: "look for hello" });
await until("the scripted turn", async () => requests.length >= 2, 60000);
const result = String((requests[1].messages || []).filter((m) => m.role === "tool").at(-1)?.content || "");
check("the model was told the typed cause, not a bare system error", result.startsWith("root missing: the project folder ") && result.includes("not a permission or argument problem"), result.slice(0, 200));
const js = (src) => win.webContents.executeJavaScript(src);
const banner = await until("the banner above the composer", async () => js(`(() => { const b = document.querySelector('[data-cause="root-missing"]'); return b ? b.textContent : null; })()`), 20000).catch(() => null);
check("the window shows the banner", !!banner, banner);
check("the banner offers to choose the folder again", await js(`!!document.querySelector('[data-action="root-missing.reselect"]')`));
await wait(3000);
process.stdout.write(` page: ${await js("document.body.innerText.slice(0, 300).replace(/\\n/g, ' | ')")}\n`);
const png = path.join(os.tmpdir(), "rx-root-missing-live.png");
try {
execFileSync("screencapture", ["-x", "-o", "-l", win.getMediaSourceId().split(":")[1], png]);
process.stdout.write(` screenshot: ${png}\n`);
} catch (err) {
process.stdout.write(` (no screenshot: ${err.message})\n`);
}
fs.renameSync(gone, workspace);
} catch (err) {
check("the run completed", false, String(err && err.message ? err.message : err));
}
process.stdout.write(failures.length ? `${failures.length} check(s) failed\n` : "all checks passed\n");
app.exit(failures.length ? 1 : 0);
}
app.whenReady().then(main);
+187
View File
@@ -2281,6 +2281,115 @@ test("a parked page is attached again when it is shown, and a page already on sc
assert.deepEqual(calls, ["remove", "add", "bounds 10"], "a page put away and shown again is attached again");
});
test("a page laid out wider than the pane is scaled to fit it, and keeps the viewport the agent asked for", async () => {
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 win = {
on() {},
isDestroyed: () => false,
isFocused: () => false,
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
getContentBounds: () => ({ width: 1200, height: 800 }),
contentView: { addChildView() {}, removeChildView() {} },
};
const sent = [];
const view = {
setBounds() {},
webContents: { isDestroyed: () => false, debugger: { sendCommand: async (method, params) => sent.push([method, params]) } },
};
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const entry = { view, at: null, attached: true };
views.entries.set("t1", entry);
views.keepKeyboard = () => {};
entry.override = { width: 1440, height: 1000, deviceScaleFactor: 0, mobile: false };
views.show("t1", { x: 0, y: 0, width: 720, height: 800 });
assert.equal(sent.length, 1);
assert.equal(sent[0][1].scale, 0.5, "a 1440 wide layout in a 720 wide pane is halved");
assert.equal(sent[0][1].width, 1440, "the layout viewport is still the one the agent asked for");
views.show("t1", { x: 0, y: 0, width: 720, height: 800 });
assert.equal(sent.length, 1, "the same pane sends nothing again");
views.show("t1", { x: 0, y: 0, width: 360, height: 800 });
assert.equal(sent.at(-1)[1].scale, 0.25, "a narrower pane scales further");
views.show("t1", { x: 0, y: 0, width: 2000, height: 1200 });
assert.equal(sent.at(-1)[1].scale, 1, "a pane larger than the layout never enlarges it");
const settled = sent.length;
views.fit(entry);
assert.equal(sent.length, settled, "an unchanged scale is not sent again");
views.fit(entry, true);
assert.equal(sent.length, settled + 1, "a capture that dropped the scale gets it back");
assert.equal(sent.at(-1)[1].scale, 1);
entry.override = undefined;
const before = sent.length;
views.show("t1", { x: 0, y: 0, width: 500, height: 800 });
assert.equal(sent.length, before, "with no override there is nothing to fit");
});
test("a page given its own viewport is fitted whole into its panel, centred, and never enlarged", () => {
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 placement;
try {
({ placement } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const panel = { x: 380, y: 60, width: 560, height: 620 };
assert.deepEqual(placement(panel, null), { at: panel, scale: 1 }, "a page without a viewport of its own fills the panel");
for (const none of [{ width: 0, height: 0 }, { width: 0, height: 600 }, { width: 390, height: 0 }, { width: NaN, height: 600 }, { width: 390, height: Infinity }, { width: -1, height: 600 }]) {
assert.deepEqual(placement(panel, none), { at: panel, scale: 1 }, `${JSON.stringify(none)} is no viewport at all`);
}
const wide = placement(panel, { width: 1280, height: 900 });
assert.equal(wide.scale, 0.4375);
assert.deepEqual(wide.at, { x: 380, y: 173, width: 560, height: 394 });
const phone = placement(panel, { width: 390, height: 844 });
assert.ok(phone.scale < 1 && phone.at.height <= panel.height && phone.at.width <= panel.width);
assert.deepEqual(placement({ x: 20, y: 20, width: 1400, height: 1000 }, { width: 1280, height: 900 }), { at: { x: 80, y: 70, width: 1280, height: 900 }, scale: 1 });
});
test("input that carries a point is scaled with the page or refused, never sent to the wrong place", () => {
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 scaleInput;
try {
({ scaleInput } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const mouse = { type: "mousePressed", x: 1200, y: 800, button: "left" };
assert.equal(scaleInput("Input.dispatchMouseEvent", mouse, 1), mouse, "an unscaled page gets the point as it is");
assert.deepEqual(scaleInput("Input.dispatchMouseEvent", mouse, 0.5), { type: "mousePressed", x: 600, y: 400, button: "left" });
for (const method of ["Input.dispatchTouchEvent", "Input.dispatchDragEvent", "Input.emulateTouchFromMouseEvent", "Input.synthesizeTapGesture", "Input.synthesizeScrollGesture", "Input.synthesizePinchGesture"]) {
const params = { x: 10, y: 20, touchPoints: [{ x: 10, y: 20 }] };
assert.equal(scaleInput(method, params, 1), params, `${method} passes on an unscaled page`);
assert.throws(() => scaleInput(method, params, 0.5), { code: "input_unscaled" }, `${method} must not reach a scaled page unscaled`);
}
const key = { type: "keyDown", key: "a" };
assert.equal(scaleInput("Input.dispatchKeyEvent", key, 0.5), key, "keys carry no point");
assert.equal(scaleInput("Input.insertText", { text: "x" }, 0.5).text, "x");
});
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");
@@ -2681,3 +2790,81 @@ test("a navigation that does finish cancels the limit, and a second one gets a f
mock.timers.reset();
}
});
// Input to a page in a minimized window.
const { InputWake } = require("../src/inputwake.js");
function wakeWindow({ minimized = true, focused = false } = {}) {
const calls = [];
const win = {
destroyed: false,
isDestroyed() { return this.destroyed; },
isMinimized: () => minimized,
isFocused: () => focused,
setOpacity: (v) => calls.push(`opacity ${v}`),
setIgnoreMouseEvents: (v) => calls.push(`ignore ${v}`),
showInactive: () => { minimized = false; calls.push("showInactive"); },
minimize: () => { minimized = true; calls.push("minimize"); },
};
return { win, calls, focus: () => { focused = true; } };
}
const wakeOf = (win) => new InputWake({ win, platform: "win32", settleMs: 5, holdMs: 20 });
const later = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
test("input to a minimized window is sent with the window raised unseen, then it is minimized again", async () => {
const { win, calls } = wakeWindow();
const sent = [];
await wakeOf(win).run(async () => sent.push([...calls]));
assert.deepEqual(sent[0], ["opacity 0", "ignore true", "showInactive"]);
await later(60);
assert.deepEqual(calls.slice(3), ["ignore false", "opacity 1", "minimize"]);
});
test("several inputs in a row raise the window once", async () => {
const { win, calls } = wakeWindow();
const wake = wakeOf(win);
await Promise.all([wake.run(async () => {}), wake.run(async () => {}), wake.run(async () => {})]);
await later(60);
assert.equal(calls.filter((c) => c === "showInactive").length, 1);
assert.equal(calls.filter((c) => c === "minimize").length, 1);
});
test("a window that is not minimized, or not on Windows, is left alone", async () => {
const open = wakeWindow({ minimized: false });
await wakeOf(open.win).run(async () => {});
const mac = wakeWindow();
await new InputWake({ win: mac.win, platform: "darwin", settleMs: 5, holdMs: 20 }).run(async () => {});
await later(40);
assert.deepEqual([...open.calls, ...mac.calls], []);
});
test("a window the person focused meanwhile is not minimized under them", async () => {
const { win, calls, focus } = wakeWindow();
await wakeOf(win).run(async () => focus());
await later(60);
assert.ok(!calls.includes("minimize"));
assert.deepEqual(calls.slice(-2), ["ignore false", "opacity 1"]);
});
test("a failed input still lets the window go back", async () => {
const { win, calls } = wakeWindow();
await assert.rejects(wakeOf(win).run(async () => { throw new Error("gone"); }), /gone/);
await later(60);
assert.ok(calls.includes("minimize"));
});
test("a window destroyed during the hold is not touched", async () => {
const { win, calls } = wakeWindow();
await wakeOf(win).run(async () => {});
win.destroyed = true;
const before = calls.length;
await later(60);
assert.equal(calls.length, before);
});
test("a window that fails to raise is given its opacity and mouse back", async () => {
const { win, calls } = wakeWindow();
win.showInactive = () => { throw new Error("no window"); };
await assert.rejects(wakeOf(win).run(async () => {}), /no window/);
assert.deepEqual(calls.slice(-3), ["ignore false", "opacity 1", "minimize"]);
});
+26
View File
@@ -27,3 +27,29 @@ read it, which costs more than the seconds it saves.
`css: true` is load-bearing: the guards under `src/styles` read `app.css`
through `?raw`, and a stubbed stylesheet hands them an empty string, which
passes on nothing.
## Large-file shell regression
From the repository root, install the frontend and Electron dependencies, then
build the host and production page before running the real-shell regression:
```sh
pnpm --dir desktop/frontend-next install --frozen-lockfile
pnpm --dir desktop/electron install --frozen-lockfile
go build -o desktop/electron/bin/ ./cmd/reasonix-studio-host
pnpm --dir desktop/frontend-next build
node desktop/electron/test/attachments_live.mjs
```
The test uses a disposable workspace, Reasonix home and Electron profile. It
checks 111 MiB native-file selection, paste and drop without byte uploads,
then streams a 65 MiB virtual file and verifies its saved size and ready chip.
No provider request is sent.
Set `REASONIX_TEST_ELECTRON` to use an existing Electron executable, or
`REASONIX_ATTACHMENT_ARTIFACTS` to retain a screenshot and JSON result in a
chosen directory. Failed runs retain their fixture directory for diagnosis.
Attachment transport limits are documented in the
[English guide](../../docs/GUIDE.md#studio-file-attachments) and
[Chinese guide](../../docs/GUIDE.zh-CN.md#studio-文件附件).
+7 -7
View File
@@ -39,8 +39,8 @@
"codemirror": "^6.0.2",
"katex": "^0.19.0",
"maplibre-gl": "6.14.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-map-gl": "8.1.3",
"react-markdown": "^10.1.0",
"recharts": "3.10.1",
@@ -56,14 +56,14 @@
"devDependencies": {
"@babel/parser": "^8.0.7",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@testing-library/user-event": "^14.6.8",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"jsdom": "^30.1.2",
"playwright": "^1.64.0",
"jsdom": "^30.1.3",
"playwright": "^1.64.1",
"typescript": "^7.0.2",
"vite": "^8.3.3",
"vitest": "^5.0.3"
"vite": "^8.3.4",
"vitest": "^5.0.4"
}
}
+1 -1
View File
@@ -79,7 +79,7 @@ node perf/stickyfold.mjs # 展开的长执行过程:滚动时头部吸在顶
node perf/crumb.mjs # 标题栏里会话标题紧跟项目名,长名字也不溢出(宽/窄)
node perf/hoverradius.mjs # 输入框底行的按钮悬停时圆角和底色一致(浅/深)
node perf/jump-ends.mjs # 回到顶部/跳到最新按钮一次点击直达两端(中间有展开块也一样)
node perf/sessionrows.mjs # 会话行样式:34px、无额外行间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
node perf/sessionrows.mjs # 会话行样式:34px、1px 紧凑间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
```
@@ -0,0 +1,42 @@
// The row of actions under a reply and the row under the person's own message read as one family: the same
// button and the same icon size. A smaller icon under one of them is the kind of slip a reader sees at once.
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 size = (el) => el.evaluate((e) => {
const b = e.getBoundingClientRect();
return `${Math.round(b.width * 10) / 10}x${Math.round(b.height * 10) / 10}`;
});
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ colorScheme: scheme, viewport: { width: 1200, height: 900 } });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".user-acts");
await page.waitForSelector(".call[data-k=\"say\"] .acts");
const mine = page.locator(".user-acts .studio-icon");
const reply = page.locator(".call[data-k=\"say\"] .acts .studio-icon");
check(`${scheme}: both rows were found`, (await mine.count()) > 0 && (await reply.count()) > 0);
const replyIcon = await size(reply.first());
const replyButton = await size(page.locator(".call[data-k=\"say\"] .acts > button").first());
for (let i = 0; i < await mine.count(); i++) {
check(`${scheme}: icon ${i + 1} of the message's own row matches the reply's`, await size(mine.nth(i)) === replyIcon, `${await size(mine.nth(i))} vs ${replyIcon}`);
}
check(`${scheme}: the buttons are the same size`, await size(page.locator(".user-acts > button").first()) === replyButton);
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");
@@ -19,11 +19,12 @@ for (const scheme of ["light", "dark"]) {
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");
await page.waitForSelector('.projects-heading [data-action="workspace.add"]');
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.locator('.projects-heading [data-action="workspace.add"]').click();
await page.getByRole("dialog", { name: "打开项目", exact: true }).getByRole("button", { name: "选择本机文件夹", exact: true }).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));
@@ -1,7 +1,5 @@
// 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.
// The Projects heading's add-folder button stays visible and hit-testable
// before hover. 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";
@@ -18,11 +16,11 @@ try {
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.waitForSelector(".projects-heading");
await page.mouse.move(width - 2, 790);
await page.waitForTimeout(300);
const g = await page.evaluate(() => {
const row = document.querySelector(".machrow[data-here]");
const row = document.querySelector(".projects-heading");
const add = row?.querySelector('[data-action="workspace.add"]');
if (!add) return null;
const r = add.getBoundingClientRect();
@@ -32,7 +30,7 @@ try {
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);
check(`${where}: the Projects heading 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);
@@ -0,0 +1,60 @@
// Full-screen browser: the page takes the whole pane, the conversation is folded away but stays mounted,
// and Escape (or the same button) brings the split back.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=2";
const SHOTS = process.env.SHOTS_DIR;
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 }, reducedMotion: "reduce" });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('.navbtn[aria-label="文件"]').first().click();
await page.waitForSelector(".workbench-filelist button");
const read = () => page.evaluate(() => {
const pane = document.querySelector(".pane");
const dock = pane.querySelector(':scope > .scroll[data-pane="browser"]');
const shown = (el) => !!el && getComputedStyle(el).display !== "none";
return {
size: pane.getAttribute("data-browser-size"),
conversation: shown(pane.querySelector(":scope > .pbody")),
composer: shown(pane.querySelector(":scope > .compose")),
dockWidth: dock.getBoundingClientRect().width,
paneWidth: pane.getBoundingClientRect().width,
};
});
await page.waitForTimeout(1200);
await page.mouse.move(1390, 890);
await page.waitForTimeout(300);
const split = await read();
check(`${scheme}: starts split with the conversation shown`, split.size === "split" && split.conversation && split.composer);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/browser-${scheme}-split.png` });
await page.locator('[data-action="browser.full"]').first().click();
await page.waitForTimeout(500);
const full = await read();
check(`${scheme}: full hides the conversation and the composer`, full.size === "full" && !full.conversation && !full.composer);
check(`${scheme}: full gives the page the whole pane`, full.dockWidth >= full.paneWidth - 1, `${full.dockWidth}px of ${full.paneWidth}px`);
await page.mouse.move(1390, 890);
await page.waitForTimeout(300);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/browser-${scheme}-full.png` });
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
const back = await read();
check(`${scheme}: Escape brings the split and the conversation back`, back.size === "split" && back.conversation && back.composer);
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");
@@ -0,0 +1,50 @@
// The code editor's text and its search field follow the interface text size, like the rest of the page.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=2";
const SHOTS = process.env.SHOTS_DIR;
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-filelist button");
await page.getByText("README.md").first().click();
await page.getByRole("button", { name: "编辑", exact: true }).click();
await page.waitForSelector(".cm-content");
await page.locator(".cm-content").click();
for (const chord of ["Control+f", "Meta+f"]) {
if (await page.locator(".cm-textfield").count()) break;
await page.keyboard.press(chord);
await page.waitForTimeout(300);
}
await page.waitForSelector(".cm-textfield");
for (const scale of [0.85, 1, 1.6]) {
await page.evaluate((v) => document.documentElement.style.setProperty("--fs-scale", String(v)), scale);
await page.waitForTimeout(1000);
const read = await page.evaluate(() => {
const px = (el) => parseFloat(getComputedStyle(el).fontSize);
return { text: px(document.querySelector(".cm-content")), field: px(document.querySelector(".cm-textfield")) };
});
check(`${scheme} x${scale}: the editor text is ${12 * scale}px`, Math.abs(read.text - 12 * scale) < 0.1, `${read.text}px`);
check(`${scheme} x${scale}: the search field is ${11 * scale}px`, Math.abs(read.field - 11 * scale) < 0.1, `${read.field}px`);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/editor-${scheme}-${scale}.png`, fullPage: false });
}
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");
+86
View File
@@ -0,0 +1,86 @@
// The Git panel in the workbench: a workspace that is one repository shows the changes as a folder tree to stage,
// discard and commit, a push that is previewed before it is confirmed, and a history whose commits open into their
// files; a folder of projects shows one section per repository, each acting on its own.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const shotDir = process.env.SHOTS;
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"]) {
for (const width of [1400, 700]) {
const tag = `${scheme} ${width}px`;
const open = async (query) => {
const page = await browser.newPage({ colorScheme: scheme, viewport: { width, height: 1000 }, locale: "zh-CN", reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=zh&ws=2&sess=3&turns=2${query}`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('.navbtn[aria-label="文件"]').first().click();
await page.waitForSelector(".workbench-changes");
return page;
};
const shot = async (page, name) => {
if (!shotDir) return;
await page.mouse.move(width - 3, 995);
await page.waitForTimeout(250);
const box = await page.locator(".workbench-explorer").first().boundingBox();
// At phone width the composer's toolbar is drawn over the foot of the column; the panel's own picture stops above it.
await page.screenshot({ path: `${shotDir}/${scheme}-${width}-${name}.png`, clip: { x: box.x, y: box.y, width: box.width, height: box.height - (width <= 700 ? 150 : 0) } });
};
const one = await open("");
const tree = await one.evaluate(() => ({
folders: [...document.querySelectorAll(".workbench-change-folder")].length,
rows: [...document.querySelectorAll('.workbench-change-row[data-action="workbench.diff"]')].length,
overflow: document.querySelector(".workbench-explorer").scrollWidth > document.querySelector(".workbench-explorer").clientWidth + 1,
}));
check(`${tag}: the changes are a folder tree`, tree.folders >= 2 && tree.rows === 4, JSON.stringify(tree));
check(`${tag}: the panel does not push sideways`, !tree.overflow);
await shot(one, "changes");
await one.locator('[data-action="workbench.discard"]').first().focus();
await one.locator('[data-action="workbench.discard"]').first().click();
await one.waitForSelector(".workbench-discard-ask");
const ask = await one.evaluate(() => document.querySelector(".workbench-discard-ask").textContent);
check(`${tag}: a discard asks first and says it cannot be undone`, ask.includes("无法恢复"));
await shot(one, "discard");
await one.getByRole("button", { name: "取消", exact: true }).first().click();
await one.getByRole("button", { name: "推送…" }).click();
await one.waitForSelector(".push-card");
const push = await one.evaluate(() => document.querySelector(".push-card").textContent);
check(`${tag}: the push says where it goes before it goes`, push.includes("github.com") && push.includes("main → origin/main") && push.includes("从不强制推送"), push.slice(0, 120));
await shot(one, "push");
await one.getByRole("button", { name: "推送到 github.com", exact: false }).last().click();
await one.waitForSelector('.push-card[data-phase="done"]');
check(`${tag}: a confirmed push reports it`, (await one.locator(".push-card").textContent()).includes("已把 2 个提交推送到 github.com"));
await shot(one, "pushed");
await one.getByText("提交历史").click();
await one.getByRole("button", { name: /show the commit graph/ }).click();
await one.getByRole("button", { name: /retry\.go/ }).last().click();
await one.waitForSelector(".gh-diff");
await shot(one, "history");
await one.close();
const many = await open("&git=multi");
const heads = await many.evaluate(() => [...document.querySelectorAll(".workbench-repo-head")].map((h) => h.textContent));
check(`${tag}: a folder of projects shows a section per repository`, heads.length === 3 && heads[0].includes("api") && heads[1].includes("feature/login"), heads.join(" | "));
await shot(many, "repos");
await many.locator('.workbench-repo[data-repo="web"] [data-action="workbench.stage"]').first().click();
await many.waitForFunction(() => document.querySelector('.workbench-repo[data-repo="web"] [data-action="workbench.stage"]')?.getAttribute("aria-checked") === "true");
check(`${tag}: staging in one repository leaves the others as they were`, (await many.locator('.workbench-repo[data-repo="api"] [data-action="workbench.stage"][aria-checked="true"]').count()) === 1);
await many.close();
}
}
} finally {
await browser.close();
}
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
+86 -6
View File
@@ -50,7 +50,8 @@ import { App } from "../src/ui/App";
import { MockHub } from "../src/port/mock_hub";
import { MockPort } from "../src/port/mock";
import { fromHistory } from "../src/state/session";
import type { AgentPort, Appearance, HistoryMessage, McpEntry } from "../src/port/port";
import { HttpError } from "../src/port/port";
import type { AgentPort, Appearance, ChipCall, HistoryMessage, McpEntry } from "../src/port/port";
import type { UpdateProgress } from "../src/port/version";
import type { ThemePack } from "../src/port/look";
import type { RuntimeView, TreeWorkspace } from "../src/port/hub";
@@ -98,6 +99,26 @@ class BenchPort extends MockPort {
// ?mcpactions=1 adds a server whose tools are withheld and one that is followed.
constructor() {
super();
// ?git=multi is a folder of projects: three repositories, each with changes of its own.
if (query.get("git") === "multi") {
this.repoRows = [
{ dir: "api", name: "api", branch: "main", detached: false },
{ dir: "web", name: "web", branch: "feature/login", detached: false },
{ dir: "tools/release", name: "release", branch: "main", detached: false },
];
this.repoChanges = {
api: [
{ path: "internal/server/routes.go", status: "M", unstaged: true, insertions: 12, deletions: 3 },
{ path: "internal/server/routes_test.go", status: "M", staged: true, insertions: 20, deletions: 0 },
{ path: "README.md", status: "??", unstaged: true },
],
web: [
{ path: "src/login/Form.tsx", status: "M", unstaged: true, insertions: 31, deletions: 9 },
{ path: "src/login/api.ts", status: "M", staged: true, unstaged: true, insertions: 4, deletions: 1 },
],
"tools/release": [],
};
}
if (!query.has("mcpactions")) return;
(this as unknown as { servers: McpEntry[] }).servers.push(
{ name: "notes", state: "ready", enabled: true, transport: "stdio", tools: 2, description: "Notes the agent can search and add to.",
@@ -106,16 +127,31 @@ class BenchPort extends MockPort {
);
}
// ?netshell=1 is a connection over HTTP: a `!` line is refused as the kernel refuses it.
async submit(text: string, chips?: ChipCall) {
(window.__sent ??= []).push(text);
if (query.has("netshell") && text.trimStart().startsWith("!")) throw new HttpError(403, "shell commands are unavailable over HTTP", { code: "shell.unavailable_over_http" });
return super.submit(text, chips);
}
// ?checkfail=<name> makes a plain test of that source answer 401.
protected async answerCheck(name: string) {
if (query.get("checkfail") !== name) return super.answerCheck(name);
return { ok: false, code: "provider.probe.unauthorized", httpStatus: 401, detail: "Invalid Authentication" };
}
// ?twoports=1: two local servers on one host, told apart by port, sharing a key slot.
// ?badprice=1: DeepSeek's flash price is a card the vendor's table does not have,
// under a pinned display currency.
async providers() {
const all = await super.providers();
if (query.has("twoports")) {
const local = (name: string, port: number) => ({
...all[0], name, baseUrl: `http://127.0.0.1:${port}/v1`, keyEnv: "LOCAL_API_KEY", inUse: false, preset: false,
models: [`${name}-chat`], default: `${name}-chat`,
});
return [...all, local("local", 8756), local("local-2", 8788)];
}
if (!query.has("badprice")) return all;
return all.map((p) => p.name === "deepseek"
? { ...p, officialPricing: true, modelPrices: { "deepseek-flash": { input: 0.3, output: 1.2, cacheHit: 0, currency: "USD", official: false } } }
@@ -149,6 +185,10 @@ class BenchPort extends MockPort {
return { path, content, revision: "r1" };
}
async updateMode() {
return query.has("update") ? "notify" : super.updateMode();
}
async versions() {
if (query.has("update")) return { current: "2.42.0", pinned: "", stalePin: false, latest: "2.43.0", newer: true, versions: [] };
if (!query.has("versions")) return super.versions();
@@ -212,14 +252,20 @@ class BenchPort extends MockPort {
search_backends: ["searxng", "deepseek_api"], searxng_url: "http://127.0.0.1:8080", search_paid_allowed: [],
fetch_backends: ["local", "deepseek_api"], fetch_paid_allowed: [],
known_search_backends: ["searxng", "deepseek_api"], known_fetch_backends: ["local", "deepseek_api"], paid_backends: ["deepseek_api"],
deepseek_api_ready: query.get("webaccess") !== "noready", path: "/Users/you/.reasonix/config.toml",
deepseek_api_ready: query.get("webaccess") !== "noready", video_url_passthrough: query.has("videourl"), path: "/Users/you/.reasonix/config.toml",
};
}
async probeSearxng() {
const answer = query.get("probe");
if (!answer || answer === "ok") return super.probeSearxng();
return { ok: false, code: `search.searxng_${answer}`, status: answer === "status" ? 429 : answer === "json_disabled" ? 403 : undefined };
}
async peersSettings() {
if (!query.has("peerset")) return super.peersSettings();
return {
enabled: true, dispatch: true, applies: "next_turn" as const, pending: query.has("pending"), available: true,
enabled: true, dispatch: true, max_dispatch_depth: 1, max_dispatched_sessions: 8, applies: "next_turn" as const, pending: query.has("pending"), available: true,
path: "/Users/you/.reasonix/config.toml",
release_projects: [{ path: "/Users/you/work/site", name: "site" }, { path: "/Users/you/work/api-gateway", name: "api-gateway" }],
};
@@ -240,8 +286,15 @@ class BenchPort extends MockPort {
return PREF === null ? look : { ...look, language: PREF };
}
private settingCardSent = false;
subscribe(on: (e: WireEvent) => void) {
this.subs.add(on);
const card = query.get("settingcard");
if (card && !this.settingCardSent) {
this.settingCardSent = true;
setTimeout(() => this.feed(settingCardFrame(card)), 400);
}
return () => this.subs.delete(on);
}
@@ -356,7 +409,7 @@ function storedHistory(): HistoryMessage[] {
const msgs: HistoryMessage[] = [];
for (let i = 0; i < TURNS; i++) {
const pinned = query.get("skill") === "1" && i === TURNS - 1;
msgs.push({ role: "user", content: pinned ? SKILL_PIN : `第 ${i} 个问题,麻烦看一下这个文件。` });
msgs.push({ role: "user", content: pinned ? SKILL_PIN : `第 ${i} 个问题,麻烦看一下这个文件。`, createdAt: new Date(2026, 9, 17, 18, 33 + i).getTime() });
msgs.push({
role: "assistant",
content: `第 ${i} 段回答,说明刚才那一步做了什么。\n\n- 要点一\n- 要点二\n`,
@@ -433,6 +486,11 @@ class BenchHub extends MockHub {
);
}
async moveSessionTo(path: string, target: string) {
(window.__moves ??= []).push({ path, target });
return super.moveSessionTo(path, target);
}
tree(): Promise<TreeWorkspace[]> {
const wait = TREE_AT - performance.now();
if (wait > 0) return new Promise((resolve) => setTimeout(() => resolve(this.tree()), wait));
@@ -441,13 +499,14 @@ class BenchHub extends MockHub {
Array.from({ length: WORKSPACES }, (_, w) => ({
root: `~/projects/workspace-${w}`,
name: `workspace-${w}`,
remembered: true,
open: w === 0,
sessions: Array.from({ length: SESSIONS }, (_, s) => ({
sessions: this.placed(`~/projects/workspace-${w}`, Array.from({ length: SESSIONS }, (_, s) => ({
path: `/sessions/w${w}-s${s}.jsonl`,
name: `w${w}-s${s}`,
title: `第 ${s} 次会话,标题是内核起的一句话`,
turns: (s % 40) + 1,
})),
}))),
})),
);
}
@@ -466,6 +525,8 @@ createRoot(document.getElementById("root")!).render(<App hub={hub} />);
declare global {
interface Window {
__moves?: { path: string; target: string }[];
__sent?: string[];
// Feeds every open pane, so a split-view measurement costs what two live
// conversations really cost.
__feed: (ev: WireEvent) => void;
@@ -502,3 +563,22 @@ window.__foldCost = () => {
fromHistory(msgs);
return performance.now() - t0;
};
// ?settingcard= draws what the kernel sends when the model asks to change a
// setting: a preference card, an elevated one, or the notice that points at a
// setting only the user may change.
function settingCardFrame(kind: string): WireEvent {
if (kind === "open") {
return { kind: "notice", level: "info", code: "setting_open", text: "助手需要改动 permissions.mode,只有你本人能改。", detail: "permissions.mode" } as WireEvent;
}
const elevated = kind === "elevated";
const id = elevated ? "peers.dispatch" : "ui.show_receipt";
return {
kind: "approval_request",
approval: {
id: "s1", tool: elevated ? "setting_change_elevated" : "setting_change", subject: id, kind: "tool", fresh: true,
allowsSession: !elevated, allowsPersist: false,
reason: `助手请求把设置 ${id} 从 false 改为 true。${elevated ? "从下一回合起生效。" : "立即生效。"}\n它给出的理由:要把任务交给另一个项目里的会话。`,
},
} as WireEvent;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
// Names the demo data itself carries, and names that are written in Chinese on purpose.
const FIXTURE = [
/并行会话演示/, /上一次的会话/, /数据管线/, /跑一遍评测/, /^中文$/, /^做游戏的小鱼$/, /^DeepSeek-Reasonix官方群$/,
/并行会话演示/, /上一次的会话/, /站点改版/, /数据管线/, /跑一遍评测/, /^中文$/, /^做游戏的小鱼$/, /^DeepSeek-Reasonix官方群$/,
/缓存命中率为什么会掉/, /Co-Authored-By/, /Generated with/, /^回复用中文$/, /^跟着用户每条消息的语言走$/, /v2 是从零重写的 Go 内核/,
/没有 web,桌面端重做/, /问题里提到了 main-v2/, /构建用 -tags legacy/, /^迁移前的构建方式$/,
];
+118
View File
@@ -0,0 +1,118 @@
// Claude Code mods in the window: the band a mod draws above the prompt, a question a mod asks, the settings
// block (switch, time limit, each mod's state by code) and the install preview's full-trust listing.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const shotDir = process.env.SHOTS;
const SHOWN = process.env.MODS_SHOWN === "1";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const band = { kind: "extension_surface", extension: { pluginId: "statusline-clock", surfaceId: "mod-above-prompt", kind: "view", view: { slot: "above_prompt", body: [
{ kind: "row", children: [{ kind: "pip", tone: "ok" }, { kind: "text", value: "statusline-clock", tone: "strong" }, { kind: "text", value: "12:30 · 今天还剩 3 个任务", tone: "dim" }, { kind: "button", label: "稍后提醒", actionId: "snooze:5" }] },
] } } };
const ask = { kind: "extension_surface", extension: { pluginId: "git-guard", surfaceId: "mod-ask", kind: "form", sessionId: "s", generation: 1, form: { title: "git-guard asks", message: "要删除已经合并的分支 old-spike 吗?", fields: [{ key: "answer", label: "回答", kind: "input" }] } } };
// The pane attaches to its stream a moment after the page is up, and an event fed earlier is lost; feed again until it lands.
async function feedUntil(page, ev, seen) {
for (let i = 0; i < 20; i++) {
await page.evaluate((e) => window.__feed(e), ev);
if (await seen().then(() => true, () => false)) return;
}
await seen();
}
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const width of [1280, 420]) {
const tag = `${scheme} ${width}px`;
const page = await browser.newPage({ viewport: { width, height: 1300 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=zh&turns=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await feedUntil(page, band, () => page.getByRole("button", { name: "稍后提醒" }).waitFor({ timeout: 500 }));
const at = await page.evaluate(() => {
const s = document.querySelector(".slotted").getBoundingClientRect();
const c = document.querySelector('[aria-label="任务输入"]').getBoundingClientRect();
return { above: s.bottom <= c.top + 4, spill: s.right > innerWidth + 1 || document.documentElement.scrollWidth > innerWidth + 1 };
});
check(`${tag}: the band stands above the text the person types in`, at.above);
check(`${tag}: the band does not push the page sideways`, !at.spill);
await page.getByRole("button", { name: "稍后提醒" }).focus();
check(`${tag}: its button can be reached from the keyboard`, await page.evaluate(() => document.activeElement?.textContent === "稍后提醒"));
const ring = await page.evaluate(() => {
const b = document.activeElement, rail = document.querySelector(".slotrail");
const cs = getComputedStyle(b);
const room = Math.min(b.getBoundingClientRect().left - rail.getBoundingClientRect().left, rail.getBoundingClientRect().right - b.getBoundingClientRect().right);
return { ring: parseFloat(cs.outlineWidth) + Math.max(0, parseFloat(cs.outlineOffset)), room };
});
check(`${tag}: the focus ring of the band's button is not cut off by the strip it sits in`, ring.room >= ring.ring - 0.5, `${ring.room}px room for a ${ring.ring}px ring`);
await page.evaluate(() => document.activeElement?.blur());
if (shotDir) { await page.mouse.move(width - 5, 895); await page.waitForTimeout(300); await page.screenshot({ path: `${shotDir}/${scheme}-${width}-band.png`, clip: { x: 0, y: 760, width, height: 540 } }); }
await feedUntil(page, ask, () => page.waitForSelector('.call[data-k="extension"] .extform', { timeout: 500 }));
const card = await page.evaluate(() => {
const form = document.querySelector(".extform");
return { note: form.querySelector(".why")?.textContent ?? "", tag: document.querySelector('.call[data-k="extension"] .tag')?.textContent ?? "" };
});
check(`${tag}: a mod's question says whose it is and that answering approves nothing`, card.note.includes("git-guard") && card.note.includes("不会批准任何工具调用或权限") && card.tag.includes("git-guard"), card.note);
if (shotDir && width === 1280) await page.locator('.call[data-k="extension"]').last().screenshot({ path: `${shotDir}/${scheme}-ask.png` });
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.trim().startsWith("扩展"))?.click());
await page.waitForSelector('[data-action="extensions.add"]');
if (!SHOWN) {
check(`${tag}: the mod settings block is not drawn while the host is hidden`, (await page.locator("#set-mods").count()) === 0);
await page.locator('input[aria-label="搜索设置"]').fill("Claude Code mod");
await page.waitForTimeout(400);
const found = await page.evaluate(() => [...document.querySelectorAll(".prefs-nav *, .prefs-results *")].some((e) => e.children.length === 0 && /Claude Code mod/.test(e.textContent)));
check(`${tag}: settings search does not lead to it either`, !found);
await page.locator('input[aria-label="搜索设置"]').fill("");
} else {
await page.waitForSelector("#set-mods");
const off = await page.evaluate(() => {
const g = document.getElementById("set-mods");
return { checked: g.querySelector('[role="switch"]').getAttribute("aria-checked"), rows: g.querySelectorAll(".modlist li").length, why: [...g.querySelectorAll(".modlist .ds")].filter((e) => e.textContent.includes("没有打开")).length };
});
check(`${tag}: the switch ships off and every mod says why it is not running`, off.checked === "false" && off.rows === 4 && off.why === 4, `${off.rows} mods, ${off.why} reasons`);
await page.locator('#set-mods [role="switch"]').click();
await page.waitForFunction(() => document.querySelectorAll("#set-mods .modlist li[data-state='running']").length === 1);
const on = await page.evaluate(() => {
const g = document.getElementById("set-mods");
const r = g.getBoundingClientRect();
const states = [...g.querySelectorAll(".modlist li")].map((li) => li.getAttribute("data-state"));
return { states, spill: r.right > innerWidth + 1 || g.scrollWidth > g.clientWidth + 1, marks: [...g.querySelectorAll(".modstate")].every((e) => /^\S+\s+\S+/.test(e.textContent.trim())) };
});
check(`${tag}: each state shows running, disabled, restarting or not running, with a mark as well as a word`, on.states.join() === "running,disabled,restarting,inactive" && on.marks, on.states.join());
check(`${tag}: the block does not spill sideways`, !on.spill);
if (shotDir) await page.locator("#set-mods").screenshot({ path: `${shotDir}/${scheme}-${width}-settings.png` });
}
if (width === 1280) {
await page.locator('[data-action="extensions.add"]').first().click();
const source = page.locator("textarea, input").filter({ hasNot: page.locator("[aria-label]") }).last();
await page.locator('textarea[placeholder*="github.com"], input[placeholder*="github.com"]').first().fill("https://github.com/acme/claude-code mod git-guard");
await page.locator('[data-action="extensions.inspect"]').first().click();
await page.waitForSelector(".modpreview", { timeout: 10000 });
const prev = await page.evaluate(() => {
const m = document.querySelector(".modpreview");
const text = m.textContent;
return { trust: text.includes("完全信任"), static: text.includes("不是保证"), imports: text.includes("看不见"), unserved: text.includes("本应用不会触发的事件"), hash: !!m.querySelector("[title]") };
});
check(`${tag}: the install preview states full trust, that it is a static listing, hidden imports and unserved events`, prev.trust && prev.static && prev.imports && prev.unserved && prev.hash, JSON.stringify(prev));
if (shotDir) await page.locator(".cand").first().screenshot({ path: `${shotDir}/${scheme}-preview.png` });
}
await page.close();
}
}
await browser.close();
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
@@ -0,0 +1,37 @@
// Two servers on one host and different ports are two connections, each listed under its own address.
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 shotDir = process.env.SHOTS;
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 900 }, reducedMotion: "reduce" });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?twoports=1`, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 20000 });
await page.getByRole("button", { name: "设置", exact: true }).click();
await page.getByText("模型服务", { exact: true }).first().click();
await page.waitForSelector('[data-action-click="provider.select"]');
const text = await page.evaluate(() => [...document.querySelectorAll('[data-action-click="provider.select"]')].map((b) => b.textContent ?? ""));
check(`${scheme}: the server on port 8756 is listed`, text.some((t) => t.includes("127.0.0.1:8756")), text.join(" | "));
check(`${scheme}: the server on port 8788 is listed beside it`, text.some((t) => t.includes("127.0.0.1:8788")), text.join(" | "));
await page.waitForTimeout(900);
if (shotDir) await page.screenshot({ path: `${shotDir}/${scheme}-ports.png` });
await ctx.close();
}
} finally {
await browser.close();
}
if (fails.length) {
console.log(`\n${fails.length} failed:\n ` + fails.join("\n "));
process.exit(1);
}
console.log("\nall passed");
@@ -0,0 +1,64 @@
// The rail's search box stays open while it holds a query: pressing a result takes focus off the box, and a
// box that folds away on blur moves the list up under the pointer, so the first press never became a click.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const shotDir = process.env.SHOTS;
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 page = await browser.newPage({ viewport: { width: 1280, height: 900 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=zh&turns=2&search=1`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
const state = () => page.evaluate(() => {
const box = document.querySelector(".wsfind");
const cs = getComputedStyle(box);
return { shown: cs.opacity === "1" && cs.position === "relative", top: Math.round(box.getBoundingClientRect().top) };
});
await page.getByRole("searchbox").fill("轮换");
await page.waitForSelector(".content-matches li");
const hit = page.locator(".content-matches li button[data-action='session.open-match']").first();
const before = await hit.boundingBox();
if (shotDir) await page.screenshot({ path: `${shotDir}/${scheme}-typed.png`, clip: { x: 0, y: 0, width: 420, height: 520 } });
await page.evaluate(() => {
window.__events = [];
for (const ev of ["mousedown", "mouseup", "click"]) {
window.addEventListener(ev, (e) => window.__events.push(`${ev} ${e.target.tagName}[${e.target.closest?.("[data-action]")?.getAttribute("data-action") ?? ""}]`), true);
}
});
await page.mouse.move(before.x + 20, before.y + before.height - 8);
await page.mouse.down();
await page.waitForTimeout(200);
const held = await state();
const moved = (await hit.boundingBox()).y - before.y;
if (shotDir) await page.screenshot({ path: `${shotDir}/${scheme}-pressed.png`, clip: { x: 0, y: 0, width: 420, height: 520 } });
await page.mouse.up();
await page.waitForTimeout(200);
const events = await page.evaluate(() => window.__events);
check(`${scheme}: the search box stays open after focus leaves it, because it holds a query`, held.shown);
check(`${scheme}: pressing a result does not move the list under the pointer`, Math.abs(moved) < 1, `${moved}px`);
check(`${scheme}: the first press on a result becomes a click on that result`, events.some((e) => e.startsWith("click ") && e.endsWith("[session.open-match]")), events.join(" | "));
await page.getByRole("searchbox").fill("");
await page.keyboard.press("Tab");
await page.mouse.click(700, 500);
await page.waitForTimeout(250);
const idle = await state();
check(`${scheme}: with no query the box still folds away when it loses focus`, !idle.shown);
await page.close();
}
await browser.close();
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
+3 -2
View File
@@ -59,9 +59,10 @@ for (const [ws, sess] of SHAPES) {
const after = Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map((m) => [m.name, m.value]));
// 展开一个折叠的工作区,量这一下点击有多久才落地。
const twist = page.locator(".wsnode").nth(1).locator("button.twist");
const project = page.locator(".wsnode > .wsrow").nth(1);
if (await project.getAttribute("aria-expanded") === "true") await project.click();
const tExpand = Date.now();
await twist.click();
await project.click();
await page.waitForTimeout(50);
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
const expand = Date.now() - tExpand;
@@ -0,0 +1,51 @@
// A conversation can be moved to another project from its menu: the dialog says what travels and what does not,
// lists the other projects, and asks the kernel exactly once for the one chosen.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const shotDir = process.env.SHOTS;
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"]) {
for (const width of [1280, 420]) {
const tag = `${scheme} ${width}px`;
const page = await browser.newPage({ viewport: { width, height: 900 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=zh&ws=3&sess=3&turns=1`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
const rows = '.sessrow[role="treeitem"][data-target]';
await page.waitForSelector(rows);
const first = await page.locator(rows).first().getAttribute("data-target");
await page.evaluate((sel) => document.querySelector(sel).dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 140, clientY: 300 })), rows);
await page.getByRole("menuitem", { name: "移动到项目…" }).click();
await page.waitForSelector('[role="dialog"] select');
const dlg = await page.evaluate(() => {
const d = document.querySelector('[role="dialog"]:has(select)');
const card = d.querySelector(".pickcard").getBoundingClientRect();
return { options: [...d.querySelectorAll("option")].map((o) => o.textContent), text: d.textContent, fits: card.left >= 0 && card.right <= innerWidth + 1 && card.bottom <= innerHeight + 1, focusIn: d.contains(document.activeElement) };
});
check(`${tag}: it lists the other projects but not the one it is in`, dlg.options.length === 2 && !dlg.options.some((o) => o.startsWith("workspace-0")), dlg.options.join(" | "));
check(`${tag}: it says what travels and what does not`, dlg.text.includes("原项目的权限与目标设置不会跟着走") && dlg.text.includes("路径保持原样"));
check(`${tag}: the dialog fits the window and holds focus`, dlg.fits && dlg.focusIn, JSON.stringify({ fits: dlg.fits, focusIn: dlg.focusIn }));
if (shotDir) await page.mouse.move(width - 3, 895), await page.waitForTimeout(250), await page.screenshot({ path: `${shotDir}/${scheme}-${width}-dialog.png` });
await page.locator('[role="dialog"] select').selectOption({ index: 1 });
await page.getByRole("button", { name: "移动", exact: true }).click();
await page.waitForFunction(() => (window.__moves ?? []).length === 1);
const moves = await page.evaluate(() => window.__moves);
check(`${tag}: the kernel is asked once, for this conversation and the chosen project`, moves.length === 1 && moves[0].path === first && moves[0].target.endsWith("workspace-2"), JSON.stringify(moves));
await page.waitForFunction(() => !document.querySelector('[role="dialog"]:has(select)'));
check(`${tag}: the dialog closes when it is done`, true);
await page.close();
}
}
await browser.close();
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
@@ -0,0 +1,60 @@
// A conversation can be moved one place up or down in its project's list from its menu or with Alt+Arrow,
// the new order is the kernel's (it is read again, not patched), and the menu says where a step has nowhere to go.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const shotDir = process.env.SHOTS;
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"]) {
for (const width of [1280]) {
const tag = `${scheme} ${width}px`;
const page = await browser.newPage({ viewport: { width, height: 900 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=zh&ws=1&sess=4&turns=1`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
const rows = '.wsnode:has(> .wsrow[data-target="~/projects/workspace-0"]) .sessrow[role="treeitem"][data-target]';
const remoteRows = '.sessrow-remote[role="treeitem"][data-target]';
const order = () => page.evaluate((sel) => [...document.querySelectorAll(sel)].map((e) => e.getAttribute("data-target")?.replace(/.*\/|\.jsonl/g, "")), rows);
await page.waitForSelector(rows);
const start = await order();
const remoteOrder = () => page.locator(remoteRows).evaluateAll((els) => els.map((el) => el.dataset.target));
const remoteStart = await remoteOrder();
check(`${tag}: SSH conversations share the list`, remoteStart.length > 0, remoteStart.join());
check(`${tag}: the project lists its four conversations`, start.length === 4, start.join());
const first = page.locator(rows).first();
await first.click({ button: "right" });
const menu = await page.evaluate(() => {
const items = [...document.querySelectorAll('[role="menuitem"][data-action="session.move"]')];
return { labels: items.map((b) => b.textContent.trim()), disabled: items.map((b) => b.disabled) };
});
check(`${tag}: the first conversation can only go down`, menu.labels.join() === "上移,下移" && menu.disabled.join() === "true,false", JSON.stringify(menu));
if (shotDir) await page.locator(".session-pop").screenshot({ path: `${shotDir}/${scheme}-menu.png` });
await page.locator('[data-action="session.move"][data-value="1"]').click();
await page.waitForFunction(([sel, s]) => [...document.querySelectorAll(sel)].map((e) => e.getAttribute("data-target")?.replace(/.*\/|\.jsonl/g, "")).join() !== s, [rows, start.join()]);
const moved = await order();
check(`${tag}: moving it down puts it second, the rest keep their order`, moved.join() === [start[1], start[0], start[2], start[3]].join(), moved.join());
await page.locator(rows).nth(1).focus();
await page.keyboard.press("Alt+ArrowUp");
await page.waitForFunction(([sel, s]) => [...document.querySelectorAll(sel)].map((e) => e.getAttribute("data-target")?.replace(/.*\/|\.jsonl/g, "")).join() === s, [rows, start.join()]);
check(`${tag}: Alt+Up on the focused row puts it back first`, (await order()).join() === start.join());
check(`${tag}: sorting local conversations preserves the SSH rows`, JSON.stringify(await remoteOrder()) === JSON.stringify(remoteStart));
check(`${tag}: sorting keeps focus on the moved row`, await page.locator(rows).first().evaluate((el) => el === document.activeElement));
await page.mouse.move(width - 3, 895);
if (shotDir) await page.locator(".rail").screenshot({ path: `${shotDir}/${scheme}-rail.png` });
await page.close();
}
}
await browser.close();
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
+104
View File
@@ -0,0 +1,104 @@
// A past conversation is referenced from the message box: "#" lists this
// workspace's conversations, a pick becomes a removable chip, a search hit in
// the rail can be referenced too, and the size limit is a real control.
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 shotDir = process.env.SHOTS;
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const width of [1280, 420]) {
const tag = `${scheme} ${width}px`;
const page = await browser.newPage({ viewport: { width, height: 900 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=zh&turns=2&search=1`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
const box = page.getByRole("combobox", { name: "任务输入" });
await box.click();
await box.pressSequentially("接着上次的讨论 #");
await page.waitForSelector(".slashmenu .mi");
const menu = await page.evaluate(() => {
const m = document.querySelector(".slashmenu");
const r = m.getBoundingClientRect();
const rows = [...m.querySelectorAll(".mi")];
return {
rows: rows.length,
kinds: [...new Set(rows.map((x) => x.querySelector(".rt")?.textContent))],
spill: r.left < -1 || r.right > innerWidth + 1,
hints: rows.every((x) => !!x.querySelector(".ds")),
};
});
check(`${tag}: # lists past conversations marked as conversations`, menu.rows >= 2 && menu.kinds.length === 1 && menu.kinds[0] === "会话", `${menu.rows} rows ${menu.kinds}`);
check(`${tag}: each row says when it was`, menu.hints);
check(`${tag}: the menu stays on screen`, !menu.spill);
if (shotDir && width === 1280) {
const area = await page.evaluate(() => {
const a = document.querySelector(".compose").getBoundingClientRect(), m = document.querySelector(".slashmenu").getBoundingClientRect();
const x = Math.min(a.left, m.left), y = Math.min(a.top, m.top);
return { x, y, width: Math.max(a.right, m.right) - x, height: Math.max(a.bottom, m.bottom) - y };
});
await page.screenshot({ path: `${shotDir}/${scheme}-menu.png`, clip: area });
}
await page.keyboard.press("Enter");
await page.waitForSelector(".shots .shot");
const chip = await page.evaluate(() => {
const c = document.querySelector(".shots .shot");
const ta = document.querySelector('[aria-label="任务输入"]');
const r = c.getBoundingClientRect();
return { title: c.querySelector(".nm")?.textContent, sub: c.querySelector(".sz")?.textContent, text: ta.value, spill: r.right > innerWidth + 1, remove: !!c.querySelector(".x") };
});
check(`${tag}: picking one makes a chip titled with it and takes the # word out of the box`, !!chip.title && chip.text === "接着上次的讨论 " && chip.sub === "历史会话 · 只读资料", `${chip.title} | "${chip.text}"`);
check(`${tag}: the chip can be removed and does not spill`, chip.remove && !chip.spill);
if (shotDir && width === 1280) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-chip.png` });
if (width === 1280) {
await page.getByRole("searchbox").fill("轮换");
await page.waitForSelector(".content-matches li", { timeout: 8000 });
const hit = await page.evaluate(() => {
const li = document.querySelector(".content-matches li");
const [open, ref] = li.querySelectorAll("button");
const o = open.getBoundingClientRect(), r = ref.getBoundingClientRect();
return { has: !!ref, size: Math.round(r.width) + "x" + Math.round(r.height), label: ref?.getAttribute("aria-label") ?? "", beside: r.left >= o.right - 1 && Math.abs(r.top - o.top) < 2, tall: r.height >= 28 };
});
check(`${tag}: a search hit offers to reference it, beside the open button`, hit.has && hit.beside && /引用「.+」到输入框/.test(hit.label), `${hit.size} ${hit.label}`);
check(`${tag}: that button is at least 28px tall`, hit.tall, hit.size);
if (shotDir) await page.locator(".content-matches").screenshot({ path: `${shotDir}/${scheme}-hit.png` });
const refButton = page.locator(".content-matches li button[data-action='session.reference-match']").first();
await refButton.focus();
await page.waitForTimeout(200);
const refBox = await refButton.boundingBox();
await page.mouse.click(refBox.x + refBox.width / 2, refBox.y + refBox.height / 2);
await page.waitForFunction(() => document.querySelectorAll(".shots .shot").length === 2);
check(`${tag}: referencing a hit adds a second chip to the message box`, true);
}
await page.evaluate(() => document.querySelector('[data-action="chrome.settings"]')?.click());
await page.waitForSelector(".prefs-nav button");
await page.evaluate(() => [...document.querySelectorAll(".prefs-nav button")].find((b) => b.textContent.trim().startsWith("会话"))?.click());
await page.waitForSelector("#set-session-ref", { timeout: 8000 });
const s = await page.evaluate(() => {
const g = document.getElementById("set-session-ref");
const input = g.querySelector("input");
const r = g.getBoundingClientRect();
return { value: input?.value, unit: g.querySelector("i")?.textContent, restore: g.querySelector('[data-action="session-ref.reset"]')?.disabled, spill: r.right > innerWidth + 1 || g.scrollWidth > g.clientWidth + 1 };
});
check(`${tag}: the size limit is a number field at its default, with nothing to restore`, s.value === "8000" && s.unit === "token" && s.restore === true, `${s.value} ${s.unit}`);
check(`${tag}: the setting does not spill sideways`, !s.spill);
if (shotDir) await page.locator("#set-session-ref").screenshot({ path: `${shotDir}/${scheme}-${width}-setting.png` });
await page.close();
}
}
await browser.close();
if (fails.length) {
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
process.exit(1);
}
console.log("\nall passed");
+9 -8
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { chromium } from "playwright";
const sheets = await Promise.all(["tokens", "app", "studio", "sessionquick", "sessionpin"].map(
const sheets = await Promise.all(["tokens", "app", "studio", "sessionquick", "sessionpin", "treerows"].map(
(name) => readFile(new URL(`../src/styles/${name}.css`, import.meta.url), "utf8"),
));
const title = "这是什么? @.reasonix/attachments/clipboard-" + "long-title".repeat(20);
@@ -14,8 +14,9 @@ try {
await page.setContent(`<div class="rail" style="width:${width}px">
<div class="kids">
<div class="sessrow sessrow-context" tabindex="0" aria-selected="true">
<span class="sesstitle" title="${title}"><i class="sesspin"></i><span>${title}</span><em class="sessarch">已归档</em></span>
<div class="sessquick"><button>归档</button><button>删除</button></div>
<button class="sesspin-action" data-action="session.pin">置顶</button>
<span class="sesstitle" title="${title}"><span>${title}</span><em class="sessarch">已归档</em></span>
<span class="sessquick"><button data-action="session.archive">归档</button></span>
</div>
<div class="sessrow sessrow-context" data-unread tabindex="0">
<span class="sesstitle" title="短标题"><span>短标题</span></span><span class="unread-dot"></span>
@@ -52,12 +53,12 @@ try {
assert.ok(row.textHeight <= row.lineHeight + 1);
assert.ok(row.fullTitle);
}
assert.equal(rows[1].top, rows[0].bottom, "no gap between sessions");
assert.equal(rows[2].top, rows[1].bottom, "no gap before remote session");
assert.equal(rows[3].top, rows[2].bottom, "no gap before earlier version");
assert.equal(rows[1].top - rows[0].bottom, 1, "only the compact row margin separates sessions");
assert.equal(rows[2].top - rows[1].bottom, 1, "the remote row has the same compact spacing");
assert.equal(rows[3].top - rows[2].bottom, 1, "the earlier version has the same compact spacing");
assert.ok(rows[0].truncated && rows[2].truncated, "long local and remote titles truncate");
if (state !== "idle") {
const contained = await page.locator(".sessquick > button").evaluateAll((buttons) => buttons.every((button) => {
const contained = await page.locator(".sessquick > button, .sesspin-action").evaluateAll((buttons) => buttons.every((button) => {
const rect = button.getBoundingClientRect();
const row = button.closest(".sessrow").getBoundingClientRect();
return rect.top >= row.top && rect.bottom <= row.bottom && rect.left >= row.left && rect.right <= row.right;
@@ -65,7 +66,7 @@ try {
assert.ok(contained, "quick actions fit inside the compact row");
}
}
console.log(`${scheme}/${width}: 34px rows, no gaps, single-line ellipsis, hover/focus stable`);
console.log(`${scheme}/${width}: 34px rows, 1px spacing, single-line ellipsis, hover/focus stable`);
await page.close();
}
}
+47 -26
View File
@@ -1,6 +1,5 @@
// Session rows carry hover Archive and Delete buttons only when this machine turned them on. The reveal is a
// width and opacity transition on :hover, so whether the buttons are really visible and hit-testable is
// a question for a real browser.
// Opt-in session actions put Pin before the title and Archive after it.
// Their visibility, geometry and pointer targets need a real browser.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
@@ -14,34 +13,56 @@ const check = (name, ok, detail = "") => {
const browser = await chromium.launch();
try {
for (const scheme of ["light", "dark"]) {
for (const on of [false, true]) {
for (const on of [null, false, true]) {
const mode = on === null ? "default" : on ? "on" : "off";
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width: 1400, height: 800 } });
if (on) await ctx.addInitScript(() => localStorage.setItem("rx-session-quick-actions", "on"));
if (on !== null) await ctx.addInitScript((enabled) => localStorage.setItem("rx-session-quick-actions", enabled ? "on" : "off"), on);
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push(e.message));
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForSelector(".sessrow", { timeout: 20000 });
const row = page.locator(".sessrow").first();
await row.scrollIntoViewIfNeeded();
const box = await row.boundingBox();
await page.mouse.move(box.x + 40, box.y + box.height / 2);
await page.waitForTimeout(300);
const quick = page.locator(".sessrow").first().locator(".sessquick");
if (!on) {
check(`${scheme}: off draws no quick buttons on a hovered row`, (await quick.count()) === 0);
} else {
const state = await quick.evaluate((el) => {
const r = el.getBoundingClientRect();
const [archive, trash] = el.querySelectorAll("button");
const hit = (b) => {
const q = b.getBoundingClientRect();
const at = document.elementFromPoint(q.left + q.width / 2, q.top + q.height / 2);
return at === b || b.contains(at);
};
return { opacity: Number(getComputedStyle(el).opacity), width: r.width, archive: hit(archive), trash: hit(trash) };
});
check(`${scheme}: on reveals both buttons on a hovered row`, state.opacity === 1 && state.width > 40 && state.archive && state.trash, JSON.stringify(state));
for (const kind of ["context", "remote"]) {
const tag = `${scheme}/${mode}/${kind}`;
const row = page.locator(`.sessrow-${kind}`).first();
await row.hover();
await page.waitForTimeout(150);
const quick = row.locator('[data-action="session.pin"], [data-action="session.archive"]');
if (!on) {
check(`${tag}: hover draws no quick buttons`, (await quick.count()) === 0);
} else {
const state = await row.evaluate((el) => {
const title = el.querySelector(".sesstitle > span").getBoundingClientRect();
const pin = el.querySelector('[data-action="session.pin"]');
const archive = el.querySelector('[data-action="session.archive"]');
const hit = (button) => {
if (!button) return false;
const r = button.getBoundingClientRect();
const at = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
let opacity = 1;
for (let node = button; node && node !== el; node = node.parentElement) opacity *= Number(getComputedStyle(node).opacity);
return opacity === 1 && r.width > 0 && r.height > 0 && !button.disabled && (at === button || button.contains(at));
};
return { pin: hit(pin), archive: hit(archive), leading: pin.getBoundingClientRect().right <= title.left,
trailing: archive.getBoundingClientRect().left >= title.right - 1 };
});
check(`${tag}: leading Pin and trailing Archive are hit-testable`, state.pin && state.archive && state.leading && state.trailing, JSON.stringify(state));
await page.mouse.move(1398, 798);
await row.locator('[data-action="session.pin"]').focus();
await page.waitForTimeout(150);
check(`${tag}: keyboard focus reveals both actions`, await quick.evaluateAll((buttons) => buttons.every((b) => {
const r = b.getBoundingClientRect();
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
let opacity = 1;
for (let node = b; node && !node.classList.contains("sessrow"); node = node.parentElement) opacity *= Number(getComputedStyle(node).opacity);
return opacity === 1 && (b === hit || b.contains(hit));
})));
}
await row.click({ button: "right" });
check(`${tag}: the menu retains Pin and Archive`, await page.getByRole("menuitem", { name: "置顶会话", exact: true }).count() === 1 && await page.getByRole("menuitem", { name: "归档会话", exact: true }).count() === 1);
check(`${tag}: Delete stays in the menu`, await page.getByRole("menuitem", { name: "删除会话", exact: true }).count() === 1);
await page.keyboard.press("Escape");
}
if (SHOTS) await page.screenshot({ path: `${SHOTS}/sessquick-${on ? "on" : "off"}-${scheme}.png`, clip: { x: 0, y: 0, width: 420, height: 800 } });
if (SHOTS) await page.screenshot({ path: `${SHOTS}/sessquick-${mode}-${scheme}.png`, clip: { x: 0, y: 0, width: 420, height: 800 } });
await ctx.close();
}
}
@@ -0,0 +1,41 @@
// A `!` line refused because this connection cannot run commands comes back selected, so the next message replaces it and is sent.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const shotDir = process.env.SHOTS;
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"]) {
for (const width of [1280, 420]) {
const tag = `${scheme} ${width}px`;
const page = await browser.newPage({ viewport: { width, height: 900 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=zh&turns=2&netshell=1`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
const box = page.locator('[aria-label="任务输入"]');
await box.waitFor();
await box.fill("!ls -la");
await box.press("Enter");
await page.waitForFunction(() => (window.__sent ?? []).length === 1);
await page.waitForFunction(() => { const t = document.querySelector('[aria-label="任务输入"]'); return t && t.value === "!ls -la" && t.selectionStart === 0 && t.selectionEnd === 7; });
check(`${tag}: the refused command is back in the box, all of it selected`, true);
const said = await page.evaluate(() => document.body.textContent.includes("HTTP 上不提供 shell 命令"));
check(`${tag}: the refusal is worded`, said);
if (shotDir) { await page.mouse.move(width - 3, 895); await page.waitForTimeout(300); await page.screenshot({ path: `${shotDir}/${scheme}-${width}-refused.png` }); }
await page.keyboard.type("你好");
check(`${tag}: typing replaces the command instead of joining it`, (await box.inputValue()) === "你好");
await box.press("Enter");
await page.waitForFunction(() => (window.__sent ?? []).length === 2);
const sent = await page.evaluate(() => window.__sent);
check(`${tag}: the next ordinary message is sent`, sent[1] === "你好", JSON.stringify(sent));
await page.close();
}
}
await browser.close();
if (fails.length) { console.log(`\n${fails.length} failed`); process.exit(1); }
console.log("\nall passed");
+4 -4
View File
@@ -276,17 +276,17 @@ await page.goto(`${PAGE}?ws=6&sess=200`, { waitUntil: "networkidle" });
await page.waitForSelector(".wsnode", { timeout: 20000 });
await page.waitForTimeout(600);
const firstWs = page.locator(".wsnode").first();
check("大树只列最近的", (await firstWs.locator(".sessrow").count()) === 30, `${await firstWs.locator(".sessrow").count()} 行`);
check("大树只列最近的", (await firstWs.locator(".sessrow").count()) === 5, `${await firstWs.locator(".sessrow").count()} 行`);
check("有「全部显示」", await firstWs.locator("button.sessmore").isVisible());
await firstWs.locator("button.sessmore").click();
await page.waitForTimeout(400);
check("展开后列全", (await firstWs.locator(".sessrow").count()) === 200, `${await firstWs.locator(".sessrow").count()} 行`);
await firstWs.locator("button.twist").click();
await firstWs.locator(":scope > .wsrow").click();
await page.waitForTimeout(300);
check("折叠后收起", (await firstWs.locator(".sessrow").count()) === 0);
await page.screenshot({ path: `${SHOTS}/6-大会话树.png` });
// Only the workspace in front opens on first load; the next one is unfolded by hand.
await page.locator(".wsnode").nth(1).locator(".wsrow").click();
const nextProject = page.locator(".wsnode > .wsrow").nth(1);
if (await nextProject.getAttribute("aria-expanded") !== "true") await nextProject.click();
await page.waitForTimeout(300);
const second = page.locator(".wsnode").nth(1).locator(".sessrow").first();
await second.click();
+3 -1
View File
@@ -32,6 +32,7 @@ for (const scheme of ["light", "dark"]) {
const wide = [...g.querySelectorAll(".box *")].filter((e) => e.getBoundingClientRect().right > box.right + 1).map((e) => e.className || e.tagName);
return {
lists,
video: g.querySelector('[data-action="web-access.video-url"]')?.getAttribute("aria-checked"),
consent: consent.map((b) => [b.getAttribute("aria-checked"), b.disabled]),
hint: g.textContent.includes("需要一个带密钥的 DeepSeek 提供方"),
applies: g.textContent.includes("之后新开的窗格"),
@@ -40,7 +41,8 @@ for (const scheme of ["light", "dark"]) {
scroll: g.querySelector(".box").scrollWidth - g.querySelector(".box").clientWidth,
};
});
check(`${tag}: search and fetch are two ordered lists, the paid backend after the free one`, r.lists.length === 2 && r.lists[0][1][0] === "SearXNG" && r.lists[0][1][1] === "DeepSeek 官方搜索" && r.lists[1][1][0] === "本机抓取" && r.lists[1][1][1] === "DeepSeek 官方抓取", JSON.stringify(r.lists));
check(`${tag}: search and fetch are two ordered lists, the paid backend after the free one`, r.lists.length === 3 && r.lists[0][1][0] === "SearXNG" && r.lists[0][1][1] === "DeepSeek 官方搜索" && r.lists[1][1][0] === "本机抓取" && r.lists[1][1][1] === "DeepSeek 官方抓取", JSON.stringify(r.lists));
check(`${tag}: the video address list is its own third list with one switch, off until the person turns it on`, r.lists[2]?.[0] === "视频地址" && r.lists[2][1].length === 1 && r.lists[2][1][0] === "把视频地址交给模型厂商" && r.video === "false", JSON.stringify([r.lists[2], r.video]));
check(`${tag}: no consent is ticked for the person`, r.consent.length === 2 && r.consent.every(([on]) => on === "false"), JSON.stringify(r.consent));
check(`${tag}: the consent is ${ready === "noready" ? "inert and says why" : "live"}`, ready === "noready" ? r.consent.every(([, off]) => off) && r.hint : r.consent.every(([, off]) => !off) && !r.hint);
check(`${tag}: it says when a change applies and where it is kept`, r.applies && r.path);
+192 -219
View File
@@ -10,7 +10,7 @@ importers:
dependencies:
'@a2ui/react':
specifier: 0.13.0
version: 0.13.0([email protected].0([email protected].0))([email protected].0)([email protected])
version: 0.13.0([email protected].1([email protected].1))([email protected].1)([email protected])
'@a2ui/web_core':
specifier: 0.13.0
version: 0.13.0
@@ -70,13 +70,13 @@ importers:
version: 1.2.5
'@mantine/charts':
specifier: 9.7.2
version: 9.7.2(@mantine/[email protected](@mantine/[email protected]([email protected].0))(@types/[email protected])([email protected].0([email protected].0))([email protected].0))(@mantine/[email protected]([email protected].0))([email protected].0([email protected].0))([email protected].0)([email protected](@types/[email protected])([email protected].0([email protected].0))([email protected])([email protected].0)([email protected]))
version: 9.7.2(@mantine/[email protected](@mantine/[email protected]([email protected].1))(@types/[email protected])([email protected].1([email protected].1))([email protected].1))(@mantine/[email protected]([email protected].1))([email protected].1([email protected].1))([email protected].1)([email protected](@types/[email protected])([email protected].1([email protected].1))([email protected])([email protected].1)([email protected]))
'@mantine/core':
specifier: 9.7.2
version: 9.7.2(@mantine/[email protected]([email protected].0))(@types/[email protected])([email protected].0([email protected].0))([email protected].0)
version: 9.7.2(@mantine/[email protected]([email protected].1))(@types/[email protected])([email protected].1([email protected].1))([email protected].1)
'@mantine/hooks':
specifier: 9.7.2
version: 9.7.2([email protected].0)
version: 9.7.2([email protected].1)
'@noble/curves':
specifier: 2.4.0
version: 2.4.0
@@ -90,20 +90,20 @@ importers:
specifier: 6.14.0
version: 6.14.0
react:
specifier: ^19.3.0
version: 19.3.0
specifier: ^19.3.1
version: 19.3.1
react-dom:
specifier: ^19.3.0
version: 19.3.0([email protected].0)
specifier: ^19.3.1
version: 19.3.1([email protected].1)
react-map-gl:
specifier: 8.1.3
version: 8.1.3([email protected])([email protected].0([email protected].0))([email protected].0)
version: 8.1.3([email protected])([email protected].1([email protected].1))([email protected].1)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/[email protected])([email protected].0)
version: 10.1.0(@types/[email protected])([email protected].1)
recharts:
specifier: 3.10.1
version: 3.10.1(@types/[email protected])([email protected].0([email protected].0))([email protected])([email protected].0)([email protected])
version: 3.10.1(@types/[email protected])([email protected].1([email protected].1))([email protected])([email protected].1)([email protected])
rehype-highlight:
specifier: ^7.0.2
version: 7.0.2
@@ -134,10 +134,10 @@ importers:
version: 8.0.7
'@testing-library/react':
specifier: ^16.3.3
version: 16.3.3(@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected].0([email protected].0))([email protected].0)
version: 16.3.3(@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected].1([email protected].1))([email protected].1)
'@testing-library/user-event':
specifier: ^14.6.7
version: 14.6.7(@testing-library/[email protected])
specifier: ^14.6.8
version: 14.6.8(@testing-library/[email protected])
'@types/react':
specifier: ^19.3.0
version: 19.3.0
@@ -146,22 +146,22 @@ importers:
version: 19.3.0(@types/[email protected])
'@vitejs/plugin-react':
specifier: ^6.1.2
version: 6.1.2([email protected].3([email protected]))
version: 6.1.2([email protected].4([email protected]))
jsdom:
specifier: ^30.1.2
version: 30.1.2(@noble/[email protected])
specifier: ^30.1.3
version: 30.1.3(@noble/[email protected])
playwright:
specifier: ^1.64.0
version: 1.64.0
specifier: ^1.64.1
version: 1.64.1
typescript:
specifier: ^7.0.2
version: 7.0.2
vite:
specifier: ^8.3.3
version: 8.3.3([email protected])
specifier: ^8.3.4
version: 8.3.4([email protected])
vitest:
specifier: ^5.0.3
version: 5.0.3([email protected].2(@noble/[email protected]))([email protected].3([email protected]))
specifier: ^5.0.4
version: 5.0.4([email protected].3(@noble/[email protected]))([email protected].4([email protected]))
packages:
@@ -185,8 +185,8 @@ packages:
resolution: {integrity: sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
'@asamuzakjp/dom-selector@9.2.4':
resolution: {integrity: sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==}
'@asamuzakjp/dom-selector@10.0.3':
resolution: {integrity: sha512-3+777rEPsOOjoy5/sRmIzHnoRDaLpm3XWY2dmWn/N7f/XwDG0bcpnFaKL3X9FYJUbNwLmpcJC1bUYexJRxsFlw==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
'@babel/[email protected]':
@@ -474,15 +474,6 @@ packages:
cpu: [x64]
os: [win32]
'@exodus/[email protected]':
resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
'@noble/hashes':
optional: true
'@exodus/[email protected]':
resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -783,8 +774,8 @@ packages:
'@types/react-dom':
optional: true
'@testing-library/[email protected].7':
resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==}
'@testing-library/[email protected].8':
resolution: {integrity: sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==}
engines: {node: '>=12', npm: '>=6'}
peerDependencies:
'@testing-library/dom': '>=7.21.4'
@@ -1028,10 +1019,10 @@ packages:
oxc-transform-react:
optional: true
'@vitest/[email protected].3':
resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==}
'@vitest/[email protected].4':
resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==}
peerDependencies:
msw: ^2.4.9
msw: ^2.4.9 || ^3.0.0
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
@@ -1039,8 +1030,8 @@ packages:
vite:
optional: true
'@vitest/[email protected].3':
resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==}
'@vitest/[email protected].4':
resolution: {integrity: sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==}
[email protected]:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
@@ -1083,8 +1074,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
chai@6.3.0:
resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
engines: {node: '>=18'}
[email protected]:
@@ -1396,8 +1387,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
[email protected].2:
resolution: {integrity: sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==}
[email protected].3:
resolution: {integrity: sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
peerDependencies:
canvas: ^3.2.3
@@ -1527,8 +1518,8 @@ packages:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
[email protected].2:
resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==}
[email protected].3:
resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==}
[email protected]:
resolution: {integrity: sha512-L7U9y9TyPJ3P/JJbW+eH+A3FzdPq+3fnZFqqeCwf/S6cRGganHwVI5b/C8/zOYzreUzsloDPZVVaMQA94wOt4A==}
@@ -1691,13 +1682,13 @@ packages:
[email protected]:
resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==}
[email protected].18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
[email protected].20:
resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
obug@2.1.4:
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
obug@3.0.0:
resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==}
engines: {node: '>=12.20.0'}
[email protected]:
@@ -1720,18 +1711,18 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
[email protected].0:
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
[email protected].1:
resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
engines: {node: '>=20'}
hasBin: true
[email protected].0:
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
[email protected].1:
resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==}
engines: {node: '>=20'}
hasBin: true
[email protected].28:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
[email protected].30:
resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==}
engines: {node: ^10 || ^12 || >=14}
[email protected]:
@@ -1758,10 +1749,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
[email protected].0:
resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==}
[email protected].1:
resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==}
peerDependencies:
react: ^19.3.0
react: ^19.3.1
[email protected]:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
@@ -1833,8 +1824,8 @@ packages:
'@types/react':
optional: true
[email protected].0:
resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
[email protected].1:
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
engines: {node: '>=0.10.0'}
[email protected]:
@@ -1937,6 +1928,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
@@ -1944,8 +1939,8 @@ packages:
resolution: {integrity: sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==}
engines: {node: '>=0.10.0'}
std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
std-env@4.3.0:
resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
[email protected]:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -1969,12 +1964,12 @@ packages:
[email protected]:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@6.1.4:
resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==}
tinybench@6.2.2:
resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==}
engines: {node: '>=20.0.0'}
[email protected].0:
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
[email protected].1:
resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==}
engines: {node: '>=18'}
[email protected]:
@@ -1995,10 +1990,6 @@ packages:
resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
engines: {node: '>=16'}
[email protected]:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==}
engines: {node: ^22.14.0 || >=24.0.0}
@@ -2030,8 +2021,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
[email protected]1.2:
resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==}
[email protected]2.1:
resolution: {integrity: sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==}
engines: {node: '>=22.19.0'}
[email protected]:
@@ -2102,8 +2093,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
[email protected].3:
resolution: {integrity: sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==}
[email protected].4:
resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -2145,20 +2136,20 @@ packages:
yaml:
optional: true
[email protected].3:
resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==}
[email protected].4:
resolution: {integrity: sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.3
'@vitest/browser-preview': 5.0.3
'@vitest/browser-playwright': 5.0.4
'@vitest/browser-preview': 5.0.4
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.3
'@vitest/coverage-v8': 5.0.3
'@vitest/ui': 5.0.3
'@vitest/coverage-istanbul': 5.0.4
'@vitest/coverage-v8': 5.0.4
'@vitest/ui': 5.0.4
happy-dom: '*'
jsdom: '*'
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
@@ -2204,10 +2195,6 @@ packages:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==}
engines: {node: ^22.14.0 || >=24.0.0}
[email protected]:
resolution: {integrity: sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==}
engines: {node: ^22.14.0 || >=24.0.0}
@@ -2243,14 +2230,14 @@ snapshots:
dompurify: 3.4.11
markdown-it: 14.3.2
'@a2ui/[email protected]([email protected].0([email protected].0))([email protected].0)([email protected])':
'@a2ui/[email protected]([email protected].1([email protected].1))([email protected].1)([email protected])':
dependencies:
'@a2ui/markdown-it': 0.2.1(@a2ui/[email protected])
'@a2ui/web_core': 0.13.0
clsx: 2.1.1
markdown-it: 14.3.2
react: 19.3.0
react-dom: 19.3.0([email protected].0)
react: 19.3.1
react-dom: 19.3.1([email protected].1)
zod: 3.25.76
'@a2ui/[email protected]':
@@ -2271,7 +2258,7 @@ snapshots:
'@csstools/css-tokenizer': 4.0.2
lru-cache: 11.5.3
'@asamuzakjp/dom-selector@9.2.4':
'@asamuzakjp/dom-selector@10.0.3':
dependencies:
bidi-js: 1.1.0
css-tree: 3.2.1
@@ -2555,10 +2542,6 @@ snapshots:
'@esbuild/[email protected]':
optional: true
'@exodus/[email protected](@noble/[email protected])':
optionalDependencies:
'@noble/hashes': 2.4.0
'@exodus/[email protected](@noble/[email protected])':
optionalDependencies:
'@noble/hashes': 2.4.0
@@ -2572,18 +2555,18 @@ snapshots:
'@floating-ui/core': 1.8.0
'@floating-ui/utils': 0.2.12
'@floating-ui/[email protected]([email protected].0([email protected].0))([email protected].0)':
'@floating-ui/[email protected]([email protected].1([email protected].1))([email protected].1)':
dependencies:
'@floating-ui/dom': 1.8.0
react: 19.3.0
react-dom: 19.3.0([email protected].0)
react: 19.3.1
react-dom: 19.3.1([email protected].1)
'@floating-ui/[email protected]([email protected].0([email protected].0))([email protected].0)':
'@floating-ui/[email protected]([email protected].1([email protected].1))([email protected].1)':
dependencies:
'@floating-ui/react-dom': 2.1.9([email protected].0([email protected].0))([email protected].0)
'@floating-ui/react-dom': 2.1.9([email protected].1([email protected].1))([email protected].1)
'@floating-ui/utils': 0.2.12
react: 19.3.0
react-dom: 19.3.0([email protected].0)
react: 19.3.1
react-dom: 19.3.1([email protected].1)
tabbable: 6.5.0
'@floating-ui/[email protected]': {}
@@ -2682,30 +2665,30 @@ snapshots:
dependencies:
'@lit-labs/ssr-dom-shim': 1.6.0
'@mantine/[email protected](@mantine/[email protected](@mantine/[email protected]([email protected].0))(@types/[email protected])([email protected].0([email protected].0))([email protected].0))(@mantine/[email protected]([email protected].0))([email protected].0([email protected].0))([email protected].0)([email protected](@types/[email protected])([email protected].0([email protected].0))([email protected])([email protected].0)([email protected]))':
'@mantine/[email protected](@mantine/[email protected](@mantine/[email protected]([email protected].1))(@types/[email protected])([email protected].1([email protected].1))([email protected].1))(@mantine/[email protected]([email protected].1))([email protected].1([email protected].1))([email protected].1)([email protected](@types/[email protected])([email protected].1([email protected].1))([email protected])([email protected].1)([email protected]))':
dependencies:
'@mantine/core': 9.7.2(@mantine/[email protected]([email protected].0))(@types/[email protected])([email protected].0([email protected].0))([email protected].0)
'@mantine/hooks': 9.7.2([email protected].0)
react: 19.3.0
react-dom: 19.3.0([email protected].0)
recharts: 3.10.1(@types/[email protected])([email protected].0([email protected].0))([email protected])([email protected].0)([email protected])
'@mantine/core': 9.7.2(@mantine/[email protected]([email protected].1))(@types/[email protected])([email protected].1([email protected].1))([email protected].1)
'@mantine/hooks': 9.7.2([email protected].1)
react: 19.3.1
react-dom: 19.3.1([email protected].1)
recharts: 3.10.1(@types/[email protected])([email protected].1([email protected].1))([email protected])([email protected].1)([email protected])
'@mantine/[email protected](@mantine/[email protected]([email protected].0))(@types/[email protected])([email protected].0([email protected].0))([email protected].0)':
'@mantine/[email protected](@mantine/[email protected]([email protected].1))(@types/[email protected])([email protected].1([email protected].1))([email protected].1)':
dependencies:
'@floating-ui/react': 0.27.20([email protected].0([email protected].0))([email protected].0)
'@mantine/hooks': 9.7.2([email protected].0)
'@floating-ui/react': 0.27.20([email protected].1([email protected].1))([email protected].1)
'@mantine/hooks': 9.7.2([email protected].1)
clsx: 2.1.1
react: 19.3.0
react-dom: 19.3.0([email protected].0)
react-number-format: 5.4.5([email protected].0([email protected].0))([email protected].0)
react-remove-scroll: 2.7.2(@types/[email protected])([email protected].0)
react: 19.3.1
react-dom: 19.3.1([email protected].1)
react-number-format: 5.4.5([email protected].1([email protected].1))([email protected].1)
react-remove-scroll: 2.7.2(@types/[email protected])([email protected].1)
type-fest: 5.10.0
transitivePeerDependencies:
- '@types/react'
'@mantine/[email protected]([email protected].0)':
'@mantine/[email protected]([email protected].1)':
dependencies:
react: 19.3.0
react: 19.3.1
'@mapbox/[email protected]': {}
@@ -2767,7 +2750,7 @@ snapshots:
'@preact/[email protected]': {}
'@reduxjs/[email protected]([email protected](@types/[email protected])([email protected].0)([email protected]))([email protected].0)':
'@reduxjs/[email protected]([email protected](@types/[email protected])([email protected].1)([email protected]))([email protected].1)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
@@ -2776,8 +2759,8 @@ snapshots:
redux-thunk: 3.1.0([email protected])
reselect: 5.2.0
optionalDependencies:
react: 19.3.0
react-redux: 9.3.0(@types/[email protected])([email protected].0)([email protected])
react: 19.3.1
react-redux: 9.3.0(@types/[email protected])([email protected].1)([email protected])
'@rolldown/[email protected]':
optional: true
@@ -2841,17 +2824,17 @@ snapshots:
picocolors: 1.1.1
pretty-format: 27.5.1
'@testing-library/[email protected](@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected].0([email protected].0))([email protected].0)':
'@testing-library/[email protected](@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected].1([email protected].1))([email protected].1)':
dependencies:
'@babel/runtime': 7.29.7
'@testing-library/dom': 10.4.1
react: 19.3.0
react-dom: 19.3.0([email protected].0)
react: 19.3.1
react-dom: 19.3.1([email protected].1)
optionalDependencies:
'@types/react': 19.3.0
'@types/react-dom': 19.3.0(@types/[email protected])
'@testing-library/[email protected].7(@testing-library/[email protected])':
'@testing-library/[email protected].8(@testing-library/[email protected])':
dependencies:
'@testing-library/dom': 10.4.1
@@ -2990,34 +2973,34 @@ snapshots:
'@ungap/[email protected]': {}
'@vis.gl/[email protected]([email protected].0([email protected].0))([email protected].0)':
'@vis.gl/[email protected]([email protected].1([email protected].1))([email protected].1)':
dependencies:
react: 19.3.0
react-dom: 19.3.0([email protected].0)
react: 19.3.1
react-dom: 19.3.1([email protected].1)
'@vis.gl/[email protected]([email protected])([email protected].0([email protected].0))([email protected].0)':
'@vis.gl/[email protected]([email protected])([email protected].1([email protected].1))([email protected].1)':
dependencies:
'@maplibre/maplibre-gl-style-spec': 19.3.3
react: 19.3.0
react-dom: 19.3.0([email protected].0)
react: 19.3.1
react-dom: 19.3.1([email protected].1)
optionalDependencies:
maplibre-gl: 6.14.0
'@vitejs/[email protected]([email protected].3([email protected]))':
'@vitejs/[email protected]([email protected].4([email protected]))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.3.3([email protected])
vite: 8.3.4([email protected])
'@vitest/[email protected].3([email protected].3([email protected]))':
'@vitest/[email protected].4([email protected].4([email protected]))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.3
'@vitest/spy': 5.0.4
estree-walker: 3.0.3
magic-string: 1.4.2
magic-string: 1.4.3
optionalDependencies:
vite: 8.3.3([email protected])
vite: 8.3.4([email protected])
'@vitest/[email protected].3': {}
'@vitest/[email protected].4': {}
[email protected]: {}
@@ -3052,7 +3035,7 @@ snapshots:
[email protected]: {}
chai@6.2.2: {}
chai@6.3.0: {}
[email protected]: {}
@@ -3130,7 +3113,7 @@ snapshots:
[email protected](@noble/[email protected]):
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 17.1.2(@noble/[email protected])
whatwg-url: 17.2.0(@noble/[email protected])
transitivePeerDependencies:
- '@noble/hashes'
@@ -3358,7 +3341,7 @@ snapshots:
[email protected](@noble/[email protected]):
dependencies:
'@exodus/bytes': 1.15.1(@noble/[email protected])
'@exodus/bytes': 1.16.0(@noble/[email protected])
transitivePeerDependencies:
- '@noble/hashes'
@@ -3401,10 +3384,10 @@ snapshots:
[email protected]: {}
[email protected].2(@noble/[email protected]):
[email protected].3(@noble/[email protected]):
dependencies:
'@asamuzakjp/css-color': 7.1.3
'@asamuzakjp/dom-selector': 9.2.4
'@asamuzakjp/dom-selector': 10.0.3
'@bramus/specificity': 2.4.2
'@csstools/css-syntax-patches-for-csstree': 1.1.15([email protected])
'@exodus/bytes': 1.16.0(@noble/[email protected])
@@ -3417,7 +3400,7 @@ snapshots:
parse5: 8.0.1
saxes: 6.0.0
tough-cookie: 6.0.2
undici: 8.11.2
undici: 8.12.1
w3c-xmlserializer: 6.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
@@ -3523,7 +3506,7 @@ snapshots:
[email protected]: {}
[email protected].2:
[email protected].3:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
@@ -3935,9 +3918,9 @@ snapshots:
[email protected]: {}
[email protected].18: {}
[email protected].20: {}
obug@2.1.4: {}
obug@3.0.0: {}
[email protected]:
dependencies:
@@ -3965,17 +3948,17 @@ snapshots:
[email protected]: {}
[email protected].0: {}
[email protected].1: {}
[email protected].0:
[email protected].1:
dependencies:
playwright-core: 1.64.0
playwright-core: 1.64.1
[email protected].28:
[email protected].30:
dependencies:
nanoid: 3.3.18
nanoid: 3.3.20
picocolors: 1.1.1
source-map-js: 1.2.1
source-map-js: 1.2.2
[email protected]: {}
@@ -3995,23 +3978,23 @@ snapshots:
[email protected]: {}
[email protected].0([email protected].0):
[email protected].1([email protected].1):
dependencies:
react: 19.3.0
react: 19.3.1
scheduler: 0.28.0
[email protected]: {}
[email protected]([email protected])([email protected].0([email protected].0))([email protected].0):
[email protected]([email protected])([email protected].1([email protected].1))([email protected].1):
dependencies:
'@vis.gl/react-mapbox': 8.1.3([email protected].0([email protected].0))([email protected].0)
'@vis.gl/react-maplibre': 8.1.3([email protected])([email protected].0([email protected].0))([email protected].0)
react: 19.3.0
react-dom: 19.3.0([email protected].0)
'@vis.gl/react-mapbox': 8.1.3([email protected].1([email protected].1))([email protected].1)
'@vis.gl/react-maplibre': 8.1.3([email protected])([email protected].1([email protected].1))([email protected].1)
react: 19.3.1
react-dom: 19.3.1([email protected].1)
optionalDependencies:
maplibre-gl: 6.14.0
[email protected](@types/[email protected])([email protected].0):
[email protected](@types/[email protected])([email protected].1):
dependencies:
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
@@ -4020,7 +4003,7 @@ snapshots:
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
react: 19.3.0
react: 19.3.1
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
@@ -4029,64 +4012,64 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected]([email protected].0([email protected].0))([email protected].0):
[email protected]([email protected].1([email protected].1))([email protected].1):
dependencies:
react: 19.3.0
react-dom: 19.3.0([email protected].0)
react: 19.3.1
react-dom: 19.3.1([email protected].1)
[email protected](@types/[email protected])([email protected].0)([email protected]):
[email protected](@types/[email protected])([email protected].1)([email protected]):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.3.0
use-sync-external-store: 1.7.0([email protected].0)
react: 19.3.1
use-sync-external-store: 1.7.0([email protected].1)
optionalDependencies:
'@types/react': 19.3.0
redux: 5.0.1
[email protected](@types/[email protected])([email protected].0):
[email protected](@types/[email protected])([email protected].1):
dependencies:
react: 19.3.0
react-style-singleton: 2.2.3(@types/[email protected])([email protected].0)
react: 19.3.1
react-style-singleton: 2.2.3(@types/[email protected])([email protected].1)
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.3.0
[email protected](@types/[email protected])([email protected].0):
[email protected](@types/[email protected])([email protected].1):
dependencies:
react: 19.3.0
react-remove-scroll-bar: 2.3.8(@types/[email protected])([email protected].0)
react-style-singleton: 2.2.3(@types/[email protected])([email protected].0)
react: 19.3.1
react-remove-scroll-bar: 2.3.8(@types/[email protected])([email protected].1)
react-style-singleton: 2.2.3(@types/[email protected])([email protected].1)
tslib: 2.8.1
use-callback-ref: 1.3.3(@types/[email protected])([email protected].0)
use-sidecar: 1.1.3(@types/[email protected])([email protected].0)
use-callback-ref: 1.3.3(@types/[email protected])([email protected].1)
use-sidecar: 1.1.3(@types/[email protected])([email protected].1)
optionalDependencies:
'@types/react': 19.3.0
[email protected](@types/[email protected])([email protected].0):
[email protected](@types/[email protected])([email protected].1):
dependencies:
get-nonce: 1.0.1
react: 19.3.0
react: 19.3.1
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.3.0
[email protected].0: {}
[email protected].1: {}
[email protected](@types/[email protected])([email protected].0([email protected].0))([email protected])([email protected].0)([email protected]):
[email protected](@types/[email protected])([email protected].1([email protected].1))([email protected])([email protected].1)([email protected]):
dependencies:
'@reduxjs/toolkit': 2.13.0([email protected](@types/[email protected])([email protected].0)([email protected]))([email protected].0)
'@reduxjs/toolkit': 2.13.0([email protected](@types/[email protected])([email protected].1)([email protected]))([email protected].1)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.52.0
eventemitter3: 5.0.4
immer: 11.1.21
react: 19.3.0
react-dom: 19.3.0([email protected].0)
react: 19.3.1
react-dom: 19.3.1([email protected].1)
react-is: 17.0.2
react-redux: 9.3.0(@types/[email protected])([email protected].0)([email protected])
react-redux: 9.3.0(@types/[email protected])([email protected].1)([email protected])
reselect: 5.2.0
tiny-invariant: 1.3.3
use-sync-external-store: 1.7.0([email protected].0)
use-sync-external-store: 1.7.0([email protected].1)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
@@ -4243,13 +4226,15 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
extend-shallow: 3.0.2
std-env@4.2.0: {}
std-env@4.3.0: {}
[email protected]:
dependencies:
@@ -4272,9 +4257,9 @@ snapshots:
[email protected]: {}
tinybench@6.1.4: {}
tinybench@6.2.2: {}
[email protected].0: {}
[email protected].1: {}
[email protected]:
dependencies:
@@ -4293,10 +4278,6 @@ snapshots:
dependencies:
tldts: 7.4.11
[email protected]:
dependencies:
punycode: 2.3.1
[email protected]:
dependencies:
punycode: 2.3.1
@@ -4342,7 +4323,7 @@ snapshots:
[email protected]: {}
[email protected]1.2: {}
[email protected]2.1: {}
[email protected]:
dependencies:
@@ -4394,24 +4375,24 @@ snapshots:
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
[email protected](@types/[email protected])([email protected].0):
[email protected](@types/[email protected])([email protected].1):
dependencies:
react: 19.3.0
react: 19.3.1
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.3.0
[email protected](@types/[email protected])([email protected].0):
[email protected](@types/[email protected])([email protected].1):
dependencies:
detect-node-es: 1.1.0
react: 19.3.0
react: 19.3.1
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.3.0
[email protected]([email protected].0):
[email protected]([email protected].1):
dependencies:
react: 19.3.0
react: 19.3.1
[email protected]: {}
@@ -4447,35 +4428,35 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
[email protected].3([email protected]):
[email protected].4([email protected]):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.28
postcss: 8.5.30
rolldown: 1.2.12
tinyglobby: 0.2.17
optionalDependencies:
esbuild: 0.28.2
fsevents: 2.3.3
[email protected].3([email protected].2(@noble/[email protected]))([email protected].3([email protected])):
[email protected].4([email protected].3(@noble/[email protected]))([email protected].4([email protected])):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.3([email protected].3([email protected]))
chai: 6.2.2
'@vitest/mocker': 5.0.4([email protected].4([email protected]))
chai: 6.3.0
es-module-lexer: 2.3.2
expect-type: 1.4.0
magic-string: 1.4.2
obug: 2.1.4
magic-string: 1.4.3
obug: 3.0.0
picomatch: 4.0.7
std-env: 4.2.0
tinybench: 6.1.4
tinyexec: 1.3.0
std-env: 4.3.0
tinybench: 6.2.2
tinyexec: 1.3.1
tinyglobby: 0.2.17
vite: 8.3.3([email protected])
vite: 8.3.4([email protected])
why-is-node-running: 3.2.1
optionalDependencies:
jsdom: 30.1.2(@noble/[email protected])
jsdom: 30.1.3(@noble/[email protected])
transitivePeerDependencies:
- msw
@@ -4491,14 +4472,6 @@ snapshots:
[email protected]: {}
[email protected](@noble/[email protected]):
dependencies:
'@exodus/bytes': 1.15.1(@noble/[email protected])
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
[email protected](@noble/[email protected]):
dependencies:
'@exodus/bytes': 1.16.0(@noble/[email protected])
+3
View File
@@ -10,3 +10,6 @@
/assets/*
Cache-Control: public, max-age=31536000, immutable
/push-sw.js
Cache-Control: no-store
+50
View File
@@ -0,0 +1,50 @@
const TEXT = {
zh: {
approval: ["有操作等你批准", "电脑上的 Reasonix 正停在那里等你。"],
input: ["模型在等你回答", "电脑上的 Reasonix 需要你的回答才能继续。"],
error: ["一轮因错误结束", "电脑上的 Reasonix 有一轮没有跑完。"],
other: ["Reasonix", "电脑上有事等你。"],
},
en: {
approval: ["An action needs your approval", "Reasonix on your computer is waiting for you."],
input: ["The model is waiting for your answer", "Reasonix on your computer needs an answer to go on."],
error: ["A turn ended with an error", "A turn on your computer did not finish."],
other: ["Reasonix", "Something on your computer is waiting for you."],
},
};
function describe(data) {
const language = /^zh/i.test(self.navigator.language || "") ? "zh" : "en";
const known = data && data.v === 1 && Object.hasOwn(TEXT.en, data.type) && data.type !== "other";
const type = known ? data.type : "other";
const count = known && Number.isInteger(data.count) && data.count > 1 && data.count < 100 ? data.count : 1;
const [title, body] = TEXT[language][type];
return { type, title: count > 1 ? `${title} (${count})` : title, body };
}
self.addEventListener("push", (event) => {
let data = null;
try {
data = event.data ? event.data.json() : null;
} catch {
data = null;
}
const shown = describe(data);
event.waitUntil(self.registration.showNotification(shown.title, {
body: shown.body,
tag: shown.type,
renotify: true,
icon: "icons/icon-192.png",
badge: "icons/icon-192.png",
}));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil((async () => {
const open = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
const page = open.find((client) => client.url.startsWith(self.registration.scope));
if (page) await page.focus();
else await self.clients.openWindow(self.registration.scope);
})());
});
+74 -1
View File
@@ -55,6 +55,10 @@ export const ACTIONS: UIAction[] = [
{ id: "chart.scroll", kind: "view", target: "none", proof: "interaction" },
{ id: "chart.data", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.policy", kind: "view", target: "none", proof: "interaction" },
{ id: "phone-device.enabled", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "phone-device.occasion", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "phone-device.retry", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "phone-device.restart", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
{ id: "chrome.more", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.open-project", kind: "shell-native", target: "none", proof: "interaction" },
@@ -86,6 +90,7 @@ export const ACTIONS: UIAction[] = [
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
{ id: "browser.open", kind: "view", target: "none", proof: "interaction" },
{ id: "browser.wide", kind: "view", target: "none", proof: "interaction" },
{ id: "browser.full", kind: "view", target: "none", proof: "interaction" },
{ id: "browser.back", kind: "navigation", target: "none", proof: "interaction" },
{ id: "browser.reload", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "browser.address", kind: "view", target: "none", proof: "static" },
@@ -94,6 +99,8 @@ export const ACTIONS: UIAction[] = [
// The way out of a window whose assets an update replaced underneath it.
{ id: "chrome.reload", kind: "navigation", target: "none", proof: "interaction" },
{ id: "chrome.changed-files", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "chrome.feedback-level-notices", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "level-up.notice-dismiss", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "chrome.fold-work", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "chrome.turn-narration", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "changes.open", kind: "navigation", target: "none", proof: "interaction" },
@@ -202,6 +209,7 @@ export const ACTIONS: UIAction[] = [
{ id: "mcp.retry", kind: "repeatable", target: "entity", proof: "interaction" },
{ id: "mcp.trust", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "mcp.signin", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "mcp.upgrade", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "mcp.follow", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "mcp.unfollow", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "mcp.remove", kind: "destructive", target: "entity", proof: "interaction" },
@@ -272,8 +280,15 @@ export const ACTIONS: UIAction[] = [
{ id: "feedback.account-link", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.board-join", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.board-leave", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.board-release", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.board-name", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.withdraw-open", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.withdraw-cancel", kind: "view", target: "entity", proof: "interaction" },
{ id: "feedback.withdraw-confirm", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "paste.save", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
@@ -425,10 +440,16 @@ export const ACTIONS: UIAction[] = [
{ id: "remote.hide", kind: "view", target: "entity", proof: "interaction" },
{ id: "remote.enable", kind: "view", target: "entity", proof: "interaction" },
{ id: "remote.read", kind: "repeatable", target: "entity", proof: "authority-effect" },
{ id: "remote.group", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "workspace.fold-all", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.add-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "root-missing.reselect", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.move", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "session.move", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "session.move-to-start", kind: "view", target: "entity", proof: "interaction" },
{ id: "session.move-to", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "session.move-to-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "session.move-to-target", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.reorder", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
// ── Taking a change back ─────────────────────────────────────────────────
@@ -446,6 +467,7 @@ export const ACTIONS: UIAction[] = [
{ id: "extensions.submit", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "provider.probe", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "provider.draft", kind: "interaction", target: "none", proof: "interaction" },
{ id: "provider.price-source", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "provider.discard-edits", kind: "destructive", target: "none", proof: "interaction" },
{ id: "provider.revert", kind: "interaction", target: "none", proof: "interaction" },
{ id: "provider.model-check-all", kind: "repeatable", target: "none", proof: "interaction" },
@@ -484,6 +506,8 @@ export const ACTIONS: UIAction[] = [
{ id: "remotes.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "remotes.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "remotes.remove", kind: "destructive", target: "entity", proof: "authority-effect" },
{ id: "remotes.kind", kind: "view", target: "none", proof: "interaction" },
{ id: "remotes.distro", kind: "view", target: "none", proof: "interaction" },
{ id: "share.card", kind: "view", target: "none", proof: "interaction" },
{ id: "device.ask-leave", kind: "view", target: "none", proof: "interaction" },
{ id: "device.leave", kind: "destructive", target: "none", proof: "authority-effect" },
@@ -524,6 +548,15 @@ export const ACTIONS: UIAction[] = [
{ id: "notify.feedback-reply", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "notify.disk-low", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "notify.sound", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "phone-notify.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "phone-notify.approval", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "phone-notify.input", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "phone-notify.error", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "phone.add", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "phone.replace", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "phone.reject", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "phone.revoke", kind: "destructive", target: "entity", proof: "authority-effect" },
{ id: "phone.revoke-ask", kind: "view", target: "entity", proof: "interaction" },
{ id: "workspace.editor", kind: "repeatable", target: "none", proof: "authority-effect" },
{ id: "wallpaper.remove", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "wallpaper.change", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -554,7 +587,14 @@ export const ACTIONS: UIAction[] = [
{ id: "web-access.backend", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "web-access.move", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "web-access.consent", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "web-access.probe", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "web-access.searxng-url", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "trace-export.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "trace-export.endpoint", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "trace-export.header-name", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "trace-export.header-env", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "trace-export.service-name", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "web-access.video-url", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.reading-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.text-size", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "appearance.measure", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -647,9 +687,30 @@ export const ACTIONS: UIAction[] = [
{ id: "skills.update-hidden", kind: "view", target: "none", proof: "interaction" },
{ id: "skills.update-reload", kind: "view", target: "none", proof: "interaction" },
{ id: "skills.update-apply", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "skills.remove-open", kind: "view", target: "entity", proof: "interaction" },
{ id: "skills.remove-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "skills.remove-reload", kind: "view", target: "none", proof: "interaction" },
{ id: "skills.remove-confirm", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "subagent-claims.optimistic", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "subagent-steer.max-bytes", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "subagent-steer.per-minute", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "write-lease.optimistic", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "write-lease.max-wait", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "workbench.stage-folder", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "workbench.change-folder", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.discard", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.discard-confirm", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "workbench.discard-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.repo", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.repos-reload", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.commit", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.commit-file", kind: "view", target: "entity", proof: "interaction" },
{ id: "push.preview", kind: "view", target: "none", proof: "interaction" },
{ id: "push.confirm", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "push.close", kind: "view", target: "none", proof: "interaction" },
{ id: "plan-approval.auto", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "default-mode.plan", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "tool-list.toggle", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "peers.target", kind: "view", target: "none", proof: "interaction" },
{ id: "peers.text", kind: "view", target: "none", proof: "interaction" },
{ id: "peers.expand", kind: "view", target: "none", proof: "interaction" },
@@ -665,11 +726,23 @@ export const ACTIONS: UIAction[] = [
{ id: "auto-archive.notice-dismiss", kind: "view", target: "none", proof: "interaction" },
{ id: "auto-archive.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "auto-archive.days", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// The persona editor edits a draft; only the save reaches the kernel, and the
// persona is composed once at boot so the write is read by later sessions.
{ id: "persona.prefix", kind: "view", target: "none", proof: "interaction" },
{ id: "persona.suffix", kind: "view", target: "none", proof: "interaction" },
{ id: "persona.preset", kind: "view", target: "optional", proof: "interaction" },
{ id: "persona.runtime-context", kind: "view", target: "none", proof: "interaction" },
{ id: "persona.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// The kernel announces the change on the stream, so no rebuild stands between
// the click and the canonical change.
{ id: "currency.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "ask-timeout.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "ask-timeout.seconds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "session-ref.tokens", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "session-ref.reset", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "session.reference-match", kind: "view", target: "entity", proof: "interaction" },
{ id: "mods.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "mods.budget", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.pause", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.rounds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.token-multiple", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
+112 -1
View File
@@ -12,6 +12,7 @@ import { EN_WINDOW } from "./en_window";
import { EN_GRAPH } from "./en_graph";
import { EN_TRAJECTORY } from "./en_trajectory";
import { EN_SETTINGS } from "./en_settings";
import { EN_PERSONA } from "./en_persona";
import { EN_METRICS } from "./en_metrics";
import { EN_STORAGE } from "./en_storage";
import { EN_USAGE } from "./en_usage";
@@ -34,9 +35,12 @@ export const EN: Record<string, string> = {
"加宽浏览器": "Widen browser",
"恢复浏览器分栏": "Restore browser split",
"全屏浏览器": "Full-screen browser",
"退出全屏": "Exit full screen",
"工作台": "Workbench",
"从右侧选择文件,或打开浏览器": "Choose a file on the right, or open a browser",
...EN_SETTINGS,
...EN_PERSONA,
...EN_COMMUNITY,
...EN_SPONSOR,
...EN_METRICS,
@@ -121,7 +125,50 @@ export const EN: Record<string, string> = {
"思考 {chars}": "Thought · {chars}",
"耗时 {t}": "Worked for {t}",
"提交历史": "Commit history",
"这个提交没有改动文件": "This commit changed no files",
"没有可显示的文本改动": "There is no text change to show",
"只显示前面的文件": "Only the first files are shown",
"推送…": "Push…",
"推送": "Push",
"读取要推送的内容…": "Reading what would be pushed…",
"已把 {branch} 推送到 {host},远端新建了这个分支": "Pushed {branch} to {host}; the branch is new on the remote",
"已把 {n} 个提交推送到 {host}": "Pushed {n} commits to {host}",
"推送到 {host}": "Push to {host}",
"远端": "Remote",
"将发送": "Will send",
"{n} 个提交": "{n} commits",
"远端比这个分支多 {n} 个提交(以上次获取为准),推送可能被拒绝。": "The remote has {n} commits this branch does not (as of the last fetch); the push may be rejected.",
"远端已经有这个分支的全部提交,没有可推送的内容。": "The remote already has every commit of this branch; there is nothing to push.",
"只推送这一个分支,从不强制推送;仓库里的钩子(包括 pre-push)不会运行。": "Pushes this one branch and never forces it; the repository's hooks (pre-push included) do not run.",
"正在推送…": "Pushing…",
"丢弃 {path} 的改动": "Discard the change to {path}",
"丢弃这个文件的改动": "Discard this file's change",
"确认丢弃": "Confirm discard",
"丢弃 {path} 的改动?这个文件会回到上次提交的样子,改动无法恢复。": "Discard the change to {path}? The file goes back to how the last commit has it, and the change cannot be recovered.",
"丢弃": "Discard",
"它已暂存的部分和还没暂存的部分都会丢弃。": "Both its staged part and its unstaged part are discarded.",
"无法读取这个仓库的提交历史": "Could not read this repository's commit history",
"这不是一个提交编号": "That is not a commit id",
"这个仓库里没有这个提交": "This repository has no such commit",
"无法读取这个提交": "Could not read this commit",
"丢弃请求的格式不对": "The discard request is malformed",
"对话正在运行,它可能正在写这个文件,稍后再丢弃": "A turn is running and may be writing this file; discard it later",
"这个文件没有未提交的改动": "This file has no uncommitted change",
"只有上次提交里已有的文件,才能丢弃它的改动": "Only a change to a file the last commit already holds can be discarded",
"丢弃失败了": "The discard failed",
"推送请求的格式不对": "The push request is malformed",
"这个工作区还没有被你信任,不能从这里推送": "You have not trusted this workspace, so nothing can be pushed from it",
"现在不在任何分支上,没有可以推送的分支": "HEAD is not on a branch, so there is no branch to push",
"这个分支还没有对应的远端分支,不能推送": "This branch has no upstream to push to",
"远端已经有这个分支的全部提交,没有可推送的内容": "The upstream already has every commit; there is nothing to push",
"这个远端用的传输方式不允许推送,只支持 https、ssh 和 git://": "This remote's transport is not allowed for a push; only https, ssh and git:// are supported",
"确认之后仓库又变了,请重新预览再推送": "The repository changed after you confirmed; preview again and then push",
"远端有这个分支没有的提交,推送被拒绝(不会强制推送)": "The remote has commits this branch does not, so the push was rejected (it is never forced)",
"远端需要认证,请先在 git 里配置凭据": "The remote needs authentication; set up a credential in git first",
"推送失败了": "The push failed",
"推送时 git 出错了": "git failed during the push",
"这个仓库不属于当前工作区": "That repository is not part of this workspace",
"无法列出这个工作区里的仓库": "Could not list the repositories in this workspace",
"重新读取": "Read again",
"这个文件夹不是 git 仓库": "This folder is not a git repository",
"还没有提交": "No commits yet",
@@ -212,6 +259,31 @@ export const EN: Record<string, string> = {
"输入文件夹路径": "Type the folder's path",
"界面大小": "Interface size",
"等待浏览器登录…": "Waiting for the browser sign-in…",
"等待浏览器授权…": "Waiting for the browser authorization…",
"授权更多权限": "Grant more access",
"追踪导出": "Trace export",
"把每轮对话以 OpenTelemetry 追踪的形式发给你自己的观测系统。默认关闭;只发送结构、耗时、数量和名称,不发送提示词、工具参数、结果或文件路径。": "Send each turn to your own observability stack as OpenTelemetry traces. Off by default; only structure, timing, counts and names are sent, never prompts, tool arguments, results or file paths.",
"导出追踪": "Export traces",
"已开启,但还没有收集器地址,所以不会发送任何东西。": "On, but there is no collector address yet, so nothing is sent.",
"现在是关闭的:开启并填写收集器地址后才会发送。": "Off now: nothing is sent until it is on and has a collector address.",
"已开启:之后新开的会话会把追踪发到下面的地址。": "On: sessions opened from now on send traces to the address below.",
"收集器地址": "Collector address",
"认证请求头名称": "Auth header name",
"保存凭据的环境变量": "Environment variable holding the credential",
"环境变量已设置;它的值不会显示,也不会写进配置文件。": "The variable is set; its value is never shown or written to the config file.",
"环境变量当前没有值:会不带凭据发送。": "The variable has no value now: traces are sent without a credential.",
"凭据不写在配置里:这里只填环境变量名,名称必须以 OTEL_ 或 REASONIX_OTEL_ 开头。": "The credential is never in the config: enter only a variable name, which must start with OTEL_ or REASONIX_OTEL_.",
"服务名": "Service name",
"保存后只对之后新开的会话生效,已开的会话沿用旧设置。这些设置写在用户配置文件里:": "A save applies to sessions opened afterwards; open ones keep the old setting. These settings live in your user config file:",
"这个服务器要求更多权限": "This server asks for more access",
"这个服务器拒绝了一次调用,并要求更多权限(以下是它自己写的名称):": "This server refused a call and asks for more access (these names are its own words):",
"这个服务器没有要求更多权限": "This server has not asked for more access",
"这个客户端不能修改追踪导出设置": "This client cannot change the trace export settings",
"收集器地址必须是带主机名的 http 或 https 地址": "The collector address must be an http or https address with a host name",
"收集器地址里不能带用户名、密码、查询串或片段": "The collector address must not contain a user name, password, query or fragment",
"这不是可用的请求头名称": "That is not a usable header name",
"凭据环境变量必须以 OTEL_ 或 REASONIX_OTEL_ 开头": "The credential variable must start with OTEL_ or REASONIX_OTEL_",
"追踪导出设置没有保存成功": "The trace export settings were not saved",
"接受并以后都自动接受这个服务器的改动": "Accept, and accept this server's changes by themselves from now on",
"自动接受这个服务器的改动": "Changes from this server are accepted by themselves",
"这个服务器的改动会自动接受,并在通知里告诉你。": "This server's changes are accepted by themselves, and you are told in a notice.",
@@ -247,12 +319,21 @@ export const EN: Record<string, string> = {
"停下的原因:{cause}": "Why it stopped: {cause}",
"最近一次判断:{reason}": "Latest assessment: {reason}",
"SearXNG 地址": "SearXNG address",
"正在测试…": "Testing…",
"没能测试这个地址:{detail}": "Could not test this address: {detail}",
"连通:这个 SearXNG 能返回 JSON 搜索结果。": "Connected: this SearXNG returns JSON search results.",
"这个 SearXNG 没有开启 JSON 格式:在它的 settings.yml 的 search.formats 里加上 json,然后重启它。": "This SearXNG does not allow the JSON format: add json to search.formats in its settings.yml, then restart it.",
"连不上这个地址:检查它是否在运行,地址和端口是否写对。": "Cannot reach this address: check that it is running and that the address and port are right.",
"这个地址有应答,但不是 SearXNG 的 JSON 搜索结果:确认地址指向 SearXNG 本身,而不是登录页或别的服务。": "This address answers, but not with SearXNG's JSON results: make sure it points at SearXNG itself, not a login page or another service.",
"这个地址把请求转到了别处:请直接填 SearXNG 的最终地址。": "This address redirects elsewhere: enter SearXNG's final address directly.",
"测试不会连接这个地址:它是链路本地、云元数据或未指定地址。自托管实例请用本机或局域网地址。": "The test will not connect to this address: it is link-local, a cloud metadata endpoint or unspecified. A self-hosted instance belongs on this machine or your local network.",
"这个 SearXNG 拒绝了测试搜索(HTTP {status}):稍后重试,或查看它的日志。": "This SearXNG refused the test search (HTTP {status}): try again later, or check its log.",
"保存后只对之后新开的窗格生效,已开的窗格沿用旧设置。这些设置写在用户配置文件里:": "A saved change reaches only panes opened afterwards; open panes keep the old settings. These settings are kept in the user config file: ",
"这些设置现在是只读的:这个窗口没有被授予编辑权限。": "These settings are read-only now: this window has not been granted editing.",
"启用会话间消息": "Turn on session messages",
"同一台机器上的会话互相发文字。收到的文字先挂起,等你批准才会交给模型。": "Sessions on this machine send each other text. Incoming text is held until you approve it; only then does the model see it.",
"允许模型把任务交给别的会话": "Let the model hand a task to another session",
"给模型一个把任务交给另一个项目里会话的工具;对方的用户批准后才会送到。": "Gives the model a tool to hand a task to a session in another project; it is delivered once that session's user approves.",
"给模型一个把任务交给另一个项目里会话的工具。打开就是你的总同意:派出的任务和对方的回信不再询问;新会话沿用本会话的模型和权限,不会比本会话更宽。": "Gives the model a tool to hand a task to a session in another project. Turning it on is your standing consent: dispatched tasks and their replies come in without asking, and the new session keeps this session's model and permissions, never more.",
"先启用会话间消息。": "Turn on session messages first.",
"这些项目收到的消息不再询问,直接交给模型:": "Messages sent to these projects are no longer asked about and go straight to the model:",
"已长期放行的项目": "Projects released for good",
@@ -375,6 +456,7 @@ export const EN: Record<string, string> = {
"失败": "Failed",
"OpenAI 兼容": "OpenAI-compatible",
"Anthropic 兼容": "Anthropic-compatible",
"Gemini 原生(实验)": "Gemini native (experimental)",
"扩展": "Extension",
"新增": "Added",
"已删除": "Deleted",
@@ -712,6 +794,8 @@ export const EN: Record<string, string> = {
"已运行,但退出码来自命令中的其他片段": "it ran, but the exit code came from another stage of the command",
"有改动;没有主机能读作证据的检查": "changed; no check the host could read as proof",
"改动后未再检查": "changed, never looked at again",
"有 Claude Code mod 在运行;它写了什么不在主机观测范围内": "a Claude Code mod was active; what it wrote is outside what the host observes",
"你在等待期间,另一个会话改动了本会话占用的路径;之前读到的内容可能已过时": "another session wrote under this session's paths while it waited on you; earlier reads there may be stale",
"自己申报未验证": "declared unverified by the turn itself",
"同一账号的两种接入方式。当前这一种不支持联网搜索;这是协议差异,不是可配置项。": "Two ways in to the same account. This one has no web search — that is a difference between the protocols, not a setting.",
"同一账号的两种接入方式。切换后下方的模型列表随之改变。": "Two ways in to the same account. Switching changes the model list below.",
@@ -1270,6 +1354,15 @@ export const EN: Record<string, string> = {
// ── 内核其余的拒绝措辞 ─────────────────────────────────────────
"该供应商拒绝了当前密钥,无法读取余额": "The provider refused this key, so the balance cannot be read",
"该供应商的余额接口无响应": "The provider's balance endpoint did not answer",
"余额地址": "Balance address",
"余额格式": "Balance format",
"留空则不显示余额。密钥只会发给这个地址,不会跟随跳转。": "Leave empty to show no balance. The key is sent only to this address and never follows a redirect.",
"供应商自己的域名会按它的格式读取;其他地址(例如中转站)必须选择它返回的格式,否则不读取余额。": "A vendor's own domain is read in that vendor's format; any other address (a relay, for example) must name the format it answers in, or no balance is read.",
"按地址推断({format})": "From the address ({format})",
"DeepSeek 格式": "DeepSeek format",
"Moonshot 格式": "Moonshot format",
"OpenRouter 格式": "OpenRouter format",
"余额格式未配置:这个地址不是供应商自己的域名,选择格式之前不会读取余额。": "Balance format not set: this address is not the vendor's own domain, so no balance is read until a format is chosen.",
"无法解析该供应商余额接口返回的内容": "The provider's balance endpoint answered in a shape this build cannot read",
"{n} 个问题": "{n} questions",
@@ -1433,6 +1526,16 @@ export const EN: Record<string, string> = {
"含赠金 {amount}": "incl. {amount} credit",
"刚刚": "just now",
"{n} 分钟前": "{n} min ago",
"高峰": "Peak",
"平峰": "Off-peak",
"现在按供应商公布的高峰价计费": "Billed at the vendor's published peak rate now",
"现在按供应商公布的平峰价计费": "Billed at the vendor's published off-peak rate now",
"现在按供应商公布的高峰价计费,约 {left} 后恢复平峰价": "Billed at the vendor's published peak rate now; off-peak again in about {left}",
"现在按供应商公布的平峰价计费,约 {left} 后进入高峰": "Billed at the vendor's published off-peak rate now; peak starts in about {left}",
"{n} 分钟": "{n} min",
"{n} 小时": "{n} h",
"{h} 小时 {m} 分钟": "{h} h {m} min",
"{n} 天": "{n} d",
"{n} 小时前": "{n} h ago",
"行宽": "Line width",
"正文行宽": "Body line width",
@@ -1651,4 +1754,12 @@ export const EN: Record<string, string> = {
"收起数据": "Hide data",
"图表数据:{title}": "Chart data: {title}",
"另有 {n} 行": "and {n} more rows",
"请求修改设置": "Asks to change a setting",
"每次都会询问": "Asked every time",
"这项设置会放宽模型的权限,所以没有「本会话内不再问」,每次都要你亲自确认。": "This setting loosens what the model may do, so there is no \"don't ask again this session\": you confirm it yourself each time.",
"打开设置": "Open settings",
"视频地址": "Video addresses",
"模型可以把你在对话里写的公开视频地址交给模型厂商读取。默认关闭,立即生效。": "The model can hand a public video address you wrote in the conversation to the model vendor to read. Off by default; applies at once.",
"把视频地址交给模型厂商": "Hand video addresses to the model vendor",
"开启后,该网址会被厂商直接访问:由厂商的服务器去打开它,不是本机下载。只有你自己写在对话里的地址才会交出去,且所选模型的厂商要支持。": "When on, the address is visited directly by the vendor: its servers open it, it is not downloaded here. Only an address you wrote in the conversation is handed over, and the selected model's vendor has to support it.",
};
@@ -196,4 +196,5 @@ export const EN_COMPOSER: Record<string, string> = {
"资源": "resource",
"↑↓ 选择 · 回车引用 · Tab 补全或进入目录 · Esc 关闭": "↑↓ selects · Enter references · Tab completes or enters a folder · Esc closes",
"↑↓ 选择 · Tab 或回车补全 · Esc 关闭": "↑↓ selects · Tab or Enter completes · Esc closes",
"重新选择文件夹": "Choose the folder again",
};
@@ -120,6 +120,20 @@ export const EN_FEEDBACK: Record<string, string> = {
"需要补充信息": "Needs more info",
"维护者已回复": "Answered by us",
"已关闭此反馈": "Closed",
"已撤回这条反馈。": "The report was withdrawn.",
"撤回这条反馈…": "Withdraw this report…",
"确认撤回": "Withdraw",
"撤回后维护者不再处理它,对应的 #{n} 议题会被关闭,你上传的图片会被删除;GitHub 上已经公开的文字会保留。": "Once withdrawn the maintainers stop working on it, its issue #{n} is closed and the images you uploaded are deleted; text already public on GitHub stays.",
"撤回后维护者不再处理它,你上传的图片会被删除。": "Once withdrawn the maintainers stop working on it and the images you uploaded are deleted.",
"来自其他设备": "From another device",
"你的反馈等级升到了 L{n}「{name}」。": "Your feedback level went up to L{n} \u00b7 {name}.",
"再落地 {r} 条升到「{next}」。": "{r} more shipped to reach {next}.",
"没能记住这条已读,稍后会再提示。": "That could not be remembered; it will be shown again later.",
"等级提示": "Level notices",
"你的反馈被采纳、等级提高时,在窗口顶部提示一次,由你点“知道了”关掉。默认关闭;关着时不会提示,也不会补报之前的升级。": "When your feedback ships and your level goes up, a note appears once at the top of the window until you dismiss it. Off by default; while off nothing is said, and earlier level-ups are not announced when you turn it on.",
"等级提升时通知我": "Tell me when my level goes up",
"不弹窗、不发系统通知,只在窗口顶部的提示条里说一次": "No pop-up and no system notification: one line in the note bar at the top of the window",
"已读标记只记在这台电脑上,其他设备上的反馈在这里仍可能显示为新回复。": "Read marks are kept on this computer only, so a report from another device may still show new replies here.",
"需要你回复": "Needs your reply",
"等你补充信息": "Waiting for your information",
"维护者": "Maintainers",
@@ -172,6 +186,7 @@ export const EN_FEEDBACK: Record<string, string> = {
"较高的额度已经失效。等级和已落地的条数都保留,之后再有反馈落地就会续上。": "Your higher limits have lapsed. Your level and count are kept, and a later report that ships renews them.",
"这是上次确认的等级({date}),连上反馈服务后会更新。": "This is the last confirmed level ({date}); it updates once the feedback service is reachable.",
"等级暂时无法显示。": "Your level cannot be shown right now.",
"等级 {n}": "Level {n}",
"这一小时的反馈次数已用完。": "You have used this hour's feedback allowance.",
"今天的反馈次数已用完。": "You have used today's feedback allowance.",
"这个网络这一小时的提交次数已用完。": "This network has used this hour's submission allowance.",
@@ -200,4 +215,20 @@ export const EN_FEEDBACK: Record<string, string> = {
"这台电脑还没有发过反馈,发出第一条之后才能关联账号。": "This computer has not sent a report yet. You can link it to an account after the first one.",
"账号关联暂时不可用,请稍后再试。": "Account linking is unavailable right now. Please try again later.",
"操作没有完成,请稍后重试。": "That did not finish. Please try again later.",
"{n} 条被采纳": "{n} adopted",
"先在设置里登录账号,才能决定是否上榜。": "Sign in from Settings first to choose whether to be on the board.",
"全部版本": "All releases",
"加入贡献榜": "Join the board",
"名次 {n}": "Place {n}",
"名次取决于维护者选择发布的内容:被婉拒的好反馈不计入。": "A ranking depends on what the maintainers chose to ship: a good report that was declined earns nothing.",
"我在榜上": "My place on the board",
"榜上将显示为:{name}": "It will be shown on the board as: {name}",
"榜上显示为": "Shown on the board as",
"榜上显示的名字": "Name shown on the board",
"给反馈被采纳并发布的人。按版本看有名次,全部版本只按名字排序,不比高低。只有自己选择加入的人才会出现。": "For people whose feedback was adopted and shipped. A release has a ranking; all releases together are listed by name, not ranked. Only people who choose to join appear.",
"贡献榜": "Contribution board",
"这个版本还没有人上榜。": "Nobody is listed for this release yet.",
"这个账号已被维护者从榜单移除,暂时不能再加入。": "A maintainer removed this account from the board; it cannot join again for now.",
"退出榜单": "Leave the board",
"默认不上榜。加入后榜上只显示下面这个名字和采纳数,不显示邮箱、安装 ID 或路径;随时可以退出,退出后下一次刷新就不再出现。": "Not on the board by default. Once you join, the board shows only the name below and your adopted count, never an email, install id or path; you can leave at any time and you disappear from the next refresh.",
};
+68 -1
View File
@@ -46,17 +46,24 @@ export const EN_KERNEL: Record<string, string> = {
"参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试": "The arguments break this tool's contract, so the call did not run; complete or correct them per the tool description and retry",
"另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试": "Another session holds the requested write claim. This operation did not run; end the current turn and retry after the claim is released",
"搜索模型「{model}」的格式应为 服务/模型": "Search model “{model}” must be written as service/model",
"搜索模型「{model}」所在的连接已被移出": "Search model “{model}”'s connection has been removed",
"搜索模型「{model}」所在的连接还没有添加:先添加这个连接,再选它": "Search model “{model}”'s connection has not been added yet: add that connection first, then pick it",
"搜索模型「{model}」已不在配置里": "Search model “{model}” is no longer configured",
"搜索模型「{model}」的协议不支持原生搜索,或已关闭搜索": "Search model “{model}” has no native search on its protocol, or search is turned off for it",
"搜索模型「{model}」所在的连接没有凭据": "Search model “{model}”'s connection has no credentials",
"反馈通道今日已满,请明天再试": "Feedback is at capacity for today - try again tomorrow",
"这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试": "This report has reached its reply limit, or you replied too often - try again later",
"这份反馈现在不接收回复": "This report takes no reply right now",
"这份反馈已经有了结论,不能再撤回": "This report already has an outcome and can no longer be withdrawn",
"找不到这份反馈": "That report could not be found",
"反馈服务要求额外验证,请稍后再试": "The feedback service asks for extra verification - try again later",
"需要先登录账号": "Sign in to your account first",
"这台电脑已关联到另一个账号": "This computer is already linked to another account",
"这个账号关联的电脑已达上限": "This account has reached its limit of linked computers",
"名字需要 2 到 30 个字符:字母、数字、空格、下划线或连字符,不能含 @ 或网址": "A name is 2 to 30 letters, digits, spaces, underscores or hyphens, with no @ and no links",
"这个名字是保留的,会让人误以为是维护者,换一个吧": "That name is reserved because it could be mistaken for a maintainer; pick another",
"已经有人用这个名字了": "Someone already uses that name",
"这个账号已被维护者从榜单移除,暂时不能再加入": "A maintainer removed this account from the board; it cannot join again for now",
"先把这台电脑关联到账号,才能加入榜单": "Link this computer to your account first, then join the board",
"这台电脑还没有发过反馈": "This computer has not sent a report yet",
"截图无法清除其中的元数据,请换一张或先用截图工具重新导出": "An image could not be cleaned of its metadata - pick another or re-export it",
"反馈内容不符合要求,请检查后重试": "That feedback could not be accepted - check the fields and try again",
@@ -95,6 +102,13 @@ export const EN_KERNEL: Record<string, string> = {
"这个窗格由另一台机器驱动,使用它自己的事件流": "This pane is driven from another machine and keeps its own event stream",
"这个后台任务已经不在运行了": "That background job is no longer running",
"这条调用已经结束了": "This call has already finished",
"没有找到这个子代理的记录": "No sub-agent run was found for that reference",
"这个子代理不属于当前会话": "That sub-agent belongs to a different conversation",
"读取子代理记录失败:{detail}": "Could not read the sub-agent record: {detail}",
"这个子代理已经不在运行,读不到新的指示": "That sub-agent is no longer running, so it cannot read new guidance",
"指示不能是空的": "Guidance cannot be empty",
"这条指示太长了,超过了发给子代理的单条上限": "That guidance is longer than the limit for one message to a sub-agent",
"短时间内发给这个子代理的指示太多了,请稍后再发": "Too much guidance was sent to this sub-agent a moment ago; send it again shortly",
"你停止了这条调用,它在外部已经做了什么无法确定": "You stopped this call; what it had already done outside the workspace is unknown",
"请输入要搜索的内容": "Enter something to search for",
"对话内容索引暂时无法回答,稍后再试": "The conversation index cannot answer right now; try again shortly",
@@ -108,6 +122,7 @@ export const EN_KERNEL: Record<string, string> = {
"该会话的待送达已关闭": "This session's pending queue is closed",
"该待送达由更高版本写入,当前版本只能读取": "This queue was written by a newer version and is read-only here",
"这份待送达属于另一个运行,暂不能接管": "This pending queue belongs to another run and cannot be taken over here",
"待送达的正文读不出来,队列内容未知,已暂停等待检查": "The pending queue's contents could not be read, so they are unknown and the queue is paused for inspection",
"该提交标识已被使用,且当时的内容不同": "That submission key was already used, with different content",
// 配置文件本身坏了,以及每一个写设置的面板被它挡住时
"配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复": "The config file {path} does not parse, so nothing was saved. Open Settings to see where it is broken and repair it",
@@ -124,8 +139,18 @@ export const EN_KERNEL: Record<string, string> = {
"多会话写入设置未能保存:{detail}": "The multi-session write setting was not saved: {detail}",
"这台服务器未开放计划批准设置": "This server does not open the plan approval setting",
"计划批准设置未能保存:{detail}": "The plan approval setting was not saved: {detail}",
"这台服务器未开放新对话起始模式设置": "This server does not open the new-conversation start mode setting",
"新对话起始模式设置未能保存:{detail}": "The new-conversation start mode setting was not saved: {detail}",
"这台服务器未开放工具设置": "This server does not open the tool settings",
"工具设置未能保存:{detail}": "The tool setting was not saved: {detail}",
"本次请求未说明要改哪个工具,未做任何修改": "The request did not say which tool to change, so nothing changed",
"这个工具被其他功能需要,不能关闭": "Another feature needs this tool, so it cannot be turned off",
"这个工具已在配置里关闭,本次调用未执行": "This tool is turned off in the configuration, so the call did not run",
"子代理写入范围设置未能保存:{detail}": "The sub-agent write scope setting was not saved: {detail}",
"这台服务器未开放子代理指示上限设置": "This server does not open the sub-agent guidance limit setting",
"子代理指示上限设置未能保存:{detail}": "The sub-agent guidance limit setting was not saved: {detail}",
"本次请求未说明选择,未做任何修改": "The request did not say which, so nothing changed",
"最长等待的分钟数不能是负数": "The longest wait cannot be negative",
"这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写": "This path is in use by another parallel sub-agent or write. This write did not run; choose another path, or wait until it finishes",
"无法解析该服务器声明:{detail}": "That server declaration could not be read: {detail}",
"未能安装该服务器:{detail}": "That server could not be installed: {detail}",
@@ -242,7 +267,17 @@ export const EN_KERNEL: Record<string, string> = {
"请为该机器填写名称": "Give this machine a name",
"请填写要连接的地址": "Say which address to dial",
"这不是有效的端口号": "That is not a port number",
"这个窗口没有连到账号服务,不能管理手机": "This window is not connected to the account service, so it cannot manage phones",
"这台电脑还没有登记到账号:先登录账号并打开远程访问": "This computer is not registered with an account yet: sign in and turn on remote access first",
"找不到这台手机,它可能已经被移除": "That phone was not found; it may already have been removed",
"账号服务拒绝了这个操作,请刷新后再试": "The account service refused that; refresh and try again",
"连不上账号服务,请检查网络后重试": "Could not reach the account service; check the network and try again",
"这台机器不是 SSH 主机,此表单不能修改它": "This machine is not an SSH host, so this form cannot change it",
"这不是有效的 WSL 发行版名称": "That is not the name of a WSL distribution",
"读不到已安装的 WSL 发行版": "Could not read which WSL distributions are installed",
"这台机器的接入方式与表单不同(SSH 或 WSL),不能这样修改它": "This machine is reached another way than this form describes (SSH or WSL), so it cannot be changed like this",
"该主机已停用,启用后才能连接": "This machine is turned off; turn it on to connect",
"主机 {host} 尚未保存,请先添加该主机再连接": "Host {host} is not saved; add it before connecting",
"该机器仍有 {n} 个打开的面板,请先关闭再移除": "This machine still has {n} open panes — close them before removing it",
"{host} 的主机密钥已变更。可能是该机器重装,也可能存在中间人。记录位于 {file} 第 {line} 行,核实前请勿连接。":
"The host key for {host} changed. That machine may have been rebuilt \u2014 or someone may be in the middle. The record is at {file} line {line}; do not connect until you have checked.",
@@ -315,6 +350,7 @@ export const EN_KERNEL: Record<string, string> = {
"这个内核没有窗口,打不开本机的编辑器。": "This kernel has no window, so it cannot open an editor on this machine.",
"这个文件夹不在当前窗口的项目列表里。": "That folder is not a project in this window.",
"项目文件夹已不在磁盘上。": "The project folder is no longer on disk.",
"这次操作需要的文件夹已不在磁盘上:可能被移走、改名或删除了,操作没有执行。": "The folder this action needs is no longer on disk: it may have been moved, renamed or deleted, and nothing was run.",
"这个内核不在本机,没法在系统文件管理器中显示它的文件。": "This kernel is not on this machine, so its files cannot be shown in the system file manager.",
"这个系统没有可用的文件夹选择框,请直接填写路径。": "This system has no folder picker available; type the path instead.",
"打不开文件夹选择框:{error}": "The folder picker could not be opened: {error}",
@@ -363,7 +399,11 @@ export const EN_KERNEL: Record<string, string> = {
"这条消息已经处理过了": "That message was already decided",
"会话间消息没能完成:{detail}": "The message between sessions did not go through: {detail}",
"放行的项目必须是一个绝对路径的文件夹": "A released project must be a folder given as an absolute path",
"派发上限是 0 或更大的整数,0 表示不限制": "A dispatch ceiling is a whole number from 0 up; 0 removes the ceiling",
"只有批准才能记住,拒绝只对这一条生效": "Only an approval can be remembered; a denial applies to this message alone",
"单个钩子的运行时限是 0 到 55000 毫秒,0 表示默认的 10 秒": "A hook's running time is 0 to 55000 milliseconds; 0 keeps the default 10 seconds",
"这个客户端不能修改 mod 设置": "This client cannot change the mod settings",
"运行 Claude Code mod 的开关还没有开放,现在不能打开": "The switch for running Claude Code mods is not open yet, so it cannot be turned on",
"会话间消息的设置没能保存:{detail}": "The messaging settings could not be saved: {detail}",
"地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥": "The address moved to another origin, so the saved key is not sent there; enter the key for this address",
"请先写下要问的内容": "Write the question first",
@@ -450,6 +490,9 @@ export const EN_KERNEL: Record<string, string> = {
"有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复": "Some items run programs here or change where a key is sent; confirm each one before restoring",
"项目列表已到你设置的上限(desktop.max_workspaces),请先移除一个项目再添加": "The project list is at the limit you set (desktop.max_workspaces); remove one before adding another",
"{model} 的价格必须是不小于 0 的数字,并选择货币": "The price for {model} must be a number not below 0, with a currency chosen",
"{model} 的价格来源必须是 http 或 https 链接,不能含用户名密码或控制字符": "The source of the price for {model} must be an http or https link with no credentials or control characters",
"{model} 的核对日期必须是 YYYY-MM-DD,且不晚于今天(按 UTC)": "The check date for {model} must be YYYY-MM-DD and not later than today (UTC)",
"计费方式只能是按量计费或订阅等价估算": "Billing is either pay-as-you-go or a subscription-equivalent estimate",
"{model} 不在已选模型中,未保存价格": "{model} is not one of the selected models, so no price was saved",
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
"不存在该预设": "There is no such preset",
@@ -462,6 +505,7 @@ export const EN_KERNEL: Record<string, string> = {
"这段内容不是纯文本,不能存为文本文件。": "This is not plain text, so it cannot be kept as a text file.",
"无法读取这个文件:{detail}。它可能被其他程序占用、已被移动,或在云盘里尚未下载。": "Could not read this file: {detail}. It may be open in another program, moved, or not yet downloaded from cloud storage.",
"附件未能保存到工作区的 .reasonix/attachments:{detail}。请检查该目录的写入权限和磁盘空间。": "The attachment could not be saved to .reasonix/attachments in the workspace: {detail}. Check that folder's write permission and the free disk space.",
"无法读取附件上传上限,请修复用户 config.toml 后重试。": "The attachment upload limit could not be read. Repair your user config.toml and try again.",
"该行过长,无法补全": "That line is too long to complete",
"该连接不支持流式传输": "This connection does not support streaming",
"服务端出现异常,与你的操作无关": "Something went wrong on this side — nothing you did is at fault",
@@ -482,6 +526,9 @@ export const EN_KERNEL: Record<string, string> = {
"窗口大小已记录,将在本轮结束后生效": "The window is saved; it starts counting once the running work finishes",
"已保存。当前对话还有未结束的工作(正在运行、等待你回答或有后台任务),仍按原设置进行;结束后再保存一次即可生效": "Saved. This conversation still has work in progress (running, waiting for your answer, or background jobs), so it keeps its current settings; save again once that finishes to apply them",
"已保存。当前对话使用的模型已不在该来源的列表中,切换模型后才会生效": "Saved. This conversation runs on a model the source no longer lists; switch models to apply it",
"余额格式未配置:这个地址不是供应商自己的域名,请在连接设置里选择它返回的格式": "Balance format not set: this address is not the vendor's own domain, so choose the format it answers in under the connection settings",
"余额地址必须是 http 或 https 开头的网址": "The balance address must be an http or https URL",
"这个余额格式不在可选范围内": "That balance format is not one of the available ones",
"额外设置中的「{path}」不能为空值(null)": "“{path}” in the extra settings cannot be null",
"图片过大,请压缩至 {limit} MB 以内": "That image is too large — bring it under {limit} MB",
"{path} 不在当前工作树中,无法查看其改动": "{path} is not inside this working tree, so its changes cannot be shown",
@@ -502,6 +549,10 @@ export const EN_KERNEL: Record<string, string> = {
"这个技能不是从本机文件夹复制安装的,无法从源文件夹更新(链接或远程来源请重新安装)": "This skill was not copied from a local folder, so it cannot be updated from one (reinstall a linked or remote skill).",
"找不到来源文件夹,可能被移动或删除了": "The source folder cannot be found; it may have been moved or deleted.",
"自你查看预览后,来源文件夹或已安装的副本又变了。请重新查看再更新": "The source folder or the installed copy changed after you opened the preview. Review it again before updating.",
"这个技能不是你自己的文件,不能在这里卸载(内置技能或由包提供的技能,随包卸载)": "This skill is not a file of yours, so it cannot be removed here (a built-in, or a package's skill, which goes with its package)",
"这个技能自你打开列表后已经变了,没有删除任何内容。请重新读取后再试": "This skill changed after you opened the list, and nothing was deleted. Read the list again and retry",
"卸载技能失败:{detail}": "Could not remove the skill: {detail}",
"其他项目的技能不能在这里卸载": "A skill of another project cannot be removed from here",
"已安装的副本被改过,更新会覆盖这些改动,需要你确认": "The installed copy was edited, and updating overwrites those edits; confirm to continue.",
"这个技能太大,无法更新(文件数或总大小超过上限)": "This skill is too large to update (too many files or too many bytes).",
"更新没有完成,已安装的副本保持原样:{detail}": "The update did not complete and the installed copy was left as it was: {detail}",
@@ -538,6 +589,7 @@ export const EN_KERNEL: Record<string, string> = {
"运行时已更换,费用显示币种未保存,请重试": "The runtime changed; the display currency was not saved. Try again.",
"费用显示币种未能保存:{detail}": "The display currency was not saved: {detail}",
"文件改动显示设置未能保存:{detail}": "The file-changes display setting was not saved: {detail}",
"等级提示的设置未能保存:{detail}": "The level notice setting was not saved: {detail}",
"折叠中间过程的设置未能保存:{detail}": "The fold-the-work setting was not saved: {detail}",
"回合间叙述的设置未能保存:{detail}": "The narration setting was not saved: {detail}",
"这台服务器没有开启联网搜索与抓取设置的编辑:只有这台电脑上的窗口能改。": "Editing the web search and fetch settings is not enabled on this server: only the window on this computer can change them.",
@@ -546,9 +598,12 @@ export const EN_KERNEL: Record<string, string> = {
"SearXNG 地址必须是带主机名的 http(s) 地址,不能含账号、查询串或片段,未做任何修改。": "The SearXNG address must be an http(s) address with a host, without credentials, query or fragment; nothing was changed.",
"无进展设置未能保存:{detail}": "The progress settings were not saved: {detail}",
"提问等待时长未能保存:{detail}": "The question wait time was not saved: {detail}",
"引用历史会话的长度上限未能保存:{detail}": "The size limit for referenced conversations was not saved: {detail}",
"长度上限超出允许范围,未做任何修改:{detail}": "The size limit is outside the allowed range; nothing was changed: {detail}",
"等待时长超出允许范围,未做任何修改:{detail}": "That wait time is out of range; nothing was changed: {detail}",
"自动归档设置未能保存:{detail}": "The auto-archive settings were not saved: {detail}",
"天数超出允许范围,未做任何修改:{detail}": "The number of days is out of range; nothing was changed: {detail}",
"人设内容超出长度上限,未做任何修改:{detail}": "The persona text is over the length limit; nothing was changed: {detail}",
"交付验收卡片的设置未能保存:{detail}": "The receipt card setting was not saved: {detail}",
"主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder",
"未能记下对此文件夹的信任决定:{detail}": "The trust decision for this folder was not saved: {detail}",
@@ -591,4 +646,16 @@ export const EN_KERNEL: Record<string, string> = {
"The old memory file could not be backed up first, so it was not migrated and was left as it is; the detail gives the reason",
"会话、工作区或运行时正在切换,这条消息没能立刻发出;它已排入队列,切换结束后会自动发送,不需要重发":
"The session, workspace or runtime is switching, so this message could not be sent right away; it is queued and will be sent automatically when the switch ends - no need to send it again",
"该会话正在窗口里打开,先关闭它再移动": "This conversation is open in a window; close it before moving it",
"该会话还在被写入,稍后再移动": "This conversation is still being written to; move it a little later",
"该会话被另一个进程占用({host} 上的 pid {pid}),结束它之后再移动": "Another process holds this session open (pid {pid} on {host}); end it, then move it",
"它已经在这个项目里了": "It is already in this project",
"这个会话的记录不完整,不能移动;先让它自己恢复": "This conversation's record is incomplete and cannot be moved; let it recover first",
"「运行 Claude Code mod」没有打开,所以没有运行任何 mod": "\"Run Claude Code mods\" is off, so no mod is running",
"这台电脑上找不到能运行 mod 的 Node 运行时": "No Node runtime that can run mods was found on this computer",
"找到的 Node 运行时版本过低,不能运行这个 mod": "The Node runtime found is too old to run this mod",
"这个 mod 的进程崩溃了,正在按退避重新启动": "This mod's process crashed and is being restarted with backoff",
"它的某个钩子超过了时间预算,进程已被结束;多次发生后会被停用": "One of its hooks ran past its time budget and the process was ended; after repeats it is disabled",
"它的某个钩子抛出了异常": "One of its hooks threw an exception",
"它用到了本宿主不支持的功能": "It uses something this host does not support",
};
@@ -0,0 +1,28 @@
export const EN_PERSONA: Record<string, string> = {
"人设": "Persona",
"系统人设": "System persona",
"已保存 · 下次会话构建时生效": "Saved · applies at the next session build",
"下次会话构建时生效": "Applies at the next session build",
"系统人设决定 agent 的身份:前缀写在系统提示词最前面,后缀写在内置指令之后。保存不会立即重建会话;新会话及现有会话下一次重建(例如切换模型)时生效。":
"The system persona shapes the agent's identity: the prefix leads the system prompt and the suffix follows the built-in guidance. Saving does not rebuild a session immediately. Changes apply to new sessions and the next rebuild of an existing session, such as a model switch.",
"无法读取人设设置。": "Couldn't read the persona settings.",
"预设": "Presets",
"严谨工程师": "Rigorous engineer",
"简洁": "Concise",
"教学导师": "Mentor",
"严苛评审": "Critic",
"先验证再下结论,只做最少的正确改动,并说明剩余风险。": "Verify before concluding, make the smallest correct change, and state the residual risk.",
"回答简短直接,不铺垫,优先代码与要点。": "Answer briefly and directly, skip the preamble, prefer code and bullets.",
"像老师一样说明取舍与原理,把决定权留给你。": "Explain trade-offs and reasoning like a teacher, and leave the decision to you.",
"以审查者的立场找问题:先指出失效方式和证据,再谈通过。": "Review adversarially: name the failure mode and the evidence before approving.",
"未应用预设;直接填写下面的两段即可。": "No preset applied; fill in the two boxes below.",
"已修改预设内容,按下面的字段保存。": "The preset text has been edited; save the fields below.",
"系统提示词开头": "Start of the system prompt",
"作为系统提示词的第一段,写在内置指令之前;留空表示不添加。": "Leads the system prompt, ahead of the built-in guidance; leave it empty to add nothing.",
"第一方指令之后": "After the first-party guidance",
"紧跟在内置指令之后、运行时上下文之前;留空表示不添加。": "Follows the built-in guidance and precedes the runtime context; leave it empty to add nothing.",
"不注入可选运行时上下文": "Do not inject optional runtime context",
"关闭后不注入探测到的运行环境与离线提示;指令、记忆与技能照常写入。": "When on, the probed environment and the offline note are not injected; instructions, memory and skills are written as usual.",
"写入用户配置,在新会话及现有会话下一次重建时生效;保存本身不会重建会话。": "Writes the user config; applies to new sessions and the next rebuild of an existing session. Saving itself does not rebuild it.",
"已保存,下次会话构建时生效": "Saved; applies at the next session build",
};
@@ -44,7 +44,27 @@ export const EN_REMOTE: Record<string, string> = {
"尚未添加远程机器。添加后,其工作区将与本地工作区并列显示在左栏。":
"No remote machines yet. Add one and its workspaces appear in the sidebar beside your local ones.",
"添加机器": "Add a machine",
"手机通知": "Phone notifications",
"有事等你时,电脑可以提醒登录了同一个账号的手机。发出去的只有“哪一类”和数量,不含会话名称、命令或任何对话内容;手机要在它自己的页面里选择接收。默认全部关闭。": "When something is waiting for you, this computer can remind phones signed in to the same account. Only the kind of occasion and a count are sent, never a session name, command or any conversation text, and a phone chooses on its own page whether to receive them. Everything is off by default.",
"提醒我的手机": "Remind my phones",
"关闭时电脑不会向账号服务报告任何事。立即生效": "When off, this computer reports nothing to the account service. Takes effect immediately.",
"一轮失败时": "When a turn fails",
"这一轮因为出错而结束;你自己取消的不算": "The turn ended in an error; one you cancelled yourself does not count",
"已登记的手机": "Enrolled phones",
"还没有手机登记。在手机上打开这台电脑的远程页面,并选择接收提醒。": "No phone is enrolled yet. Open this computer's remote page on a phone and choose to receive reminders.",
"等你批准 · 标识 {id}": "Waiting for you · fingerprint {id}",
"最近使用 {at}": "Last used {at}",
"替换旧设备": "Replace the old device",
"确认移除": "Confirm removal",
"WSL 发行版": "WSL distribution",
"SSH 主机": "SSH host",
"连接类型": "Connection type",
"发行版": "Distribution",
"{name}(WSL 1,不支持)": "{name} (WSL 1, not supported)",
"内核运行在 Studio 保持的一个会话里:关闭 Studio 后它会随发行版一起停止,对话保存在磁盘上,下次连接时继续。模型凭据使用发行版自己的配置。": "The kernel runs in a session Studio keeps open: when Studio closes, it stops with the distribution. Conversations are kept on disk and continue on the next connect. Model credentials come from the distribution's own configuration.",
"未设置默认工作区": "No default workspace",
"默认连接目录:{path}": "Default connection directory: {path}",
"连接目录:{path}": "Connected directory: {path}",
"跟随 ssh_config": "Follows ssh_config",
"{n} 条转发": "{n} forwards",
"{n} 个面板": "{n} panes",
@@ -229,4 +249,26 @@ export const EN_REMOTE: Record<string, string> = {
"断开": "Disconnect",
"还没有手机连上来。": "No phone is connected yet.",
"连接走的是局域网里的明文 HTTP:同一个网络里的其他人能看到传输内容。在自己家里可以用,咖啡馆、机场这类公共网络不要开。Tailscale 地址的流量本身是加密的。": "The connection is plain HTTP on your local network, so others on the same network can read it. Fine at home; do not turn it on at a café, an airport or any other public network. Traffic to a Tailscale address is already encrypted.",
"这台手机的提醒": "This phone's reminders",
"电脑有事等你时,这台手机会收到一条通知。通知里只有“哪一类”和数量,不含会话名称、命令或对话内容。默认关闭,只提醒你勾选的几类。": "When something on your computer is waiting for you, this phone gets a notification. It carries only the kind of occasion and a count, never a session name, command or conversation text. Off by default, and only the occasions you tick are sent.",
"这个浏览器没有通知能力。iPhone 上通常要先把这个页面添加到主屏幕,再从主屏幕打开。": "This browser cannot receive notifications. On an iPhone you usually need to add this page to the Home Screen and open it from there.",
"提醒这台手机": "Remind this phone",
"第一次打开时,这台手机会向电脑申请登记,需要你在电脑上批准。": "The first time you switch this on, the phone asks your computer to register it, and you approve that on the computer.",
"这台手机在等电脑批准:到电脑上的 设置 → 远程 → 手机通知,点「添加」,然后回来再试。": "This phone is waiting for your computer to approve it: on the computer open Settings → Remote → Phone notifications, press Add, then come back and try again.",
"再试一次": "Try again",
"电脑已经把这台手机移除了。重新登记会换一把新的密钥,并需要电脑再批准一次。": "The computer has removed this phone. Registering again makes a new key and needs the computer to approve it once more.",
"重新登记": "Register again",
"有操作要批准": "An action needs approval",
"模型要动的东西超出了已经给出的授权": "The model wants to touch something beyond what it was authorised for",
"模型向我提问": "The model asks me something",
"模型停下来等你回答": "The model has stopped and is waiting for your answer",
"一轮出错结束": "A turn ends in an error",
"上一次提醒没有送达(推送服务回应 {status}),已自动放慢重试。": "The last reminder was not delivered (the push service answered {status}); retries have been slowed down.",
"上一次提醒没有送达,已自动放慢重试。": "The last reminder was not delivered; retries have been slowed down.",
"浏览器没有允许通知。请在浏览器的网站设置里允许这个页面发通知,再打开这个开关。": "The browser did not allow notifications. Allow them for this page in the browser's site settings, then switch this on again.",
"这个浏览器不能接收提醒。": "This browser cannot receive reminders.",
"这台手机还没有被电脑批准。": "This phone has not been approved by the computer yet.",
"需要重新登录账号后再试。": "Sign in to your account again, then try again.",
"账号服务暂时不可用,请稍后再试。": "The account service is unavailable right now; try again later.",
"浏览器没能向它的推送服务登记,所以这台手机暂时收不到提醒。常见原因是网络连不上浏览器厂商的推送服务。": "The browser could not register with its push service, so this phone cannot get reminders for now. A common cause is a network that cannot reach the browser maker's push service.",
};
+109 -1
View File
@@ -407,12 +407,29 @@ export const EN_SETTINGS: Record<string, string> = {
"无法读取多会话写入设置。": "Could not read the multi-session write setting.",
"无法读取计划批准设置。": "Could not read the plan approval setting.",
"计划批准": "Plan approval",
"新对话的起始模式": "How new conversations start",
"新建对话时先进入哪种模式。只影响起点,不会改动已有的对话。": "Which mode a new conversation opens in. It only sets the starting point and never changes a conversation that already exists.",
"新对话默认进入计划模式": "Start new conversations in plan mode",
"新建的对话先做计划,你批准后才动手。输入框里的模式按钮仍可随时切换;已有的对话保持它自己的模式。默认关闭,只写在你自己的用户配置里,项目配置不能替你改。": "A new conversation plans first and acts only after you approve. The mode button in the composer still switches it at any time, and a conversation that already exists keeps its own mode. Off by default, written only to your own user config; a project config cannot change it for you.",
"无法读取新对话的起始模式设置。": "Could not read the setting for how new conversations start.",
"模型可用的工具": "Tools the model can use",
"关掉的工具不会再出现在发给模型的请求里,模型也调用不了它;可以省下每次请求里的提示词开销。新开的对话生效,已有对话不受影响。": "A tool you turn off is no longer sent to the model, and a call to it is refused; this also saves its prompt cost on every request. It takes effect in conversations you open afterwards and never changes one that already exists.",
"无法读取工具列表。": "Could not read the tool list.",
"{feature}需要它,不能关闭。": "{feature} needs it, so it cannot be turned off.",
"目标模式": "Goal mode",
"计划步骤跟踪": "Plan step tracking",
"结束一个宿主留着没完的回合": "Ending a turn the host left open",
"这个项目的配置文件把它关掉了;要重新打开,请在项目的 reasonix.toml 里去掉。": "This project's config file turned it off; to turn it back on, remove it from the project's reasonix.toml.",
"加载延后的 MCP 工具": "Loading deferred MCP tools",
"计划模式提交计划后,默认等你点批准才开始执行。长时间无人值守的任务可以改成自动批准。": "After Plan mode submits a plan, it waits for your approval by default. For long unattended runs it can be set to approve automatically.",
"自动批准计划": "Approve plans automatically",
"计划提交后不再等你点批准,直接开始执行,对话里会留下一条说明。只放行这一次点击:工具批准、沙箱和拒绝规则照常生效。默认关闭,只写在你自己的用户配置里,项目配置不能替你打开。": "A submitted plan starts without waiting for your click, and the transcript notes it. Only that click is released: tool approvals, the sandbox and deny rules still apply. Off by default, written only to your own user config; a project config cannot turn it on for you.",
"多个会话同时写一个项目": "Several sessions writing one project",
"多个会话在同一个项目里工作时如何互相等待。默认严格:没声明写入范围的操作(bash、MCP、没写 write_paths 的子代理)会占住整个工作区,其他会话要等它结束。只影响之后新开的会话,已在运行的会话保持它启动时的设置。": "How sessions working in one project wait for each other. Strict by default: an operation that declares no write scope (bash, MCP, a sub-agent with no write_paths) holds the whole workspace and other sessions wait for it. It affects sessions opened afterwards; a session already running keeps the setting it started with.",
"乐观模式(多会话并行)": "Optimistic mode (sessions in parallel)",
"等待其他会话释放写入权的最长时间": "Longest wait for another session to release the write claim",
"另一个会话占着要写的路径时,这里的写入会一直等;设了分钟数,超过就失败,并给模型一个 workspace.claim_timeout 的明确原因。默认 0,一直等到本轮结束;想设上限,建议 120。之后新开的会话才采用新值。": "While another session holds a path this write needs, the write waits. With a number of minutes set, it fails past that and the model is told why with workspace.claim_timeout. Default 0 waits as long as the turn lasts; 120 is a sensible limit. Only sessions opened afterwards use it.",
"分钟": "minutes",
"没声明写入范围的操作(bash、MCP、没写 write_paths 的子代理)不再占住整个工作区,所以同一个项目里的多个会话可以同时工作,不必互相等待。代价:声明了路径的写入(如编辑文件)仍按路径互斥,但 bash 和 MCP 的写入没有事前的路径围栏,只在事后观察,两个会话用它们改同一个文件时可能互相覆盖。默认关闭(严格模式);只对之后新开的会话生效。": "An operation that declares no write scope (bash, MCP, a sub-agent with no write_paths) no longer holds the whole workspace, so several sessions in one project can work at the same time without waiting. The cost: writes that declare a path (such as editing a file) still exclude each other by path, but bash and MCP writes have no path fence beforehand and are only observed afterwards, so two sessions using them on one file can overwrite each other. Off by default (strict mode); applies only to sessions opened afterwards.",
"无法读取子代理写入范围设置。": "Could not read the sub-agent write scope setting.",
"子代理的写入范围": "Sub-agent write scope",
@@ -446,6 +463,17 @@ export const EN_SETTINGS: Record<string, string> = {
"{n} 个档位": "{n} levels",
"{n} 个模型单独设置": "{n} models with their own levels",
"按模型设置价格": "Price by model",
"来源与核对日期": "Source and check date",
"核对日期": "Checked on",
"{model} 的价格来源": "Source of the price for {model}",
"{model} 的核对日期": "Check date of the price for {model}",
"{model} 打开价格来源": "Open the price source of {model}",
"来源必须是 http 或 https 链接,不能带用户名密码。": "The source must be an http or https link without a username or password.",
"核对日期不能晚于今天(按 UTC)。": "The check date cannot be later than today (UTC).",
"核对于 {date}(UTC)": "Checked on {date} (UTC)",
"等价估算": "API-priced estimate",
"按 API 价格估算的订阅用量,不是实际扣费": "Subscription use priced at API rates; not a charge",
"订阅用量按 API 价格估算,不是实际扣费,也不计入上面的实付": "Subscription use priced at API rates. It is not a charge and is not part of the paid figure above.",
"这个价格不在官方价目表里,选了人民币或美元显示时无法换算。": "This price is not in the vendor's price list, so it cannot be converted when a display currency is chosen.",
"缓存命中价是 0:命中的 token 会按免费计,费用会偏低。": "The cache-hit price is 0: cached tokens are counted as free, so cost reads too low.",
"{model} 恢复官方价": "{model} restore official price",
@@ -799,6 +827,20 @@ export const EN_SETTINGS: Record<string, string> = {
"源文件夹不见了": "Source folder is gone",
"找不到来源文件夹:{path}": "The source folder cannot be found: {path}",
"从源文件夹更新…": "Update from source folder…",
"卸载…": "Remove…",
"卸载": "Remove",
"卸载中…": "Removing…",
"卸载技能「{name}」?": "Remove the skill \"{name}\"?",
"会从磁盘删除下面这个文件夹及其中所有文件,不能撤销。要再用它,需要重新安装。": "This deletes the folder below and everything in it from disk, and cannot be undone. To use it again you would have to install it again.",
"会从磁盘删除下面这个文件,不能撤销。要再用它,需要重新安装。": "This deletes the file below from disk and cannot be undone. To use it again you would have to install it again.",
"无法读取子代理指示的上限设置。": "Could not read the limits on guidance to sub-agents.",
"单条指示的字节上限": "Bytes in one message of guidance",
"每分钟条数上限": "Messages of guidance per minute",
"默认 {n}。超过的指示会被拒绝,不会发给子代理;填 0 表示不设上限。": "Default {n}. Guidance over it is refused and not sent to the sub-agent; 0 sets no limit.",
"默认 {n}。发给同一个子代理的指示,一分钟内超过这个条数就会被拒绝;填 0 表示不设上限。": "Default {n}. Guidance to one sub-agent beyond this many in a minute is refused; 0 sets no limit.",
"条": "per minute",
"给运行中子代理的指示": "Guidance to running sub-agents",
"你或模型在子代理还没做完时发给它的补充指示,在它下一次调用工具时读到。这里限制每条的大小和发送的频率,改动立即对正在运行的子代理生效。": "Extra guidance you or the model send a sub-agent that is still working, read at its next tool call. This limits how large each message is and how often they are sent; a change applies to running sub-agents at once.",
"文件太大,这里不显示内容。请到来源文件夹里查看:{path}": "This file is too large to show here. Read it in the source folder: {path}",
"不是文本文件,这里不显示内容。": "Not a text file, so its content is not shown here.",
"(没有可显示的差异)": "(no difference to show)",
@@ -1258,7 +1300,9 @@ export const EN_SETTINGS: Record<string, string> = {
"从 Reasonix 1.x 迁移过来的会话是否出现在侧栏列表里。隐藏后不会删除它们,搜索时仍能找到,正在打开的旧会话也照常显示。默认显示。": "Whether sessions migrated from Reasonix 1.x appear in the sidebar lists. Hiding does not delete them; search still finds them and an open one stays visible. Shown by default.",
"显示": "Show",
"会话行快捷按钮": "Session row buttons",
"鼠标移到侧栏会话行上时,是否在右侧显示「归档」「删除」按钮。关闭时这两个操作仍在右键菜单里。默认关闭。": "Whether the Archive and Delete buttons appear at the right of a sidebar session row while the pointer is over it. When off, both stay in the row's context menu. Off by default.",
"悬停或键盘聚焦会话时,左侧显示置顶按钮,右侧显示归档按钮。默认关闭;关闭时可通过会话菜单操作。": "Show Pin on the left and Archive on the right when a session is hovered or focused. Off by default; when hidden, use the session menu.",
"置顶会话:{title}": "Pin session: {title}",
"取消置顶:{title}": "Unpin session: {title}",
"归档会话:{title}": "Archive session: {title}",
"取消归档:{title}": "Unarchive: {title}",
"隐藏": "Hide",
@@ -1277,4 +1321,68 @@ export const EN_SETTINGS: Record<string, string> = {
"仅侧栏收起时显示": "Only when the sidebar is collapsed",
"不显示": "Never",
"关闭或收起时,这些页面仍可从设置和侧栏打开": "With it off or hidden, these pages still open from settings and the sidebar",
"引用历史会话的长度": "Size of a referenced conversation",
"在输入框里引用一段历史会话时,最多给这条消息添加多少内容。": "How much a referenced past conversation may add to a message.",
"引用的最大长度": "Largest quote",
"引用的历史会话会作为只读资料放进这条消息;超过上限时只保留最近的消息,并写明保留了多少。默认 {n} token。": "A referenced conversation goes into the message as read-only reference material. Past the limit only the newest messages stay, and the quote says how many. Default {n} tokens.",
"恢复默认": "Restore default",
"无法读取引用设置。": "The reference settings could not be read.",
"历史会话 · 只读资料": "Past conversation · read-only reference",
"引用「{title}」到输入框": "Reference \"{title}\" in the message box",
"↑↓ 选择 · 回车添加到这条消息 · Esc 关闭": "↑↓ choose · Enter adds it to this message · Esc closes",
"把「{name}」移到另一个项目": "Move \"{name}\" to another project",
"只带走对话本身和你设的标题、置顶、归档。原项目的权限与目标设置不会跟着走,新项目按它自己的规则重新判断;对话里记录的路径保持原样。": "Only the conversation itself and the title, pin and archive state you set travel. What the old project allowed does not follow: the new project decides for itself, and the paths recorded inside the conversation stay as they were.",
"没有别的项目可以移到。先添加一个项目。": "There is no other project to move it to. Add a project first.",
"目标项目": "Target project",
"移动": "Move",
"先关闭这个会话,再移动它": "Close this conversation first, then move it",
"移动到项目…": "Move to a project…",
"检查点(回退快照)、后台任务日志和排队中的消息不随会话移动,原件会被移除。": "Checkpoints (rewind snapshots), background-task logs and queued messages do not travel; the originals are removed.",
"对话已经移过去了,但原来的那份没能删除,请手动删除。": "The conversation was moved, but the original could not be removed; please delete it by hand.",
"运行 Claude Code mod": "Run Claude Code mods",
"默认关闭。打开后,已安装的 mod 里的代码会以你的权限在这台电脑上运行(完全信任):没有沙盒,不受网络出口治理,它做的事也不会被本应用观测到。关闭时一个 mod 都不会运行。": "Off by default. When on, the code in installed mods runs on this computer with your permissions (full trust): there is no sandbox, network egress governance does not apply, and what it does is not observed by this app. With it off, no mod runs.",
"单个钩子的运行时限": "Time limit for one hook",
"超过这个时间,mod 的进程会被结束。0 表示使用默认的 10 秒,最大 {max} 毫秒。": "Past this time the mod's process is ended. 0 uses the default 10 seconds; the most is {max} ms.",
"毫秒": "ms",
"无法读取 mod 设置。": "The mod settings could not be read.",
"已安装的 mod": "Installed mods",
"未运行": "Not running",
"正在启动": "Starting",
"正在重启": "Restarting",
"已重启 {n} 次": "Restarted {n} times",
"它注册的事件": "Events it registered",
"本应用不会触发这些事件": "Events this app never raises",
"没有安装带 mod 的插件包。": "No installed package carries a mod.",
"Claude Code mod": "Claude Code mods",
"插件包里的 mod 可以在会话的事件上挂钩子。": "A mod in a package can hook the session's events.",
"Claude Code mod 预览": "Claude Code mod preview",
"Claude Code mod · 完全信任": "Claude Code mod · full trust",
"它的代码会在这台电脑上以你的权限运行:没有沙盒,不受网络出口治理,它做的事也不会被本应用观测到。只有在设置里打开「运行 Claude Code mod」后才会运行。": "Its code runs on this computer with your permissions: there is no sandbox, network egress governance does not apply, and what it does is not observed by this app. It runs only once \"Run Claude Code mods\" is turned on in Settings.",
"下面是读取代码语法树得到的静态清单,不是保证:imports 是看不见的代码;eval、new Function 和用计算出来的名字访问接口,都会让清单列不全。": "What follows is a static listing read from the code's syntax tree, not a guarantee: imports are code you cannot see, and eval, new Function and reaching the API by a computed name all leave the listing incomplete.",
"未预览": "Not previewed",
"这不等于没问题:读不了的代码按“未预览”处理。": "That does not mean it is fine: code that cannot be read counts as not previewed.",
"它是 TypeScript,本应用不读": "It is TypeScript, which this app does not read",
"文件太大": "The file is too large",
"语法解析失败": "It could not be parsed",
"解析超时": "Parsing timed out",
"不是本宿主支持的模块形式": "It is not a module form this host supports",
"它挂钩的事件": "Events it hooks",
"读你的对话": "Reads your conversation",
"往模型面前排提示": "Queues prompts in front of the model",
"以它自己的名义,不冒充你": "Under its own name, never as you",
"(没有列出)": "(none listed)",
"名字无法列出的事件": "Events whose names cannot be listed",
"它用到的接口": "API it uses",
"它引入的代码(看不见)": "Code it pulls in (not visible)",
"会让清单列不全的写法": "Constructs that leave the listing incomplete",
"用了 eval": "Uses eval",
"用了 new Function": "Uses new Function",
"用计算出来的名字访问接口": "Reaches the API by a computed name",
"把接口对象交给了别处": "Hands the API object elsewhere",
"把注册函数交给了别处": "Hands the registering function elsewhere",
"把接口对象解构了出来": "Destructures the API object",
"没有找到导出的 register": "No exported register was found",
"本应用不会触发的事件": "Events this app never raises",
"模块": "Module",
"这是插件「{id}」的提问;回答它不会批准任何工具调用或权限。": "This is a question from the plugin \"{id}\"; answering it does not approve any tool call or permission.",
};
@@ -3,6 +3,15 @@
// en_settings and en_kernel were — one screen's worth of wording, read together.
export const EN_WINDOW: Record<string, string> = {
"打开项目": "Open project",
"项目列表的更多操作": "More project list actions",
"项目与会话": "Projects and conversations",
"选择本机文件夹": "Choose a local folder",
"连接与机器": "Connections and machines",
"打开第一个项目": "Open your first project",
"展开所有项目": "Expand all projects",
"收起所有项目": "Collapse all projects",
"搜索时自动展开项目": "Projects expand automatically while searching",
"这台机器的更多操作": "More actions for this machine",
"使用系统默认应用打开": "Open with default application",
"图片加载失败,它可能已被移动、删除或超过大小限制": "The image could not be loaded. It may have been moved, deleted, or exceed the size limit.",
+97 -1
View File
@@ -67,6 +67,27 @@ const SAID: Record<string, string> = {
"workspace.file_invalid": "文件保存请求格式不正确",
"workspace.file_changed": "文件已在打开后被其他操作修改,请重新载入",
"git.history_failed": "无法读取这个仓库的提交历史",
"git.commit_bad_id": "这不是一个提交编号",
"git.commit_unknown": "这个仓库里没有这个提交",
"git.commit_read_failed": "无法读取这个提交",
"discard.bad_request": "丢弃请求的格式不对",
"discard.turn_running": "对话正在运行,它可能正在写这个文件,稍后再丢弃",
"discard.unknown_path": "这个文件没有未提交的改动",
"discard.not_discardable": "只有上次提交里已有的文件,才能丢弃它的改动",
"discard.failed": "丢弃失败了",
"push.bad_request": "推送请求的格式不对",
"push.workspace_untrusted": "这个工作区还没有被你信任,不能从这里推送",
"push.detached_head": "现在不在任何分支上,没有可以推送的分支",
"push.no_upstream": "这个分支还没有对应的远端分支,不能推送",
"push.nothing_to_push": "远端已经有这个分支的全部提交,没有可推送的内容",
"push.protocol_not_allowed": "这个远端用的传输方式不允许推送,只支持 https、ssh 和 git://",
"push.plan_changed": "确认之后仓库又变了,请重新预览再推送",
"push.rejected": "远端有这个分支没有的提交,推送被拒绝(不会强制推送)",
"push.auth_required": "远端需要认证,请先在 git 里配置凭据",
"push.failed": "推送失败了",
"push.git_failed": "推送时 git 出错了",
"git.repo_unknown": "这个仓库不属于当前工作区",
"git.repos_failed": "无法列出这个工作区里的仓库",
"workspace.file_missing": "找不到该文件,它可能已被移动或删除",
"workspace.file_failed": "文件操作失败,请重试",
"workspace.path_outside_tree": "该路径不在当前工作区内",
@@ -142,7 +163,7 @@ const SAID: Record<string, string> = {
"model.not_decision_source": "「{model}」是对话模型,不是决策来源;决策只能选择「模型服务」里添加的决策来源",
"roles.override_not_in_user_config": "「{key}」不在用户配置里,可能已被清除,或来自项目配置",
"roles.search_model_bad_ref": "搜索模型「{model}」的格式应为 服务/模型",
"roles.search_model_not_added": "搜索模型「{model}」所在的连接已被移出",
"roles.search_model_not_added": "搜索模型「{model}」所在的连接还没有添加:先添加这个连接,再选它",
"roles.search_model_removed": "搜索模型「{model}」已不在配置里",
"roles.search_model_unsupported": "搜索模型「{model}」的协议不支持原生搜索,或已关闭搜索",
"roles.search_model_no_credentials": "搜索模型「{model}」所在的连接没有凭据",
@@ -167,6 +188,9 @@ const SAID: Record<string, string> = {
"workspace.limit_reached": "项目列表已到你设置的上限(desktop.max_workspaces),请先移除一个项目再添加",
"provider.bad_model_price": "{model} 的价格必须是不小于 0 的数字,并选择货币",
"provider.model_prices_unlisted": "{model} 不在已选模型中,未保存价格",
"provider.bad_price_source": "{model} 的价格来源必须是 http 或 https 链接,不能含用户名密码或控制字符",
"provider.bad_price_checked": "{model} 的核对日期必须是 YYYY-MM-DD,且不晚于今天(按 UTC)",
"provider.billing_mode_unknown": "计费方式只能是按量计费或订阅等价估算",
"workspace.changing_disabled": "这台服务器不支持切换工作区",
"workspace.worktree_source_missing": "起始分支已不存在,请重新选择分支",
"workspace.worktree_stale": "会话或工作目录已改变,请新建会话后重新选择 worktree",
@@ -187,6 +211,7 @@ const SAID: Record<string, string> = {
"attachment.not_text": "这段内容不是纯文本,不能存为文本文件。",
"attachment.unreadable": "无法读取这个文件:{detail}。它可能被其他程序占用、已被移动,或在云盘里尚未下载。",
"attachment.write_failed": "附件未能保存到工作区的 .reasonix/attachments:{detail}。请检查该目录的写入权限和磁盘空间。",
"attachment.config_unavailable": "无法读取附件上传上限,请修复用户 config.toml 后重试。",
"complete.line_too_long": "该行过长,无法补全",
"stream.unsupported": "该连接不支持流式传输",
"internal.failed": "服务端出现异常,与你的操作无关",
@@ -231,6 +256,7 @@ const SAID: Record<string, string> = {
"editor.no_window": "这个内核没有窗口,打不开本机的编辑器。",
"workspace.not_listed": "这个文件夹不在当前窗口的项目列表里。",
"workspace.folder_missing": "项目文件夹已不在磁盘上。",
"tool.root_missing": "这次操作需要的文件夹已不在磁盘上:可能被移走、改名或删除了,操作没有执行。",
"workspace.locate_no_window": "这个内核不在本机,没法在系统文件管理器中显示它的文件。",
"device.host_only": "这项操作只能在电脑上的窗口里做,已配对的手机做不了。",
"device.host_rejected": "这个地址不是本机共享的地址,请重新扫码。",
@@ -350,6 +376,11 @@ const SAID: Record<string, string> = {
"session.in_use": "该会话正被其他位置占用,请先关闭该处",
// 占用者在另一个进程时,这个窗口里没有可关闭的对象,pid 是唯一可操作的事实。
"session.in_use_by": "该会话被另一个进程占用({host} 上的 pid {pid}),结束它之后再删",
"session.move_open": "该会话正在窗口里打开,先关闭它再移动",
"session.move_held": "该会话还在被写入,稍后再移动",
"session.move_held_by": "该会话被另一个进程占用({host} 上的 pid {pid}),结束它之后再移动",
"session.same_project": "它已经在这个项目里了",
"session.unreadable": "这个会话的记录不完整,不能移动;先让它自己恢复",
"session.bind_failed": "接管该会话失败,请重新打开窗口",
"session.has_open_pane": "该会话仍有打开的面板,请先关闭",
"session.outside_workspace": "该路径不属于任何已知的工作区",
@@ -366,8 +397,17 @@ const SAID: Record<string, string> = {
"remote.not_available": "该内核不负责连接其他机器",
"remote.name_required": "请为该机器填写名称",
"remote.host_required": "请填写要连接的地址",
"phones.unavailable": "这个窗口没有连到账号服务,不能管理手机",
"phones.not_registered": "这台电脑还没有登记到账号:先登录账号并打开远程访问",
"phones.not_found": "找不到这台手机,它可能已经被移除",
"phones.refused": "账号服务拒绝了这个操作,请刷新后再试",
"phones.account_unreachable": "连不上账号服务,请检查网络后重试",
"remote.bad_port": "这不是有效的端口号",
"remote.distro_invalid": "这不是有效的 WSL 发行版名称",
"remote.wsl_unreadable": "读不到已安装的 WSL 发行版",
"remote.kind_unsupported": "这台机器的接入方式与表单不同(SSH 或 WSL),不能这样修改它",
"remote.disabled": "该主机已停用,启用后才能连接",
"remote.unknown_host": "主机 {host} 尚未保存,请先添加该主机再连接",
"remote.has_open_panes": "该机器仍有 {n} 个打开的面板,请先关闭再移除",
// 主机密钥变了没有「仍然连接」这条路:能绕过的警告等于没有警告。
"remote.host_key_changed": "{host} 的主机密钥已变更。可能是该机器重装,也可能存在中间人。记录位于 {file} 第 {line} 行,核实前请勿连接。",
@@ -398,6 +438,13 @@ const SAID: Record<string, string> = {
"mcp.editing_disabled": "这个客户端不能登录 MCP 服务器",
"mcp.signin_declined": "你没有同意前往该登录站点,已取消登录",
"mcp.signin_ineligible": "这个服务器不使用 OAuth 登录",
"observability.editing_disabled": "这个客户端不能修改追踪导出设置",
"observability.endpoint_invalid": "收集器地址必须是带主机名的 http 或 https 地址",
"observability.endpoint_credentials": "收集器地址里不能带用户名、密码、查询串或片段",
"observability.header_invalid": "这不是可用的请求头名称",
"observability.header_env_invalid": "凭据环境变量必须以 OTEL_ 或 REASONIX_OTEL_ 开头",
"observability.save_failed": "追踪导出设置没有保存成功",
"mcp.no_scope_upgrade": "这个服务器没有要求更多权限",
"mcp.signin_busy": "这个服务器已经在登录中",
"mcp.signin_no_host": "服务器给出的登录页没有主机名,已停止",
"mcp.signin_no_browser": "这台机器打不开浏览器",
@@ -410,6 +457,13 @@ const SAID: Record<string, string> = {
// ── 待送达:条目、队列、这份存档各自会拒 ─────────────────────────
"inbox.not_found": "该条已不在待送达队列中",
"tool_call.not_running": "这条调用已经结束了",
"subagent.not_found": "没有找到这个子代理的记录",
"subagent.not_owned": "这个子代理不属于当前会话",
"subagent.read_failed": "读取子代理记录失败:{detail}",
"subagent.not_running": "这个子代理已经不在运行,读不到新的指示",
"subagent.steer_empty": "指示不能是空的",
"subagent.steer_too_large": "这条指示太长了,超过了发给子代理的单条上限",
"subagent.steer_rate_limited": "短时间内发给这个子代理的指示太多了,请稍后再发",
"tool.cancelled_by_user": "你停止了这条调用,它在外部已经做了什么无法确定",
"history.query_required": "请输入要搜索的内容",
"history.search_failed": "对话内容索引暂时无法回答,稍后再试",
@@ -427,6 +481,7 @@ const SAID: Record<string, string> = {
"inbox.schema_readonly": "该待送达由更高版本写入,当前版本只能读取",
"inbox.idempotency_conflict": "该提交标识已被使用,且当时的内容不同",
"inbox.foreign_run": "这份待送达属于另一个运行,暂不能接管",
"inbox.unrecovered": "待送达的正文读不出来,队列内容未知,已暂停等待检查",
// ── 配置文件本身坏了,以及每一个写设置的面板被它挡住时 ─────────
"config.unparsed": "配置文件 {path} 无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复",
@@ -445,12 +500,22 @@ const SAID: Record<string, string> = {
"browser_tools.no_enabled": "本次请求未说明开关状态,未做任何修改",
"subagent_claims.editing_disabled": "这台服务器未开放子代理写入范围设置",
"subagent_claims.save_failed": "子代理写入范围设置未能保存:{detail}",
"subagent_steer.editing_disabled": "这台服务器未开放子代理指示上限设置",
"subagent_steer.save_failed": "子代理指示上限设置未能保存:{detail}",
"subagent_claims.no_choice": "本次请求未说明选择,未做任何修改",
"write_lease.editing_disabled": "这台服务器未开放多会话写入设置",
"write_lease.save_failed": "多会话写入设置未能保存:{detail}",
"plan_approval.editing_disabled": "这台服务器未开放计划批准设置",
"plan_approval.save_failed": "计划批准设置未能保存:{detail}",
"plan_approval.no_choice": "本次请求未说明选择,未做任何修改",
"default_mode.editing_disabled": "这台服务器未开放新对话起始模式设置",
"default_mode.save_failed": "新对话起始模式设置未能保存:{detail}",
"default_mode.no_choice": "本次请求未说明选择,未做任何修改",
"tools.editing_disabled": "这台服务器未开放工具设置",
"tools.save_failed": "工具设置未能保存:{detail}",
"tools.no_choice": "本次请求未说明要改哪个工具,未做任何修改",
"tools.host_required": "这个工具被其他功能需要,不能关闭",
"tool.disabled_by_config": "这个工具已在配置里关闭,本次调用未执行",
"peer.disabled": "会话间消息未开启",
"peer.editing_disabled": "这个客户端不能收发会话间消息",
"peer.not_found": "本机没有叫这个名字的会话",
@@ -463,27 +528,44 @@ const SAID: Record<string, string> = {
"peer.not_held": "这条消息已经处理过了",
"peer.send_failed": "会话间消息没能完成:{detail}",
"peer.invalid_project": "放行的项目必须是一个绝对路径的文件夹",
"peer.invalid_ceiling": "派发上限是 0 或更大的整数,0 表示不限制",
"peer.remember_denied": "只有批准才能记住,拒绝只对这一条生效",
"mods.invalid_budget": "单个钩子的运行时限是 0 到 55000 毫秒,0 表示默认的 10 秒",
"mods.editing_disabled": "这个客户端不能修改 mod 设置",
"mods.withheld": "运行 Claude Code mod 的开关还没有开放,现在不能打开",
"mod.switch_off": "「运行 Claude Code mod」没有打开,所以没有运行任何 mod",
"mod.runtime_missing": "这台电脑上找不到能运行 mod 的 Node 运行时",
"mod.runtime_unsupported": "找到的 Node 运行时版本过低,不能运行这个 mod",
"mod.crashed": "这个 mod 的进程崩溃了,正在按退避重新启动",
"mod.hook_timeout": "它的某个钩子超过了时间预算,进程已被结束;多次发生后会被停用",
"mod.hook_threw": "它的某个钩子抛出了异常",
"mod.unsupported": "它用到了本宿主不支持的功能",
"peer.settings_unwritable": "会话间消息的设置没能保存:{detail}",
"provider.key_origin_changed": "地址换了域名,已保存的密钥不会发过去;请填入这个地址对应的密钥",
"write_lease.no_choice": "本次请求未说明选择,未做任何修改",
"write_lease.bad_wait": "最长等待的分钟数不能是负数",
"write.path_busy": "这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写",
"display_currency.invalid": "不支持这个币种,只能选自动、CNY 或 USD:{detail}",
"display_currency.runtime_changed": "运行时已更换,费用显示币种未保存,请重试",
"display_currency.save_failed": "费用显示币种未能保存:{detail}",
"changed_files.save_failed": "文件改动显示设置未能保存:{detail}",
"feedback_level_notices.save_failed": "等级提示的设置未能保存:{detail}",
"fold_work.save_failed": "折叠中间过程的设置未能保存:{detail}",
"turn_narration.save_failed": "回合间叙述的设置未能保存:{detail}",
"web_access.editing_disabled": "这台服务器没有开启联网搜索与抓取设置的编辑:只有这台电脑上的窗口能改。",
"web_access.save_failed": "联网搜索与抓取的设置未能保存:{detail}",
"web_access.unknown_backend": "“{backend}”不是可选的联网后端,未做任何修改。",
"web_access.probe_failed": "没能测试这个地址:{detail}",
"web_access.bad_searxng_url": "SearXNG 地址必须是带主机名的 http(s) 地址,不能含账号、查询串或片段,未做任何修改。",
"ask_timeout.save_failed": "提问等待时长未能保存:{detail}",
"ask_timeout.out_of_range": "等待时长超出允许范围,未做任何修改:{detail}",
"session_ref.save_failed": "引用历史会话的长度上限未能保存:{detail}",
"session_ref.out_of_range": "长度上限超出允许范围,未做任何修改:{detail}",
"receipt.save_failed": "交付验收卡片的设置未能保存:{detail}",
"progress_watch.save_failed": "无进展设置未能保存:{detail}",
"auto_archive.save_failed": "自动归档设置未能保存:{detail}",
"auto_archive.out_of_range": "天数超出允许范围,未做任何修改:{detail}",
"persona.too_large": "人设内容超出长度上限,未做任何修改:{detail}",
"workspace.untrustable": "主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹",
"workspace.trust_save_failed": "未能记下对此文件夹的信任决定:{detail}",
"progress_watch.out_of_range": "该数值超出允许范围,未做任何修改:{detail}",
@@ -599,6 +681,9 @@ const SAID: Record<string, string> = {
"wallet.unauthorized": "该供应商拒绝了当前密钥,无法读取余额",
"wallet.unreachable": "该供应商的余额接口无响应",
"wallet.unreadable": "无法解析该供应商余额接口返回的内容",
"wallet.format_not_declared": "余额格式未配置:这个地址不是供应商自己的域名,请在连接设置里选择它返回的格式",
"provider.balance_url_invalid": "余额地址必须是 http 或 https 开头的网址",
"provider.balance_format_unknown": "这个余额格式不在可选范围内",
"feedback.invalid": "反馈内容不符合要求,请检查后重试",
"feedback.too_large": "反馈内容过大,请缩短文字或减少截图",
"feedback.rate_limited": "提交太频繁了,请稍后再试",
@@ -612,11 +697,18 @@ const SAID: Record<string, string> = {
"feedback.image_metadata": "截图无法清除其中的元数据,请换一张或先用截图工具重新导出",
"feedback.reply_limit": "这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试",
"feedback.not_replyable": "这份反馈现在不接收回复",
"feedback.not_withdrawable": "这份反馈已经有了结论,不能再撤回",
"feedback.not_found": "找不到这份反馈",
"feedback.challenge_required": "反馈服务要求额外验证,请稍后再试",
"feedback.account_required": "需要先登录账号",
"feedback.link_conflict": "这台电脑已关联到另一个账号",
"feedback.link_limit": "这个账号关联的电脑已达上限",
"feedback.no_install": "这台电脑还没有发过反馈",
"feedback.board_name_invalid": "名字需要 2 到 30 个字符:字母、数字、空格、下划线或连字符,不能含 @ 或网址",
"feedback.board_name_reserved": "这个名字是保留的,会让人误以为是维护者,换一个吧",
"feedback.board_name_taken": "已经有人用这个名字了",
"feedback.board_delisted": "这个账号已被维护者从榜单移除,暂时不能再加入",
"feedback.board_no_link": "先把这台电脑关联到账号,才能加入榜单",
// ── 远程连接停下来问的那一句 ───────────────────────────────────
"ask.not_found": "不存在该待回答的问题",
@@ -637,6 +729,10 @@ const SAID: Record<string, string> = {
"skills.update_not_local": "这个技能不是从本机文件夹复制安装的,无法从源文件夹更新(链接或远程来源请重新安装)",
"skills.update_source_missing": "找不到来源文件夹,可能被移动或删除了",
"skills.update_plan_stale": "自你查看预览后,来源文件夹或已安装的副本又变了。请重新查看再更新",
"skill.not_removable": "这个技能不是你自己的文件,不能在这里卸载(内置技能或由包提供的技能,随包卸载)",
"skill.changed": "这个技能自你打开列表后已经变了,没有删除任何内容。请重新读取后再试",
"skill.remove_failed": "卸载技能失败:{detail}",
"skill.other_project": "其他项目的技能不能在这里卸载",
"skills.update_needs_confirm": "已安装的副本被改过,更新会覆盖这些改动,需要你确认",
"skills.update_too_large": "这个技能太大,无法更新(文件数或总大小超过上限)",
"skills.update_failed": "更新没有完成,已安装的副本保持原样:{detail}",
+145 -1
View File
@@ -106,12 +106,24 @@ export interface SubagentClaimsSettings {
path: string;
}
// The bounds on guidance sent to a running sub-agent, as the user file records
// them: bytes in one message and messages per minute to one sub-agent, zero
// lifting a bound. A project file cannot set them.
export interface SubagentSteerSettings {
maxBytes: number;
perMinute: number;
defaultMaxBytes: number;
defaultPerMinute: number;
path: string;
}
// How this machine's sessions hold the workspace write lease against one
// another, as the user file records it. A project file cannot set it. A running
// session keeps the lease it was built with, so a change reaches sessions
// opened after it.
export interface WriteLeaseSettings {
optimistic: boolean;
maxWaitMinutes: number;
path: string;
}
@@ -122,6 +134,30 @@ export interface PlanApprovalSettings {
path: string;
}
// Whether a new conversation starts in plan mode, as the user file records it.
// A project file cannot set it.
export interface DefaultModeSettings {
plan: boolean;
path: string;
}
// One tool on the settings page: whether the user removed it, and which host
// feature needs it when it cannot be removed.
export interface ToolSetting {
name: string;
description: string;
disabled: boolean;
requiredBy?: string;
byProject?: boolean;
}
// The tools a request carries plus the ones the user removed, as the user file
// records them.
export interface ToolSettings {
tools: ToolSetting[];
path: string;
}
export interface PeerView {
key: string;
title: string;
@@ -168,6 +204,9 @@ export interface PeersSettings {
enabled: boolean;
dispatch: boolean;
release_projects: PeerProject[];
// Ceilings on chained and simultaneous dispatches; 0 removes a ceiling.
max_dispatch_depth: number;
max_dispatched_sessions: number;
path: string;
applies: "next_turn";
pending: boolean;
@@ -180,6 +219,8 @@ export interface PeersSettingsEdit {
enabled?: boolean;
dispatch?: boolean;
release_projects?: string[];
max_dispatch_depth?: number;
max_dispatched_sessions?: number;
}
export type PeerRemember = "project" | "none";
@@ -196,6 +237,26 @@ export interface AutoArchiveSettings {
path: string;
}
// The user's system persona. The kernel serves the built-in starting points
// with the settings so the page never keeps a second copy of the prompt text.
export interface PersonaPreset {
id: string;
prefix: string;
suffix: string;
include_runtime_context: boolean;
}
export interface PersonaSettings {
prefix: string;
suffix: string;
include_runtime_context: boolean;
path: string;
presets: PersonaPreset[];
}
/** The three fields a save writes; path and presets are the kernel's answer. */
export type PersonaDraft = Pick<PersonaSettings, "prefix" | "suffix" | "include_runtime_context">;
export type DisplayCurrencyMode = "auto" | "CNY" | "USD";
// Which currency costs are shown in; the user file holds it.
@@ -240,10 +301,41 @@ export interface WebAccessSettings {
known_fetch_backends: string[];
paid_backends: string[];
deepseek_api_ready: boolean;
video_url_passthrough: boolean;
path: string;
}
export type WebAccessPatch = Partial<Pick<WebAccessSettings, "search_backends" | "searxng_url" | "search_paid_allowed" | "fetch_backends" | "fetch_paid_allowed">>;
// What one test search to a SearXNG address showed. code is empty when it answered.
export interface SearxngProbe {
ok: boolean;
code?: string;
status?: number;
}
export type WebAccessPatch = Partial<Pick<WebAccessSettings, "search_backends" | "searxng_url" | "search_paid_allowed" | "fetch_backends" | "fetch_paid_allowed" | "video_url_passthrough">>;
// The opt-in OpenTelemetry trace export. headerEnvSet says the named variable
// has a value now; the value itself is never read back. A save reaches sessions
// opened afterwards, not the ones already running.
export interface ObservabilitySettings {
enabled: boolean;
endpoint: string;
headerName: string;
headerEnv: string;
serviceName: string;
headerEnvSet: boolean;
path: string;
applies: string;
}
export type ObservabilityPatch = Partial<Pick<ObservabilitySettings, "enabled" | "endpoint" | "headerName" | "headerEnv" | "serviceName">>;
// Whether the person is told when their feedback level goes up; the user file
// holds it, and it starts off.
export interface FeedbackLevelNotices {
enabled: boolean;
path: string;
}
export interface ChangedFilesSettings {
show: boolean;
@@ -264,6 +356,16 @@ export interface AskTimeoutSettings {
path: string;
}
// How many tokens one referenced past conversation may add to a message; the
// user file holds it.
export interface SessionRefSettings {
tokens: number;
defaultTokens: number;
minTokens: number;
maxTokens: number;
path: string;
}
// Whether the end-of-turn delivery receipt is drawn as a card; the user file
// holds it, so the terminal reads the same answer.
export interface ReceiptSettings {
@@ -279,3 +381,45 @@ export interface ProgressWatchSettings {
defaultTokenMultiple: number;
path: string;
}
/** Where a Claude Code mod's hooks module stands in this session. */
export type ModState = "inactive" | "starting" | "running" | "restarting" | "disabled";
export interface ModRuntime {
// "electron" for Studio's own binary in Node mode, "node" for one found on PATH.
kind: string;
path: string;
version: string;
}
/** One installed mod. `code` is the typed cause when it is not running
* (mod.switch_off, mod.runtime_missing, mod.crashed, ...); a frontend maps it
* to its own words and never reads `detail` to decide anything. */
export interface ModStatus {
plugin: string;
state: ModState;
code?: string;
detail?: string;
restarts: number;
runtime?: ModRuntime;
// Literal event names the module registered; `unserved` are the ones this host never raises.
events?: string[];
unserved?: string[];
}
/** The user file's [mods] table. Turning `enabled` on is Elevated: the module
* of an installed mod then runs on this machine with the user's trust. */
export interface ModsSettings {
enabled: boolean;
// One hook's own running time in milliseconds; 0 keeps Claude Code's 10000.
hook_budget_ms: number;
path: string;
// False until the switch can be turned on; the row is not drawn then.
available: boolean;
applies: "next_session";
}
export interface ModsSettingsEdit {
enabled?: boolean;
hook_budget_ms?: number;
}
@@ -370,6 +370,22 @@ describe("a request too large for one message", () => {
expect(socket.sent).toHaveLength(0);
});
it("stops reading an oversized upload stream at the relay budget", async () => {
const { conn, socket } = connection(["upload"]);
const link = new RemoteLink(conn, async () => conn, (end) => ends.push(end));
let chunks = 0;
const canceled = vi.fn();
const body = new ReadableStream({
pull(controller) { chunks++; controller.enqueue(new Uint8Array(1024 * 1024)); },
cancel: canceled,
});
const request = new Request(`${location.origin}/rt/one/attachments/upload?name=large.apk`, { method: "POST", body, duplex: "half" } as RequestInit);
await expect(link.fetch(request)).rejects.toThrow(/16 MB/);
expect(chunks).toBeLessThanOrEqual(18);
expect(canceled).toHaveBeenCalledOnce();
expect(socket.sent).toHaveLength(0);
});
it("starts again from the first part, under the same id, when the link is lost midway", async () => {
const first = connection(["replay", "upload"], "desktop-1");
const second = connection(["replay", "upload"], "desktop-1");
+32 -1
View File
@@ -96,6 +96,37 @@ const UPLOAD_PART = 24 * 1024;
const MAX_UPLOAD = 16 * 1024 * 1024;
const BUFFER_HIGH = 1024 * 1024;
async function requestBytes(request: Request): Promise<Uint8Array> {
const uploadSignal: AbortSignal = request.signal;
uploadSignal.throwIfAborted();
if (!request.body) return new Uint8Array();
const reader = request.body.getReader();
const abort = () => { void reader.cancel(uploadSignal.reason); };
uploadSignal.addEventListener("abort", abort, { once: true });
const chunks: Uint8Array[] = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
uploadSignal.throwIfAborted();
if (done) break;
size += value.byteLength;
if (size > MAX_UPLOAD) {
await reader.cancel();
throw new Error(t("这次发送的内容太大:远程控制最多只能传 {mb} MB。请换一个更小的文件。", { mb: MAX_UPLOAD / (1024 * 1024) }));
}
chunks.push(value);
}
} finally {
uploadSignal.removeEventListener("abort", abort);
reader.releaseLock();
}
const body = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.length; }
return body;
}
const DEFAULTS: RemoteLinkOptions = {
requestTimeoutMs: 30_000,
retryDelaysMs: [500, 1_000, 2_000, 4_000, 8_000, 15_000],
@@ -153,7 +184,7 @@ export class RemoteLink {
async fetch(request: Request): Promise<Response> {
if (this.ended) throw new Error(t("远程连接已断开,请重新连接。"));
const id = crypto.randomUUID();
const body = new Uint8Array(await request.arrayBuffer());
const body = await requestBytes(request);
const path = request.url.slice(location.origin.length);
const room = maxRequestBody(path);
const method = request.method;
@@ -6,6 +6,9 @@ import { classifyHandshakeFailure, failureView, retryAfterSeconds } from "./clou
import { linkCodec, RemoteLink, RemoteLinkError, type RemoteConnection, type RemoteEnd } from "./cloud_link";
const ACCOUNT = (import.meta.env.VITE_ACCOUNTS_API || "https://id.reasonix.io").replace(/\/$/, "");
export const accountOrigin = ACCOUNT;
// The computer this page is controlling; null on the desktop's own window.
export const remoteDeviceId = () => new URLSearchParams(location.search).get("device")?.trim() || null;
const RELAY = (import.meta.env.VITE_REMOTE_GATEWAY || "wss://remote.reasonix.io").replace(/\/$/, "");
const REMOTE_HOME = import.meta.env.VITE_REMOTE_HOME || "https://reasonix.io/remote/";
const HANDSHAKE_TIMEOUT_MS = 30_000;
+50 -2
View File
@@ -9,10 +9,10 @@ export const FEEDBACK_CATEGORIES: readonly FeedbackCategory[] = ["bug", "idea",
// What a person may see. A report waiting for triage reads as received; one
// the maintainers declined reads as closed, with no reason.
export type FeedbackStatus =
| "received" | "needs_info" | "answered" | "recorded" | "in_progress" | "fixed" | "wontfix" | "duplicate" | "closed";
| "received" | "needs_info" | "answered" | "recorded" | "in_progress" | "fixed" | "wontfix" | "duplicate" | "closed" | "withdrawn";
export const FEEDBACK_STATUSES: readonly FeedbackStatus[] = [
"received", "needs_info", "answered", "recorded", "in_progress", "fixed", "wontfix", "duplicate", "closed",
"received", "needs_info", "answered", "recorded", "in_progress", "fixed", "wontfix", "duplicate", "closed", "withdrawn",
];
export const isUnderReview = (item: Pick<FeedbackItem, "status" | "underReview" | "statusUnavailable">): boolean =>
@@ -27,6 +27,12 @@ export const FEEDBACK_REPLYABLE: readonly FeedbackStatus[] = ["needs_info", "ans
export const canReplyTo = (item: Pick<FeedbackItem, "status" | "underReview" | "statusUnavailable">): boolean =>
!item.statusUnavailable && (FEEDBACK_REPLYABLE.includes(item.status) || (item.status === "received" && !isUnderReview(item)));
// A report can be taken back until it has an outcome; the service has the last word.
export const FEEDBACK_WITHDRAWABLE: readonly FeedbackStatus[] = ["received", "needs_info", "answered", "recorded", "in_progress"];
export const canWithdraw = (item: Pick<FeedbackItem, "status" | "statusUnavailable">): boolean =>
!item.statusUnavailable && FEEDBACK_WITHDRAWABLE.includes(item.status);
export interface FeedbackEnvInfo {
version: string;
commit: string;
@@ -100,6 +106,8 @@ export interface FeedbackReply {
}
export interface FeedbackItem {
// A report another device of the same account filed; read marks stay on this computer.
otherInstall?: boolean;
receipt: string;
category: FeedbackCategory;
titleSnippet: string;
@@ -162,6 +170,13 @@ export interface FeedbackMine {
unread: number;
hasNew: boolean;
profile?: FeedbackProfile | null;
// A level the service now states that this machine has not told the person about.
promotion?: FeedbackPromotion | null;
}
export interface FeedbackPromotion {
from: number;
to: number;
}
// One install an account holds, as the service lists it: the short id, when it
@@ -185,6 +200,37 @@ export const FEEDBACK_LIMIT = {
globalBurst: "global_burst",
} as const;
// One person on the contribution board: the name they chose, how many of their
// suggestions shipped, where, and (in a release view only) their place.
export interface FeedbackBoardEntry {
displayName: string;
credits: number;
rank: number | null;
releases: FeedbackBoardRelease[];
}
export interface FeedbackBoardRelease {
version: string;
count: number;
}
// The public board. available is false where the service does not serve one, which
// hides the section rather than failing it.
export interface FeedbackBoard {
available: boolean;
view: "all" | "release";
release: string | null;
entries: FeedbackBoardEntry[];
}
// What the signed-in person has chosen about being on the board.
export interface FeedbackBoardMe {
listed: boolean;
displayName: string | null;
// A maintainer removed this account; it cannot list itself again until restored.
delisted: boolean;
}
export const FEEDBACK_REPO_ISSUES = "https://github.com/esengine/DeepSeek-Reasonix/issues/";
export const FEEDBACK_NEXT_VERSION = "next";
@@ -200,6 +246,8 @@ export const FEEDBACK_CODE = {
badToken: "feedback.bad_token",
replyLimit: "feedback.reply_limit",
notReplyable: "feedback.not_replyable",
notWithdrawable: "feedback.not_withdrawable",
notFound: "feedback.not_found",
challengeRequired: "feedback.challenge_required",
accountRequired: "feedback.account_required",
linkConflict: "feedback.link_conflict",
@@ -13,11 +13,36 @@ function answer(status: number, body: string) {
}));
}
it("keeps local project moves and host-qualified pin/archive calls on their owning routes", async () => {
const fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 });
vi.stubGlobal("fetch", fetch);
try {
const hub = new SseHub();
await hub.pinSession("/same/session.jsonl", true);
await hub.pinSession("/same/session.jsonl", false, "gpu box");
await hub.archiveSession("/same/session.jsonl", true);
await hub.archiveSession("/same/session.jsonl", false, "gpu box");
await hub.moveSessionTo("/same/session.jsonl", "/other-project");
expect(fetch.mock.calls.map(([path, init]) => [path, JSON.parse(init.body)])).toEqual([
["/tree/sessions/pin", { path: "/same/session.jsonl", pinned: true }],
["/remotes/gpu%20box/sessions/pin", { path: "/same/session.jsonl", pinned: false }],
["/tree/sessions/archive", { path: "/same/session.jsonl", archived: true }],
["/remotes/gpu%20box/sessions/archive", { path: "/same/session.jsonl", archived: false }],
["/tree/sessions/move-to", { path: "/same/session.jsonl", target: "/other-project" }],
]);
} finally { vi.unstubAllGlobals(); }
});
// The same defect SseHttp was fixed for, still standing in the hub port: asking
// for JSON first threw away every answer that was not an envelope, and what
// reached the window was "this request did not reach the kernel (HTTP 502)" —
// wrong twice over, because it had, and the kernel had explained itself.
describe("a failed hub call", () => {
it("surfaces a remote book conflict instead of treating it as an unread book", async () => {
answer(409, JSON.stringify({ code: "remote.serve_provider_mismatch", error: "provider setting conflict", params: { host: "gpu-box" } }));
await expect(new SseHub().remoteTree("gpu-box")).rejects.toMatchObject({ status: 409,
reason: { code: "remote.serve_provider_mismatch", params: { host: "gpu-box" } } });
});
it("carries a plain-text reason through", async () => {
answer(502, "remote /runtimes: 400 Bad Request: workspace /srv/data is not a directory");
const err = (await new SseHub().openRemote({ host: "gpu-box" }).catch((e) => e)) as HttpError;
+40 -11
View File
@@ -1,7 +1,7 @@
import { watchTree } from "./tree_events";
import { HttpError, type AgentPort } from "./port";
import type { RemoteAsk, RemoteHost, RemoteHostEdit, RemoteListing, RemoteProbe } from "./remote";
import type { CloudShareOffer, DeviceSelf, ShareOffer, SharePort, ShareStatus } from "./share";
import type { RemoteAsk, RemoteHost, RemoteHostEdit, RemoteListing, RemoteProbe, WSLDistros } from "./remote";
import type { CloudShareOffer, DeviceSelf, PhoneAction, PhoneList, ShareOffer, SharePort, ShareStatus } from "./share";
import { SsePort } from "./sse";
import { host } from "./host";
@@ -177,12 +177,15 @@ export interface HubPort extends SharePort {
/** Open a listed project in one of those programs. */
openWith(path: string, target: string): Promise<void>;
moveWorkspace(path: string, direction: -1 | 1): Promise<void>;
moveSession(path: string, direction: -1 | 1): Promise<void>;
/** Drop a project before another remembered one, or at the end when `before` is empty. */
placeWorkspace(path: string, before: string): Promise<void>;
removeSession(path: string): Promise<void>;
archiveSession(path: string, archived: boolean): Promise<void>;
/** The conversation moves into the named project; originalRemoved is false when the copy arrived but the original could not be taken away. */
moveSessionTo(path: string, target: string): Promise<{ path: string; originalRemoved: boolean }>;
archiveSession(path: string, archived: boolean, host?: string): Promise<void>;
// Tells the kernel which conversations automatic archiving must leave alone.
pinSession(path: string, pinned: boolean): Promise<void>;
pinSession(path: string, pinned: boolean, host?: string): Promise<void>;
// Adds pins made before the kernel kept them. The first call also opens the
// gate: until then the kernel archives nothing on its own.
syncPins(paths: string[]): Promise<void>;
@@ -221,6 +224,8 @@ export interface HubPort extends SharePort {
// taken yet. Filling it by hand when the addresses are written down next
// door is the step people skip.
remoteCandidates(): Promise<string[]>;
// The WSL distributions on this machine, for the choice of one as a host.
wslDistros(): Promise<WSLDistros>;
// Questions a connect stopped for. Nothing arrives here in a browser: the
// kernel that would ask is the one this build cannot reach.
onRemoteAsk(cb: (ask: RemoteAsk) => void): () => void;
@@ -367,20 +372,28 @@ export class SseHub implements HubPort {
await this.post<void>("/tree/workspaces/move", { path, direction });
}
async moveSession(path: string, direction: -1 | 1) {
await this.post<void>("/tree/sessions/move", { path, direction });
}
async placeWorkspace(path: string, before: string) {
await this.post<void>("/tree/workspaces/move", { path, before });
}
async moveSessionTo(path: string, target: string) {
return this.post<{ path: string; originalRemoved: boolean }>("/tree/sessions/move-to", { path, target });
}
async removeSession(path: string) {
await this.post<void>("/tree/sessions/remove", { path });
}
async archiveSession(path: string, archived: boolean) {
await this.post<void>("/tree/sessions/archive", { path, archived });
async archiveSession(path: string, archived: boolean, host?: string) {
await this.post<void>(host ? `/remotes/${encodeURIComponent(host)}/sessions/archive` : "/tree/sessions/archive", { path, archived });
}
async pinSession(path: string, pinned: boolean) {
await this.post<void>("/tree/sessions/pin", { path, pinned });
async pinSession(path: string, pinned: boolean, host?: string) {
await this.post<void>(host ? `/remotes/${encodeURIComponent(host)}/sessions/pin` : "/tree/sessions/pin", { path, pinned });
}
async syncPins(paths: string[]) {
@@ -429,9 +442,6 @@ export class SseHub implements HubPort {
async remoteTree(host: string) {
const path = `/remotes/${encodeURIComponent(host)}/tree`;
const res = await fetch(path, { credentials: "same-origin" });
// Nothing open on that machine yet, which is a step to take rather than a
// failure to report.
if (res.status === 409) return null;
if (!res.ok) await SseHub.fail(path, res);
return (await res.json()) as TreeWorkspace[];
}
@@ -469,6 +479,10 @@ export class SseHub implements HubPort {
return this.get<string[]>("/remotes/candidates");
}
wslDistros() {
return this.get<WSLDistros>("/remotes/wsl");
}
// The question is state the kernel holds, and this is how it is found. No
// stream carries it: it exists only while a dial this client started is still
// waiting, and that is a thing the client already knows.
@@ -609,6 +623,21 @@ export class SseHub implements HubPort {
return this.post<ShareStatus>("/share/revoke", { id });
}
async sharePhones() {
const res = await fetch("/share/phones", { credentials: "same-origin" });
if (res.status === 404) return null;
if (!res.ok) await SseHub.fail("/share/phones", res);
return (await res.json()) as PhoneList;
}
resolvePhone(id: string, action: PhoneAction, replaceId?: string) {
return this.post<PhoneList>("/share/phones/resolve", { id, action, ...(replaceId ? { replaceId } : {}) });
}
revokePhone(id: string) {
return this.post<PhoneList>("/share/phones/revoke", { id });
}
async device() {
const res = await fetch("/device", { credentials: "same-origin" });
if (res.status === 404) return null;
+3
View File
@@ -28,6 +28,9 @@ export interface McpEntry {
launch?: string;
// Why a pending server waits for the user, as the kernel's code.
pendingReason?: string;
// What a signed-in server asked to be allowed, in its own words. Only the
// user grants it, by clicking; nothing here reads the scope names.
scopeUpgrade?: string[];
// Tools this connection withheld from the model because their definition is
// not the one the user approved. heldDigest names exactly that set, and
// approving it hands the digest back so what is approved is what was shown.
+6 -3
View File
@@ -1,3 +1,4 @@
import { sessionCompletion } from "./mock_sessions";
import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface";
import type { JobOutput, PlanAction } from "./session";
import { approvalKind } from "./mock_approval";
@@ -327,6 +328,8 @@ export class MockPort extends MockWorkspace implements AgentPort {
})).filter((it) => it.insert !== line.slice(at, to));
return { kind: "ref", from: at, to, query: frag.slice(dir.length), items };
}
const hash = sessionCompletion(line, cursor);
if (hash) return hash;
if (line.startsWith("/") && !/\s/.test(line)) {
const q = line.toLowerCase();
const items = [...this.builtins, ...this.commands].filter((it) => it.label.toLowerCase().startsWith(q));
@@ -356,8 +359,8 @@ export class MockPort extends MockWorkspace implements AgentPort {
async markSessionViewed() {}
async status() {
return { ...this.state };
async status(): Promise<SessionStatus> {
return { ...this.state, ...(location.search.includes("tariff") && { tariff: { peak: !location.search.includes("tariff=off"), changesAt: new Date(Date.now() + 130 * 60_000).toISOString() } }) };
}
async balance(): Promise<WalletReading | null> {
@@ -572,7 +575,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
// The fixture has a window with an icon, because the state worth designing
// is the one where both switches mean something.
private notifications: NotifyPrefs = { enabled: false, turnDone: true, approval: true, ask: true, feedbackReply: true, diskLow: true, sound: false };
private notifications: NotifyPrefs = { enabled: false, turnDone: true, approval: true, ask: true, feedbackReply: true, diskLow: true, sound: false, phone: { enabled: false, approval: false, input: false, error: false } };
async notifyPrefs(): Promise<NotifyPrefs | null> {
return { ...this.notifications };
+140 -7
View File
@@ -1,7 +1,9 @@
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings, UpdateMode } from "./port";
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, ToolSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings, UpdateMode } from "./port";
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FeedbackLevelNotices, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
import { mockMods } from "./mock_mods";
import { HttpError } from "./http_error";
import { MockShell } from "./mock_shell";
import type { PersonaDraft, PersonaSettings } from "./boundary";
// The boundary half of the fixture: what the agent is refused outright, and how
// far an approved write reaches. It carries one rule of each kind already, so
@@ -15,6 +17,9 @@ function effectiveBash(s: SandboxSettings): string {
return s.bash.trim() === "off" ? "off" : "enforce";
}
// One file per machine, so every port of the demo reads the same answer.
let levelNotices: FeedbackLevelNotices = { enabled: false, path: "/Users/you/.reasonix/config.toml" };
export class MockBoundary extends MockShell {
async decideWorkspaceTrust(_trust: "trusted" | "declined") {}
// One interrupted barrier and one that a later turn took over, so the card
@@ -148,6 +153,17 @@ export class MockBoundary extends MockShell {
return { ...this.claims };
}
private steerLimits: SubagentSteerSettings = { maxBytes: 16384, perMinute: 30, defaultMaxBytes: 16384, defaultPerMinute: 30, path: "/Users/you/.reasonix/config.toml" };
async subagentSteerSettings(): Promise<SubagentSteerSettings> {
return { ...this.steerLimits };
}
async saveSubagentSteerSettings(maxBytes: number, perMinute: number): Promise<SubagentSteerSettings> {
this.steerLimits = { ...this.steerLimits, maxBytes, perMinute };
return { ...this.steerLimits };
}
private plans: PlanApprovalSettings = { auto: false, path: "/Users/you/.reasonix/config.toml" };
async planApproval(): Promise<PlanApprovalSettings> {
@@ -159,14 +175,48 @@ export class MockBoundary extends MockShell {
return { ...this.plans };
}
private lease: WriteLeaseSettings = { optimistic: false, path: "/Users/you/.reasonix/config.toml" };
private modes: DefaultModeSettings = { plan: false, path: "/Users/you/.reasonix/config.toml" };
async defaultMode(): Promise<DefaultModeSettings> {
return { ...this.modes };
}
async saveDefaultMode(plan: boolean): Promise<DefaultModeSettings> {
this.modes = { ...this.modes, plan };
return { ...this.modes };
}
private toolList: ToolSettings = {
tools: [
{ name: "bash", description: "Run a shell command in the workspace.", disabled: false },
{ name: "read_file", description: "Read a file, or a line range of it.", disabled: false },
{ name: "recall", description: "Search what earlier turns folded away.", disabled: false },
{ name: "todo_write", description: "Keep a checklist for a multi-step task.", disabled: false, requiredBy: "plan_steps" },
{ name: "web_search", description: "Search the web.", disabled: true, byProject: true },
{ name: "update_goal", description: "Report a goal as complete, blocked or stopped.", disabled: false, requiredBy: "goal_mode" },
{ name: "use_capability", description: "Reach a deferred MCP tool.", disabled: false, requiredBy: "deferred_mcp_tools" },
{ name: "web_fetch", description: "Fetch a web page.", disabled: false },
],
path: "/Users/you/.reasonix/config.toml",
};
async toolSettings(): Promise<ToolSettings> {
return structuredClone(this.toolList);
}
async saveToolDisabled(name: string, disabled: boolean): Promise<ToolSettings> {
this.toolList = { ...this.toolList, tools: this.toolList.tools.map((x) => (x.name === name ? { ...x, disabled } : x)) };
return structuredClone(this.toolList);
}
private lease: WriteLeaseSettings = { optimistic: false, maxWaitMinutes: 0, path: "/Users/you/.reasonix/config.toml" };
async writeLease(): Promise<WriteLeaseSettings> {
return { ...this.lease };
}
async saveWriteLease(optimistic: boolean): Promise<WriteLeaseSettings> {
this.lease = { ...this.lease, optimistic };
async saveWriteLease(edit: { optimistic?: boolean; maxWaitMinutes?: number }): Promise<WriteLeaseSettings> {
this.lease = { ...this.lease, ...edit };
return { ...this.lease };
}
@@ -186,7 +236,23 @@ export class MockBoundary extends MockShell {
this.peerState = { ...this.peerState, held: this.peerState.held.filter((h) => h.id !== id) };
}
protected peerSettings: PeersSettings = { enabled: false, dispatch: false, release_projects: [], path: "/Users/you/.reasonix/config.toml", applies: "next_turn", pending: false, available: true };
protected modConfig: ModsSettings = { enabled: false, hook_budget_ms: 0, path: "/Users/you/.reasonix/config.toml", available: false, applies: "next_session" };
async modsSettings(): Promise<ModsSettings> {
return { ...this.modConfig };
}
async saveModsSettings(edit: ModsSettingsEdit): Promise<ModsSettings> {
if (edit.hook_budget_ms !== undefined && (edit.hook_budget_ms < 0 || edit.hook_budget_ms > 55000)) throw new Error("a hook budget is 0 to 55000 milliseconds");
this.modConfig = { ...this.modConfig, enabled: edit.enabled ?? this.modConfig.enabled, hook_budget_ms: edit.hook_budget_ms ?? this.modConfig.hook_budget_ms };
return this.modsSettings();
}
async mods(): Promise<ModStatus[]> {
return mockMods(this.modConfig.enabled);
}
protected peerSettings: PeersSettings = { enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, path: "/Users/you/.reasonix/config.toml", applies: "next_turn", pending: false, available: true };
async peersSettings(): Promise<PeersSettings> {
return { ...this.peerSettings, release_projects: [...this.peerSettings.release_projects] };
@@ -198,6 +264,8 @@ export class MockBoundary extends MockShell {
...this.peerSettings,
enabled: edit.enabled ?? this.peerSettings.enabled,
dispatch: edit.dispatch ?? this.peerSettings.dispatch,
max_dispatch_depth: edit.max_dispatch_depth ?? this.peerSettings.max_dispatch_depth,
max_dispatched_sessions: edit.max_dispatched_sessions ?? this.peerSettings.max_dispatched_sessions,
release_projects: keep ? this.peerSettings.release_projects.filter((p) => keep.includes(p.path)) : this.peerSettings.release_projects,
pending: true,
};
@@ -215,6 +283,32 @@ export class MockBoundary extends MockShell {
this.archive = { ...this.archive, ...s };
return { ...this.archive };
}
private personaState: PersonaSettings = {
prefix: "", suffix: "", include_runtime_context: true,
path: "/Users/you/.reasonix/config.toml",
presets: [
{
id: "engineer",
prefix: "You are a rigorous staff engineer. Verify before claiming, make the smallest correct change, and state the residual risk.",
suffix: "", include_runtime_context: true,
},
{
id: "concise",
prefix: "",
suffix: "Be terse. Skip the preamble; prefer code and bullets.",
include_runtime_context: false,
},
],
};
async persona(): Promise<PersonaSettings> {
return { ...this.personaState, presets: this.personaState.presets.map((p) => ({ ...p })) };
}
async savePersonaSettings(s: PersonaDraft): Promise<PersonaSettings> {
this.personaState = { ...this.personaState, ...s };
return this.persona();
}
private currency: DisplayCurrencySettings = { mode: "auto", path: "/Users/you/.reasonix/config.toml", unconverted: [] };
@@ -238,6 +332,15 @@ export class MockBoundary extends MockShell {
return { ...this.changedFiles };
}
async feedbackLevelNotices(): Promise<FeedbackLevelNotices> {
return { ...levelNotices };
}
async saveFeedbackLevelNotices(enabled: boolean): Promise<FeedbackLevelNotices> {
levelNotices = { ...levelNotices, enabled };
return { ...levelNotices };
}
private foldWork: FoldWorkSettings = { fold: false, path: "/Users/you/.reasonix/config.toml" };
async foldWorkSettings(): Promise<FoldWorkSettings> {
@@ -255,10 +358,24 @@ export class MockBoundary extends MockShell {
return { ...this.narration };
}
private observability: ObservabilitySettings = {
enabled: false, endpoint: "", headerName: "", headerEnv: "", serviceName: "", headerEnvSet: false,
path: "/Users/you/.reasonix/config.toml", applies: "next_session",
};
async observabilitySettings(): Promise<ObservabilitySettings> {
return { ...this.observability };
}
async saveObservabilitySettings(patch: ObservabilityPatch): Promise<ObservabilitySettings> {
this.observability = { ...this.observability, ...patch };
return { ...this.observability };
}
private webAccess: WebAccessSettings = {
search_backends: [], searxng_url: "", search_paid_allowed: [], fetch_backends: [], fetch_paid_allowed: [],
known_search_backends: ["searxng", "deepseek_api"], known_fetch_backends: ["local", "deepseek_api"],
paid_backends: ["deepseek_api"], deepseek_api_ready: false, path: "/Users/you/.reasonix/config.toml",
paid_backends: ["deepseek_api"], deepseek_api_ready: false, video_url_passthrough: false, path: "/Users/you/.reasonix/config.toml",
};
async webAccessSettings(): Promise<WebAccessSettings> {
@@ -270,6 +387,10 @@ export class MockBoundary extends MockShell {
return { ...this.webAccess };
}
async probeSearxng(): Promise<SearxngProbe> {
return this.webAccess.searxng_url.trim() ? { ok: true } : { ok: false, code: "search.searxng_unreachable" };
}
async saveFoldWorkSettings(fold: boolean): Promise<FoldWorkSettings> {
this.foldWork = { ...this.foldWork, fold };
return { ...this.foldWork };
@@ -291,6 +412,18 @@ export class MockBoundary extends MockShell {
this.ask = { ...this.ask, seconds };
return { ...this.ask };
}
private sessionRef: SessionRefSettings = { tokens: 8000, defaultTokens: 8000, minTokens: 500, maxTokens: 200000, path: "/Users/you/.reasonix/config.toml" };
async sessionRefSettings(): Promise<SessionRefSettings> {
return { ...this.sessionRef };
}
async saveSessionRefSettings(tokens: number): Promise<SessionRefSettings> {
if (tokens !== 0 && (tokens < this.sessionRef.minTokens || tokens > this.sessionRef.maxTokens)) throw new Error("session reference limit out of range");
this.sessionRef = { ...this.sessionRef, tokens: tokens || this.sessionRef.defaultTokens };
return { ...this.sessionRef };
}
private receipt: ReceiptSettings = { show: false, path: "/Users/you/.reasonix/config.toml" };
async receiptSettings(): Promise<ReceiptSettings> {
+15 -4
View File
@@ -1,5 +1,6 @@
import { download } from "./download";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, ScopeLayer, SkillCatalog, SkillEntry, SkillUpdatePlan } from "./port";
import { mockModPlan } from "./mock_mods";
import { HttpError, type PluginExport, type PluginInstallRequest, type PluginPackage, type PluginPlan, type ScopeLayer, type SkillCatalog, type SkillEntry, type SkillUpdatePlan } from "./port";
import { MockMarket } from "./mock_market";
// The extension half of the fixture: skills, and the packages that bring them.
@@ -10,7 +11,7 @@ export class MockExtensions extends MockMarket {
private skillList: SkillEntry[] = [
{
name: "review", slashName: "review", description: "复核这一轮改动,给出严重度分级",
scope: "project", path: ".reasonix/skills/review/SKILL.md", subagent: true, enabled: true,
scope: "project", path: ".reasonix/skills/review/SKILL.md", removes: ".reasonix/skills/review", removesFolder: true, subagent: true, enabled: true,
},
{ name: "init", slashName: "init", description: "为这个仓库生成一份项目说明", scope: "builtin", enabled: true },
{
@@ -21,12 +22,12 @@ export class MockExtensions extends MockMarket {
// row the old list could not show at all.
{
name: "release-notes", description: "从提交历史起草发布说明",
scope: "global", path: "~/.reasonix/skills/release-notes/SKILL.md", enabled: false,
scope: "global", path: "~/.reasonix/skills/release-notes/SKILL.md", removes: "~/.reasonix/skills/release-notes", removesFolder: true, enabled: false,
license: "Apache-2.0", compatibility: "需要 git,并能访问网络", metadata: { author: "example-org", version: "1.0" },
},
{
name: "deploy-runbook", slashName: "deploy-runbook", description: "只在你点名时跑的部署清单",
scope: "project", path: ".reasonix/skills/deploy-runbook/SKILL.md", manual: true, enabled: true,
scope: "project", path: ".reasonix/skills/deploy-runbook.md", removes: ".reasonix/skills/deploy-runbook.md", manual: true, enabled: true,
},
];
@@ -77,6 +78,15 @@ export class MockExtensions extends MockMarket {
}
async applySkillUpdate(): Promise<void> {}
async removeSkill(name: string, scope: string, path: string): Promise<void> {
const at = this.skillList.findIndex((x) => x.name === name);
if (at < 0) throw new HttpError(404, "no skill named " + name, { code: "request.not_found" });
const sk = this.skillList[at];
if (sk.scope === "builtin" || sk.plugin || !sk.path) throw new HttpError(409, "not removable", { code: "skill.not_removable" });
if (sk.scope !== scope || sk.path !== path) throw new HttpError(409, "changed", { code: "skill.changed" });
this.skillList.splice(at, 1);
}
async setSkillEnabled(name: string, enabled: boolean, scope: ScopeLayer = "project") {
const sk = this.skillList.find((x) => x.name === name);
if (!sk) return;
@@ -102,6 +112,7 @@ export class MockExtensions extends MockMarket {
// a hook and connects a server is what the confirmation page exists for.
async planPlugin(req: PluginInstallRequest): Promise<PluginPlan> {
const source = req.source;
if (/\bmod\b/i.test(source)) return mockModPlan(source);
return {
ok: true, status: "planned", applied: false, source, planId: "high:sha256:mock",
actions: [
@@ -1,6 +1,6 @@
import { HttpError } from "./http_error";
import { MockCommit } from "./mock_commit";
import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackImage, type FeedbackItem, type FeedbackLinkedInstall, type FeedbackMine, type FeedbackProfile, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
import { FEEDBACK_CODE, canWithdraw, type FeedbackBoard, type FeedbackBoardEntry, type FeedbackBoardMe, type FeedbackEnv, type FeedbackImage, type FeedbackItem, type FeedbackLinkedInstall, type FeedbackMine, type FeedbackProfile, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
const LIMITS = { bodyBytes: 8192, nameChars: 40, contactChars: 120, images: 3, imageBytes: 2 << 20, uploadBytes: 10 << 20, replyBytes: 4096 };
@@ -28,6 +28,8 @@ const SEEDED: FeedbackItem[] = [
item({ receipt: "FB-3F6G-2KV9", category: "idea", titleSnippet: "Fixed in the next build: quieter update banner", status: "fixed", issueNumber: 11390, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11390", resolvedVersion: "next", createdAt: ago(8), updatedAt: ago(0.5) }),
item({ receipt: "FB-8Q2J-6PW4", category: "other", titleSnippet: "Please support a monorepo layout", status: "wontfix", issueNumber: 11288, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11288", createdAt: ago(12), updatedAt: ago(5) }),
item({ receipt: "FB-4T7V-3ZH1", category: "bug", titleSnippet: "Crash when opening settings on a small window", status: "duplicate", issueNumber: 11301, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11301", duplicateOf: 11299, createdAt: ago(15), updatedAt: ago(9) }),
item({ receipt: "FB-2D8K-4HQ6", category: "idea", titleSnippet: "Sent by mistake, please ignore", status: "withdrawn", createdAt: ago(6), updatedAt: ago(5.5) }),
item({ receipt: "FB-7L3Q-2DM8", category: "idea", titleSnippet: "Filed from my other laptop: a calmer sidebar", status: "recorded", issueNumber: 11402, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11402", otherInstall: true, createdAt: ago(3), updatedAt: ago(2) }),
item({ receipt: "FB-5R2T-6VB3", category: "bug", titleSnippet: "Settings sheet flickers when the window is dragged", status: "received", underReview: true, createdAt: ago(0.3), updatedAt: ago(0.3) }),
item({ receipt: "FB-6M9R-5CE8", category: "question", titleSnippet: "How do I move my sessions to another disk?", status: "received", statusUnavailable: true, createdAt: ago(40), updatedAt: ago(40) }),
item({ receipt: "FB-1C5W-7NB2", category: "bug", titleSnippet: "Terminal output is cut off after a resize", status: "needs_info", needsInput: true, createdAt: ago(2), updatedAt: ago(0.2),
@@ -55,6 +57,7 @@ function fault(key = FAULT): string {
// the same reason: "l0".."l6", "legacy", "lapsed", "revoked", or "none" for a
// service that states no profile.
const STANDING = "rx-mock-feedback-standing";
const PROMOTION = "rx-mock-feedback-promotion";
const THRESHOLDS = [0, 1, 3, 6, 12, 24, 48];
const LEVEL_LIMITS = [[3, 10, 3], [5, 15, 4], [6, 20, 5], [8, 25, 6], [10, 30, 8], [12, 40, 10], [12, 60, 10]] as const;
@@ -127,7 +130,19 @@ export class MockFeedback extends MockCommit {
return { ...i, needsInput: i.statusUnavailable ? false : i.needsInput, unreadReplies };
});
const unread = items.filter((i) => i.unreadReplies > 0 || i.needsInput).length;
return { items, offline: code === "mine_offline", unread, hasNew: unread > 0, profile: standing(fault(STANDING)) };
return { items, offline: code === "mine_offline", unread, hasNew: unread > 0, profile: standing(fault(STANDING)), promotion: this.promotion() };
}
// A tab's own sessionStorage ("2:3") stages a level-up for a dev page, until it is acknowledged.
private promotion(): { from: number; to: number } | null {
const [from, to] = fault(PROMOTION).split(":").map(Number);
return Number.isInteger(from) && Number.isInteger(to) && to > from && !this.acked.has(to) ? { from, to } : null;
}
private acked = new Set<number>();
async ackFeedbackPromotion(level: number): Promise<void> {
this.acked.add(level);
}
announced = 0;
@@ -179,11 +194,69 @@ export class MockFeedback extends MockCommit {
this.links = this.links.filter((l) => !l.current);
}
// The demo board: a handful of people, credits spread over three releases.
private board: FeedbackBoardEntry[] = [
{ displayName: "Sample Alpha", credits: 7, rank: null, releases: [{ version: "v2.45.0", count: 3 }, { version: "v2.44.0", count: 2 }, { version: "v2.43.0", count: 2 }] },
{ displayName: "示例用户", credits: 5, rank: null, releases: [{ version: "v2.45.0", count: 2 }, { version: "v2.44.0", count: 3 }] },
{ displayName: "sample_beta", credits: 3, rank: null, releases: [{ version: "v2.45.0", count: 2 }, { version: "v2.43.0", count: 1 }] },
{ displayName: "Sample-Gamma", credits: 2, rank: null, releases: [{ version: "v2.44.0", count: 1 }, { version: "v2.43.0", count: 1 }] },
{ displayName: "Sample Delta", credits: 1, rank: null, releases: [{ version: "v2.45.0", count: 1 }] },
];
private chosen: string | null = null;
private boardMe(): FeedbackBoardMe {
return { listed: this.chosen !== null, displayName: this.chosen, delisted: false };
}
async feedbackBoard(release?: string): Promise<FeedbackBoard> {
if (!release) {
const all = [...this.board].sort((a, b) => a.displayName.localeCompare(b.displayName));
return { available: true, view: "all", release: null, entries: all };
}
const rows = this.board
.map((e) => ({ ...e, releases: e.releases.filter((r) => r.version === release) }))
.filter((e) => e.releases.length > 0)
.map((e) => ({ ...e, credits: e.releases.reduce((n, r) => n + r.count, 0) }))
.sort((a, b) => b.credits - a.credits || a.displayName.localeCompare(b.displayName));
let rank = 0;
const entries = rows.map((e, i) => {
if (i === 0 || e.credits !== rows[i - 1].credits) rank = i + 1;
return { ...e, rank };
});
return { available: true, view: "release", release, entries };
}
async feedbackBoardMe(): Promise<FeedbackBoardMe> {
this.requireAccount();
return this.boardMe();
}
async joinFeedbackBoard(displayName: string): Promise<FeedbackBoardMe> {
this.requireAccount();
this.chosen = displayName.trim();
return this.boardMe();
}
async leaveFeedbackBoard(): Promise<FeedbackBoardMe> {
this.requireAccount();
this.chosen = null;
return this.boardMe();
}
async unlinkFeedbackInstall(id: string): Promise<void> {
this.requireAccount();
this.links = this.links.filter((l) => l.id !== id);
}
async withdrawFeedback(receipt: string): Promise<void> {
const row = this.rows.find((i) => i.receipt === receipt);
if (!row) throw refusal(FEEDBACK_CODE.notFound);
if (!canWithdraw(row)) throw refusal(FEEDBACK_CODE.notWithdrawable);
row.status = "withdrawn";
row.needsInput = false;
row.updatedAt = new Date().toISOString();
}
async feedbackSeen(receipt: string, upTo: number): Promise<void> {
if (this.rows.some((i) => i.receipt === receipt)) this.seen.set(receipt, Math.max(this.seen.get(receipt) ?? 0, upTo));
}
@@ -0,0 +1,79 @@
import { HttpError } from "./http_error";
import type { ChangeDiff, DiscardRequest, GitCommitFiles, GitRepoRow, GitRepos, PushPlan, PushRequest, PushResult, WorkspaceChange } from "./workspace";
import { MockFeedback } from "./mock_feedback";
const refuse = (status: number, code: string, error: string) => new HttpError(status, error, { code, error });
// The git panel's reads and writes beyond the workspace's own changes: which
// repositories there are, what a commit holds, a discard and a push. Scripted,
// because there is no repository behind the fixture, but held in fields so a
// write is seen by the next read the way the kernel's would be.
export class MockGit extends MockFeedback {
// One entry is the workspace being a repository; several are a folder of projects.
repoRows: GitRepoRow[] = [{ dir: "", name: "reasonix", branch: "main", detached: false }];
// The changes of each repository below the workspace; the workspace's own are MockWorkspace.changeList.
repoChanges: Record<string, WorkspaceChange[]> = {};
async gitRepos(): Promise<GitRepos> {
return { repos: this.repoRows.map((r) => ({ ...r })), truncated: false };
}
async discardChange(req: DiscardRequest): Promise<void> {
const list = req.repo ? this.repoChanges[req.repo] : (this as unknown as { changeList: WorkspaceChange[] }).changeList;
const found = list?.find((c) => c.path === req.path);
if (!found) throw refuse(400, "discard.unknown_path", "not a changed file in the workspace");
if (!["M", "D", "MM"].includes(found.status)) throw refuse(409, "discard.not_discardable", "only a change to a file the last commit holds can be discarded");
const rest = list.filter((c) => c !== found);
if (req.repo) this.repoChanges[req.repo] = rest;
else (this as unknown as { changeList: WorkspaceChange[] }).changeList = rest;
}
async commitFiles(id: string): Promise<GitCommitFiles> {
if (!/^[0-9a-f]{1,64}$/i.test(id)) throw refuse(400, "git.commit_bad_id", "that is not a commit id");
return {
repo: true,
truncated: false,
files: [
{ path: "internal/provider/retry.go", status: "M", insertions: 14, deletions: 3 },
{ path: "internal/provider/retry_test.go", status: "A", insertions: 42 },
{ path: "docs/old-notes.md", oldPath: "docs/notes.md", status: "R", insertions: 1, deletions: 1 },
{ path: "assets/logo.png", status: "M" },
],
};
}
async commitDiff(_id: string, path: string): Promise<ChangeDiff> {
return {
path,
truncated: false,
diff: [`--- a/${path}`, `+++ b/${path}`, "@@ -1,3 +1,4 @@", " package provider", "-func retry() {}", "+func retry(after time.Duration) {", "+ time.Sleep(after)", " }"].join("\n"),
};
}
// Commits the branch has that the upstream does not. Pushing sets it to zero.
pushAhead = 2;
pushScript: "ok" | "rejected" | "auth" | "untrusted" | "protocol" = "ok";
private plan(repo?: string): PushPlan {
return {
remote: "origin", branch: "main", upstreamBranch: "main", scheme: "https", host: "github.com",
url: "https://github.com/esengine/DeepSeek-Reasonix.git", head: repo ? "9f2c41b07ad3" : "0123456789ab", ahead: this.pushAhead, behind: 0,
};
}
async pushPreview(repo?: string): Promise<PushPlan> {
if (this.pushScript === "untrusted") throw refuse(403, "push.workspace_untrusted", "this workspace is not trusted");
if (this.pushScript === "protocol") throw refuse(403, "push.protocol_not_allowed", "the remote's protocol is not allowed");
return this.plan(repo);
}
async push(req: PushRequest): Promise<PushResult> {
await new Promise((r) => setTimeout(r, 400));
if (this.pushAhead === 0) throw refuse(409, "push.nothing_to_push", "nothing to push");
if (this.pushScript === "rejected") throw refuse(409, "push.rejected", "the remote has commits this branch does not");
if (this.pushScript === "auth") throw refuse(409, "push.auth_required", "the remote needs authentication");
this.pushAhead = 0;
return { plan: req.plan, new: false };
}
}
@@ -1,5 +1,6 @@
import { MockMcp } from "./mock_mcp";
import { HttpError, type HookCatalog, type HookDryRun, type HookEntry } from "./port";
import type { SubagentRun, SubagentTranscript } from "./subagent";
// Hooks, and what a dry run of one reports back.
export class MockHook extends MockMcp {
@@ -12,6 +13,18 @@ export class MockHook extends MockMcp {
throw new HttpError(409, "no running tool call with this id", { code: "tool_call.not_running" });
}
async subagents(): Promise<SubagentRun[]> {
return [];
}
async steerSubagent(_ref: string, _text: string): Promise<void> {
throw new HttpError(404, "no sub-agent run answers to that reference", { code: "subagent.not_found" });
}
async subagentTranscript(_ref: string): Promise<SubagentTranscript> {
throw new HttpError(404, "no sub-agent run answers to that reference", { code: "subagent.not_found" });
}
async hooks(): Promise<HookCatalog> {
return {
hooks: this.hookList.map((h) => ({ ...h })),
@@ -0,0 +1,18 @@
// @vitest-environment jsdom
import { expect, it } from "vitest";
import { MockHub } from "./mock_hub";
it("keeps session placement and pin/archive flags in the same mock tree", async () => {
const hub = new MockHub();
const original = (await hub.tree())[0].sessions;
const [first, second] = original;
await hub.pinSession(second.path, true);
await hub.moveSession(second.path, -1);
const moved = (await hub.tree())[0].sessions;
expect(moved.map((s) => s.path)).toEqual([second.path, first.path]);
expect(moved[0].pinned).toBe(true);
await hub.archiveSession(second.path, true);
const archived = (await hub.tree())[0].sessions;
expect(archived.map((s) => s.path)).toEqual([second.path, first.path]);
expect(archived[0]).toMatchObject({ archived: true, pinned: false });
});
+67 -10
View File
@@ -1,7 +1,7 @@
import type { AgentPort } from "./port";
import type { HistoryHit, HubPort, RuntimeView, TreeWindow, TreeWorkspace } from "./hub";
import type { HistoryHit, HubPort, RuntimeView, TreeSession, TreeWindow, TreeWorkspace } from "./hub";
import type { RemoteHost, RemoteListing, RemoteProbe } from "./remote";
import type { ShareStatus } from "./share";
import type { PhoneAction, PhoneList, PhoneView, ShareStatus } from "./share";
import { MockPort } from "./mock";
// MockHub is the fixture's answer to a window that drives several panes. Each
@@ -16,6 +16,12 @@ export class MockHub implements HubPort {
];
private readonly roots = ["~/projects/DeepSeek-Reasonix", "~/projects/my-website"];
private seq = 1;
private readonly flags = new Map<string, Pick<TreeSession, "pinned" | "archived">>();
private decorate(tree: TreeWorkspace[], host = ""): TreeWorkspace[] {
return tree.map((ws) => ({ ...ws, sessions: ws.sessions.map((session) => ({ ...session,
...this.flags.get(JSON.stringify([host, session.path])) })) }));
}
runtimes() {
return Promise.resolve([...this.views]);
@@ -60,7 +66,7 @@ export class MockHub implements HubPort {
// the tree under a connected host can be designed without one.
remoteTree(host: string) {
if (host !== "gpu-box") return Promise.resolve(null);
return Promise.resolve<TreeWorkspace[]>([
return Promise.resolve<TreeWorkspace[]>(this.decorate([
{
root: "/srv/training",
name: "training",
@@ -70,7 +76,7 @@ export class MockHub implements HubPort {
],
},
{ root: "/srv/scratch", name: "scratch", sessions: [] },
]);
], host));
}
openRemote(req: { host: string; workspace?: string; sessionPath?: string }) {
@@ -144,6 +150,10 @@ export class MockHub implements HubPort {
return Promise.resolve(["attic", "old-laptop"]);
}
wslDistros() {
return Promise.resolve({ available: true, distros: [{ name: "Ubuntu-24.04", version: 2, default: true }, { name: "Debian", version: 2 }, { name: "Alpine-legacy", version: 1 }] });
}
// The fixture has no link to block, so nothing ever asks. The dialog is
// driven from a story instead of from a stub that would fire on load.
onRemoteAsk() {
@@ -164,19 +174,19 @@ export class MockHub implements HubPort {
tree(_window?: TreeWindow) {
const open = new Map(this.views.filter((v) => v.sessionPath).map((v) => [v.sessionPath!, v.id]));
return Promise.resolve(
this.roots.map<TreeWorkspace>((root, i) => ({
this.decorate(this.roots.map<TreeWorkspace>((root, i) => ({
root,
name: root.split("/").pop() ?? root,
open: this.views.some((v) => v.root === root),
remembered: true,
sessions:
sessions: this.placed(root,
i === 0
? [
{ path: "/sessions/mock.jsonl", name: "mock", title: "并行会话演示", turns: 3, runtimeId: open.get("/sessions/mock.jsonl") },
{ path: "/sessions/older.jsonl", name: "older", title: "上一次的会话", turns: 12, runtimeId: open.get("/sessions/older.jsonl"), legacy: true },
]
: [{ path: "/sessions/site.jsonl", name: "site", title: "站点改版", turns: 5, runtimeId: open.get("/sessions/site.jsonl") }],
})),
: [{ path: "/sessions/site.jsonl", name: "site", title: "站点改版", turns: 5, runtimeId: open.get("/sessions/site.jsonl") }]),
}))),
);
}
@@ -201,6 +211,26 @@ export class MockHub implements HubPort {
return Promise.resolve();
}
// Where the person placed sessions, by workspace: what the kernel keeps per project.
protected placedSessions = new Map<string, string[]>();
protected placed(root: string, sessions: TreeSession[]): TreeSession[] {
const order = this.placedSessions.get(root) ?? [];
const rank = (s: TreeSession) => order.indexOf(s.path);
return [...sessions].sort((a, b) => rank(a) - rank(b));
}
async moveSession(path: string, direction: -1 | 1) {
const ws = (await this.tree()).find((w) => w.sessions.some((s) => s.path === path));
if (!ws) return;
const order = ws.sessions.map((s) => s.path);
const at = order.indexOf(path);
const to = at + direction;
if (to < 0 || to >= order.length) return;
[order[at], order[to]] = [order[to], order[at]];
this.placedSessions.set(ws.root, order);
}
moveWorkspace(path: string, direction: -1 | 1) {
const at = this.roots.indexOf(path);
const to = at + direction;
@@ -223,6 +253,10 @@ export class MockHub implements HubPort {
return Promise.resolve();
}
moveSessionTo(path: string, target: string) {
return Promise.resolve({ path: `${target}/${path.split("/").pop()}`, originalRemoved: true });
}
removeSession(_path: string) {
return Promise.resolve();
}
@@ -231,11 +265,15 @@ export class MockHub implements HubPort {
return Promise.resolve();
}
archiveSession(_path: string, _archived: boolean) {
archiveSession(path: string, archived: boolean, host = "") {
const key = JSON.stringify([host, path]);
this.flags.set(key, { ...this.flags.get(key), archived, ...(archived ? { pinned: false } : {}) });
return Promise.resolve();
}
pinSession(_path: string, _pinned: boolean) {
pinSession(path: string, pinned: boolean, host = "") {
const key = JSON.stringify([host, path]);
this.flags.set(key, { ...this.flags.get(key), pinned });
return Promise.resolve();
}
@@ -280,6 +318,25 @@ export class MockHub implements HubPort {
cloudRemote: { online: false, reason: "signed_out" },
};
private phones: PhoneView[] = [
{ id: "rc_aaa", ordinal: 1, name: "iPhone Safari", state: "active", uaClass: "ios-safari", createdAt: "2026-10-12T08:00:00Z", lastSeenAt: "2026-10-18T09:30:00Z", keyThumbprint: "kP3x…f9a1" },
{ id: "rc_bbb", name: "Android Chrome", state: "pending", uaClass: "android-chrome", createdAt: "2026-10-18T09:40:00Z", lastSeenAt: "2026-10-18T09:40:00Z", expiresAt: "2026-10-18T09:55:00Z", keyThumbprint: "Zq8m…02c7" },
];
sharePhones() {
return Promise.resolve<PhoneList | null>({ phones: this.phones.map((p) => ({ ...p })) });
}
resolvePhone(id: string, action: PhoneAction) {
this.phones = this.phones.flatMap((p) => (p.id !== id ? [p] : action === "reject" ? [] : [{ ...p, state: "active" as const, expiresAt: undefined, ordinal: 2 }]));
return Promise.resolve<PhoneList>({ phones: this.phones.map((p) => ({ ...p })) });
}
revokePhone(id: string) {
this.phones = this.phones.filter((p) => p.id !== id);
return Promise.resolve<PhoneList>({ phones: this.phones.map((p) => ({ ...p })) });
}
shareStatus() {
return Promise.resolve<ShareStatus | null>({ ...this.share });
}
@@ -127,6 +127,13 @@ export class MockMcp extends MockProvider {
return { name, state: "ready", host: "this machine", tools: s.tools };
}
async upgradeMcp(name: string) {
const s = this.servers.find((x) => x.name === name);
if (!s) throw new HttpError(404, "no configured MCP server named " + name, { code: "mcp.signin_ineligible" });
s.scopeUpgrade = undefined;
return { name, state: "ready", host: "this machine", tools: s.tools };
}
async followMcp(name: string, follow: boolean) {
const s = this.servers.find((x) => x.name === name);
if (!s) throw new HttpError(404, "no configured MCP server named " + name, { code: "mcp.approval_owed" });
@@ -0,0 +1,41 @@
import type { ModStatus } from "./boundary";
import type { ModPreview, PluginPlan } from "./plugin";
const RUNTIME = { kind: "electron", path: "/Applications/Reasonix Studio.app/Contents/MacOS/Reasonix Studio", version: "v22.12.0" };
/** Four installed mods in the states a person can meet: the list is the same
* with the switch off, but then nothing runs and each says why. */
export function mockMods(enabled: boolean): ModStatus[] {
const names = ["statusline-clock", "git-guard", "prompt-tweaks", "fancy-notes"];
if (!enabled) return names.map((plugin) => ({ plugin, state: "inactive" as const, code: "mod.switch_off", restarts: 0 }));
return [
{ plugin: names[0], state: "running", restarts: 0, runtime: RUNTIME, events: ["session.start", "turn.complete"], unserved: [] },
{ plugin: names[1], state: "disabled", code: "mod.hook_timeout", restarts: 3, runtime: RUNTIME, events: ["tool.check", "tool.after"], unserved: ["tool.rewrite"] },
{ plugin: names[2], state: "restarting", code: "mod.crashed", restarts: 1, runtime: RUNTIME, events: ["prompt.submit"], unserved: [] },
{ plugin: names[3], state: "inactive", code: "mod.runtime_missing", restarts: 0, events: ["turn.complete"], unserved: [] },
];
}
export const MOCK_MOD_PREVIEW: ModPreview = {
plugin: "git-guard", module: "hooks/index.js", sha256: "9f2c41b07ad35e8c61f0aa42d9be3377c0e1145f8a6d2b9c4e7710d3a5b8c6e2", listed: true,
events: [{ name: "tool.check" }, { name: "tool.after" }, { name: "turn.complete" }, { name: "x", dynamic: true }],
api: [{ namespace: "process", method: "run" }, { namespace: "fs", method: "readFile" }, { namespace: "ui", method: "toast" }],
imports: [{ spec: "./lib/rules.js", kind: "static" }, { spec: "minimatch", kind: "require" }],
findings: [{ kind: "computed_member" }],
session: ["messages", "submit"],
unserved: ["tool.rewrite"],
};
/** The plan a source naming a mod gets: one package whose action carries the
* preview of its hooks module. */
export function mockModPlan(source: string): PluginPlan {
return {
ok: true, status: "planned", applied: false, source, planId: "high:sha256:mockmod",
actions: [{
kind: "plugin", action: "install_plugin_package", status: "planned", riskLevel: "high", name: "git-guard", version: "1.2.0", source,
target: "~/.reasonix/plugins/git-guard", manifestKind: "claude-code", skills: [], skillCount: 0, commandCount: 0, hookCount: 0,
riskReasons: ["FULL TRUST: ships a Claude Code mod whose code runs on this machine"], mod: MOCK_MOD_PREVIEW,
}],
next: "Review the plan, then install.",
};
}
@@ -36,6 +36,7 @@ export class MockProvider extends MockBoundary {
name: "deepseek", kind: "openai", effortField: "reasoning_effort", baseUrl: "https://api.deepseek.com",
models: ["deepseek-v4-pro", "deepseek-flash"], default: "deepseek-v4-pro",
hasKey: true, inUse: true, preset: false, keyEnv: "DEEPSEEK_API_KEY", canSetVision: false,
balanceUrl: "https://api.deepseek.com/user/balance", impliedBalanceFormat: "deepseek", balanceFormats: ["deepseek", "moonshot", "openrouter"],
},
{
name: "deepseek-anthropic", kind: "anthropic", baseUrl: "https://api.deepseek.com/anthropic",
@@ -48,11 +49,13 @@ export class MockProvider extends MockBoundary {
name: "myrelay", kind: "openai", effortField: "reasoning_effort", baseUrl: "https://relay.example.com/v1",
models: ["gpt-4o", "claude-sonnet-4"], default: "gpt-4o",
hasKey: true, inUse: false, preset: false, keyEnv: "MYRELAY_API_KEY",
balanceUrl: "https://relay.example.com/user/balance", impliedBalanceFormat: "", balanceFormats: ["deepseek", "moonshot", "openrouter"],
visionModels: ["gpt-4o"], canSetVision: true, canSetThinking: true, sendsThinking: true,
reasoningProtocol: "openai", supportedEfforts: ["low", "medium", "high"],
modelEfforts: { "claude-sonnet-4": { supportedEfforts: ["low", "high", "max"], defaultEffort: "high" } },
contextWindow: 131072,
modelLimits: { "claude-sonnet-4": { contextWindow: 200000, maxOutputTokens: 64000 } },
modelPrices: { "gpt-4o": { input: 2.5, output: 10, cacheHit: 1.25, currency: "USD", sourceUrl: "https://relay.example.com/pricing", checked: "2026-10-17" } },
inheritedLimits: { "gpt-4o": { contextWindow: 131072 }, "claude-sonnet-4": { contextWindow: 131072 } },
inheritedEfforts: {
"gpt-4o": { supportedEfforts: ["low", "medium", "high"] },
@@ -174,6 +177,7 @@ export class MockProvider extends MockBoundary {
...p, models: edit.models, default: edit.default, visionModels: edit.vision,
contextWindow: edit.contextWindow, maxOutputTokens: edit.maxOutputTokens,
idleTimeoutSeconds: edit.idleTimeoutSeconds || undefined,
balanceUrl: edit.balanceUrl ?? p.balanceUrl, balanceFormat: edit.balanceFormat ?? p.balanceFormat,
headers: edit.headers, extraBody: edit.extraBody,
reasoningProtocol: edit.reasoningProtocol,
sendsThinking: edit.reasoningProtocol === undefined ? p.sendsThinking : edit.reasoningProtocol !== "none",
@@ -0,0 +1,28 @@
import type { Completion, CompletionItem } from "./port";
const PAST = [
{ id: "20261018-020806-feature", title: "把密钥轮换做成一键操作", at: "2026-10-18 02:08" },
{ id: "20261017-101010-flaky", title: "修一个偶发红的分支菜单测试", at: "2026-10-17 10:10" },
{ id: "20261016-183000-search", title: "跨对话搜索要能搜对话内容", at: "2026-10-16 18:30" },
];
/** What the kernel offers for a #word: past conversations whose title holds it,
* newest first, each inserting its @session reference. */
export function pastSessionItems(query: string): CompletionItem[] {
const needle = query.trim().toLowerCase();
return PAST.filter((s) => !needle || s.title.toLowerCase().includes(needle)).map((s) => ({
label: s.title,
insert: "@session:" + s.id,
hint: s.at,
kind: "session",
}));
}
/** The #word menu under the caret, or null when the caret is not in one. */
export function sessionCompletion(line: string, cursor: number): Completion | null {
const before = line.slice(0, cursor);
const word = /(?:^|\s)#(\S*)$/.exec(before);
if (!word) return null;
const rest = line.slice(cursor).search(/\s/);
return { kind: "session", from: before.length - word[1].length - 1, to: rest < 0 ? line.length : cursor + rest, query: word[1], items: pastSessionItems(word[1]) };
}
@@ -46,6 +46,7 @@ export function mockUsage(query: number | UsageQuery): UsageReport {
requests: Math.round(total / 20_000), turns: Math.round(total / 150_000),
cacheHit: Math.round(total * 0.92), cacheMiss: Math.round(total * 0.08),
cost: amount ? [{ amount, currency: "CNY" }] : undefined,
estimateCost: i >= count - 2 && total > 0 ? [{ amount: i === count - 1 ? "2.8" : "3.5", currency: "CNY" }] : undefined,
};
});
const tokens = daily.reduce((a, d) => a + d.total, 0);
@@ -56,6 +57,7 @@ export function mockUsage(query: number | UsageQuery): UsageReport {
cache_hit: daily.reduce((a, d) => a + d.cacheHit, 0),
cache_miss: daily.reduce((a, d) => a + d.cacheMiss, 0),
cost: [{ amount: "10.4882", currency: "CNY" }],
estimateCost: daily.some((d) => d.estimateCost) ? [{ amount: "6.3", currency: "CNY" }] : undefined,
active_days: daily.filter((d) => d.total > 0).length,
top_model: "deepseek/deepseek-flash", top_provider: "deepseek",
daily,
@@ -2,7 +2,7 @@ import { reason } from "../i18n/kernel";
import { HttpError } from "./http_error";
import type { KeptText } from "./attachment";
import type { ChangeDiff, GitHistory, StageRequest, WorkspaceChange, WorkspaceChanges, WorkspaceGit, WorkspaceBranches } from "./workspace";
import { MockFeedback } from "./mock_feedback";
import { MockGit } from "./mock_git";
export function mockWorkspaces(root: string): import("./workspace").WorkspaceInfo {
return { current: root, canSwitch: true, canIsolate: true, recents: [
@@ -19,7 +19,7 @@ export function mockWorktreeSources(root: string, branch: string): import("./wor
] };
}
export class MockWorkspace extends MockFeedback {
export class MockWorkspace extends MockGit {
opensFiles() { return false; }
async openFile() {}
@@ -81,17 +81,16 @@ export class MockWorkspace extends MockFeedback {
{ path: "docs/notes.md", status: "??", unstaged: true },
];
async changes(): Promise<WorkspaceChanges> {
return { repo: true, changes: this.changeList.map((c) => ({ ...c })) };
async changes(repo?: string): Promise<WorkspaceChanges> {
return { repo: true, changes: (repo ? this.repoChanges[repo] ?? [] : this.changeList).map((c) => ({ ...c })) };
}
async stageChanges(req: StageRequest): Promise<void> {
const named = new Set(req.paths ?? []);
this.changeList = this.changeList.map((c) =>
req.all || named.has(c.path)
? { ...c, staged: !req.unstage, unstaged: !!req.unstage }
: c,
);
const mark = (list: WorkspaceChange[]) =>
list.map((c) => (req.all || named.has(c.path) ? { ...c, staged: !req.unstage, unstaged: !!req.unstage } : c));
if (req.repo) this.repoChanges[req.repo] = mark(this.repoChanges[req.repo] ?? []);
else this.changeList = mark(this.changeList);
}
// Scripted, because there is no tree to read: enough of a diff for the
+4 -2
View File
@@ -11,7 +11,7 @@ export interface CompletionItem {
// A directory, or a command with its own argument menu: accepting re-opens
// the menu one level deeper instead of closing it.
descend?: boolean;
// builtin | command | skill | subagent | prompt | file | dir | resource.
// builtin | command | skill | subagent | prompt | file | dir | resource | session.
// Empty on argument values, which are not things.
kind?: string;
// The arguments an MCP prompt declares, in order.
@@ -27,7 +27,7 @@ export interface CompletionArg {
}
export interface Completion {
kind: "" | "slash" | "slash-arg" | "ref";
kind: "" | "slash" | "slash-arg" | "ref" | "session";
from: number;
to: number;
// What the kernel filtered on, so a row can point at the letters that put it
@@ -52,6 +52,8 @@ export interface ModelEntry {
modelAlias?: string;
kind?: string;
active?: boolean;
// Configured but outside the access list the person added, so a role cannot pick it.
notAdded?: boolean;
default?: boolean;
// The endpoint host. Rows sharing it are one service reached under more than
// one protocol, which is what lets the list fold them into a single choice.
@@ -2,6 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MuxClient } from "./muxclient";
import { subscribeStream } from "./sse_subscribe";
import { StreamBudget } from "./streambudget";
import type { WireEvent } from "./wire";
interface Source { url: string; closed: boolean; open: (conn: string) => void; say: (pane: string, frame: object) => void }
@@ -56,6 +57,25 @@ function pane(client: MuxClient, id: string, seen: { at: number | null } = { at:
}
describe("panes sharing one stream", () => {
it("keeps paused SSH fallback streams charged when the connection budget settles again", async () => {
const hub = stubHub(() => ({ status: 409, code: "mux.pane_not_local" }));
const client = new MuxClient();
const a = pane(client, "ssh-a");
const b = pane(client, "ssh-b");
hub.sources[0].open("c1");
await flush();
const budget = new StreamBudget(() => 1);
const dropA = budget.attach("ssh-a", a.stop, () => {}, { visible: true, busy: false });
const dropB = budget.attach("ssh-b", b.stop, () => {}, { visible: false, busy: false });
expect(budget.liveIds()).toEqual(["ssh-a"]);
budget.resettle();
expect(budget.liveIds()).toEqual(["ssh-a"]);
budget.update("ssh-a", { visible: false, busy: false });
budget.update("ssh-b", { visible: true, busy: false });
budget.resettle();
expect(budget.liveIds()).toEqual(["ssh-b"]);
dropA(); dropB(); a.stop(); b.stop();
});
it("carries nine panes on one connection", async () => {
const hub = stubHub();
const client = new MuxClient();
+2 -1
View File
@@ -62,7 +62,8 @@ export class MuxClient {
void this.subscribe(id, pane);
}
},
holdsConnection: () => !!pane.own && !pane.paused,
// A paused dedicated stream needs a slot again when resumed.
holdsConnection: () => !!pane.own,
},
);
}
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { AccountError, accountClient, enrollDevice, uaClassOf, type KeyStore } from "./phone_device";
import { deviceIdFor, enrollMessage, fromBase64url, thumbprintOf, type DeviceJwk, type DeviceKey } from "./phone_proof";
const HOST = "ab".repeat(32);
function memoryKeys(): KeyStore & { held: Map<string, DeviceKey> } {
const held = new Map<string, DeviceKey>();
return { held, load: async (h) => held.get(h) ?? null, save: async (h, k) => void held.set(h, k), remove: async (h) => void held.delete(h) };
}
// An account that checks the proof the way the real service does: the nonce is
// single use and the signature must verify over the enrollment message.
function fakeAccount(options: { state?: "active" | "pending" | "revoked" } = {}) {
const calls: string[] = [];
const nonces = new Set<string>();
let enrolled: { body: Record<string, unknown>; verified: boolean } | null = null;
const send = (async (url: string, init?: RequestInit) => {
const path = url.replace("https://id.test", "");
calls.push(`${init?.method} ${path}`);
const body = init?.body ? JSON.parse(String(init.body)) : {};
const reply = (status: number, data: unknown) => new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
if (path === "/me") return reply(200, { user: { id: 7 } });
if (path === "/me/remote-controllers/challenge") {
const nonce = String(nonces.size).padStart(64, "0");
nonces.add(nonce);
return reply(200, { nonce, expiresAt: "later" });
}
if (path === "/me/remote-controllers/enroll") {
if (!nonces.delete(body.nonce)) return reply(403, { error: { code: "challenge_invalid" } });
const thumbprint = await thumbprintOf(body.publicKey as DeviceJwk);
const key = await crypto.subtle.importKey("jwk", body.publicKey, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
const verified = await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, fromBase64url(body.signature), enrollMessage({ nonce: body.nonce, userId: 7, host: String(body.host), thumbprint }));
enrolled = { body, verified };
if (!verified) return reply(403, { error: { code: "invalid_proof" } });
const controller = { id: body.controllerId, state: options.state ?? "active", name: "iPhone Safari" };
if (controller.state === "pending") return reply(403, { error: { code: "controller_unconfirmed" }, controller });
if (controller.state === "revoked") return reply(403, { error: { code: "controller_revoked" }, controller });
return reply(200, { controller });
}
return reply(404, { error: { code: "not_found" } });
}) as unknown as typeof fetch;
return { calls, send, enrolled: () => enrolled };
}
describe("enrolling this phone with the account", () => {
it("makes a key once, proves it, and is recorded under the id the key names", async () => {
const keys = memoryKeys(), account = fakeAccount();
const id = { account: accountClient("https://id.test", account.send), keys, uaClass: "ios-safari" };
const device = await enrollDevice(id, HOST);
const key = keys.held.get(HOST)!;
expect(device.state).toBe("active");
expect(device.id).toBe(await deviceIdFor(HOST, await thumbprintOf(key.publicKey)));
expect(account.enrolled()?.verified).toBe(true);
expect(account.enrolled()?.body.uaClass).toBe("ios-safari");
await enrollDevice(id, HOST);
expect(keys.held.get(HOST)).toBe(key);
});
it("keeps a separate key for each computer", async () => {
const keys = memoryKeys();
const id = { account: accountClient("https://id.test", fakeAccount().send), keys, uaClass: "other" };
await enrollDevice(id, HOST);
const other = "cd".repeat(32);
await enrollDevice(id, other);
expect(keys.held.size).toBe(2);
expect(keys.held.get(HOST)).not.toBe(keys.held.get(other));
});
it("reports a phone waiting for the desktop's approval as pending, not as a failure", async () => {
const id = { account: accountClient("https://id.test", fakeAccount({ state: "pending" }).send), keys: memoryKeys(), uaClass: "other" };
const device = await enrollDevice(id, HOST);
expect(device.state).toBe("pending");
expect(device.id).toMatch(/^rc_/);
});
it("reports a revoked phone as revoked", async () => {
const id = { account: accountClient("https://id.test", fakeAccount({ state: "revoked" }).send), keys: memoryKeys(), uaClass: "other" };
expect((await enrollDevice(id, HOST)).state).toBe("revoked");
});
it("lets any other refusal through with its code", async () => {
const send = (async () => new Response(JSON.stringify({ error: { code: "unauthorized" } }), { status: 401 })) as unknown as typeof fetch;
const id = { account: accountClient("https://id.test", send), keys: memoryKeys(), uaClass: "other" };
await expect(enrollDevice(id, HOST)).rejects.toMatchObject({ status: 401, code: "unauthorized" });
await expect(enrollDevice(id, HOST)).rejects.toBeInstanceOf(AccountError);
});
});
describe("naming this browser for the desktop's device list", () => {
it.each([
["Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1", "ios-safari"],
["Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 CriOS/120.0 Mobile/15E148 Safari/604.1", "ios-webview"],
["Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/120.0 Mobile Safari/537.36", "android-chrome"],
["Mozilla/5.0 (Linux; Android 14; Pixel 8; wv) AppleWebKit/537.36 Chrome/120.0 Mobile Safari/537.36", "android-webview"],
["Mozilla/5.0 (Macintosh) Gecko/20100101 Firefox/120.0", "desktop-firefox"],
["something else", "other"],
])("%s", (agent, expected) => {
expect(uaClassOf(agent)).toBe(expected);
});
});
@@ -0,0 +1,145 @@
import { deviceIdFor, enrollMessage, generateDeviceKey, sign, thumbprintOf, type DeviceKey } from "./phone_proof";
// The key never leaves the browser that made it and is not extractable, so a
// copied cookie or a script reading storage cannot sign for this phone. It is
// kept per computer: a controller id names one key on one host.
export interface KeyStore {
load(host: string): Promise<DeviceKey | null>;
save(host: string, key: DeviceKey): Promise<void>;
remove(host: string): Promise<void>;
}
const DB = "reasonix-phone-device";
const STORE = "keys";
function request<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function open(): Promise<IDBDatabase> {
const opening = indexedDB.open(DB, 1);
opening.onupgradeneeded = () => opening.result.createObjectStore(STORE);
return request(opening);
}
export const indexedDbKeyStore: KeyStore = {
async load(host) {
const db = await open();
try {
const found = await request(db.transaction(STORE).objectStore(STORE).get(host));
return found && typeof found === "object" && "privateKey" in found ? (found as DeviceKey) : null;
} finally {
db.close();
}
},
async save(host, key) {
const db = await open();
try {
await request(db.transaction(STORE, "readwrite").objectStore(STORE).put(key, host));
} finally {
db.close();
}
},
async remove(host) {
const db = await open();
try {
await request(db.transaction(STORE, "readwrite").objectStore(STORE).delete(host));
} finally {
db.close();
}
},
};
// An account-service failure keeps its code, so a caller tells "waiting for
// approval" from "revoked" from "try again later" without reading a sentence.
export class AccountError extends Error {
constructor(readonly status: number, readonly code: string, readonly detail: unknown = null) {
super(`${status} ${code}`);
}
}
export interface Account {
call<T>(method: string, path: string, body?: unknown): Promise<T>;
}
export function accountClient(base: string, send: typeof fetch): Account {
return {
async call<T>(method: string, path: string, body?: unknown): Promise<T> {
const response = await send(`${base.replace(/\/$/, "")}${path}`, {
method,
credentials: "include",
headers: body === undefined ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (response.status === 204) return undefined as T;
const parsed = await response.json().catch(() => null) as { error?: { code?: string } } | null;
if (!response.ok) throw new AccountError(response.status, parsed?.error?.code ?? "unknown", parsed);
return parsed as T;
},
};
}
export async function currentUserId(account: Account): Promise<number> {
return (await account.call<{ user: { id: number } }>("GET", "/me")).user.id;
}
export type DeviceState = "pending" | "active" | "revoked";
export interface Device {
id: string;
state: DeviceState;
name: string;
}
interface DeviceView extends Device {
ordinal: number | null;
}
export interface Identity {
account: Account;
keys: KeyStore;
uaClass: string;
}
export function uaClassOf(agent: string, mobileHint = false): string {
if (/iPhone|iPad|iPod/.test(agent)) return /Safari/.test(agent) && !/FxiOS|CriOS|EdgiOS/.test(agent) ? "ios-safari" : "ios-webview";
if (/Android/.test(agent)) return /; wv\)/.test(agent) ? "android-webview" : "android-chrome";
if (mobileHint) return "other";
if (/Edg\//.test(agent)) return "desktop-edge";
if (/Firefox\//.test(agent)) return "desktop-firefox";
if (/Chrome\//.test(agent)) return "desktop-chrome";
if (/Safari\//.test(agent)) return "desktop-safari";
return "other";
}
// Makes this browser's key on first use and asks the account to record it for
// the computer. A second phone on the same computer is held for the desktop to
// approve: the account answers 403 controller_unconfirmed and the phone shows
// that, instead of treating the refusal as a failure.
export async function enrollDevice(id: Identity, host: string): Promise<Device> {
let key = await id.keys.load(host);
if (!key) {
key = await generateDeviceKey();
await id.keys.save(host, key);
}
const thumbprint = await thumbprintOf(key.publicKey);
const deviceId = await deviceIdFor(host, thumbprint);
const userId = await currentUserId(id.account);
const { nonce } = await id.account.call<{ nonce: string }>("POST", "/me/remote-controllers/challenge", { host, purpose: "enroll" });
const signature = await sign(key.privateKey, enrollMessage({ nonce, userId, host, thumbprint }));
try {
const { controller } = await id.account.call<{ controller: DeviceView }>("POST", "/me/remote-controllers/enroll", {
host, nonce, publicKey: key.publicKey, signature, uaClass: id.uaClass, controllerId: deviceId,
});
return { id: controller.id, state: controller.state, name: controller.name };
} catch (error) {
if (error instanceof AccountError && (error.code === "controller_unconfirmed" || error.code === "controller_revoked")) {
const { controller } = (error.detail as { controller?: DeviceView } | null) ?? {};
return { id: controller?.id ?? deviceId, state: error.code === "controller_revoked" ? "revoked" : "pending", name: controller?.name ?? "" };
}
throw error;
}
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { base64url, deviceIdFor, enrollMessage, fromBase64url, generateDeviceKey, pushMessage, sign, thumbprintOf } from "./phone_proof";
// Fixed values from the accounts service (auth/controllerProof.vectors.ts). The
// service verifies these bytes, so the phone must reproduce them exactly.
const v = {
jwk: { kty: "EC", crv: "P-256", x: "FelvUaklSvIURZzOclLu5BBoKwDzjYHqf1O4quuqWCA", y: "bhFRUDuvHP0ARL9G26uoySCYF_YUSmmVfgJPkqJkaFQ" } as const,
thumbprint: "6z6kTwo2bbB7TA7xu8RgrZusCXrW5vWsXINpYlQLYOQ",
deviceId: "rc_jYC8UQU0VMlnqQuN8s_bqulPqAchjxNYSm2fRkQSiGc",
host: "ab".repeat(32),
nonce: "cd".repeat(32),
userId: 7,
enrollHex: "726561736f6e69782d636f6e74726f6c6c65722d76310006656e726f6c6c004063646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364000137004061626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162002b367a366b54776f32626242375441377875385267725a7573435872573576577358494e70596c514c594f51",
signature: "CGP4TsOSCxKrRY6WAN7NnA1-a05DNGduZokdcn69E5D5DTlIxVzZm6dFJNlaDW-3Y-wy2VYCZoCoMNH7NZjB2w",
pushHex: "726561736f6e69782d636f6e74726f6c6c65722d7631000470757368004063646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364636463646364000137004061626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162002e72635f6a59433855515530564d6c6e7151754e38735f6271756c50714163686a784e59536d3266526b5153694763002b5f7539764c6f694e5a754d7043364e7a5f2d795f334e49465039765f36506e37564c5278346d7447464a41",
};
const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
describe("the phone's proof bytes match the accounts service", () => {
it("names the key and the device the way the service does", async () => {
expect(await thumbprintOf(v.jwk)).toBe(v.thumbprint);
expect(await deviceIdFor(v.host, v.thumbprint)).toBe(v.deviceId);
});
it("encodes the enrollment message byte for byte, and the service's signature verifies over it", async () => {
const message = enrollMessage({ nonce: v.nonce, userId: v.userId, host: v.host, thumbprint: v.thumbprint });
expect(hex(message)).toBe(v.enrollHex);
const key = await crypto.subtle.importKey("jwk", v.jwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
expect(await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, fromBase64url(v.signature), message)).toBe(true);
});
it("encodes the push message byte for byte", async () => {
const message = await pushMessage({
nonce: v.nonce, userId: v.userId, host: v.host, deviceId: v.deviceId,
endpoint: "https://fcm.googleapis.com/fcm/send/abc", p256dh: "BPp-key", auth: "auth-secret",
});
expect(hex(message)).toBe(v.pushHex);
});
it("binds the endpoint and both keys: changing any one changes the message", async () => {
const base = { nonce: v.nonce, userId: v.userId, host: v.host, deviceId: v.deviceId, endpoint: "https://e/1", p256dh: "k", auth: "a" };
const same = hex(await pushMessage(base));
for (const change of [{ endpoint: "https://e/2" }, { p256dh: "k2" }, { auth: "a2" }]) {
expect(hex(await pushMessage({ ...base, ...change }))).not.toBe(same);
}
});
});
describe("a generated device key", () => {
it("signs in the 64-byte form the service accepts, and the private half cannot be exported", async () => {
const key = await generateDeviceKey();
expect(fromBase64url(await sign(key.privateKey, new Uint8Array([1, 2, 3]))).length).toBe(64);
await expect(crypto.subtle.exportKey("jwk", key.privateKey)).rejects.toThrow();
expect(base64url(fromBase64url(key.publicKey.x))).toBe(key.publicKey.x);
});
});
@@ -0,0 +1,79 @@
// The phone's half of the controller proof: the same bytes the accounts service
// verifies in auth/controllerProof.ts. A signature is WebCrypto's IEEE P1363
// form (r and s, 32 bytes each), which is what the service expects.
const encoder = new TextEncoder();
type Bytes = Uint8Array<ArrayBuffer>;
const PROOF_LABEL = "reasonix-controller-v1";
const ID_LABEL = "reasonix-rc-v1";
export interface DeviceJwk {
kty: "EC";
crv: "P-256";
x: string;
y: string;
}
export function base64url(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function fromBase64url(text: string): Bytes {
const padded = text.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text.length / 4) * 4, "=");
return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
}
async function sha256(input: string): Promise<Bytes> {
return new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(input)));
}
export async function thumbprintOf(jwk: DeviceJwk): Promise<string> {
return base64url(await sha256(`{"crv":"${jwk.crv}","kty":"${jwk.kty}","x":"${jwk.x}","y":"${jwk.y}"}`));
}
export async function deviceIdFor(host: string, thumbprint: string): Promise<string> {
return `rc_${base64url(await sha256(`${ID_LABEL}${host}${thumbprint}`))}`;
}
function lengthPrefixed(parts: string[]): Bytes {
const chunks = [encoder.encode(PROOF_LABEL)];
for (const part of parts) {
const bytes = encoder.encode(part);
chunks.push(new Uint8Array([bytes.length >> 8, bytes.length & 0xff]), bytes);
}
const out = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
let at = 0;
for (const chunk of chunks) {
out.set(chunk, at);
at += chunk.length;
}
return out;
}
export function enrollMessage(input: { nonce: string; userId: number; host: string; thumbprint: string }): Bytes {
return lengthPrefixed(["enroll", input.nonce, String(input.userId), input.host, input.thumbprint]);
}
export async function pushMessage(input: {
nonce: string; userId: number; host: string; deviceId: string; endpoint: string; p256dh: string; auth: string;
}): Promise<Bytes> {
const fields = [input.endpoint, input.p256dh, input.auth].map((part) => `${part.length}:${part}`).join("|");
return lengthPrefixed(["push", input.nonce, String(input.userId), input.host, input.deviceId, base64url(await sha256(fields))]);
}
export interface DeviceKey {
publicKey: DeviceJwk;
privateKey: CryptoKey;
}
export async function generateDeviceKey(): Promise<DeviceKey> {
const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
return { publicKey: { kty: "EC", crv: "P-256", x: String(jwk.x), y: String(jwk.y) }, privateKey: pair.privateKey };
}
export async function sign(key: CryptoKey, message: Bytes): Promise<string> {
return base64url(new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, message)));
}
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from "vitest";
import { accountClient } from "./phone_device";
import { EnableRefused, disablePush, enablePush, pushStatus, setCategories, type BrowserSubscription, type PushBrowser } from "./phone_push";
import { base64url, fromBase64url, generateDeviceKey, pushMessage, sign } from "./phone_proof";
const HOST = "ab".repeat(32);
const DEVICE = { id: "rc_device", state: "active" as const, name: "iPhone Safari" };
const SUBSCRIPTION: BrowserSubscription = { endpoint: "https://fcm.googleapis.com/fcm/send/xyz", p256dh: "BPp-key", auth: "auth-secret" };
const VAPID = base64url(new Uint8Array(65).fill(4));
const CATEGORIES = { approval: true, input: false, error: true };
function browser(over: Partial<PushBrowser> = {}): PushBrowser & { subscribed: Uint8Array[]; unsubscribed: number } {
const state = { subscribed: [] as Uint8Array[], unsubscribed: 0 };
return Object.assign(state, {
supported: () => true,
permission: () => "default" as NotificationPermission,
requestPermission: async () => "granted" as NotificationPermission,
subscribe: async (key: Uint8Array) => { state.subscribed.push(key); return SUBSCRIPTION; },
unsubscribe: async () => { state.unsubscribed++; },
...over,
});
}
function server() {
const calls: { method: string; path: string; body: Record<string, unknown> }[] = [];
const send = (async (url: string, init?: RequestInit) => {
const path = url.replace("https://id.test", "");
const body = init?.body ? JSON.parse(String(init.body)) : {};
calls.push({ method: String(init?.method), path, body });
const reply = (status: number, data?: unknown) => new Response(data === undefined ? null : JSON.stringify(data), { status });
if (path === "/me/remote-push/key") return reply(200, { publicKey: VAPID });
if (path === "/me/remote-controllers/challenge") return reply(200, { nonce: "ef".repeat(32) });
if (init?.method === "PUT") return reply(200, { subscription: { categories: body.categories, lastFailureAt: null, lastFailureStatus: null, failureCount: 0 } });
if (init?.method === "PATCH") return reply(200, { subscription: { categories: body.categories, lastFailureAt: null, lastFailureStatus: null, failureCount: 0 } });
if (init?.method === "DELETE") return reply(204);
if (path === "/me/remote-push/rc_none") return reply(404, { error: { code: "controller_not_found" } });
return reply(200, { subscription: { categories: CATEGORIES, lastFailureAt: "t", lastFailureStatus: 410, failureCount: 3 } });
}) as unknown as typeof fetch;
return { calls, account: accountClient("https://id.test", send) };
}
describe("switching phone notifications on", () => {
it("asks permission, subscribes with the service's key, and attaches the address with a proof over it", async () => {
const s = server(), b = browser(), key = await generateDeviceKey();
const status = await enablePush(s.account, b, (m) => sign(key.privateKey, m), HOST, DEVICE, 7, CATEGORIES);
expect(status.categories).toEqual(CATEGORIES);
expect(Array.from(b.subscribed[0])).toEqual(Array.from(fromBase64url(VAPID)));
const put = s.calls.find((c) => c.method === "PUT")!;
expect(put.path).toBe("/me/remote-push/rc_device");
expect(put.body.subscription).toEqual({ endpoint: SUBSCRIPTION.endpoint, keys: { p256dh: SUBSCRIPTION.p256dh, auth: SUBSCRIPTION.auth } });
const message = await pushMessage({ nonce: "ef".repeat(32), userId: 7, host: HOST, deviceId: "rc_device", ...SUBSCRIPTION });
const verifier = await crypto.subtle.importKey("jwk", key.publicKey, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
expect(await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, verifier, fromBase64url(String(put.body.signature)), message)).toBe(true);
});
it("does not ask again once permission is already granted", async () => {
const request = vi.fn(async () => "granted" as NotificationPermission);
const key = await generateDeviceKey();
await enablePush(server().account, browser({ permission: () => "granted", requestPermission: request }), (m) => sign(key.privateKey, m), HOST, DEVICE, 7, CATEGORIES);
expect(request).not.toHaveBeenCalled();
});
it("stops at a refused permission without telling the account anything", async () => {
const s = server(), b = browser({ requestPermission: async () => "denied" });
await expect(enablePush(s.account, b, async () => "", HOST, DEVICE, 7, CATEGORIES)).rejects.toMatchObject({ reason: "denied" });
expect(s.calls).toEqual([]);
expect(b.subscribed).toEqual([]);
});
it("names a browser that cannot reach its push service, and sends the account nothing", async () => {
const s = server();
const b = browser({ subscribe: async () => { throw new DOMException("Registration failed - permission denied", "AbortError"); } });
await expect(enablePush(s.account, b, async () => "", HOST, DEVICE, 7, CATEGORIES)).rejects.toMatchObject({ reason: "push_service" });
expect(s.calls.map((c) => c.path)).toEqual(["/me/remote-push/key"]);
});
it("refuses a browser without push and a phone the desktop has not approved", async () => {
const s = server();
await expect(enablePush(s.account, browser({ supported: () => false }), async () => "", HOST, DEVICE, 7, CATEGORIES)).rejects.toBeInstanceOf(EnableRefused);
await expect(enablePush(s.account, browser(), async () => "", HOST, { ...DEVICE, state: "pending" }, 7, CATEGORIES)).rejects.toMatchObject({ reason: "not_active" });
expect(s.calls).toEqual([]);
});
});
describe("a switched-on phone", () => {
it("reads its state, including a failing address, and treats none as none", async () => {
const s = server();
expect(await pushStatus(s.account, "rc_device")).toMatchObject({ failureCount: 3, lastFailureStatus: 410 });
expect(await pushStatus(s.account, "rc_none")).toBeNull();
});
it("changes categories without resubscribing", async () => {
const s = server();
expect((await setCategories(s.account, "rc_device", { approval: false, input: true, error: false })).categories).toEqual({ approval: false, input: true, error: false });
expect(s.calls.map((c) => c.method)).toEqual(["PATCH"]);
});
it("switching off removes the account's copy and the browser's subscription", async () => {
const s = server(), b = browser();
await disablePush(s.account, b, "rc_device");
expect(s.calls.map((c) => `${c.method} ${c.path}`)).toEqual(["DELETE /me/remote-push/rc_device"]);
expect(b.unsubscribed).toBe(1);
});
});
@@ -0,0 +1,107 @@
import { AccountError, type Account, type Device } from "./phone_device";
import { fromBase64url, pushMessage } from "./phone_proof";
export interface PushCategories {
approval: boolean;
input: boolean;
error: boolean;
}
export const NO_CATEGORIES: PushCategories = { approval: false, input: false, error: false };
export interface PushStatus {
categories: PushCategories;
lastFailureAt: string | null;
lastFailureStatus: number | null;
failureCount: number;
}
export interface BrowserSubscription {
endpoint: string;
p256dh: string;
auth: string;
}
// Everything the browser decides, behind one seam: whether push exists here, what
// the person answered, and the address the browser hands back.
export interface PushBrowser {
supported(): boolean;
permission(): NotificationPermission;
requestPermission(): Promise<NotificationPermission>;
subscribe(applicationServerKey: Uint8Array<ArrayBuffer>): Promise<BrowserSubscription>;
unsubscribe(): Promise<void>;
}
export const WORKER_URL = "./push-sw.js";
export function realPushBrowser(): PushBrowser {
const registration = () => navigator.serviceWorker.register(WORKER_URL, { scope: "./" }).then(() => navigator.serviceWorker.ready);
return {
supported: () => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window,
permission: () => Notification.permission,
requestPermission: () => Notification.requestPermission(),
async subscribe(applicationServerKey) {
const reg = await registration();
const existing = await reg.pushManager.getSubscription();
if (existing) await existing.unsubscribe();
const sub = (await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })).toJSON();
return { endpoint: sub.endpoint ?? "", p256dh: sub.keys?.p256dh ?? "", auth: sub.keys?.auth ?? "" };
},
async unsubscribe() {
const reg = await navigator.serviceWorker.getRegistration("./");
await (await reg?.pushManager.getSubscription())?.unsubscribe();
},
};
}
export type EnableRefusal = "unsupported" | "denied" | "not_active" | "push_service";
export class EnableRefused extends Error {
constructor(readonly reason: EnableRefusal) {
super(reason);
}
}
// The browser prompt needs a user gesture, so this is called from the press that
// turns the switch on and asks for permission before anything else. Only a
// subscription that carries the device's signature over its own address and keys
// is accepted by the account, so a stolen cookie cannot attach one.
export async function enablePush(
account: Account, browser: PushBrowser, sign_: (message: Uint8Array<ArrayBuffer>) => Promise<string>,
host: string, device: Device, userId: number, categories: PushCategories,
): Promise<PushStatus> {
if (!browser.supported()) throw new EnableRefused("unsupported");
if (device.state !== "active") throw new EnableRefused("not_active");
const answer = browser.permission() === "granted" ? "granted" : await browser.requestPermission();
if (answer !== "granted") throw new EnableRefused("denied");
const { publicKey } = await account.call<{ publicKey: string }>("GET", "/me/remote-push/key");
// Whatever the browser says when it cannot register with its own push service
// is its answer, not the account's.
const subscription = await browser.subscribe(fromBase64url(publicKey)).catch(() => { throw new EnableRefused("push_service"); });
const { nonce } = await account.call<{ nonce: string }>("POST", "/me/remote-controllers/challenge", { host, purpose: "push" });
const signature = await sign_(await pushMessage({
nonce, userId, host, deviceId: device.id, endpoint: subscription.endpoint, p256dh: subscription.p256dh, auth: subscription.auth,
}));
const { subscription: stored } = await account.call<{ subscription: PushStatus }>("PUT", `/me/remote-push/${device.id}`, {
host, nonce, signature, categories, subscription: { endpoint: subscription.endpoint, keys: { p256dh: subscription.p256dh, auth: subscription.auth } },
});
return stored;
}
export async function pushStatus(account: Account, deviceId: string): Promise<PushStatus | null> {
try {
return (await account.call<{ subscription: PushStatus }>("GET", `/me/remote-push/${deviceId}`)).subscription;
} catch (error) {
if (error instanceof AccountError && error.status === 404) return null;
throw error;
}
}
export async function setCategories(account: Account, deviceId: string, categories: PushCategories): Promise<PushStatus> {
return (await account.call<{ subscription: PushStatus }>("PATCH", `/me/remote-push/${deviceId}`, { categories })).subscription;
}
export async function disablePush(account: Account, browser: PushBrowser, deviceId: string): Promise<void> {
await account.call("DELETE", `/me/remote-push/${deviceId}`);
await browser.unsubscribe();
}
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { AccountError, type Account, type KeyStore } from "./phone_device";
import { phoneReminders } from "./phone_reminders";
import type { PushBrowser } from "./phone_push";
import type { DeviceKey } from "./phone_proof";
const HOST = "ab".repeat(32);
const CATEGORIES = { approval: false, input: false, error: false };
const STATUS = { categories: CATEGORIES, lastFailureAt: null, lastFailureStatus: null, failureCount: 0 };
function memoryKeys(): KeyStore & { held: Map<string, DeviceKey> } {
const held = new Map<string, DeviceKey>();
return { held, load: async (h) => held.get(h) ?? null, save: async (h, k) => void held.set(h, k), remove: async (h) => void held.delete(h) };
}
function setup(state: "active" | "pending" | "revoked" = "active") {
const calls: string[] = [];
let subscribed = false;
const account: Account = {
async call<T>(method: string, path: string, body?: unknown): Promise<T> {
calls.push(`${method} ${path}`);
if (path === "/me") return { user: { id: 7 } } as T;
if (path === "/me/remote-controllers/challenge") return { nonce: "ef".repeat(32) } as T;
if (path === "/me/remote-controllers/enroll") {
const id = (body as { controllerId: string }).controllerId;
if (state === "pending") throw new AccountError(403, "controller_unconfirmed", { controller: { id, state, name: "" } });
if (state === "revoked") throw new AccountError(403, "controller_revoked", { controller: { id, state, name: "" } });
return { controller: { id, state, name: "iPhone Safari" } } as T;
}
if (path === "/me/remote-push/key") return { publicKey: "BAQ" } as T;
if (method === "PUT") { subscribed = true; return { subscription: STATUS } as T; }
if (method === "PATCH") return { subscription: { ...STATUS, categories: (body as { categories: typeof CATEGORIES }).categories } } as T;
if (method === "DELETE") { subscribed = false; return undefined as T; }
if (subscribed) return { subscription: STATUS } as T;
throw new AccountError(404, "controller_not_found");
},
};
const browser: PushBrowser = {
supported: () => true, permission: () => "granted", requestPermission: async () => "granted",
subscribe: async () => ({ endpoint: "https://e/1", p256dh: "k", auth: "a" }), unsubscribe: async () => {},
};
const keys = memoryKeys();
return { calls, keys, reminders: phoneReminders(HOST, { account, keys, browser, uaClass: "ios-safari" }) };
}
describe("this phone's reminders", () => {
it("reads as off without making a key or touching the account's device list", async () => {
const s = setup();
expect(await s.reminders.load()).toEqual({ kind: "off" });
expect(s.keys.held.size).toBe(0);
expect(s.calls).toEqual([]);
});
it("switching on enrolls, subscribes with every occasion still off, and then reads as on", async () => {
const s = setup();
const on = await s.reminders.enable();
expect(on).toEqual({ kind: "on", status: STATUS });
expect(s.keys.held.size).toBe(1);
expect(await s.reminders.load()).toMatchObject({ kind: "on" });
});
it("asks for permission before any request leaves, and a refusal sends nothing", async () => {
const s = setup();
const order: string[] = [];
const reminders = phoneReminders(HOST, {
account: { call: async (m: string, p: string) => { order.push(`${m} ${p}`); throw new Error("must not be called"); } } as Account,
keys: s.keys, uaClass: "other",
browser: { supported: () => true, permission: () => "default", requestPermission: async () => { order.push("prompt"); return "denied"; }, subscribe: async () => ({ endpoint: "", p256dh: "", auth: "" }), unsubscribe: async () => {} },
});
await expect(reminders.enable()).rejects.toMatchObject({ reason: "denied" });
expect(order).toEqual(["prompt"]);
expect(s.keys.held.size).toBe(0);
});
it("a phone the desktop has not approved yet waits, and nothing is subscribed", async () => {
const s = setup("pending");
expect(await s.reminders.enable()).toEqual({ kind: "waiting" });
expect(s.calls.some((c) => c.startsWith("PUT"))).toBe(false);
});
it("a phone the desktop removed says so, and can start over with a new key", async () => {
const s = setup("revoked");
expect(await s.reminders.enable()).toEqual({ kind: "removed" });
const old = s.keys.held.get(HOST);
expect(await s.reminders.startOver()).toEqual({ kind: "off" });
expect(s.keys.held.has(HOST)).toBe(false);
await s.reminders.enable();
expect(s.keys.held.get(HOST)).not.toBe(old);
});
it("chooses occasions and switches off", async () => {
const s = setup();
await s.reminders.enable();
const chosen = await s.reminders.choose({ approval: true, input: false, error: true });
expect(chosen).toMatchObject({ kind: "on", status: { categories: { approval: true, error: true } } });
expect(await s.reminders.disable()).toEqual({ kind: "off" });
expect(await s.reminders.load()).toEqual({ kind: "off" });
});
it("choosing on a phone that never enrolled changes nothing", async () => {
const s = setup();
expect(await s.reminders.choose({ approval: true, input: true, error: true })).toEqual({ kind: "off" });
expect(s.calls).toEqual([]);
});
});
@@ -0,0 +1,72 @@
import { currentUserId, enrollDevice, type Account, type Device, type KeyStore } from "./phone_device";
import { disablePush, enablePush, EnableRefused, NO_CATEGORIES, pushStatus, setCategories, type PushBrowser, type PushCategories, type PushStatus } from "./phone_push";
import { deviceIdFor, sign, thumbprintOf } from "./phone_proof";
// Where this phone stands, as the person would put it. "waiting" is the desktop
// not having approved it yet; "removed" is the desktop having taken it back.
export type Reminders =
| { kind: "off" }
| { kind: "waiting" }
| { kind: "removed" }
| { kind: "on"; status: PushStatus };
export interface RemindersDeps {
account: Account;
keys: KeyStore;
browser: PushBrowser;
uaClass: string;
}
export interface PhoneReminders {
supported(): boolean;
load(): Promise<Reminders>;
enable(): Promise<Reminders>;
choose(categories: PushCategories): Promise<Reminders>;
disable(): Promise<Reminders>;
startOver(): Promise<Reminders>;
}
// One computer's reminders on this phone. Nothing is created until enable():
// reading the state never makes a key and never adds a row to the desktop's list.
export function phoneReminders(host: string, deps: RemindersDeps): PhoneReminders {
const { account, keys, browser } = deps;
const known = async () => {
const key = await keys.load(host);
return key ? { key, id: await deviceIdFor(host, await thumbprintOf(key.publicKey)) } : null;
};
const load = async (): Promise<Reminders> => {
const own = await known();
const status = own ? await pushStatus(account, own.id) : null;
return status ? { kind: "on", status } : { kind: "off" };
};
return {
supported: () => browser.supported(),
load,
// The permission prompt needs the press that started this, so it is asked
// before the first network round trip rather than after.
async enable() {
if (!browser.supported()) throw new EnableRefused("unsupported");
if (browser.permission() !== "granted" && (await browser.requestPermission()) !== "granted") throw new EnableRefused("denied");
const device: Device = await enrollDevice({ account, keys, uaClass: deps.uaClass }, host);
if (device.state === "pending") return { kind: "waiting" };
if (device.state === "revoked") return { kind: "removed" };
const key = (await keys.load(host))!;
const status = await enablePush(account, browser, (m) => sign(key.privateKey, m), host, device, await currentUserId(account), NO_CATEGORIES);
return { kind: "on", status };
},
async choose(categories) {
const own = await known();
if (!own) return { kind: "off" };
return { kind: "on", status: await setCategories(account, own.id, categories) };
},
async disable() {
const own = await known();
if (own) await disablePush(account, browser, own.id);
return { kind: "off" };
},
async startOver() {
await keys.remove(host);
return { kind: "off" };
},
};
}
+46
View File
@@ -39,6 +39,51 @@ export interface PluginRuntime {
tools?: string[];
}
// What the install preview shows about a Claude Code mod's hooks module, read
// from its syntax tree. A listing, not a guarantee.
export interface ModPreviewEvent {
name: string;
// Not a literal: the name cannot be listed.
dynamic?: boolean;
}
export interface ModPreviewCall {
namespace: string;
method?: string;
dynamic?: boolean;
}
// Code the module pulls in that the listing cannot see.
export interface ModPreviewImport {
spec: string;
kind: "static" | "require" | "dynamic";
}
// eval, new Function, a computed member of the API: parts of the module are unlistable.
export interface ModPreviewFinding {
kind: string;
}
export interface ModPreview {
plugin: string;
// The module's path inside the package, and the hash of the bytes previewed.
module: string;
sha256: string;
// False when the module could not be read (typescript | too_large | unparseable | timeout |
// unsupported_module): say "not previewed", never "clean".
listed: boolean;
reason?: string;
events: ModPreviewEvent[];
api: ModPreviewCall[];
imports: ModPreviewImport[];
findings: ModPreviewFinding[];
// What the module takes from the session or queues into it, by literal call:
// "messages" and "usage" read it, "submit" queues a prompt.
session: string[];
// Literal events this host never raises.
unserved: string[];
}
export interface PluginSkipped {
capability: string;
path?: string;
@@ -106,6 +151,7 @@ export interface PluginAction {
env?: Record<string, string>;
headers?: Record<string, string>;
runtime?: PluginRuntime & { fullTrust: boolean };
mod?: ModPreview;
warnings?: string[];
error?: string;
next?: string;
+82 -11
View File
@@ -27,8 +27,9 @@ import type { NetworkProbe, NetworkSettings } from "./network";
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, JobOutput, Preset, RewindPlan, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, SessionUsage, SourceUsage, WalletLine, WalletReading, PlanAction } from "./session";
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
import type { SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan } from "./skill";
import type { SubagentRun, SubagentTranscript } from "./subagent";
import type { StudioCrash, StudioLogs, UpdateMode, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo } from "./workspace";
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo } from "./workspace";
// The port is one contract; its subjects each keep their own file, the way the
// wire and the layers below already do. This is where a reader still finds
@@ -40,17 +41,18 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
RewindResult, RewindUndo, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus, SessionUsage, SourceUsage,
ShellOption, ShellSettings, SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan, UpdateProgress, VersionEntry,
StudioCrash, StudioLogs, UpdateMode, VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo };
StudioCrash, StudioLogs, UpdateMode, VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo };
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
import type { Appearance, ThemeImport, ThemePack } from "./look";
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, WebAccessPatch, WebAccessSettings, BrowserToolsSettings, SubagentClaimsSettings, WriteLeaseSettings, PlanApprovalSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, SessionRefSettings, AutoArchiveSettings, ChangedFilesSettings, FeedbackLevelNotices, FoldWorkSettings, TurnNarrationSettings, ObservabilityPatch, ObservabilitySettings, WebAccessPatch, WebAccessSettings, SearxngProbe, BrowserToolsSettings, SubagentClaimsSettings, SubagentSteerSettings, WriteLeaseSettings, PlanApprovalSettings, DefaultModeSettings, ToolSettings, PeerReceipt, PeersState, PeersSettings, PeersSettingsEdit, PeerRemember, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
import type { Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
export type { ModelEffort, ModelLimit, ModelRate, Protocol, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
import type { FeedbackEnv, FeedbackImage, FeedbackLinkedInstall, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
import type { FeedbackBoard, FeedbackBoardMe, FeedbackEnv, FeedbackImage, FeedbackLinkedInstall, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
import type { PersonaDraft, PersonaSettings } from "./boundary";
export type * from "./plugin";
export type * from "./market";
@@ -157,6 +159,17 @@ export interface NotifyPrefs {
feedbackReply: boolean;
diskLow: boolean;
sound: boolean;
// What this desktop tells its enrolled phones about.
phone: PhoneNotifyPrefs;
}
/** Only the kind of occasion and a count are ever sent to a phone: the master
* switch and which occasions it reports are the desktop's to decide. */
export interface PhoneNotifyPrefs {
enabled: boolean;
approval: boolean;
input: boolean;
error: boolean;
}
export interface TrayPrefs {
@@ -189,6 +202,10 @@ export interface AgentPort {
setSkillEnabled(name: string, enabled: boolean, scope?: ScopeLayer, root?: string): Promise<void>;
planSkillUpdate(name: string): Promise<SkillUpdatePlan>;
applySkillUpdate(name: string, planId: string, overwriteLocal: boolean): Promise<void>;
// Deletes the skill the person was shown, named by the scope and path the list
// drew. A built-in or a package's skill is refused (skill.not_removable), and
// one that changed since the list was read is too (skill.changed).
removeSkill(name: string, scope: string, path: string, root?: string): Promise<void>;
// Drops this project's exception so the skill inherits again.
clearSkillOverride(name: string, root?: string): Promise<void>;
plugins(): Promise<PluginPackage[]>;
@@ -282,34 +299,54 @@ export interface AgentPort {
saveBrowserTools(enabled: boolean): Promise<BrowserToolsSettings>;
subagentClaims(): Promise<SubagentClaimsSettings>;
saveSubagentClaims(optimistic: boolean): Promise<SubagentClaimsSettings>;
subagentSteerSettings(): Promise<SubagentSteerSettings>;
saveSubagentSteerSettings(maxBytes: number, perMinute: number): Promise<SubagentSteerSettings>;
planApproval(): Promise<PlanApprovalSettings>;
savePlanApproval(auto: boolean): Promise<PlanApprovalSettings>;
defaultMode(): Promise<DefaultModeSettings>;
saveDefaultMode(plan: boolean): Promise<DefaultModeSettings>;
toolSettings(): Promise<ToolSettings>;
saveToolDisabled(name: string, disabled: boolean): Promise<ToolSettings>;
writeLease(): Promise<WriteLeaseSettings>;
saveWriteLease(optimistic: boolean): Promise<WriteLeaseSettings>;
saveWriteLease(edit: { optimistic?: boolean; maxWaitMinutes?: number }): Promise<WriteLeaseSettings>;
peers(): Promise<PeersState>;
sendPeerMessage(to: string, text: string): Promise<PeerReceipt>;
resolvePeerMessage(id: string, approve: boolean, remember?: PeerRemember): Promise<void>;
peersSettings(): Promise<PeersSettings>;
savePeersSettings(edit: PeersSettingsEdit): Promise<PeersSettings>;
mods(): Promise<ModStatus[]>;
modsSettings(): Promise<ModsSettings>;
saveModsSettings(edit: ModsSettingsEdit): Promise<ModsSettings>;
displayCurrency(): Promise<DisplayCurrencySettings>;
// Applies live: the kernel announces it and every ledger rebinds; no rebuild.
saveDisplayCurrency(mode: DisplayCurrencyMode): Promise<DisplayCurrencySettings>;
changedFilesSettings(): Promise<ChangedFilesSettings>;
saveChangedFilesSettings(show: boolean): Promise<ChangedFilesSettings>;
feedbackLevelNotices(): Promise<FeedbackLevelNotices>;
saveFeedbackLevelNotices(enabled: boolean): Promise<FeedbackLevelNotices>;
foldWorkSettings(): Promise<FoldWorkSettings>;
saveFoldWorkSettings(fold: boolean): Promise<FoldWorkSettings>;
turnNarrationSettings(): Promise<TurnNarrationSettings>;
saveTurnNarrationSettings(level: TurnNarrationSettings["level"]): Promise<TurnNarrationSettings>;
observabilitySettings(): Promise<ObservabilitySettings>;
saveObservabilitySettings(patch: ObservabilityPatch): Promise<ObservabilitySettings>;
webAccessSettings(): Promise<WebAccessSettings>;
saveWebAccessSettings(patch: WebAccessPatch): Promise<WebAccessSettings>;
probeSearxng(): Promise<SearxngProbe>;
progressWatch(): Promise<ProgressWatchSettings>;
// Applies to the running turn at its next round; no rebuild.
saveProgressWatch(s: Pick<ProgressWatchSettings, "pause" | "rounds" | "tokenMultiple">): Promise<ProgressWatchSettings>;
askTimeout(): Promise<AskTimeoutSettings>;
saveAskTimeout(seconds: number): Promise<AskTimeoutSettings>;
sessionRefSettings(): Promise<SessionRefSettings>;
saveSessionRefSettings(tokens: number): Promise<SessionRefSettings>;
autoArchive(): Promise<AutoArchiveSettings>;
// Read by the kernel's background sweep on each pass; no rebuild.
saveAutoArchive(s: Pick<AutoArchiveSettings, "enabled" | "days">): Promise<AutoArchiveSettings>;
persona(): Promise<PersonaSettings>;
// The persona is part of the cache-stable prefix, composed once at boot:
// saving is written now and read by the next session, not this one's runtime.
savePersonaSettings(s: PersonaDraft): Promise<PersonaSettings>;
receiptSettings(): Promise<ReceiptSettings>;
saveReceiptSettings(show: boolean): Promise<ReceiptSettings>;
// null in a browser tab: there is no window to keep running.
@@ -331,6 +368,8 @@ export interface AgentPort {
trustMcp(name: string, digest: string): Promise<{ state: string; tools?: number; error?: string }>;
// Blocks until the browser sign-in ends (up to five minutes); a refusal is a thrown coded error.
signinMcp(name: string): Promise<{ name: string; state: string; host?: string; tools?: number }>;
// Like signinMcp, for the access a server asked for; the kernel asks the person every time.
upgradeMcp(name: string): Promise<{ name: string; state: string; host?: string; tools?: number }>;
followMcp(name: string, follow: boolean): Promise<{ name: string; follow: boolean; tools?: number }>;
setMcpEnabled(name: string, enabled: boolean, scope?: ScopeLayer, root?: string): Promise<void>;
clearMcpOverride(name: string, root?: string): Promise<void>;
@@ -446,12 +485,22 @@ export interface AgentPort {
replyFeedback(receipt: string, body: string, images?: FeedbackImage[]): Promise<FeedbackReplyReceipt>;
// Records that the thread was shown up to reply upTo, so those replies stop counting as new.
feedbackSeen(receipt: string, upTo: number): Promise<void>;
// Records that the person was told about this level; only a dismissal calls it.
ackFeedbackPromotion(level: number): Promise<void>;
// Takes one of this install's reports back; refused with feedback.not_withdrawable once it has an outcome.
withdrawFeedback(receipt: string): Promise<void>;
// Tying this install to the signed-in account is always the person's own
// request. Linking changes no quota; unlinking only removes the join.
feedbackLinks(): Promise<FeedbackLinkedInstall[]>;
linkFeedbackAccount(): Promise<void>;
unlinkFeedbackAccount(): Promise<void>;
unlinkFeedbackInstall(id: string): Promise<void>;
// The public contribution board, all releases or one; a person is on it only by
// their own choice, under a name they pick, and can leave at any time.
feedbackBoard(release?: string): Promise<FeedbackBoard>;
feedbackBoardMe(): Promise<FeedbackBoardMe>;
joinFeedbackBoard(displayName: string): Promise<FeedbackBoardMe>;
leaveFeedbackBoard(): Promise<FeedbackBoardMe>;
// Asks the window to raise the neutral "new reply" notification; the kernel
// answers for the switches. Rejects where there is no window to raise it.
announceFeedbackReply(): Promise<void>;
@@ -533,13 +582,20 @@ export interface AgentPort {
// What the working tree actually differs by. Tool events cannot answer it: a
// file created and then removed by a shell command leaves both events behind
// and nothing on disk.
changes(): Promise<WorkspaceChanges>;
// repo names a repository below the workspace as gitRepos lists it; absent is
// the workspace's own.
changes(repo?: string): Promise<WorkspaceChanges>;
// The workspace's repositories: itself when it is one, otherwise those found below it.
gitRepos(): Promise<GitRepos>;
// The work tree's identity as git itself reports it: which branch, or which
// commit when HEAD is detached. This is the composer's branch reading — the
// capability scope's file-derived answer is for the picker's project name.
workspaceGit(): Promise<WorkspaceGit>;
workspaceGit(repo?: string): Promise<WorkspaceGit>;
// The commit graph's newest page, read-only. `limit` bounds it (default 100).
workspaceGitLog(limit?: number): Promise<import("./workspace").GitHistory>;
workspaceGitLog(limit?: number, repo?: string): Promise<import("./workspace").GitHistory>;
// What one commit changed, and one path's diff within it. Read-only.
commitFiles(id: string, repo?: string): Promise<GitCommitFiles>;
commitDiff(id: string, path: string, repo?: string): Promise<ChangeDiff>;
// The repository's local branches, the current one marked, for the composer's
// branch menu. Re-read on open: a terminal or another window may have moved
// HEAD since this pane last looked.
@@ -551,10 +607,16 @@ export interface AgentPort {
// What one of those paths actually differs by. The list says a file moved;
// only this says how, and asking per path is what keeps a session that
// touched two hundred files from shipping two hundred diffs nobody opened.
changeDiff(path: string): Promise<ChangeDiff>;
changeDiff(path: string, repo?: string): Promise<ChangeDiff>;
// Puts one modified or deleted file back as the last commit has it. Nothing keeps a copy.
discardChange(req: DiscardRequest): Promise<void>;
// Drafts a message for what is staged. Nothing is committed: the person edits
// the text, and commitStaged records it locally once they confirm.
proposeCommit(signal?: AbortSignal): Promise<CommitProposal>;
proposeCommit(signal?: AbortSignal, repo?: string): Promise<CommitProposal>;
// Where the repository's current branch would be pushed and what it would send,
// read without reaching the remote; push sends exactly that plan and never forces.
pushPreview(repo?: string): Promise<PushPlan>;
push(req: PushRequest): Promise<PushResult>;
commitStaged(req: CommitRequest): Promise<CommitResult>;
// Moves changes the workspace lists into or out of the index, so a commit has
// something to record without a terminal.
@@ -572,7 +634,7 @@ export interface AgentPort {
// that knows where the file came from uses dropRefs and copies nothing.
// Naming the bytes is what stores them as that kind of file; unnamed bytes
// must prove they are an image.
attach(blob: Blob, name?: string): Promise<Attachment>;
attach(blob: Blob, name?: string, signal?: AbortSignal): Promise<Attachment>;
// Keeps a pasted text as a file in the attachment directory and answers with
// the pointer that replaces it in the message. Only on a person's say-so.
keepText(text: string): Promise<KeptText>;
@@ -629,6 +691,15 @@ export interface AgentPort {
// Ends one running tool call and lets the turn go on; a call that already
// finished is refused (tool_call.not_running).
stopToolCall(callId: string): Promise<void>;
// The sub-agent runs this conversation spawned, running or finished, newest
// first, and one run's conversation a page at a time: a running run is read
// live. A run of another conversation is refused (subagent.not_owned).
subagents(): Promise<SubagentRun[]>;
subagentTranscript(ref: string, after?: number, limit?: number): Promise<SubagentTranscript>;
// The person's guidance to a sub-agent that is still running, read at its next
// tool call. Refused with a code when it is not running (subagent.not_running),
// is empty, over the size or rate limit, or is another conversation's.
steerSubagent(ref: string, text: string): Promise<void>;
// The end of a running job's output, for the person looking at it. It does
// not take output from the model's own reader; a job that ended is refused
// (job.not_running).
@@ -63,6 +63,9 @@ export interface ProviderEntry {
// Per model: the price per million tokens it declares for itself. A model
// with none is absent, which means unknown, not zero.
modelPrices?: Record<string, ModelRate>;
// payg, or subscription_equivalent where the figures are an API-priced estimate
// of a plan rather than what is billed.
billingMode?: string;
// A vendor's own endpoint: its table can say whether a stored price is its own.
officialPricing?: boolean;
// How the latest plain test of this saved entry ended, in this kernel process.
@@ -82,6 +85,13 @@ export interface ProviderEntry {
idleTimeoutSeconds?: number;
// What a source without one waits; the kernel owns the number.
idleTimeoutDefault?: number;
// The wallet endpoint and the format its answer is read in. impliedBalanceFormat
// is what the saved address's own host implies when none is declared, empty for
// a relay, which must declare one; balanceFormats is the closed set to offer.
balanceUrl?: string;
balanceFormat?: string;
impliedBalanceFormat?: string;
balanceFormats?: string[];
headers?: Record<string, string>;
extraBody?: Record<string, unknown>;
}
@@ -249,6 +259,10 @@ export interface ProviderEdit {
maxOutputTokens?: number;
// 0 is the built-in default; omitted leaves the stored value alone.
idleTimeoutSeconds?: number;
// The wallet endpoint and its format; omitted leaves them alone, an empty
// address removes the readout and its format.
balanceUrl?: string;
balanceFormat?: string;
headers?: Record<string, string>;
extraBody?: Record<string, unknown>;
// Which request shape controls thinking here. "" is auto — no declaration,
@@ -278,6 +292,10 @@ export interface ModelRate {
output: number;
cacheHit: number;
currency: string;
// Where the price was read and on what UTC date (YYYY-MM-DD). They describe the
// price and take no part in quoting it.
sourceUrl?: string;
checked?: string;
// True when the card is one the vendor's table has; sent by the kernel, never back.
official?: boolean;
}
Loaded 100 of 1184 files, more files were not shown because too many files have changed in this diff. Show more