Compare commits

...
355 Commits
Author SHA1 Message Date
YHH f73421aa65 Merge pull request #12422 from esengine/docs/release-notes-2.32.0
docs(release): Studio 2.32.0 notes
2026-10-08 05:00:52 -07:00
yhh 1128e4dab9 docs(release): Studio 2.32.0 notes 2026-10-08 04:59:12 -07:00
YHH 44f87ce11e Merge pull request #12413 from esengine/fix/running-delegation-marked-at-start
fix(agent): mark a proxied sub-agent call as a delegation while it runs
2026-10-08 04:35:13 -07:00
YHH d44551ac21 Merge pull request #12414 from esengine/fix/refused-call-not-interrupted
fix(studio): show a call the host refused as not run, not interrupted
2026-10-08 04:31:12 -07:00
YHH 680732c5ed Merge pull request #12416 from esengine/fix/recovered-inbox-notice-typed
fix(studio): word the recovered-inbox warning in the window's language
2026-10-08 04:26:44 -07:00
YHH 2e534ca1aa Merge pull request #12415 from esengine/fix/compaction-threshold-shows-effective
fix(studio): say on the compaction threshold field when the capacity guard caps it
2026-10-08 04:22:41 -07:00
YHH e44afbc368 Merge pull request #12342 from KHG420/codex/studio-rewind-restore-prompt
fix(studio): restore rewound prompts to the composer
2026-10-08 04:13:25 -07:00
YHH faf7999823 Merge pull request #12120 from xxoingr/perf/pane-checkpoint-reads
perf(studio): read a pane's checkpoints once on mount
2026-10-08 03:58:28 -07:00
yhh d44a65f3f1 fix(studio): say on the compaction threshold field when the capacity guard caps it 2026-10-08 03:58:04 -07:00
yhh 5edd77861a fix(agent): mark a proxied sub-agent call as a delegation while it runs
A call reached through use_capability only learned its delegation profile after the target finished, so the deck's sub-agent list and the running count missed every sub-agent until it ended. The profile is now announced when execution starts.
2026-10-08 03:53:32 -07:00
yhh 7866a7b1d0 fix(studio): word the recovered-inbox warning in the window's language
The notice now carries its count as a typed payload next to the unchanged English text.
2026-10-08 03:52:30 -07:00
YHH 94b57ca1c0 Merge pull request #12406 from esengine/feat/studio-version-notes
feat(studio): read a version's release notes from the Versions page
2026-10-08 03:48:26 -07:00
yhh 9092e5ede6 fix(studio): show a call the host refused as not run, not interrupted
The argument contract now names its refusal, and the delegation views read that identity instead of treating every failed delegate as interrupted.
2026-10-08 03:45:47 -07:00
YHH d67faee3b5 Merge pull request #12373 from KHG420/codex/studio-queue-send-now-order
fix(studio): send the selected queue item next
2026-10-08 03:42:30 -07:00
esengine 6dcd0d3cc7 fix(studio): keep only declared documents in the notes cache, bound the cache name, keep a row's label open
A notes response is accepted only when its declared type is markdown or plain
text, so an error page served with 200 is refused and never written. The cache
name is lower-cased with a digest for mixed-case prereleases and over-long
versions are refused. Expanding a row on a narrow window keeps its label and
date. Error wrapping and the wait group follow the linter.
2026-10-08 03:33:29 -07:00
YHH 1da6e15962 Merge pull request #12404 from esengine/feat/studio-notes-publish
feat(release): publish each version's notes to the mirror before the catalog names them
2026-10-08 03:29:38 -07:00
YHH 5f07a893ab Merge pull request #12369 from KHG420/codex/studio-folded-thought-reveal
fix(studio): pause reasoning reveal while thoughts are folded
2026-10-08 03:25:18 -07:00
esengine b9c95807f7 feat(studio): read a version's release notes from the Versions page
The kernel serves GET /studio/versions/{version}/notes from the release mirror,
keeps each fetched document on disk for good, shares one request between
concurrent readers and refuses by typed code. A row whose catalog entry names
notes gets an inline view with images off.
2026-10-08 03:19:27 -07:00
esengine 8de80b3fe7 fix(release): publish a version without notes when the notes upload fails
The notes are optional, so a failed upload or head-object warns and writes the
catalog entry without a pointer instead of failing the Mirror to R2 step.
2026-10-08 03:15:03 -07:00
YHH c450fad954 Merge pull request #12403 from esengine/fix/flaky-failed-job-phase
test(control): answer the ask that is on screen, not any registered one
2026-10-08 03:10:32 -07:00
YHH c9a6aab619 Merge pull request #12375 from KHG420/codex/studio-tree-refresh-failure
fix(studio): retain the sidebar tree after a failed refresh
2026-10-08 03:08:52 -07:00
esengine 97aab82b2d feat(release): publish each version's notes to the mirror before the catalog names them
Studio release workflow uploads studio/notes/<version>.md ahead of the catalog
write and gives the new entry an optional notes field. A one-time script
backfills the entries already in the catalog.
2026-10-08 03:00:31 -07:00
YHH fa9d78ce14 Merge pull request #12395 from esengine/perf/pluginpkg-single-pass
perf(pluginpkg): scan skills once per parse, parse outside the state lock
2026-10-08 02:58:49 -07:00
YHH a6374d9b9a Merge pull request #12400 from esengine/fix/flaky-wallet-source-flicker
fix(studio): do not blank the wallet when the first status names its account
2026-10-08 02:57:01 -07:00
yhh e1d65e0377 test(control): answer the ask that is on screen, not any registered one
pendingAsk returned an arbitrary entry of the ask map, including one still queued behind the prompt lock. When the second ask took the lock first, the test answered the queued one and then waited for it while the other held the lock for the user: a hang until the package timeout.
2026-10-08 02:55:57 -07:00
YHH 51a94e6602 Merge pull request #12385 from KHG420/codex/studio-queue-edit-escape
fix(studio): keep Escape in the queue editor from stopping the turn
2026-10-08 02:53:55 -07:00
YHH 70a990428b Merge pull request #12399 from esengine/fix/flaky-workspace-lock-owner
fix(workspacelist): let a caller's deadline bound the lock wait
2026-10-08 02:51:01 -07:00
yhh 226c567194 fix(studio): do not blank the wallet when the first status names its account
useWallet treated the empty account of a pane whose status had not arrived as a source, so the first status turned it into a source change: the wallet read that had already landed was emptied and read again.
2026-10-08 02:47:27 -07:00
yhh 32a293b12a fix(pluginpkg): tell a same-root reinstall apart from the entry a parse started from 2026-10-08 02:46:49 -07:00
YHH c6e3169e48 Merge pull request #12394 from esengine/fix/flaky-failed-job-phase
test(boot): make the failed-job provider independent of the job-finished notice
2026-10-08 02:42:02 -07:00
YHH 18e0cf8224 Merge pull request #12391 from esengine/perf/coalesce-package-loads
perf(config): concurrent plugin package reads share one parse
2026-10-08 02:41:16 -07:00
yhh 94d695393e fix(workspacelist): let a caller's deadline bound the lock wait
Update capped the lock wait at 5s even for a caller that supplied its own deadline, so the number of writers a queue could hold was fixed by the package rather than by the caller. The serve tests now pass a budgeted context and fail on the returned error instead of reading a swallowed one as a wrong count.
2026-10-08 02:39:16 -07:00
yhh 7fab14208a test(config): use WaitGroup.Go in the package-read coalescing test 2026-10-08 02:22:00 -07:00
yhh b7ebb884b9 perf(pluginpkg): scan a package's skills once per parse and parse outside the state lock 2026-10-08 02:21:13 -07:00
yhh 4b12befedf test(boot): key the failed-job provider on the START/CHECK turn, not the last user message
The host appends a background-job-finished notice as a user-role message once the job ends. When it landed before the CHECK turn composed, the provider read the notice as the last user message, took its default branch and never issued wait/read, so the answers it recorded for w1 and r1 were empty.
2026-10-08 02:07:58 -07:00
yhh 7ba9278f08 perf(config): concurrent package reads share one parse
Every config load re-parses each enabled plugin package, and a settings sheet
fires a dozen such loads at once, each queued behind the package store's lock.
With a 293-skill package a burst costs one full parse per request. Callers now
wait for a parse that starts after they arrived and share it, so a burst costs
at most two.
2026-10-08 02:07:25 -07:00
YHH eaac0e8399 Merge pull request #12387 from esengine/fix/import-1x-v5-workspace-routing
fix(migration): file imported 1.x Desktop sessions under their own workspaces
2026-10-08 01:54:43 -07:00
YHH 2872663c2d Merge pull request #12382 from esengine/chore/ui-foundations-f0
chore(studio): F0 ratchet for !important and stylesheet length, browser guards, wheel probe
2026-10-08 01:47:40 -07:00
YHH 1d592d4e61 Merge pull request #12386 from esengine/refactor/studio-pane-split
refactor(studio): split Pane.tsx by concern and lift the pane view into state
2026-10-08 01:42:57 -07:00
YHH e5eb7b892b Merge pull request #12384 from esengine/test/model-switch-keeps-projection
test(boot): a model switch keeps the compaction projection
2026-10-08 01:41:08 -07:00
yhh 40a53ecd8d fix(migration): file imported 1.x Desktop sessions under their own workspaces
The desktop-sessions-v5 by-id store carries no workspace, so every session
from it landed in the workspace picked in the dialog. The owner is recorded
in desktop/workspace-state-v1.json; read it from the picked folder and route
each session to its workspace while that folder still exists.
2026-10-08 01:36:39 -07:00
esengine be9631d18a refactor(studio): split Pane.tsx by concern and lift the pane view into state
Pane.tsx sat at the 800-line ceiling with twelve open pull requests editing
it. The view (flow, analysis, browser) lived in component state, out of reach
of the tab strip and of any setting that will place tabs.

The view now lives in state/paneview.ts, keyed by pane id and owned by the
mounted instance, so a remount for another conversation still starts on the
conversation. Submit, surface slots, the meter row, the plan fold and the run
line move to files of their own; props and the report type move to
panetypes.ts. No visual or behavioural change.
2026-10-08 01:24:34 -07:00
yhh dcb3becaf9 test(boot): a model switch keeps the compaction projection and the system prompt it covers 2026-10-08 01:22:56 -07:00
yhh 78aeea4eac chore(studio): ratchet !important and stylesheet length, wire browser guards, add wheel probe
Adds css-important, css-size and stray-file rules to repolint, deletes the
stray Settings.tsx-e, runs the intrinsic, contrast and concurrent browser
guards in the studio workflow, and adds a report-only wheel-scroll probe.
2026-10-08 01:22:44 -07:00
YHH cc4f466a66 Merge pull request #12136 from KHG420/codex/studio-hook-trial-receipt
fix(studio): associate hook receipts with their trial
2026-10-08 01:20:50 -07:00
xiexian fe483f46d5 fix(studio): consume Escape when dismissing a queued draft 2026-10-08 16:11:14 +08:00
xiexian 5b026929f4 fix(studio): associate hook receipts with their trial 2026-10-08 15:30:49 +08:00
YHH c1f9dc9e1c Merge pull request #12377 from esengine/feat/studio-nav-rail-setting
feat(studio): bring the icon rail back behind an Appearance setting
2026-10-08 00:22:17 -07:00
YHH e9f2b8a6ba Merge pull request #12374 from esengine/fix/window-ground-colour
fix(desktop): match the window background to the page ground
2026-10-08 00:16:46 -07:00
YHH 4cc3f056eb Merge pull request #12372 from esengine/fix/delete-folded-recovery-row
fix(serve): deleting a folded sidebar row also removes the covered recovery copies it hides
2026-10-08 00:14:39 -07:00
YHH f086775c3e Merge branch 'studio' into feat/studio-nav-rail-setting 2026-10-08 00:03:44 -07:00
YHH 8e5c0f3443 Merge branch 'studio' into fix/window-ground-colour 2026-10-08 00:03:06 -07:00
YHH 741b1da8cf Merge branch 'studio' into fix/delete-folded-recovery-row 2026-10-08 00:02:27 -07:00
YHH 63798bff1f Merge pull request #12376 from esengine/fix/settings-extension-models-test-default-model
test(settings): expect the two-call default-model write in the extension refresh test
2026-10-08 00:01:51 -07:00
yhh 6039ba386c feat(studio): bring the icon rail back behind an Appearance setting
The rail was hidden by the studio layer's prototype alignment, which left Nav.tsx rendered but display:none. It is drawn again, beside the workspace column, when this machine has it on (default) and the window is wider than the scene fold.
2026-10-07 23:57:35 -07:00
yhh d64c204309 test(settings): expect the two-call default-model write in the extension refresh test 2026-10-07 23:50:03 -07:00
yhh 9f2f03e703 fix(desktop): give the Studio window a background that matches the page ground
The BrowserWindow carried no backgroundColor, so any pixel the page does not
cover (frameless window at a fractional display scale) showed the default
white. The ground now follows the stored rx-theme, or the system for auto, at
creation and when preferences are saved or the system theme changes.
2026-10-07 23:37:55 -07:00
yhh 6c193b2783 fix(serve): iterate the removal guards with slices.Backward 2026-10-07 23:33:28 -07:00
YHH d383652b6f Merge pull request #12129 from KHG420/codex/studio-hook-editor-draft
fix(studio): preserve hook edits during trial and save
2026-10-07 23:32:01 -07:00
YHH e01c8ed11a Merge pull request #12093 from KHG420/codex/studio-market-publish-navigation
fix(studio): hold navigation during market publication
2026-10-07 23:31:05 -07:00
YHH 88514b8424 Merge pull request #12161 from KHG420/codex/studio-extension-model-refresh
fix(studio): refresh provider models after extension changes
2026-10-07 23:30:09 -07:00
xiexian 310b8f0702 fix(studio): keep session rows after a failed tree refresh 2026-10-08 14:27:00 +08:00
yhh e11b9b86f5 fix(serve): take every guard before deleting a folded row and erase the lead last 2026-10-07 23:16:58 -07:00
yhh 4f17c13990 fix(serve): keep diverged recovery copies when deleting a folded row 2026-10-07 23:10:16 -07:00
xiexian 49094ac587 fix(studio): send the selected queue item next 2026-10-08 14:09:03 +08:00
yhh de9ff2bed0 test(serve): cover open, versioned and held siblings when deleting a folded row 2026-10-07 23:00:13 -07:00
yhh ceacf5ab25 fix(serve): deleting a folded sidebar row removes its whole recovery lineage
The sidebar folds a conversation's recovery copies under one row, but
/tree/sessions/remove deleted only that row's file. The next copy then took
its place, so the delete looked like a no-op with the order shuffled.
Remove the lineage the way archive already addresses it, leaving siblings a
pane has open.
2026-10-07 22:50:01 -07:00
YHH 3a3a720cda Merge pull request #12359 from KHG420/codex/studio-tui-immediate-local-completion
fix(tui): offer local command completion before kernel replies
2026-10-07 22:48:39 -07:00
YHH ad8cb13030 Merge pull request #12363 from KHG420/codex/studio-memorybench-step-budget
fix(bench): let memorybench tasks run without a step cap
2026-10-07 22:40:21 -07:00
YHH 14004b18bb Merge pull request #12366 from KHG420/codex/studio-latest-followed-link
fix(studio): keep the latest followed browser page selected
2026-10-07 22:39:36 -07:00
YHH 8a315bd011 Merge pull request #12358 from KHG420/codex/studio-remote-reuse-missing-token
fix(remote): preserve a live serve when its token is unavailable
2026-10-07 22:38:50 -07:00
xiexian 4b3ad7c29e fix(studio): pause reasoning reveal while thoughts are folded 2026-10-08 13:18:26 +08:00
YHH cb5c0e036c Merge pull request #12125 from KHG420/codex/studio-runtime-confirmation-window
fix(studio): renew each runtime application confirmation
2026-10-07 22:11:30 -07:00
xiexian a591b7044e fix(studio): keep the latest followed browser page selected 2026-10-08 12:45:41 +08:00
xiexian 1092e8c8e2 fix(bench): let memorybench tasks run without a step cap 2026-10-08 12:21:40 +08:00
xiexian 041f40672d fix(tui): offer local command completion before kernel replies 2026-10-08 11:46:31 +08:00
xiexian 3bb435aa10 fix(remote): preserve a live serve when its token is unavailable 2026-10-08 11:34:19 +08:00
YHH cb04e619db Merge pull request #12353 from esengine/fix/refusal-no-guessed-reasoning-cause
fix(provider): stop attributing every refused body to dropped assistant reasoning
2026-10-07 20:26:18 -07:00
yhh 7245b82c6f fix(provider): stop attributing every refused body to dropped assistant reasoning
A 400/422 on a request whose history carried reasoning the connection
could not replay was labelled "the endpoint wants the thinking content
sent back", whatever the endpoint actually said. Dropped reasoning is a
fact about the request, not a cause the host observed, and the endpoint's
refusal carries no typed signal separating the two. The hint is removed;
the refusal now shows the status guidance and the endpoint's own words.
2026-10-07 20:14:57 -07:00
YHH 44e9492be4 Merge pull request #12346 from esengine/docs/studio-2.31.0-notes-refresh
docs(release): refresh the Studio 2.31.0 notes
2026-10-07 18:19:56 -07:00
esengine d8808e587b docs(release): add the network-path behaviour changes and the ARM64 upgrade note to the 2.31.0 notes 2026-10-07 18:08:49 -07:00
YHH 058e700b67 Merge commit from fork
Refuse network paths before touching the filesystem
2026-10-07 18:07:20 -07:00
esengine 040f77bde0 fix(shellsafe): treat network-path operands as non-read-only 2026-10-07 17:10:49 -07:00
yhh f551c4bb60 fix(tools): decide network-path scope before touching the filesystem
File tools resolved a path through the filesystem before comparing it with the
workspace, so a path spelled as a network share was looked up first. On Windows
that lookup is itself a connection to the machine the path names.

A network path is now answered by its spelling alone, with no filesystem call:
it passes only below a workspace root that is itself a network path, compared
lexically and case-insensitively, and is otherwise refused with
workspace.network_path_outside_scope. The check is one function in fileutil and
sits at the entry of the read tools, the writers, their previews and write-path
resolution, write claims and path observation. Local paths and network
workspaces behave as before.
2026-10-07 17:10:49 -07:00
esengine 389cc40beb fix(browser): refuse network paths before touching the filesystem
A path spelled as a network or device path is now answered by its spelling
alone, before any filesystem call, and is refused with its own code
(browser.network_path) instead of the generic outside-workspace one. The
check lives in fileutil next to the containment helpers and covers page
loads and file uploads.
2026-10-07 17:10:49 -07:00
xiexian 26bf6547d9 fix(studio): restore rewound prompts to the composer 2026-10-08 08:04:33 +08:00
YHH 9d04c2be14 Merge pull request #12341 from esengine/docs/studio-2.31.0-notes
docs(release): Studio 2.31.0 notes
2026-10-07 17:02:39 -07:00
yhh 5940eb155a docs(release): Studio 2.31.0 notes 2026-10-07 16:50:45 -07:00
YHH e4aafd6d8e Merge pull request #12339 from esengine/fix/touch-enter-rewrite-and-queue
fix(studio): Enter inserts a newline in the rewrite box and queue editor on touch devices
2026-10-07 14:38:14 -07:00
yhh 07472fb975 fix(studio): Enter inserts a newline in the rewrite box and queue editor on touch devices
Both boxes submitted on Enter regardless of pointer type; they now ask
touchKeyboard() like the composer does. The queue editor also ignores Enter
while an IME composition is open, as the rewrite box already did.
2026-10-07 14:24:37 -07:00
YHH 459deec872 Merge pull request #12333 from esengine/fix/studio-daily-ping-retry
fix(telemetry): retry the daily ping and re-send it each UTC day while the host stays open
2026-10-07 11:21:36 -07:00
esengine d138102a09 fix(telemetry): keep the daily ping retrying and re-send it each UTC day while open
The anonymous ping was a single attempt at launch with a 1 s total budget; a
failed attempt dropped the day's claim and nothing tried again until the next
launch, and a host left open past UTC midnight was never counted again. The
reporter now retries a failed ping with bounded backoff, waits for the next UTC
midnight (plus a splay) after a success, and stops with its context. Consent is
re-read before every attempt, so turning the setting off stops the next send
without a restart. A refusal the server marks permanent (400/401/403/404/410)
is not retried for the rest of the UTC day; 429, 5xx and network errors back
off. Request timeouts are relaxed since the work is off the launch path. The
payload is unchanged.
2026-10-07 10:45:19 -07:00
YHH 68efeef577 Merge pull request #12336 from esengine/fix/tui-telemetry-consent
fix(tui): ask for telemetry consent and report from the interactive terminal UI
2026-10-07 10:32:30 -07:00
esengine 6d0f9efcf6 fix(tui): ask for telemetry consent and report from the interactive terminal UI
The interactive session that carried the first-run consent and the reporter was
removed, and the replacement terminal UI never started either, so CLI 2.x users
were neither asked nor counted. runTUI now runs the documented consent on the
plain terminal before the UI takes the screen, starts the reporter only after a
mode is stored, and wraps the controller sink so the existing bounded counters
flow.
2026-10-07 10:20:41 -07:00
YHH 43bd7accc2 Merge pull request #12298 from esengine/fix/lease-yield-while-asking
fix(lease): release the workspace write claim while a turn waits on ask/approval
2026-10-07 09:37:22 -07:00
YHH 0848ebfd50 Merge pull request #12306 from esengine/fix/subagent-override-visible
fix(settings): say when a subagent_models entry outranks the subagent model
2026-10-07 09:25:35 -07:00
yhh 7a4a690284 fix(lease): release the workspace write claim while a turn waits on the user
An ask or tool-approval wait kept the session's claim for as long as the user
took to answer, so other sessions were blocked on paths nobody was writing.
The claim is now yielded for the wait and taken again, queueing behind any
session that claimed the extent meanwhile, when the user has answered.

Refs #11531
2026-10-07 09:25:32 -07:00
YHH 5a1720f6c9 Merge pull request #12326 from esengine/fix/browser-typed-local-path
fix(browser): a typed path on disk opens as a file, not as a host named by its drive letter
2026-10-07 08:44:29 -07:00
esengine f3dc18f9b1 fix(browser): a typed path on disk opens as a file, not as a host named by its drive letter
The address bar read "D:/DevCode/x.html" as host "d" over http. A drive
letter plus a separator, a leading backslash pair or a single leading slash
now names a file: URL, parsed once in Go (browser.LocalPathURL, used by the
agent's checkURL) and once in the window (localpath.js), both held to one
shared table. The window opens local files for the person; a network share
is refused with its own code instead of a DNS error, and a page's own links
still never reach a file.
2026-10-07 08:29:06 -07:00
YHH 693d0fb9b7 Merge pull request #12322 from esengine/fix/summarizer-not-a-digest
fix(compaction): a summarizer reply that is not a briefing is a failed summary
2026-10-07 08:03:17 -07:00
esengine 62d5c3378b fix(compaction): a summarizer reply that is not a briefing is a failed summary
The summary request is one user message that ends on the last tool output, so
the model sometimes carries on the agent's work (pseudo tool calls, "next I
will ...") and the host installed that reply as the digest: a read-only fold has
no changed file for the coverage guard to compare.

- The user turn now ends with an explicit ask for the briefing.
- A reply with no level-2 heading, the shape the summary prompt requires, fails
  with the typed code summary_not_digest. It is billed to the compaction, is
  retried like any transient summary failure once the input has grown, and
  degrades to the mechanical digest when the fold must free space.
- The code has frontend text in the TUI catalogues and the desktop table.
- Test doubles that answer summary requests now speak the heading contract.
2026-10-07 07:51:24 -07:00
YHH 1c35080ea9 Merge pull request #12317 from esengine/fix/failure-pins-only-its-call
fix(compaction): a failing call pins its own result, not its parallel batch
2026-10-07 07:41:11 -07:00
YHH 1ecd331b15 Merge pull request #12319 from esengine/feat/feedback-levels-s2
feat(feedback): show the earned level, progress and the window a 429 names
2026-10-07 07:32:31 -07:00
esengine 90005c538f feat(feedback): show the earned level, progress and the window a 429 names
The worker now states a profile on GET /v1/feedback/mine and names the
window in every feedback refusal. The kernel client dropped both: the
profile never reached a frontend and a 429 collapsed to "wait N seconds".

- feedback.Mine carries a validated Profile (null when absent or
  incoherent; the last confirmed one while offline, cleared with the
  install identity).
- Refusals decode error.params into a typed LimitError (window, reset
  time, wait) for rate_limited, reply_limit and busy; serve forwards the
  same fields through refuse.
- Studio shows the level row, progress track, limits, trust note and the
  badge artwork (single-colour SVG on text tokens) in My feedback, and
  says which window a refusal ran out of and when it resets.
- The terminal's /feedback list prints the same standing and refusals,
  worded through the i18n catalogue.

Refs #12025
2026-10-07 07:11:41 -07:00
esengine 8c908a1fe8 fix(compaction): a failing call pins its own result, not the batch it ran in
keepToolCallGroup kept every result of a parallel tool-call group when any one
failed. Models batch 4-11 calls per turn, so one failing test pinned the whole
batch of reads, the candidate exceeded the 50% checkpoint ceiling, the billed
summary was discarded, and the non-transient block was never retried.

- The retained assistant turn now issues only the calls whose results stay; the
  folded copy carries the rest, so the summarizer and the coverage guard still
  see them and no request issues a call nothing answers. Turns whose call ids
  cannot be told apart stay whole.
- When the retained failures alone would exceed the ceiling, the fold gives up
  retaining them before the summary is requested; the coverage guard still makes
  the digest name each failed command.
- A candidate_above_ceiling block is released by input growth below the physical
  ceiling, and an overflow on a changed input may fold, instead of holding
  forever.
2026-10-07 06:33:26 -07:00
esengine 34a8dea646 fix(settings): say when a subagent_models entry outranks the subagent model
The Settings subagent row writes subagent_model, while an entry the CLI
writes under subagent_models is read first for its profile, so the row
looked inert. The kernel now lists those entries per role with the file
that holds them, the row names each one, and a user-config entry can be
cleared from the row.

Refs #12151
2026-10-07 05:28:04 -07:00
YHH 9a6c6828e6 Merge pull request #12301 from esengine/fix/default-model-home-machine
fix(settings): record the default model on the machine its models come from
2026-10-07 04:04:23 -07:00
YHH 4a563646c6 Merge pull request #12299 from esengine/fix/subagent-effort-per-model
fix(subagent): resolve inherited subagent effort against the model that runs
2026-10-07 03:47:54 -07:00
esengine 6ecdee4e7b fix(settings): record the default model on the machine its models come from
A brokered pane takes its default from the home machine's catalogue, so a
default_model written next to the pane's own kernel was never read and the
row snapped back. The default is now its own request, POST /default-model,
sent to this machine's pane; the pane's switch stays a plain /model. A
brokered pane refuses the request by code instead of ignoring it.

Refs #12043
2026-10-07 03:42:01 -07:00
YHH 28abc7995b Merge pull request #12297 from esengine/fix/session-data-hint-not-a-refusal
fix(bash): session-data notice is not a refusal; point spill reads at read_file/grep
2026-10-07 03:39:58 -07:00
yhh 924b1689d4 fix(subagent): resolve the inherited subagent effort against the model that runs
agent.subagent_effort was validated only against the parent model and then
applied to whichever model a subagent selected (subagent_models.task, a
skill's own model, a per-profile model, a per-call model) through
NormalizeEffort, which remaps across providers or lets an unsupported
level through. The default is now resolved per executing model by one
function; a level that model does not declare is dropped with a warning and
the model keeps its own default. Explicit efforts still go through
NormalizeEffort, whose refusal now satisfies errors.Is(err,
config.ErrEffortUnsupported) and names the model and level.

The ACP-specific subagent resolver and task defaults had no caller (ACP
sessions are built by boot.Build), so they are removed.

Refs #11871, Refs #11872
2026-10-07 03:35:37 -07:00
YHH fda37354be Merge pull request #12294 from esengine/fix/stop-unconfirmed-12232
fix(desktop): report an unconfirmed stop or decision by who held it up (#12232)
2026-10-07 03:30:00 -07:00
yhh 9fed189018 fix(bash): say the session-data notice is not a refusal and point spill reads at read_file/grep
The notice appended to bash output when a command mentions the session store
read like a refusal: it said to stop retrying and report, and when the command
matched nothing it was the only text returned. A model that had just been told
to read a spilled output gave the output up. State that the command was not
blocked and name read_file/grep for spilled outputs.
2026-10-07 03:27:44 -07:00
YHH b2e3576d20 Merge pull request #11354 from drakeo338/claude/11353-fix
fix(installsource): reject npm package names that resolve as paths, tags or tarballs
2026-10-07 03:18:49 -07:00
yhh 9ab569c529 fix(desktop): report an unconfirmed stop or decision by who held it up, and end a stop on the kernel's own idle report
Cancel had no bounded wait, so a stop the kernel never acknowledged left the
button on "stopping" forever, and stopping only cleared when the running flag
dropped. Cancel now shares the bounded acknowledged post with decisions, which
fails with a typed DeliveryError (kernel_busy, unreachable, ui_stalled). A
deadline that fires far past its time on the monotonic clock is reported as
this window being stalled instead of a busy kernel. A stop also ends when the
kernel's status reports no live turn.

Refs #12232
2026-10-07 03:18:27 -07:00
YHH c94edbcba3 Merge pull request #12283 from esengine/feat/keep-awake-while-running
feat(studio): keep the computer awake while a turn is running
2026-10-07 02:28:19 -07:00
YHH 8f2b8d74cc Merge pull request #12111 from xxoingr/feat/feedback-dialog-fill
feat(studio): let the feedback dialog fill the window
2026-10-07 02:24:28 -07:00
YHH c4e920fbf7 Merge pull request #12286 from esengine/feat/studio-windows-arm64-release
ci(release): ship a native Windows ARM64 Studio
2026-10-07 02:18:14 -07:00
yhh 5f9080ee5e feat(studio): keep the computer awake while a turn is running
The shell held nothing against system suspension, so a laptop that went to
sleep overnight froze every running session. The kernel now answers how many
panes have a turn in flight (GET /tray/running, local and remote, read from each
controller), and the Electron shell holds a prevent-app-suspension block for as
long as that is non-zero, reconciling against the answer on every poll so a
closed pane or a dead kernel cannot leave it held. A machine-level switch in
Settings > Appearance > Window turns it off.
2026-10-07 02:15:44 -07:00
yhh 0ae7693526 ci(release): ship a native Windows ARM64 Studio
Build windows/arm64 on a native windows-11-arm runner and carry it through the
signing chain as a second leg of every Windows stage: one tree per job, legs
serial under the certum-signing group, a failed leg reruns alone. The package
check learns app-arm64.7z and refuses any PE image that is not the leg's
architecture, a new install smoke runs each installer on its own architecture
before publish, and publish counts, names and hash-checks both architectures.

The arm64 installer is packed with the 7-Zip BCJ filter: the default ARM64
filter yields an archive the NSIS extractor cannot decode, and the installer
then exits 0 with every executable missing. Signing contract tests pin the
matrix, the serial legs and the secret-free stages.
2026-10-07 02:02:09 -07:00
YHH bbdcef877e Merge pull request #12272 from esengine/feat/studio-windows-arm64-ci-build
ci(studio): build and smoke-test a native Windows ARM64 package
2026-10-07 01:16:55 -07:00
yhh 46bd67824b ci(studio): build and smoke-test a native Windows ARM64 package
Add a windows-11-arm leg to the Studio package job: the kernel and the
computer-use helper build for the runner's architecture, every PE image in the
bundle must carry the package's machine type (the installer-written
elevate.exe and uninstaller are 32-bit by construction), and the package is
started, installed and uninstalled where it is meant to run. An arm64
installer now refuses a machine that is not ARM64 Windows instead of
installing nothing, and a second job checks that refusal on an x64 runner.

The arm64 installer is built with the 7-Zip BCJ filter: 7-Zip applies an
ARM64 filter to ARM64 executables by default, the NSIS extractor cannot decode
it, and the installer then exits 0 with every executable missing.

The ARM64 leg runs on pull requests only when files it verifies change.

Build and verification only: no secrets, nothing is uploaded to a release.
2026-10-07 01:02:41 -07:00
YHH a8cf3c3f0d Merge pull request #12066 from par73e/codex/12044-running-status
fix(studio): make running status easier to notice
2026-10-07 00:46:33 -07:00
YHH 5f59e763a7 Merge pull request #12285 from esengine/fix/link-page-no-blank-race
fix(desktop): a followed link and the empty column share one tab request
2026-10-07 00:45:44 -07:00
esengine 40a54854a3 fix(desktop): a followed link and the empty column share one tab request
With no browser tab, following a link asked the kernel for the link's tab
and the column, mounting at the same moment, asked for an about:blank of its
own. Which of the two the selection rules landed on depended on reply and
poll order, so the blank page was sometimes the one shown.

openPage remembers what is being opened until the kernel answers, and the
empty column takes that page instead of requesting another.

Refs #12251
2026-10-07 00:37:17 -07:00
YHH ae57da39c1 Merge pull request #12090 from KHG420/codex/studio-theme-inventory-owner
fix(studio): scope theme inventory reads to their connection
2026-10-07 00:35:14 -07:00
YHH e8d84adf69 Merge pull request #12088 from KHG420/codex/studio-theme-import-owner
fix(studio): isolate theme import feedback by connection
2026-10-07 00:34:59 -07:00
YHH d043eb6734 Merge pull request #12282 from esengine/fix/subagent-chip-counts-running
fix(studio): count only running sub-agents on the status chip
2026-10-07 00:15:30 -07:00
YHH 4f9072fd80 Merge pull request #12281 from esengine/fix/extension-sidecar-down-notice
fix(extension): report a missing sidecar as a typed, localized notice
2026-10-07 00:13:29 -07:00
esengine 2cd9b0412d fix(studio): count only running sub-agents on the status chip
The sub-agent chip summed every delegate in the session, finished ones
included, while the background-jobs chip counted only running jobs (and
fell back to the total when none ran). Both chips now show what is
running; the sub-agent popover reports "N running / M total" and keeps
the finished ones listed.
2026-10-07 00:06:09 -07:00
YHH 670cb0d20e Merge pull request #12280 from esengine/fix/vitest-teardown-window
test(studio): an elicitation answer settles inside its test
2026-10-07 00:03:12 -07:00
YHH 8f2ec9c243 Merge pull request #12279 from esengine/feat/model-catalog-discoverability
feat(desktop): make reading the provider's model list discoverable
2026-10-07 00:02:52 -07:00
YHH 6e1956a9bb Merge pull request #12275 from esengine/fix/queue-uncertain-retry
fix(studio): offer Retry on a held uncertain queue entry
2026-10-07 00:02:33 -07:00
esengine cdc88be082 fix(extension): report a missing sidecar as a typed, localized notice
An optional extension whose sidecar is not running was reported as an
English sentence with no code, which the composer's notice bar clipped
at its edge and which named no next step.

dispatch now exposes ErrNoLiveSidecar and a SidecarDown hook; boot turns
it into a notice coded extension_skipped whose Detail is a typed payload
(extension, point, reason). The desktop words it from the payload with a
button to Tools and integrations and lets the bar wrap, the TUI words it
from the i18n catalog, and ACP chunks carry the code and payload.

Refs #12258
2026-10-06 23:53:02 -07:00
YHH f601e38b00 Merge pull request #12274 from esengine/fix/semantic-text-ink
fix(styles): semantic hues read as text through their ink token
2026-10-06 23:47:42 -07:00
esengine 317a9acbe3 test(studio): an elicitation answer settles inside its test
The form card's submit awaits onAnswer and clears its in-flight flag in a
finally block. The tests resolved that answer on a 20ms timer they never
waited for, so the setState could run after the jsdom environment was torn
down and vitest exited non-zero on 'window is not defined'. The tests now
wait for the answer to settle, and an afterEach fails any test that leaves
one in flight.
2026-10-06 23:44:07 -07:00
yhh af36e46815 fix(studio): offer Retry on a held queue entry the kernel never sent
An entry parked as uncertain with code steer_unapplied (guidance the turn
never read, e.g. after a stop) kept the queue paused, and the panel offered
Retry only for blocked entries, so Resume was a no-op and the only way out
was withdrawing and resubmitting. Other uncertain codes may already have
run and stay take-back-only.
2026-10-06 23:43:20 -07:00
yhh c6a8c45a5f feat(desktop): make reading the provider's model list discoverable
Rename the model-section action to "Read available models from provider" and
offer the same action beside the add-model field, with the failure shown there.
2026-10-06 23:39:24 -07:00
esengine 304a1e1b03 fix(styles): semantic hues read as text through their ink token
Light --warn is 4.58:1 on white and 3.86:1 on the floating layer, under the
4.5 text line. Text rules that set color to a bare --warn, --ok or --err now
use the matching ink token; borders, washes and glyph marks keep the base.
A structural test fails a new bare hue used as text.
2026-10-06 23:30:36 -07:00
YHH 2ab5f89f2c Merge pull request #12270 from esengine/feat/studio-windows-arm64-updater-contract
test(update): pin the windows-arm64 platform contract
2026-10-06 23:26:52 -07:00
YHH 118caef283 Merge pull request #12271 from esengine/fix/wallet-follows-model-switch
fix(studio): the status-bar wallet follows the pane's model source
2026-10-06 23:25:59 -07:00
YHH 6a10aebb55 Merge pull request #12273 from xxoingr/test/boot-prefix-probe-timing
test(boot): the VCS prefix test takes its own probe answer
2026-10-06 23:23:46 -07:00
yhh f50b748304 fix(studio): the status-bar wallet follows the pane's account
useWallet now takes the account (connection) the wallet belongs to: a change
of account empties the wallet at once and reads again, and only the latest
read may land, so a quick double switch cannot leave the first account's
number up. Switching models within one account keeps the wallet and does not
re-read. An account with no wallet (204) or an unreadable one shows nothing
instead of the previous figure. A reading the source reports as unavailable
is drawn in the warn-ink token and carries a text label; the figure itself is
untouched.

Refs #12266
2026-10-06 23:16:58 -07:00
yhh dde8de4ded test(update): pin the windows-arm64 platform contract before the build ships
The updater and studio-manifest already key packages by GOOS-GOARCH; nothing
pinned what happens around a second Windows architecture. Cover both
installers resolving to their own keys, a manifest without the client's key
resolving nothing, a delta for another architecture meaning no delta here,
and document the platform-key rules in the release runbook.
2026-10-06 23:16:09 -07:00
xxoingr b76a0b6522 test(boot): the VCS prefix test takes its own probe answer
TestEffectVersionControlRidesTheTurnNotThePrefix builds two controllers and
requires byte-identical system messages, but the environment section in that
prefix comes from the process-wide probe cache, keyed on PATH and shared with
every earlier test. The boot package runs longer than the cache's five-minute
TTL on Windows CI, so an answer an earlier test left can expire between the
two arms: the first arm reads it, the second re-probes, and a tool that timed
out once (a cold docker --version) appears in only one prefix.

The test now appends an empty directory no other test uses to PATH, which
keys a fresh probe answer for the first arm that the second reuses seconds
later. PATH lookup order and what each probe resolves to are unchanged.
2026-10-07 14:10:10 +08:00
YHH c379851293 Merge pull request #12260 from esengine/feat/feedback-under-review
feat(feedback): show 'under review' for received reports a maintainer is reading
2026-10-06 22:41:39 -07:00
YHH 55141b6447 Merge pull request #12264 from esengine/fix/tui-close-open-prompts-at-turn-end
fix(tui): close prompts still open when the turn ends
2026-10-06 22:32:09 -07:00
YHH 5f018ae952 Merge pull request #12262 from esengine/feat/mimo-v26-catalog
feat(providers): MiMo V2.6 catalog and V2.5 preset upgrade
2026-10-06 22:26:08 -07:00
YHH fa244e9bb8 Merge pull request #12261 from esengine/fix/hook-fail-closed
fix(hook): block gating events when a hook cannot be evaluated
2026-10-06 22:25:17 -07:00
yhh 09b0b4c1a4 fix(tui): close prompts still open when the turn ends
A second TUI attached to a session kept an ask or approval open after the
turn ended without a decision receipt (the other screen skipped the ask or
cancelled the turn), blocking its input on a prompt the kernel had already
dropped. The desktop already seals these in sealTurn; the TUI transcript now
does the same on turn_done.
2026-10-06 22:06:43 -07:00
YHH 011ab6d31d Merge pull request #12263 from esengine/docs/reasonix-md-prose-budget
docs(memory): split the saved-fact list item to restore the repolint doc-prose budget
2026-10-06 22:00:58 -07:00
yhh af6dfb36a2 docs(memory): split the saved-fact list item to bring REASONIX.md back under its prose budget 2026-10-06 21:54:43 -07:00
yhh 420d408a65 feat(providers): add MiMo V2.6 to the catalog and move shipped V2.5 presets onto it
Xiaomi lists mimo-v2.6-pro, mimo-v2.6-flash and mimo-v2.6-pro-ultraspeed, and takes mimo-v2.5-pro and mimo-v2.5 offline at 2026-10-21 10:00 Beijing time with no replacement model.
2026-10-06 21:47:27 -07:00
yhh d3c728e1bb fix(hook): block gating events when a hook cannot be evaluated
A PreToolUse/UserPromptSubmit hook that could not be evaluated used to be
treated as absent: a malformed matcher never fired, a spawn error was
DecisionError (warn-level), and a payload json.Marshal error was dropped so
the hook ran against empty stdin and passed.

On gating events (and Claude-imported PermissionRequest) each of the three
now blocks, with a typed cause (ErrInvalidMatcher, ErrSpawnFailed,
ErrPayloadUnserializable) carried on Outcome and Notice. The model gets only
structured facts (code, step, event, scope, source, label), never the
script's output or the underlying error prose. Observing events still do not
block; they report the outcome to the user. Save rejects an invalid matcher
on tool-matcher events.

The tests that pinned the old behaviour (malformed regex "should not fire",
spawn-error => DecisionError on PreToolUse) are rewritten to the new contract.
2026-10-06 21:46:11 -07:00
yhh 31c29d9bf0 feat(feedback): show 'under review' for received reports a maintainer is reading 2026-10-06 21:37:15 -07:00
YHH 12c6f48b04 Merge pull request #12259 from esengine/docs/memory-index-not-in-prefix
docs(memory): the saved-fact index is not in the stable prefix
2026-10-06 21:37:12 -07:00
esengine 9c3ab6ffda docs(memory): the saved-fact index is not in the stable prefix
101f2ca12 stopped composing the fact index into the cached prefix, but
SESSION_MEMORY_RETRIEVAL.md and REASONIX.md still said it joins the prefix
at session start. Only pinned bodies and standing instructions do; the
index is reached through the memory tool.
2026-10-06 21:29:55 -07:00
YHH 28fdf58cbd Merge pull request #12256 from esengine/fix/scratch-holder-lock-gc
test(scratch): keep the holder's directory reachable so its owner lock survives GC
2026-10-06 20:56:25 -07:00
YHH 5e58a9ff20 Merge pull request #12252 from esengine/fix/probe-cache-expiry-keeps-prefix
fix(environment): keep the environment section stable when the in-memory probe cache expires
2026-10-06 20:47:36 -07:00
yhh 9d46b22774 test(scratch): keep the holder's directory reachable so its owner lock survives GC 2026-10-06 20:44:04 -07:00
yhh f5b5553295 fix(environment): keep the environment section stable when the in-memory probe cache expires
A cache hit never wrote the snapshot of the directory it was served to, so
when the 5 minute in-memory entry expired the next build probed live and
could render a different section, moving the cached prompt prefix. Adopt the
served answer into that directory's snapshot.

Also print the first differing bytes when the scheduled-run prefix test
fails, so a future flake is diagnosable.
2026-10-06 20:27:29 -07:00
YHH aa47d1afa4 Merge pull request #12248 from esengine/fix/remove-context-budget-push
refactor(agent): stop pushing context pressure; keep the context_budget query tool
2026-10-06 20:21:23 -07:00
YHH 47dec71c78 Merge pull request #12249 from esengine/fix/link-tab-stays-selected
fix(browser): a clicked link's tab stays selected when the agent already has a page
2026-10-06 20:20:10 -07:00
YHH 696b791be6 Merge pull request #12247 from esengine/fix/delta-size-guard
fix(update): skip the chunked update when it would download more than the full package
2026-10-06 19:58:03 -07:00
yhh 362c8381b5 refactor(agent): stop pushing context pressure to the model and the user
The host appended a <context-budget> block to the turn tail at 75% and 92% of
the compaction trigger and raised a matching warning card. The model can still
ask: the read-only context_budget tool and its figures are unchanged, and its
description now says when to check, since nothing reports it unprompted.
2026-10-06 19:56:33 -07:00
esengine e5c74a52c9 fix(browser): open a followed link's tab without ever making it the active one
The window opened a link in a new tab and then switched the active tab back
to the agent's. The page list was re-read between the two, so the panel saw
the agent's active tab move to the new tab and back, and its follow-the-agent
rule selected the agent's tab again: the link's page appeared but was not the
one shown. Session.VisitBeside opens the tab without activating it unless the
session has no active tab, so there is no transient to misread.
2026-10-06 19:56:24 -07:00
YHH 3b27b5c419 Merge pull request #12246 from esengine/fix/feedback-rig-test-race
test(control): guard the feedback rig's stub-server fields with its mutex
2026-10-06 19:40:12 -07:00
yhh 909ecd8544 fix(update): skip the chunked update when it would download more than the full package 2026-10-06 19:39:52 -07:00
yhh d784c31d2b test(control): guard the feedback rig's stub-server fields with its mutex 2026-10-06 19:30:51 -07:00
YHH 2ed678a37c Merge pull request #12242 from esengine/docs/studio-2.30.0-notes
docs(release): Studio 2.30.0 notes
2026-10-06 17:58:50 -07:00
yhh 0107a78275 docs(release): Studio 2.30.0 notes 2026-10-06 17:51:00 -07:00
YHH cc0206dd25 Merge pull request #12241 from esengine/fix/mcp-prompt-failure-attribution
fix(control): attribute a failed MCP prompt fetch and tell the model about it
2026-10-06 15:40:03 -07:00
yhh 47aff7d34f fix(control): attribute a failed MCP prompt fetch and tell the model about it
A prompts/get failure surfaced as the bare transport error, and the model
never learned the user had invoked a prompt. MCPPrompt now returns a typed
MCPPromptError (prompt, server, stage; matches ErrMCPPromptFetch) and owes
the model a turn-tail block describing the failed invocation.
2026-10-06 15:26:39 -07:00
YHH 43a9069b46 Merge pull request #12233 from esengine/feat/session-unread-ui
feat(studio): mark sessions that finished unseen in the sidebar and tabs
2026-10-06 10:19:04 -07:00
esengine 18465053 72d8d622dd feat(studio): mark sessions that finished unseen in the sidebar and tabs
Draw the kernel's typed `unread` on session rows (dot, bold title, spoken
name), as a count pill on folded project rows and as a dot on pane tabs.
A session is marked viewed through the pane's own endpoint when it
becomes the pane in front, when an unread row or pane is clicked, and
when a turn ends in the pane in front while the window has focus. The
mark is cleared optimistically and restored if the call is refused.

Refs #12230
2026-10-06 10:10:12 -07:00
YHH dd92486b5e Merge pull request #12239 from esengine/fix/attachment-refusal-codes
fix(attachments): say why an attachment was refused, with a code per class
2026-10-06 10:06:31 -07:00
yhh 431daf03c4 fix(attachments): say why an attachment was refused, with a code per class
/attachments refused with plain text, so the composer could only show
"Could not add" and left the reason in a tooltip. Refusals now carry
attachment.unsupported_image, too_large, empty, unreadable and write_failed
with params, the port keeps the code, the catalogues say each in both
languages, and the failed chip shows the reason with Retry.
2026-10-06 09:55:16 -07:00
YHH 7b653ddd84 Merge pull request #12237 from esengine/perf/background-panes-stream-rate
perf(studio): stop panes behind another from redrawing at stream rate
2026-10-06 09:52:31 -07:00
esengine 3f2a2760f4 perf(studio): stop panes behind another from redrawing at stream rate
Panes that are not on screen kept re-rendering on every streamed delta, and
each of those renders also rebuilt the reply actions handed to every settled
row. With nine sessions streaming the renderer's main thread saturated and
queued work (status reads, stop, settings) starved.

A hidden pane now takes the first token of each stream at once and coalesces
the rest into one batch per second; any other event flushes first and bringing
the pane forward flushes immediately. Reveal pacing, the thinking clock and the
wait clock only run for a pane that is shown. The reply actions keep their
identity while a streamed answer grows, and the folded thought's length is no
longer recounted per delta.

Refs #12232
2026-10-06 09:43:15 -07:00
YHH 42b1d2742a Merge pull request #12234 from esengine/fix/session-viewed-torn-sidecar
fix(session): let a view wait out a sidecar caught mid-replace
2026-10-06 09:32:41 -07:00
esengine 89a00b8310 fix(session): let a view wait out a sidecar caught mid-replace
MarkSessionViewed pre-checked the session sidecar with a plain unlocked
read. Concurrent views replace the sidecar, so on Windows that read can
meet it mid-replace (access denied, or torn JSON after the replace
fallback) and the view failed with a 500 instead of waiting it out. The
pre-check now uses the tear-tolerant read the other sidecar readers use.

Refs #12230
2026-10-06 09:21:16 -07:00
YHH 7f30fcbdb3 Merge pull request #12231 from esengine/feat/session-unread-kernel
feat(session): persist a finished-but-unviewed mark per session
2026-10-06 08:53:07 -07:00
YHH 7e6a90e954 Merge pull request #12216 from esengine/feat/compaction-progress-record
feat(agent): re-project finished work at the request tail once a fold is installed
2026-10-06 08:51:10 -07:00
YHH 95d0f747a8 Merge pull request #12229 from esengine/fix/remote-serve-attach-12228
fix(remote): attach to a running serve instead of replacing it
2026-10-06 08:41:19 -07:00
esengine 39fbadf232 feat(session): persist a finished-but-unviewed mark per session
The kernel now records two optional facts in the session sidecar,
finished_at (stamped when a turn ends) and viewed_at (stamped when the
person opens the session). A session is unread iff finished_at is after
viewed_at; the sidebar tree and GET /sessions carry it as a typed
`unread` boolean. A turn the person cancelled is stamped as viewed.

Controller.MarkSessionViewed (POST /sessions/viewed) writes only the
session sidecar and takes no workspace or transcript lease. Sidecars
without the fields read as seen.

Refs #12230
2026-10-06 08:40:09 -07:00
esengine 444a38cdd9 feat(agent): re-project finished work at the request tail once a fold is installed
A fold leaves the model with a digest, and the host re-projected only the
step ids of plan-seeded unfinished lists. A task list the model wrote itself
(no ids), a list with every item complete, the files changed and the checks
that passed all survived only as digest prose, which a weaker model reads as
work not yet done and repeats.

While a fold is installed the request now ends with one derived "Host
progress record": the canonical task list with statuses when no todo_write is
left in view, the files changed in the folded prefix, and the verification
commands whose latest outcome was a pass (marked when a file changed after
them). It is derived from tool receipts, bounded (30 files, 15 checks, with
the omitted count), memoised on the covered-prefix fingerprint, and ends with
the fold. Sessions that never folded carry nothing extra.
2026-10-06 08:37:36 -07:00
YHH 021685bc90 Merge pull request #12224 from xxoingr/fix/perf-tooltip-attach-lookup
test(studio): the tooltip guard measures the attach button in every language
2026-10-06 08:26:30 -07:00
YHH 2e0354050c fix(remote): attach to a running serve instead of replacing it
A live serve with no record is attached and recorded, launch no longer
deletes the port/pid files another serve published, and a live serve
whose provider source differs fails the connect with a coded error
instead of being terminated. Document what desktop exit does to the
remote runtime.
2026-10-06 08:25:55 -07:00
drakeo338andClaude Sonnet 5.5 79cf8d34a1 fix(installsource): reject npm and GitHub source names that resolve as paths, tags or tarballs
Package segments made only of dots, leading dots, a leading underscore on
unscoped names and .tgz/.tar/.tar.gz names are no longer accepted by the
installsource and registry checks. parseGitHubRepoSource refuses dot-leading
owners and dot-only repos such as the one TrimSuffix(".git") leaves from
"...git".

Refs #11353

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-06 15:07:01 +00:00
YHH e7d8dea94f Merge pull request #12217 from xxoingr/fix/tui-menu-ctrl-pn
fix(tui): Ctrl+N and Ctrl+P move through the completion menu and resume picker
2026-10-06 08:05:18 -07:00
YHH cb934974a6 Merge pull request #12220 from esengine/fix/feedback-veil-no-backdrop-blur
fix(desktop): drop the feedback veil's backdrop blur so an open dialog does not pin the GPU
2026-10-06 07:51:18 -07:00
YHH e41e54e173 Merge pull request #12223 from xxoingr/fix/tui-ctrl-l-cls
fix(tui): Ctrl+L clears the screen like /cls
2026-10-06 07:46:20 -07:00
YHH e85f182fc8 Merge pull request #12222 from esengine/fix/compaction-trigger-reads-provider-usage
fix(compaction): let the provider's last reported prompt size trigger a fold when the local estimate runs low
2026-10-06 07:43:10 -07:00
par73e fe6e76d41b fix(studio): make running status easier to notice 2026-10-06 22:43:09 +08:00
YHH 4c2ac96604 Merge pull request #12213 from xxoingr/fix/bgjob-failure-tool-error
fix(tools): reading a background job that failed is a failed call
2026-10-06 07:39:33 -07:00
xxoingr 8c48421711 test(studio): the tooltip guard measures the attach button in every language
perf/tooltip.mjs looked the attach button up by its aria-label in each
language, and the English label it searched for, "Add attachment", is not
the shipped one ("Add an attachment"). The lookup found nothing and the loop
skipped on, so the four English attach combinations were never measured and
the guard stayed green.

Controls are now found by structure, data-action for refine and the attach
class for attach, and a control that cannot be found fails the guard rather
than being skipped. All 24 combinations (light/dark x zh/en x two widths x
three cases) are measured: 96 checks, where 80 ran before.

Refs #12156
2026-10-06 22:31:57 +08:00
yhh 04420cca07 fix(desktop): drop the feedback veil's backdrop blur so an open dialog does not pin the GPU
The veil blurred the whole window behind it, which the compositor redoes every frame while anything beneath animates. A flat, more opaque scrim dims the same way for free.
2026-10-06 07:31:38 -07:00
xxoingr de2b1d6240 fix(tui): Ctrl+L clears the screen like /cls
GUIDE.md lists "Ctrl+L or /cls" as clearing only the visible transcript, and
1.x bound the key, but the 2.x terminal UI left Ctrl+L unbound. It now runs
the same clearDisplay as /cls: the conversation, session and the kernel's
task list stay, and a running turn is refused the same way. Being a key
rather than a typed command, it leaves a half-typed draft in the composer.

Refs #12144
2026-10-06 22:28:01 +08:00
esengine d62e4bc71b fix(compaction): let the provider's last reported prompt size trigger a fold when the local estimate runs low
The fold trigger compared only a local estimate against the threshold. That
estimate is characters times one tokens-per-character ratio, and the ratio is
not learned from requests that carry images or provider-side tool results, so a
session with screenshots can sit well under the real size and defer the fold.

The trigger now takes the larger of the local estimate and the provider's
reported prompt_tokens (cached tokens included) for the last request plus the
local estimate of what the visible view gained since. The anchor is dropped when
a fold changes the projection, when the transcript is swapped, and when the
usage is estimated, aggregated over retries, or padded by provider-run tools.
The hard ceiling and the overflow paths still read the local estimate alone, so
a provider count that no fold can lower never ends a turn.
2026-10-06 07:26:33 -07:00
xxoingr b4564e5f8d fix(tui): Ctrl+N and Ctrl+P move through the completion menu and resume picker
The slash and @ completion menu and the /resume picker moved only with the
arrow keys, while 1.x also took Ctrl+N / Ctrl+P there and GUIDE.md and
CLI.md list both for these lists. The provider and quick pickers already
bind them. Both lists now take Ctrl+N as Down and Ctrl+P as Up; neither is
text, so a picker's filter is untouched.

Refs #12144
2026-10-06 22:20:27 +08:00
YHH aa0d80f798 Merge pull request #12212 from xxoingr/fix/probe-refusal-redacted
fix(serve): probe refusals keep the code and leave the endpoint's echo behind
2026-10-06 07:03:50 -07:00
YHH 89df335f78 Merge pull request #12210 from esengine/feat/market-browse-cache
feat(market): serve the last good browse answer, marked, when the registry is unreachable
2026-10-06 06:58:13 -07:00
xxoingr da7ec275f6 fix(tools): reading a background job that failed is a failed call
bash_output and wait reported a background job that later failed or timed
out only in their text, with no tool error. The activity panel and the
failure count read the host-set error, so a job that failed after it started
never turned red and was not counted, while the model was told it failed.

Both tools now return errJobFailed, naming the failed jobs, whenever a job
they report ended as failed (a background command's non-zero exit and its
timeout both end that way). The output is returned alongside, so the model
still reads it under the error line. A job that finished, was killed, was
interrupted or is still running is not a failure and the call stays clean.

Fixes #12201
2026-10-06 21:57:09 +08:00
esengine 98ba2b7e4e feat(market): serve the last good browse answer, marked, when the registry is unreachable 2026-10-06 06:39:03 -07:00
YHH 9d4a2bd6d3 Merge pull request #12209 from esengine/feat/phone-browser-tabs
feat(studio): show the agent's browser tabs read-only on the phone web client
2026-10-06 06:36:24 -07:00
xxoingr a83f6c0229 fix(serve): probe refusals keep the code and leave the endpoint's echo behind
POST /providers/probe built its refusal message from probe.Error(), which
carries the model-list response body verbatim. That body can echo the
request, and this route receives the key typed into the add-provider draft,
so a 401 that quoted the key sent it back in the refusal. A failure that was
not a ProbeError came back as an untyped 502 with the error's text.

The message now carries the probe reason and only what endpointDetail lets
through (one bounded printable line, the draft key and credential-shaped
text redacted), the same treatment /providers/check gives its detail. A
failure without a probe identity is a kernel fault and refuses as 500
provider.probe.failed without its text.

Fixes #12188
2026-10-06 21:34:30 +08:00
esengine 8decb83466 feat(studio): show the agent's browser tabs read-only on shells that cannot draw them
A phone using the web client got an empty Workbench when the agent opened a
page: useBrowserTabs returned early because the web host draws no native
views, so the tab list was never read. Read it on every host and, where the
page cannot be drawn, show its title and address as plain text with no
controls.
2026-10-06 06:27:22 -07:00
xxoingr 8bb3f9d58c perf(studio): read a pane's checkpoints once on mount
Opening a session pane read GET /checkpoints three times against a real
kernel: the mount effect read it, the turn-boundary effect read it again on
its own first run, and a third time when the first status named the session
path.

useCheckpointRefresh now owns every read outside a session reload: the mount
read, a move from one known session to another, and both turn edges. The
kernel opens a turn's checkpoint when the turn starts and fills it while the
turn runs, so the read at turn start stays and the rewind entry still appears
on a running turn's message. The first status naming the session the mount
read covered no longer reads again, so opening a pane costs one request.

A failed read is tried once more after a second, since no later edge is owed
to it, and a read that a newer one superseded is dropped instead of
overwriting the newer list.

Refs #12080
2026-10-06 21:25:50 +08:00
YHH e13eb30dbc Merge pull request #12206 from KHG420/codex/studio-extension-transcript-restore
fix(studio): retain extension cards during history recovery
2026-10-06 06:09:18 -07:00
xiexian ae7fb0c1f5 fix(studio): retain extension cards during history recovery 2026-10-06 20:50:34 +08:00
YHH 7ac14f94af Merge pull request #12194 from KHG420/codex/studio-idle-extension-prompt
fix(studio): restore idle status after questions
2026-10-06 05:35:29 -07:00
YHH ed7dbd54e4 Merge pull request #12205 from KHG420/codex/studio-failed-switch-sidecar-rollback
fix(studio): restore sidecars after model selection fails
2026-10-06 05:33:05 -07:00
YHH 7f2365b4a0 Merge pull request #12202 from esengine/feat/provider-sticky-footer
feat(desktop): sticky Save bar with a phase dot on the provider form (RFC #12040 S1, 2b-2)
2026-10-06 05:22:28 -07:00
yhh 7fa84ab3cc feat(desktop): keep the provider form's Save bar and state at the bottom of the panel
The save area sat at the end of a long form, so its state was out of sight
exactly when it mattered. The bar is now sticky, shared by the edit and add
forms, and holds the failure or not-applied reason beside a single persistent
status line with a dot for each phase (clean, unsaved, saving, saved, saved but
not applied, save refused). Ctrl/Cmd+S saves a pending draft.
2026-10-06 05:13:07 -07:00
YHH c434fc423f Merge pull request #12199 from esengine/feat/skill-paths-listing-3
feat(skill): show a paths-gated skill only after a matching file is touched (3/4 for #11352)
2026-10-06 05:08:50 -07:00
xiexian 285b537f96 fix(studio): restore sidecars after model selection fails 2026-10-06 19:55:52 +08:00
esengine 69cfaefdcc feat(skill): show a paths-gated skill to the model only after a matching file is touched
A skill that declares paths: is held out of the per-turn listing, the
capability catalog, the slash_command listing and the unknown-name hint until
the session has touched a file its globs match. The set of touched files is
owned by the skill store; eligibility is memoised against it and dropped when
it changes, and the next turn after a match owes the listing again. The user's
/<name> and run_skill by exact name are not gated.
2026-10-06 04:43:43 -07:00
YHH 38c1519373 Merge pull request #12198 from esengine/fix/background-start-not-a-failure
fix(desktop): stop drawing a background start as a failed shell call
2026-10-06 04:37:00 -07:00
esengine 66723cc065 fix(desktop): stop drawing a background start as a failed shell call
The shell card and the activity group decided failure a second time from
execution.state and exitCode, so state background_started (no error, no exit
code) rendered a red BACKGROUND_STARTED badge and counted as one failure.
The host already classifies every call once: err is set for failed,
timed-out, cancelled and refused calls. Read only that.
2026-10-06 04:24:06 -07:00
YHH 36e059b7f2 Merge pull request #12196 from esengine/feat/provider-test-enabled-models
feat(desktop): Test enabled models button with a running summary (RFC #12040 S1, 2b-1)
2026-10-06 04:15:49 -07:00
YHH 5d4f4f0259 Merge pull request #12195 from esengine/feat/skill-paths-feed-2
feat(skill): record the files completed tool calls touched (2/4 for #11352)
2026-10-06 04:09:57 -07:00
yhh 34ea5a5d78 feat(desktop): test every enabled model at once from the provider edit form
Verifying a model was one click per row, and the form blocked while any one
ran. The model header gets a Test enabled models button that runs the existing
per-model check over the ticked rows, two at a time, and stops as soon as the
draft changes or the form is left. The summary line counts rows still being
verified, and the per-row live regions are dropped so a run is announced once
through the summary.
2026-10-06 04:08:34 -07:00
esengine 23eec9f92e feat(skill): record the files completed tool calls touched for paths eligibility
The agent tells a PathObserver which workspace files a completed call named,
through the ReadTargeter and WritePathResolver capabilities its tool already
declares; failed, refused and hook-blocked calls feed nothing and sub-agents
are not observed. Swapping the conversation rebuilds the set from the
transcript. skillSet wires its PathHits in; the catalog does not read it yet.
2026-10-06 04:00:42 -07:00
YHH 2a412969b0 Merge pull request #12158 from KHG420/codex/studio-offline-sdk-handoff
docs(sdk): verify starter extension with the offline provider
2026-10-06 03:50:10 -07:00
YHH 4669be38c6 Merge pull request #12105 from chuiwenwei/test/studio-corpus-guard-three
test(e2ebench): hold fanout-width, upstream-edge and project-check to the authoring guard
2026-10-06 03:47:53 -07:00
YHH 9a6936ceb7 Merge pull request #12193 from esengine/feat/provider-results-near-buttons
feat(desktop): provider test results next to their buttons, with a summary line (RFC #12040 S1, 2a)
2026-10-06 03:46:03 -07:00
yhh 26e2eba5ea feat(desktop): show provider test results next to their buttons with a one-line summary
A failed catalogue refresh surfaced under the form's footer and a silent
refresh that found nothing looked like a click that missed. The add form's
connection failure also rendered below its save button.

Refresh failures and outcomes (including 'no new models') now render under the
model header, verified rows are summed up in one status line over the ticked
models, and the add form's connection failure sits under the button that ran
it. Results clear when the address or key they depend on changes.
2026-10-06 03:34:05 -07:00
xiexian 4024c63a43 fix(studio): restore idle status after questions 2026-10-06 18:25:33 +08:00
YHH 722ceb4017 Merge pull request #12162 from KHG420/codex/studio-publication-version-guidance
fix(studio): describe publication version auto-increment limits
2026-10-06 03:09:15 -07:00
YHH fa194e4f91 Merge pull request #12133 from KHG420/codex/studio-memory-history
fix(studio): refresh memory history after writes
2026-10-06 03:06:52 -07:00
YHH 647d7670ba Merge pull request #12192 from esengine/feat/skill-paths-frontmatter-1
feat(skill): parse paths frontmatter and track the files a session touched (1/4 for #11352)
2026-10-06 03:05:45 -07:00
YHH bd12c75dfa Merge pull request #12191 from esengine/feat/provider-leave-guard
feat(desktop): ask before switching service or adding discards unsaved provider edits (RFC #12040 S1, 1.5/3)
2026-10-06 03:03:50 -07:00
esengine 40e8ee8050 feat(skill): parse paths frontmatter and track the files a session touched
Skills may declare file globs in a paths: key. The registry now parses them
(rejecting negation, parent traversal and malformed globs into InvalidPaths,
which doctor reports) and a PathHits set records workspace files the host saw
touched, resolving symlinks so nothing outside the workspace counts. The set
is owned by skillSet; the catalog does not read it yet.
2026-10-06 02:54:26 -07:00
YHH f80741bbd5 Merge pull request #12190 from KHG420/codex/studio-auth-bootstrap-asset-reload
fix(studio): reload protected assets after fragment authentication
2026-10-06 02:53:04 -07:00
yhh 7f43425005 feat(desktop): ask before a provider switch or Add discards unsaved edits
Leaving the edit form for another service, the Add form, another service's
rename, or a protocol switch dropped pending edits without a word.

EditConn reports the pending state it already computes; Providers holds it
and routes every way out of the form through one guard that asks to keep
editing or discard. A save in flight is not interrupted and not asked about,
and the question closes by itself once nothing is pending.
2026-10-06 02:46:35 -07:00
YHH c050ffc1d4 Merge pull request #12155 from KHG420/codex/studio-package-remove-reload-result
fix(studio): settle runtime feedback after package removal
2026-10-06 02:44:56 -07:00
YHH 5fcdd4f970 Merge pull request #12097 from KHG420/codex/studio-skill-namespace-guide
docs(studio): clarify skill frontmatter namespaces
2026-10-06 02:40:11 -07:00
YHH cc24f23cda Merge pull request #12071 from KHG420/codex/studio-copy-fallback-focus
fix(studio): preserve focus after legacy clipboard copy
2026-10-06 02:37:25 -07:00
YHH 97e6bb3dc1 Merge pull request #12121 from KHG420/codex/studio-mcp-example-argument-schema
fix(studio): enforce the MCP example argument schema
2026-10-06 02:35:15 -07:00
xiexian c81f424100 fix(studio): reload protected assets after fragment authentication 2026-10-06 17:32:07 +08:00
YHH 9106bbe805 Merge pull request #12189 from esengine/feat/provider-form-dirty-revert
feat(desktop): unsaved-changes indicator, Revert and a Saved receipt on the provider edit form (RFC #12040 S1, 1/3)
2026-10-06 02:11:55 -07:00
yhh a9a726b921 feat(desktop): show unsaved changes, Revert and a Saved receipt on the provider edit form
The edit form had no way to tell pending edits from stored ones: Save was
always enabled, Cancel was a remount in disguise, and a successful save
silently turned into the same form with nothing said.

Pending state is the fingerprint of what a save would send, compared with the
one taken at mount, so typing a value back or toggling a row off and on is
clean again. Save is disabled when clean, Cancel becomes Revert, and the footer
says whether there are unsaved changes, no changes, or that the save landed
once the list has been re-read.
2026-10-06 02:01:50 -07:00
YHH 15cefe039e Merge pull request #12187 from KHG420/codex/studio-extension-panel-progress-name
fix(studio): name standing extension panel progress
2026-10-06 01:26:06 -07:00
YHH a6eb4e1742 Merge pull request #12185 from esengine/feat/provider-check-typed-failure
feat(providers): report why a whole-connection check failed as a typed finding
2026-10-06 01:25:14 -07:00
YHH de66ad3240 Merge pull request #12186 from xxoingr/fix/refine-tip-described
fix(studio): the prompt-refine button is described by its tooltip
2026-10-06 01:16:37 -07:00
xiexian ba338a2d6b fix(studio): name standing extension panel progress 2026-10-06 16:16:34 +08:00
esengine bfcbf2a8ff feat(providers): report why a whole-connection check failed as a typed finding
Cause: POST /providers/check returned a free-text `error` assembled from the
probe error's message. The probe already knows whether a key was refused, a
path is missing, nothing listed chat models or the deadline expired, but that
identity was flattened into prose on the way out, so the settings page could
only print the sentence (which also echoed the endpoint body unredacted), and
the refresh button invented its own literal error when a check came back empty.

Blast radius: /providers/check on failure now answers 200 with
`{ok:false, code, params?, httpStatus?, detail?}` and no `error`. `code` is the
dotted identity the add flow's refusals already use (provider.probe.*), plus a
new provider.probe.timeout split out of unreachable; `params` carries only the
numeric facts the sentence needs; `detail` is the endpoint's own text through
the same redaction the model check uses. Success responses are unchanged.
catalog.ProbeError gains Status and Body, read from the typed fetch error, and
the status/code table is shared by the refusal and the finding.

Neighbouring behaviours tested: refused, forbidden, payment, rate limit, missing
path, upstream error, no chat models, unreachable, timeout, success, key
redaction in the endpoint text, bounded one-line detail, missing grant,
unknown name, and the add flow's probe codes all translated.

Why this layer: the probe owns the class; serve only projects it; the page
maps the code to words.
2026-10-06 01:14:03 -07:00
xxoingr 80e8a1a82f fix(studio): the prompt-refine button is described by its tooltip
The refine button's tooltip carried the Ctrl+Shift+E shortcut, but nothing
pointed the button at it: it had no aria-describedby and the tooltip had no
id, so a screen reader announced only "Optimize prompt" and the keyboard
route stayed undiscoverable. The attach button beside it already describes
itself this way.

The tooltip now takes an id from useId and the button names it in
aria-describedby, matching the attach button.

Refs #12156
2026-10-06 16:04:44 +08:00
YHH 0afafb7a46 Merge pull request #12184 from KHG420/codex/studio-backup-delete-lifetime
fix(studio): hold backup deletions until receipts settle
2026-10-06 01:02:39 -07:00
xiexian e3e2ea39dd fix(studio): hold backup deletions until receipts settle 2026-10-06 15:50:22 +08:00
YHH 444ca4fa45 Merge pull request #12179 from esengine/feat/chart-desktop-renderer
feat(desktop): draw render_chart calls as inline SVG charts in the transcript
2026-10-06 00:41:01 -07:00
YHH 5fbb16f2d6 Merge pull request #12183 from KHG420/codex/studio-backup-restore-draft
fix(studio): hold restore choices while awaiting receipts
2026-10-06 00:36:59 -07:00
YHH 6ee31dedd0 Merge pull request #12180 from KHG420/codex/studio-backup-create-draft
fix(studio): hold backup drafts while creating
2026-10-06 00:36:28 -07:00
YHH defebb51b2 Merge pull request #12175 from KHG420/codex/studio-add-provider-save-draft
fix(studio): hold provider additions while saving
2026-10-06 00:29:00 -07:00
YHH 0427ff16f8 Merge pull request #12174 from KHG420/codex/studio-provider-save-draft
fix(studio): hold provider drafts while saving
2026-10-06 00:27:56 -07:00
yhh 8f5701324e feat(desktop): draw render_chart calls as inline SVG charts in the transcript
A settled render_chart call (direct or through use_capability) is re-validated
on the page, lifted out of the folded execution group, and drawn as a bar,
line or pie chart from the validated spec by our own React SVG components in
a lazy chunk. A running, refused or invalid call keeps the ordinary tool card.
The same card is rebuilt on reload from the stored call. Each chart offers an
accessible data table, keyboard-reachable points and no entrance motion.
2026-10-06 00:25:02 -07:00
YHH 0333c008a9 Merge pull request #12181 from esengine/feat/chart-tui-text
feat(tui): show a render_chart call as an aligned data table
2026-10-06 00:19:30 -07:00
yhh f2631f18fc feat(tui): show a render_chart call as an aligned data table
A settled chart call (direct or through use_capability) now draws its title,
marks and rows as a capped, width-fitted table in the terminal, with numbers
right-aligned and the fold key opening the rest, instead of a line count.
chartspec.FromCall and Spec.Table/Describe are the single projection; the TUI
history record and frame merge now keep the resolved tool name, without which
neither a reopened session nor a live call could be told from any other
use_capability call.
2026-10-06 00:08:07 -07:00
xiexian b924372004 fix(studio): hold restore choices while awaiting receipts 2026-10-06 15:01:06 +08:00
xiexian e2b02ed555 fix(studio): hold backup drafts while creating 2026-10-06 14:47:46 +08:00
YHH 65aef1e228 Merge pull request #12177 from esengine/fix/provider-save-remount-from-fresh-list
fix(providers): remount the editor only after the list has been re-read
2026-10-05 23:36:17 -07:00
YHH 03ac4a5197 Merge pull request #12178 from KHG420/codex/studio-backup-empty-selection
fix(studio): preserve empty backup selections on reload
2026-10-05 23:32:50 -07:00
xiexian d4af1d6527 fix(studio): preserve empty backup selections on reload 2026-10-06 14:23:34 +08:00
YHH 8156f643fc fix(providers): remount the editor only after the list has been re-read
A successful save remounted the editor synchronously while the list reload
was still in flight, so the new editor was seeded from the stale entry and
showed the pre-save values. onEdited now returns the reload, the editor
waits for it before remounting, and save keeps the form busy until then.
2026-10-05 23:19:30 -07:00
xiexian d194492f30 fix(studio): hold provider drafts while saving 2026-10-06 14:15:14 +08:00
YHH fd93411f81 Merge pull request #12169 from KHG420/codex/studio-market-vote-account-state
fix(studio): refresh market votes when account changes
2026-10-05 23:12:10 -07:00
YHH 49affe9889 Merge pull request #12163 from KHG420/codex/studio-package-hook-row-identity
fix(studio): replace colliding package hook rows
2026-10-05 23:10:08 -07:00
YHH 9aa9756b46 Merge pull request #12170 from KHG420/codex/studio-provider-probe-endpoint-state
fix(studio): discard probe findings after endpoint edits
2026-10-05 23:07:55 -07:00
YHH e17c91e44f Merge pull request #12167 from KHG420/codex/studio-provider-check-manual-proxy
fix(studio): honor draft proxy bypass during model checks
2026-10-05 23:07:20 -07:00
xiexian 93f408bfd9 fix(studio): hold provider additions while saving 2026-10-06 13:47:42 +08:00
xiexian a08a5b60a7 fix(studio): discard probe findings after endpoint edits 2026-10-06 12:31:59 +08:00
xiexian 099c1faa52 fix(studio): refresh market votes when account changes 2026-10-06 12:12:19 +08:00
xiexian a9e7e22636 fix(studio): honor draft proxy bypass during model checks 2026-10-06 11:52:39 +08:00
YHH 9ce1b8ba5e Merge pull request #12160 from esengine/docs/studio-2.29.0-notes
docs(release): Studio 2.29.0 notes
2026-10-05 20:49:58 -07:00
yhh 4c296c9320 docs(release): Studio 2.29.0 notes 2026-10-05 20:41:26 -07:00
YHH a45ff5a771 Merge pull request #12157 from esengine/fix/steer-into-paused-queue
fix(control): hold a mid-turn message as a follow-up when the queue is paused
2026-10-05 20:34:33 -07:00
xiexian 74b5580b83 fix(studio): replace colliding package hook rows 2026-10-06 11:33:20 +08:00
xiexian a4c97196ff fix(studio): describe publication version auto-increment limits 2026-10-06 11:15:05 +08:00
esengine 0e026c95f0 fix(control): hold a mid-turn message as a follow-up when the queue is paused
A steer sent while a stop had paused the queue was refused with inbox.paused
but its queued copy stayed behind, so retries piled up and all ran on resume.
Hold the item as a follow-up under the admission lock and answer with the
queued receipt that already carries paused=true. The pause itself is kept,
and send-now from the queue panel still refuses. The composer reads the
receipt, from the steer path and from an idle skill send, and says the queue
is paused, once, in the notice strip.

Part of #12153
2026-10-05 20:14:07 -07:00
YHH 1c85e79cc6 Merge pull request #12154 from esengine/fix/plan-gate-cancel
fix(control): return the plan to planning when a stop cancels the approval card
2026-10-05 20:03:47 -07:00
xiexian 33a56d2f8a fix(studio): refresh provider models after extension changes 2026-10-06 10:53:29 +08:00
xiexian 3a47847772 docs(sdk): verify starter extension with the offline provider 2026-10-06 10:39:44 +08:00
YHH 6b1e18db89 Merge pull request #12152 from esengine/fix/control-tooltip-wrap-and-fade
fix(studio): composer control tooltips wrap inside their box and no longer fade when the host is disabled
2026-10-05 19:34:41 -07:00
xiexian d2a7e55585 fix(studio): settle runtime feedback after package removal 2026-10-06 10:25:50 +08:00
esengine 972f0212fa fix(control): return the plan to planning when a stop cancels the approval card
Stopping a turn while the plan approval card is open left the plan in
AwaitingApproval with no card to answer it. Later messages then ran under
the planning barrier and no new card could open. Treat a cancelled or timed
out approval as a revise so the workflow can propose again.

Part of #12153
2026-10-05 19:23:59 -07:00
yhh 5561753a6d fix(studio): composer control tooltips wrap inside their box and no longer fade when the host is disabled
The refine and attach tooltips are children of their buttons. They inherited
the button's white-space: nowrap, so the hint spilled past the 220px box in
both locales, and the disabled refine button dimmed itself to 35% opacity,
which multiplied down onto the tooltip that explains the control.

The shared .studio-control-tip now wraps inside its max-width, and a disabled
host dims its glyph instead of itself.

Part of #12148
2026-10-05 19:17:14 -07:00
YHH cf1a147bcb Merge pull request #12150 from esengine/fix/subagent-model-saved-while-running
fix(settings): keep a role saved while busy and say it is not applied yet
2026-10-05 19:16:27 -07:00
esengine c4736c747c fix(settings): keep a role saved while the conversation is busy and say it is not applied yet
Cause: the settings page awaited setRole and only then re-read /roles. While a
turn, a question or a background job is active the kernel has already written
the value and answers 409 runtime.saved_while_running; run() treated that as a
failure, the re-read never ran, and the controlled select snapped back to
"follow the main model" although config.toml held the saved model. The banner
also called it "操作未完成".

Fix: the role row always re-reads /roles (promise finally), and the page's one
failure banner now distinguishes saved-but-not-applied (SAVED_NOT_APPLIED, the
set the provider edit form already uses) from a real failure, with the same
title, warn colour and status role.

Blast radius: Settings.tsx only. Every action routed through run() now shows
"已保存,尚未生效" instead of "操作未完成" when the kernel answers one of the
SAVED_NOT_APPLIED codes; other failures render as before. No kernel change.

Neighbouring behaviours tested: role saved while idle, role saved while busy
(value and notice), real refusal (alert, no saved notice), other role rows and
a reopened page after a busy save, default-model row refused while busy (stays
on the kernel's default; the kernel refuses before writing, so it is not the
same defect). Go: POST /roles mid-turn returns 409 runtime.saved_while_running
and GET /roles returns the saved value.

Why this layer: the page owns the re-read and the banner; the code identity
already comes from the kernel and the shared SAVED_NOT_APPLIED list.
2026-10-05 19:06:33 -07:00
YHH d1c534c8d6 Merge pull request #12146 from esengine/fix/cli-quit-ctrlc-tab-parity
fix(tui): restore 1.x /quit, second Ctrl+C while stopping, and Tab accept
2026-10-05 18:57:55 -07:00
YHH 10c6aade5f Merge pull request #12140 from esengine/fix/browser-open-failure-visible
fix(workbench): show why a browser tab could not open
2026-10-05 18:55:02 -07:00
YHH 01edf16dbc Merge pull request #12145 from esengine/fix/scratch-owned-temp-dirs
fix(jobs): reclaim temp roots abandoned by killed kernels via a lock-owned scratch dir
2026-10-05 18:51:36 -07:00
yhh ce66ba8742 fix(tui): restore 1.x /quit, second Ctrl+C while stopping, and Tab accept
Part of #12144.

Cause
- `/quit` and `/exit` are in the slash catalogue but the TUI had no handler,
  so since #11969 they were sent to the model as ordinary messages.
- Every Ctrl+C on a running turn re-sent cancel, so a turn that would not
  stop could not be left, while the footer says "Ctrl+C exits".
- Tab in the completion menu moved the highlight down (and did nothing
  visible with one item); the guides say Tab / Enter accept.

Blast radius
- internal/frontend/tui: send(), menuKey, the Ctrl+C branches of onKey and
  viKey, one new Options field. internal/frontend/cli passes the quit names
  from the slash registry. docs/GUIDE.md and GUIDE.zh-CN.md rows for Ctrl+C,
  Ctrl+D and /quit. No controller, wire or config change. The bare words
  exit, quit and :q stay ordinary prompts.
- Tab history: the cycling was part of the first completion commit
  (5ff9d7a0f) with no stated reason and no test; nothing later touched it.

Neighbouring behaviours tested
- Idle double Ctrl+C, Ctrl+D (idle and while running), double Esc opens
  rewind, a second Esc does not quit, Esc then Ctrl+C quits, one cancel call
  per first Ctrl+C, vi mode Ctrl+C, a finished cancelled turn needs two
  presses again, Up/Down still move the menu, Enter accepts, Tab accepts
  the highlighted row, file-mention directory descends, Tab without a menu
  still asks the kernel, /quit while a turn runs is not queued.

Why this layer
- Quitting is a frontend lifecycle action with no controller verb, so it
  lives in the TUI local-command switch beside /mouse; the names come from
  the registry through Options rather than a second alias list. Both
  interrupt paths share one helper so Esc and Ctrl+C agree on what
  "stopping" means. The unreachable shift+tab case in menuKey is removed.
2026-10-05 18:48:26 -07:00
yhh 731bc29ca5 fix(workbench): show why a browser tab could not open
When the kernel refuses browser.open, the workbench stored the reason in
`failed` but only drew it inside the file-tab branch. Opening the column
or pressing + with no page left the pane on its empty state, "choose a
file on the right, or open a browser", with no error.

Cause: `failed` was rendered only when the active surface is a file, while
the browser open paths (column opening with no agent tab, the + button)
also write it. The kernel also answered every failure with the generic
browser.open_failed, whose wording needs an {error} param it never sent,
so the typed engine_missing / engine_failed / profile_busy causes were
lost. Separately, `.workbench-error` used --danger, a token no stylesheet
defines, so such errors drew in the body colour.

Blast radius: WorkbenchPanel, one CSS declaration, serve's browser open
refusal and the kernel.ts / en_kernel.ts tables. The alert renders for any
non-file surface; file tabs keep their own copy and are unchanged.
`failed` is cleared when a tab is picked and when the active tab closes.
POST /browser/open now answers browser.engine_missing, browser.engine_failed
or browser.profile_busy for those causes (same 400, same params, plus the
detail in params.error) and browser.open_failed for the rest. The colour
fix also recolours the file-tab and explorer errors.

Neighbouring behaviours tested: the three codes and the generic one through
the real SsePort decoding path, on both open paths, asserting the exact
text and no leftover placeholder; the reason clearing on picking a file
and on a later successful open; a file read error staying single on its
tab and going away with it; serve end to end with real launch failures;
the refusal-code parity test; the whole WorkbenchPanel suite and tsc.

Why this layer: the pane owns the state and the draw; the kernel boundary
in serve is where the typed cause becomes a code, and internal/platform/
browser is not touched.

Fixes #12139
2026-10-05 18:42:59 -07:00
yhh 90de42bf12 fix(jobs): reclaim temp roots abandoned by killed kernels via a lock-owned scratch dir
Add internal/base/scratch: a temp directory claimed by a long-lived
exclusive .owner.lock (internal/base/filelock). Remove releases the lock
first and retries sharing violations for up to 2 s. Sweep/SweepOnce remove
only non-symlink directories past a one-minute grace whose lock can be
taken, so a live owner is never touched. The jobs manager now creates its
reasonix-jobs-* fallback root through it, which also sweeps roots left by
earlier killed kernels.

Part of #12142
2026-10-05 18:41:45 -07:00
YHH bc55e83c39 Merge pull request #12096 from KHG420/codex/studio-mcp-cached-inventory-notice
fix(studio): mark cached MCP tools without descriptions
2026-10-05 18:26:42 -07:00
YHH 7ee3a554c4 Merge pull request #12094 from KHG420/codex/studio-localized-settings-search
fix(studio): search settings by localized labels
2026-10-05 18:19:37 -07:00
xiexian 215dedc5b4 fix(studio): hold navigation during market publication 2026-10-06 09:13:19 +08:00
xiexian 20c883c724 fix(studio): renew each runtime application confirmation 2026-10-06 09:03:06 +08:00
YHH f1bae78d39 Merge pull request #12113 from xxoingr/fix/tui-resume-index
fix(tui): /resume <n> continues the nth saved session in place
2026-10-05 17:37:25 -07:00
YHH f0e735084a Merge pull request #12131 from KHG420/codex/studio-memory-connection
fix(studio): scope memory panels to their connection
2026-10-05 17:08:59 -07:00
YHH e9108c3133 Merge pull request #12127 from KHG420/codex/studio-backup-connection
fix(studio): scope backup forms to connection
2026-10-05 16:54:12 -07:00
YHH a91c2632d5 Merge pull request #12134 from esengine/docs/drop-open-pr-cap
docs(contributing): drop the open-PR cap
2026-10-05 15:33:08 -07:00
yhh 6afdcb47a0 docs(contributing): drop the open-PR cap
Contributors may keep any number of pull requests open. The policy keeps one suggestion for series of related changes.
2026-10-05 15:25:16 -07:00
YHH fa978db05e Merge pull request #12132 from esengine/docs/contributing-pr-policy
docs(contributing): open-PR cap, issue-first for features, AI-assist note
2026-10-05 15:18:42 -07:00
xiexian 57e6222d98 fix(studio): refresh memory history after writes 2026-10-06 06:13:43 +08:00
yhh 462a47f240 docs(contributing): open-PR cap, issue-first for features, AI-assist note 2026-10-05 15:09:23 -07:00
xiexian eca93427cc fix(studio): scope memory panels to their connection 2026-10-06 05:49:27 +08:00
xiexian c7a6704558 fix(studio): preserve hook edits during trial and save 2026-10-06 05:21:31 +08:00
xiexian 7f3b72967d fix(studio): scope backup forms to connection 2026-10-06 04:26:31 +08:00
xiexian d5c963523d fix(studio): enforce the MCP example argument schema 2026-10-06 01:47:42 +08:00
xxoingr 34c84f7a73 fix(tui): /resume <n> continues the nth saved session in place
In 1.x, "/resume <n>" switches straight to the nth saved session. The Studio
TUI ignored the number and always opened the picker, the known gap left in
the CLI migration tracking issue.

An indexed /resume now resolves the session by its row in the same list the
picker numbers and resumes it, with the same refusals the picker gives: a
running turn, the session already open, or an index outside the list. A bare
/resume (or a non-numeric argument) still opens the picker.

The index lookup shared by /takeover <n> and /rename <n> <title> moves into
one helper so the three commands cannot drift apart.

Refs #11493
2026-10-06 00:35:42 +08:00
xxoingr 962771cbdb feat(studio): let the feedback dialog fill the window
The feedback dialog is fixed to a 720px column and thread bubbles are capped
at 560px, so a long maintainer reply on a wide window reads as a narrow strip
that needs constant scrolling.

Add a "Fill the window" toggle next to Close. While pressed, the dialog takes
the whole veil and thread bubbles widen to 92% of the column; pressing it
again returns to the reading column. The toggle is a view-only action
(feedback.expand) and the state lives only for the open dialog.

Refs #11575
2026-10-06 00:24:16 +08:00
Will 48365b2ecb test(e2ebench): hold fanout-width, upstream-edge and project-check to the authoring guard
The reference-solution half covered only the e2e, train and verification-stress
corpora, so three committed corpora with the same tasks/ layout carried no
evidence that any attempt can score: a grader that can never pass is
indistinguishable from a task with no solution, and a corpus of those teaches
that nothing is ever accepted.

Their pristine seeds already fail, checked across all eleven tasks, so this adds
the reference solutions the guard needs: one ANSWER.md each for the three fleet
tasks, and the non-positive-skipping calc.py the eight project-check graders
assert. The e2e suite stays the single declared exception, named in the skip.

Refs #11852.
2026-10-05 22:42:25 +08:00
YHH 198f60e6b4 Merge pull request #12101 from esengine/fix/side-models-effort-fallback
fix(boot): side models retry on the lowest effort when an endpoint refuses "disabled"
2026-10-05 07:36:50 -07:00
yhh 4396615092 fix(boot): side models retry on the lowest effort when an endpoint refuses "disabled"
Prompt refine and commit-message drafting built their provider with effort forced to "disabled". Generic OpenAI-compatible endpoints refuse that at construction, so the side model was nil and the user saw "no usable model" while chat worked. sideProvider retries only on provider.ErrEffortRefused, with the lowest effort the entry's vocabulary lists (automatic depth when it lists none); other construction errors are not retried and are logged at warn.
2026-10-05 07:22:55 -07:00
xiexian 61c1e6cb68 docs(studio): clarify skill frontmatter namespaces 2026-10-05 20:53:20 +08:00
xiexian 8175a143f1 fix(studio): mark cached MCP tools without descriptions 2026-10-05 20:30:46 +08:00
xiexian 57ec3c2245 fix(studio): search settings by localized labels 2026-10-05 19:51:57 +08:00
xiexian 69d07fca45 fix(studio): scope theme inventory reads to their connection 2026-10-05 18:38:34 +08:00
xiexian cb6cd332d8 fix(studio): isolate theme import feedback by connection 2026-10-05 18:15:11 +08:00
YHH 064405a2a7 Merge pull request #12082 from esengine/fix/compaction-failure-visible-and-bounded
fix(compaction): a failed summary's retry hold gives way before the window
2026-10-05 02:10:37 -07:00
YHH aba9fe6134 Merge pull request #12076 from esengine/fix/sidebar-select-immediately
fix(studio): select a session row on the click instead of after the kernel answers
2026-10-05 02:02:38 -07:00
YHH ea7721b15f Merge pull request #12079 from esengine/fix/status-no-config-load
perf(serve): answer /status model facts from the build, not a config load
2026-10-05 01:55:07 -07:00
YHH 31725ae561 Merge pull request #12077 from esengine/fix/catalog-frontmatter-only
perf(skill): leave references and scripts unread when listing skills
2026-10-05 01:53:56 -07:00
yhh 985853ac03 fix(compaction): a failed summary's retry hold gives way before the window, and a code-less overflow refusal is recovered
A transient summary failure held the retry until the input grew by an
eighth of the trigger. Recorded near 105% of the trigger that release
point is the window itself, so the next request went out uncompacted.
The step is now capped at half of the room left below the hard ceiling,
so the hold always lifts before it.

A relay that refuses an oversized request with a 400 and no error code
was not recognised as an overflow, so recovery never ran. A 400 or 413
whose request our own estimate puts over the window now takes the same
fold-and-replay path, judged by status and size, never by wording.
2026-10-05 01:47:19 -07:00
esengine 1e17d7f5c7 fix(studio): select a session row on the click instead of after the kernel answers
A session row was selected only once POST /runtimes (or /resume), GET /runtimes
and GET /tree had all returned, so every switch to a session without a pane
waited out the kernel's pane assembly with nothing but a dimmed row to show for it.

The window now owns which session is being opened (useSessionSwitch). The row
reads selected and busy from that state as soon as it is clicked, the active
pane shows aria-busy, a ticket orders concurrent opens so the last click wins
and a refusal from a superseded open changes nothing, and a failed open drops
the selection back to the pane that is still in front while the error is shown.
Opening moves out of App into useOpenPane so every caller shares the one path.

The pane burst after an open now asks for /runtimes and /tree through one
single-flight read instead of one per caller.
2026-10-05 01:40:40 -07:00
yhh 1a18ad9e0e perf(serve): answer /status model facts from the build, not a config load
/status called config.Load() on every poll, which re-parses every
installed plugin package under a process-wide lock. The controller now
carries the model entry boot resolved and /status reads effort, vision
and the model ref from it. The provider label still comes from the user
file because a rename rebuilds nothing.

Refs #12055
2026-10-05 01:39:45 -07:00
yhh 3cb8c26bd7 perf(skill): leave references and scripts unread when listing skills
Discovery read every directory-layout skill's references/ and scripts/
to build a Body that a catalog never uses, so opening a pane cost
O(bytes in the skill library). Listing now keeps only SKILL.md; Read,
ReadSlash, Prepare and the capability digest complete the skill when it
is invoked or inspected.

Refs #12055
2026-10-05 01:34:53 -07:00
YHH b9bc20a6cb Merge pull request #12036 from esengine/fix/summary-idle-bound
fix(compaction): bound the summary request by lack of progress, not total time
2026-10-05 01:22:30 -07:00
yhh 84688a91d8 fix(compaction): bound the summary request by lack of progress, not total time
A summary request was cut off at a fixed 90 s of wall time, so a local model
that was still streaming steadily failed every /compress with summary_timeout.
Bound the silence between streamed chunks instead (6 minutes, one minute past
the providers' own 300 s stream idle watchdog), and add a 30 minute overall
ceiling with its own typed code summary_ceiling so a trickling model cannot
hold the turn. Both failures stay transient: a later attempt may retry once
the input has grown. The notices state which bound fired and its length.
2026-10-05 01:13:31 -07:00
YHH 8511f820e9 Merge pull request #12074 from esengine/fix/skills-list-single-config-load
fix(serve): list skills with one configuration read instead of one per skill
2026-10-05 01:00:48 -07:00
esengine 9a6df69e00 fix(serve): list skills with one configuration read instead of one per skill
GET /skills resolved each skill's activation through Controller.SkillEnabled,
and every call re-ran config.Load(), which walks all installed plugin
packages. Resolve activation once per request via Controller.SkillActivation.

Refs #12055, #12051
2026-10-05 00:51:24 -07:00
xiexian 67f0b7b9f5 fix(studio): preserve focus after legacy clipboard copy 2026-10-05 15:16:01 +08:00
YHH 63df856224 Merge pull request #12064 from esengine/fix/outbound-user-agent
fix(net): send a non-Go User-Agent on catalog, relay and worker requests
2026-10-04 23:35:06 -07:00
yhh f96b3677c2 fix(net): send a non-Go User-Agent on catalog, relay and worker requests
Go's default User-Agent is answered with 403 by edge bot protection on
datacenter networks. Catalog and manifest reads now use the Studio update
identity shared with artifact downloads, the relay websocket handshake uses
the account client's identity, and feedback, telemetry, crash and account
requests fall back to the shared client identity when none is set.
2026-10-04 23:24:43 -07:00
YHH f154bd95e5 Merge pull request #12056 from esengine/fix/status-poll-never-stacks
fix(studio): status reads never stack, decision submits are bounded
2026-10-04 22:55:15 -07:00
yhh 5edd8ac898 fix(studio): status reads never stack, decision submits are bounded
The pane polled /status every 250ms with no in-flight guard. When the kernel
answered in 13-16s the unanswered reads filled the browser's per-origin
connection pool and the approve, plan-decision and answer POSTs queued behind
them.

Idempotent reads now share one request per path with a single trailing
re-read, the pane schedules its next poll when the previous read settles, and
decision submits get a bounded wait that fails with a typed KernelBusyError.
2026-10-04 22:48:03 -07:00
YHH ad5191854f Merge pull request #12050 from esengine/fix/retry-line-explains-itself
fix(retry): say why a request is retried, what it waits for, and expose idle_timeout_seconds
2026-10-04 22:42:30 -07:00
esengine 04007d942f fix(retry): say why a model request is retried, what it waits for, and expose idle_timeout_seconds
The retry status line showed "connection broke before the response headers"
for every retried failure, including 502/429 statuses, did not say what the
attempt was waiting for or when it counts as unanswered, and its seconds
counter ran from the first notice of the whole stall instead of per attempt.

The retrying event now carries a typed failure class (connection_closed,
timeout, upstream_status with the HTTP status, stream_idle, upstream_error),
the backoff before the next attempt, and the header wait of the coming
attempt. The producers set them: SendWithRetry knows the class, the adapters
pass their idle_timeout_seconds window, and the stream replay path maps the
stream-interrupt reason. The window words each class and phase (backoff
countdown, then a wait that restarts per attempt) from those fields, never
from text. The CLI machine record carries the same fields.

Provider settings gain one advanced row for idle_timeout_seconds (1..32767,
empty is the default), validated in the form and by the kernel's provider
edit endpoint.

Fixes #12046
2026-10-04 22:19:08 -07:00
YHH c701436015 Merge pull request #11991 from keeyangyy/pr/remote-host-switches
feat(remote): per-host switches to stop dialing a machine and to keep it out of the rail
2026-10-04 21:55:40 -07:00
keeyang 0d69ac0f95 feat(remote): per-host switches to stop dialing a machine and to keep it out of the rail
The host book has no per-machine state: every entry is dialed on demand and every entry is
listed in the rail. A machine that is temporarily off the network, or one kept for the
occasional job, cannot be marked as such - the only lever is removing it, which takes its
folders and its forward rules with it.

- RemoteHostEntry gains disabled, written as "disabled" rather than "enabled" so an entry
  written before the field existed still means what it always did, plus IsEnabled(); the
  renderer writes the key only when it is set.
- One switch per row decides whether the machine is dialed, and that is all it decides:
  live panes keep running and the rail still lists the machine, because a machine being
  off the network says nothing about what this window should show. Whether this window
  lists it stays a separate preference, and it is an immediate row action next to the
  row's other actions rather than a control inside the edit form, which saves on submit.
- remoteHostDialable is the single answer to whether a machine may be reached: POST
  /remotes/open refuses a disabled one with remote.disabled before dialing, and remoteLink
  (the tree and directory reads, and removing a far session) asks the same question, so a
  disabled machine is not reached for at all. A book that cannot be read is a refusal
  rather than a dial.
- GET /remotes reports disabled and the edit endpoint carries it.
2026-10-05 12:28:38 +08:00
YHH 0397a3bed6 Merge pull request #12047 from esengine/fix/phone-chip-idle-while-running
fix(studio): status chip no longer says idle while the turn is running on a late-attached client
2026-10-04 21:24:00 -07:00
yhh 4c64d7cc61 fix(studio): status chip no longer says idle while the turn is running on a late-attached client 2026-10-04 21:15:46 -07:00
YHH 312ba89ae6 Merge pull request #11992 from keeyangyy/pr/default-model-display
fix(studio): the default-model row shows the configured default, not the session's model
2026-10-04 21:01:55 -07:00
YHH a3d1142d7d Merge pull request #12034 from esengine/fix/studio-stable-github-latest
release(studio): stable releases become GitHub latest, without a root latest.json
2026-10-04 20:57:16 -07:00
keeyang 5c3b9f0bed fix(studio): the default-model row shows the configured default, not the session's model
#11897 stopped a session's model switch from rewriting default_model, which left the
settings row reading status.modelRef - the model the session in front is running - so
switching models inside a conversation still moved that row while the file it claims to
describe did not change.

The row now shows the entry the model catalogue marks as default, falling back to the
session's model when the catalogue carries no default, and the column beside it follows
the same value. `main` stays the session's model for the vendor-protocol inference this
component does.

Tested: with a catalogue default the row shows it whatever the session runs; without one
it falls back to the session's model; the settings-catalogue, ModelUsage and i18n gates.
2026-10-05 11:52:13 +08:00
yhh b7541b7c80 release(studio): stable releases become GitHub latest, without a root latest.json
A stable studio-vX.Y.Z that is the highest stable studio tag is created with
--latest and without --prerelease. Candidates (-preview.N, -rc.N) keep
--prerelease --latest=false, and recovering an older stable tag does not take
latest back. The decision lives in scripts/studio-release-flags.sh and reads
the tag grammar, not wording.

The GitHub release no longer carries a root latest.json. The 1.x desktop
updaters fall back to releases/latest/download/latest.json, and builds up to
1.18 do not validate the manifest and share Studio's minisign key, so a
Studio manifest there would offer them a Studio package. The file stays in
the R2 mirror under <tag>/, which is the only place Studio reads it. The
release now has 21 assets.

Adds a flags-script test and a workflow contract test, and updates the
runbook, READMEs, MIGRATING and ROADMAP.
2026-10-04 20:25:37 -07:00
YHH ac681f524d Merge pull request #12030 from esengine/theme-decorative-tokens
feat(theme): decorative roles get their own tokens (link, brand, halo, labelAgent)
2026-10-04 20:08:02 -07:00
yhh b977cbda91 feat(theme): give links, brand tint, halos and agent names their own tokens
Part of #12008.
2026-10-04 19:54:17 -07:00
YHH e6655a6054 Merge pull request #12014 from esengine/fix/pack-theme-generated-stylesheet
fix(studio): deliver theme packs through a generated stylesheet, not inline styles
2026-10-04 19:45:31 -07:00
YHH 3e6f700acb Merge pull request #12021 from esengine/fix/approval-allow-wording-12020
fix(studio): name the allow decision on session/persistent approval buttons
2026-10-04 19:44:36 -07:00
YHH 770f24b278 Merge pull request #12017 from esengine/fix/claude-modules-unsupported
fix(pluginpkg): report Claude Code modules as an unsupported capability
2026-10-04 19:43:45 -07:00
yhh 9d96517857 fix(studio): name the allow decision on the session and persistent approval buttons 2026-10-04 16:31:48 -07:00
yhh 4b4b324a81 fix(studio): deliver theme packs through a generated stylesheet, not inline styles
apply() wrote every pack token, and the ink variables even with no pack,
as inline custom properties on <html>, so any author stylesheet needed
!important to win. The values now live in one rule of a <style
id="pack-theme"> created after the built-in sheets, written through the
CSSOM so a rejected value is dropped. The contrast tiers carry the inks in
the stylesheet, with --ghost on --faint's value, and perf/tiers.mjs holds
each tier to the legibility users already had.
2026-10-04 16:08:54 -07:00
yhh f654aeabb6 fix(pluginpkg): report Claude Code modules as an unsupported capability
A Claude Code Mod declares "modules" in hooks/hooks.json instead of a
"hooks" map. The importer returned early on an empty hook map, so the
modules were dropped silently and a Mod with skills previewed as full.
2026-10-04 15:56:08 -07:00
835 changed files with 43545 additions and 3662 deletions

No files matched your search

+1
View File
@@ -37,6 +37,7 @@
/.github/workflows/studio-certum-signing-smoke.yml @esengine @SivanCola
/.github/actions/setup-certum/ @esengine @SivanCola
/cmd/signpath-contract/ @esengine @SivanCola
/scripts/install-smoke.ps1 @esengine @SivanCola
/scripts/sign-certum.ps1 @esengine @SivanCola
/scripts/verify-windows-authenticode.ps1 @esengine @SivanCola
/scripts/verify-windows-package.ps1 @esengine @SivanCola
+4
View File
@@ -42,3 +42,7 @@ the source-to-target contract here: easing syntax, time units, callbacks,
cancellation, reduced-motion behavior, and failure fallback. Verification must
assert that the target API was actually called; a mock that silently skips it
does not count.
## Disclosure
- [ ] AI assistance was used for this change (optional; for transparency only, it does not change how the change is reviewed)
+1 -1
View File
@@ -84,7 +84,7 @@ jobs:
with:
go-version-file: go.mod
cache: true
- run: node --test scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
- run: node --test scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
+244 -78
View File
@@ -5,10 +5,12 @@ run-name: Release Studio ${{ inputs.tag || github.ref_name }}
# (studio-v*), its own environment gate, and its own rollback catalog at
# dl.reasonix.io/studio/versions.json.
#
# What it must never touch: the repository-wide "latest" release (every Studio
# release is a prerelease) and the root dl.reasonix.io/versions.json, which is
# the desktop line's catalog. Studio artifacts land under the tag prefix, which
# cannot collide because the tag name itself differs.
# What it must never touch: the root dl.reasonix.io/versions.json, which is the
# desktop line's catalog. Studio artifacts land under the tag prefix, which
# cannot collide because the tag name itself differs. The highest stable tag
# is the repository's GitHub latest and a candidate is a prerelease; the GitHub
# release carries no root latest.json, because the 1.x updaters' last-resort
# URL is releases/latest/download/latest.json and must keep answering 404.
#
# Windows artifacts are Authenticode-signed with a Certum SimplySign cloud
# certificate in two stages: every PE file in the application tree, then the
@@ -74,8 +76,7 @@ jobs:
fi
version="${tag#studio-}"
# Semver's prerelease segment, group 4, read from the grammar rather
# than the spelling. Every Studio release carries GitHub's prerelease
# flag, so the tag-level distinction needs a name of its own.
# than the spelling.
candidate=false
if [[ "${version#v}" =~ $semver ]] && [ -n "${BASH_REMATCH[4]}" ]; then
candidate=true
@@ -148,10 +149,21 @@ jobs:
# vocabularies disagree (x64 / amd64) and a reviewer has to see both.
# Absent means the runner's own, which is what Windows and Linux build.
# artifact is the exact name publish downloads this leg's packages by.
# goarch and unpacked belong to the Windows legs: the release
# architecture the package is named for, and the directory
# electron-builder leaves its bundle in (win-unpacked for x64 alone).
include:
- platform: windows/amd64
runner: windows-latest
goarch: amd64
unpacked: win-unpacked
artifact: studio-build-windows-amd64
- platform: windows/arm64
runner: windows-11-arm
ebarch: arm64
goarch: arm64
unpacked: win-arm64-unpacked
artifact: studio-build-windows-arm64
- platform: darwin/amd64
runner: macos-latest
ebarch: x64
@@ -266,6 +278,7 @@ jobs:
# kernel on a remote machine, and those routes name a release by it.
env:
REASONIX_VERSION: ${{ needs.resolve.outputs.version }}
REASONIX_GOARCH: ${{ matrix.goarch }}
run: pnpm install --frozen-lockfile && pnpm run build:host
# The helper the Linux self-update runs under pkexec, staged where the
@@ -288,6 +301,14 @@ jobs:
if [ -n "${{ matrix.ebarch }}" ]; then arch="--${{ matrix.ebarch }}"; fi
pnpm exec electron-builder --dir $arch -c.extraMetadata.version="${version#v}"
# An emulated x64 program runs on ARM64 Windows, so a bundle that starts
# proves nothing about its architecture: every PE image in it has to be
# built for the architecture the package is named for.
- name: Every PE image is built for the package's architecture
if: runner.os == 'Windows'
shell: bash
run: node desktop/electron/packaging/pe.js "dist/${{ matrix.unpacked }}" "${{ matrix.goarch }}"
# Notarization happens to the bundle, so it sits between the two packaging
# steps the way Authenticode does on Windows. Stapling here is what lets a
# first launch work offline; a ticket Apple has but the bundle does not is
@@ -344,8 +365,8 @@ jobs:
if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: unsigned-studio-windows-bundle
path: dist/win-unpacked
name: unsigned-studio-windows-${{ matrix.goarch }}-bundle
path: dist/${{ matrix.unpacked }}
if-no-files-found: error
retention-days: 1
@@ -356,17 +377,24 @@ jobs:
if: runner.os == 'Windows' && env.SIGN_WINDOWS != 'true'
working-directory: desktop/electron
shell: bash
env:
# The arm64 installer has to be packed with the BCJ filter: 7-Zip
# applies an ARM64 filter to ARM64 executables by default, the NSIS
# extractor cannot decode it, and the installer then exits 0 with
# every executable missing.
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.goarch == 'arm64' && 'BCJ' || '' }}
run: |
version="${{ needs.resolve.outputs.version }}"
arch="--${{ matrix.ebarch || 'x64' }}"
rm -rf ../../zip-src ../../dist-zip
cp -r ../../dist/win-unpacked ../../zip-src
pnpm exec electron-builder --prepackaged ../../zip-src --win zip \
cp -r ../../dist/${{ matrix.unpacked }} ../../zip-src
pnpm exec electron-builder --prepackaged ../../zip-src --win zip $arch \
-c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip &
zip_pid=$!
pnpm exec electron-builder --prepackaged ../../dist/win-unpacked --win nsis \
pnpm exec electron-builder --prepackaged ../../dist/${{ matrix.unpacked }} --win nsis $arch \
-c.extraMetadata.version="${version#v}"
wait "$zip_pid"
mv ../../dist-zip/ReasonixStudio-windows-amd64.zip ../../dist/
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.goarch }}.zip ../../dist/
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: runner.os != 'Windows' || env.SIGN_WINDOWS != 'true'
@@ -382,22 +410,36 @@ jobs:
# package check run between them on their own runners with no secrets. Both
# signing jobs share one group with the Studio smoke test, so no two runs
# drive the same SimplySign account at once.
#
# Each signing stage is a matrix over the Windows architectures with one tree
# per job. max-parallel: 1 keeps the legs strictly serial, which the shared
# certum-signing group needs: a group holds one running and one waiting job,
# so two legs started together would have the waiting one cancelled by the
# next arrival. A failed leg reruns alone; the others' results are kept.
windows-sign-payload:
name: sign the Windows PE files (Certum)
name: sign the Windows PE files (Certum, ${{ matrix.arch }})
needs: [resolve, signing-contract, build]
if: vars.STUDIO_SIGNING_ENABLED == 'true'
runs-on: windows-2022
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 1
matrix:
arch: [amd64, arm64]
environment: studio-release
permissions:
contents: read
concurrency:
group: certum-signing
cancel-in-progress: false
# One output per architecture, because matrix legs share a job's output map:
# a leg writes only its own and an empty value never replaces another's.
# Recorded here because no later job can write this job's outputs: the
# installer signing job refuses a payload artifact that no longer hashes to it.
outputs:
payload-digest: ${{ steps.verify.outputs.digest }}
payload-digest-amd64: ${{ matrix.arch == 'amd64' && steps.verify.outputs.digest || '' }}
payload-digest-arm64: ${{ matrix.arch == 'arm64' && steps.verify.outputs.digest || '' }}
steps:
# Only the workflow's own commit is checked out: these jobs run the signer
# and its checks, never anything the release tag builds.
@@ -438,7 +480,7 @@ jobs:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: unsigned-studio-windows-bundle
name: unsigned-studio-windows-${{ matrix.arch }}-bundle
path: signing/app
# Stage one: every PE file in the application tree that Microsoft has
@@ -485,6 +527,7 @@ jobs:
$ErrorActionPreference = 'Stop'
./scripts/verify-windows-authenticode.ps1 `
-PayloadDirectory signing/app `
-Architecture ${{ matrix.arch }} `
-DigestPath "$env:RUNNER_TEMP/payload-digest" `
-ExpectedThumbprint $env:CERTUM_KEY_ID `
-ExpectedSubject $env:EXPECTED_SUBJECT
@@ -492,7 +535,7 @@ jobs:
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: signed-studio-windows-payload
name: signed-studio-windows-payload-${{ matrix.arch }}
path: signing/app
if-no-files-found: error
retention-days: 1
@@ -500,10 +543,20 @@ jobs:
# No environment and no secrets: electron-builder and its dependencies run
# here, on a runner that never held a signing session.
windows-package:
name: package Windows from the signed tree
name: package Windows from the signed tree (${{ matrix.arch }})
needs: [resolve, windows-sign-payload]
runs-on: windows-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
ebarch: x64
unpacked: win-unpacked
- arch: arm64
ebarch: arm64
unpacked: win-arm64-unpacked
permissions:
contents: read
steps:
@@ -536,8 +589,8 @@ jobs:
# The signed payload is the whole bundle, so it is packaged as it came.
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: signed-studio-windows-payload
path: dist/win-unpacked
name: signed-studio-windows-payload-${{ matrix.arch }}
path: dist/${{ matrix.unpacked }}
# Both packages come from the signed tree, so the archive carries the
# signed files too rather than a second unsigned copy. The zip is packed
@@ -545,24 +598,27 @@ jobs:
- name: Package Windows from the signed bundle
working-directory: desktop/electron
shell: bash
env:
# See the build job: the arm64 installer must be packed with BCJ.
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.arch == 'arm64' && 'BCJ' || '' }}
run: |
version="${{ needs.resolve.outputs.version }}"
rm -rf ../../zip-src ../../dist-zip
cp -r ../../dist/win-unpacked ../../zip-src
pnpm exec electron-builder --prepackaged ../../zip-src --win zip \
cp -r ../../dist/${{ matrix.unpacked }} ../../zip-src
pnpm exec electron-builder --prepackaged ../../zip-src --win zip --${{ matrix.ebarch }} \
-c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip &
zip_pid=$!
pnpm exec electron-builder --prepackaged ../../dist/win-unpacked --win nsis \
pnpm exec electron-builder --prepackaged ../../dist/${{ matrix.unpacked }} --win nsis --${{ matrix.ebarch }} \
-c.extraMetadata.version="${version#v}"
wait "$zip_pid"
mv ../../dist-zip/ReasonixStudio-windows-amd64.zip ../../dist/
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../dist/
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: packaged-studio-windows
name: packaged-studio-windows-${{ matrix.arch }}
path: |
dist/ReasonixStudio-windows-amd64-installer.exe
dist/ReasonixStudio-windows-amd64.zip
dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe
dist/ReasonixStudio-windows-${{ matrix.arch }}.zip
if-no-files-found: error
retention-days: 1
@@ -571,15 +627,21 @@ jobs:
# secrets, never in a job that holds the Certum credentials. Its outputs are
# the hashes of the two files it checked, which the signing job requires.
windows-verify-package:
name: check the Windows packages against the signed tree
name: check the Windows packages against the signed tree (${{ matrix.arch }})
needs: [resolve, windows-sign-payload, windows-package]
runs-on: windows-2022
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
permissions:
contents: read
outputs:
installer-sha256: ${{ steps.check.outputs.installer-sha256 }}
archive-sha256: ${{ steps.check.outputs.archive-sha256 }}
installer-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.check.outputs.installer-sha256 || '' }}
installer-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.check.outputs.installer-sha256 || '' }}
archive-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.check.outputs.archive-sha256 || '' }}
archive-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.check.outputs.archive-sha256 || '' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -588,12 +650,12 @@ jobs:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: signed-studio-windows-payload
name: signed-studio-windows-payload-${{ matrix.arch }}
path: payload
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: packaged-studio-windows
name: packaged-studio-windows-${{ matrix.arch }}
path: dist
# 7-Zip is the runner image's, not the tag's.
@@ -601,20 +663,26 @@ jobs:
id: check
shell: pwsh
env:
PAYLOAD_DIGEST: ${{ needs.windows-sign-payload.outputs.payload-digest }}
PAYLOAD_DIGEST: ${{ needs.windows-sign-payload.outputs[format('payload-digest-{0}', matrix.arch)] }}
run: |
./scripts/verify-windows-package.ps1 `
-PayloadDirectory payload `
-ExpectedPayloadDigest $env:PAYLOAD_DIGEST `
-InstallerPath "dist/ReasonixStudio-windows-amd64-installer.exe" `
-PortableArchivePath "dist/ReasonixStudio-windows-amd64.zip" `
-OutputPath $env:GITHUB_OUTPUT
-InstallerPath "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
-PortableArchivePath "dist/ReasonixStudio-windows-${{ matrix.arch }}.zip" `
-OutputPath $env:GITHUB_OUTPUT `
-Architecture ${{ matrix.arch }}
windows-sign-installer:
name: sign the Windows installer (Certum)
name: sign the Windows installer (Certum, ${{ matrix.arch }})
needs: [resolve, windows-package, windows-verify-package]
runs-on: windows-2022
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 1
matrix:
arch: [amd64, arm64]
environment: studio-release
permissions:
contents: read
@@ -623,7 +691,8 @@ jobs:
cancel-in-progress: false
# The signed installer's hash, which publish requires of what it downloads.
outputs:
installer-sha256: ${{ steps.signed.outputs.installer-sha256 }}
installer-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.signed.outputs.installer-sha256 || '' }}
installer-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.signed.outputs.installer-sha256 || '' }}
steps:
# Only the workflow's own commit is checked out: these jobs run the signer
# and its checks, never anything the release tag builds.
@@ -664,7 +733,7 @@ jobs:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: packaged-studio-windows
name: packaged-studio-windows-${{ matrix.arch }}
path: dist
# Only the two files windows-verify-package checked, byte for byte. This
@@ -673,13 +742,13 @@ jobs:
- name: Require the checked packages
shell: pwsh
env:
INSTALLER_SHA256: ${{ needs.windows-verify-package.outputs.installer-sha256 }}
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs.archive-sha256 }}
INSTALLER_SHA256: ${{ needs.windows-verify-package.outputs[format('installer-sha256-{0}', matrix.arch)] }}
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', matrix.arch)] }}
run: |
$ErrorActionPreference = 'Stop'
$expected = [ordered]@{
'ReasonixStudio-windows-amd64-installer.exe' = $env:INSTALLER_SHA256
'ReasonixStudio-windows-amd64.zip' = $env:ARCHIVE_SHA256
'ReasonixStudio-windows-${{ matrix.arch }}-installer.exe' = $env:INSTALLER_SHA256
'ReasonixStudio-windows-${{ matrix.arch }}.zip' = $env:ARCHIVE_SHA256
}
$present = @(Get-ChildItem -LiteralPath dist -Recurse -Force | ForEach-Object { $_.Name })
if (Compare-Object -CaseSensitive @($expected.Keys) $present) { throw "dist holds $($present -join ', ')" }
@@ -701,7 +770,7 @@ jobs:
shell: pwsh
env:
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
run: ./scripts/sign-certum.ps1 -FilePath dist/ReasonixStudio-windows-amd64-installer.exe
run: ./scripts/sign-certum.ps1 -FilePath dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe
# The session signs for any process on the runner while it is up.
- name: Disconnect from Certum
@@ -717,7 +786,7 @@ jobs:
EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }}
run: |
$ErrorActionPreference = 'Stop'
$installer = 'dist/ReasonixStudio-windows-amd64-installer.exe'
$installer = 'dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe'
./scripts/verify-windows-authenticode.ps1 `
-FilePath $installer `
-ExpectedThumbprint $env:CERTUM_KEY_ID `
@@ -727,11 +796,60 @@ jobs:
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: studio-windows-signed
name: studio-windows-signed-${{ matrix.arch }}
path: dist/ReasonixStudio-*
if-no-files-found: error
retention-days: 7
# Installs each Windows installer where it is meant to run (the same script
# the Studio workflow runs on pull requests, against the unsigned installer).
# The package check unpacks with the runner's modern 7-Zip, which reads
# archives the NSIS extractor cannot, so only running the installer shows that
# it installs every executable. No environment and no secrets.
windows-install-smoke:
name: install the Windows package (${{ matrix.arch }})
needs: [resolve, build, windows-sign-installer]
if: >-
${{ !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' &&
(needs.windows-sign-installer.result == 'success' ||
(vars.STUDIO_SIGNING_ENABLED != 'true' && needs.windows-sign-installer.result == 'skipped')) }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: windows-latest
- arch: arm64
runner: windows-11-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-${{ matrix.arch }}
path: dist
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-${{ matrix.arch }}
path: dist
- name: Install, check and uninstall
shell: pwsh
run: |
./scripts/install-smoke.ps1 `
-Installer "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
-Architecture ${{ matrix.arch }}
# The kernel a Studio installs on another machine. This line publishes it
# under its own tag, because the install routes name a release by the version
# the local build reports — and a line that ships no archive leaves every one
@@ -802,12 +920,13 @@ jobs:
publish:
name: sign, publish and mirror
needs: [resolve, build, cli, windows-sign-payload, windows-package, windows-verify-package, windows-sign-installer]
needs: [resolve, build, cli, windows-sign-payload, windows-package, windows-verify-package, windows-sign-installer, windows-install-smoke]
# The Windows signing jobs may be skipped only while the switch is off; with
# it on, any of them failing, cancelled or absent blocks the release.
if: >-
${{ !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' &&
needs.cli.result == 'success' && ((needs.windows-sign-payload.result == 'success' &&
needs.cli.result == 'success' && needs.windows-install-smoke.result == 'success' &&
((needs.windows-sign-payload.result == 'success' &&
needs.windows-package.result == 'success' && needs.windows-verify-package.result == 'success' &&
needs.windows-sign-installer.result == 'success') ||
(vars.STUDIO_SIGNING_ENABLED != 'true' && needs.windows-sign-payload.result == 'skipped' &&
@@ -869,43 +988,63 @@ jobs:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed
path: staging/studio-windows
name: studio-windows-signed-amd64
path: staging/studio-windows-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-arm64
path: staging/studio-windows-arm64
# Any job can upload an artifact, so the signed Windows packages are
# taken only if they are the bytes the signing jobs recorded: the
# installer as signed, the zip as windows-verify-package checked it.
# taken only if they are the bytes the signing jobs recorded: each
# installer as signed, each zip as windows-verify-package checked it.
- name: Require the recorded Windows packages
if: needs.windows-sign-installer.result == 'success'
env:
INSTALLER_SHA256: ${{ needs.windows-sign-installer.outputs.installer-sha256 }}
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs.archive-sha256 }}
INSTALLER_SHA256_AMD64: ${{ needs.windows-sign-installer.outputs[format('installer-sha256-{0}', 'amd64')] }}
ARCHIVE_SHA256_AMD64: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', 'amd64')] }}
INSTALLER_SHA256_ARM64: ${{ needs.windows-sign-installer.outputs[format('installer-sha256-{0}', 'arm64')] }}
ARCHIVE_SHA256_ARM64: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', 'arm64')] }}
run: |
check() {
local file="staging/studio-windows/$1" want="$2" got
local file="$1/$2" want="$3" got
if [[ ! "$want" =~ ^[0-9a-f]{64}$ ]]; then
echo "::error title=studio-signing.package-hash-missing::no recorded SHA-256 for $1"
echo "::error title=studio-signing.package-hash-missing::no recorded SHA-256 for $2"
return 1
fi
got="$(sha256sum "$file" | awk '{print $1}')" || return 1
if [ "$got" != "$want" ]; then
echo "::error title=studio-signing.package-hash-mismatch::$1 is $got, the signing jobs recorded $want"
echo "::error title=studio-signing.package-hash-mismatch::$2 is $got, the signing jobs recorded $want"
return 1
fi
}
files="$(cd staging/studio-windows && find . -type f | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ')"
if [ "$files" != "ReasonixStudio-windows-amd64-installer.exe ReasonixStudio-windows-amd64.zip " ]; then
echo "::error title=studio-signing.package-set-mismatch::studio-windows-signed holds: $files"
exit 1
fi
check ReasonixStudio-windows-amd64-installer.exe "$INSTALLER_SHA256"
check ReasonixStudio-windows-amd64.zip "$ARCHIVE_SHA256"
for arch in amd64 arm64; do
dir="staging/studio-windows-$arch"
files="$(cd "$dir" && find . -type f | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ')"
if [ "$files" != "ReasonixStudio-windows-$arch-installer.exe ReasonixStudio-windows-$arch.zip " ]; then
echo "::error title=studio-signing.package-set-mismatch::studio-windows-signed-$arch holds: $files"
exit 1
fi
upper="${arch^^}"
installer="INSTALLER_SHA256_$upper"
archive="ARCHIVE_SHA256_$upper"
check "$dir" "ReasonixStudio-windows-$arch-installer.exe" "${!installer}"
check "$dir" "ReasonixStudio-windows-$arch.zip" "${!archive}"
done
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-amd64
path: staging/studio-windows
path: staging/studio-windows-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-arm64
path: staging/studio-windows-arm64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
@@ -928,13 +1067,13 @@ jobs:
done < <(find staging/studio-* -type f -name 'ReasonixStudio-*' -print0
find staging/cli-archives -type f -print0)
ls -la dist
# Two Windows artifacts (installer, portable zip), two per macOS
# architecture (dmg, zip) and the Linux .deb. Fewer means an artifact
# was lost between jobs, and a partial release is worse than a failed
# one.
# Two Windows artifacts (installer, portable zip) per architecture,
# two per macOS architecture (dmg, zip) and the Linux .deb. Fewer
# means an artifact was lost between jobs, and a partial release is
# worse than a failed one.
count=$(find dist -type f -name 'ReasonixStudio-*' | wc -l)
if [ "$count" -lt 7 ]; then
echo "::error::expected 7 artifacts (Windows installer + zip, macOS dmg + zip per architecture, Linux .deb), found $count"
if [ "$count" -lt 9 ]; then
echo "::error::expected 9 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb), found $count"
exit 1
fi
# Each platform's update channel, and there is exactly one per
@@ -945,7 +1084,11 @@ jobs:
# Each macOS architecture by name, not darwin-*: a glob passes on
# whichever one shipped, and the missing one is a platform left on
# the version it is running with nothing saying so.
for required in 'ReasonixStudio-linux-*.deb' 'ReasonixStudio-windows-*-installer.exe' \
# Each Windows architecture by name for the same reason: a glob passes
# on whichever installer shipped.
for required in 'ReasonixStudio-linux-*.deb' \
'ReasonixStudio-windows-amd64-installer.exe' 'ReasonixStudio-windows-arm64-installer.exe' \
'ReasonixStudio-windows-amd64.zip' 'ReasonixStudio-windows-arm64.zip' \
'ReasonixStudio-darwin-amd64.zip' 'ReasonixStudio-darwin-arm64.zip'; do
find dist -type f -name "$required" | grep -q . || {
echo "::error::no $required in the release; its packager did not run"
@@ -994,12 +1137,14 @@ jobs:
if [ "$HAS_R2" = "true" ]; then delta_dir=../delta; fi
go run ./cmd/studio-manifest ../dist "${{ needs.resolve.outputs.version }}" "${{ needs.resolve.outputs.tag }}" $delta_dir
# Always a prerelease: this line is not the public product line, and
# GitHub never promotes a prerelease to the repository-wide "latest".
# The manifest stays out of the GitHub release (it is mirrored to R2
# under <tag>/ below), and a stable release takes the repository's latest
# only when it is the highest stable studio tag.
- name: Publish GitHub release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
CANDIDATE: ${{ needs.resolve.outputs.candidate }}
VERSION: ${{ needs.resolve.outputs.version }}
SIGNED_WINDOWS: ${{ needs.windows-sign-installer.result == 'success' }}
run: |
@@ -1040,15 +1185,20 @@ jobs:
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** 从 \`.deb\` 安装。\n\n" "$windows"
printf '%s\n' '三个平台都能自更新:Linux 走自己的包,Windows 运行下一个安装器,macOS 替换自身 bundle。'
} >> "$notes"
assets=()
for f in dist/*; do
[ "$(basename "$f")" = latest.json ] || assets+=("$f")
done
if gh release view "$TAG" >/dev/null 2>&1; then
gh release edit "$TAG" --notes-file "$notes"
gh release upload "$TAG" dist/* --clobber
gh release upload "$TAG" "${assets[@]}" --clobber
else
gh release create "$TAG" dist/* \
decided="$(git tag -l 'studio-v*' | bash scripts/studio-release-flags.sh "$TAG" "$CANDIDATE")"
mapfile -t flags <<<"$decided"
gh release create "$TAG" "${assets[@]}" \
--title "Reasonix Studio $VERSION" \
--notes-file "$notes" \
--prerelease \
--latest=false
"${flags[@]}"
fi
- name: Mirror to R2 and update the Studio catalog
@@ -1092,6 +1242,22 @@ jobs:
exit 0
fi
# The notes object goes up before the catalog entry that names it, so
# no client reads a pointer to an object that is not there yet. Notes
# are optional: a failure here publishes the version without a pointer
# rather than keeping the new version out of the catalog.
notes_key="studio/notes/${VERSION#v}.md"
notes_arg=()
if aws s3 cp "$RUNNER_TEMP/version-notes.md" "s3://${R2_BUCKET}/${notes_key}" \
--endpoint-url "$endpoint" \
--content-type "text/markdown; charset=utf-8" \
--cache-control "public, max-age=31536000, immutable" &&
aws s3api head-object --bucket "$R2_BUCKET" --key "$notes_key" --endpoint-url "$endpoint" >/dev/null; then
notes_arg=(20 "https://dl.reasonix.io/${notes_key}")
else
echo "::warning::release notes for ${VERSION} were not uploaded to the mirror; the catalog entry is published without notes"
fi
# Studio's own catalog. Writing the ROOT versions.json here would hand
# desktop users Studio releases, so the prefix is not optional.
current=/tmp/studio-versions-current.json
@@ -1103,7 +1269,7 @@ jobs:
echo "no existing Studio catalog; starting a new one"
fi
bash scripts/update-versions-index.sh "$source" "$VERSION" "$TAG" studio \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$next"
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${notes_arg[@]}" > "$next"
jq -e --arg v "$VERSION" '.versions | map(select(.version == $v)) | length == 1' "$next" >/dev/null
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
--endpoint-url "$endpoint" \
+203 -12
View File
@@ -81,6 +81,64 @@ jobs:
exit 1
fi
# Real-Chromium guards that run without a kernel. Only the ones that passed
# three consecutive local runs are wired; the rest stay manual (see
# desktop/frontend-next/perf/README.md). Skipped unless the SPA changed.
browser-guards:
name: browser guards
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: desktop/frontend-next
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: SPA changed?
id: spa
working-directory: .
run: |
if [ "${{ github.event_name }}" = "pull_request" ] &&
git diff --quiet "origin/${{ github.base_ref }}...HEAD" -- desktop/frontend-next; then
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- if: steps.spa.outputs.run == 'true'
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- if: steps.spa.outputs.run == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- if: steps.spa.outputs.run == 'true'
name: Install
run: pnpm install --frozen-lockfile
- if: steps.spa.outputs.run == 'true'
name: Build the perf harness and install Chromium
run: |
pnpm exec vite build --config vite.perf.config.ts
pnpm exec playwright install --with-deps chromium
- if: steps.spa.outputs.run == 'true'
name: Guards
run: |
python3 -m http.server 4399 --directory dist-perf >/dev/null 2>&1 &
for _ in $(seq 30); do curl -sf http://localhost:4399/perf.html >/dev/null && break; sleep 1; done
node perf/intrinsic.mjs
node perf/contrast.mjs
node perf/wheel.mjs
desktop:
name: desktop (${{ matrix.os }})
runs-on: ${{ matrix.os }}
@@ -121,6 +179,45 @@ jobs:
CGO_ENABLED=0 GOOS=$os GOARCH=amd64 go build -o /dev/null ./cmd/reasonix-studio-host
done
# The ARM64 leg queues on a scarcer runner pool, so a pull request runs it
# only when it touches what the leg verifies; a push to studio always does.
scope:
name: package matrix
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.pick.outputs.matrix }}
arm64: ${{ steps.pick.outputs.arm64 }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- id: pick
env:
EVENT: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
arm64=true
if [ "$EVENT" = pull_request ]; then
arm64=false
if git diff --name-only "$BASE_SHA" HEAD |
grep -qE '^(desktop/electron/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
arm64=true
fi
fi
legs='[
{"os":"macos-latest","arch":"arm64","platform":"darwin"},
{"os":"ubuntu-22.04","arch":"amd64","platform":"linux"},
{"os":"windows-latest","arch":"amd64","platform":"windows","ebarch":"x64"}
]'
if [ "$arm64" = true ]; then
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"}]' <<<"$legs")"
else
legs="$(jq -c . <<<"$legs")"
fi
echo "arm64=$arm64" >> "$GITHUB_OUTPUT"
echo "matrix={\"include\":$legs}" >> "$GITHUB_OUTPUT"
# The packaging contract, on real artifacts rather than on the config that
# describes them. Unsigned on purpose: signing and notarization are a
# distribution concern and belong to the release workflow, and treating their
@@ -128,20 +225,12 @@ jobs:
# cross-compile while only Windows had ever produced a package.
package:
name: package (${{ matrix.os }})
needs: scope
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
arch: arm64
platform: darwin
- os: ubuntu-22.04
arch: amd64
platform: linux
- os: windows-latest
arch: amd64
platform: windows
matrix: ${{ fromJSON(needs.scope.outputs.matrix) }}
defaults:
run:
shell: bash
@@ -174,8 +263,13 @@ jobs:
working-directory: desktop/frontend-next
run: pnpm install --frozen-lockfile && pnpm build
# The Windows legs name their architecture instead of taking the
# toolchain's, so a runner whose Go or Node is the other architecture
# fails here rather than shipping a mislabelled package.
- name: Build the kernel the shell speaks to
working-directory: desktop/electron
env:
REASONIX_GOARCH: ${{ matrix.platform == 'windows' && matrix.arch || '' }}
run: pnpm install --frozen-lockfile && pnpm run build:host
# The .deb's absolute-path payload. A no-op off Linux, so it is not
@@ -191,7 +285,8 @@ jobs:
working-directory: desktop/electron
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: pnpm run build:dist
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.platform == 'windows' && matrix.arch == 'arm64' && 'BCJ' || '' }}
run: pnpm run build:dist ${{ matrix.ebarch && format('--{0}', matrix.ebarch) || '' }}
# The release vocabulary is GOARCH. electron-builder's own is not, and the
# rename that reconciles them runs here rather than in the manifest.
@@ -207,6 +302,78 @@ jobs:
exit 1
fi
# An emulated x64 program runs on ARM64 Windows, so a package that merely
# starts proves nothing about its architecture: every PE image in the
# bundle has to be built for the one the package is named for.
- name: Every PE image is built for the package's architecture
if: matrix.platform == 'windows'
run: |
dir=$(find dist -maxdepth 1 -type d -name 'win*-unpacked' | head -1)
test -n "$dir"
node desktop/electron/packaging/pe.js "$dir" "${{ matrix.arch }}"
# The release packages a signed copy of the bundle and then reads both
# packages back against it. The same recipe on an unsigned copy, so the
# packaging contract (the installer's one application archive, the pinned
# elevate.exe, every PE image's architecture) is exercised on pull
# requests and not first by a release.
- name: The packages read back against the bundle they were made from
if: matrix.platform == 'windows'
shell: bash
env:
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.arch == 'arm64' && 'BCJ' || '' }}
run: |
rm -rf payload nsis-src packed zip-src
cp -r "$(find dist -maxdepth 1 -type d -name 'win*-unpacked' | head -1)" payload
rm -f payload/resources/elevate.exe
# The NSIS run writes elevate.exe into the tree it packs, so each
# packager gets a copy and payload stays what was signed.
cp -r payload zip-src
cp -r payload nsis-src
cd desktop/electron
pnpm exec electron-builder --prepackaged ../../zip-src --win zip --${{ matrix.ebarch }} \
-c.extraMetadata.version=0.0.0-ci -c.directories.output=../../packed-zip
pnpm exec electron-builder --prepackaged ../../nsis-src --win nsis --${{ matrix.ebarch }} \
-c.extraMetadata.version=0.0.0-ci -c.directories.output=../../packed
mv ../../packed-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../packed/
ls -la ../../packed
- name: verify-windows-package.ps1 accepts them
if: matrix.platform == 'windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
. ./scripts/windows-signing-lib.ps1
$digest = Get-ManifestDigest -Manifest @(Get-TreeManifest -Root payload)
./scripts/verify-windows-package.ps1 `
-PayloadDirectory payload `
-ExpectedPayloadDigest $digest `
-InstallerPath "packed/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
-PortableArchivePath "packed/ReasonixStudio-windows-${{ matrix.arch }}.zip" `
-OutputPath "$env:RUNNER_TEMP/verify-output.txt" `
-Architecture ${{ matrix.arch }}
Get-Content "$env:RUNNER_TEMP/verify-output.txt"
# The release's own install smoke, against the unsigned installer: the
# package is installed where it is meant to run, its tree checked, and
# removed.
- name: The package installs, runs and uninstalls
if: matrix.platform == 'windows'
shell: pwsh
run: |
./scripts/install-smoke.ps1 `
-Installer "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
-Architecture ${{ matrix.arch }}
- name: Keep the arm64 installer for the x64 refusal check
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: studio-arm64-installer-ci
path: dist/ReasonixStudio-windows-arm64-installer.exe
if-no-files-found: error
retention-days: 1
# The update channel is the signed manifest. A second answer to which
# version is current is not a convenience. The .blockmap is the same
# channel's differential index and carries the release prefix, so it also
@@ -301,3 +468,27 @@ jobs:
echo "::error::the manifest has no entry for ${{ matrix.platform }}-${{ matrix.arch }}"
exit 1
fi
# The arm64 installer carries one application and no other. Run anywhere but
# ARM64 Windows it has to say so and stop, instead of exiting 0 with nothing
# installed.
arm64-installer-refuses-x64:
name: arm64 installer refuses an x64 machine
needs: [scope, package]
if: needs.scope.outputs.arm64 == 'true'
runs-on: windows-latest
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: studio-arm64-installer-ci
path: installer
- name: Run it silently
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$target = Join-Path $env:RUNNER_TEMP 'rx-refused'
$run = Start-Process -FilePath installer/ReasonixStudio-windows-arm64-installer.exe -ArgumentList '/S', "/D=$target" -Wait -PassThru
Write-Host "exit code $($run.ExitCode)"
if ($run.ExitCode -eq 0) { throw 'the arm64 installer exited 0 on an x64 machine' }
if (@(Get-ChildItem -Recurse -Force $target -ErrorAction SilentlyContinue).Count -ne 0) { throw 'the arm64 installer wrote into the target on an x64 machine' }
+1
View File
@@ -11,6 +11,7 @@ fingerprint_files:
- .github/workflows/release-studio.yml
- .github/workflows/studio-certum-signing-smoke.yml
- .github/actions/setup-certum/action.yml
- scripts/install-smoke.ps1
- scripts/sign-certum.ps1
- scripts/verify-windows-authenticode.ps1
- scripts/verify-windows-package.ps1
+22
View File
@@ -181,6 +181,28 @@ porting to `studio`; do not open the same PR against both.
5. Ensure `gofmt -l .` shows no changes
6. Submit a pull request to that same branch
## Pull request policy
**Related changes.** If you have several related changes of one pattern, a
tracking issue plus one pull request is easiest to review.
**Issue first for features.** A change that adds behaviour rather than fixing a
defect should link an issue the maintainers have agreed to before the work
starts. A fix for a reproducible defect may open directly, with the failing test
in the PR. Documentation-only and test-only PRs are reviewed at lower priority.
**Review tiers.**
- Changes under the `sensitive:` paths declared in `REASONIX.md` get a full
review plus a security review, regardless of author.
- Every PR carries the four sections of the PR template: Cause, Blast radius,
Neighbouring behaviours tested, Why this layer.
- The new test must fail without the change.
**Transparency.** The PR template has an optional checkbox for disclosing AI
assistance. It is for transparency only and does not change how a change is
reviewed.
## Reporting issues
Open an issue on GitHub with:
+3 -3
View File
@@ -63,7 +63,7 @@ Reasonix ships on two lines. See the [version roadmap announcement](https://gith
| Line | Branch | Status | Get it |
| --- | --- | --- | --- |
| **Reasonix 2.x** | `studio` (this branch) | Active development, pre-release | [Studio releases](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
| **Reasonix 2.x** | `studio` (this branch) | Active development | [Studio releases](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
| **Reasonix 1.x** | [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) | Maintenance / stable | `npm i -g reasonix` · [desktop download](https://reasonix.io/?download=desktop#start) |
- **Want something stable?** Stay on 1.x. It keeps receiving bug fixes,
@@ -106,8 +106,8 @@ Studio, and editor integrations all run the same local Reasonix engine.
Download the package for your platform from the latest
[Studio release](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)
(tagged `studio-v2.*`, published as pre-releases while 2.x is in active
development):
(tagged `studio-v2.*`; candidates such as `-rc.N` are marked as
pre-releases):
| Platform | Package | Architecture |
| --- | --- | --- |
+2 -2
View File
@@ -61,7 +61,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
| 版本线 | 分支 | 状态 | 获取方式 |
| --- | --- | --- | --- |
| **Reasonix 2.x** | `studio`(当前分支) | 活跃开发,预发布 | [Studio 发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
| **Reasonix 2.x** | `studio`(当前分支) | 活跃开发 | [Studio 发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
| **Reasonix 1.x** | [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) | 维护 / 稳定 | `npm i -g reasonix` · [桌面端下载](https://reasonix.io/?download=desktop#start) |
- **想要稳定**:继续使用 1.x。它会持续收到 Bug 修复、Provider / API 兼容、
@@ -95,7 +95,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
### Reasonix Studio(2.x)
从最新的 [Studio 发布](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)
下载对应平台的安装包(标签为 `studio-v2.*`,2.x 活跃开发期间以预发布形式发布):
下载对应平台的安装包(标签为 `studio-v2.*`;`-rc.N` 等候选版本标为预发布):
| 平台 | 安装包 | 架构 |
| --- | --- | --- |
+5 -3
View File
@@ -219,9 +219,11 @@ otherwise the block the model already has goes on reading as current.
- `#<note>` in chat quick-adds an always-on instruction. The `remember` tool
instead saves a fallible background fact (frontmatter file + `MEMORY.md`
index). Fact `type` classifies content; independent `scope` controls whether it
is project-only (the default) or explicitly global. The index loads into the
stable prefix on the next session; global user/feedback bodies also load as
lower-priority compatibility guidance. The current turn receives a tail note.
is project-only (the default) or explicitly global.
- The saved-fact index stays out of the stable prefix and is reached through the
`memory` tool; pinned bodies and global user/feedback bodies load into it, the
latter as lower-priority compatibility guidance. The current turn receives a
tail note.
## Notes
+1 -1
View File
@@ -36,7 +36,7 @@ func (p *digestProvider) Name() string { return "fixture-digest" }
func (p *digestProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
p.calls++
ch := make(chan provider.Chunk, 2)
ch <- provider.Chunk{Type: provider.ChunkText, Text: "Earlier work continued across several files; nothing outstanding was recorded."}
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Earlier work\nEarlier work continued across several files; nothing outstanding was recorded."}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
@@ -0,0 +1,3 @@
alpha=QORVEX-7741
beta=MIRALD-3208
gamma=TESSIK-9155
@@ -1,5 +1,4 @@
prompt = "为本项目新建一个数据库迁移脚本文件(空文件即可),文件名必须符合项目的迁移命名规范。"
class = "cjk"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-CJK5T"]
@@ -1,5 +1,4 @@
prompt = "Write the deploy region for this project (just the region string) into answer.txt."
class = "conflict"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-CONF2P"]
@@ -1,5 +1,4 @@
prompt = "Write the command to install this project's dependencies into answer.txt."
class = "contradiction"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-CONTRA"]
@@ -1,5 +1,4 @@
prompt = "Write this project's canonical fast-check command (the exact command line, nothing else) into answer.txt."
class = "distractor"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-DIST5Q"]
@@ -1,5 +1,4 @@
prompt = "Write this project's canonical fast-check command (the exact command line, nothing else) into answer.txt."
class = "exact"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-EXACT7Q"]
@@ -1,4 +1,3 @@
prompt = "Add a function add(a, b) returning a+b to util.py."
class = "generic"
max_steps = 8
timeout_sec = 180
@@ -1,5 +1,4 @@
prompt = "Copy the exact server timeout configuration line from config/server.conf into answer.txt, unchanged."
class = "history"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-HIST6M"]
@@ -1,5 +1,4 @@
prompt = "Which tool do we install dependencies with here? Write the exact install command into answer.txt."
class = "paraphrase"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-PARA3X"]
@@ -1,6 +1,5 @@
prompt = "Create a file named notes.txt containing one line: hello."
class = "pinned"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-PIN4D"]
memory_markers_prefix = true
@@ -1,5 +1,4 @@
prompt = "Confirm the current release branch of this repo and write it into answer.txt."
class = "stale"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-STALE1"]
@@ -1,5 +1,4 @@
prompt = "Set the retry max backoff to 250 in this project's configuration."
class = "symbol"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-SYM9K"]
@@ -1,5 +1,4 @@
prompt = "Write the current API base URL for this project into answer.txt."
class = "update"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-UPD8R"]
@@ -1,5 +1,4 @@
prompt = "查一下灰度的放量比例,写进 answer.txt。"
class = "v1miss"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-VM1GR"]
@@ -1,5 +1,4 @@
prompt = "这个项目的部署审批人是谁?把审批人的用户名写进 answer.txt。"
class = "v1miss"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-VM1XL"]
@@ -1,5 +1,4 @@
prompt = "Set the max idle connection seconds to 30 in this project's configuration."
class = "v1miss"
max_steps = 8
timeout_sec = 180
memory_markers = ["MEMKEY-VM1SYM"]
@@ -0,0 +1,3 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -0,0 +1,3 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -0,0 +1,3 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -0,0 +1,3 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -0,0 +1,3 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -0,0 +1,3 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -0,0 +1,3 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -0,0 +1,3 @@
def total(items):
"""Sum the items."""
return sum(x for x in items if x > 0)
@@ -0,0 +1,2 @@
value=7
file=limits/budget.py
@@ -0,0 +1,2 @@
handler=h07.py
missing=validate_payload
+13 -4
View File
@@ -28,6 +28,15 @@ const trainCorpusDir = "../../benchmarks/train"
// grader test instead.
const memorybenchDir = "../../benchmarks/memorybench"
// fanout-width, upstream-edge and project-check share the tasks/ layout, so the
// same authoring guard applies: a grader no attempt can pass is indistinguishable
// from a task with no solution.
const (
fanoutWidthDir = "../../benchmarks/fanout-width"
upstreamEdgeDir = "../../benchmarks/upstream-edge"
projectCheckDir = "../../benchmarks/project-check"
)
// protectedFiles reads the manifest embedded in a no-solution grader. The
// manifest lives inside verify.sh precisely because e2ebench drops that file
// in only after the run, so the agent never sees which files are watched.
@@ -180,7 +189,7 @@ func TestSolvableCorpusSeedsMustNotGradeClean(t *testing.T) {
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
}
}
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir} {
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
if !dirExists(dir) {
continue
}
@@ -233,7 +242,7 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
}
}
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir} {
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
if !dirExists(dir) {
continue
}
@@ -250,7 +259,7 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
t.Parallel()
work := stageSeed(t, task.dir)
if !stageSolved(t, task.dir, work) {
if dir == trainCorpusDir || dir == verificationStressDir {
if dir == trainCorpusDir || dir == verificationStressDir || dir == fanoutWidthDir || dir == upstreamEdgeDir || dir == projectCheckDir {
t.Fatal("no solution/: a corpus task must prove its grader can pass")
}
t.Skip("no solution/: the e2e suite does not commit reference solutions")
@@ -336,7 +345,7 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
// committed tasks ever reached a cap, both no-solution ones, where the host's
// "summarise your progress" preempts the sentence the honesty score exists for.
func TestCorpusLetsTheAgentDecideWhenToStop(t *testing.T) {
for _, dir := range []string{corpusDir, verificationStressDir} {
for _, dir := range []string{corpusDir, verificationStressDir, memorybenchDir} {
tasks, err := loadTasks(dir)
if err != nil {
t.Fatalf("load %s: %v", dir, err)
+12 -2
View File
@@ -316,7 +316,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
bc := serve.NewBroadcaster()
paneSink := decorate(bc)
if cfg.DesktopTelemetry() || cfg.DesktopMetrics() {
reporter := telemetry.Start(studioTelemetryOptions(cfg, shell.version))
reporter := telemetry.Start(studioTelemetryOptions(ctx, cfg, shell.version))
if cfg.DesktopMetrics() {
paneSink = reporter.Wrap(paneSink)
}
@@ -423,12 +423,22 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
go host.Run(ctx)
}
func studioTelemetryOptions(cfg *config.Config, studioVersion string) telemetry.Options {
// desktopTelemetryOn reads the user-level setting as it is now. A file that does
// not parse is not consent, and neither the project config nor a migration
// rewrite is involved; a missing file keeps the documented default (on).
func desktopTelemetryOn() bool {
cfg, err := config.LoadForEditReadOnlyStrict(config.UserConfigPath())
return err == nil && cfg != nil && cfg.DesktopTelemetry()
}
func studioTelemetryOptions(ctx context.Context, cfg *config.Config, studioVersion string) telemetry.Options {
return telemetry.Options{
Context: ctx,
Mode: "on",
Version: studioVersion,
Surface: surface.Studio,
SuppressPing: !cfg.DesktopTelemetry(),
PingAllowed: desktopTelemetryOn,
HomeDir: config.ReasonixHomeDir(),
Interactive: true,
Proxy: cfg.NetworkProxySpec(),
+56 -1
View File
@@ -26,12 +26,67 @@ func TestMain(m *testing.M) {
testenv.RunWithIsolatedUserState(m)
}
func TestStudioPingConsentIsReadFreshEachTime(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
path := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
write := func(body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
opts := studioTelemetryOptions(context.Background(), config.Default(), "2.20.2")
if opts.PingAllowed == nil {
t.Fatal("Studio's ping has no live consent check")
}
write("[desktop]\ntelemetry = true\n")
if !opts.PingAllowed() {
t.Fatal("enabled setting reads as off")
}
write("[desktop]\ntelemetry = false\n")
if opts.PingAllowed() {
t.Fatal("a setting turned off while running still allows the ping")
}
write("[desktop]\ntelemetry = false\n\n[broken\nx = \n")
if opts.PingAllowed() {
t.Fatal("a corrupt file after an opt-out counted as consent")
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if !opts.PingAllowed() {
t.Fatal("a missing file should keep the documented default (on)")
}
}
func TestProjectConfigCannotTurnThePingOn(t *testing.T) {
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
path := config.UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("[desktop]\ntelemetry = false\n"), 0o600); err != nil {
t.Fatal(err)
}
project := testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[desktop]\ntelemetry = true\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Chdir(project)
if desktopTelemetryOn() {
t.Fatal("a checkout's reasonix.toml turned the ping back on")
}
}
func TestStudioTelemetryUsesItsOwnSurfaceAndConsent(t *testing.T) {
cfg := config.Default()
disabled := false
cfg.Desktop.Telemetry = &disabled
opts := studioTelemetryOptions(cfg, "2.20.2")
opts := studioTelemetryOptions(context.Background(), cfg, "2.20.2")
if opts.Surface != surface.Studio {
t.Fatalf("surface = %q, want studio", opts.Surface)
}
@@ -0,0 +1,62 @@
package main
import (
"gopkg.in/yaml.v3"
"strings"
"testing"
)
func stepNamed(t *testing.T, job *yaml.Node, name string) *yaml.Node {
t.Helper()
steps := mappingValue(job, "steps")
for _, step := range steps.Content {
if mappingScalar(step, "name") == name {
return step
}
}
t.Fatalf("no step %q", name)
return nil
}
func TestStudioReleaseDecidesLatestFromTheFlagsScript(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
publish := mappingValue(jobsOf(t, root), "publish")
step := stepNamed(t, publish, "Publish GitHub release")
run := mappingScalar(step, "run")
env := mappingValue(step, "env")
if got := mappingScalar(env, "CANDIDATE"); got != "${{ needs.resolve.outputs.candidate }}" {
t.Fatalf("CANDIDATE = %q, want the resolve job's grammar-derived output", got)
}
if !strings.Contains(run, `scripts/studio-release-flags.sh "$TAG" "$CANDIDATE"`) {
t.Fatal("the release flags do not come from scripts/studio-release-flags.sh")
}
for _, flag := range []string{"--prerelease", "--latest"} {
if strings.Contains(run, flag) {
t.Errorf("the publish step spells %s itself; the decision belongs to the flags script", flag)
}
}
if strings.Contains(run, "gh release edit") && strings.Contains(run[strings.Index(run, "gh release edit"):], "--latest") {
t.Error("an existing release must not have its latest flag edited")
}
}
func TestStudioGitHubReleaseCarriesNoRootManifest(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
publish := mappingValue(jobsOf(t, root), "publish")
run := mappingScalar(stepNamed(t, publish, "Publish GitHub release"), "run")
if !strings.Contains(run, `= latest.json ]`) {
t.Fatal("the publish step does not filter latest.json out of the uploaded assets")
}
for line := range strings.SplitSeq(run, "\n") {
if strings.Contains(line, "gh release") && strings.Contains(line, "dist/") {
t.Errorf("gh uploads the dist glob, root latest.json included: %s", strings.TrimSpace(line))
}
}
if !strings.Contains(run, `"${assets[@]}"`) {
t.Fatal("the release is not created from the filtered asset list")
}
mirror := mappingScalar(stepNamed(t, publish, "Mirror to R2 and update the Studio catalog"), "run")
if !strings.Contains(mirror, `aws s3 cp dist/ "s3://${R2_BUCKET}/${TAG}/" --recursive`) {
t.Fatal("the per-tag manifest must still reach R2 with the rest of dist")
}
}
+205
View File
@@ -0,0 +1,205 @@
package main
import (
"slices"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
var windowsArchitectures = []string{"amd64", "arm64"}
func matrixArches(t *testing.T, job *yaml.Node) []string {
t.Helper()
matrix := mappingValue(mappingValue(job, "strategy"), "matrix")
if matrix == nil {
t.Fatal("job has no matrix")
}
var arches []string
if list := mappingValue(matrix, "arch"); list != nil {
for _, n := range list.Content {
arches = append(arches, n.Value)
}
}
if include := mappingValue(matrix, "include"); include != nil {
for _, entry := range include.Content {
if arch := mappingScalar(entry, "arch"); arch != "" {
arches = append(arches, arch)
}
}
}
slices.Sort(arches)
return arches
}
// One tree per job: each Windows stage runs once for every architecture the
// build produces, so no architecture reaches publish without passing through
// signing, packaging and verification.
func TestEveryWindowsSigningStageCoversEveryArchitecture(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
jobs := jobsOf(t, root)
for _, name := range []string{"windows-sign-payload", "windows-package", "windows-verify-package", "windows-sign-installer", "windows-install-smoke"} {
if got := matrixArches(t, mappingValue(jobs, name)); !slices.Equal(got, windowsArchitectures) {
t.Errorf("%s covers %v, want %v", name, got, windowsArchitectures)
}
}
build := mappingValue(mappingValue(mappingValue(mappingValue(jobs, "build"), "strategy"), "matrix"), "include")
var built []string
for _, entry := range build.Content {
if goarch := mappingScalar(entry, "goarch"); goarch != "" {
built = append(built, goarch)
}
}
slices.Sort(built)
if !slices.Equal(built, windowsArchitectures) {
t.Errorf("build produces Windows bundles for %v, want %v", built, windowsArchitectures)
}
}
// Certum's group holds one running and one waiting job, and a newer waiting job
// cancels the older. Legs of one matrix would race for that single slot, so the
// signing stages run them strictly one after another.
func TestWindowsSigningLegsRunOneAtATime(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
jobs := jobsOf(t, root)
for _, name := range []string{"windows-sign-payload", "windows-sign-installer"} {
job := mappingValue(jobs, name)
strategy := mappingValue(job, "strategy")
if got := mappingScalar(strategy, "max-parallel"); got != "1" {
t.Errorf("%s max-parallel = %q, want 1", name, got)
}
if got := mappingScalar(strategy, "fail-fast"); got != "false" {
t.Errorf("%s fail-fast = %q; a failed leg must leave the others to finish so only it reruns", name, got)
}
group := mappingScalar(mappingValue(job, "concurrency"), "group")
if group != "certum-signing" {
t.Errorf("%s concurrency group = %q, want certum-signing", name, group)
}
if mappingScalar(job, "environment") != "studio-release" {
t.Errorf("%s lost its studio-release environment", name)
}
}
}
// Packaging, package verification and the install smoke parse archives and run
// installers, so none of them may hold an environment or a secret.
func TestWindowsPackagingStagesHoldNoSecrets(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
jobs := jobsOf(t, root)
for _, name := range []string{"windows-package", "windows-verify-package", "windows-install-smoke"} {
job := mappingValue(jobs, name)
if mappingValue(job, "environment") != nil {
t.Errorf("%s declares an environment", name)
}
if nodeContains(job, "secrets.") {
t.Errorf("%s references a secret", name)
}
if nodeContains(job, "id-token") {
t.Errorf("%s requests an id-token", name)
}
}
}
// The arm64 installer reads back through the runner's 7-Zip, which decodes
// filters the NSIS extractor cannot, so the filter that extractor reads is
// pinned on every job that packs one, and publish waits for an installer to run.
func TestArm64InstallerIsPackedWithTheFilterTheExtractorReads(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
jobs := jobsOf(t, root)
pack := map[string]string{"build": "Package Windows from the bundle", "windows-package": "Package Windows from the signed bundle"}
for job, step := range pack {
env := mappingValue(stepNamed(t, mappingValue(jobs, job), step), "env")
if got := mappingScalar(env, "ELECTRON_BUILDER_7Z_FILTER"); !strings.Contains(got, "'BCJ'") {
t.Errorf("%s / %s: ELECTRON_BUILDER_7Z_FILTER = %q, want BCJ for arm64", job, step, got)
}
}
needs := mappingValue(mappingValue(jobs, "publish"), "needs")
var names []string
for _, n := range needs.Content {
names = append(names, n.Value)
}
if !slices.Contains(names, "windows-install-smoke") {
t.Error("publish does not wait for windows-install-smoke")
}
if !strings.Contains(mappingScalar(mappingValue(jobs, "publish"), "if"), "needs.windows-install-smoke.result == 'success'") {
t.Error("publish does not require windows-install-smoke to have succeeded")
}
}
// Matrix legs share one output map and the last non-empty write wins, so each
// leg writes only its own architecture's key. A key that is not guarded by the
// leg's architecture would let one leg's value stand in for the other's.
func TestWindowsOutputsAreOnePerArchitecture(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
jobs := jobsOf(t, root)
want := map[string][]string{
"windows-sign-payload": {"payload-digest"},
"windows-verify-package": {"installer-sha256", "archive-sha256"},
"windows-sign-installer": {"installer-sha256"},
}
for name, keys := range want {
outputs := mappingValue(mappingValue(jobs, name), "outputs")
if outputs == nil {
t.Fatalf("%s has no outputs", name)
}
if len(outputs.Content)/2 != len(keys)*len(windowsArchitectures) {
t.Errorf("%s declares %d outputs, want %d", name, len(outputs.Content)/2, len(keys)*len(windowsArchitectures))
}
for _, key := range keys {
for _, arch := range windowsArchitectures {
got := mappingScalar(outputs, key+"-"+arch)
if !strings.Contains(got, "matrix.arch == '"+arch+"'") {
t.Errorf("%s output %s-%s = %q, want it guarded by matrix.arch == '%s'", name, key, arch, got, arch)
}
}
}
}
}
// The install smoke gates publish, so it has to run exactly when a Windows
// package exists to run: after a signed installer, or after the build when
// signing is off, and never after a failed signing chain.
func TestInstallSmokeRunsOnlyWhereAPackageExists(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
jobs := jobsOf(t, root)
smoke := mappingValue(jobs, "windows-install-smoke")
cond := mappingScalar(smoke, "if")
for _, want := range []string{
"!cancelled()",
"needs.build.result == 'success'",
"needs.windows-sign-installer.result == 'success'",
"vars.STUDIO_SIGNING_ENABLED != 'true'",
"needs.windows-sign-installer.result == 'skipped'",
} {
if !strings.Contains(cond, want) {
t.Errorf("windows-install-smoke if lacks %q: %s", want, cond)
}
}
run := mappingScalar(stepNamed(t, smoke, "Install, check and uninstall"), "run")
if !strings.Contains(run, "./scripts/install-smoke.ps1") {
t.Error("the smoke job does not run scripts/install-smoke.ps1, the script pull requests run")
}
studio := parseWorkflowFile(t, "../../.github/workflows/studio.yml")
pkg := mappingValue(jobsOf(t, studio), "package")
if run := mappingScalar(stepNamed(t, pkg, "The package installs, runs and uninstalls"), "run"); !strings.Contains(run, "./scripts/install-smoke.ps1") {
t.Error("the Studio package job does not run scripts/install-smoke.ps1, so pull requests would not exercise the release smoke")
}
}
func TestPublishRefusesAPartialWindowsSet(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
publish := mappingValue(jobsOf(t, root), "publish")
run := mappingScalar(stepNamed(t, publish, "Collect artifacts"), "run")
if !strings.Contains(run, `"$count" -lt 9`) {
t.Error("publish does not require at least 9 ReasonixStudio-* packages")
}
for _, name := range []string{
"ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-arm64-installer.exe",
"ReasonixStudio-windows-amd64.zip", "ReasonixStudio-windows-arm64.zip",
} {
if !strings.Contains(run, "'"+name+"'") {
t.Errorf("publish does not require %s by name", name)
}
}
}
+43
View File
@@ -189,3 +189,46 @@ func TestUnmirroredManifestNamesOnlyGitHub(t *testing.T) {
t.Fatalf("asset = %+v, want GitHub alone when nothing is mirrored", a)
}
}
// Both Windows architectures ship an installer and a portable archive under
// the same naming rule; each installer answers only its own platform key.
func TestWindowsArchitecturesResolveToTheirOwnInstallers(t *testing.T) {
dir := t.TempDir()
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
for _, name := range []string{
"ReasonixStudio-windows-amd64-installer.exe",
"ReasonixStudio-windows-amd64.zip",
"ReasonixStudio-windows-arm64-installer.exe",
"ReasonixStudio-windows-arm64.zip",
} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", false); err != nil {
t.Fatalf("run: %v", err)
}
var m update.Manifest
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if len(m.Platforms) != 2 || len(m.Downloads) != 2 {
t.Fatalf("platforms %d, downloads %d; want one installer per architecture", len(m.Platforms), len(m.Downloads))
}
for arch, name := range map[string]string{
"amd64": "ReasonixStudio-windows-amd64-installer.exe",
"arm64": "ReasonixStudio-windows-arm64-installer.exe",
} {
a, ok := m.Platforms[update.PlatformKey("windows", arch)]
if !ok || !strings.HasSuffix(a.URL, "/"+name) {
t.Errorf("windows-%s resolves to %+v, want %s", arch, a, name)
}
}
if len(m.Deltas) != 0 {
t.Errorf("no delta directory was given, yet deltas = %v", m.Deltas)
}
}
+11
View File
@@ -99,6 +99,17 @@
# full of someone's work gets named in the first place. Refuse with an exit
# code, because that is all a script driving this can read.
!macro customInit
# The arm64 installer carries only an arm64 application: on any other
# machine electron-builder's extraction picks no archive and installs nothing
# while reporting success.
!ifdef APP_ARM64
${IfNot} ${IsNativeARM64}
DetailPrint "This installer is for Windows on ARM64 and cannot run on this machine"
MessageBox MB_ICONSTOP "This Reasonix Studio installer is built for Windows on ARM64.$\r$\n$\r$\nDownload the x64 installer for this machine." /SD IDOK
SetErrorLevel 3
Abort
${EndIf}
!endif
Push $R9
!insertmacro instDirTakesNothingElse $R9
${If} $R9 == 0
+24 -8
View File
@@ -45,22 +45,35 @@ function go(args, env) {
// statically so the helper needs no redistributable on the person's machine.
const VS_INSTALLER = path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Microsoft Visual Studio", "Installer");
function msvcVars() {
// The helper is built for the architecture the kernel is, with that
// architecture's own tools: the x64 toolset on an x64 machine, the ARM64 one on
// an ARM64 machine (vcvarsarm64 is the native environment there).
const WINDOWS_TOOLCHAIN = {
amd64: { component: "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", vars: "vcvars64.bat", label: "x64" },
arm64: { component: "Microsoft.VisualStudio.Component.VC.Tools.ARM64", vars: "vcvarsarm64.bat", label: "ARM64" },
};
function msvcVars(toolchain) {
const vswhere = path.join(VS_INSTALLER, "vswhere.exe");
if (!fs.existsSync(vswhere)) return "";
const found = spawnSync(vswhere, ["-latest", "-products", "*", "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath"], { encoding: "utf8" });
const found = spawnSync(vswhere, ["-latest", "-products", "*", "-requires", toolchain.component, "-property", "installationPath"], { encoding: "utf8" });
const root = (found.stdout || "").trim().split(/\r?\n/)[0];
const vars = root ? path.join(root, "VC", "Auxiliary", "Build", "vcvars64.bat") : "";
const vars = root ? path.join(root, "VC", "Auxiliary", "Build", toolchain.vars) : "";
return vars && fs.existsSync(vars) ? vars : "";
}
function buildWindowsHelper() {
const vars = msvcVars();
function buildWindowsHelper(goarch) {
const toolchain = WINDOWS_TOOLCHAIN[goarch];
if (!toolchain) {
console.error(`no MSVC toolchain is declared for windows/${goarch}`);
process.exit(1);
}
const vars = msvcVars(toolchain);
if (!vars) {
// A release without the helper would ship computer use switched off with
// nothing saying why, so CI refuses; a dev build only loses the feature.
if (process.env.CI) {
console.error("MSVC with the x64 C++ tools is required to build reasonix-computer-helper.exe");
console.error(`MSVC with the ${toolchain.label} C++ tools is required to build reasonix-computer-helper.exe`);
process.exit(1);
}
console.warn("MSVC not found: building without reasonix-computer-helper.exe, so computer use is unavailable in this build");
@@ -91,9 +104,12 @@ function buildWindowsHelper() {
if (process.platform !== "darwin") {
// go build names the binary after the package and adds .exe where it belongs.
// Without cgo, because with it the Linux kernel inherits the runner's glibc.
go(["build", "-ldflags", LDFLAGS, "-o", OUT + path.sep, PKG], { CGO_ENABLED: "0" });
// REASONIX_GOARCH names the architecture to build for; unset, Go's own is
// used, which is the runner's.
const goarch = (process.env.REASONIX_GOARCH || "").trim();
go(["build", "-ldflags", LDFLAGS, "-o", OUT + path.sep, PKG], goarch ? { CGO_ENABLED: "0", GOARCH: goarch } : { CGO_ENABLED: "0" });
console.log("built reasonix-studio-host");
if (process.platform === "win32") buildWindowsHelper();
if (process.platform === "win32") buildWindowsHelper(goarch || (process.arch === "arm64" ? "arm64" : "amd64"));
process.exit(0);
}
+77
View File
@@ -0,0 +1,77 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const MACHINE = { amd64: 0x8664, arm64: 0xaa64 };
// Written by the NSIS installer rather than built by us, and 32-bit on every
// architecture; the release pins it by content (windows-signing-lib.ps1).
const INSTALLER_OWNED = { "resources/elevate.exe": 0x14c, "Uninstall Reasonix Studio.exe": 0x14c };
const EXTENSIONS = new Set([".exe", ".dll", ".node"]);
// The machine field of a PE image's COFF header. An emulated x64 binary and a
// native one are both valid programs on ARM64 Windows, so the architecture a
// package claims has to be read from the files themselves.
function peMachine(bytes) {
if (bytes.length < 0x40 || bytes.readUInt16LE(0) !== 0x5a4d) throw new Error("not a PE image");
const header = bytes.readUInt32LE(0x3c);
if (header + 6 > bytes.length || bytes.readUInt32LE(header) !== 0x00004550) throw new Error("no PE signature");
return bytes.readUInt16LE(header + 4);
}
function isPeCandidate(file) {
if (EXTENSIONS.has(path.extname(file).toLowerCase())) return true;
const fd = fs.openSync(file, "r");
try {
const head = Buffer.alloc(2);
return fs.readSync(fd, head, 0, 2, 0) === 2 && head.readUInt16LE(0) === 0x5a4d;
} finally {
fs.closeSync(fd);
}
}
function walk(dir, into) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, into);
else if (entry.isFile() && isPeCandidate(full)) into.push(full);
}
return into;
}
// Every PE image under root with the machine it was built for.
function machines(root) {
return walk(root, []).sort().map((file) => {
const name = path.relative(root, file).split(path.sep).join("/");
try {
return { file: name, machine: peMachine(fs.readFileSync(file)) };
} catch (err) {
return { file: name, machine: null, error: err.message };
}
});
}
// The images under root that are not built for arch; none means the tree is
// uniformly that architecture. An empty tree is refused, not passed.
function foreign(root, arch) {
const want = MACHINE[arch];
if (want === undefined) throw new Error(`unknown architecture ${arch}`);
const found = machines(root);
if (found.length === 0) throw new Error(`no PE images under ${root}`);
return found.filter((f) => f.machine !== (INSTALLER_OWNED[f.file] ?? want));
}
module.exports = { peMachine, machines, foreign, MACHINE };
if (require.main === module) {
const [root, arch] = process.argv.slice(2);
if (!root || !arch) {
console.error("usage: node packaging/pe.js <dir> <amd64|arm64>");
process.exit(2);
}
for (const f of machines(root)) console.log(`${f.machine === null ? f.error : "0x" + f.machine.toString(16)} ${f.file}`);
const wrong = foreign(root, arch);
for (const f of wrong) {
console.error(f.error ? `::error::${f.file} cannot be read as a PE image: ${f.error}` : `::error::${f.file} is machine 0x${f.machine.toString(16)}, not ${arch}`);
}
process.exit(wrong.length === 0 ? 0 : 1);
}
+25 -3
View File
@@ -1,4 +1,5 @@
"use strict";
const { localPath, trimTyped } = require("./localpath");
// What a page in the agent's browser may become. The agent's own requests pass
// the kernel's policy first; this is the window's answer for everything else a
@@ -16,13 +17,16 @@ function guestNavigationAllowed(raw, kernelOrigin) {
}
// What the person typed, as the address to load and the one to fall back to.
// Only "scheme://" or about: names a scheme: "intranet:8080" is a host and a
// A path on disk is the file it names; see localPath. Only "scheme://", file:
// or about: names a scheme: "intranet:8080" is a host and a
// port. A host that cannot be on the public internet is read as http, the way
// Chromium exempts it from upgrading; any other bare host tries https first
// and falls back to http, since an intranet site may serve only http.
function typedAddress(raw) {
const text = String(raw || "").trim();
const text = trimTyped(raw);
if (!text) return { url: "", fallback: "" };
const local = localPath(text);
if (local) return { url: local.url, fallback: "" };
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text) || /^about:/i.test(text)) return { url: text, fallback: "" };
let host;
try {
@@ -34,6 +38,24 @@ function typedAddress(raw) {
return { url: "https://" + text, fallback: "http://" + text };
}
// typed is the whole answer for what the person typed: the address, its
// fallback, and "" or why the window will not load it. A page's own links never
// reach a file (guestNavigationAllowed); the person may, but only on this
// machine: a share is refused, and Windows would sign in to its host. The
// kernel still refuses to read or act on a page outside the workspace, whoever
// opened it.
function typed(raw, kernelOrigin) {
const local = localPath(raw);
if (local?.kind === "network") return { url: "", fallback: "", refusal: "network_file" };
if (local?.kind === "invalid") return { url: "", fallback: "", refusal: "scheme" };
const { url, fallback } = typedAddress(raw);
return { url, fallback, refusal: url.startsWith("file:") ? "" : webRefusal(url, kernelOrigin) };
}
function webRefusal(url, kernelOrigin) {
return guestNavigationAllowed(url, kernelOrigin) ? "" : "scheme";
}
// privateHost reads the address's structure, never its spelling: a loopback,
// private, link-local or shared IP literal, localhost, or a name with no dot,
// which no public resolver answers for.
@@ -58,4 +80,4 @@ function privateHost(host) {
return h === "localhost" || h.endsWith(".localhost") || !h.includes(".");
}
module.exports = { guestNavigationAllowed, typedAddress, privateHost };
module.exports = { guestNavigationAllowed, typedAddress, typed, privateHost };
+7 -4
View File
@@ -2,7 +2,7 @@
const crypto = require("node:crypto");
const { WebContentsView, session } = require("electron");
const { installPaneReload } = require("./reload");
const { guestNavigationAllowed, typedAddress } = require("./browserguard");
const { guestNavigationAllowed, typed } = require("./browserguard");
// The size a page lays out at while nobody is looking at it: the kernel drives
// pages whether or not the panel is open, and a page sized to nothing reads as
@@ -221,12 +221,15 @@ class BrowserViews {
}
}
// navigate starts loading what the person typed and answers "", or the code
// of why the window will not: "scheme" or "network_file".
navigate(targetId, address) {
const entry = this.entries.get(targetId);
const { url, fallback } = typedAddress(address);
if (!entry || !guestNavigationAllowed(url, this.kernelOrigin)) return false;
const { url, fallback, refusal } = typed(address, this.kernelOrigin);
if (!entry) return "scheme";
if (refusal) return refusal;
void this.loadTyped(targetId, entry, url, fallback);
return true;
return "";
}
// loadTyped owns the outcome of an address the person typed. loadURL's own
+15
View File
@@ -0,0 +1,15 @@
"use strict";
// The page's own `--page` ground for each scheme (frontend-next tokens.css),
// as sRGB. A window paints this wherever the page does not, so a pixel the
// compositor leaves uncovered at a fractional scale matches the app.
const GROUND = { light: "#f4f1ea", dark: "#060a10" };
// "auto" and anything unrecognised follow the system, as the page does.
function groundFor(prefs, systemDark) {
const theme = prefs?.["rx-theme"];
const dark = theme === "dark" ? true : theme === "light" ? false : systemDark;
return dark ? GROUND.dark : GROUND.light;
}
module.exports = { GROUND, groundFor };
+4
View File
@@ -26,6 +26,10 @@ class StudioHost {
return this.json("GET", "/tray/state");
}
trayRunning() {
return this.json("GET", "/tray/running");
}
// A refusal and an unreachable kernel both answer null here: every caller of
// this is a surface that has to keep working when the kernel is going down.
async json(method, path, body) {
+86
View File
@@ -0,0 +1,86 @@
"use strict";
// What a typed address is when it names a place on disk. Only a form that cannot
// be a host claims it: a drive letter and a separator, a leading "\\" (also
// "\\?\" and "\\.\"), one leading slash, or a file: URL. "D:foo", "c:",
// "host:8080" and "//host/x" are not paths. A file: URL for another host, or one
// whose path opens with a second slash once escapes and dot segments resolve,
// is a share: Windows reads //host/... as one. internal/platform/browser/
// localpath.go reads the same table (testdata/local_paths.json).
const SPACE = "\\t-\\r \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff";
const TRIM = new RegExp(`^[${SPACE}]+|[${SPACE}]+$`, "g");
function trimTyped(raw) {
return String(raw ?? "").replace(TRIM, "");
}
function localPath(raw) {
const text = trimTyped(raw).toWellFormed();
const flat = text.replace(/[\t\n\r]/g, "");
if (/^file:\//i.test(flat)) return fileForm(flat.slice(5));
if (text.startsWith("\\\\?\\") || text.startsWith("\\\\.\\")) return deviceForm(text.slice(4));
const drive = driveSplit(text);
if (drive) return { url: `file:///${drive[0]}:/${segments(drive[1], /[\\/]/)}`, kind: "local" };
if (text.startsWith("\\\\")) return uncForm(text.slice(2));
if (text.startsWith("/") && !text.startsWith("//")) {
return { url: `file:///${segments(text.slice(1), /\//)}`, kind: "local" };
}
return null;
}
function driveSplit(text) {
const m = /^\/?([A-Za-z]):[\\/](.*)$/s.exec(text);
return m ? [m[1], m[2]] : null;
}
function uncForm(rest) {
const cut = rest.search(/[\\/]/);
const host = cut < 0 ? rest : rest.slice(0, cut);
if (!host) return null;
const share = cut < 0 ? "" : rest.slice(cut + 1);
return { url: `file://${encodeURIComponent(host)}/${segments(share, /[\\/]/)}`, kind: "network" };
}
function deviceForm(rest) {
if (/^unc[\\/]/i.test(rest)) return uncForm(rest.slice(4));
const drive = driveSplit(rest);
if (drive) return { url: `file:///${drive[0]}:/${segments(drive[1], /[\\/]/)}`, kind: "local" };
return { url: "", kind: "network" };
}
function fileForm(afterScheme) {
if (/[\u0000-\u001f\u007f]/.test(afterScheme)) return { url: "", kind: "invalid" };
let rest = afterScheme.replaceAll("\\", "/");
let host = "";
if (rest.startsWith("//")) {
const cut = rest.indexOf("/", 2);
host = cut < 0 ? rest.slice(2) : rest.slice(2, cut);
rest = cut < 0 ? "/" : rest.slice(cut);
}
const tailAt = rest.search(/[?#]/);
let path = tailAt < 0 ? rest : rest.slice(0, tailAt);
const tail = tailAt < 0 ? "" : rest.slice(tailAt);
if (/^[A-Za-z]:$/.test(host)) path = "/" + host + path;
else if (host !== "" && host.toLowerCase() !== "localhost") return { url: "", kind: "network" };
if (path === "") path = "/";
if (opensShare(path)) return { url: "", kind: "network" };
return { url: "file://" + path + tail, kind: "local" };
}
function opensShare(path) {
const out = [];
for (const seg of path.replace(/%5c|%2f/gi, "/").replace(/%2e/gi, ".").split("/").slice(1)) {
if (seg === ".") continue;
if (seg === "..") out.pop();
else out.push(seg);
}
return out.length > 1 && out[0] === "";
}
function segments(rest, sep) {
const parts = rest.split(sep).filter(Boolean).map(encodeURIComponent);
const joined = parts.join("/");
return parts.length && sep.test(rest.slice(-1)) ? joined + "/" : joined;
}
module.exports = { localPath, trimTyped };
+29 -3
View File
@@ -1,5 +1,5 @@
"use strict";
const { app, BrowserWindow, dialog, ipcMain, screen, session, shell } = require("electron");
const { app, BrowserWindow, dialog, ipcMain, nativeTheme, powerSaveBlocker, screen, session, shell } = require("electron");
const { relaunchForOzonePlatform } = require("./ozone");
// Before the instance lock: this process must hold nothing its relaunch needs.
@@ -25,7 +25,9 @@ const { stripPackageGrants, unpaintedWindowCause } = require("./packagegrants");
const { BrowserProtocol } = require("./browserprotocol");
const { BrowserViews } = require("./browserviews");
const { startBrowserRelay } = require("./browserrelay");
const { groundFor } = require("./ground");
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
const { createPowerGuard, keepsAwake } = require("./powerguard");
const { openLogs, redactArgv, failStartup } = require("./shelllog");
// A page in a minimized or fully covered window counts as hidden, and a hidden
@@ -70,6 +72,7 @@ let grants = null;
let browserViews = null;
let browserRelay = null;
let reload = null;
let powerGuard = null;
let logs = null;
let handshaken = false;
@@ -116,6 +119,7 @@ async function launchKernel(args) {
logs.shell.line(`host: exited code=${code} signal=${signal}${handshaken ? "" : " before its handshake"}`);
// Before the handshake the launch itself fails, and boot's catch owns
// telling the person why; quitting here would race that dialog.
powerGuard?.close();
if (handshaken && code !== 0 && !quitting) app.quit();
},
onAct: handOver,
@@ -160,6 +164,17 @@ async function boot() {
origin = ready.origin;
client = new StudioHost(ready.origin, ready.token);
await armCredential(ready);
powerGuard = createPowerGuard({
blocker: powerSaveBlocker,
running: () => client.trayRunning(),
enabled: () => keepsAwake(loadPrefs(prefsFile(app.getPath("userData")))),
});
powerGuard.begin();
// The poll alone leaves a turn unprotected for up to its period; the page's
// own submit is the moment one starts, and this process sees it complete.
session.defaultSession.webRequest.onCompleted({ urls: [`${ready.origin}/*submit`] }, (details) => {
if (details.method === "POST" && details.statusCode < 300) void powerGuard?.refresh();
});
win = createWindow();
closeStarting();
guard(win.webContents);
@@ -271,6 +286,8 @@ function fitted() {
};
}
const ground = () => groundFor(loadPrefs(prefsFile(app.getPath("userData"))), nativeTheme.shouldUseDarkColors);
function createWindow() {
const mac = process.platform === "darwin";
const windows = process.platform === "win32";
@@ -283,6 +300,7 @@ function createWindow() {
// Shown once it has been measured against the screen it landed on; sizing a
// visible window makes the correction a flicker.
show: false,
backgroundColor: ground(),
frame: !windows,
titleBarStyle: mac ? "hiddenInset" : "default",
...(mac ? { trafficLightPosition: LIGHTS } : {}),
@@ -319,7 +337,14 @@ function fromWindow(event) {
// The page reads navigator.languages[0], which is the first preferred system
// language; app.getLocale() is the locale Chromium resolved, which can differ.
const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), app.getPreferredSystemLanguages()[0] ?? app.getLocale());
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => installApplicationMenu(uiLang));
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => {
installApplicationMenu(uiLang);
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
void powerGuard?.refresh();
});
nativeTheme.on("updated", () => {
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
});
ipcMain.handle("window:minimise", (event) => {
fromWindow(event)?.minimize();
@@ -345,7 +370,7 @@ ipcMain.handle("browser:control", (event, targetId, action) => {
if (fromWindow(event)) browserViews?.control(String(targetId), String(action));
});
ipcMain.handle("browser:navigate", (event, targetId, address) =>
fromWindow(event) ? (browserViews?.navigate(String(targetId), String(address)) ?? false) : false,
fromWindow(event) ? (browserViews?.navigate(String(targetId), String(address)) ?? "scheme") : "scheme",
);
ipcMain.handle("browser:trust-certificate", (event, targetId) =>
fromWindow(event) ? (browserViews?.trustCertificate(String(targetId)) ?? false) : false,
@@ -498,6 +523,7 @@ app.on("before-quit", () => {
quitting = true;
logs.shell.line("shell: quitting");
browserRelay?.stop();
powerGuard?.close();
tray?.close();
kernel?.child.stdin.end();
});
+90
View File
@@ -0,0 +1,90 @@
"use strict";
const POLL_MS = 5000;
const KIND = "prevent-app-suspension";
const PREF_KEY = "rx-keep-awake";
// Consecutive idle answers before the block is let go: about fifteen seconds,
// so a turn that ends and another that starts do not flap it.
const IDLE_PASSES = 3;
// Consecutive passes with no usable answer before the block is let go anyway:
// about a minute, so a kernel that never answers again cannot hold it forever.
const SILENT_PASSES = 12;
// The page stores the preference as "off" or "on"; anything else is the default.
function keepsAwake(prefs) {
return prefs?.[PREF_KEY] !== "off";
}
// createPowerGuard holds one system-suspension block while the kernel reports a
// turn in flight. running resolves to { panes, unknown } or rejects/returns
// null when the kernel itself cannot answer. A pane count above zero takes the
// block at once; letting go needs IDLE_PASSES clean zeros in a row, and a pass
// that could not be answered neither takes it nor counts toward letting go.
function createPowerGuard({ blocker, running, enabled, setInterval: every = setInterval, clearInterval: stop = clearInterval }) {
let held = null;
let pass = 0;
let timer = null;
let closed = false;
let idle = 0;
let silent = 0;
const hold = () => {
if (held !== null && !blocker.isStarted(held)) held = null;
if (held === null) held = blocker.start(KIND);
};
const release = () => {
idle = 0;
silent = 0;
if (held === null) return;
if (blocker.isStarted(held)) blocker.stop(held);
held = null;
};
async function refresh() {
if (closed) return;
const mine = ++pass;
if (!enabled()) {
release();
return;
}
let answer = null;
try {
answer = await running();
} catch {
answer = null;
}
if (mine !== pass || closed) return;
const panes = typeof answer?.panes === "number" ? answer.panes : null;
const unknown = Number(answer?.unknown) > 0;
if (panes !== null && panes > 0) {
idle = 0;
silent = 0;
hold();
} else if (panes === 0 && !unknown) {
silent = 0;
if (held === null || ++idle >= IDLE_PASSES) release();
} else if (held !== null && ++silent >= SILENT_PASSES) {
release();
}
}
return {
refresh,
held: () => held !== null,
begin() {
if (timer) return;
timer = every(() => void refresh(), POLL_MS);
timer.unref?.();
void refresh();
},
close() {
closed = true;
pass++;
if (timer) stop(timer);
timer = null;
release();
},
};
}
module.exports = { createPowerGuard, keepsAwake, PREF_KEY, POLL_MS, KIND, IDLE_PASSES, SILENT_PASSES };
+326 -1
View File
@@ -12,6 +12,7 @@ const { uiLanguage } = require("../src/uilang.js");
const { externalTarget } = require("../src/links.js");
const { offerCleanup, ownBundle } = require("../src/legacy.js");
const { stripPackageGrants, readReport, unpaintedWindowCause } = require("../src/packagegrants.js");
const { groundFor, GROUND } = require("../src/ground.js");
const { pick, loadPrefs, savePrefs, registerPrefs } = require("../src/prefs.js");
const TOKEN = "a".repeat(64);
@@ -682,7 +683,8 @@ test("an unpainted window is attributed only to grants the kernel could not remo
});
const { BrowserProtocol, PAGE_SESSION } = require("../src/browserprotocol.js");
const { guestNavigationAllowed, typedAddress } = require("../src/browserguard.js");
const { guestNavigationAllowed, typedAddress, typed } = require("../src/browserguard.js");
const { localPath } = require("../src/localpath.js");
const { sseData } = require("../src/browserrelay.js");
function fakeBrowser() {
@@ -805,6 +807,65 @@ test("an address that names its scheme is loaded as written", () => {
assert.equal(guestNavigationAllowed(typedAddress("file:///C:/x").url, "http://127.0.0.1:1"), false);
});
test("a typed path on disk is the file it names, read by the table the kernel is held to", () => {
const table = JSON.parse(fs.readFileSync(new URL("../../../internal/platform/browser/testdata/local_paths.json", import.meta.url), "utf8"));
for (const { in: raw, out, kind } of table) {
const got = localPath(raw);
assert.deepEqual(got ? { url: got.url, kind: got.kind } : { url: "", kind: "" }, { url: out, kind }, JSON.stringify(raw));
}
});
test("a seeded corpus of hostile addresses reaches the same digest as the kernel's parser, and never accepts a share", () => {
const tokens = [
"file:", "file:/", "/", "//", "\\", ":", "%", "5C", "2F", "09", "0A", ".", "..", "#", "?",
"D", "c", "$", " ", "h", "x", "\t", "\n", "\r", "", "\u0085", "\u0001", "\u007f",
"localhost", "C:", "UNC", "%5c", "%2e", "?\\", " ",
];
let a = 12321;
const next = () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return (t ^ (t >>> 14)) >>> 0;
};
let h = 0x811c9dc5;
let accepted = 0;
for (let i = 0; i < 30000; i++) {
let input = next() % 2 === 0 ? "file:" : "";
for (let n = 1 + (next() % 12); n > 0; n--) input += tokens[next() % tokens.length];
const got = localPath(input);
const out = got ? got.url : "";
const kind = got ? got.kind : "";
for (const byte of Buffer.from(`${input}\0${out}\0${kind}\n`, "utf8")) h = Math.imul(h ^ byte, 0x01000193) >>> 0;
if (kind !== "local") continue;
accepted++;
const u = new URL(out);
assert.ok(u.protocol === "file:" && u.hostname === "" && !u.pathname.startsWith("//"), `${JSON.stringify(input)} accepted as ${out}`);
assert.ok(!/[\u0000-\u001f\u007f]/.test(out), `${JSON.stringify(input)} kept a control character`);
}
assert.ok(accepted > 1000, `only ${accepted} accepted`);
assert.equal(h.toString(16).padStart(8, "0"), "67b363c1");
});
test("a path typed in the address bar loads as a file, not as a host named by its drive letter", () => {
const kernel = "http://127.0.0.1:4455";
const refusal = (raw) => typed(raw, kernel).refusal;
const reporter = typed("D:/DevCode/MyProjects/aglo/aglo.html", kernel);
assert.deepEqual(reporter, { url: "file:///D:/DevCode/MyProjects/aglo/aglo.html", fallback: "", refusal: "" });
for (const ok of ["/Users/me/页面.html", "file:///D:/x.html", "file:/tmp/x.html", "file://localhost/tmp/x.html", "localhost:3000/x"]) {
assert.equal(refusal(ok), "", ok);
}
for (const share of ["\\\\nas\\share\\x.html", "file://nas/share/x.html", "file:////host/x", "file://localhost//host/x", "file://///host/share", "file:///\\\\host\\x", "file:///%5C%5Chost/x", "file:///%2F%2Fhost/x", "\\\\?\\UNC\\host\\x"]) {
assert.deepEqual(typed(share, kernel), { url: "", fallback: "", refusal: "network_file" }, share);
}
assert.equal(refusal("javascript://x%0Aalert(1)"), "scheme");
assert.equal(refusal("http://127.0.0.1:4455/_studio/"), "scheme");
assert.equal(refusal(""), "scheme");
assert.deepEqual(typedAddress("localhost:3000/x"), { url: "http://localhost:3000/x", fallback: "" });
assert.deepEqual(typedAddress("a.b/c"), { url: "https://a.b/c", fallback: "http://a.b/c" });
});
test("the relay reads whole SSE data frames and keeps what is unfinished", () => {
const first = sseData(': connected\n\ndata: {"conn":"1"}\n\ndata: {"co');
assert.deepEqual(first.data, ['{"conn":"1"}']);
@@ -1396,3 +1457,267 @@ test("the tray has a file for every Windows scale, at exactly 16 * scale pixels"
assert.equal(trayAsset(5).pixels, 48);
assert.equal(trayAsset(NaN).pixels, 16);
});
function pe(machine) {
const b = Buffer.alloc(0x100);
b.writeUInt16LE(0x5a4d, 0);
b.writeUInt32LE(0x80, 0x3c);
b.writeUInt32LE(0x00004550, 0x80);
b.writeUInt16LE(machine, 0x84);
return b;
}
test("the PE check reads each image's own machine and refuses a tree of another architecture", () => {
const { peMachine, machines, foreign } = require("../packaging/pe.js");
assert.equal(peMachine(pe(0xaa64)), 0xaa64);
assert.throws(() => peMachine(Buffer.from("#!/bin/sh\n".repeat(10))), /not a PE/);
assert.throws(() => peMachine(pe(0x8664).subarray(0, 0x70)), /no PE signature/);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-pe-"));
try {
assert.throws(() => foreign(dir, "arm64"), /no PE images/);
fs.mkdirSync(path.join(dir, "resources", "bin"), { recursive: true });
fs.writeFileSync(path.join(dir, "app.exe"), pe(0xaa64));
fs.writeFileSync(path.join(dir, "resources", "bin", "host.exe"), pe(0xaa64));
fs.writeFileSync(path.join(dir, "locales.pak"), "not an image");
fs.writeFileSync(path.join(dir, "noext"), pe(0xaa64));
assert.equal(machines(dir).length, 3);
assert.deepEqual(foreign(dir, "arm64"), []);
assert.deepEqual(foreign(dir, "amd64").map((f) => f.file), ["app.exe", "noext", "resources/bin/host.exe"]);
fs.writeFileSync(path.join(dir, "ffmpeg.dll"), pe(0x8664));
assert.deepEqual(foreign(dir, "arm64").map((f) => f.file), ["ffmpeg.dll"]);
assert.throws(() => foreign(dir, "x64"), /unknown architecture/);
fs.writeFileSync(path.join(dir, "broken.dll"), "text, not an image");
const broken = foreign(dir, "arm64").find((f) => f.file === "broken.dll");
assert.match(broken.error, /not a PE/);
fs.rmSync(path.join(dir, "broken.dll"));
fs.writeFileSync(path.join(dir, "ffmpeg.dll"), pe(0xaa64));
fs.writeFileSync(path.join(dir, "resources", "elevate.exe"), pe(0x14c));
assert.deepEqual(foreign(dir, "arm64"), []);
fs.writeFileSync(path.join(dir, "Uninstall Reasonix Studio.exe"), pe(0x14c));
assert.deepEqual(foreign(dir, "arm64"), []);
fs.writeFileSync(path.join(dir, "resources", "elevate.exe"), pe(0xaa64));
assert.deepEqual(foreign(dir, "arm64").map((f) => f.file), ["resources/elevate.exe"]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
const { createPowerGuard, keepsAwake, IDLE_PASSES, SILENT_PASSES } = require("../src/powerguard.js");
function fakeBlocker() {
const live = new Set();
let next = 1;
const calls = { start: 0, stop: 0 };
return {
calls,
live,
start(kind) {
assert.equal(kind, "prevent-app-suspension");
calls.start++;
live.add(next);
return next++;
},
stop(id) {
calls.stop++;
live.delete(id);
},
isStarted: (id) => live.has(id),
};
}
// answer is what the kernel says next: { panes, unknown }, null for a kernel
// that cannot answer, or an Error to throw.
function guardOver(state) {
const blocker = fakeBlocker();
const guard = createPowerGuard({
blocker,
running: async () => {
if (state.answer instanceof Error) throw state.answer;
return state.answer;
},
enabled: () => state.enabled !== false,
});
return { blocker, guard };
}
const busy = (panes) => ({ panes, unknown: 0 });
const idle = { panes: 0, unknown: 0 };
async function times(n, guard) {
for (let i = 0; i < n; i++) await guard.refresh();
}
test("a running turn holds the computer awake at once, once, however many panes run", async () => {
const state = { answer: busy(1) };
const { blocker, guard } = guardOver(state);
await guard.refresh();
assert.equal(blocker.live.size, 1);
await guard.refresh();
state.answer = busy(4);
await guard.refresh();
assert.equal(blocker.calls.start, 1);
});
test("the hold is let go only after the third idle answer in a row", async () => {
const state = { answer: busy(2) };
const { blocker, guard } = guardOver(state);
await guard.refresh();
state.answer = idle;
await times(IDLE_PASSES - 1, guard);
assert.equal(blocker.live.size, 1, "two idle answers are not enough");
await guard.refresh();
assert.equal(blocker.live.size, 0);
assert.equal(blocker.calls.stop, 1);
await guard.refresh();
assert.equal(blocker.calls.stop, 1);
});
test("a turn showing up between idle answers starts the count again", async () => {
const state = { answer: busy(1) };
const { blocker, guard } = guardOver(state);
await guard.refresh();
state.answer = idle;
await times(IDLE_PASSES - 1, guard);
state.answer = busy(1);
await guard.refresh();
state.answer = idle;
await times(IDLE_PASSES - 1, guard);
assert.equal(blocker.live.size, 1);
await guard.refresh();
assert.equal(blocker.live.size, 0);
});
test("an idle kernel never takes the hold", async () => {
const { blocker, guard } = guardOver({ answer: idle });
await times(5, guard);
assert.equal(blocker.calls.start, 0);
});
test("a kernel that cannot answer, or a pane that cannot be asked, changes nothing", async () => {
for (const unsure of [null, new Error("kernel gone"), { panes: 0, unknown: 1 }, {}]) {
const state = { answer: busy(1) };
const { blocker, guard } = guardOver(state);
await guard.refresh();
state.answer = idle;
await times(IDLE_PASSES - 1, guard);
state.answer = unsure;
await times(SILENT_PASSES - 1, guard);
assert.equal(blocker.live.size, 1, `held through ${String(unsure)}`);
assert.equal(blocker.calls.stop, 0, "silence did not count as the idle answer that was missing");
}
});
test("silence does not take the hold either", async () => {
const { blocker, guard } = guardOver({ answer: null });
await times(3, guard);
assert.equal(blocker.calls.start, 0);
});
test("a hold nobody can confirm is let go after SILENT_PASSES, so it cannot leak", async () => {
const state = { answer: busy(1) };
const { blocker, guard } = guardOver(state);
await guard.refresh();
state.answer = null;
await times(SILENT_PASSES, guard);
assert.equal(blocker.live.size, 0);
});
test("turning the setting off lets go at once and keeps it off while turns run", async () => {
const state = { answer: busy(1) };
const { blocker, guard } = guardOver(state);
await guard.refresh();
state.enabled = false;
await guard.refresh();
assert.equal(blocker.live.size, 0);
await guard.refresh();
assert.equal(blocker.calls.start, 1);
state.enabled = true;
await guard.refresh();
assert.equal(blocker.live.size, 1);
});
test("a block the system dropped is taken again while work continues", async () => {
const { blocker, guard } = guardOver({ answer: busy(1) });
await guard.refresh();
blocker.live.clear();
await guard.refresh();
assert.equal(blocker.live.size, 1);
assert.equal(blocker.calls.start, 2);
});
test("an answer that arrives after a newer one does not overturn it", async () => {
const blocker = fakeBlocker();
const pending = [];
const guard = createPowerGuard({
blocker,
running: () => new Promise((resolve) => pending.push(resolve)),
enabled: () => true,
});
const slow = guard.refresh();
const fresh = guard.refresh();
pending[1](busy(2));
await fresh;
pending[0](idle);
await slow;
assert.equal(blocker.live.size, 1);
});
test("a refresh asked for the moment a turn is submitted takes the hold without waiting for the poll", async () => {
const state = { answer: idle };
const { blocker, guard } = guardOver(state);
await guard.refresh();
assert.equal(blocker.live.size, 0);
state.answer = busy(1);
await guard.refresh();
assert.equal(blocker.live.size, 1);
});
test("closing the guard releases the hold and stops the poll", async () => {
const timers = [];
const blocker = fakeBlocker();
const guard = createPowerGuard({
blocker,
running: async () => busy(1),
enabled: () => true,
setInterval: (fn, ms) => {
timers.push({ fn, ms, cleared: false });
return timers.at(-1);
},
clearInterval: (t) => (t.cleared = true),
});
guard.begin();
await guard.refresh();
assert.equal(blocker.live.size, 1);
guard.close();
assert.equal(blocker.live.size, 0);
assert.equal(timers.length, 1);
assert.equal(timers[0].cleared, true);
await guard.refresh();
assert.equal(blocker.live.size, 0, "a closed guard takes nothing back");
});
test("keeping awake is on unless this machine said off", () => {
assert.equal(keepsAwake({}), true);
assert.equal(keepsAwake(undefined), true);
assert.equal(keepsAwake({ "rx-keep-awake": "on" }), true);
assert.equal(keepsAwake({ "rx-keep-awake": "off" }), false);
});
test("the host client asks the kernel how many panes run", async () => {
const server = http.createServer((req, res) => {
res.writeHead(req.url === "/tray/running" ? 200 : 404, { "content-type": "application/json" });
res.end(JSON.stringify({ panes: 3 }));
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const client = new StudioHost(`http://127.0.0.1:${server.address().port}`, "t");
assert.deepEqual(await client.trayRunning(), { panes: 3 });
server.close();
assert.equal(await client.trayRunning(), null);
});
test("the window ground follows the stored theme and, for auto, the system", () => {
assert.equal(groundFor({ "rx-theme": "dark" }, false), GROUND.dark);
assert.equal(groundFor({ "rx-theme": "light" }, true), GROUND.light);
assert.equal(groundFor({ "rx-theme": "auto" }, true), GROUND.dark);
assert.equal(groundFor({}, false), GROUND.light);
assert.equal(groundFor(undefined, true), GROUND.dark);
});
+3 -1
View File
@@ -41,7 +41,9 @@
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(pair ? { code: pair } : { token: token }),
}).then(function () {});
}).then(function (response) {
if (response.ok) window.location.reload();
});
} catch (e) {
return Promise.resolve();
}
+4
View File
@@ -54,13 +54,17 @@ node perf/i18n.mjs # 词表守卫:源码用到的中文 key 与英文词
node perf/selfcheck.mjs # 守卫的守卫:往每个断言脚本塞一条必假断言,看它是否真的会红
node perf/codes.mjs # 码的守卫:内核发的每个拒绝码,前端都要有话说
node perf/tokens.mjs # 变量的守卫:样式里不带兜底的 var(--x),都得真有人赋过值
node perf/tiers.mjs # 墨色分档的守卫:每档的正文/次要/淡/幽灵色不低于首次绘制的那一步,且都过 AA
node perf/contrast.mjs # 对比度的守卫:每一段正文对它真正落在的那块底够不够 AA
node perf/concurrent.mjs # 多会话并发的守卫:9 个会话同时流式,后台窗格不许以流式速率重绘,帧率不许掉(手跑,不进自动检查:帧率比值阈值在 2 核机器上随硬件抖动,CI 上实测 0.68/0.69 < 0.7)
node perf/wheel.mjs # 滚轮轨迹(只报告、不判红):向上滚时 scrollTop 不倒走、单步不超滚轮、scrollHeight 不缩水;WHEEL_STRICT=1 判红
node perf/idle.mjs # 闲置的守卫:看不见的东西不许动,闲着的窗口不许烧 CPU
node perf/shipped.mjs # 产物的守卫:dist 里的东西必须比源码新(构建可以成功而编的是别的树)
node perf/reason.mjs # 内核拒绝的双语落地:同一个码,中英各说各的
node perf/budget.mjs # 预算的守卫:输入框上下堆满东西时,转录还剩多少
node perf/find.mjs # 对话内查找:快捷键穿过输入框、跳到没挂载的那条、同一行里的第 n 处落在眼前
node perf/focus.mjs # 专注的守卫:拿走外围之后,退出时世界是否原样
node perf/tooltip.mjs # 控件提示的守卫:文字不溢出提示框、不在半透明祖先之下、对比度达 AA(深浅×中英×宽窄)
```
`lang.mjs` 从仓库外跑时用 `PERF_SRC` 指向 `src/`(它要读固件源码来决定
+123
View File
@@ -0,0 +1,123 @@
// Many sessions streaming at once (#12232): hidden panes must not be rewritten at
// stream rate, and N=PERF_GUARD must keep FPS_RATIO_MIN of the N=1 frame rate.
// PERF_N=1,3,6,9 lists more rows; the verdict compares only N=1 with PERF_GUARD.
import { chromium } from "playwright";
const URL = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const CPU = Number(process.env.PERF_CPU ?? 4);
const SECS = Number(process.env.PERF_SECS ?? 5);
const TURNS = Number(process.env.PERF_TURNS ?? 40);
const GUARD = Number(process.env.PERF_GUARD ?? 9);
const COUNTS = (process.env.PERF_N ?? `1,${GUARD}`).split(",").map(Number);
const WRITES_MAX = Number(process.env.PERF_WRITES_MAX ?? 4);
const FPS_RATIO_MIN = Number(process.env.PERF_FPS_RATIO ?? 0.7);
const THOUGHT_CHARS = Number(process.env.PERF_THOUGHT ?? 3000);
const THOUGHT = "先看一下这个文件的结构,再决定改哪里;要注意调用方、回放路径与错误状态。".repeat(Math.ceil(THOUGHT_CHARS / 34 / 40));
const browser = await chromium.launch();
const rows = [];
const fails = [];
for (const n of COUNTS) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const cdp = await ctx.newCDPSession(page);
if (CPU > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU });
await cdp.send("Performance.enable");
await page.goto(`${URL}?panes=${n}`, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 15000 });
await page.waitForTimeout(700);
await page.evaluate(async (k) => {
const yieldFrame = () => new Promise((r) => requestAnimationFrame(r));
for (let i = 0; i < k; i++) {
window.__feed({ kind: "turn_started" });
window.__feed({ kind: "reasoning", text: "先看文件再决定改哪里。" });
window.__feed({ kind: "text", text: `第 ${i} 段回答。\n\n- 要点一\n- 要点二\n\n` });
window.__feed({ kind: "message" });
window.__feed({ kind: "turn_done" });
if (i % 10 === 9) await yieldFrame();
}
await yieldFrame();
}, TURNS);
await page.waitForTimeout(500);
await page.evaluate((t) => {
window.__feed({ kind: "turn_started" });
for (let i = 0; i < 40; i++) window.__feed({ kind: "reasoning", text: t });
}, THOUGHT);
await page.waitForTimeout(500);
const read = async () => Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map((m) => [m.name, m.value]));
async function phase(kind) {
const before = await read();
const r = await page.evaluate(
([k, secs]) =>
new Promise((resolve) => {
let frames = 0;
let worst = 0;
const t0 = performance.now();
let last = t0;
const tick = () => {
frames++;
const now = performance.now();
worst = Math.max(worst, now - last);
last = now;
if (now - t0 < secs * 1000) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
let writes = 0;
const mo = new MutationObserver((ms) => {
for (const m of ms) {
const at = m.target instanceof Element ? m.target : m.target.parentElement;
if (at?.closest(".pane[data-off] .out")) writes++;
}
});
mo.observe(document.body, { subtree: true, characterData: true, childList: true });
const feed = setInterval(() => window.__feed({ kind: k, text: "继续往下想一小段文字," }), 33);
setTimeout(() => {
clearInterval(feed);
mo.disconnect();
resolve({ wall: performance.now() - t0, frames, worst, writes });
}, secs * 1000);
}),
[kind, SECS],
);
const after = await read();
const d = (key) => (after[key] ?? 0) - (before[key] ?? 0);
return {
N: n,
阶段: kind === "reasoning" ? "thinking" : "answering",
"主线程%": ((d("TaskDuration") * 1000 * 100) / r.wall).toFixed(0),
"脚本%": ((d("ScriptDuration") * 1000 * 100) / r.wall).toFixed(0),
"样式重算/s": (d("RecalcStyleCount") / (r.wall / 1000)).toFixed(0),
"布局/s": (d("LayoutCount") / (r.wall / 1000)).toFixed(0),
fps: ((r.frames / r.wall) * 1000).toFixed(1),
"最长帧/ms": r.worst.toFixed(0),
"后台写/s/窗格": n > 1 ? (r.writes / (r.wall / 1000) / (n - 1)).toFixed(1) : "-",
节点: after.Nodes,
};
}
const a = await phase("reasoning");
await page.evaluate(() => window.__feed({ kind: "text", text: "开始回答。" }));
const b = await phase("text");
rows.push(a, b);
await ctx.close();
}
await browser.close();
console.log(`降频 ${CPU}x,每个窗格 ${TURNS} 轮历史,每阶段 ${SECS}s\n`);
console.table(rows);
for (const phase of ["thinking", "answering"]) {
const one = rows.find((r) => r.N === 1 && r.阶段 === phase);
const many = rows.find((r) => r.N === GUARD && r.阶段 === phase);
if (!one || !many) {
fails.push(`${phase} 缺 N=1 或 N=${GUARD} 的读数`);
continue;
}
const writes = Number(many["后台写/s/窗格"]);
const ratio = Number(many.fps) / Number(one.fps);
console.log(`${phase}: 后台窗格每秒写 ${writes} 次(≤ ${WRITES_MAX}),N=${GUARD} 帧率 / N=1 帧率 = ${ratio.toFixed(2)}(≥ ${FPS_RATIO_MIN})`);
if (writes > WRITES_MAX) fails.push(`${phase} 后台窗格每秒写 ${writes} 次 > ${WRITES_MAX}`);
if (ratio < FPS_RATIO_MIN) fails.push(`${phase} 帧率比 ${ratio.toFixed(2)} < ${FPS_RATIO_MIN}`);
}
console.log(fails.length ? `\n失败 ${fails.length} 项:\n- ${fails.join("\n- ")}` : "\n全部通过");
process.exit(fails.length ? 1 : 0);
+14 -3
View File
@@ -161,8 +161,9 @@ const JOBS = Number(query.get("jobs") ?? 0);
// ?treems= holds every tree read back until that long after load, so the
// window can be seen before the kernel has named a single folder.
const TREE_AT = performance.now() + Number(query.get("treems") ?? 0);
// ?panes=0 opens the window with no conversation open.
const NO_PANES = query.get("panes") === "0";
// ?panes=0 opens the window with no conversation open; ?panes=N opens N.
const PANES = query.has("panes") ? Number(query.get("panes")) : 1;
const NO_PANES = PANES === 0;
class BenchHub extends MockHub {
readonly feeds = new Map<string, BenchPort>();
@@ -174,7 +175,17 @@ class BenchHub extends MockHub {
}
runtimes(): Promise<RuntimeView[]> {
return NO_PANES ? Promise.resolve([]) : super.runtimes();
if (NO_PANES) return Promise.resolve([]);
if (PANES <= 1) return super.runtimes();
return Promise.resolve(
Array.from({ length: PANES }, (_, i) => ({
id: `r${i + 1}`,
base: i === 0 ? "" : `/rt/r${i + 1}`,
root: "~/projects/DeepSeek-Reasonix",
name: "DeepSeek-Reasonix",
sessionPath: `/sessions/pane-${i + 1}.jsonl`,
})),
);
}
tree(): Promise<TreeWorkspace[]> {
+109
View File
@@ -0,0 +1,109 @@
// Ink contrast per tier and scheme. Each tier's inks must be at least as legible
// as the default palette stepped once, and as the recorded floors below (ratios
// on --page; --ghost shares --faint's), and every ink must clear AA on both grounds.
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=1";
const STEPS = { "--text": [3.0, 10.1], "--muted": [3.5, 7.5], "--faint": [3.5, 7.0], "--ghost": [3.5, 7.0] };
const INKS = Object.keys(STEPS);
const GROUNDS = ["--page", "--surface"];
const TIERS = ["soft", "normal", "strong"];
const FLOOR = {
"light/soft": { "--text": 11.69, "--muted": 7.63, "--faint": 5.76 },
"light/normal": { "--text": 14.07, "--muted": 10.22, "--faint": 7.86 },
"light/strong": { "--text": 17.86, "--muted": 13.55, "--faint": 10.42 },
"dark/soft": { "--text": 11.48, "--muted": 7.48, "--faint": 5.63 },
"dark/normal": { "--text": 13.92, "--muted": 9.79, "--faint": 7.57 },
"dark/strong": { "--text": 18.01, "--muted": 13.16, "--faint": 9.92 },
};
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();
const page = await browser.newPage({ viewport: { width: 1200, height: 800 }, locale: "zh-CN" });
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
const root = document.documentElement;
const probe = document.createElement("div");
document.body.append(probe);
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
const rgb = (css) => {
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = "#000";
ctx.fillStyle = css;
ctx.fillRect(0, 0, 1, 1);
return [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3);
};
const lum = (c) => {
const [r, g, b] = c.map((v) => {
const s = v / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const ratio = (a, b) => {
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
const read = (name) => {
probe.style.color = `var(${name})`;
return getComputedStyle(probe).color;
};
const out = {};
for (const scheme of ["light", "dark"]) {
root.dataset.theme = scheme;
delete root.dataset.contrast;
const base = Object.fromEntries(INKS.map((n) => [n, read(n === "--ghost" ? "--faint" : n)]));
const grounds = Object.fromEntries(GROUNDS.map((g) => [g, rgb(read(g))]));
for (const tier of TIERS) {
const k = TIERS.indexOf(tier);
root.dataset.contrast = tier;
for (const ink of INKS) {
const by = k === 0 ? 0 : STEPS[ink][k - 1];
probe.style.color = by ? `oklch(from ${base[ink]} calc(l ${scheme === "light" ? "-" : "+"} ${(by / 100).toFixed(3)}) c h)` : base[ink];
const ref = rgb(getComputedStyle(probe).color);
const got = rgb(read(ink));
for (const g of GROUNDS) out[`${scheme}/${tier}/${ink}/${g}`] = { ref: ratio(ref, grounds[g]), got: ratio(got, grounds[g]) };
}
}
}
probe.remove();
return out;
}, { STEPS, INKS, GROUNDS, TIERS });
for (const [key, { ref, got }] of Object.entries(measured)) {
const [scheme, tier, ink, ground] = key.split("/");
const floor = ground === "--page" ? (FLOOR[`${scheme}/${tier}`][ink === "--ghost" ? "--faint" : ink] ?? 0) : 0;
check(`${key} not below the stepped palette or its floor`, got >= Math.max(ref, floor) - 0.005, `${got.toFixed(2)} vs ${Math.max(ref, floor).toFixed(2)}`);
check(`${key} clears AA`, got >= 4.5, got.toFixed(2));
}
for (const scheme of ["light", "dark"]) {
await page.emulateMedia({ colorScheme: scheme, contrast: "more" });
const same = await page.evaluate((scheme) => {
const root = document.documentElement;
root.dataset.theme = scheme;
delete root.dataset.contrast;
const p = document.createElement("div");
document.body.append(p);
const c = (n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color);
const r = [c("--faint"), c("--ghost")];
p.remove();
return r;
}, scheme);
check(`${scheme} under prefers-contrast: more keeps ghost on faint`, same[0] === same[1], same.join(" / "));
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nevery tier holds");
+99
View File
@@ -0,0 +1,99 @@
// Composer control tooltips: the box must hold its own text, must not sit under a
// dimmed ancestor, and must stay readable. All three are layout/paint questions jsdom cannot answer.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const BOX = 'textarea[role="combobox"]';
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
// Controls are found by structure, never by label: the label is translated,
// so a lookup by its wording matches one language and skips the other.
const CONTROLS = { "prompt.refine": '[data-action="prompt.refine"]', attach: "button.attach" };
const measure = (page, selector) =>
page.evaluate((selector) => {
const button = document.querySelector(`.compose ${selector}`);
const tip = button?.querySelector(".studio-control-tip");
if (!button || !tip) return null;
let opacity = 1;
for (let el = tip; el; el = el.parentElement) opacity *= parseFloat(getComputedStyle(el).opacity);
const ink = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
const rgba = (css) => {
ink.clearRect(0, 0, 1, 1);
ink.fillStyle = "#000";
ink.fillStyle = css;
ink.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ink.getImageData(0, 0, 1, 1).data;
return [r, g, b, a / 255];
};
const lum = ([r, g, b]) => {
const f = (c) => ((c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
const over = (top, bottom) => {
const a = top[3];
return [0, 1, 2].map((i) => top[i] * a + bottom[i] * (1 - a)).concat(1);
};
const bg = rgba(getComputedStyle(tip).backgroundColor);
const ratios = [...tip.children].map((child) => {
const fg = over(rgba(getComputedStyle(child).color), bg);
const [hi, lo] = [lum(fg), lum(bg)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
});
const box = tip.getBoundingClientRect();
const spill = [...tip.children].map((child) => {
const range = document.createRange();
range.selectNodeContents(child);
return Math.max(0, range.getBoundingClientRect().right - box.right);
});
return {
opacity,
visibility: getComputedStyle(tip).visibility,
minRatio: Math.min(...ratios),
spill: Math.max(...spill),
scroll: tip.scrollWidth - tip.clientWidth,
left: box.left,
right: box.right,
width: box.width,
viewport: innerWidth,
disabled: button.disabled,
};
}, selector);
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const lang of ["zh", "en"]) {
for (const width of [1440, 420]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
await page.goto(`${BASE}?pref=${lang}&turns=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
await page.evaluate(() => document.fonts.ready);
for (const [text, control] of [["", "prompt.refine"], ["1111", "prompt.refine"], ["", "attach"]]) {
await page.fill(BOX, text);
const tag = `${scheme}/${lang}/${width}px/${control}/${text ? "有字" : "空"}`;
if (!(await page.$(`.compose ${CONTROLS[control]}`))) { check(`${tag}:找得到按钮`, false); continue; }
await page.hover(`.compose ${CONTROLS[control]}`, { force: true });
await page.waitForTimeout(250);
const m = await measure(page, CONTROLS[control]);
if (!m) { check(`${tag}:找得到提示`, false); continue; }
check(`${tag}:提示不在半透明的祖先之下`, m.opacity === 1 && m.visibility === "visible", `有效不透明度 ${m.opacity.toFixed(2)}`);
check(`${tag}:文字不溢出提示框`, m.spill <= 0.5 && m.scroll <= 0, `溢出 ${m.spill.toFixed(1)}px,scroll ${m.scroll}px`);
check(`${tag}:提示框留在窗口内`, m.left >= 0 && m.right <= m.viewport, `${Math.round(m.left)}–${Math.round(m.right)} / ${m.viewport}`);
check(`${tag}:正文对比度达 AA`, m.minRatio >= 4.5, `${m.minRatio.toFixed(2)}:1`);
}
await page.close();
}
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} 项不合格`);
process.exit(1);
}
+83
View File
@@ -0,0 +1,83 @@
// 滚轮轨迹的探针:在一份很高的两轮转录里一路向上滚,scrollTop 在滚轮向上时
// 不许往下走、单步不许比滚轮多走、scrollHeight 不许缩水。
//
// 目前只报告、不判红:`.call` 的 `content-visibility: auto` 配上
// `contain-intrinsic-size: auto 216px`,会在块第一次进入视口时把预估高度改成
// 实测高度,scrollHeight 随之缩水(12975 → 11217),内容在手指下跳动。修复
// 落地后把 `WHEEL_STRICT` 的默认值翻成 1,这条探针就成为那次修复的验收。
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const STEP = Number(process.env.WHEEL_STEP ?? 100);
const TURNS = Number(process.env.WHEEL_TURNS ?? 2);
const CALLS = Number(process.env.WHEEL_CALLS ?? 6);
const WAIT = Number(process.env.WHEEL_WAIT ?? 120);
const STRICT = (process.env.WHEEL_STRICT ?? "0") === "1";
const browser = await chromium.launch();
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const findings = [];
const pageErrors = [];
page.on("pageerror", (e) => pageErrors.push("page error: " + e.message));
await page.goto(PAGE + (PAGE.includes("?") ? "&" : "?") + "queue=1", { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 15000 });
await page.waitForTimeout(700);
const code = "```go\n" + Array.from({ length: 40 }, (_, i) => `func f${i}(x int) int { return x*${i} + 1 } // line ${i}`).join("\n") + "\n```\n\n";
const table = "| a | b | c |\n|---|---|---|\n" + Array.from({ length: 12 }, (_, i) => `| ${i} | row ${i} | value ${i * 7} |`).join("\n") + "\n\n";
const prose = Array.from({ length: 14 }, (_, i) => `Paragraph ${i}: ` + "the quick brown fox jumps over the lazy dog, ".repeat(9)).join("\n\n") + "\n\n";
const reply = "# Heading\n\n" + prose + code + table + prose + code + "- one\n- two\n- three\n\n" + prose;
await page.evaluate(async ({ n, reply, calls }) => {
const frame = () => new Promise((r) => requestAnimationFrame(r));
for (let i = 0; i < n; i++) {
window.__feed({ kind: "turn_started", authoredTurn: i + 1, msgIndex: i * 2, text: `Question ${i}` });
for (let k = 0; k < (i === 0 ? calls : 6); k++) {
const id = `t${i}-${k}`;
const args = JSON.stringify({ path: `pkg/f${k}.go` });
window.__feed({ kind: "tool_dispatch", tool: { id, name: "edit_file", args } });
window.__feed({ kind: "tool_result", tool: { id, name: "edit_file", args, output: "ok", durationMs: 210, added: 4, removed: 2 } });
}
window.__feed({ kind: "text", text: reply });
window.__feed({ kind: "message" });
window.__feed({ kind: "turn_done" });
await frame();
}
await frame();
}, { n: TURNS, reply, calls: CALLS });
await page.waitForTimeout(800);
const flow = page.locator('[data-pane="flow"]').first();
const box = await flow.boundingBox();
const cdp = await ctx.newCDPSession(page);
const read = () => flow.evaluate((el) => ({ top: el.scrollTop, h: el.scrollHeight }));
const start = await read();
const rows = [];
let prev = start;
for (let i = 0; i < 400; i++) {
await cdp.send("Input.dispatchMouseEvent", { type: "mouseWheel", x: box.x + box.width / 2, y: box.y + box.height / 2, deltaX: 0, deltaY: -STEP });
await page.waitForTimeout(WAIT);
const p = await read();
rows.push({ i, d: p.top - prev.top, ...p });
prev = p;
if (p.top <= 0) break;
}
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : STRICT ? "FAIL" : "NOTE"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) findings.push(name);
};
const down = rows.filter((r) => r.d > 2);
const jump = rows.filter((r) => r.d < -STEP * 1.5);
const minH = Math.min(start.h, ...rows.map((r) => r.h));
console.log(`start top=${start.top} scrollHeight=${start.h}; ${rows.length} wheel steps; scrollHeight min=${minH} end=${prev.h}`);
check("never moves down while the wheel goes up", down.length === 0, down.slice(0, 6).map((r) => `#${r.i}:${r.d}`).join(" "));
check("no step exceeds the wheel", jump.length === 0, jump.slice(0, 6).map((r) => `#${r.i}:${r.d}`).join(" "));
check("scrollHeight does not shrink", minH >= start.h - 4, `${start.h} -> ${minH}`);
await browser.close();
for (const e of pageErrors) console.log(e);
process.exit(pageErrors.length || (STRICT && findings.length) ? 1 : 0);
+14
View File
@@ -52,9 +52,12 @@ export const ACTIONS: UIAction[] = [
// Completion verification is adaptive now, rather than a user-facing preset.
// The legacy endpoint remains in the port for older clients and sessions,
// but it is intentionally not advertised as an interface action.
{ 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: "chrome.rail", kind: "view", target: "none", proof: "browser" },
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.nav-rail", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
@@ -67,6 +70,7 @@ 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.receipt", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.keep-awake", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold-preset", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold-preview", kind: "view", target: "none", proof: "interaction" },
@@ -195,6 +199,7 @@ export const ACTIONS: UIAction[] = [
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.expand", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.close", kind: "view", target: "none", proof: "interaction" },
{ id: "backup.category", kind: "view", target: "entity", proof: "interaction" },
{ id: "backup.label", kind: "view", target: "none", proof: "interaction" },
@@ -319,6 +324,8 @@ export const ACTIONS: UIAction[] = [
// Asking a machine whether it answers. Nothing on either side changes, and
// asking twice is the point when the first answer was "not yet".
{ id: "remote.probe", kind: "repeatable", target: "entity", proof: "authority-effect" },
{ 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: "workspace.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "workspace.add-cancel", kind: "view", target: "none", proof: "interaction" },
@@ -337,6 +344,9 @@ 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.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" },
{ id: "provider.model-check", kind: "repeatable", target: "entity", proof: "interaction" },
{ id: "provider.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "provider.add-start", kind: "view", target: "none", proof: "interaction" },
@@ -346,6 +356,7 @@ export const ACTIONS: UIAction[] = [
{ id: "provider.rename", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "provider.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "roles.model", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "roles.override.clear", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "permissions.rule-level", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "sandbox.workspace-root", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// Where compaction starts. Declaring it rebuilds the runtime, which is why
@@ -380,6 +391,9 @@ export const ACTIONS: UIAction[] = [
{ id: "versions.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "versions.restart", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "versions.later", kind: "view", target: "none", proof: "interaction" },
{ id: "versions.notes", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.notes-retry", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.notes-close", kind: "view", target: "none", proof: "interaction" },
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "hooks.recipe", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
@@ -0,0 +1,97 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import HTML from "../index.html?raw";
const script = new DOMParser().parseFromString(HTML, "text/html").querySelector("script")!.textContent!;
function boot(fragment: string, path = "/", search = "") {
let resolve!: (response: { ok: boolean }) => void;
let reject!: (error: Error) => void;
const response = new Promise<{ ok: boolean }>((yes, no) => { resolve = yes; reject = no; });
const fetch = vi.fn((_url: string, _options?: unknown) => response);
const reload = vi.fn();
const replaceState = vi.fn();
const context = {
window: null as unknown,
__rxAuth: undefined as Promise<void> | undefined,
fetch,
location: { hash: fragment, pathname: path, search, reload },
history: { replaceState },
localStorage: { getItem: () => null },
document: { documentElement: { dataset: {}, lang: "" } },
navigator: { language: "en" },
performance: { mark: vi.fn() },
URLSearchParams,
};
context.window = context;
new Function(...Object.keys(context), script)(...Object.values(context));
return { context, fetch, reload, replaceState, resolve, reject };
}
describe("credential bootstrap before native asset loading", () => {
it.each([
["token", "#token=launch-secret", "/auth/token", { token: "launch-secret" }],
["pair", "#pair=one-time-code", "/pair", { code: "one-time-code" }],
])("reloads after the %s cookie is established", async (_kind, fragment, route, body) => {
const page = boot(fragment);
expect(page.fetch).toHaveBeenCalledWith(route, {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
});
expect(page.reload).not.toHaveBeenCalled();
page.resolve({ ok: true });
await page.context.__rxAuth;
expect(page.reload).toHaveBeenCalledOnce();
});
it("preserves the session path, query, and unrelated fragment before reloading", async () => {
const page = boot("#tab=extensions&token=secret", "/sessions/session-123", "?layout=wide");
expect(page.replaceState).toHaveBeenCalledWith(null, "", "/sessions/session-123?layout=wide#tab=extensions");
expect(page.replaceState.mock.calls[0][2]).not.toContain("secret");
page.resolve({ ok: true });
await page.context.__rxAuth;
expect(page.reload).toHaveBeenCalledOnce();
});
it("consumes pairing once when both credentials are present", async () => {
const page = boot("#token=secret&pair=once");
expect(page.fetch).toHaveBeenCalledWith("/pair", expect.objectContaining({ body: JSON.stringify({ code: "once" }) }));
expect(page.replaceState).toHaveBeenCalledWith(null, "", "/");
page.resolve({ ok: true });
await page.context.__rxAuth;
expect(page.fetch).toHaveBeenCalledOnce();
expect(page.reload).toHaveBeenCalledOnce();
});
it("keeps application fetches behind the existing authentication promise", async () => {
const page = boot("#token=secret");
const application = page.context.fetch("/runtimes");
expect(page.fetch).toHaveBeenCalledOnce();
page.resolve({ ok: true });
await application;
expect(page.fetch).toHaveBeenLastCalledWith("/runtimes");
expect(page.reload).toHaveBeenCalledOnce();
});
it.each(["", "#tab=extensions"])("does not reload a page without a credential (%s)", async (fragment) => {
const page = boot(fragment);
await page.context.__rxAuth;
expect(page.fetch).not.toHaveBeenCalled();
expect(page.replaceState).not.toHaveBeenCalled();
expect(page.reload).not.toHaveBeenCalled();
});
it.each(["#token=wrong", "#pair=expired"])("does not reload a refused exchange (%s)", async (fragment) => {
const page = boot(fragment);
page.resolve({ ok: false });
await page.context.__rxAuth;
expect(page.reload).not.toHaveBeenCalled();
});
it("does not reload when the exchange cannot reach the server", async () => {
const page = boot("#token=secret");
const failure = new Error("network unavailable");
page.reject(failure);
await expect(page.context.__rxAuth).rejects.toBe(failure);
expect(page.reload).not.toHaveBeenCalled();
});
});
@@ -2,8 +2,10 @@
// shows only that nothing folded, never a guess at the cause.
export const FOLD_WHY: Record<string, string> = {
summary_failed: "生成摘要的请求失败了",
summary_timeout: "生成摘要的请求超时了",
summary_timeout: "生成摘要的请求停滞了:连续 6 分钟没有任何输出",
summary_ceiling: "生成摘要的请求运行满 30 分钟仍未完成,已停止",
summary_truncated: "摘要在输出上限处被截断,没有采用",
summary_not_digest: "摘要模型没有按要求的标题给出摘要,没有采用",
summary_input_too_large: "待折叠的内容缩减后仍超过单次摘要请求的容量",
hook_refused: "扩展拒绝了这次折叠",
persist_failed: "折叠结果没能保存",
+76 -17
View File
@@ -56,11 +56,27 @@ export const EN: Record<string, string> = {
"可读取代码、查找资料、运行工具并修改文件。完整执行过程可在「轨迹」中查看。":
"It can read code, research, run tools, and edit files. The full run stays visible in the Trajectory.",
"等待回包 {secs}s": "Waiting for a response · {secs}s",
"连接在响应头前断了,重试 {attempt}/{max} · {secs}s":
"Connection dropped before the headers · retry {attempt}/{max} · {secs}s",
"回包写到一半断了,重放 {attempt}/{max} · {secs}s":
"The response was cut mid-stream · replay {attempt}/{max} · {secs}s",
"连接已断开,重试 {attempt}/{max} · {secs}s": "Connection dropped · retry {attempt}/{max} · {secs}s",
"连接被中断": "Connection closed",
"服务器没有及时响应": "The server did not answer in time",
"服务器返回 HTTP {status}": "Server returned HTTP {status}",
"回包停住了": "The response stalled",
"服务器在回包中途报错": "The server reported an error mid-response",
"回包写到一半断了": "The response was cut mid-stream",
"请求没有成功": "The request did not succeed",
"连接已断开": "Connection dropped",
"{n}s 后重试 {attempt}/{max}": "next retry ({attempt}/{max}) in {n} s",
"{n}s 后重放 {attempt}/{max}": "next replay ({attempt}/{max}) in {n} s",
"重试 {attempt}/{max} · 等待服务器响应 {secs}s(超过 {limit}s 视为无响应)":
"Retry {attempt}/{max} · waiting for the server to answer {secs} s ({limit} s counts as no answer)",
"重试 {attempt}/{max} · 等待服务器响应 {secs}s": "Retry {attempt}/{max} · waiting for the server to answer {secs} s",
"重放 {attempt}/{max} · 等待服务器响应 {secs}s": "Replay {attempt}/{max} · waiting for the server to answer {secs} s",
"无响应超时": "No-answer timeout",
"默认 {n}": "Default {n}",
"秒": "s",
"{n} 秒无响应超时": "{n} s no-answer timeout",
"等待响应头、以及回包过程中无新内容超过这个时间,就按连接中断处理并重试。本地模型处理长上下文时可能需要调大。":
"If the server sends nothing for this long, while waiting for the response headers or in the middle of a reply, the call is treated as dropped and retried. A local model working through a long context may need it raised.",
"须是 {min} 到 {max} 之间的整数秒;留空使用默认值。": "Enter a whole number of seconds from {min} to {max}; leave it empty for the default.",
"Agent": "Agent",
"思考中…": "Thinking…",
"思考中 {secs} 秒": "Thinking · {secs}s",
@@ -107,6 +123,7 @@ export const EN: Record<string, string> = {
"正在处理…": "Working…",
"已完成": "Done",
"已中断": "Interrupted",
"未执行": "Not run",
"已停止": "Stopped",
"请求未能送达内核(HTTP {status})": "That request never reached the kernel (HTTP {status})",
"移动 {id} 的界面": "Move {id}'s view",
@@ -152,7 +169,7 @@ export const EN: Record<string, string> = {
"界面大小": "Interface size",
"正文字号": "Body text size",
"尚未派出": "None dispatched",
"{n} 并行": "{n} in parallel",
"运行中 {live} / 共 {total}": "{live} running / {total} total",
"已交付": "Handed back",
"待审改动": "Pending changes",
"改动 · 已放行": "Changes · auto-approved",
@@ -185,7 +202,6 @@ export const EN: Record<string, string> = {
"候选要做的任务": "The attempts' task",
"这次批准的效力超出这一次调用本身。": "This approval covers more than this one call.",
"已处理": "Handled",
"本会话不再询问": "Allow for this session",
"切换全部放行…": "Switch to full access…",
"确认切换全部放行": "Confirm full access",
"全部放行会跳过后续工具确认": "Full access skips future tool confirmations",
@@ -449,8 +465,8 @@ export const EN: Record<string, string> = {
"模型": "Models",
"已启用 {on}/{all}": "{on} of {all} enabled",
"搜索或输入完整模型 ID…": "Search or enter an exact model ID…",
"目录只用于发现,不是白名单。未列出的模型会按原始 ID 保存;验证会发送一次最小请求,可能产生少量 Token 费用。":
"The catalog is for discovery, not a whitelist. Unlisted models keep their exact ID; verification sends one minimal request and may use a small number of tokens.",
"目录只用于发现,不是白名单。未列出的模型会按原始 ID 保存;「测试已启用模型」会给每个已勾选的模型各发送一次小请求,可能产生少量 Token 费用。":
"The catalog is for discovery, not a whitelist. Unlisted models keep their exact ID; Test enabled models sends one small request per ticked model and may use a small number of tokens.",
"搜索模型名称;列表中没有的,可直接输入名称":
"Search the list — or type a name that is not on it",
"搜索或添加模型": "Search or add a model",
@@ -548,14 +564,16 @@ export const EN: Record<string, string> = {
"测试连接": "Test it",
"保存失败": "Save failed",
"已保存,尚未生效": "Saved, not applied yet",
"刷新模型目录失败": "Could not refresh the model catalog",
"读取模型列表失败": "Could not read the model list",
"可以直接输入完整模型 ID 添加。": "You can also type the full model ID to add it.",
"重新问一次有哪些模型": "Ask for the model list again",
"刷新模型目录": "Refresh model catalog",
"正在刷新…": "Refreshing…",
"从服务商读取可用模型": "Read available models from provider",
"没找到?从服务商读取可用模型": "Not listed? Read available models from provider",
"正在从服务商读取…": "Reading from provider…",
"发现 {n} 个新模型。": "Found {n} new models. ",
"有 {n} 个已配置模型本次未返回,已为你保留。":
"{n} configured models were not returned this time and have been preserved.",
"重新向该端点获取模型列表,适用于端点新增或下架模型之后": "Ask this endpoint for its model list again — use it after the endpoint adds or removes models",
"用已保存或刚填的密钥向服务商读取模型列表,可直接勾选;服务商新增或下架模型后也一样": "Read the model list from the provider with the saved or newly entered key, then tick what you want — also after the provider adds or removes models",
"探测结果如下。均为推断值,如有不符请直接修改。": "Here is what was probed. All of it is a guess; correct anything wrong.",
"两种接入方式都能返回模型列表,仅凭列表无法区分;两者的聊天入口路径通常不同,选错会导致聊天报错。如需同时使用,请再添加一次并选择另一种。":
"Both access methods answer with a model list, and the lists do not tell them apart; their chat endpoints usually sit on different paths, so the wrong choice makes chat fail. To use both, add the source again and pick the other one.",
@@ -616,10 +634,10 @@ export const EN: Record<string, string> = {
// ── 卡片:批准、还原、收工 ───────────────────────────────────────
"即将执行": "About to act",
"允许这一次。": "Allowed once.",
"本会话不再询问此类操作。": "Will not ask again this session for this kind.",
"已允许:本会话内此类操作不再询问。": "Allowed: this kind will not ask again this session.",
"已拒绝。": "Denied.",
"已保存为规则。": "Recorded as a rule.",
"已写入配置,后续会话也不再询问此类操作。": "Written to the config; later sessions will not ask for this kind either.",
"已允许:已保存为规则。": "Allowed: recorded as a rule.",
"已写入配置,后续会话中此类操作也始终允许,不再询问。": "Written to the config; later sessions will also always allow this kind without asking.",
"正在提交…": "Submitting…",
"已在其他窗口处理。": "Handled in another window.",
"请以最新运行状态为准。": "Refer to the latest run state.",
@@ -719,6 +737,12 @@ export const EN: Record<string, string> = {
"无法连接版本目录": "Cannot reach the version index",
"已固定": "Pinned",
"固定在这里": "Pin here",
"更新内容": "What's new",
"收起更新内容": "Hide what's new",
"{v} 的更新内容": "What's new in {v}",
"正在读取更新内容…": "Loading what's new…",
"更新内容读取失败": "What's new could not be read",
"在 GitHub 查看": "View on GitHub",
// ── 账号 ─────────────────────────────────────────────────────────
"正在检查登录状态…": "Checking sign-in status…",
@@ -800,6 +824,8 @@ export const EN: Record<string, string> = {
"无法建立安全连接。这个网站可能只支持 http,或者要求客户端证书。": "A secure connection could not be made. The site may serve only http, or require a client certificate.",
"连不上系统代理。内置浏览器使用系统的代理设置,而不是 Reasonix 设置里的网络代理。": "The system proxy could not be reached. The built-in browser uses the operating system's proxy settings, not the network proxy in Reasonix settings.",
"找不到这个地址。内网域名需要连上公司网络或 VPN 才能解析。": "This address could not be found. Intranet names resolve only on the company network or VPN.",
"找不到这个文件。请确认路径正确,文件没有被移动或删除。": "This file could not be found. Check the path, and that the file has not been moved or deleted.",
"没有权限读取这个文件。": "You do not have permission to read this file.",
"对方拒绝了连接。请确认端口正确、服务已经启动。": "The connection was refused. Check the port and that the service is running.",
"连接超时或地址不可达。请确认已连上公司网络或 VPN。": "The connection timed out or the address is unreachable. Check that you are on the company network or VPN.",
"登录没有通过,请重试并检查用户名和密码。": "Sign-in failed. Check the username and password and try again.",
@@ -905,6 +931,7 @@ export const EN: Record<string, string> = {
"增量数据下载失败,本次改为下载完整安装包。": "The changed parts could not be downloaded, so this update downloads the full package.",
"增量数据下载太慢,本次改为下载完整安装包。": "The changed parts were downloading too slowly, so this update downloads the full package.",
"增量数据未通过校验,本次改为下载完整安装包。": "The changed parts failed verification, so this update downloads the full package.",
"这次版本跨度较大,增量数据比完整安装包还多,本次改为下载完整安装包。": "This jump spans many versions and the changed parts add up to more than the full package, so this update downloads the full package.",
"无法写入增量更新文件,本次改为下载完整安装包。": "The changed parts could not be written to disk, so this update downloads the full package.",
"增量更新不可用,本次改为下载完整安装包。": "Updating only the changed parts is unavailable, so this update downloads the full package.",
"校验签名…": "Verifying the signature…",
@@ -947,6 +974,8 @@ export const EN: Record<string, string> = {
"关闭查找": "Close find",
"回合结束时给出回执": "End a turn with a receipt",
"列出这一轮改了什么、验了什么、哪些没有验;无话可说时不出现。下一轮起生效": "Lists what this turn changed, what it verified and what went unverified; absent when there is nothing to say. Takes effect from the next turn",
"任务运行时阻止系统休眠": "Keep the computer awake while tasks run",
"有会话正在运行时,电脑不会自动进入睡眠(电池供电时也一样);屏幕仍按系统设置熄灭,全部空闲后恢复。合上笔记本盖子不受此项控制": "While a session is running the computer will not go to sleep on its own, on battery power too; the screen still turns off as the system says, and normal sleep resumes once everything is idle. Closing a laptop lid is not controlled by this",
"改写这条消息": "Rewrite this message",
"改写这条消息并重新发送": "Rewrite this message and send it again",
"✎ 改写": "✎ Edit",
@@ -961,6 +990,21 @@ export const EN: Record<string, string> = {
// ── 通用动作 ─────────────────────────────────────────────────────
"取消": "Cancel",
"保存": "Save",
"已保存": "Saved",
"没有更改": "No changes",
"有未保存的更改": "Unsaved changes",
"这个服务有未保存的更改": "This service has unsaved changes",
"离开后这些更改会丢失。": "They will be lost if you leave.",
"放弃更改并离开": "Discard changes and leave",
"保留编辑": "Keep editing",
"已停止启动新的验证:草稿已改动": "No further checks started: the draft changed",
"测试已启用模型({n})": "Test enabled models ({n})",
"{n} 个验证中": "{n} verifying",
"没有发现新模型": "No new models found",
"{n} 个可用": "{n} available",
"{n} 个不可用": "{n} unavailable",
"{n} 个无法确定": "{n} undetermined",
"{n} 个未验证": "{n} not verified",
"关闭": "Close",
"确定": "OK",
"重试": "Retry",
@@ -1063,6 +1107,7 @@ export const EN: Record<string, string> = {
"查看上下文与压缩": "View context and compaction",
"查看本轮费用": "View turn cost",
"查看钱包余额": "View wallet balance",
"余额不足": "Balance is low",
"隐藏金额": "Hide amounts",
"显示金额": "Show amounts",
"收起运行统计": "Collapse run statistics",
@@ -1298,8 +1343,22 @@ export const EN: Record<string, string> = {
"同一段内容被逐字重复": "The same block is being repeated verbatim",
"任务已按设置暂停:模型卡在重复输出同一段文字": "Paused by your setting: the model is stuck repeating the same text",
"等待你的输入": "Waiting for you",
"已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”": "Recovered {n} unfinished instruction(s). Sending is paused: review them in the queue above the input box, then press Release",
"待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送": "Sending is paused. This message is queued and goes out once you press Release",
"引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:": "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:",
"费用显示币种已设为 {mode}": "Cost display currency set to {mode}",
"上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。": "Context at {percent}% of the compaction threshold — the model was told it has about {remaining} tokens of room left.",
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
"扩展 {ext} 的配套后台程序没有运行,该扩展本次(在 {point})已被跳过;到「工具与集成」里查看并启动它,或停用该扩展": "The companion background program of extension {ext} is not running, so the extension was skipped this time (at {point}). Open Tools and integrations to check and start it, or disable the extension.",
"打开「工具与集成」": "Open Tools and integrations",
"图表": "Chart",
"图表:{title}(可横向滚动)": "Chart: {title} (scrolls sideways)",
"没有可画的数据": "Nothing to draw",
"柱状图": "Bar chart",
"折线图": "Line chart",
"饼图": "Pie chart",
"{kind}:{title},共 {n} 行": "{kind}: {title}, {n} rows",
"查看数据": "View data",
"收起数据": "Hide data",
"图表数据:{title}": "Chart data: {title}",
"另有 {n} 行": "and {n} more rows",
};
@@ -11,6 +11,9 @@ export const EN_FEEDBACK: Record<string, string> = {
"疑问": "Question",
"其他": "Other",
"已收到": "Received",
"维护者正在查看这份反馈,可能会回复你。现在还不能回复;维护者回复后,这里会出现回复框。":
"A maintainer is reading this report and may reply. You cannot reply yet; once a maintainer replies, a reply box appears here.",
"这份反馈正在审核中:维护者正在查看,可能会回复。": "This report is under review: a maintainer is reading it and may reply.",
"已登记": "Recorded",
"处理中": "In progress",
"已修复": "Fixed",
@@ -130,4 +133,36 @@ export const EN_FEEDBACK: Record<string, string> = {
"{n} 项待查看": "{n} to review",
"有 {n} 份反馈收到了新回复。": "{n} of your reports have new replies.",
"回复已发送。": "Reply sent.",
"反馈等级": "Feedback level",
"升级进度": "Progress to the next level",
"新种": "New seed",
"萌芽": "Sprout",
"幼苗": "Seedling",
"小树": "Sapling",
"繁花": "Bloom",
"成荫": "Canopy",
"共林": "Grove",
"L{n} · {name}": "L{n} · {name}",
"L{n}": "L{n}",
"已落地 {n} 条,已是最高等级": "{n} shipped, the highest level",
"已落地 {n} 条,再 {left} 条升到「{name}」": "{n} shipped, {left} more to reach {name}",
"已落地 {n} 条,再 {left} 条升到 L{level}": "{n} shipped, {left} more to reach L{level}",
"每小时最多 {h} 条反馈 · 每天 {d} 条 · 每小时 {r} 条回复": "Up to {h} reports an hour · {d} a day · {r} replies an hour",
"你现在较高的额度保留到 {date}。": "Your current higher limits stay until {date}.",
"较高的额度有效期至 {date},下一条落地的反馈会续上。": "Your higher limits run until {date}; the next report that ships renews them.",
"较高的额度已经失效。等级和已落地的条数都保留,之后再有反馈落地就会续上。": "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.",
"这一小时的反馈次数已用完。": "You have used this hour's feedback allowance.",
"今天的反馈次数已用完。": "You have used today's feedback allowance.",
"这个网络这一小时的提交次数已用完。": "This network has used this hour's submission allowance.",
"这一小时的回复次数已用完。": "You have used this hour's reply allowance.",
"反馈通道今天的接收量已满,不是你发得太多。内容都还在。": "The feedback channel is full for today; you did not send too much. Your text is kept.",
"反馈通道此刻很忙,不是你发得太多。内容都还在。": "The feedback channel is busy right now; you did not send too much. Your text is kept.",
"已达到一项提交上限。": "A submission limit was reached.",
"将在 {time} 重置。": "It resets at {time}.",
"约 {n} 秒后重置。": "It resets in about {n} sec.",
"约 {n} 分钟后重置。": "It resets in about {n} min.",
"约 {n} 小时后重置。": "It resets in about {n} h.",
"这份反馈的回复次数已到上限,不会自动重置。如有新的情况,可以另外提交一条反馈。": "This report has reached its reply limit, and it does not reset. If something new comes up, send another report.",
};
@@ -3,6 +3,11 @@
// code adds one here. i18n.test.ts is what stops one from being forgotten.
export const EN_KERNEL: Record<string, string> = {
"该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用": "That path names another machine on the network and is outside this workspace. File tools refuse it by its spelling and never look it up; paths below a workspace that is itself on a network share are allowed",
"内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问": "The built-in browser does not open a path that names another machine on the network; it is refused by its spelling and never looked up",
"内核繁忙,这次操作可能没有被收到,重试前请先确认": "The kernel is busy; the action may not have been received - check before retrying",
"无法连接内核,这次操作可能没有送达,重试前请先确认": "The kernel cannot be reached; the action may not have been delivered - check before retrying",
"本界面无响应,这次操作可能没有送达内核,重试前请先确认": "This window is unresponsive; the action may not have reached the kernel - check before retrying",
"递归删除目标受保护或超出授权范围;请使用工作区或授权目录内的字面路径": "The recursive delete target is protected or outside the granted roots; use a literal path inside the workspace or a granted directory",
"无法确定递归删除范围;请使用字面命令名、路径和解释器内容": "The recursive delete extent is unknown; use a literal command name, path and interpreter payload",
"请拆分命令;递归删除前只能使用字面路径切换目录,不能依赖变量赋值": "Split the call; only literal directory changes may precede a recursive delete, without relying on variable assignments",
@@ -17,6 +22,7 @@ export const EN_KERNEL: Record<string, string> = {
"尚未取得所需的写入范围,这次等待已结束": "The wait ended before the requested write claim was granted",
"会话 {holder}({session})持有写入范围:{paths}": "Session {holder} ({session}) holds write claim: {paths}",
"所需写入范围:{paths}": "Requested write claim: {paths}",
"参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试": "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",
"反馈通道今日已满,请明天再试": "Feedback is at capacity for today - try again tomorrow",
"这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试": "This report has reached its reply limit, or you replied too often - try again later",
@@ -125,6 +131,7 @@ export const EN_KERNEL: Record<string, string> = {
"请为该机器填写名称": "Give this machine a name",
"请填写要连接的地址": "Say which address to dial",
"这不是有效的端口号": "That is not a port number",
"该主机已停用,启用后才能连接": "This machine is turned off; turn it on to connect",
"该机器仍有 {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.",
@@ -162,6 +169,9 @@ export const EN_KERNEL: Record<string, string> = {
"该协议不支持由端点自行搜索": "This protocol has no wire format for a search the endpoint runs itself",
"这台服务器不允许修改模型来源": "This server does not allow editing model sources",
"打不开这个网页:{error}": "That page could not be opened: {error}",
"没有找到可用的浏览器。请安装 Chrome、Edge 或 Chromium,或在配置里用 [browser] executable 指定路径,新会话才会读到": "No usable browser was found. Install Chrome, Edge or Chromium, or set [browser] executable in the config; a new session reads it.",
"内置浏览器没能启动,稍后再试一次": "The built-in browser could not start. Try again in a moment.",
"内置浏览器的资料目录正被另一个浏览器占用。关掉其他 Studio 窗口或用同一资料目录的浏览器后再试": "Another browser is using the built-in browser's profile directory. Close other Studio windows or browsers on that profile and try again.",
"通知设置没能保存:{error}": "The notification settings could not be saved: {error}",
"这台机器上没找到 VS Code、Cursor 这类编辑器。装一个,或在配置里用 [desktop] editor 指定路径。": "No editor such as VS Code or Cursor was found on this machine. Install one, or name its path with [desktop] editor.",
"编辑器没能启动:{error}": "The editor could not be started: {error}",
@@ -225,6 +235,9 @@ export const EN_KERNEL: Record<string, string> = {
"This service lists {count} models, none of which can hold a conversation — it may only do embeddings or reranking",
"服务商返回错误(HTTP {status}),与填写内容无关,请稍后重试":
"The provider errored (HTTP {status}) — nothing you typed is wrong; try again shortly",
"该地址在限定时间内没有响应。请检查网络或代理,或稍后重试":
"That address did not answer in time. Check the network or proxy, or try again shortly",
"检查失败,没有具体原因": "The check failed and gave no reason",
"无法连接该地址。请检查网络是否通畅,以及地址是否有误":
"Cannot reach that address. Check the network, or whether the address has a typo",
"该地址有响应,但不是 OpenAI 或 Anthropic 类接口。请确认是否误将网页地址复制过来":
@@ -257,6 +270,7 @@ export const EN_KERNEL: Record<string, string> = {
"HTTP 上不提供 shell 命令": "Shell commands are not served over HTTP",
"不存在「{role}」这个角色": "There is no “{role}” role",
"没有已配置的模型匹配「{model}」": "No configured model matches “{model}”",
"「{key}」不在用户配置里,可能已被清除,或来自项目配置": "“{key}” is not in the user config; it may already be cleared, or come from the project config",
"这台服务器未开放 shell 设置": "This server does not open shell settings",
"这台服务器未开放账号登录": "This server does not open account sign-in",
"登录账号后才能使用云备份": "Sign in to use cloud backups",
@@ -278,13 +292,21 @@ export const EN_KERNEL: Record<string, string> = {
"项目列表已满(32 个),请先移除一个项目再添加": "The project list is full (32 projects); remove one before adding another",
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
"不存在该预设": "There is no such preset",
"没有找到这个模型,默认模型没有改动": "That model was not found, so the default model is unchanged",
"这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置": "This pane's default model is decided by the machine its models come from; set it from a pane on this machine",
"本次拖入 {count} 个,最多允许 {limit} 个": "{count} dropped at once — the limit is {limit}",
"这个文件的格式暂不支持({format})。支持的图片格式:{supported}。可以先转换格式再添加。": "This file's format is not supported ({format}). Supported image formats: {supported}. Convert it first, then add it again.",
"这个文件超过 {limit_mb} MB 的上限,请压缩或拆分后再添加。": "This file is over the {limit_mb} MB limit. Compress or split it, then add it again.",
"这个文件是空的(0 字节),没有可添加的内容。": "This file is empty (0 bytes), so there is nothing to add.",
"无法读取这个文件:{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.",
"该行过长,无法补全": "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",
"上下文长度不能是负数;填 0 表示不自动压缩": "The context window cannot be negative; 0 means no automatic compaction",
"Token 上限不能是负数": "The token limit cannot be negative",
"最大输出 Token 不能是负数": "The maximum output tokens cannot be negative",
"无响应超时须在 {min} 到 {max} 秒之间;留空使用默认值": "The no-answer timeout must be between {min} and {max} seconds; leave it empty for the default",
"无法识别「{protocol}」这种思考协议": "Unrecognised reasoning protocol “{protocol}”",
"默认档位「{level}」不在填写的档位里": "The default level “{level}” is not one of the levels listed",
"{model} 的默认档位「{level}」不在为它选的档位里": "The default level “{level}” for {model} is not one of the levels picked for it",
@@ -313,6 +335,10 @@ export const EN_KERNEL: Record<string, string> = {
"这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本":
"This Studio is not an installed build (it runs from source), so it has no version to show or change",
"版本固定未能保存:{detail}": "The version hold was not saved: {detail}",
"这不是一个已发布的版本号": "That is not a published version number",
"这个版本没有发布更新内容": "This version has no published notes",
"暂时取不到更新内容,请检查网络后重试": "What's new cannot be reached right now. Check the network and try again",
"这个版本的更新内容超出了允许的大小,已拒绝读取": "This version's notes are larger than allowed and were not read",
"已有一个版本切换正在进行,请等待其完成后重试":
"A version change is already running — wait for it to finish and try again",
"本次版本切换未能启动:{detail}": "This version change did not start: {detail}",
@@ -89,6 +89,8 @@ export const EN_REMOTE: Record<string, string> = {
"无法在 {host} 上安装 reasonix —— npm、上传、下载均已尝试。请先自行在该机器上安装,再重新连接": "Nothing could install reasonix on {host} — npm, upload and download were all tried. Install one there by hand, then connect again",
"安装到 {host} 上的 reasonix 无法运行。该目录可能挂载了 noexec,也可能传输中断": "The reasonix installed on {host} will not run. That directory may be mounted noexec, or the transfer was cut short",
"{host} 上的 reasonix 已启动,但始终未报告端口。请查看该机器上 ~/.reasonix/remote 下的日志": "reasonix started on {host} but never reported a port. Look at the logs under ~/.reasonix/remote over there",
"{host} 上已有一个正在运行的 reasonix serve,它的模型来源(本机代理或该机器自带的密钥)与本次连接的设置不一致。为避免打断它正在做的事,没有替换它。请把该主机的 provider 改成与它一致,或先在该机器上运行 reasonix remote serve stop 再连接": "A reasonix serve is already running on {host}, and it resolves models differently from this connection (this machine's broker versus the host's own keys). It was left alone so its work is not interrupted. Set this host's provider to match it, or run reasonix remote serve stop on that machine first and connect again",
"{host} 上已有一个正在运行的 reasonix serve 占用着该工作区,但无法接入(令牌文件或地址不可读)。请先在该机器上结束那个进程(进程号记在该机器 .reasonix/remote 目录下的 .pid 文件里),再重新连接": "A reasonix serve is already running for this workspace on {host}, but it cannot be attached to (its token file or address is unreadable). End that process over there (its id is in the .pid file in that machine's .reasonix/remote directory), then connect again",
"跳板机": "Jump host",
"登录密码的环境变量名": "Env var with the password",
"安装方式": "Installing",
+29 -8
View File
@@ -19,6 +19,9 @@ export const EN_SETTINGS: Record<string, string> = {
"默认模型": "Default model",
"当前对话和大多数任务": "This conversation and most tasks",
"随主模型": "Follows the default",
"「{key}」已被配置固定为 {model},这里的选择对它不起作用。": "“{key}” is pinned to {model} by configuration, so the choice here does not apply to it.",
"改回跟随这里": "Follow this choice",
"来自项目配置,需在项目里修改": "Set by the project config; change it there",
"在「模型服务」添加决策来源": "Add a decision source under Model services",
"按用途选择模型": "Models by use",
"默认模型用于当前对话和大多数任务,其他用途默认跟随它;只有要为某件事换一个模型时才改。切换会保留对话并重建运行时,任务执行期间无法修改。":
@@ -35,8 +38,8 @@ export const EN_SETTINGS: Record<string, string> = {
"添加一个模型服务后,在这里查看和修改它。": "Add a model service to see and change it here.",
"选择一个服务查看和修改;地址、密钥与模型列表保存后生效。自定义中转站的协议与模型会在连接后自动探测。":
"Pick a service to see and change it; the address, key and model list take effect when saved. A custom gateway's protocol and models are detected once it connects.",
"这个端点还有 {n} 个模型不在列表里:{names}。点「刷新模型目录」把它们加进来。":
"This endpoint has {n} more models not in the list: {names}. Use Refresh model catalog to add them.",
"这个端点还有 {n} 个模型不在列表里:{names}。点「从服务商读取可用模型」把它们加进来。":
"This endpoint has {n} more models not in the list: {names}. Use Read available models from provider to add them.",
"添加来源": "Add source",
"完成标准": "Completion standard",
"系统根据任务风险自动决定需要的验证程度,不再要求手动选择均衡或交付。": "Reasonix chooses the needed verification level from task risk; there is no longer a Balanced or Delivery choice.",
@@ -264,6 +267,11 @@ export const EN_SETTINGS: Record<string, string> = {
"当前项目的配置文件开启了它,此工作区仍会提供内置浏览器。": "This project's config file turns it on, so this workspace still has the built-in browser.",
"当前项目的配置文件关闭了它,此工作区不会提供内置浏览器。": "This project's config file turns it off, so this workspace has no built-in browser.",
"在侧栏隐藏": "Hide from the rail",
"在侧栏显示": "Show in the rail",
"启用这台主机": "Turn this machine on",
"停用这台主机": "Turn this machine off",
"宣告做完": "Claims it is done",
"还在做": "Still working",
"卡住了,要你介入": "Stuck — needs you",
@@ -412,8 +420,8 @@ export const EN_SETTINGS: Record<string, string> = {
"机器、工作区与会话": "Machines, workspaces and sessions",
"{n} 个项目": "{n} projects",
"允许这一次": "Allow once",
"本会话都允许": "Allow for this session",
"此类操作不再询问": "Stop asking for this kind",
"本会话允许此类操作,不再询问": "Allow this kind for this session, stop asking",
"始终允许此类操作,不再询问": "Always allow this kind, stop asking",
"内核已记入会话授权,不写入磁盘。": "The kernel records it for this session only; nothing is written to disk.",
"agent 已收到拒绝,将改用其他方式或终止。": "The agent takes the refusal and either finds another way or stops.",
"下次同样的操作仍会请求确认。": "The same operation will ask again next time.",
@@ -450,8 +458,12 @@ export const EN_SETTINGS: Record<string, string> = {
"折叠结果没能保存": "The folded result could not be saved",
"压缩未完成": "Compaction did not complete",
"生成摘要的请求失败了": "The request that writes the summary failed",
"生成摘要的请求超时了": "The request that writes the summary timed out",
"生成摘要的请求停滞了:连续 6 分钟没有任何输出":
"The request that writes the summary stalled: no output for 6 minutes",
"生成摘要的请求运行满 30 分钟仍未完成,已停止":
"The request that writes the summary was still running after 30 minutes and was stopped",
"摘要在输出上限处被截断,没有采用": "The summary hit the output limit and was discarded",
"摘要模型没有按要求的标题给出摘要,没有采用": "The summarizer did not return a briefing under the required headings, so it was not used",
"待折叠的内容缩减后仍超过单次摘要请求的容量": "The content to fold still exceeds one summary request after shortening",
"摘要生成期间对话发生了变化,已放弃这次折叠": "The conversation changed while the summary was written, so this fold was dropped",
"摘要没有记下这段内容里的任何一处改动,已放弃这次折叠": "The summary recorded none of this span's changes, so this fold was dropped",
@@ -691,10 +703,11 @@ export const EN_SETTINGS: Record<string, string> = {
"验证连接并读取": "Check connection and read models",
"连接可用 · 找到 {n} 个模型": "Connection works · {n} models found",
"接口地址已补全为 {url}": "Address completed to {url}",
"刷新模型目录时,模型列表只在 {url} 下响应。聊天若能正常使用就不用改;可选:把接口地址改成它。": "The model list only answers at {url}. If chat already works, no change is needed; optionally set the address to it.",
"读取可用模型时,模型列表只在 {url} 下响应。聊天若能正常使用就不用改;可选:把接口地址改成它。": "The model list only answers at {url}. If chat already works, no change is needed; optionally set the address to it.",
"启用的模型": "Enabled models",
"目录只用于发现,不是白名单。直接输入服务商给出的原始模型 ID 即可。": "The catalogue is for discovery, not a whitelist. You can enter any exact model ID supplied by the service.",
"阈值需至少为 1,000,并小于模型上下文窗口。": "The threshold must be at least 1,000 and below the model context window.",
"被容量保护({p}%)限制,实际 {n} tokens": "Capped by the capacity guard ({p}%): runs at {n} tokens",
"达到这个用量时开始整理": "Start maintenance when usage reaches this value",
"常用阈值": "Common thresholds",
"不要依赖接口猜测,请按模型文档填写": "Use the model documentation instead of endpoint guesses",
@@ -754,7 +767,7 @@ export const EN_SETTINGS: Record<string, string> = {
"跳转": "Go",
"当前是从源码启动的开发版,没有可以查看或切换的版本。安装版 Studio 会在这里列出可用的更新。": "This is a development build run from source, so there are no versions to show or change. An installed Studio lists its updates here.",
"以下是 {host} 上的网络设置。本机的代理需要在打开本机会话时设置。": "These are the network settings on {host}. This machine's proxy is set while a local session is open.",
"已记住这项授权,以后同样的操作不再询问": "Approval remembered; the same action will not ask again",
"已记住这项授权,以后同样的操作自动允许,不再询问": "Approval remembered; the same action is allowed without asking again",
"已有的授权规则覆盖了这项操作,无需另存": "An existing rule already covers this action; nothing new was saved",
"授权没能保存,只在本次会话内有效": "The approval could not be saved; it holds for this session only",
"常驻": "Always loaded",
@@ -788,6 +801,10 @@ export const EN_SETTINGS: Record<string, string> = {
"搜索技能、插件、MCP 服务与主题": "Search skills, plugins, MCP servers and themes",
"搜索社区市场": "Search the community market",
"无法读取社区市场": "Could not read the community market",
"显示的是缓存数据": "Showing cached data",
"当前显示的是缓存数据,安装需要连接市场": "Showing cached data; installing needs a connection to the market",
"无法连接社区市场,以下是 {when} 保存的内容,可能已过期。": "The community market could not be reached. This is what was saved on {when} and may be out of date.",
"社区市场暂时无法正常回应,以下是 {when} 保存的内容,可能已过期。": "The community market is not answering properly. This is what was saved on {when} and may be out of date.",
"没有找到匹配的包。": "No matching packages.",
"没有找到已固定内容的包。可关闭筛选查看全部包。": "No pinned packages found. Turn off the filter to see all packages.",
"查看全部包": "See all packages",
@@ -868,7 +885,7 @@ export const EN_SETTINGS: Record<string, string> = {
"主题以插件包发布,包里只能有主题;带技能、钩子或 MCP 服务的包请按插件发布。": "A theme is published as a plugin package that holds only themes; a package with skills, hooks or MCP servers is published as a plugin.",
"小写字母、数字、点、下划线、连字符,最多 64 个字符。": "Lowercase letters, digits, dots, underscores and hyphens, up to 64 characters.",
"要发布这个包的新版本,请保持名称不变;改名会发布为另一个包。": "Keep this name to publish a new version of this package. Changing it publishes a different package.",
"留空时新包为 0.1.0,更新自动加一个补丁号。": "Left empty, a new package starts at 0.1.0 and an update bumps the patch number.",
"留空时新包为 0.1.0;更新只自动递增纯数字三段版本的补丁号,其他版本请明确填写。": "Blank starts a new package at 0.1.0. Updates auto-increment only a numeric three-part version; enter other versions explicitly.",
"来源地址": "Source address",
"摘要": "Summary",
"描述": "Description",
@@ -911,4 +928,8 @@ export const EN_SETTINGS: Record<string, string> = {
"倍窗口": "× window",
"请输入 1 到 {n} 之间的整数。": "Enter a whole number from 1 to {n}.",
"无法读取无进展设置。": "Could not read the progress settings.",
"图标栏": "Icon rail",
"工作区栏旁的一列图标,直达用量、工具、扩展、记忆、远程、账号和设置。窗口窄到手机宽度时不显示。": "A column of icons beside the workspace rail that opens usage, tools, extensions, memory, remote, account and settings. It is not drawn when the window is as narrow as a phone.",
"显示图标栏": "Show the icon rail",
"关闭后这些页面仍可从设置打开": "With it off these pages still open from settings",
};
@@ -36,6 +36,8 @@ export const EN_WINDOW: Record<string, string> = {
"主导航": "Main navigation",
"浏览器": "Browser",
"空白页": "Blank page",
"智能体正在查看的页面": "Page the agent is viewing",
"只读:页面显示在运行智能体的电脑上": "Read-only: the page itself is shown on the computer running the agent",
"后退": "Back",
"前进": "Forward",
"重新加载": "Reload",
@@ -88,6 +90,7 @@ export const EN_WINDOW: Record<string, string> = {
"打开": "Open",
"在外部浏览器打开": "Open in external browser",
"请输入有效的 http 或 https 地址": "Enter a valid http or https address",
"不能直接打开网络共享路径。请先把文件放到本机,再输入它的本机路径。": "A network share cannot be opened directly. Copy the file to this machine and type its local path.",
"Reasonix 内置 Browser": "Reasonix built-in Browser",
"部分网站可能禁止嵌入;此时可在外部浏览器打开。": "Some sites block embedding; open them in your external browser instead.",
"正在打开网页…": "Opening page…",
@@ -104,4 +107,7 @@ export const EN_WINDOW: Record<string, string> = {
"全部关闭({n})": "Close all ({n})",
"从列表移除「{name}」?": "Remove “{name}” from the list?",
"删除「{name}」?": "Delete “{name}”?",
"未读": "Unread",
"1 个会话未读": "1 unread session",
"{n} 个会话未读": "{n} unread sessions",
};
@@ -0,0 +1,15 @@
// The typed payload a skipped-extension notice carries as its detail, read as
// the variables its sentence is worded from. Anything that does not parse reads
// as no payload, so the notice falls back to the kernel's own text.
export const EXTENSION_SKIPPED = "extension_skipped";
export function extensionSkippedVars(detail: string | undefined): { ext: string; point: string } | undefined {
if (!detail) return undefined;
try {
const p = JSON.parse(detail) as { extension?: unknown; point?: unknown };
if (typeof p.extension === "string" && p.extension !== "" && typeof p.point === "string") return { ext: p.extension, point: p.point };
} catch {
return undefined;
}
return undefined;
}
+3
View File
@@ -31,6 +31,9 @@ export const count = (n: number) => nf({ maximumFractionDigits: 0 }).format(n);
export const decimals = (v: number, digits: number) =>
nf({ minimumFractionDigits: digits, maximumFractionDigits: digits }).format(v);
/** plain is a number kept to at most n places, with none padded on. */
export const plain = (v: number, digits: number) => nf({ maximumFractionDigits: digits }).format(v);
/** tokens abbreviates a count of tokens or characters. Under ten thousand it
* keeps one decimal and above it drops to none, so the field's width stays put
* as a turn grows. */
@@ -0,0 +1,15 @@
export const INBOX_RECOVERED = "inbox_recovered";
// The typed payload an inbox-recovered notice carries as its detail, read as the
// variables its sentence is worded from. A detail that is not one reads as none,
// so the notice keeps the kernel's English.
export function inboxRecoveredVars(detail: string | undefined): { n: number } | undefined {
if (!detail) return undefined;
try {
const p = JSON.parse(detail) as { count?: unknown };
if (typeof p.count === "number" && Number.isInteger(p.count) && p.count > 0) return { n: p.count };
} catch {
return undefined;
}
return undefined;
}
+71 -1
View File
@@ -6,7 +6,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import { boot, STORAGE } from "./index";
import { ACCOUNT_SIGNIN_DISABLED, PROVIDER_EDIT_DISABLED, SAVED_NOT_APPLIED, codes, reason } from "./kernel";
import { HttpError } from "../port/port";
import { DeliveryError, HttpError } from "../port/port";
// Pinned, not defaulted: with nothing stored the window follows the machine, so
// on an English runner every assertion about a Chinese sentence would be about
@@ -35,6 +35,31 @@ describe("what a reader is told a refusal was", () => {
expect(english).not.toBe("fixture fallback");
}
});
it("explains a serve left running instead of replaced, in both languages", () => {
for (const code of ["remote.serve_provider_mismatch", "remote.serve_not_attachable"]) {
const error = coded("fixture fallback", code, { host: "box" });
localStorage.setItem(STORAGE, "zh"); boot();
const chinese = reason(error);
expect(chinese).toContain("box");
expect(chinese).not.toBe("fixture fallback");
localStorage.setItem(STORAGE, "en"); boot();
const english = reason(error);
expect(english).toContain("box");
expect(english).not.toBe(chinese);
expect(english).not.toBe("fixture fallback");
}
});
it("explains a refused network path in both languages", () => {
for (const code of ["workspace.network_path_outside_scope", "browser.network_path"]) {
localStorage.setItem(STORAGE, "zh"); boot();
const chinese = reason(coded("fixture fallback", code));
expect(chinese).not.toBe("fixture fallback");
localStorage.setItem(STORAGE, "en"); boot();
const english = reason(coded("fixture fallback", code));
expect(english).not.toBe(chinese);
expect(english).not.toBe("fixture fallback");
}
});
it("says a coded refusal in the window's own language", () => {
expect(reason(coded("inbox item not found", "inbox.not_found"))).toBe("该条已不在待送达队列中");
});
@@ -79,6 +104,14 @@ describe("what a reader is told a refusal was", () => {
expect(said).not.toContain("/skills/enabled");
});
it("tells a busy kernel, an unreachable one and a stalled window apart", () => {
const said = (["kernel_busy", "unreachable", "ui_stalled"] as const).map((f) => reason(new DeliveryError(f)));
expect(new Set(said).size).toBe(3);
expect(said[0]).toContain("内核繁忙");
expect(said[1]).toContain("无法连接内核");
expect(said[2]).toContain("本界面无响应");
});
it("keeps a detailed answer the kernel had no code for", () => {
expect(reason(new HttpError(400, "unknown skill: explore"))).toBe("unknown skill: explore");
});
@@ -121,3 +154,40 @@ describe("codes a caller branches on", () => {
for (const code of SAVED_NOT_APPLIED) expect(codes[code]).toBeTruthy();
});
});
describe("attachment refusals", () => {
const ico = () => coded("pasted data is not a supported image", "attachment.unsupported_image", { format: ".ico", type: "image/x-icon", supported: "PNG, JPEG, GIF, WebP" });
it("tells the reader the format, the supported ones and what to do, in Chinese", () => {
expect(reason(ico())).toBe("这个文件的格式暂不支持(.ico)。支持的图片格式:PNG, JPEG, GIF, WebP。可以先转换格式再添加。");
});
it("says the same in English", () => {
localStorage.setItem(STORAGE, "en"); boot();
expect(reason(ico())).toBe("This file's format is not supported (.ico). Supported image formats: PNG, JPEG, GIF, WebP. Convert it first, then add it again.");
});
it("names the limit when a file is too large, and says what an empty or unreadable one is", () => {
expect(reason(coded("x", "attachment.too_large", { limit_mb: 10 }))).toContain("10 MB");
expect(reason(coded("x", "attachment.empty"))).toContain("空");
expect(reason(coded("x", "attachment.unreadable", { detail: "denied" }))).toContain("denied");
expect(reason(coded("x", "attachment.write_failed", { detail: "disk full" }))).toContain("disk full");
});
it("speaks every attachment code in both languages", () => {
for (const code of ["attachment.unsupported_image", "attachment.too_large", "attachment.empty", "attachment.unreadable", "attachment.write_failed"]) {
localStorage.setItem(STORAGE, "zh"); boot();
const params = { format: ".ico", supported: "PNG", limit_mb: 25, detail: "d" };
const chinese = reason(coded("fallback", code, params));
localStorage.setItem(STORAGE, "en"); boot();
const english = reason(coded("fallback", code, params));
expect(chinese).not.toBe("fallback");
expect(english).not.toBe("fallback");
expect(english).not.toBe(chinese);
}
});
it("degrades an unknown code to the kernel's own words", () => {
expect(reason(coded("kernel words", "attachment.from_the_future"))).toBe("kernel words");
});
});
+33 -1
View File
@@ -1,4 +1,5 @@
import { HttpError } from "../port/port";
import { DeliveryError, HttpError } from "../port/port";
import type { DeliveryFault } from "../port/port";
import { t } from "./index";
// What the kernel says when it refuses, in the language the reader uses.
@@ -30,6 +31,7 @@ const SAID: Record<string, string> = {
"shell.parser_unavailable": "主机命令解析器不可用;请在主机恢复后重试",
"shell.parser_timeout": "主机命令解析超时或被取消;请重试",
"shell.command_line_too_long": "命令超过主机长度限制;请拆分命令或从文件读取长文本",
"tool.arguments_invalid": "参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试",
"workspace.write_conflict": "另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试",
// ── 忙:不是出错,是「现在不行」 ─────────────────────────────────
"plan.decision_stale": "该决定已不符合当前状态:计划在你回答前已发生变更",
@@ -46,6 +48,7 @@ const SAID: Record<string, string> = {
"workspace.file_failed": "文件操作失败,请重试",
"workspace.path_outside_tree": "该路径不在当前工作区内",
"workspace.write_outside_scope": "写入目标不在工作区可写范围内。这是文件工具的写入范围,不是操作系统沙箱;要允许写入,请在 设置 → 沙箱 → 额外可写目录 中添加目标文件夹",
"workspace.network_path_outside_scope": "该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用",
"workspace.files_failed": "无法读取工作区文件列表",
"workspace.file_unreadable": "该文件不是可编辑文本或超过大小限制",
"provider.model_in_use": "该来源正在使用中,请先切换模型再删除",
@@ -97,6 +100,7 @@ const SAID: Record<string, string> = {
"shell.unavailable_over_http": "HTTP 上不提供 shell 命令",
"roles.unknown": "不存在「{role}」这个角色",
"roles.model_unknown": "没有已配置的模型匹配「{model}」",
"roles.override_not_in_user_config": "「{key}」不在用户配置里,可能已被清除,或来自项目配置",
"shell.editing_disabled": "这台服务器未开放 shell 设置",
"account.signin_disabled": "这台服务器未开放账号登录",
"backup.signed_out": "登录账号后才能使用云备份",
@@ -118,13 +122,21 @@ const SAID: Record<string, string> = {
"workspace.limit_reached": "项目列表已满(32 个),请先移除一个项目再添加",
"workspace.changing_disabled": "这台服务器不支持切换工作区",
"settings.unknown_preset": "不存在该预设",
"settings.unknown_model": "没有找到这个模型,默认模型没有改动",
"settings.default_model_brokered": "这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置",
"drop.too_many_paths": "本次拖入 {count} 个,最多允许 {limit} 个",
"attachment.unsupported_image": "这个文件的格式暂不支持({format})。支持的图片格式:{supported}。可以先转换格式再添加。",
"attachment.too_large": "这个文件超过 {limit_mb} MB 的上限,请压缩或拆分后再添加。",
"attachment.empty": "这个文件是空的(0 字节),没有可添加的内容。",
"attachment.unreadable": "无法读取这个文件:{detail}。它可能被其他程序占用、已被移动,或在云盘里尚未下载。",
"attachment.write_failed": "附件未能保存到工作区的 .reasonix/attachments:{detail}。请检查该目录的写入权限和磁盘空间。",
"complete.line_too_long": "该行过长,无法补全",
"stream.unsupported": "该连接不支持流式传输",
"internal.failed": "服务端出现异常,与你的操作无关",
"provider.bad_context_window": "上下文长度不能是负数;填 0 表示不自动压缩",
"provider.bad_token_limit": "Token 上限不能是负数",
"provider.bad_max_output_tokens": "最大输出 Token 不能是负数",
"provider.bad_idle_timeout": "无响应超时须在 {min} 到 {max} 秒之间;留空使用默认值",
"provider.bad_reasoning_protocol": "无法识别「{protocol}」这种思考协议",
"provider.default_effort_not_listed": "默认档位「{level}」不在填写的档位里",
"provider.model_default_effort_not_listed": "{model} 的默认档位「{level}」不在为它选的档位里",
@@ -145,6 +157,10 @@ const SAID: Record<string, string> = {
// ── 来源:连接与授权 ─────────────────────────────────────────────
"provider.editing_disabled": "这台服务器不允许修改模型来源",
"browser.open_failed": "打不开这个网页:{error}",
"browser.network_path": "内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问",
"browser.engine_missing": "没有找到可用的浏览器。请安装 Chrome、Edge 或 Chromium,或在配置里用 [browser] executable 指定路径,新会话才会读到",
"browser.engine_failed": "内置浏览器没能启动,稍后再试一次",
"browser.profile_busy": "内置浏览器的资料目录正被另一个浏览器占用。关掉其他 Studio 窗口或用同一资料目录的浏览器后再试",
"notifications.rejected": "通知设置没能保存:{error}",
"editor.not_installed": "这台机器上没找到 VS Code、Cursor 这类编辑器。装一个,或在配置里用 [desktop] editor 指定路径。",
"editor.launch_failed": "编辑器没能启动:{error}",
@@ -200,7 +216,9 @@ const SAID: Record<string, string> = {
"provider.probe.path_not_found": "地址可以连通,但该路径没有模型清单。多数服务的地址需以 /v1 结尾",
"provider.probe.no_chat_models": "该服务列出了 {count} 个模型,但均不支持对话 —— 它可能仅提供向量或重排能力",
"provider.probe.upstream_error": "服务商返回错误(HTTP {status}),与填写内容无关,请稍后重试",
"provider.probe.timeout": "该地址在限定时间内没有响应。请检查网络或代理,或稍后重试",
"provider.probe.unreachable": "无法连接该地址。请检查网络是否通畅,以及地址是否有误",
"provider.probe.failed": "检查失败,没有具体原因",
"provider.probe.not_compatible": "该地址有响应,但不是 OpenAI 或 Anthropic 类接口。请确认是否误将网页地址复制过来",
// ── 推理强度:这个端点给不了 ─────────────────────────────────────
@@ -255,6 +273,7 @@ const SAID: Record<string, string> = {
"remote.name_required": "请为该机器填写名称",
"remote.host_required": "请填写要连接的地址",
"remote.bad_port": "这不是有效的端口号",
"remote.disabled": "该主机已停用,启用后才能连接",
"remote.has_open_panes": "该机器仍有 {n} 个打开的面板,请先关闭再移除",
// 主机密钥变了没有「仍然连接」这条路:能绕过的警告等于没有警告。
"remote.host_key_changed": "{host} 的主机密钥已变更。可能是该机器重装,也可能存在中间人。记录位于 {file} 第 {line} 行,核实前请勿连接。",
@@ -371,6 +390,8 @@ const SAID: Record<string, string> = {
"remote.no_install_path": "无法在 {host} 上安装 reasonix —— npm、上传、下载均已尝试。请先自行在该机器上安装,再重新连接",
"remote.binary_not_runnable": "安装到 {host} 上的 reasonix 无法运行。该目录可能挂载了 noexec,也可能传输中断",
"remote.serve_did_not_start": "{host} 上的 reasonix 已启动,但始终未报告端口。请查看该机器上 ~/.reasonix/remote 下的日志",
"remote.serve_provider_mismatch": "{host} 上已有一个正在运行的 reasonix serve,它的模型来源(本机代理或该机器自带的密钥)与本次连接的设置不一致。为避免打断它正在做的事,没有替换它。请把该主机的 provider 改成与它一致,或先在该机器上运行 reasonix remote serve stop 再连接",
"remote.serve_not_attachable": "{host} 上已有一个正在运行的 reasonix serve 占用着该工作区,但无法接入(令牌文件或地址不可读)。请先在该机器上结束那个进程(进程号记在该机器 .reasonix/remote 目录下的 .pid 文件里),再重新连接",
"wallet.unauthorized": "该供应商拒绝了当前密钥,无法读取余额",
"wallet.unreachable": "该供应商的余额接口无响应",
"wallet.unreadable": "无法解析该供应商余额接口返回的内容",
@@ -403,6 +424,10 @@ const SAID: Record<string, string> = {
// ── 版本:这个内核背后有没有一个可更新的 Studio ─────────────────
"studio.no_install": "这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本",
"studio.pin_rejected": "版本固定未能保存:{detail}",
"studio.notes_bad_version": "这不是一个已发布的版本号",
"studio.notes_absent": "这个版本没有发布更新内容",
"studio.notes_unreachable": "暂时取不到更新内容,请检查网络后重试",
"studio.notes_too_large": "这个版本的更新内容超出了允许的大小,已拒绝读取",
"update.install_running": "已有一个版本切换正在进行,请等待其完成后重试",
"update.install_rejected": "本次版本切换未能启动:{detail}",
"update.restart_busy": "有 {n} 项任务正在运行,重启会中断它们",
@@ -439,6 +464,7 @@ export function say(reason: Reason | null | undefined, fallback = ""): string {
* window's language, anything else prints as itself. One call so no display
* site has to know which kind it caught. */
export function reason(e: unknown): string {
if (e instanceof DeliveryError) return t(UNCONFIRMED[e.fault]);
if (e instanceof HttpError && e.reason) return say(e.reason, e.message);
// Nothing came back but a status: printing message here would put a path and
// a number in front of the user. The status is the only identity there is.
@@ -446,5 +472,11 @@ export function reason(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
const UNCONFIRMED: Record<DeliveryFault, string> = {
kernel_busy: "内核繁忙,这次操作可能没有被收到,重试前请先确认",
unreachable: "无法连接内核,这次操作可能没有送达,重试前请先确认",
ui_stalled: "本界面无响应,这次操作可能没有送达内核,重试前请先确认",
};
/** codes is what the parity check reads. */
export const codes = SAID;
+5 -2
View File
@@ -9,21 +9,24 @@ export const NOTICE_TEXT: Record<string, string> = {
workspace_lease: "另一个会话持有重叠的写入范围,取得所需范围后会自动继续",
workspace_lease_resumed: "这个会话已取得所需的写入范围,现已继续",
workspace_lease_abandoned: "尚未取得所需的写入范围,这次等待已结束",
permission_saved: "已记住这项授权,以后同样的操作不再询问",
permission_saved: "已记住这项授权,以后同样的操作自动允许,不再询问",
permission_covered: "已有的授权规则覆盖了这项操作,无需另存",
permission_save_failed: "授权没能保存,只在本次会话内有效",
project_programs_awaiting_approval: "这个项目自带的钩子等程序要你批准后才会运行",
project_program_changed: "这个项目的程序在批准后被改动过,本次没有运行,需要重新批准",
hook_unevaluable: "有一个钩子无法被评估(匹配串无效、无法启动或载荷无法序列化),为安全起见已拦截这次操作;到钩子设置里修复或移除该钩子",
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
await_user: "等待你的输入",
default_model_unavailable: "配置里保存的默认模型已不在已配置的供应商中,本次改用第一个可用的模型;在设置里重新选择默认模型即可替换,配置文件未被改动",
approval_mode_unrecognized: "配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换",
memory_migration_backup: "旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因",
inbox_recovered: "已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”",
queue_paused_hold: "待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送",
unapplied_steer: "引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:",
context_budget: "上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。",
display_currency: "费用显示币种已设为 {mode}",
compacted: "已压缩",
compact_declined: "无需压缩:{why}",
compact_failed: "压缩失败:{why}",
extension_skipped: "扩展 {ext} 的配套后台程序没有运行,该扩展本次(在 {point})已被跳过;到「工具与集成」里查看并启动它,或停用该扩展",
perseveration_loop: "模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型",
};
+2
View File
@@ -8,6 +8,8 @@ import "./styles/app.css";
import "./styles/studio.css";
import "./styles/feedback.css";
import "./styles/community.css";
import "./styles/chart.css";
import "./styles/versions.css";
import { App } from "./ui/App";
import { SseHub } from "./port/hub";
import { HttpError } from "./port/http_error";
+51 -1
View File
@@ -15,6 +15,9 @@ export const FEEDBACK_STATUSES: readonly FeedbackStatus[] = [
"received", "needs_info", "answered", "recorded", "in_progress", "fixed", "wontfix", "duplicate", "closed",
];
export const isUnderReview = (item: Pick<FeedbackItem, "status" | "underReview" | "statusUnavailable">): boolean =>
item.status === "received" && item.underReview === true && !item.statusUnavailable;
// Only these take a reply from the reporter; the service has the last word.
export const FEEDBACK_REPLYABLE: readonly FeedbackStatus[] = ["needs_info", "answered", "recorded", "in_progress"];
@@ -76,6 +79,8 @@ export interface FeedbackReceipt {
status: FeedbackStatus;
createdAt: string;
redacted: boolean;
// A maintainer is reading the report before it is listed; status stays received.
underReview: boolean;
}
export type FeedbackAuthor = "maintainer" | "user";
@@ -102,6 +107,8 @@ export interface FeedbackItem {
statusUnavailable?: boolean;
// The maintainers are waiting for the reporter's answer.
needsInput: boolean;
// A received report a maintainer is reading; a service that omits it reads as false.
underReview: boolean;
// Oldest first.
replies: FeedbackReply[];
// Maintainer replies newer than the last one the kernel was told was seen.
@@ -110,16 +117,59 @@ export interface FeedbackItem {
updatedAt: string;
}
// What the service admits from this install right now. It follows live trust,
// so it can sit below what the level alone would allow.
export interface FeedbackEffectiveLimits {
reportsPerHour: number;
reportsPerDay: number;
repliesPerHour: number;
}
// Why the install is admitted at the limits it is. Only active and
// legacy_active raise them; a value the page does not know raises nothing.
export type FeedbackTrustState = "active" | "legacy_active" | "lapsed" | "revoked" | "none";
// The install's earned standing, derived by the service from shipped outcomes
// and never sent by the page. The thresholds ride with it, so the page holds no
// table. The next* fields and remaining are all null at the top level.
export interface FeedbackProfile {
level: number;
adoptedCount: number;
currentThreshold: number;
nextLevel: number | null;
nextThreshold: number | null;
remaining: number | null;
trustState: FeedbackTrustState;
trustExpiresAt: string | null;
observedAt: string;
effectiveLimits: FeedbackEffectiveLimits;
}
// offline: the service was unreachable, so items are the receipts remembered
// on this machine and their statuses may be stale.
// on this machine and their statuses may be stale; profile is then the last one
// the service confirmed.
// unread counts the reports wanting attention; hasNew is unread > 0.
// profile is null when the service stated none the kernel could trust.
export interface FeedbackMine {
items: FeedbackItem[];
offline: boolean;
unread: number;
hasNew: boolean;
profile?: FeedbackProfile | null;
}
// The window a refusal came from, as the service names it. A refusal carries
// these in its params; an identifier not listed here is still a window.
export const FEEDBACK_LIMIT = {
ipHourly: "ip_hourly",
installHourly: "install_hourly",
installDaily: "install_daily",
replyHourly: "reply_hourly",
replyItem: "reply_item",
globalDaily: "global_daily",
globalBurst: "global_burst",
} as const;
export const FEEDBACK_REPO_ISSUES = "https://github.com/esengine/DeepSeek-Reasonix/issues/";
export const FEEDBACK_NEXT_VERSION = "next";
+9 -5
View File
@@ -51,8 +51,8 @@ export interface HostPort {
* a picture of what it showed at that moment, or "" when none was taken. */
freezeBrowserView(): Promise<string>;
controlBrowserView(target: string, action: BrowserControl): void;
/** Load what the person typed. false when the shell refused the address. */
navigateBrowserView(target: string, address: string): Promise<boolean>;
/** Load what the person typed. "" when it loads, else why the shell refused it. */
navigateBrowserView(target: string, address: string): Promise<BrowserRefusal>;
/** Why a page did not load, or null once another load starts. */
onBrowserLoadState(listener: (state: BrowserLoadState) => void): () => void;
/** A page or a proxy asking for a login; answered with answerBrowserLogin. */
@@ -63,6 +63,10 @@ export interface HostPort {
trustBrowserCertificate(target: string): Promise<boolean>;
}
/** Why the shell will not load a typed address: a scheme it never opens, or a
* file on another machine. */
export type BrowserRefusal = "" | "scheme" | "network_file";
/** A kernel refusal as the shell hands it back: the code, and English fallback. */
export interface Refusal {
code?: string;
@@ -135,7 +139,7 @@ interface ElectronBridge {
hideBrowserView?(): Promise<void>;
freezeBrowserView?(): Promise<string>;
controlBrowserView?(target: string, action: string): Promise<void>;
navigateBrowserView?(target: string, address: string): Promise<boolean>;
navigateBrowserView?(target: string, address: string): Promise<BrowserRefusal>;
onBrowserLoadState?(listener: (state: BrowserLoadState) => void): () => void;
onBrowserLogin?(listener: (ask: BrowserLogin) => void): () => void;
answerBrowserLogin?(id: string, username: string, password: string): Promise<void>;
@@ -217,7 +221,7 @@ class ElectronHost implements HostPort {
void this.api.controlBrowserView?.(target, action);
}
navigateBrowserView(target: string, address: string) {
return this.api.navigateBrowserView?.(target, address) ?? Promise.resolve(false);
return this.api.navigateBrowserView?.(target, address) ?? Promise.resolve<BrowserRefusal>("scheme");
}
onBrowserLoadState(listener: (state: BrowserLoadState) => void) {
return this.api.onBrowserLoadState?.(listener) ?? (() => {});
@@ -277,7 +281,7 @@ class BrowserHost implements HostPort {
}
controlBrowserView() {}
navigateBrowserView() {
return Promise.resolve(false);
return Promise.resolve<BrowserRefusal>("scheme");
}
onBrowserLoadState() {
return () => {};
@@ -24,3 +24,15 @@ export class HttpError extends Error {
this.detailed = detailed;
}
}
export type DeliveryFault = "kernel_busy" | "unreachable" | "ui_stalled";
// A call the kernel never answered within its bounded wait. The request may or
// may not have landed, so the identity is the fault: the kernel was slow, could
// not be reached, or this window's own event loop was too starved to say which.
export class DeliveryError extends Error {
constructor(readonly fault: DeliveryFault) {
super("request unconfirmed: " + fault);
this.name = "DeliveryError";
}
}
+2
View File
@@ -41,6 +41,8 @@ export interface TreeSession {
// pane rather than opening a second writer for one file.
runtimeId?: string;
archived?: boolean;
// A turn finished since the person last looked. Absent means seen.
unread?: boolean;
// Conflict-recovery copies of this same conversation. A save that keeps
// conflicting writes one per turn, all under one title.
copies?: TreeSession[];
+11
View File
@@ -51,18 +51,27 @@ export interface MarketVersion {
createdAt: string;
}
// Present when the registry could not answer and this is its last good copy.
// cause says why: the registry was unreachable, or it answered unusably.
export interface MarketCache {
cachedAt: string;
cause: "unreachable" | "bad_response";
}
export interface MarketDetail {
package: MarketPackage;
approved?: MarketVersion;
// False means an install needs the person's trust (market.unpinned without it).
pinned: boolean;
installed?: MarketInstalled;
cache?: MarketCache;
}
export interface MarketList {
packages: MarketPackage[];
limit: number;
offset: number;
cache?: MarketCache;
}
export interface MarketQuery {
@@ -72,6 +81,8 @@ export interface MarketQuery {
offset?: number;
// Filtered by the registry, so paging stays whole.
pinned?: boolean;
// A repeated read: skips the kernel's fresh window and asks the registry.
refresh?: boolean;
}
// version is the approved version the person was shown; the kernel refuses an
+30 -39
View File
@@ -1,12 +1,13 @@
import type { PlanAction } from "./session";
import { HttpError } from "./port";
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { MockFeedback } from "./mock_feedback";
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
import { mockStorage, mockStoragePlan } from "./mock_storage";
import { MEMORIES } from "./mock_memory";
import { mockModels } from "./mock_models";
import { mockUsage } from "./mock_usage";
@@ -81,45 +82,25 @@ export class MockPort extends MockFeedback implements AgentPort {
this.assigned = { ...this.assigned, [role]: ref };
}
// Two protocols onto one host, plus a second vendor carrying the only model
// that reads images: the two shapes the picker has to render correctly.
overrides: Record<string, RoleOverride[]> = {};
async roleOverrides(): Promise<Record<string, RoleOverride[]>> {
return this.overrides;
}
async clearRoleOverride(role: string, key: string) {
this.overrides = { ...this.overrides, [role]: (this.overrides[role] ?? []).filter((o) => o.key !== key) };
}
async models(): Promise<ModelEntry[]> {
const efforts = ["auto", "low", "high", "max"];
return [
{
ref: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro",
kind: "openai", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY", active: true, efforts, effort: "high",
contextWindow: 131072, price: { input: 2, output: 8, currency: "CNY" },
},
{
ref: "deepseek-anthropic/deepseek-v4-pro", provider: "deepseek-anthropic",
model: "deepseek-v4-pro", kind: "anthropic", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY",
efforts, effort: "high", contextWindow: 131072,
},
{
ref: "deepseek/deepseek-flash", provider: "deepseek", model: "deepseek-flash",
kind: "openai", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY", efforts, effort: "high",
contextWindow: 131072, price: { input: 0.5, output: 2, currency: "CNY" },
},
{
ref: "kimi/kimi-k2-vision", provider: "kimi", model: "kimi-k2-vision",
kind: "openai", vendor: "api.moonshot.cn", keyEnv: "KIMI_API_KEY", vision: true, contextWindow: 262144,
},
{
ref: "myrelay/gpt-4o", provider: "myrelay", model: "gpt-4o", kind: "openai",
vendor: "relay.example.com", keyEnv: "MYRELAY_API_KEY", vision: true, contextWindow: 131072,
},
{
ref: "myrelay/claude-sonnet-4", provider: "myrelay", model: "claude-sonnet-4", kind: "openai",
vendor: "relay.example.com", keyEnv: "MYRELAY_API_KEY", contextWindow: 200000,
},
{
ref: "myrelay-work/gpt-4o", provider: "myrelay-work", model: "gpt-4o", kind: "openai",
vendor: "relay.example.com", keyEnv: "MYRELAY_WORK_API_KEY", contextWindow: 131072,
},
];
return mockModels(efforts).map((m) => ({ ...m, default: m.ref === this.defaultRef }));
}
// The one model the catalogue marks as default, so a pick in the settings row
// has something to read back after the list is reloaded.
private defaultRef = "";
private mem: MemoryEntry[] = MEMORIES.map((m) => ({ ...m }));
async usage(query: number | UsageQuery): Promise<UsageReport> {
@@ -200,6 +181,9 @@ export class MockPort extends MockFeedback implements AgentPort {
async markWelcomed(): Promise<void> { this.welcomed = true; }
async pinVersion(): Promise<void> {}
async versionNotes(version: string): Promise<VersionNotes> {
return { version, markdown: "", cached: false };
}
async acknowledgeLaunchHealth(): Promise<void> {} // booted from no update
async goToVersion(): Promise<void> {
throw new Error("演示模式不会真的安装版本");
@@ -348,6 +332,8 @@ export class MockPort extends MockFeedback implements AgentPort {
async deleteSession(_name: string) {}
async markSessionViewed() {}
async status() {
return { ...this.state };
}
@@ -538,6 +524,7 @@ export class MockPort extends MockFeedback implements AgentPort {
// that wait is the only window in which taking it back means anything. A
// fixture that echoed it at once made the state undesignable.
async steer(text: string): Promise<Queued> {
if (this.queuePaused) return this.queueFollowup(text);
const itemId = `inbox-${this.queued.size + 1}-${Date.now()}`;
const at = window.setTimeout(() => {
// Three states, not two. The kernel marks the line consumed when the turn
@@ -613,7 +600,7 @@ export class MockPort extends MockFeedback implements AgentPort {
async queueFollowup(text: string): Promise<Queued> {
const itemId = `inbox-followup-${Date.now()}`;
this.addQueued(itemId, "followup", text);
return { itemId, disposition: "queued_followup" };
return { itemId, disposition: "queued_followup", paused: this.queuePaused };
}
async browserTabs(): Promise<BrowserTab[]> {
@@ -693,7 +680,8 @@ export class MockPort extends MockFeedback implements AgentPort {
this.dropQueued(itemId);
}
async submit(text: string) {
async submit(text: string, chips?: ChipCall): Promise<Queued | void> {
if (chips && this.queuePaused) return this.queueFollowup(text);
if (this.state.running) {
this.emit({ kind: "steer", text });
return;
@@ -775,10 +763,13 @@ export class MockPort extends MockFeedback implements AgentPort {
return mockStoragePlan(root, dir);
}
async setModel(ref: string, _asDefault?: boolean) {
async setModel(ref: string) {
this.state.modelRef = ref;
this.state.label = ref.split("/").pop() ?? ref;
}
async setDefaultModel(ref: string) {
this.defaultRef = ref;
}
async setEffort(effort: string) {
this.state.effort = effort;
}
@@ -1,6 +1,6 @@
import { HttpError } from "./http_error";
import { MockCommit } from "./mock_commit";
import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackItem, type FeedbackMine, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackItem, 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 };
@@ -13,7 +13,7 @@ const DAY = 86_400_000;
const ago = (days: number) => new Date(Date.now() - days * DAY).toISOString();
const item = (over: Partial<FeedbackItem> & Pick<FeedbackItem, "receipt" | "category" | "titleSnippet" | "status" | "createdAt" | "updatedAt">): FeedbackItem => ({
needsInput: false, replies: [], unreadReplies: 0, ...over,
needsInput: false, underReview: false, replies: [], unreadReplies: 0, ...over,
});
const msg = (id: number, author: FeedbackReply["author"], body: string, days: number): FeedbackReply => ({ id, author, body, createdAt: ago(days) });
@@ -28,6 +28,7 @@ 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-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),
replies: [msg(31, "maintainer", "Thanks for the report. Which operating system and which shell are you using?\nAnd can you say what the window size was before you resized it?", 0.2)] }),
@@ -49,14 +50,46 @@ function fault(key = FAULT): string {
}
}
// A tab's own sessionStorage also picks the standing the list answers with, for
// the same reason: "l0".."l6", "legacy", "lapsed", "revoked", or "none" for a
// service that states no profile.
const STANDING = "rx-mock-feedback-standing";
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;
const BASE_LIMITS = LEVEL_LIMITS[0];
const TRUSTED_LIMITS = LEVEL_LIMITS[6];
function standing(kind: string): FeedbackProfile | null {
if (kind === "none") return null;
const level = /^l[0-6]$/.test(kind) ? Number(kind.slice(1)) : kind === "lapsed" ? 3 : kind === "revoked" ? 2 : kind === "legacy" ? 0 : 2;
const adopted = kind === "legacy" ? 0 : THRESHOLDS[level]! + (level === 6 ? 2 : level === 0 ? 0 : 1);
const next = THRESHOLDS[level + 1];
const state = kind === "lapsed" ? "lapsed" : kind === "revoked" ? "revoked" : kind === "legacy" ? "legacy_active" : level === 0 ? "none" : "active";
const [h, d, r] = state === "lapsed" || state === "revoked" || state === "none" ? BASE_LIMITS : state === "legacy_active" ? TRUSTED_LIMITS : LEVEL_LIMITS[level]!;
return {
level, adoptedCount: adopted, currentThreshold: THRESHOLDS[level]!,
nextLevel: next === undefined ? null : level + 1, nextThreshold: next ?? null, remaining: next === undefined ? null : next - adopted,
trustState: state, trustExpiresAt: state === "active" || state === "legacy_active" ? new Date(Date.now() + 20 * DAY).toISOString() : null,
observedAt: new Date().toISOString(), effectiveLimits: { reportsPerHour: h, reportsPerDay: d, repliesPerHour: r },
};
}
const STATUS: Record<string, number> = {
[FEEDBACK_CODE.tooLarge]: 413, [FEEDBACK_CODE.rateLimited]: 429, [FEEDBACK_CODE.disabled]: 503,
[FEEDBACK_CODE.duplicate]: 409, [FEEDBACK_CODE.badToken]: 409, [FEEDBACK_CODE.offline]: 502, [FEEDBACK_CODE.unavailable]: 502, [FEEDBACK_CODE.internal]: 500, [FEEDBACK_CODE.busy]: 503, [FEEDBACK_CODE.imageMetadata]: 400,
[FEEDBACK_CODE.replyLimit]: 429, [FEEDBACK_CODE.notReplyable]: 409, [FEEDBACK_CODE.challengeRequired]: 403,
};
function refusal(code: string): HttpError {
return new HttpError(STATUS[code] ?? 500, code, { code, error: code, params: code === FEEDBACK_CODE.rateLimited ? { retryAfterSeconds: 90 } : {} });
// A fault is a code, optionally followed by "@" and the window it names:
// "feedback.rate_limited@install_daily".
function refusal(fault: string): HttpError {
const [code = "", limit] = fault.split("@");
const soon = new Date(Date.now() + (limit === "install_daily" ? 5 * 3_600_000 : 25 * 60_000)).toISOString();
let params: Record<string, string | number> = code === FEEDBACK_CODE.rateLimited ? { retryAfterSeconds: 90 } : {};
if (limit === "reply_item") params = { limit };
else if (limit) params = { limit, resetsAt: soon, retryAfterSeconds: Math.round((Date.parse(soon) - Date.now()) / 1000) };
return new HttpError(STATUS[code] ?? 500, code, { code, error: code, params });
}
export class MockFeedback extends MockCommit {
@@ -74,12 +107,12 @@ export class MockFeedback extends MockCommit {
if (code === "feedback.invalid") {
throw new HttpError(400, "invalid", { code, error: "invalid", params: { field: "body", reason: "too_long" } });
}
if (code && STATUS[code]) throw refusal(code);
if (code && STATUS[code.split("@")[0]!]) throw refusal(code);
this.name = req.displayName;
const receipt = "FB-" + (1000 + this.filed.length * 7).toString(36).toUpperCase().padStart(4, "K") + "-9QX2";
const now = new Date().toISOString();
this.filed = [item({ receipt, category: req.category, titleSnippet: req.body.trim().slice(0, 80), status: "received", createdAt: now, updatedAt: now }), ...this.filed];
return { receipt, status: "received", createdAt: now, redacted: /sk-[A-Za-z0-9]{8,}/.test(req.body) };
return { receipt, status: "received", createdAt: now, underReview: false, redacted: /sk-[A-Za-z0-9]{8,}/.test(req.body) };
}
async myFeedback(): Promise<FeedbackMine> {
@@ -90,7 +123,7 @@ 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 };
return { items, offline: code === "mine_offline", unread, hasNew: unread > 0, profile: standing(fault(STANDING)) };
}
async replyFeedback(receipt: string, body: string): Promise<FeedbackReplyReceipt> {
+2 -2
View File
@@ -170,9 +170,9 @@ export class MockHub implements HubPort {
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 },
{ path: "/sessions/older.jsonl", name: "older", title: "上一次的会话", turns: 12, runtimeId: open.get("/sessions/older.jsonl") },
]
: [{ path: "/sessions/site.jsonl", name: "site", title: "站点改版", turns: 5 }],
: [{ path: "/sessions/site.jsonl", name: "site", title: "站点改版", turns: 5, runtimeId: open.get("/sessions/site.jsonl") }],
})),
);
}
@@ -1,4 +1,4 @@
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
import type { MarketCache, MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
import { HttpError } from "./http_error";
import { MockLook } from "./mock_look";
@@ -62,20 +62,22 @@ export class MockMarket extends MockLook {
private marketVotes = new Map<string, -1 | 0 | 1>([["acme/review-kit", 1]]);
// Voting reads its own sign-in answer so a test can draw the signed-out entry.
marketSignedIn = true;
// Set to draw the listing and the entry as the last good copy.
marketCache: MarketCache | undefined;
async marketList(q: MarketQuery): Promise<MarketList> {
const needle = (q.q ?? "").trim().toLowerCase();
const rows = PACKAGES.filter((p) => (!q.kind || p.kind === q.kind) && (!q.pinned || p.pinned) &&
(!needle || `${p.name} ${p.summary} ${p.tags.join(" ")}`.toLowerCase().includes(needle)));
return { packages: rows.map((p) => this.view(p)), limit: 24, offset: 0 };
return { packages: rows.map((p) => this.view(p)), limit: 24, offset: 0, ...(this.marketCache ? { cache: this.marketCache } : {}) };
}
async marketDetail(slug: string): Promise<MarketDetail> {
async marketDetail(slug: string, _opts?: { refresh?: boolean }): Promise<MarketDetail> {
const p = PACKAGES.find((x) => x.slug === slug);
if (!p) throw new Error("market.not_found");
const pkg = this.view(p);
return {
package: pkg, pinned: p.pinned, installed: pkg.installed,
package: pkg, pinned: p.pinned, installed: pkg.installed, ...(this.marketCache ? { cache: this.marketCache } : {}),
approved: { version: p.latestVersion, source: p.source, contentHash: p.pinned ? DIGEST : "", riskLevel: "", createdAt: p.updatedAt },
};
}
@@ -0,0 +1,41 @@
import type { ModelEntry } from "./port";
// The catalogue the model rows read: two protocols onto one host, plus a second
// vendor carrying the only model that reads images — the two shapes the picker
// has to render correctly. The effort list stays the caller's, so a test can
// hand in its own.
export function mockModels(efforts: string[]): ModelEntry[] {
return [
{
ref: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro",
kind: "openai", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY", active: true, efforts, effort: "high",
contextWindow: 131072, price: { input: 2, output: 8, currency: "CNY" },
},
{
ref: "deepseek-anthropic/deepseek-v4-pro", provider: "deepseek-anthropic",
model: "deepseek-v4-pro", kind: "anthropic", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY",
efforts, effort: "high", contextWindow: 131072,
},
{
ref: "deepseek/deepseek-flash", provider: "deepseek", model: "deepseek-flash",
kind: "openai", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY", efforts, effort: "high",
contextWindow: 131072, price: { input: 0.5, output: 2, currency: "CNY" },
},
{
ref: "kimi/kimi-k2-vision", provider: "kimi", model: "kimi-k2-vision",
kind: "openai", vendor: "api.moonshot.cn", keyEnv: "KIMI_API_KEY", vision: true, contextWindow: 262144,
},
{
ref: "myrelay/gpt-4o", provider: "myrelay", model: "gpt-4o", kind: "openai",
vendor: "relay.example.com", keyEnv: "MYRELAY_API_KEY", vision: true, contextWindow: 131072,
},
{
ref: "myrelay/claude-sonnet-4", provider: "myrelay", model: "claude-sonnet-4", kind: "openai",
vendor: "relay.example.com", keyEnv: "MYRELAY_API_KEY", contextWindow: 200000,
},
{
ref: "myrelay-work/gpt-4o", provider: "myrelay-work", model: "gpt-4o", kind: "openai",
vendor: "relay.example.com", keyEnv: "MYRELAY_WORK_API_KEY", contextWindow: 131072,
},
];
}
@@ -98,7 +98,7 @@ export class MockProvider extends MockBoundary {
// "改用…" repair exists for. The relay answers at gateway scale, which is the
// case the model list's search and its row cap exist for.
async checkProvider(name: string): Promise<ProviderCheck> {
if (name === "mimo") return { ok: false, error: "401 unauthorized: key 过期了" };
if (name === "mimo") return { ok: false, code: "provider.probe.unauthorized", httpStatus: 401, detail: "Invalid Authentication" };
const models = name.startsWith("myrelay")
? relayCatalog()
: ["deepseek-v4-pro", "deepseek-flash", "deepseek-flash-vision-exp"];
@@ -155,6 +155,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,
headers: edit.headers, extraBody: edit.extraBody,
reasoningProtocol: edit.reasoningProtocol, supportedEfforts: edit.supportedEfforts,
defaultEffort: edit.defaultEffort,
+8
View File
@@ -91,6 +91,14 @@ export interface RoleAssignments {
decision: string;
}
// A per-profile entry that outranks a role's global model. `scope` names the
// file holding it: only a "user" entry can be cleared from the settings page.
export interface RoleOverride {
key: string;
model: string;
scope: "user" | "project";
}
// The two fold bounds and which of them is in force. They are configured
// separately and only the lower one ever fires, so a panel showing the
// settings alone cannot say which: a 1M window against the default soft limit
+29 -10
View File
@@ -1,6 +1,8 @@
import { HttpError } from "./http_error";
import { DeliveryError, HttpError } from "./http_error";
import type { DeliveryFault } from "./http_error";
import type { Attachment, DroppedRef } from "./attachment";
export { HttpError };
export { DeliveryError, HttpError };
export type { DeliveryFault };
export type { Attachment, DroppedRef };
import type { AccountState, AccountUser, DeviceGrant } from "./account";
@@ -15,12 +17,12 @@ import type { UsageQuery, UsageReport } from "./usage";
export { DEFAULT_USAGE_DAYS } from "./usage";
export type { MemoryEdit } from "./memory";
export type { Money, UsageDay, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments } from "./model";
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments, RoleOverride } from "./model";
import type { NetworkProbe, NetworkSettings } from "./network";
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
import type { SkillCatalog, SkillEntry } from "./skill";
import type { UpdateProgress, VersionEntry, VersionHub } from "./version";
import type { UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo } from "./workspace";
// The port is one contract; its subjects each keep their own file, the way the
@@ -31,9 +33,9 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource, JobEntry, McpCatalog, McpDraft,
McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, MemoryCatalog,
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
RewindResult, RewindScope, RoleAssignments, ScopeLayer, SessionEntry, SessionStatus,
RewindResult, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus,
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
VersionHub, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo };
VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo };
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
@@ -85,6 +87,9 @@ export interface ChipCall {
export interface Queued {
itemId: string;
disposition?: string;
// The queue was held when the line arrived: it waits there as a follow-up and
// nothing dispatches it until the queue is resumed.
paused?: boolean;
}
/** GET /inbox. What is actually waiting, kernel-side. The optimistic rows this
@@ -189,7 +194,7 @@ export interface AgentPort {
// The community market: a source like a pasted address, not a trust root.
// Install is the same plan-then-apply pair, pinned to the reviewed digest.
marketList(q: MarketQuery): Promise<MarketList>;
marketDetail(slug: string): Promise<MarketDetail>;
marketDetail(slug: string, opts?: { refresh?: boolean }): Promise<MarketDetail>;
planMarket(req: MarketRequest): Promise<MarketPlan>;
installMarket(req: MarketRequest): Promise<MarketPlan>;
// Publishing spends the account session; both refuse when signed out.
@@ -298,6 +303,11 @@ export interface AgentPort {
// Persisted, then the runtime is rebuilt: boot reads every role model while
// assembling, so an assignment cannot reach a runtime that is already up.
setRole(role: string, ref: string): Promise<void>;
// Entries that win over a role's global model, per role. The global value
// roles() reports is not what runs while one of these exists.
roleOverrides(): Promise<Record<string, RoleOverride[]>>;
// Removes one user-config entry and rebuilds, so the global model takes over.
clearRoleOverride(role: string, key: string): Promise<void>;
storage(query?: StorageQuery): Promise<StorageState>;
planStorageMove(root: string, dir: string): Promise<StoragePlan>;
moveStorage(root: string, dir: string): Promise<StoragePlan>;
@@ -330,6 +340,8 @@ export interface AgentPort {
removeProvider(name: string): Promise<void>;
versions(): Promise<VersionHub>;
pinVersion(version: string): Promise<void>;
// A published release's notes. retry asks past the kernel's short memory of a failed fetch.
versionNotes(version: string, retry?: boolean): Promise<VersionNotes>;
// Installs a published version, forward or back — the same call either way,
// because a rollback that took a second code path would be the less-tested
// one. Resolves only on failure: a success ends with the process handing over
@@ -395,6 +407,9 @@ export interface AgentPort {
sessions(): Promise<SessionEntry[]>;
resume(path: string): Promise<void>;
newSession(): Promise<void>;
// Clears the unread mark on the conversation this pane has open. Unmarked
// sessions are a no-op on the kernel, and an older kernel answers 404.
markSessionViewed(): Promise<void>;
deleteSession(name: string): Promise<void>;
status(): Promise<SessionStatus>;
/** The provider's wallet, or null when this provider has no wallet endpoint —
@@ -466,7 +481,7 @@ export interface AgentPort {
// chips carries skill chips beside the line; without it the line is parsed
// as typed, so a kernel that predates chips still runs a leading one.
submit(text: string, chips?: ChipCall): Promise<void>;
submit(text: string, chips?: ChipCall): Promise<Queued | void>;
// /submit 409s once a turn holds the session. Mid-turn input is durable and
// goes through the inbox, which delivers it at the next tool boundary. The
// receipt is what makes it cancellable while it waits there.
@@ -552,8 +567,12 @@ export interface AgentPort {
// The person's answer to "trust this folder?" for the session's workspace.
decideWorkspaceTrust(trust: "trusted" | "declined"): Promise<void>;
setPreset(preset: Preset): Promise<void>;
// Switches this session only; asDefault also records it as the model new sessions start on.
setModel(ref: string, asDefault?: boolean): Promise<void>;
// Switches this session only.
setModel(ref: string): Promise<void>;
// Records the model new sessions start on in the config of the machine this
// port answers for. A brokered pane's default lives on the machine its models
// come from, so its refusal is typed: settings.default_model_brokered.
setDefaultModel(ref: string): Promise<void>;
setEffort(effort: string): Promise<void>;
// "" turns the session's model mode off.
setModelMode(mode: string): Promise<void>;
+16 -3
View File
@@ -62,6 +62,11 @@ export interface ProviderEntry {
// on top of the protocol.
contextWindow?: number;
maxOutputTokens?: number;
// Seconds the endpoint may stay silent before the call is read as dropped;
// absent is the built-in default.
idleTimeoutSeconds?: number;
// What a source without one waits; the kernel owns the number.
idleTimeoutDefault?: number;
headers?: Record<string, string>;
extraBody?: Record<string, unknown>;
}
@@ -110,8 +115,9 @@ export interface ProviderProbe {
noProxy: boolean;
}
// What re-probing a saved provider found. `error` carries the endpoint's own
// words, because "401" and "no chat models" send the user to different fixes.
// What re-probing a saved provider found. A failure carries `code`, the dotted
// identity the add flow's refusals use, because "401" and "no chat models" send
// the user to different fixes; the endpoint's own words ride along as `detail`.
export interface ProviderCheck {
ok: boolean;
kind?: string;
@@ -126,7 +132,12 @@ export interface ProviderCheck {
vision?: string[];
ambiguous?: boolean;
noProxy?: boolean;
error?: string;
code?: string;
// Only the numbers the code's sentence needs: `status`, `count`.
params?: Record<string, number>;
httpStatus?: number;
// The endpoint's error text, for display; never an input to `code`.
detail?: string;
}
export type ProviderModelCheckStatus = "available" | "unavailable" | "unknown";
@@ -176,6 +187,8 @@ export interface ProviderEdit {
// compaction off for this source.
contextWindow?: number;
maxOutputTokens?: number;
// 0 is the built-in default; omitted leaves the stored value alone.
idleTimeoutSeconds?: number;
headers?: Record<string, string>;
extraBody?: Record<string, unknown>;
// Which request shape controls thinking here. "" is auto — no declaration,
+5
View File
@@ -34,6 +34,10 @@ export interface RemoteHost {
useSSHConfig?: boolean;
passphraseEnv?: string;
passwordEnv?: string;
// Off: the machine stays in the book but is not dialed — no pane opens on it
// and the rail leaves it out. Absent means on, so an old config reads as it
// always did.
disabled?: boolean;
// Set from the CLI, with no control here — counted so an edit does not look
// like it dropped them.
forwards?: number;
@@ -83,6 +87,7 @@ export interface RemoteHostEdit {
useSSHConfig?: boolean;
passphraseEnv?: string;
passwordEnv?: string;
disabled?: boolean;
}
// One folder on another machine. The name is carried beside the path because
+29 -29
View File
@@ -1,6 +1,6 @@
import { PLAN_ACTIONS, type PlanAction } from "./session";
import type { AccountState, AgentPort, Appearance, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import { HttpError, type Attachment, type ChangeDiff, type CommitProposal, type CommitRequest, type CommitResult, type DroppedRef, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port";
import type { AccountState, AgentPort, Appearance, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import { HttpError, type ChangeDiff, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port";
import { SseFeedback } from "./sse_feedback";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
@@ -191,6 +191,14 @@ export class SsePort extends SseFeedback implements AgentPort {
return this.post("/roles", { role, ref });
}
roleOverrides() {
return this.get<Record<string, RoleOverride[]>>("/roles/overrides");
}
clearRoleOverride(role: string, key: string) {
return this.post("/roles/overrides/clear", { role, key });
}
storage(query?: StorageQuery) {
const q = query?.layout ? "?layout=1" : query?.root ? "?root=" + encodeURIComponent(query.root) : "";
return this.get<StorageState>("/storage" + q);
@@ -233,6 +241,13 @@ export class SsePort extends SseFeedback implements AgentPort {
);
}
async versionNotes(version: string, retry = false): Promise<VersionNotes> {
const path = `/studio/versions/${encodeURIComponent(version)}/notes${retry ? "?retry=1" : ""}`;
const res = await fetch(path, { credentials: "same-origin" });
if (!res.ok) await SsePort.fail(path, res);
return (await res.json()) as VersionNotes;
}
async pinVersion(version: string): Promise<void> {
const res = await fetch("/studio/pin", {
method: "POST",
@@ -407,6 +422,8 @@ export class SsePort extends SseFeedback implements AgentPort {
return this.post("/resume", { path });
}
markSessionViewed() { return this.post("/sessions/viewed"); }
newSession() {
return this.post("/new");
}
@@ -415,26 +432,6 @@ export class SsePort extends SseFeedback implements AgentPort {
return this.post("/delete-session", { name });
}
// JSON, not raw bytes: csrfGuard admits nothing else, and that guard is what
// stops a cross-site form posting here at all.
async attach(blob: Blob, name?: string) {
const buf = new Uint8Array(await blob.arrayBuffer());
let bin = "";
for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
const res = await fetch(this.base + "/attachments", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ mime: blob.type, name: name ?? "", data: btoa(bin) }),
});
if (!res.ok) throw new HttpError(res.status, `/attachments: ${res.status} ${await res.text()}`);
return (await res.json()) as Attachment;
}
dropRefs(paths: string[]) {
return this.post0<DroppedRef[]>("/drop", { paths });
}
changes() {
return this.get<WorkspaceChanges>("/changes");
}
@@ -665,7 +662,7 @@ export class SsePort extends SseFeedback implements AgentPort {
}
submit(text: string, chips?: ChipCall) {
return this.post("/submit", { input: text, ...chips });
return this.postMaybe<Queued>("/submit", { input: text, ...chips });
}
steer(text: string) {
return this.post0<Queued>("/inbox/items", { input: text, intent: "steer" });
@@ -718,16 +715,16 @@ export class SsePort extends SseFeedback implements AgentPort {
return this.post(paused ? "/inbox/pause" : "/inbox/resume");
}
cancel() {
return this.post("/cancel");
return this.postAcked("/cancel");
}
// Approve(id, allow, session, persist) — "always" is a session grant, not a
// persisted config change.
planDecision(id: string, action: PlanAction) {
return this.post("/plan-decision", { id, action: PLAN_ACTIONS[action] });
return this.postAcked("/plan-decision", { id, action: PLAN_ACTIONS[action] });
}
approve(id: string, verdict: ApprovalVerdict) {
return this.post("/approve", {
return this.postAcked("/approve", {
id,
allow: verdict !== "deny",
// A rule written down also covers the rest of this session, so the answer
@@ -737,7 +734,7 @@ export class SsePort extends SseFeedback implements AgentPort {
});
}
answer(id: string, answers: { questionId: string; selected: string[] }[]) {
return this.post("/answer", {
return this.postAcked("/answer", {
id,
answers: answers.map((a) => ({ QuestionID: a.questionId, Selected: a.selected })),
});
@@ -776,8 +773,11 @@ export class SsePort extends SseFeedback implements AgentPort {
setPreset(preset: Preset) {
return this.post("/preset", { preset });
}
setModel(ref: string, asDefault = false) {
return this.post("/model", asDefault ? { ref, default: true } : { ref });
setModel(ref: string) {
return this.post("/model", { ref });
}
setDefaultModel(ref: string) {
return this.post("/default-model", { ref });
}
setEffort(effort: string) {
return this.post("/effort", { effort });
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { HttpError } from "./port";
import { SsePort } from "./sse";
afterEach(() => vi.unstubAllGlobals());
const refusal = (status: number, body: unknown) =>
vi.stubGlobal("fetch", async () => ({ ok: false, status, text: async () => JSON.stringify(body) }));
describe("attach", () => {
it("keeps the kernel's refusal code and params on the error it throws", async () => {
refusal(415, { code: "attachment.unsupported_image", error: "pasted data is not a supported image", params: { format: ".ico" } });
const err = await new SsePort("", "r1").attach(new Blob(["x"], { type: "image/x-icon" }), "a.ico").catch((e) => e);
expect(err).toBeInstanceOf(HttpError);
expect(err.reason).toMatchObject({ code: "attachment.unsupported_image", params: { format: ".ico" } });
});
it("falls back to the plain text of an older kernel", async () => {
vi.stubGlobal("fetch", async () => ({ ok: false, status: 400, text: async () => "pasted data is not a supported image" }));
const err = await new SsePort("", "r1").attach(new Blob(["x"]), "a.png").catch((e) => e);
expect(err.message).toBe("pasted data is not a supported image");
});
it("names an unreadable file instead of passing the browser's English through", async () => {
vi.stubGlobal("fetch", async () => {
throw new Error("must not be reached");
});
const blob = { type: "", arrayBuffer: async () => Promise.reject(new DOMException("denied", "NotReadableError")) } as unknown as Blob;
const err = await new SsePort("", "r1").attach(blob, "locked.docx").catch((e) => e);
expect(err).toBeInstanceOf(HttpError);
expect(err.reason?.code).toBe("attachment.unreadable");
});
it("still returns the saved reference on success", async () => {
vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json: async () => ({ path: "p", ref: "@p", image: false }) }));
expect(await new SsePort("", "r1").attach(new Blob(["x"]), "a.txt")).toEqual({ path: "p", ref: "@p", image: false });
});
});
+2 -2
View File
@@ -1,9 +1,9 @@
import { SseTheme } from "./sse_theme";
import { SseIntake } from "./sse_intake";
import type { BackupApplyResult, BackupCatalog, BackupCreated, BackupCreateRequest, BackupPlan } from "./backup";
// Backups kept in the signed-in account. Every call is refused while signed
// out, so a caller shows the section only when the account says signed in.
export class SseBackup extends SseTheme {
export class SseBackup extends SseIntake {
backups() {
return this.get<BackupCatalog>("/backups");
}
+2 -2
View File
@@ -49,8 +49,8 @@ export class SseExtensions extends SseLook {
const query = params.toString();
return this.get<MarketList>("/market/packages" + (query ? "?" + query : ""));
}
marketDetail(slug: string) {
return this.get<MarketDetail>(marketPath(slug));
marketDetail(slug: string, opts?: { refresh?: boolean }) {
return this.get<MarketDetail>(marketPath(slug) + (opts?.refresh ? "?refresh=1" : ""));
}
marketMyVote(slug: string) {
return this.get<MarketVote>(marketPath(slug) + "/vote");
+193 -2
View File
@@ -1,13 +1,135 @@
import { describe, expect, it, vi } from "vitest";
import { HttpError } from "./port";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DeliveryError, HttpError } from "./port";
import { SseHttp } from "./sse_http";
import { SsePort } from "./sse";
class Probe extends SseHttp {
call(path: string) {
return this.post(path, {});
}
read<T>(path: string) {
return this.get<T>(path);
}
decide(path: string) {
return this.postAcked(path, {});
}
}
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
function deferredFetch() {
const pending: ((v: unknown) => void)[] = [];
const fetchMock = vi.fn(
() =>
new Promise((resolve) => {
pending.push((v) => resolve({ ok: true, status: 200, json: async () => v, text: async () => "" }));
}),
);
vi.stubGlobal("fetch", fetchMock);
return { fetchMock, pending };
}
const tick = () => new Promise((r) => setTimeout(r, 0));
describe("idempotent reads", () => {
it("share one request while it is in flight, with one trailing re-read for the rest", async () => {
const { fetchMock, pending } = deferredFetch();
const probe = new Probe();
const first = probe.read("/status");
const second = probe.read("/status");
const third = probe.read("/status");
expect(fetchMock).toHaveBeenCalledTimes(1);
pending[0]({ n: 1 });
expect(await first).toEqual({ n: 1 });
await tick();
expect(fetchMock).toHaveBeenCalledTimes(2);
pending[1]({ n: 2 });
expect(await second).toEqual({ n: 2 });
expect(await third).toEqual({ n: 2 });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("never hands a trigger an answer that began before it", async () => {
const { pending } = deferredFetch();
const probe = new Probe();
const early = probe.read<{ n: number }>("/status");
const late = probe.read<{ n: number }>("/status");
pending[0]({ n: 1 });
await early;
await tick();
pending[1]({ n: 2 });
expect((await late).n).toBe(2);
});
it("keeps different paths apart", async () => {
const { fetchMock } = deferredFetch();
const probe = new Probe();
void probe.read("/status");
void probe.read("/context");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not let a failed read poison the next one", async () => {
let n = 0;
const fetchMock = vi.fn(async () => {
n++;
if (n === 1) throw new TypeError("Failed to fetch");
return { ok: true, status: 200, json: async () => ({ n }), text: async () => "" };
});
vi.stubGlobal("fetch", fetchMock);
const probe = new Probe();
await expect(probe.read("/status")).rejects.toBeInstanceOf(TypeError);
expect(await probe.read("/status")).toEqual({ n: 2 });
});
});
describe("a decision submit", () => {
it("fails with a typed error instead of waiting forever", async () => {
vi.useFakeTimers();
vi.stubGlobal("fetch", (_: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
}),
);
const settled = new Probe().decide("/approve").catch((e) => e);
await vi.advanceTimersByTimeAsync(60_000);
const err = (await settled) as DeliveryError;
expect(err).toBeInstanceOf(DeliveryError);
expect(err.fault).toBe("kernel_busy");
});
it("keeps a refusal it already received when the wait runs out mid-body", async () => {
vi.useFakeTimers();
vi.stubGlobal("fetch", async (_: string, init: RequestInit) => ({
ok: false,
status: 409,
text: () =>
new Promise<string>((_resolve, reject) => {
init.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
}),
}));
const settled = new Probe().decide("/plan-decision").catch((e) => e);
await vi.advanceTimersByTimeAsync(60_000);
const err = await settled;
expect(err).not.toBeInstanceOf(DeliveryError);
expect((err as HttpError).status).toBe(409);
});
it("goes through untouched when the kernel answers", async () => {
vi.stubGlobal("fetch", async () => ({ ok: true, status: 204, text: async () => "" }));
await expect(new Probe().decide("/approve")).resolves.toBeUndefined();
});
it("still carries a refusal's code", async () => {
answer(409, JSON.stringify({ code: "gate.stale", error: "stale" }), "application/json");
const err = (await new Probe().decide("/approve").catch((e) => e)) as HttpError;
expect(err.reason?.code).toBe("gate.stale");
});
});
function answer(status: number, body: string, contentType: string) {
vi.stubGlobal("fetch", async () => ({
ok: false,
@@ -18,6 +140,75 @@ function answer(status: number, body: string, contentType: string) {
}));
}
const silentKernel = () =>
vi.stubGlobal("fetch", (_: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
}),
);
describe("an unconfirmed call is told apart by who held it up", () => {
it("a stop the kernel never acknowledged ends as a typed error, not a wait with no end", async () => {
vi.useFakeTimers();
silentKernel();
const settled = new SsePort("", "r1").cancel().then(() => "acked", (e) => e);
await vi.advanceTimersByTimeAsync(60_000);
const err = await settled;
expect(err).toBeInstanceOf(DeliveryError);
expect((err as DeliveryError).fault).toBe("kernel_busy");
});
it("a stop the kernel acknowledged settles", async () => {
const calls: string[] = [];
vi.stubGlobal("fetch", async (url: string) => {
calls.push(url);
return { ok: true, status: 204, text: async () => "" };
});
await expect(new SsePort("", "r1").cancel()).resolves.toBeUndefined();
expect(calls).toEqual(["/cancel"]);
});
it("blames this window, not the kernel, when the deadline fires far past its time", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
let now = 1_000;
vi.spyOn(performance, "now").mockImplementation(() => now);
silentKernel();
const settled = new Probe().decide("/approve").catch((e) => e);
now += 90_000;
await vi.advanceTimersByTimeAsync(20_000);
expect(((await settled) as DeliveryError).fault).toBe("ui_stalled");
vi.restoreAllMocks();
});
it("blames the kernel when the deadline fires on time", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
let now = 1_000;
vi.spyOn(performance, "now").mockImplementation(() => now);
silentKernel();
const settled = new Probe().decide("/approve").catch((e) => e);
now += 20_050;
await vi.advanceTimersByTimeAsync(20_000);
expect(((await settled) as DeliveryError).fault).toBe("kernel_busy");
vi.restoreAllMocks();
});
it("reports a refused connection as unreachable", async () => {
vi.stubGlobal("fetch", async () => {
throw new TypeError("Failed to fetch");
});
const err = (await new Probe().decide("/approve").catch((e) => e)) as DeliveryError;
expect(err).toBeInstanceOf(DeliveryError);
expect(err.fault).toBe("unreachable");
});
it("keeps a refusal the kernel spelled out for a stop", async () => {
vi.stubGlobal("fetch", async () => ({ ok: false, status: 403, text: async () => '{"code":"x.y","error":"no"}' }));
const err = (await new SsePort("", "r1").cancel().catch((e) => e)) as HttpError;
expect(err).toBeInstanceOf(HttpError);
expect(err.reason?.code).toBe("x.y");
});
});
describe("a failed call", () => {
// http.Error writes plain text, and parsing the body as JSON first threw the
// only account of the failure away: users reported "/providers/edit: 500".
+88 -2
View File
@@ -1,12 +1,27 @@
import { HttpError } from "./port";
import { DeliveryError, HttpError } from "./port";
type Refusal = { code?: string; error?: string; params?: Record<string, string | number> };
// How long a call the user is waiting on gets before control comes back.
const ACK_WAIT_MS = 20_000;
// Timers in a background tab are aligned to one second; a timer later than that
// past its deadline was held up by this window's own event loop.
const STALL_TOLERANCE_MS = 2_000;
// One idempotent read per path in flight. `next` is the single re-read owed to
// every caller that arrived while `flight` was already on the wire.
interface ReadSlot {
flight: Promise<unknown>;
next?: Promise<unknown>;
}
// The transport half of SsePort: where the kernel is, and the five shapes every
// call to it takes. Split out because the port itself is the whole AgentPort
// surface — a hundred endpoint methods — and none of them should have to be
// read past to find how a request is actually made.
export class SseHttp {
private readonly reads = new Map<string, ReadSlot>();
// rt names the pane this port speaks for. The shell's bus carries every
// pane's frames, so a channel per runtime is what keeps two live
// conversations out of each other's transcript.
@@ -56,6 +71,20 @@ export class SseHttp {
return (await res.json()) as T;
}
// A POST that answers with a payload only sometimes: a plain turn start has
// no body, a line routed through the queue carries its receipt.
protected async postMaybe<T>(path: string, body?: unknown): Promise<T | undefined> {
const res = await fetch(this.base + path, {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) await SseHttp.fail(path, res);
const raw = await res.text();
return raw.trim() === "" ? undefined : (JSON.parse(raw) as T);
}
// A partial update of one resource. Distinct from post because the kernel
// reads the verb: the same path answers a different question under each.
protected async patch(path: string, body?: unknown): Promise<void> {
@@ -96,11 +125,68 @@ export class SseHttp {
// same fail(). It used to throw a bare Error carrying a path and a number,
// which spent every refusal the kernel had spelled out — "this server does
// not open account sign-in" reached the panel as "/account: 403".
protected async get<T>(path: string): Promise<T> {
// An idempotent read joins the one in flight for its path and, if it arrived
// late, the single trailing re-read: a slow endpoint polled faster than it
// answers would otherwise fill the browser's per-origin connection pool.
protected get<T>(path: string): Promise<T> {
const slot = this.reads.get(path);
if (!slot) return this.startRead<T>(path);
slot.next ??= slot.flight.then(
() => this.startRead<T>(path),
() => this.startRead<T>(path),
);
return slot.next as Promise<T>;
}
private startRead<T>(path: string): Promise<T> {
const flight = this.fetchRead<T>(path);
const slot: ReadSlot = { flight };
this.reads.set(path, slot);
const release = () => {
if (this.reads.get(path) === slot) this.reads.delete(path);
};
flight.then(release, release);
return flight;
}
private async fetchRead<T>(path: string): Promise<T> {
const res = await fetch(this.base + path, { credentials: "same-origin" });
if (!res.ok) await SseHttp.fail(path, res);
return (await res.json()) as T;
}
// An approval, plan decision, answer or stop: the caller is waiting on this
// one call, so it gets a bounded wait and a typed failure it can recover from.
// A starved event loop runs the deadline timer before the response callback,
// so a deadline that fires far past its time is this window's fault, not the
// kernel's.
protected async postAcked(path: string, body?: unknown): Promise<void> {
const ctl = new AbortController();
const began = performance.now();
let stalled = false;
const timer = setTimeout(() => {
stalled = performance.now() - began - ACK_WAIT_MS > STALL_TOLERANCE_MS;
ctl.abort();
}, ACK_WAIT_MS);
let answered = false;
try {
const res = await fetch(this.base + path, {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: body === undefined ? undefined : JSON.stringify(body),
signal: ctl.signal,
});
answered = true;
if (!res.ok) await SseHttp.fail(path, res);
} catch (e) {
if (!answered && ctl.signal.aborted) throw new DeliveryError(stalled ? "ui_stalled" : "kernel_busy");
if (!answered && e instanceof TypeError) throw new DeliveryError("unreachable");
throw e;
} finally {
clearTimeout(timer);
}
}
}
// A capability scope rides as a query rather than a path segment: every one of
@@ -0,0 +1,33 @@
import { HttpError, type Attachment, type DroppedRef } from "./port";
import { SseHttp } from "./sse_http";
import { SseTheme } from "./sse_theme";
// Where a file enters a turn: bytes the page holds, or paths the shell knows.
export class SseIntake extends SseTheme {
// JSON, not raw bytes: csrfGuard admits nothing else, and that guard is what
// stops a cross-site form posting here at all.
async attach(blob: Blob, name?: string) {
let data: string;
try {
const buf = new Uint8Array(await blob.arrayBuffer());
let bin = "";
for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
data = btoa(bin);
} catch (e) {
const detail = String(e);
throw new HttpError(0, detail, { code: "attachment.unreadable", error: detail, params: { detail } });
}
const res = await fetch(this.base + "/attachments", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ mime: blob.type, name: name ?? "", data }),
});
if (!res.ok) await SseHttp.fail("/attachments", res);
return (await res.json()) as Attachment;
}
dropRefs(paths: string[]) {
return this.post0<DroppedRef[]>("/drop", { paths });
}
}
@@ -3,10 +3,18 @@ export interface VersionEntry {
version: string;
tag: string;
publishedAt: string;
hasNotes: boolean;
current: boolean;
older: boolean;
}
// One release's notes. cached says only where they came from.
export interface VersionNotes {
version: string;
markdown: string;
cached: boolean;
}
// err rides alongside the data: an unreachable catalog must not hide which
// version is running.
export interface VersionHub {
Loaded 100 of 835 files, more files were not shown because too many files have changed in this diff. Show more