Compare commits

...
Author SHA1 Message Date
esengine 95da89e081 chore(studio): bring actions.ts and Settings.tsx back under the file-size ceiling 2026-10-20 00:16:11 -07:00
YHH 2895ba3249 Merge pull request #13983 from esengine/a9/delivery-prose-no-vcs
fix(readiness): Delivery owes no generic check for prose outside any repository
2026-10-20 00:07:02 -07:00
YHH 5573356405 Merge pull request #13989 from SivanCola/split/jobs-launch
fix(jobs): a job started while the manager closes no longer races its wait
2026-10-20 00:03:25 -07:00
YHH a44df06755 Merge pull request #13986 from esengine/a5/mark-read
feat(studio): mark a session read from its menu without opening it
2026-10-19 23:58:26 -07:00
YHH 4d3dbd69f6 Merge pull request #13976 from imcreazy/fix/numeric-error-code-in-responses-and-gemini
fix(provider): read a numeric error code in the responses and gemini models
2026-10-19 23:56:54 -07:00
YHH 533e75933e Merge pull request #13946 from SivanCola/fix/session-scratch-literal-path
fix(control): tell the model its scratch directory is $TMPDIR and needs no cleanup
2026-10-19 23:55:03 -07:00
YHH d87d0d959e Merge pull request #13755 from esengine/a5/schedule-api
feat(schedule): controller port, serve routes and port types for scheduled runs
2026-10-19 23:53:38 -07:00
YHH f0ce91a097 Merge pull request #13985 from SivanCola/fix/cua-socket-readiness
fix(studio): wait for embedded CUA readiness / 修复 CUA 启动就绪竞态
2026-10-19 23:46:53 -07:00
YHH ea43bb3014 Merge pull request #13414 from imcreazy/android-shell
android: run the kernel and page as an Android app
2026-10-19 23:45:28 -07:00
YHH 66c923f4a8 Merge pull request #13720 from SivanCola/fix/electron-launcher-ownership
fix(studio): end a development shell with its launcher and survive a closed console / 开发版 Studio 随启动者退出,控制台关闭后不再卡死
2026-10-19 23:45:17 -07:00
zumac e2163d96e6 fix(jobs): a job started while the manager closes no longer races its wait
StartForSession called m.wg.Add(1) without a lock while Close could already
be inside m.wg.Wait() after cancelling the root: Add racing Wait at a zero
count is the misuse sync.WaitGroup documents. Under
`go test -race -run TestStartRacingClose -count=40 -cpu 1,2,8` on studio it
reports a data race and panics with "WaitGroup is reused before previous Wait
has returned".

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

Split out of #13947 at the reviewer's request so the jobs manager is
reviewed on its own.
2026-10-19 23:32:39 -07:00
esengine a3d2f761ce fix(readiness): an abridged debt keeps the progress signature of its full form; refresh trace readings 2026-10-19 23:32:10 -07:00
imcreazy dbad5a9b3a fix(provider): share one tolerant code reader across the three models
`flexString` existed byte for byte in the openai, gemini and responses packages.
One type written three times diverges at the next edge case, so it moves to
`internal/base/flexstr`, which is a leaf (it imports nothing under reasonix) and
is declared in `tools/repolint/layers.go` so the layering gate allows every model
package to use it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The history this replaces, kept where it belongs:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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


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

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

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

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


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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fix: enforce source authority in the shared contract. Project MCP/extension
parameters allow only supported timeouts. Validate declarations, imports, old
revisions, and both definition/resolved halves of retained executable bindings.
Keep structural history validation separate so legacy unsafe bindings remain
readable without regaining execution. Align Mock save/import, document the source
table in both languages, and add the release note. Preserve Standard/Minimal,
unbound defaults, persona, additional directories and exact dispatch bindings.

Verification: failing-first boot tests prove changed args start a subprocess and
changed URL contacts another endpoint before the fix; both now refuse before
startup. User positive controls, scope/parameter table, store/revision/old-record
and event-log history tests pass. Format/vet/lint/makecheck, required builtin/boot,
full root, contract/store/sessionstore/serve/ACP race, Desktop/SDK Go and frontend typecheck/build pass;
671 frontend files / 5472 tests pass. Local review/security review recorded;
maintainer approval and new-head hosted CI remain separate requirements.
The full boot race invocation reached its existing aggregate 10-minute limit
as another test started; disjoint test-list groups are used to finish the whole
inventory without raising that timeout or skipping tests.
2026-10-18 17:42:48 +08:00
esengine 84e70760b7 feat(feedback): show the reporter's level badge beside their replies in a thread 2026-10-18 02:31:55 -07:00
YHH 4014c87a38 Merge pull request #13768 from esengine/feat/11995-sync-close
feat(feedback): close the mirror issue of a withdrawn report
2026-10-18 02:31:46 -07:00
esengine f3bcb117f7 Merge remote-tracking branch 'origin/studio' into a5/wsl-ui 2026-10-18 02:31:25 -07:00
esengine 4f80c12e2b Merge branch 'a5/wsl-link' into a5/wsl-ui 2026-10-18 02:31:25 -07:00
esengine 8b58ce9eed feat(desktop): add a WSL distribution as a remote machine from Settings 2026-10-18 02:31:25 -07:00
esengine f02ef7d620 feat(integrations): DeepSeek Harness plugin market index for the Reasonix bundle
The index pins the commit that last changed the package; scripts/dsh-market-index.mjs
pins it and validates it with DSH's own validator. docs/DSH.md explains Discover
and the Studio release runbook gains a precondition that keeps the pin current.
2026-10-18 02:30:46 -07:00
esengine 79430cf8cf feat(integrations): website, author and doc links on the DeepSeek Harness bundle 2026-10-18 02:30:29 -07:00
esengine e22b515af1 feat(integrations): DeepSeek Harness plugin market index for the Reasonix bundle
A format-1 index pinned to the commit that last changed the package, and
scripts/dsh-market-index.mjs to pin and validate it with DSH's own validator.
2026-10-18 02:30:29 -07:00
esengine 8ba8c1cf72 feat(integrations): a ready-made DeepSeek Harness bundle that runs Reasonix as a subagent
A configuration-only package (no code) in integrations/dsh-plugin, installed
from this repository's subdirectory with pnpm's path: selector, plus the
integration guide docs/DSH.md.
2026-10-18 02:30:29 -07:00
esengine 34eb44fcd0 docs: using Reasonix from DeepSeek Harness as an ACP subagent
How to wire dsh's subagent-acp provider to reasonix acp, what each permission
policy does to Reasonix, what the parent sees, and the limits, verified against
dsh 0.2.1-rc.2.
2026-10-18 02:30:29 -07:00
esengine 957bdaa323 chore(i18n): keep the message tables at their size ceiling 2026-10-18 02:27:40 -07:00
esengine 2f28235ab6 feat(receipt): say that a Claude Code mod's effects were not observed, and count a live mod as an unseen writer 2026-10-18 02:27:40 -07:00
YHH 7c56994e82 Merge pull request #13705 from esengine/a9/acp-auth-required
fix(acp): a session that cannot open for want of credentials is the authentication-required error
2026-10-18 02:26:40 -07:00
esengine 84d16a1508 Merge remote-tracking branch 'origin/studio' into a5/wsl-link 2026-10-18 02:24:49 -07:00
esengine 36044d1515 style(remote): match exit errors with errors.As in the Linux-only tests 2026-10-18 02:24:47 -07:00
YHH 1a11a6691d Merge pull request #13766 from esengine/a9/rules-dir
feat(rules): rules directories, delivered on the turn and scoped by paths
2026-10-18 02:20:33 -07:00
esengine 49e2a036e6 ci: run checks against studio 2026-10-18 02:19:16 -07:00
esengine 221061b759 Merge remote-tracking branch 'origin/studio' into a5/schedule-api
# Conflicts:
#	desktop/frontend-next/src/i18n/en.ts
#	desktop/frontend-next/src/i18n/kernel.ts
#	desktop/frontend-next/src/port/mock_boundary.ts
#	desktop/frontend-next/src/port/port.ts
#	desktop/frontend-next/vite.config.ts
#	internal/frontend/serve/routes.go
#	internal/session/control/port.go
#	tools/repolint/wireparity.go
2026-10-18 02:19:16 -07:00
esengine 119f392248 feat(delegation): guide a running sub-agent through task_id or from the window 2026-10-18 02:17:23 -07:00
YHH d4d7ef809e Merge pull request #13752 from esengine/a5/schedule-ticker
feat(schedule): the Studio host runs the ticker while supervised and visible
2026-10-18 02:16:16 -07:00
esengine 74b4c5d21b feat(feedback): show linked devices' reports in My feedback and link from the terminal 2026-10-18 02:15:42 -07:00
SivanCola f975d35816 merge: preserve session bindings across current Studio entry points
Problem: newer Studio changes conflict with the ACP session contract and HTTP
runtime opening after the previous assembly integration.

Root cause: Studio adds a new-conversation execution-mode flag where this branch
also owns explicit composition selection and immutable inherited bindings.

Fix: normally merge Studio 4d3c09211. Add NewConversation to the one SessionParams
definition and retain it in HTTP build options. Resolve moved transcript paths
before reading either branch settings or the original composition. Keep exact
dispatch inheritance, workspace refusal, candidate rollback, ACP directories and
the accepted Standard/Minimal composer and management design.

Verification: format, vet, lint, make check, mandatory builtin/boot, full root
Go, Desktop Go, frontend typecheck/build and census pass. Frontend 671 files /
5465 tests and eight browser/four native renderer scenes pass. Affected
boot/ACP/serve/control/remote/delegation race tests pass. Provider request bytes
equal actual Studio with and without persona. Local review/security review are
recorded separately from maintainer review. The unchanged macOS computer
coverage baseline remains 88.2% below 89.8%; no floor is changed.
2026-10-18 17:12:02 +08:00
YHH d70edac126 Merge pull request #13737 from esengine/feat/session-move-import
feat(studio): move a conversation to another project, carrying the conversation and not what the old project allowed
2026-10-18 02:11:57 -07:00
esengine 5bd0afb74d fix(desktop): keep the scheduled runs block on a failed read, label parked items as model text 2026-10-18 02:06:16 -07:00
esengine f9c6f37b78 fix(remote): stage WSL launches in a private home directory and bound the wait by the session 2026-10-18 02:04:21 -07:00
esengine ae0f9f8fee feat(feedback): close the mirror issue of a withdrawn report 2026-10-18 02:03:13 -07:00
esengine 057d8094ba fix(acp): a session that cannot open for want of credentials is the authentication-required error
session/new answered -32603 for a model with no API key or no configured
provider, although initialize advertises a setup auth method. The cause is now
a provider.ErrKeyMissing / ErrNoProvider identity and the frontend maps it to
the ACP schema's -32000.
2026-10-18 02:01:16 -07:00
esengine ab3f88ac9b Merge remote-tracking branch 'origin/studio' into a5/wsl-link 2026-10-18 02:00:31 -07:00
SivanCola de5627d5e0 merge: reconcile Agent compositions with current Studio assembly
Problem: Studio advancement made the Agent composition PR conflict in boot
assembly while adding trace export and a workspace-aware mod runtime source.

Root cause: wireMCP now has a composition error result, and extension preflight
filters immutable bindings where Studio introduced its package source.

Fix: normally merge Studio ed1c127d6. Keep OTLP cleanup before returning from
wireMCP and retain workspace-aware discovery before exact composition filtering.
Preserve upstream tighten-only dispatch, replay protection, ACP elicitation and
the withheld mod switch. Keep Standard/Minimal, opt-in binding, ACP directories,
exact dispatch inheritance and the accepted composer layout.

Verification: gofmt, vet, lint, make check, required builtin/boot, full root Go,
affected boot/extension/modhost/ACP/serve/control race suites, Desktop and SDK
tests pass. Frontend typecheck/build, census, 668 files / 5450 tests, Electron
unit tests and eight browser/four native renderer scenes pass. Raw provider
requests equal actual Studio with both persona and empty persona. Local review
and security review found no new issue. Coverage gate still reports only the
unchanged macOS computer baseline, 88.2% versus 89.8%; no floor is lowered.
2026-10-18 17:00:30 +08:00
esengine 137a5831e9 feat(rules): rules directories, delivered on the turn and scoped by paths
Rule files under .reasonix/rules, .claude/rules and the user's rules folder are
read from disk each turn. Rules without paths: join the turn as a block owed
like the standing instructions; rules with paths: are owed once the session has
touched a matching file and retracted when they stop applying. Glob parsing and
matching stay with skills' paths: through one exported entry point. Neither layer
reaches the cache-stable prefix; capability diagnostics report what was skipped.
2026-10-18 01:57:13 -07:00
YHH 4d3c09211b Merge pull request #13725 from esengine/feat/13714-skill-remove
feat(skills): remove a skill from Settings, and never take its whole folder root with it
2026-10-18 01:56:43 -07:00
esengine 2bf3bd7b7e ci: run checks against studio 2026-10-18 01:56:02 -07:00
esengine 9e74ed1806 Merge remote-tracking branch 'origin/studio' into a5/schedule-ticker 2026-10-18 01:56:00 -07:00
esengine c013a4f20b fix(serve): the move target is matched against declared project roots and never reaches the file system 2026-10-18 01:55:42 -07:00
YHH 113523c5f8 Merge pull request #13686 from esengine/feat/11721-browser-full
feat(studio): the browser and workbench can be viewed full screen with the conversation folded away
2026-10-18 01:50:05 -07:00
YHH 0f13cf54c5 Merge pull request #13765 from esengine/a5/wsl-kind
feat(remote): a host entry has a kind, and a WSL distribution is one
2026-10-18 01:46:19 -07:00
YHH 5c99c45ded Merge pull request #13750 from esengine/feat/13693-subagent-rate
feat(delegation): report an estimated token rate while a sub-agent streams
2026-10-18 01:46:13 -07:00
YHH 92ca1b2180 Merge pull request #13753 from esengine/feat/11995-withdraw-client
feat(feedback): withdraw your own report from My feedback or /feedback withdraw
2026-10-18 01:42:08 -07:00
esengine dd9cf72abf style(remote): range over a count in the live test 2026-10-18 01:40:06 -07:00
esengine 6cd443fc94 Merge branch 'a5/wsl-kind' into a5/wsl-link 2026-10-18 01:38:43 -07:00
esengine 34c17c5abf style(config): range over the name's length 2026-10-18 01:38:40 -07:00
YHH ae0af1d5ed Merge pull request #13760 from esengine/a9/default-mode
feat(agent): start new conversations in plan mode when the user chose it
2026-10-18 01:35:23 -07:00
esengine ce73697390 Merge branch 'a5/wsl-kind' into a5/wsl-link 2026-10-18 01:33:54 -07:00
esengine 3ed0deb61d Merge remote-tracking branch 'origin/studio' into a5/wsl-kind 2026-10-18 01:33:39 -07:00
esengine f91eba7de2 fix(config): a WSL distribution name is checked against the characters WSL allows 2026-10-18 01:33:35 -07:00
esengine 89fbc59761 feat(remote): translate Windows workspaces for a WSL machine and refuse a distribution without ps 2026-10-18 01:31:49 -07:00
YHH c9c258067d Merge pull request #13761 from esengine/a5/bootstrap-fs
refactor(remote): bootstrap depends on the file calls it uses, not on SFTP
2026-10-18 01:31:33 -07:00
esengine d235c1fce8 feat(remote): bring up a kernel in a WSL distribution and reach it over loopback 2026-10-18 01:28:00 -07:00
esengine 03725a6939 test(desktop): the full-screen guard waits out the entrance and keeps the pointer off the rail before it shoots 2026-10-18 01:23:09 -07:00
esengine ff5242bcfc docs(release-notes): shorten the full-screen browser line 2026-10-18 01:23:09 -07:00
esengine 00ecd029d4 feat(studio): the browser and workbench can be viewed full screen with the conversation folded away 2026-10-18 01:23:09 -07:00
YHH 74772ab76a Merge pull request #13744 from esengine/fix/1q3k-moved-root-path
fix(hub): open a session by its pre-move path after the state root moves
2026-10-18 01:21:59 -07:00
esengine a67c05616d feat(feedback): withdraw your own report from My feedback or /feedback withdraw 2026-10-18 01:19:11 -07:00
esengine a280324b78 feat(agent): start new conversations in plan mode when the user chose it
agent.default_mode (user file only, off by default) is applied by the assembly
for builds that open a fresh conversation: Studio panes without a saved
session, the TUI without a resume, and ACP session/new. A resume, a headless
run and sub-agents never take it. Settings gets the switch.
2026-10-18 01:11:28 -07:00
YHH ddc01a3793 Merge pull request #13762 from xxoingr/fix/completion-receipt-cut-at-character
fix(completion): cut a declined check's command at a character boundary
2026-10-18 01:10:52 -07:00
esengine 764fad7299 fix(hub): open a session by its pre-move path after the state root moves 2026-10-18 01:04:35 -07:00
esengine 45f7b33165 Merge branch 'a5/bootstrap-fs' into a5/wsl-link 2026-10-18 01:04:11 -07:00
YHH fc4c0e8e5b Merge pull request #13757 from esengine/feat/7950-btw
feat(tui): /btw asks a read-only side question that is not saved to the session
2026-10-18 01:03:26 -07:00
esengine 4025ef24dc feat(remote): a host entry has a kind, and a WSL distribution is one 2026-10-18 01:01:42 -07:00
YHH ed1c127d65 Merge pull request #13747 from esengine/a5/schedule-tick
feat(schedule): a tick that runs what is due, and the schedule commands
2026-10-18 00:55:49 -07:00
xxoingr a94ba07bab fix(completion): cut a declined check's command at a character boundary
The unverified-change gap names the command the host declined, bounded
at 80 bytes by command[:80]. A check with non-ASCII paths, common in a
Chinese project, could be cut inside a character, and the delivery card
then showed a replacement glyph before the cut mark. Step back to the
start of the character instead, keeping the budget and the mark.
2026-10-18 15:54:15 +08:00
YHH cdb7d2318a Merge pull request #13724 from esengine/feat/13694-mod-host
feat(modhost): run Claude Code mods as tighten-only extension runtimes
2026-10-18 00:54:01 -07:00
esengine b39058d42b feat(tui): /btw asks a read-only side question that is not saved to the session 2026-10-18 00:50:18 -07:00
esengine d91e7f8d17 refactor(remote): bootstrap depends on the file calls it uses, not on SFTP 2026-10-18 00:48:06 -07:00
YHH d31d8daf07 Merge pull request #13743 from esengine/fix/dispatch-proof-replay
fix(peers): a dispatch proof is valid once per sequence number, and the adopt/spool boundaries are pinned
2026-10-18 00:46:31 -07:00
esengine d821569545 Merge remote-tracking branch 'origin/a5/schedule-api' into a5/schedule-ui 2026-10-18 00:45:56 -07:00
esengine 4d7bd5e7a7 Merge remote-tracking branch 'origin/a5/schedule-ticker' into a5/schedule-api
# Conflicts:
#	internal/frontend/cli/schedule_manage.go
2026-10-18 00:45:51 -07:00
YHH 8b1895f05d Merge pull request #13746 from esengine/a5/acp-names-cancel
feat(acp): cancel one request, message ids, elicitation
2026-10-18 00:41:44 -07:00
esengine c52d0c325c Merge remote-tracking branch 'origin/a5/schedule-tick' into a5/schedule-ticker
# Conflicts:
#	docs/GUIDE.md
2026-10-18 00:41:40 -07:00
esengine 24c3fe0f87 feat(schedule): refuse agent-started commands, confirm by typing the plan id, record who created it
Subprocesses the host starts for the agent carry REASONIX_AGENT_CHILD=1, and the
person-only schedule commands refuse when it is set, so a pty opened by a tool
no longer passes the terminal check. add prints the whole plan and requires its
short id typed back; list shows how each schedule was confirmed. The Windows
preview test compares against the resolved workspace path.
2026-10-18 00:39:59 -07:00
esengine adee10bd33 fix(modhost): read the package through a root, and run the mod effect tests with the sandbox off 2026-10-18 00:35:49 -07:00
esengine 971346ff56 docs(release): the user-visible change in the mod host core is the MCP ruling, not a switch that is still withheld 2026-10-18 00:35:49 -07:00
esengine 58a6432e55 test(modhost): wait for the script's stderr line instead of reading it the instant the reply lands 2026-10-18 00:35:49 -07:00
esengine 035453edfb refactor(agent): one gate method for the trusted MCP path 2026-10-18 00:35:49 -07:00
esengine 92c7f9d107 fix(desktop): English wording for the withheld mods switch 2026-10-18 00:35:49 -07:00
esengine 338264c6b7 fix(modhost): an extension's ruling also reaches installed MCP tools, and the mods switch stays withheld until the gaps merge 2026-10-18 00:35:49 -07:00
esengine 905eb76143 refactor(modhost): satisfy the linters 2026-10-18 00:35:48 -07:00
esengine fea2a123f6 refactor(modhost): keep the touched files inside their size and comment budgets 2026-10-18 00:35:48 -07:00
esengine 7dad28cf6b fix(desktop): the mods routes and refusal wording the parity tests ask for 2026-10-18 00:35:48 -07:00
esengine 00d6b85d34 docs(mods): the mod host, its trust and what it serves 2026-10-18 00:35:40 -07:00
esengine 95428e8f17 feat(mods): the mods status and settings port, its routes, and the environment a host states 2026-10-18 00:35:39 -07:00
esengine ec49147dee feat(permission): a tighten-only extension's ask is a fresh human approval of the one call 2026-10-18 00:35:39 -07:00
esengine 2234a3d68b test(modhost): effect tests for the prefix, a refused allow and a mod that dies or hangs 2026-10-18 00:35:39 -07:00
esengine f2a2da4120 feat(modhost): a mod installs, previews and runs through the real boot assembly 2026-10-18 00:35:39 -07:00
esengine 2a035ffdb1 feat(config): the [mods] switch and hook budget, user file only 2026-10-18 00:35:39 -07:00
esengine 152db8582e feat(modhost): synthesise a runtime for an installed mod and supervise it through the sidecar client 2026-10-18 00:35:27 -07:00
esengine 3d624951db feat(ext): dynamic tools and kill-on-timeout for single-threaded runtimes; the mod host serves tools, commands and the band 2026-10-18 00:35:27 -07:00
esengine 047b9e96b0 style(modhost): gofmt the harness 2026-10-18 00:35:27 -07:00
esengine a21d8d8b64 feat(modhost): the host script and its protocol tests under a real node 2026-10-18 00:35:27 -07:00
esengine 3219135e7e feat(modhost): resolve the JavaScript runtime a mod runs on 2026-10-18 00:35:27 -07:00
esengine 674712eadf feat(ext): a static preview of a mod's hooks module from its syntax tree 2026-10-18 00:35:27 -07:00
esengine 0154163c84 feat(ext): tighten-only runtimes and the wire types for Claude Code mods 2026-10-18 00:35:27 -07:00
esengine e906f7b4d5 docs(peers): say what a dispatched session inherits, including YOLO's reach over ask rules 2026-10-18 00:32:28 -07:00
esengine 749b7a6ede Merge branch 'a5/schedule-api' into a5/schedule-ui 2026-10-18 00:31:27 -07:00
YHH 8d7a55e8ad Merge pull request #13742 from esengine/a5/otlp-export
feat(otlp): opt-in OpenTelemetry trace export, user config only
2026-10-18 00:29:50 -07:00
esengine 89f422ad27 Walk the runs backwards with slices.Backward 2026-10-18 00:24:51 -07:00
YHH c73eb851bf Merge pull request #13749 from esengine/docs/sandbox-in-containers
docs(sandbox): what the Linux sandbox does in a container, measured
2026-10-18 00:24:43 -07:00
esengine 2c23584ee4 Merge branch 'a5/schedule-api' into a5/schedule-ui 2026-10-18 00:24:03 -07:00
esengine 6745de2eda feat(desktop): the scheduled runs screen under Settings, Automation 2026-10-18 00:24:03 -07:00
SivanCola c6a421a97f merge: integrate landed persona and current Studio contracts
Problem: Agent compositions still carried the persona prerequisite diff and
missed newly added session lifecycle contracts on Studio.

Root cause: the branch predated the landed persona, ACP additional directories
and project-dispatch assembly paths. Combining their independent additions
also exceeded existing file/scalar-state budgets.

Fix: normally merge Studio 896552483 after verifying #13588 actually landed.
Resolve the Persona/composition imports and keep one ACP SessionParams owner.
Clone vetted extra directories through composition switches. Pass the exact
private binding into dispatched runtime assembly; reject cross-workspace
bindings before opening, and reject fresh assembly errors without a history
fallback. Ordinary unbound dispatch remains unchanged. Extract the command
catalog and Mock memory projection, and place the MCP guard with catalog
refresh state. No budget baseline is raised.

Keep Standard/Minimal cards, the left mode-name-only picker, right run status,
default-off/unbound Standard behavior and the landed user-config persona.
Update bilingual behavior/API documentation and release notes.

Verification: format, vet, lint, make check, required builtin/boot tests, full
root count1 atomic-coverage tests, the complete CI race package group, Desktop
and SDK suites; frontend typecheck/build and 667 files / 5443 tests. Electron
unit176 pass with one existing skip. Browser8 and Electron4 controlled rendering
scenes cover locale/theme/width, selection, mode cards, locked/new flows and
left/right layout. Real HTTP requests match landed Studio byte-for-byte with
user persona29139 bytes and empty persona29110 bytes, with fixed probe PATH.
ACP/dispatch regressions fail before propagation and pass afterward. Local
review and security review cover MCP contracts and dispatch/ingress authority.

Known local gate: make coverage-gate reports the untouched macOS computer
package at88.2 below89.8. The exact command on landed Studio reproduces the
same result and uncovered lines. No floor is lowered; hosted Linux gates
remain required. Hosted results for this new head are reported separately.
2026-10-18 15:23:19 +08:00
esengine 6ee9cd0069 Take the default folder from the caller, not the process 2026-10-18 00:20:06 -07:00
esengine 46284a98f4 feat(schedule): the controller port, the serve routes and the port types for scheduled runs
One draft shape and one set of confirmation facts for every frontend; a
creation is accepted only with the token of the preview the person saw; only
the machine's owner may create or resume, any reader may pause or delete.
2026-10-18 00:14:49 -07:00
esengine 92b2e46f69 docs(sandbox): what the Linux sandbox does in a container, measured 2026-10-18 00:08:54 -07:00
esengine 4cddc3390c feat(schedule): the Studio host runs the ticker while it is supervised and visible
The host also answers 'schedule exec' by running the CLI's entry, so the
supervisor can start it as the child. One kernel at a time ticks, under the
machine's ticker lock.
2026-10-18 00:08:22 -07:00
esengine e453fe539e Merge studio into a5/otlp-export 2026-10-18 00:01:14 -07:00
esengine 20d807cb80 Merge remote-tracking branch 'origin/studio' into a5/schedule-tick 2026-10-18 00:00:24 -07:00
esengine c85b77bee2 Split the ceilings in the scheduled runs guide 2026-10-18 00:00:06 -07:00
esengine da001ded22 Shorten the release note fragment 2026-10-17 23:58:24 -07:00
esengine 3d71e57313 Shorten the release note fragment 2026-10-17 23:58:20 -07:00
esengine fb99e0effd feat(schedule): a tick that claims and runs what is due, and the person-facing schedule commands
Scheduler.Tick reaps dead runs, claims at most one due slot inside its grace
and hands it to the supervisor; the schedule add, list, pause, resume, renew,
rm and tick commands drive it. Creating, resuming and deleting need a
terminal.
2026-10-17 23:58:11 -07:00
esengine 6d700b071b Merge remote-tracking branch 'origin/studio' into a5/acp-names-cancel 2026-10-17 23:57:46 -07:00
esengine b09691402d Keep ACP docs and protocol.go within the size standards 2026-10-17 23:57:44 -07:00
esengine b3ec1e2e87 feat(delegation): report an estimated token rate while a sub-agent streams 2026-10-17 23:56:26 -07:00
YHH 896552483e Merge pull request #13588 from SivanCola/feature/new_system_person_20261017
feat(persona): add a system persona to Studio / 新增 Studio 系统人设
2026-10-17 23:54:08 -07:00
esengine e6ba328ad4 Merge remote-tracking branch 'origin/studio' into a5/acp-names-cancel 2026-10-17 23:52:15 -07:00
esengine ba8900aba9 fix(peers): a dispatch proof is valid once per sequence number, and the adopt/spool boundaries are pinned 2026-10-17 23:48:36 -07:00
esengine 57ca36e635 fix(studio): the move dialog says checkpoints, task logs and queued messages stay behind 2026-10-17 23:47:27 -07:00
esengine 5fdd45af4b feat(acp): cancel one request, message ids, tool names on permission requests, elicitation
$/cancel_request cancels only the named request and answers -32800;
agent message chunks carry an opaque messageId; an MCP server's form or
page goes through elicitation/create when the client advertised the mode.
2026-10-17 23:43:20 -07:00
SivanCola cc629b6424 merge: isolate persona imports from evolving settings types
Problem: Studio's new SearXNG connection probe conflicts with the persona
types in three transport import lines, blocking this PR again.

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

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

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

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

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

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

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

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

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

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

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

Verification: reproduced one failure in 10000 race-instrumented iterations
with GOMAXPROCS=2 before the change; 10000 final iterations pass afterward.
A temporary explicit-marker mutation that ignores cancellation is rejected.
The complete CI race package group passes, alongside gofmt, vet, lint,
make check and builtin/boot tests. Prior frontend/coverage evidence applies
to the unchanged product tree.
2026-10-18 12:39:10 +08:00
esengine eacf0618e8 fix(acp): vendor notifications reach only clients that declare them; transcriptPath rides _meta
The prompt result carried a top-level transcriptPath the ACP schema does not
allow, and status_update / mcpStatus_update went to every client, which
standard SDK clients answer with a method-not-found error each. The two
notifications now need clientCapabilities._meta["reasonix.io"].statusUpdates
and the path moves under _meta["reasonix.io"].
2026-10-17 21:28:25 -07:00
YHH 8c311b02bd Merge pull request #13708 from esengine/feat/13687-session-reference
feat(studio): reference a past conversation of this workspace from the message box
2026-10-17 21:27:41 -07:00
esengine 7b775ed15e test(video): an address another session delivered is not the user's, and a reopened conversation keeps who wrote what 2026-10-17 21:23:55 -07:00
SivanCola 8777a70d87 fix(studio): end a development shell with its launcher and survive a closed console
Problem
A development Studio shell started with `pnpm start` (`electron .`) had two
ways to outlive the launch that owned it:
- with its launcher killed outright, the shell and its kernel kept running
  with nobody holding them;
- with the launcher's console closed underneath it (a terminal gone, a
  background job's reader exited), the next kernel stderr line the shell
  echoed raised an EPIPE that nothing handled. Electron's modal "A JavaScript
  error occurred in the main process" box then held the main thread, so the
  shell stopped draining the kernel and ignored SIGTERM (still alive 30 s
  after it, 2/2 runs). The kernel logs a request every few seconds, so this
  followed within seconds of the console closing.

Root cause
main.js echoed kernel stderr with a bare process.stderr.write and attached
no error listener, and nothing in the shell watched for the loss of the
process that launched it. The kernel already ends with the shell through its
stdin lease; the shell had no equivalent towards its own launcher.

Fix
- src/launcher.js owns both: watchLauncher ends an unpackaged shell once its
  launcher is gone (POSIX reparents an orphan; Windows keeps the dead pid, so
  there it is probed), and consoleEcho drops output for a console that failed
  or backed up instead of throwing or queueing. shell.log and host.log keep
  every line.
- Both relaunch paths (the kernel's relaunch act and the X11 relaunch) hand
  the next instance over first: the launcher exits with this process, so a
  relaunched shell would otherwise read its own start as the launcher dying.
- shell.log records the shell's and the kernel's pids, which is how the live
  check finds them.

Verification
- node --test test/unit.mjs test/linux-packaging.test.mjs: 181 pass, 1 skip.
  The new real-pipe test fails (child exits 1 on EPIPE) without consoleEcho.
- pnpm launcher-live (new, real shell + kernel, macOS arm64): launcher
  SIGKILL after and before the handshake, closed console then SIGTERM, plain
  SIGTERM all pass; with the fix removed the first three fail.
- pnpm smoke: 62 ok, 1 fail ("the bridge exposes verbs and nothing else"),
  identical on unmodified studio.
2026-10-18 12:22:56 +08:00
YHH 64c5ff3273 Merge pull request #13717 from esengine/feat/13693-subagent-view
feat(delegation): read a sub-agent run's conversation while it runs
2026-10-17 21:19:13 -07:00
esengine 291d02c884 Merge remote-tracking branch 'origin/studio' into a5/searxng-probe 2026-10-17 21:14:41 -07:00
SivanCola f40ab35660 merge: reconcile persona with the settings interface
Problem: newer Studio settings UI and persona translations both appended to
the shared English catalog, leaving the PR conflicting. Its new setting-open
notices also lacked a destination for the guarded persona fields.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verification: gofmt, vet, lint, make check, builtin/boot tests, frontend
typecheck, production/perf builds and 651 files / 5370 tests; Chromium
fs-scale, text-size and explorer guards. In light and dark, both counts
measure 10px, 11.5px and 13px across the three interface size steps.
2026-10-18 10:05:34 +08:00
esengine 5393e818e2 Merge remote-tracking branch 'origin/studio' into a5/searxng-probe 2026-10-17 19:04:52 -07:00
esengine aab760b0b2 feat(settings): declare attachments.max_stream_bytes as a guarded size limit 2026-10-17 18:59:07 -07:00
SivanCola a72134c0ef fix(studio): key each skill metadata row
The metadata list mapped entries to an unkeyed fragment, so React warned about the missing key when Settings -> Extensions rendered a skill's spec. Give the fragment the entry key and add a regression test that fails without it.
2026-10-18 09:58:59 +08:00
esengine 530ccbf688 style(config): fold two comments so config.go is back inside its size budget 2026-10-17 18:57:53 -07:00
esengine 9c6c91da9c style(settings): errors.As in tests, slices.Contains, keep config.go within its size budget 2026-10-17 18:57:27 -07:00
esengine c7e6ef9a52 feat(settings): offer the setting tool, name the switch on a disabled dispatch, and document both 2026-10-17 18:57:27 -07:00
esengine f3a5a6bcce feat(settings): the setting tool, the host that confirms a change, and the structured remedy on refusals 2026-10-17 18:57:27 -07:00
SivanCola f5ebeb1bd6 merge(studio): reuse the guarded persona settings foundation
Problem: the latest studio settings catalog must know the persona fields, and
opt-in Standard mode must retain access to the prerequisite user-persona UI.

Root cause: the new catalog landed during the composition review, while the
initial composition page had replaced persona editing with a default adapter.

Fix: integrate the corrected persona parent and current studio, reuse its
editor in Standard mode, preserve settings-search navigation, and distinguish
user persona edits from immutable composition bindings. Keep the accepted two
cards, compact left picker and right run information.

Verification: gofmt, vet, lint, make check, root Go suite including boot/builtin,
frontend typecheck/build and 656 files / 5,385 tests pass. Parent persona keys
are Guarded; the actual request-byte and old-reader probes remain unchanged.
2026-10-18 09:52:37 +08:00
SivanCola 6fe0fd6867 fix(persona): declare guarded keys in the settings catalog
Problem: the new studio settings catalog rejects config fields without an
explicit classification, including the three persona fields added here.

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

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

Verification: root Go tests including settings, boot and builtin pass; gofmt,
vet, lint and make check pass; frontend typecheck/build and 5,370 tests pass.
2026-10-18 09:46:59 +08:00
SivanCola f3930c0c7f fix(composition): require explicit selection for session presets
Problem: default selections bound every new session and migrated the user's
persona, changing the ordinary path and making downgrade impossible.

Root cause: entry points manufactured a selection, persona settings were
adapted into the preset store, and blank unbound rewrites were not persisted.

Fix: keep unselected and Standard sessions unbound, preserve the user persona,
require explicit non-Standard selection, and restore durable state on failed
publication from either origin. Reuse the corrected persona parent, preserve
Standard/Minimal cards and the compact unified picker, and remove committed
composition captures. Freeze both skill discovery paths without copying locks.

Verification: root Go suite, vet, lint, make check, Desktop/SDK suites, Electron
unit tests and focused frontend suites pass. Real Build HTTP bytes match the
persona parent; its original reader can continue a new unbound transcript.
Project authorization, rollback and skill discovery regressions pass.
2026-10-18 09:45:52 +08:00
SivanCola e7d50cf0e9 fix(persona): preserve first-party guidance and user scope
Problem: project configuration could replace the user persona, and complete
mode could suppress built-in guidance. The UI misstated rebuild semantics.

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

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

Verification: gofmt, vet, lint, make check, root and Desktop Go suites, boot
scope/prompt regressions, frontend typecheck/build and 5,369 tests pass.
2026-10-18 09:22:31 +08:00
esengine e858a46f70 feat(search): say why a SearXNG instance does not answer, and test the address from settings 2026-10-17 10:54:09 -07:00
SivanCola 4522bb569a merge(studio): retain local bindings during native video projection
Problem: studio's native-video projection refactor overlaps the local-only
Agent composition metadata filter at the provider boundary.

Fix: keep the shared projection predicate and include composition metadata in
its host-only fields. Preserve stored bindings/video references and remove
answered video payloads only from model requests. Add regressions for a binding
as the sole copy trigger and for binding/video projection together.

Verification: gofmt, go vet, make lint/check, uncached root Go tests,
provider/OpenAI/boot/builtin suites, Desktop Go tests, frontend typecheck,
production build and 5,348 frontend tests. Existing mode layouts are preserved.
2026-10-18 00:48:01 +08:00
SivanCola 914bb02fc0 merge(studio): preserve pinned compositions with followed MCP tools
Problem: studio's MCP-follow changes and session compositions both own the
publication of a server tool catalog.

Root cause: the new followed-tool metadata must travel with the same catalog
whose fingerprint the immutable session binding validated.

Fix: retain follow metadata and the composition check before catalog publication.
Preserve upstream account localization and MCP-follow behavior. Add refresh,
stale-adapter and reconnect regressions with following enabled and disabled,
plus a real boot/provider test showing old bindings block drift while new
conversations can bind the changed server.

Verification: gofmt, go vet, make lint/check, root Go and boot/builtin suites,
Desktop and SDK Go suites, composition-guard race tests, frontend typecheck,
production build and 5,347 frontend tests. Browser evidence remains in design QA.
2026-10-18 00:37:35 +08:00
SivanCola 525d397009 feat(studio): offer Standard and Minimal Agent modes
Problem: the shipped catalog and composer exposed more mode choices and status
badges than the requested Standard/Minimal workflow needs.

Fix: ship two native compositions, with a full-configuration Standard mode
and a terminal/image Minimal mode using a concise persona. Preserve retained
bindings and report retired defaults explicitly. Separate built-in cards from
custom presets, keep only the mode name in the trigger, and place run status
at the right. Keep source/lock details in the accessible label and menu.

Verification: gofmt, go vet, make lint/check, required boot/builtin tests,
complete root and Desktop Go suites, frontend typecheck/build and 5,343 tests.
Provider-boundary and custom read-only fixtures retain capability-denial
coverage. Browser checks cover light/dark, 1440px/390px and locked controls.
2026-10-18 00:28:15 +08:00
SivanCola eebc6d2a3c fix: make remote workspace binding explicit before link reuse
Problem: Host reads could borrow another pane's workspace or reuse an existing
link after its host was disabled. Review also requested explicit defaults,
boundary regressions and removal of development notes from the tree.

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

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

Verification: Reproduced both incorrect read behaviors before repair. Added
HTTP regressions for declared defaults, fallback, empty configuration,
reordered extras, explicit opens, project injection and disabled hosts with
existing panes. Focused frontend tests (80), production build, owning Go
serve/config and builtin/boot tests, gofmt, go vet, lint and make check passed.
2026-10-18 00:19:23 +08:00
SivanCola 8143adc062 merge(studio): preserve Agent composition UI during upstream integration
Problem: the session-composition branch conflicted with the latest studio
settings, composer, peer-thread and runtime-construction changes.

Root cause: both branches extended shared port declarations, provider wiring
and the composer status row; failed-build cleanup also gained a separate
cancellable construction lifetime upstream.

Fix: retain both sets of settings and providers, preserve the top-row unified
Agent menu and responsive anchoring, and combine lifetime cancellation with
composition resource cleanup. Keep upstream accessible status labels and
text-size scaling. Add provider-boundary coverage showing the new image tool
cannot exceed review/research tool selections, plus updated visual evidence.

Verification: gofmt, go vet, make lint/check, required boot/builtin tests,
root and Desktop Go suites, frontend typecheck/build and 5,342 tests, Electron
unit tests, and browser light/dark checks at 1440px and 390px.
2026-10-18 00:13:18 +08:00
SivanCola 8709b556f0 merge: integrate current studio project contracts
Problem: The unified sidebar conflicted with newer Studio worktree grouping
and text scaling changes.

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

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

Verification: The integrated frontend suite passed 5381 tests. Four focused
grouped SSH/palette regressions and the production build passed. gofmt, go vet,
make lint, make check and builtin/boot tests passed without widening baselines.
2026-10-18 00:02:12 +08:00
SivanCola f2e2f1f460 feat(studio): unify Agent presets in the composer status row
Problem: Session composition controls competed with task actions in the
bottom toolbar, and the adjacent ellipsis did not explain how to manage
presets.

Root cause: Composition selection and management were rendered as separate
controls below the input. Floating menu placement also missed fold changes
published after window resize and the end of layout transitions.

Fix: Place one Agent presets menu beside the run status, show sources and
capability notes, and group management below a separator. Preserve locked
bindings and explicit new-conversation actions. Reuse the existing Picker
with per-choice action identities, disabled-row keyboard navigation and
current-state semantics; refresh shared floating anchors after reflow.
Update bilingual guides, release notes and before/after screenshot evidence.

Verification: All 633 frontend test files / 5,225 tests pass, including
selection/management, keyboard focus, pending/failure, lock/new-session,
source identity and responsive positioning regressions. Typecheck,
production build, gofmt, go vet, make lint, make check, and required
boot/builtin suites pass. Browser light/dark, wide/390px and resize recovery
were verified with synthetic fixtures.
2026-10-18 00:01:26 +08:00
SivanCola da051abb2c feat: unify local and SSH project navigation
Problem: Machine-grouped navigation made project switching cumbersome, and
session hover actions had inconsistent selection and keyboard behavior.

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

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

Verification: 5263 frontend tests passed, production TypeScript/Vite build
passed, and focused browser and real SSH navigation checks are documented in
the QA reports. Owning Go serve/remotehost/attach suites passed. Pre-commit
gofmt, go vet, make lint, make check and builtin/boot tests passed without
widening the repository lint baseline.
2026-10-17 23:48:21 +08:00
SivanCola 9717c4ff2c fix(session): preserve composition admission and idle handoffs
Problem: an admitted turn could fail before adding its user message and unlock
its composition. Controller-owned continuations could strand ACP config changes,
and MCP disconnect could remove code needed by a subsequent reconnect.

Root cause: the started latch followed message insertion, ACP drained only its
own prompt returns, and connection cleanup owned session package resources.

Fix: durably latch composition admission before executing or acknowledging a
turn and refuse work while the latch is pending. Notify owners at real idle
transitions and drain ACP changes through its existing serialization domain.
Separate transport state from package lifetime, retain script sibling bundles,
exclude VCS metadata, reject linked transcript exports and freeze late tool names.
Add ACP exact-binding/history tests, actual old-reader evidence, bilingual notes
and the complete light/dark wide/390px before-and-after screenshot set.

Verification: deterministic async/sync admission and failed-save regressions,
ten staged background-continuation/config-switch runs, real MCP reconnect cycles,
shared lifecycle race tests, package coverage, vet, lint and repository checks.
Frontend full suite and independent Desktop/SDK/Electron suites remain green.

Refs: #13613
2026-10-17 19:36:35 +08:00
SivanCola 4fe345ec44 feat(studio): bind immutable Agent compositions to sessions
Problem: Studio persona settings cannot select an independent tool and module
combination for each conversation, or preserve that combination across reloads.

Root cause: discovery and runtime construction follow mutable shared configuration
without a versioned session composition or a shared admission boundary.

Fix: introduce versioned scoped definitions, retained resources and authoritative
session bindings. Assemble isolated runtime selections, enforce tool ceilings,
pin MCP contracts, and share the lifecycle gate with first-prompt admission.
Expose independent HTTP, CLI, ACP and Studio management and selection controls,
with migration, native import/export, diagnostics and read-only history recovery.

Verification: composition mechanism tests, provider-wire cache tests, lifecycle
rollback tests, sensitive-boundary regressions, frontend typecheck and all 5,219
frontend tests; gofmt, vet, lint and repository checks. Browser light/dark wide
and 390px screenshots and real Electron backend interactions are included.

Refs: #13588
2026-10-17 19:09:49 +08:00
SivanCola ce29aae765 docs(release): note for the Studio persona page 2026-10-17 17:03:44 +08:00
SivanCola 27434c885f feat(studio): a persona page that writes the user config for new sessions 2026-10-17 17:01:53 +08:00
SivanCola 21d8cc2fa9 feat(persona): compose a user persona into the cache-stable system prompt 2026-10-17 17:01:51 +08:00
1719 changed files with 98911 additions and 4435 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@
*.bat text eol=crlf
*.css text eol=lf
*.go text eol=lf
*.golden text eol=lf
*.html text eol=lf
*.js text eol=lf
*.json text eol=lf
+37
View File
@@ -0,0 +1,37 @@
name: acp-conformance
# The agent is driven over stdio by the official ACP TypeScript SDK, and every
# message it writes is held to the schema that SDK ships. Not a required check:
# it runs when the ACP frontend or the harness changes, and on request.
on:
pull_request:
branches: [main-v2, studio]
paths:
- "internal/frontend/acp/**"
- "tools/acp-conformance/**"
- ".github/workflows/acp-conformance.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: acp-conformance-${{ github.ref }}
cancel-in-progress: true
jobs:
conformance:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "22"
- run: make acp-conformance
+3 -3
View File
@@ -43,20 +43,20 @@ jobs:
if: matrix.branch != ''
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Initialize CodeQL
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
- name: Perform CodeQL Analysis
if: matrix.branch == ''
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
category: "/language:${{ matrix.language }}"
# Results from a checked-out line are filed under that line's ref, not the
# default branch the schedule happened to run from.
- name: Perform CodeQL Analysis (${{ matrix.branch }})
if: matrix.branch != ''
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
category: "/language:${{ matrix.language }}"
ref: refs/heads/${{ matrix.branch }}
+32
View File
@@ -0,0 +1,32 @@
name: Deploy website
# GitHub requires workflow_dispatch to be registered on the default branch.
# The production implementation is the same workflow path on website.
on:
workflow_dispatch:
inputs:
release_version:
description: "Optional product release version associated with this publication"
required: false
default: ""
type: string
release_request:
description: "Optional release workflow correlation ID"
required: false
default: ""
type: string
studio_version:
description: "Studio version that must be present in the generated changelog"
required: false
default: ""
type: string
permissions: {}
jobs:
require-website:
runs-on: ubuntu-latest
steps:
- run: |
echo "::error::Deploy the website with --ref website; production is restricted to that branch."
exit 1
+28 -1
View File
@@ -389,6 +389,14 @@ jobs:
version="${{ needs.resolve.outputs.version }}"
bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-$arch.deb" "$arch" "${version#v}"
- name: Verify the AppImage
if: runner.os == 'Linux'
shell: bash
run: |
platform="${{ matrix.platform }}"
arch="${platform#linux/}"
bash scripts/verify-linux-appimage.sh "dist/ReasonixStudio-linux-$arch.AppImage" "$arch"
# With signing on, this leg only hands the bundle on: the signing jobs
# run nothing but the signer, and windows-package packages in between.
- name: Upload the unsigned Windows bundle for signing
@@ -1199,7 +1207,7 @@ jobs:
fi
{
printf '%s\n\n' '---'
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。x86_64 用文件名带 amd64 的包,aarch64(统信 UOS、麒麟等)用带 arm64 的包。\n\n" "$windows"
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。x86_64 用文件名带 amd64 的包,aarch64(统信 UOS、麒麟等)用带 arm64 的包。也提供 \`.AppImage\`(只提供下载,不会自更新;需要 FUSE 2,或用 \`--appimage-extract-and-run\` 启动)。\n\n" "$windows"
printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
} >> "$notes"
assets=()
@@ -1294,6 +1302,25 @@ jobs:
--cache-control "public, max-age=300, stale-if-error=86400"
echo "Studio catalog -> $VERSION"
website:
name: publish the Studio website changelog
needs: [resolve, publish]
if: github.repository == 'esengine/DeepSeek-Reasonix' && needs.resolve.outputs.candidate != 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
actions: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.workflow_sha }}
- name: Deploy and verify the website changelog
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.resolve.outputs.version }}
run: bash scripts/publish-studio-changelog.sh
# A CLI channel that moves while 1.x still publishes would have two lines
# writing it, so every CLI job waits for this check. It runs no repository code.
cli-gate:
+21 -1
View File
@@ -144,7 +144,18 @@ jobs:
node perf/jump-ends.mjs
node perf/modelrows.mjs
node perf/explorersize.mjs
node perf/git-panel.mjs
node perf/session-move-to.mjs
node perf/shell-refused.mjs
node perf/external-origin.mjs
node perf/rail-search-hold.mjs
node perf/rail-toggle-panes.mjs
node perf/session-move.mjs
node perf/session-ref.mjs
node perf/browser-full.mjs
node perf/mod-ui.mjs
node perf/codeeditor-size.mjs
node perf/action-icons.mjs
node perf/rowhit.mjs
node perf/tab-pip.mjs
node perf/context-card-hover.mjs
@@ -167,6 +178,11 @@ jobs:
node perf/mcp-actions.mjs
node perf/web-access.mjs
node perf/peers-settings.mjs
node perf/output-cap.mjs
node perf/progress-tail.mjs
node perf/trust-wait.mjs
node perf/nest-nodes.mjs
node perf/provider-ports.mjs
node perf/peer-threads.mjs
node perf/text-size.mjs
node perf/fs-scale.mjs
@@ -356,7 +372,7 @@ jobs:
arm64=false
platform=$(git diff -z --name-only --no-renames "$BASE_SHA" HEAD | bash scripts/ci-platform-scope.sh)
if git diff --name-only --no-renames "$BASE_SHA" HEAD |
grep -qE '^(desktop/electron/|desktop/packaging/|desktop/cmd/update-helper/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(linux-install-smoke|verify-linux-package|verify-studio-artifacts)\.sh$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
grep -qE '^(desktop/electron/|desktop/packaging/|desktop/cmd/update-helper/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(linux-install-smoke|verify-linux-package|verify-linux-appimage|verify-studio-artifacts)\.sh$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
arm64=true
fi
fi
@@ -600,6 +616,10 @@ jobs:
if: runner.os == 'Linux'
run: bash scripts/linux-install-smoke.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.deb" "${{ matrix.arch }}" 0.0.0-ci
- name: The AppImage carries its architecture, the kernel and the page
if: runner.os == 'Linux'
run: bash scripts/verify-linux-appimage.sh "dist/ReasonixStudio-linux-${{ matrix.arch }}.AppImage" "${{ matrix.arch }}"
# Recorded, not asserted: the real bundle layout, so later work argues
# from what electron-builder produced rather than from its documentation.
- name: Record the macOS bundle layout
+8 -1
View File
@@ -9,7 +9,7 @@ GOEXE := $(shell go env GOEXE)
# One pin for the Makefile and the CI lint job; see .github/workflows/ci.yml.
GOLANGCI_VERSION := $(shell cat .golangci-version)
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck check test studio-test sdk-test sdk-test-race hooks cross clean studio
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update coverage-gate coverage-gate-update pricecheck acp-conformance check test studio-test sdk-test sdk-test-race hooks cross clean studio
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
@@ -87,6 +87,13 @@ lint-cross:
test: studio-test sdk-test
go test ./...
# Drives `reasonix acp` with the official ACP TypeScript SDK and holds every
# message it writes to the schema that SDK ships. Needs node and network for
# `npm ci`, which runs without install scripts against a lockfile.
acp-conformance: build
cd tools/acp-conformance && npm ci --ignore-scripts --no-audit --no-fund
cd tools/acp-conformance && REASONIX_BIN=$(CURDIR)/bin/reasonix$(GOEXE) npm test
# The shell is a nested module, so the root `test` target never reaches it.
studio-test:
cd desktop && go test ./...
+18 -6
View File
@@ -93,6 +93,16 @@ Prefer a terminal? Download the `reasonix` command-line tool, run `reasonix setu
All four are the same program underneath. What follows is for advanced users and contributors.
## Use it from DeepSeek Harness
DeepSeek Harness (`dsh`) can hand a task to Reasonix as a subagent over ACP. This repository publishes a plugin index for it:
1. Run `reasonix setup` on the machine that runs `dsh`. In the DSH Web UI turn on Custom plugin markets (Plugins, More) and add this index in the Discover tab: `https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/studio/integrations/dsh-plugin/market-index.json`
2. Install "Reasonix subagent" from it.
3. Open the plugin's page and turn its switch on. While it is off the model has no `reasonix` tool and may run `reasonix -p` in its own shell, which looks like a delegation and is not one.
Setup details, the permission policy and limits are in [Using Reasonix from DeepSeek Harness](./docs/DSH.md).
## Versions
Reasonix ships on two lines. See the [version roadmap announcement](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748) for the reasoning behind the split.
@@ -151,7 +161,7 @@ pre-releases):
| --- | --- | --- |
| macOS | `.dmg` or `.zip` | Apple Silicon (`arm64`) / Intel (`amd64`) |
| Windows | Installer `-installer.exe` or portable `.zip` | x64 |
| Linux | `.deb` or `.rpm` | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
| Linux | `.deb`, `.rpm`, or `.AppImage` (download only) | x64 (`amd64`) / ARM64 (`arm64`, aarch64) |
Every package ships with a `.minisig` signature, and the release carries
`SHA256SUMS`. Once installed, Studio updates itself in place.
@@ -240,12 +250,14 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
## Documentation
- **Getting started:** [Guide](./docs/GUIDE.md) · [CLI reference](./docs/CLI.md) ·
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md)
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.md) ·
[Use from DeepSeek Harness](./docs/DSH.md)
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[Recovery and updates](./docs/RECOVERY.md) ·
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
- **Agent presets:** [Session Agent compositions](./docs/AGENT_COMPOSITIONS.md)
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
[Tool contract](./docs/TOOL_CONTRACT.md) · [Moving from 1.x to 2.x](./docs/MIGRATING.md) ·
@@ -277,10 +289,10 @@ A small list of folks whose work has shaped Reasonix the most — the current to
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
| [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**Harbor404**](https://github.com/Harbor404) |
| [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) |
| [**xxoingr**](https://github.com/xxoingr) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) | [**par73e**](https://github.com/par73e) |
| [**Bernardxu123**](https://github.com/Bernardxu123) | [**Guan0923**](https://github.com/Guan0923) | [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) |
<!-- reasonix-top-contributors:end -->
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
+17 -6
View File
@@ -91,6 +91,16 @@ Reasonix 是一个开源的 AI 编程助手。程序跑在你的电脑上;AI
四种用法背后是同一套程序。下面的内容写给进阶用户和贡献者。
## 在 DeepSeek Harness 里使用
DeepSeek Harness(`dsh`)可以通过 ACP 把任务交给 Reasonix 作为子智能体去做。本仓库为此发布了一个插件索引:
1. 在运行 `dsh` 的机器上先运行 `reasonix setup`。在 DSH 网页界面打开“自定义插件市场”(插件,更多),再到 Discover 页添加这个索引:`https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/studio/integrations/dsh-plugin/market-index.json`
2. 从中安装“Reasonix subagent”。
3. 打开这个插件的页面,把它的开关打开。关着的时候模型没有 `reasonix` 工具,可能改在自己的 shell 里直接运行 `reasonix -p`,看起来像委派,其实不是。
配置细节、权限策略和限制见 [在 DeepSeek Harness 中使用 Reasonix](./docs/DSH.md)。
## 版本
Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748)。
@@ -139,7 +149,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
| --- | --- | --- |
| macOS | `.dmg` 或 `.zip` | Apple Silicon(`arm64`)/ Intel(`amd64`) |
| Windows | 安装器 `-installer.exe` 或便携 `.zip` | x64 |
| Linux | `.deb` 或 `.rpm` | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
| Linux | `.deb`、`.rpm` 或 `.AppImage`(只提供下载) | x64(`amd64`)/ ARM64(`arm64`,aarch64) |
每个安装包都附带 `.minisig` 签名,发布页另有 `SHA256SUMS`。安装后 Studio 会在
应用内自行更新。
@@ -221,12 +231,13 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
- **开始使用:** [指南](./docs/GUIDE.zh-CN.md) ·
[CLI 命令参考](./docs/CLI.zh-CN.md) · [配置路径](./docs/CONFIG_PATHS.md) ·
[ACP 编辑器接入](./docs/ACP.md)
[ACP 编辑器接入](./docs/ACP.md) · [在 DeepSeek Harness 中使用](./docs/DSH.md)
- **功能与排障:** [子智能体 Profile](./docs/SUBAGENT_PROFILES.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.md) ·
[恢复与安全模式](./docs/RECOVERY.md) ·
[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)
- **Agent 预设:** [会话级组合](./docs/AGENT_COMPOSITIONS.zh.md)
- **工程与迁移:** [规格](./docs/SPEC.md) ·
[任务合约与暂停策略](./docs/TASK_CONTRACT.md) ·
[工具合约](./docs/TOOL_CONTRACT.md) ·
@@ -259,10 +270,10 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
| [**XTLine**](https://github.com/XTLine) | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**Harbor404**](https://github.com/Harbor404) |
| [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) |
| [**xxoingr**](https://github.com/xxoingr) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**noreply**](https://github.com/noreply) | [**par73e**](https://github.com/par73e) |
| [**Bernardxu123**](https://github.com/Bernardxu123) | [**Guan0923**](https://github.com/Guan0923) | [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) |
<!-- reasonix-top-contributors:end -->
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
+18 -5
View File
@@ -373,6 +373,10 @@ out of how a path is spelled, which cannot tell `internal/auth` from
- sensitive: internal/safety/egress/**
- sensitive: internal/platform/browser/**
- sensitive: internal/platform/computer/**
- sensitive: internal/platform/gitpush/**
- sensitive: internal/session/control/git_push.go
- sensitive: internal/frontend/serve/git_push.go
- sensitive: internal/frontend/serve/host_routes.go
One declaration, two effects: the same list is also the coverage gate's subject.
`make coverage-gate` holds each path to the coverage it already has, because
@@ -409,8 +413,13 @@ moving a number here must not move the promise.
- Symlinks are recorded by `Lstat`, so a write through one lands outside what
the walk compares. Confining the resolved target is the sandbox's job.
- `internal/safety/sandbox` confines writes to the workspace, configured extras, temp
and toolchain caches — on macOS and Linux. Windows has no OS-level bash
sandbox, so there nothing the host enforces bounds `Writable`.
and toolchain caches on macOS and Linux.
- On Windows a user-enabled write-restricted token bounds the *creation* of files
(create, append, overwrite, `mkdir`, HKCU writes, global git config, writes through links).
- There, deleting or renaming away an existing file is not bounded, and with the
setting off nothing the host enforces bounds `Writable`.
- The caches a command may write can be poisoned for the user's later unsandboxed
builds, on every platform.
- The governed host authorities are a *named* set — ssh-agent, docker, podman.
DBus, Wayland, X11, gpg-agent, keyrings and arbitrary sockets stay reachable,
so the claim is "these endpoints are governed", never "host IPC is isolated".
@@ -456,9 +465,13 @@ stop a connect. Possession is not authority: the key can be unreadable while an
agent still signs with it.
A protection claim exists only where a backend has a demonstrated enforcement
primitive for that dimension. Unsupported dimensions stay explicitly unsupported
— Windows currently enforces none of the three — because a claim that outruns
its enforcement is worse than an absent one: it is believed.
primitive for that dimension. Unsupported dimensions stay explicitly unsupported,
because a claim that outruns its enforcement is worse than an absent one: it is
believed.
Windows enforces Integrity on file creation only, when the user turns it on.
Deletion and rename, Confidentiality and Authority (the named pipes of ssh-agent
and docker, COM and RPC services) are not enforced there.
**Known unmeasured surface — macOS host-mediated delegation:** Apple Events and
Mach-service lookup are not denied by the current allow-default profile. Prior
+22
View File
@@ -0,0 +1,22 @@
# Build output and local tooling state.
build/
.gradle/
.kotlin/
local.properties
*.iml
.idea/
# The signing key and the file that names it.
#
# A key is not part of the project: it is the identity one person ships under,
# and publishing it would let anyone else sign an APK that installs over
# everyone's. Neither the keystore nor its password belongs in version control.
# `app/build.gradle` reads both from here when present and falls back to the
# debug key when they are not, so a fresh clone builds without them.
keystore/
# Build inputs, produced by scripts/ before Gradle runs.
# jniLibs/ holds the cross-compiled kernel, assets/page/ the built
# frontend. assets/LICENSE is hand-placed and stays tracked.
app/src/main/jniLibs/
app/src/main/assets/page/
+216
View File
@@ -0,0 +1,216 @@
---
owner: @esengine
backup: @SivanCola
status: active
reviewed: 2026-10-20
---
# Reasonix Mobile — the Android shell
Runs Reasonix as an Android app: the kernel ships inside the APK and starts on the
device, the interface is the same `desktop/frontend-next` build the desktop Studio
serves, and the agent reaches servers over SSH.
This file is for people building it. What a person installing it needs — what it
does, how to pair with a computer, what it cannot do — belongs in the release
notes.
## One command
```bash
./scripts/build.sh release # or debug
```
Three steps, in this order:
1. `build-kernel.sh` → `app/src/main/jniLibs/arm64-v8a/libreasonix.so`
2. `sync-page.sh` → `app/src/main/assets/page/`
3. `./gradlew assembleRelease`
Output: `app/build/outputs/apk/release/app-release.apk`
The kernel and the page are **inputs to packaging, not Gradle build steps**.
Running `gradle assembleRelease` on its own fails on purpose and names the script
that produces whichever input is missing (`requireBuildInputs` in
`app/build.gradle`): an APK with no kernel in it should not be buildable by
accident.
## Requirements
| Component | Version |
| --- | --- |
| JDK | 21 (17 also works) |
| Android SDK | `platforms;android-35`, `build-tools;35.0.0`, `platform-tools` |
| Gradle | the committed wrapper, 8.14.3 (for AGP 8.11.1) |
| Go | 1.26+ |
| Node / pnpm | Node 24+ / pnpm 10 |
```bash
export ANDROID_HOME="$HOME/Android/Sdk"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export JAVA_HOME="/path/to/jdk-21"
export PATH="$PATH:$(go env GOPATH)/bin"
```
`ANDROID_HOME` is the one thing nothing sets for you.
## Scripts
| Script | What it does |
| --- | --- |
| `build.sh [debug\|release]` | The whole chain |
| `build-kernel.sh` | Cross-compiles the kernel only (`GOOS=android GOARCH=arm64 CGO_ENABLED=0`) |
| `sync-page.sh` | Builds the frontend, copies `dist/` into assets, copies `LICENSE` |
| `verify-page.sh` | **No phone needed**: starts the real kernel against the packaged page |
| `run.sh [debug\|release]` | `adb install`, launch, follow the log |
`verify-page.sh` is the one to run after a change: it starts the kernel with the
same arguments `Kernel.kt` uses (`--auth token`, `--token-file`, `--port-file`,
`-page`) and checks that it binds a port and answers 200. Without a device, that
is as deep as the chain can be verified.
## Where the code is
| File | Owns |
| --- | --- |
| `MainActivity.kt` | The WebView, the back gesture, downloads, external links, renderer restart |
| `Kernel.kt` | Unpacking the page, starting and stopping the kernel, waiting for the port, health probes, logs, the workspace directory |
| `KernelService.kt` | The foreground service that keeps a long turn alive |
| `NativeBridge.kt` | `@JavascriptInterface`: what the page may ask the phone for |
| `BrowserShellServer.kt` | Loopback HTTP server the kernel's browser tools drive the overlay WebView through |
| `Downloader.kt` | Writing a kernel-produced file through the system document picker |
The page reaches the shell through `window.reasonixHost` and `ReasonixNative`,
defined in `desktop/frontend-next/src/port/host.ts` (`AndroidHost`).
**Phone and desktop share one page build.** What differs only on a phone is
decided at runtime, not by a second set of components. `phoneShell()` in
`src/port/host.ts` and the `[data-shell="android"]` selector in `mobile.css` both
read `<html data-shell="android">`, which `index.html` sets before the bundle
loads.
One attribute feeding both is what keeps style and components from disagreeing
about what a phone is.
## What the page may ask the phone for
`NativeBridge` is reachable from whatever document the WebView holds, so every
method that touches the device asks `trusted()` first.
The activity answers by comparing the **main frame's** origin against the
kernel's and against the one paired computer the person scanned a code for. A
page that navigated elsewhere is not trusted, even though it may still hold the
object.
Five methods answer without the check — `version`, `systemTheme`, `safeArea`,
`setLayerOpen`, `setRunning` — because they report back what the page already
knows about its own window and grant nothing.
`openInApp(url)` is the pairing decision, not a navigation one: it loads a URL in
this window only for the origin the scanner just returned, and only once.
Without that, any page holding the kernel origin could hand itself the bridge.
External links leave through the browser. A non-web scheme is routed to the
system only for `mailto:` and `tel:`; everything else is dropped, because an
`intent://` URL can start an arbitrary exported activity with caller-supplied
extras.
## Permissions, and what they cost
`MANAGE_EXTERNAL_STORAGE` — opening a folder on the phone as a workspace. The
kernel is a Go binary: it opens real paths with `open(2)`, and SAF hands back a
`content://` URI it cannot open.
Requested from the folder-picker action, never at launch. Android shows no dialog
for it and opens system settings instead.
**There is no OS-level sandbox around this kernel.** The PR sets the sandbox to
off on Android, so confining the agent to the chosen folder is Reasonix's own
write-root check and nothing else. A shell tool with all-files access can reach
all shared storage.
`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` — Doze freezes the app group, which stops
a turn that is still running. Asked once when a turn actually runs, never at
launch, and the person can refuse. Refusing costs battery life during long turns
and nothing else.
**Google Play restricts this permission**: fine for a sideloaded build, a
store-policy risk if this is ever listed.
`FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_DATA_SYNC` — keeping the kernel alive
while the screen is off. `dataSync` is capped per 24 hours from Android 15. It is
the closest honest description of "an agent is working".
`CAMERA` — reading the pairing code a computer shows. Requested when the person
asks to scan, never at launch. `ScanActivity` is not exported.
`READ_EXTERNAL_STORAGE` / `WRITE_EXTERNAL_STORAGE` — the same folder walk below
API 33. `maxSdkVersion`-gated; above 33 they are superseded by the permission
above.
`allowBackup=false` with explicit extraction rules, so a `keystore` or a token in
app storage never leaves the device in a cloud backup.
## Cleartext HTTP
The kernel serves its page on `127.0.0.1:<port>`, and a paired computer serves
the same page over its LAN address. Both are plain HTTP.
Neither address can be written into a `network-security-config`: the port is
chosen at start, the LAN address at scan time, and the format matches literals
and domains, not subnets.
So the config permits cleartext and the boundary is in the code instead.
`shouldOverrideUrlLoading` sends every other http(s) origin to the browser, and
the bridge answers only the kernel and the just-paired computer. A plaintext page
on the LAN cannot become the document holding the bridge.
## What goes into the APK
| Content | Size | Why it is shaped this way |
| --- | --- | --- |
| `lib/arm64-v8a/libreasonix.so` | 45 MB | Go static binary, stripped. It is a `.so` rather than a file in `assets/` because only `nativeLibraryDir` is guaranteed executable — a binary copied to app storage fails on a device whose data partition is mounted `noexec`. |
| `assets/page/` | 14 MB | The frontend build, including the Inter and Noto Sans SC fonts, `noCompress` |
| `assets/LICENSE` | 1 KB | The repository's MIT licence, verbatim. MIT requires the notice and the licence text to accompany a binary distribution. |
`assets/LICENSE` is generated by `sync-page.sh` from the repository's root
`LICENSE`, not committed a second time: a checked-in copy drifts when the root
file is updated.
It sits in `assets/` rather than `assets/page/` because that directory is removed
and rebuilt on every build.
A release APK is about 31 MB.
## Building
**The Gradle wrapper is committed**, jar and all. `build.sh` refuses to run
without `gradlew` rather than generating one from whatever `gradle` is on `PATH`:
the dependency versions are part of the build.
`gradle-wrapper.properties` pins `distributionSha256Sum`, so a mirror that serves
a different distribution zip fails verification instead of being unpacked and
run. Dependency repositories are the official ones only — a mirror belongs in a
user-level init script.
**Signing.** A release with no key configured comes out **unsigned**
(`app-release-unsigned.apk`) with a warning. It used to fall back to the debug
key, which produced an artifact that looked shippable and was signed by a
per-machine key nobody chose: it could not install over the previous build.
Point the build at a real key by writing `keystore/keystore.properties`
(`storeFile`, `storePassword`, `keyAlias`, and optionally `keyPassword`) or by
setting the matching `REASONIX_KEYSTORE*` environment variables. Key material
stays out of the repository and out of build logs.
Debug builds always use the debug key. Signing a debug build with the release key
would let anyone who can read the keystore produce a build that installs over the
shipped one under the same signature.
**Non-ASCII checkout paths.** AGP refuses to build under a path with non-ASCII
characters on Windows unless `android.overridePathCheck=true` is set. That is a
property of one machine's disk layout, not of this project, so it is not in the
repository's `gradle.properties`.
Set it in a user-level `~/.gradle/gradle.properties` or pass
`-Pandroid.overridePathCheck=true`.
+242
View File
@@ -0,0 +1,242 @@
// The app module.
//
// Two things ship inside the APK and neither is fetched at runtime: the kernel
// binary, and the built page it serves. Bundling the page is what makes the app
// work offline and start instantly; bundling the kernel is what lets it run
// `reasonix serve` on the phone itself, which is the whole point — the agent
// starts locally and reaches the server over SSH, so the interface, the model
// catalogue and the credentials all live on the phone.
//
// The kernel is placed as `jniLibs/arm64-v8a/libreasonix.so`, not under
// `assets/`. That is not a trick for its own sake: Android permits executing a
// file under `nativeLibraryDir` and nothing under `filesDir` has that
// guarantee, so a binary shipped the ordinary way would have to be copied out
// and made executable at runtime — which fails outright on a device mounted
// noexec, and fails confusingly on the ones that are merely unusual. `scripts/
// build-kernel.sh` writes it there; the name is fixed by the packaging rules.
plugins {
id "com.android.application"
id "org.jetbrains.kotlin.android"
}
// One version, read by the About screen and the update check.
def versionMajor = 2
def versionMinor = 0
def versionPatch = 0
def versionBuild = 1
// Where the two build inputs are expected. Both are produced from the checkout
// beside this module, by the scripts named in each message.
def upstream = rootProject.file("..")
def pageDir = new File(projectDir, "src/main/assets/page")
def kernelSo = new File(projectDir, "src/main/jniLibs/arm64-v8a/libreasonix.so")
android {
namespace "io.reasonix.mobile.app"
compileSdk 35
defaultConfig {
applicationId "io.reasonix.mobile.app"
minSdk 26
// 35: the API level this app is actually built and tested against.
//
// It was briefly lowered to 26 while chasing an install failure that
// turned out to be a package-name collision with an unrelated app on the
// user's phone — the signature error named the wrong cause. Nothing about
// the target level ever caused it, so it goes back to the level the code
// is really written for.
//
// One consequence to keep in mind: from 35, Android 15 forces the window
// edge-to-edge and ignores a request to lay out inside the safe area. The
// window therefore *is* drawn under the status bar and the home indicator,
// and the only correct answer is for the page to pad itself — which is
// what `MainActivity` feeds real inset values for, since a WebView does
// not implement `env(safe-area-inset-*)`.
targetSdk 35
versionCode versionMajor * 10000 + versionMinor * 100 + versionPatch * 10 + versionBuild
versionName "${versionMajor}.${versionMinor}.${versionPatch}"
// The kernel is built for arm64 only, so every other ABI would ship an
// APK that installs and then cannot start. Naming the one real ABI keeps
// the store listing and the installer honest.
ndk {
abiFilters "arm64-v8a"
}
resourceConfigurations += ["zh", "en"]
}
// Android identifies an app by package name *and* signing key: an APK signed
// with a different key is not an update, it is an impostor, and the
// installer refuses it with "the signature does not match the installed
// app". Gradle's default release key is the debug key in the user's home
// directory, which is per-machine, so a build made on a second computer
// could never be installed over one made on the first.
//
// Whoever ships a build therefore pins a key — and that key is theirs, not
// the project's. It lives beside the checkout, in `keystore/`, which is
// ignored; so does the file naming it. A checkout without either signs with
// the debug key instead, which is what a contributor building locally wants
// and is what the README tells them to expect. Point the build at a real key
// by writing `keystore/keystore.properties` (storeFile, storePassword,
// keyAlias) or by setting REASONIX_KEYSTORE / REASONIX_KEYSTORE_PASSWORD /
// REASONIX_KEY_ALIAS.
def keyProperties = new Properties()
def keyPropertiesFile = rootProject.file("keystore/keystore.properties")
if (keyPropertiesFile.exists()) {
keyPropertiesFile.withInputStream { keyProperties.load(it) }
}
def keyStorePath = keyProperties.getProperty("storeFile") ?: System.getenv("REASONIX_KEYSTORE")
def keyStorePassword = keyProperties.getProperty("storePassword") ?: System.getenv("REASONIX_KEYSTORE_PASSWORD")
// The key's own password is a separate fact from the store's. PKCS#12 keystores
// almost always use one password for both, so falling back to the store's
// password keeps those working — but assuming they are equal is how a keystore
// that separates them gets a build failure at signing time, after the work is
// done, with an error that does not say which of the two was wrong.
def keyPasswordValue = keyProperties.getProperty("keyPassword") ?: System.getenv("REASONIX_KEY_PASSWORD") ?: keyStorePassword
def keyAliasName = keyProperties.getProperty("keyAlias") ?: System.getenv("REASONIX_KEY_ALIAS")
def haveReleaseKey = keyStorePath && keyStorePassword && keyAliasName &&
rootProject.file(keyStorePath).exists()
signingConfigs {
if (haveReleaseKey) {
releaseKey {
storeFile rootProject.file(keyStorePath)
storePassword keyStorePassword
keyAlias keyAliasName
keyPassword keyPasswordValue
// v1 is not optional here. Android 7+ reads v2 and ignores v1, so
// a v2-only APK installs on a modern phone — and then fails on the
// compatibility layers older devices and HarmonyOS' 卓易通 run,
// which know only the JAR signature and report its absence as a
// signature *mismatch*. Signing both is what makes one APK install
// everywhere.
enableV1Signing = true
enableV2Signing = true
enableV3Signing = true
}
}
}
// Nothing native is compiled here — the one .so is a prebuilt the kernel
// script writes — so the external native build is left alone entirely.
buildTypes {
debug {
applicationIdSuffix ".debug"
versionNameSuffix "-debug"
// The debug key, always. Signing a debug build with the release key
// would mean anyone who can read the release keystore can produce a
// build that installs over the shipped one under the same signature,
// and it buys nothing: the debug package is suffixed `.debug`, so the
// two already install side by side.
}
release {
// The kernel is a stripped Go binary and the page is minified, but
// the Kotlin shell still benefits and shipping the shrunk build is
// how a size regression gets noticed.
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
// A release with no key of its own comes out *unsigned*
// (`app-release-unsigned.apk`). It used to fall back to the debug key
// so the artifact would install, which produced a release that looked
// shippable and was signed by a per-machine key nobody chose: it could
// not be installed over the previous build, and every machine made a
// different one. An unsigned artifact says what it is.
if (haveReleaseKey) {
signingConfig signingConfigs.releaseKey
} else {
logger.warn(
"release build has no signing key: producing an unsigned APK. " +
"Write keystore/keystore.properties (storeFile, storePassword, keyAlias) " +
"or set REASONIX_KEYSTORE / REASONIX_KEYSTORE_PASSWORD / REASONIX_KEY_ALIAS."
)
}
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
// AGP 8 stops generating BuildConfig unless asked. `Kernel` reads the
// version name and code out of it to decide whether the bundled page
// needs unpacking, so it is asked for.
buildConfig = true
}
packaging {
jniLibs {
// `extractNativeLibs=true`: the kernel must exist as a real file on
// disk to be executed, and an uncompressed-and-mmap'd .so is not
// one. This is the only reason the flag is on.
useLegacyPackaging true
}
resources {
excludes += ["META-INF/*.version", "META-INF/*.kotlin_module", "kotlin/**", "DebugProbesKt.bin"]
}
}
// The page is read out of assets by `Kernel.publish`, not by the resource
// system, so it is not compressed — a page is already-compressed text and
// shrinking it again only costs startup time.
androidResources {
noCompress += ["json", "woff2", "ttf", "woff", "txt", "svg"]
}
lint {
abortOnError true
warningsAsErrors false
checkReleaseBuilds true
}
}
dependencies {
implementation "androidx.core:core-ktx:1.15.0"
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.activity:activity-ktx:1.9.3"
implementation "androidx.webkit:webkit:1.12.1"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.7"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"
// Scanning the pairing code a computer shows.
//
// CameraX for the preview and the frame stream, ZXing for the decode, and
// deliberately not ML Kit: this app installs on phones whose HarmonyOS
// compatibility layer has no Google Play Services, so a decoder that needs
// GMS would work on the developer's phone and fail on the user's. ZXing
// core is pure Java with no runtime dependency at all, which is what makes
// the scanner work on both.
implementation "androidx.camera:camera-core:1.4.1"
implementation "androidx.camera:camera-camera2:1.4.1"
implementation "androidx.camera:camera-lifecycle:1.4.1"
implementation "androidx.camera:camera-view:1.4.1"
implementation "com.google.zxing:core:3.5.3"
}
// Fail before the packaging step with something a person can act on, rather
// than shipping an APK whose kernel is missing or whose page is a directory the
// build never filled. The two messages name the script that produces each.
tasks.register("requireBuildInputs") {
doFirst {
if (!kernelSo.exists()) {
throw new GradleException(
"内核二进制缺失:${kernelSo}\n先运行 ./scripts/build-kernel.sh(在 android/ 目录下)交叉编译内核。")
}
if (!pageDir.exists() || !new File(pageDir, "index.html").exists()) {
throw new GradleException(
"前端页面缺失:${pageDir}/index.html\n先运行 ./scripts/sync-page.sh(在 android/ 目录下)构建并同步页面。")
}
}
}
tasks.configureEach { task ->
if (task.name.startsWith("merge") && (task.name.contains("Assets") || task.name.contains("JniLibFolders"))) {
task.dependsOn("requireBuildInputs")
}
}
+32
View File
@@ -0,0 +1,32 @@
# R8 rules for the shell.
#
# There is very little to keep here, and that is the point: the app's own code
# is reached from the manifest and from Android's own callbacks, both of which
# R8 already traces, and nothing in it is loaded reflectively. What follows is
# the two places where a name is looked up rather than called.
# The WebView's JavaScript bridge, if one is ever added, is resolved by name on
# the Java side and by the string the page uses. Keeping annotated members makes
# that survive shrinking rather than failing at runtime with a method-not-found
# that only shows up on a release build.
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# The Kernel's child process is started by absolute path, and its own log is
# read as text; neither is reflective, so nothing else needs keeping.
# Line numbers make a stack trace from a release build readable, which is the
# only way a crash report from a phone can be acted on.
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
# Kotlin's `@Metadata` annotation is present on every class the compiler touched
# and nothing in this app reflects over it. It is tempting to `-assumenosideeffects`
# it away, and that was tried: any pattern broad enough to name its members also
# matches the methods every class inherits from `Object` — `equals`, `hashCode`,
# `toString` — and R8 then treats those as removable, which is a silent
# behaviour change for a few kilobytes. Left as a warning suppression instead:
# the annotation is dropped from the shrunk output anyway, because nothing reads
# it.
-dontwarn kotlin.Metadata
+112
View File
@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The shell's manifest.
There is no INTERNET-adjacent permission beyond INTERNET itself: the page and
the kernel talk over loopback, and reaching a server is the kernel's job over
a socket it owns. Reading and writing files goes through the system's document
picker (SAF), which hands back a grant for one file at a time — so no
storage permission is declared and none is needed.
`usesCleartextTraffic` is off here, and cleartext is re-enabled in the network
security config only because the kernel serves its page on `127.0.0.1` and a
paired computer serves the same page over its LAN address — both plain HTTP.
The config says what that costs and what bounds it.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Only to tell the person the kernel is still running while the screen is
off; it is a foreground service with a dismissible notification, not a
background wake-up. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- From API 34 every foreground-service *type* needs its own permission as
well as the general one: naming dataSync in the manifest without asking
for it is a SecurityException at startForeground, on the one code path
that keeps a long task alive. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Only to read the pairing code a computer shows. Requested at the
moment the person asks to scan, never at launch. -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- The kernel is a child process, and a task left running is one the person
expects to finish with the screen off. Android's own doze can freeze the
whole app group instead, which is what the shell asks to be exempt from
— once, when a turn is actually running, never at launch. It is a
request the person can refuse, and refusing it only costs battery life
during a long turn. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Every-file access, for opening a folder on this phone as a workspace.
The kernel is a plain Go binary: it opens paths with `open(2)`, and a
document URI is not a path it can open. SAF would hand back exactly
that unusable URI, so the one honest permission is the broad one —
granted by the person in system settings, which is the only place
Android offers it. Nothing here reads storage for any other purpose. -->
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<!-- Below API 33 the same folder walk needs the older pair; from 33 they are
superseded by the permission above and asking for them would be a lie. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<!-- A phone without a touchscreen cannot drive this interface; saying so
keeps the listing honest rather than letting it install on a TV. -->
<uses-feature android:name="android.hardware.touchscreen" android:required="true" />
<!-- The camera is optional: a phone without one is still a fine client for
a kernel it already knows, and it can still pair by opening the link the
computer shows. Requiring it would take the app off those devices. -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<application
android:name=".App"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Reasonix"
android:usesCleartextTraffic="false"
tools:targetApi="35">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode|density|fontScale|smallestScreenSize"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Reasonix">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- The kernel outlives the screen: a turn that takes minutes would
otherwise be killed when the phone is pocketed. The service is
`stopWithTask="false"` so swiping the app away ends it deliberately
rather than silently, and `foregroundServiceType="dataSync"` is the
closest honest description of "an agent is working". -->
<service
android:name=".KernelService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:stopWithTask="false" />
<!-- Reading the pairing code a computer shows. Its own activity rather
than a dialog, because the camera preview needs the whole screen to
be worth pointing at anything. Not exported: only this app starts
it, and a scanner another app could open is one that could be made
to read a code on someone else's behalf. -->
<activity
android:name=".ScanActivity"
android:exported="false"
android:screenOrientation="portrait"
android:theme="@style/Theme.Reasonix.Scan" />
</application>
</manifest>
@@ -0,0 +1,59 @@
package io.reasonix.mobile.app
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
/**
* The process-wide facts this app needs.
*
* The kernel is one child process, so there is exactly one of it no matter how
* many activities exist: it is held here, lazily, and the activity and the
* service both reach the same instance. Nothing else is stored — the theme, the
* language and every preference belong to the page and live in its localStorage.
*/
class App : Application() {
/**
* The kernel, created on first use.
*
* Lazy rather than eager so that merely launching the process to answer a
* notification does not unpack a 15MB page.
*/
val kernel: Kernel by lazy { Kernel(this) }
override fun onCreate() {
super.onCreate()
createChannel()
}
/**
* A channel the person can mute without muting the app, and one they can
* find: an agent that runs for minutes on a phone has to say so somewhere
* the system will not kill, and a notification is the only place Android
* offers. `IMPORTANCE_MIN` because the notification is a receipt, not an
* interruption — the work it reports is on another machine.
*/
private fun createChannel() {
// No SDK_INT guard: minSdk is 26, which is the release NotificationChannel
// arrived in, so every device this app installs on has it.
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_KERNEL) != null) return
val channel = NotificationChannel(
CHANNEL_KERNEL,
getString(R.string.channel_kernel),
NotificationManager.IMPORTANCE_MIN,
).apply {
description = getString(R.string.channel_kernel_desc)
setShowBadge(false)
enableVibration(false)
setSound(null, null)
}
manager.createNotificationChannel(channel)
}
companion object {
const val CHANNEL_KERNEL = "reasonix-kernel"
}
}
@@ -0,0 +1,699 @@
package io.reasonix.mobile.app
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.util.Log
import android.webkit.WebView
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
import java.io.OutputStream
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* The kernel's browser, as a loopback HTTP server.
*
* The Go kernel (`internal/platform/browser/shell.go`) drives a WebView by
* POSTing JSON to a handful of paths on 127.0.0.1. This class is the other end
* of that conversation: it accepts connections on a random port, parses the
* minimal HTTP the kernel sends, and translates each path into an operation on
* the overlay WebView.
*
* The server is deliberately tiny — no connection pooling, no keep-alive, no
* chunked encoding — because the kernel is its only client and sends one
* request per connection. A `ServerSocket` accept loop on a daemon thread is
* the whole transport; each request is handled synchronously on its own thread.
*
* WebView operations must run on the UI thread. Each handler posts to the main
* looper and waits on a latch, with a timeout that bounds the worst case (a
* page that hangs the JS thread) to something the kernel's own 30-second client
* timeout will not outlast.
*
* Memory note on `/screenshot`: a full-page `WebView.draw` into an ARGB_8888
* bitmap at device resolution can reach tens of MB for a long page. The bitmap
* is downsampled by a factor of 2 in each dimension before PNG encoding, which
* keeps the peak allocation under ~10 MB for a typical 1080p viewport. The
* original is recycled immediately after the downsample.
*/
/** One thing a page asked the network for, as the WebView's request
* interceptor reports it. The interceptor is handed the method and the URL
* and nothing about the response, so a caller may show exactly this much and
* must not read it as a completed exchange. */
data class BrowserRequest(val method: String, val url: String, val kind: String)
class BrowserShellServer(
/** The overlay WebView, when one already exists. The routes that act on an
* open page — back, forward, reload, close — use this: with no page there
* is nothing to act on, and creating one to do nothing to it would start a
* renderer nobody asked for. */
private val existingView: () -> WebView?,
/** The overlay WebView, created on first use. The routes that drive a page
* use this, because the kernel can open one before the panel has ever been
* shown: the page's own panel asks for the view when it is displayed, and
* an agent's `browser_open` can arrive first. Requiring the view to exist
* already answered every such call with "browser view not ready", which
* reads as a broken engine rather than as a view that was not created yet.
* Called on the UI thread. */
private val ensureView: () -> WebView,
/** Why the page in the view is the browser's own error page rather than a
* site, as `(url, chromium error)`; null when the last load succeeded.
* `/read` asks this, because extracting `innerText` from the error page
* returns the browser's own "this page could not be loaded" notice, which
* the kernel would hand to the model as though the site had said it. */
private val loadFailure: () -> Pair<String, String>? = { null },
/** Begin a navigation: forget the load failure recorded for the page being
* left, drop its request list, and start the wait a following `/read`
* joins. Called on the UI thread, before the new address is loaded, so the
* wait is in place before any callback for the new page can fire. */
private val startNavigation: () -> Unit = {},
/** What the page currently in the view has asked the network for, oldest
* first. The WebView reports a request's method and URL to its interceptor
* but nothing about the response, so that is all a caller may show. */
private val requests: () -> List<BrowserRequest> = { emptyList() },
/** Wait for the navigation last asked for to settle — the page arrived, or
* its load failed — up to the given milliseconds. Called on the request's
* own thread, never the UI thread: the settle is signalled from the UI
* thread, so waiting on it there would deadlock. `/load` starts a
* navigation and returns before the page has been visited; without this a
* `/read` right behind it still sees the URL of the page before, and a
* failure recorded for that page was handed back as the new one's answer. */
private val awaitNavigation: (Long) -> Unit = {},
) {
companion object {
private const val TAG = "BrowserShellServer"
/** How long a UI-thread operation may take before the handler gives up.
* The kernel's client timeout is 30 s; staying under it means the kernel
* sees a clean error rather than a dropped connection. */
private const val UI_TIMEOUT_MS = 25_000L
/** The maximum text `/read` returns. Matches the kernel's `maxReadText`
* (400 << 10). The shell truncates here and says so, rather than letting
* the kernel truncate silently. */
private const val MAX_READ_TEXT = 400 * 1024
/** Screenshot downsample factor. 2 means half width and half height. */
private const val SCREENSHOT_SCALE = 2
/** The address the kernel dials. Both ends must agree on the family:
* see [start] for what goes wrong when they do not. */
private const val LOOPBACK_V4 = "127.0.0.1"
/** How long `/read` waits for a navigation it follows to settle before
* reading whatever the view is on. A page that never finishes loading
* must not hold the tool call open to the kernel's own timeout. */
private const val NAV_SETTLE_MS = 10_000L
/** How long `/read` then waits for the page's own script to answer.
* `NAV_SETTLE_MS + READ_SCRIPT_MS` has to stay under [UI_TIMEOUT_MS],
* and that under the kernel's 30-second client timeout: a read that
* waited out both budgets would outlast the caller, which would see a
* dropped connection instead of this shell's answer. 10 + 15 = 25 s. */
private const val READ_SCRIPT_MS = 15_000L
}
@Volatile
private var serverSocket: ServerSocket? = null
/** The port the server is listening on. Valid after [start] returns. */
@Volatile
var port: Int = 0
private set
/**
* The bearer credential every request must carry. Minted by the activity
* and handed to the kernel through the child's environment, so the server
* answers only the process this shell started.
*
* A phone's loopback is not a boundary: any other app may connect to
* 127.0.0.1, and the routes here run script in and read text out of
* whatever page is loaded. Binding to `getLoopbackAddress()` keeps other
* machines out but not other apps, so the credential is what makes "only
* the kernel this shell started" true.
*/
@Volatile
var token: String = ""
private set
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Start listening on 127.0.0.1 with an ephemeral port. The port is available
* via [port] immediately after this returns, and the credential the kernel
* must present via [token].
*/
fun start() {
token = newToken()
// The loopback address is spelled out as IPv4, not taken from
// `InetAddress.getLoopbackAddress()`. That call is allowed to answer
// either family and Android's libcore answers IPv6 (`::1`), while the
// kernel's client is hardcoded to `http://127.0.0.1:<port>`
// (`internal/assembly/boot/browser.go`). A socket bound to `::1` does
// not answer a dial to `127.0.0.1`, so every browser call failed with
// "connection refused" while the port was bound and reported — the
// failure looked like a server that never started. Both ends have to
// name the same family, and IPv4 is the one the kernel names.
val ss = ServerSocket(0, 16, java.net.InetAddress.getByName(LOOPBACK_V4))
serverSocket = ss
port = ss.localPort
Log.i(TAG, "browser shell server listening on $LOOPBACK_V4:$port")
Thread({
while (!ss.isClosed) {
try {
val client = ss.accept()
Thread({ handleClient(client, token) }, "browser-shell-conn").apply { isDaemon = true }.start()
} catch (_: SocketException) {
// Server was closed; exit the loop.
}
}
}, "browser-shell-accept").apply { isDaemon = true }.start()
}
fun stop() {
runCatching { serverSocket?.close() }
serverSocket = null
}
/** A fresh 256-bit credential, hex-encoded, for one launch of the server. */
private fun newToken(): String {
val bytes = ByteArray(32)
java.security.SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
// ---- HTTP plumbing --------------------------------------------------------
private fun handleClient(client: Socket, expected: String) {
try {
client.soTimeout = 30_000
val input = BufferedInputStream(client.getInputStream())
val output = client.getOutputStream()
// Parse the request line.
val requestLine = readLine(input) ?: return
val parts = requestLine.split(" ", limit = 3)
if (parts.size < 2) return
val method = parts[0]
val path = parts[1].substringBefore('?')
// Read headers.
var contentLength = 0
var bearer: String? = null
while (true) {
val header = readLine(input) ?: break
if (header.isEmpty()) break
val lower = header.lowercase()
if (lower.startsWith("content-length:")) {
contentLength = header.substringAfter(':').trim().toIntOrNull() ?: 0
} else if (lower.startsWith("authorization:")) {
bearer = header.substringAfter(':').trim().removePrefix("Bearer ").trim()
}
}
// Nothing behind this server is safe to hand out on the strength of
// an address: the port is on loopback, which any other app on the
// phone can dial, and the routes run script in and read text out of
// the loaded page. The credential the kernel was started with is the
// only thing that says a caller is that kernel. Every route is
// refused without it, /health included — a probe needs no exemption.
if (expected.isEmpty() || bearer != expected) {
writeResponse(output, 401, """{"error":"unauthorized"}""")
return
}
// Read body.
val body = if (contentLength > 0) {
val buf = ByteArray(contentLength)
var read = 0
while (read < contentLength) {
val n = input.read(buf, read, contentLength - read)
if (n == -1) break
read += n
}
String(buf, 0, read, Charsets.UTF_8)
} else ""
// Route.
val (status, responseBody) = route(method, path, body)
writeResponse(output, status, responseBody)
} catch (e: Exception) {
Log.w(TAG, "connection error: ${e.message}")
} finally {
runCatching { client.close() }
}
}
private fun route(method: String, path: String, body: String): Pair<Int, String> {
return when {
method == "GET" && path == "/health" -> 200 to """{"ok":true}"""
method == "POST" && path == "/load" -> handleLoad(body)
method == "POST" && path == "/close" -> handleClose()
method == "POST" && path == "/read" -> handleRead()
method == "POST" && path == "/screenshot" -> handleScreenshot()
method == "POST" && path == "/click" -> handleClick(body)
method == "POST" && path == "/eval" -> handleEval(body)
method == "POST" && path == "/back" -> handleBack()
method == "POST" && path == "/forward" -> handleForward()
method == "POST" && path == "/reload" -> handleReload()
method == "POST" && path == "/requests" -> handleRequests()
else -> 404 to """{"error":"not found"}"""
}
}
private fun writeResponse(out: OutputStream, status: Int, body: String) {
val reason = when (status) {
200 -> "OK"
404 -> "Not Found"
500 -> "Internal Server Error"
else -> "Error"
}
val bytes = body.toByteArray(Charsets.UTF_8)
val header = "HTTP/1.1 $status $reason\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: ${bytes.size}\r\n" +
"Connection: close\r\n" +
"\r\n"
out.write(header.toByteArray(Charsets.UTF_8))
out.write(bytes)
out.flush()
}
private fun readLine(input: BufferedInputStream): String? {
val sb = StringBuilder()
while (true) {
val b = input.read()
if (b == -1) return if (sb.isEmpty()) null else sb.toString()
if (b == '\r'.code) {
val next = input.read()
if (next == '\n'.code) return sb.toString()
sb.append(b.toChar())
if (next != -1) sb.append(next.toChar())
continue
}
if (b == '\n'.code) return sb.toString()
sb.append(b.toChar())
}
}
// ---- endpoint handlers ----------------------------------------------------
/** POST /load {"url":"..."} → {"url":"...","title":"..."} */
private fun handleLoad(body: String): Pair<Int, String> {
val json = runCatching { JSONObject(body) }.getOrNull()
?: return 500 to """{"error":"invalid request body"}"""
val url = json.optString("url", "")
if (url.isEmpty()) return 500 to """{"error":"missing url"}"""
val result = AtomicReference("")
val latch = CountDownLatch(1)
mainHandler.post {
val wv = ensureView()
// The failure recorded for the page being left stops being this
// tab's, and the wait a following `/read` joins is armed before the
// new address is loaded, so no callback for the new page can fire
// ahead of it. `/load` answers before the page arrives, which is
// what left the old failure to be read back as the new page's.
startNavigation()
wv.loadUrl(url)
// The title and final URL are not available synchronously after
// loadUrl; answer with what was requested. The kernel stores this
// as the tab's URL and reads the real one on the next /read.
result.set(JSONObject().apply {
put("url", url)
put("title", "")
}.toString())
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout waiting for UI thread"}"""
}
return 200 to result.get()
}
/** POST /close {} → 200 */
private fun handleClose(): Pair<Int, String> {
val latch = CountDownLatch(1)
mainHandler.post {
val wv = existingView()
if (wv != null) {
wv.loadUrl("about:blank")
wv.clearHistory()
}
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
return 200 to "{}"
}
/**
* POST /read {} → {"title":"...","url":"...","text":"..."}
*
* Injects a script that extracts readable text from the page, preferring
* `<article>` or `<main>` and falling back to `body.innerText`. The result
* is truncated to 400 KB (matching the kernel's `maxReadText`); when
* truncated, a note is appended so the model knows the text was cut.
*/
private fun handleRead(): Pair<Int, String> {
// The page the caller just asked for has not necessarily been visited
// yet: `/load` returns as soon as it has started the navigation. Waiting
// here, off the UI thread, is what makes this read answer about that
// page instead of the one before it.
awaitNavigation(NAV_SETTLE_MS)
val result = AtomicReference("")
val latch = CountDownLatch(1)
mainHandler.post {
val wv = ensureView()
// A page that did not load is not content. Whether the view is on
// the browser's own error page is read from the view itself rather
// than from a remembered failure: a failure recorded for the page
// before a new navigation would otherwise be handed back as this
// page's answer, long after the page the caller asked for had
// loaded. The remembered failure is still what names the address
// that failed and why, since the error page's own URL does not.
if ((wv.url ?: "").startsWith("chrome-error:")) {
val (url, reason) = loadFailure() ?: ((wv.url ?: "") to "ERR_FAILED")
result.set(JSONObject().apply {
put("url", url)
put("title", "")
put("reason", reason)
put("text", "")
}.toString())
latch.countDown()
return@post
}
// The extraction script: prefer article/main for content pages,
// fall back to body. innerText preserves visual line breaks. It
// also stamps a ref on every element a person could act on, so the
// kernel can name one in an act step the way the desktop backend
// names an accessibility node. A ref already on an element is kept,
// so it stays the same across snapshots of one document; a fresh
// document starts the numbering over, and the kernel only accepts a
// ref from the snapshot it last read.
val script = """
(function(){
var seq = window.__rxRefSeq || 0;
var used = {};
var marked = document.querySelectorAll('[data-rx-ref]');
for (var i = 0; i < marked.length; i++) { used[marked[i].getAttribute('data-rx-ref')] = true; }
function roleOf(el) {
var tag = el.tagName.toLowerCase();
var role = (el.getAttribute('role') || '').toLowerCase();
if (role) return role;
if (tag === 'a') return 'link';
if (tag === 'button') return 'button';
if (tag === 'select') return 'combobox';
if (tag === 'textarea') return 'textbox';
if (tag === 'input') {
var t = (el.getAttribute('type') || 'text').toLowerCase();
if (t === 'checkbox' || t === 'radio' || t === 'submit' || t === 'button' || t === 'reset') return t;
return 'textbox';
}
return 'generic';
}
function nameOf(el) {
var n = el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('name') || '';
if (!n) { n = (el.innerText || el.textContent || '').trim(); }
return n.replace(/\s+/g, ' ').slice(0, 160);
}
var sel = 'a[href],button,input,select,textarea,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[contenteditable=true]';
var els = document.querySelectorAll(sel);
var elements = [];
for (var j = 0; j < els.length; j++) {
var el = els[j];
var ref = el.getAttribute('data-rx-ref');
if (!ref) {
do { seq++; ref = 'e' + seq; } while (used[ref]);
el.setAttribute('data-rx-ref', ref);
used[ref] = true;
}
var raw = (el.value != null && String(el.value) !== '') ? String(el.value).slice(0, 160) : '';
elements.push({ref: ref, role: roleOf(el), name: nameOf(el), value: raw});
}
window.__rxRefSeq = seq;
var el2 = document.querySelector('article') || document.querySelector('main') || document.body;
var text = el2 ? (el2.innerText || '') : '';
var title = document.title || '';
var url = location.href;
return JSON.stringify({title: title, url: url, text: text, elements: elements});
})()
""".trimIndent()
wv.evaluateJavascript(script) { value ->
// evaluateJavascript returns a JSON-encoded string (the result
// is double-encoded). Strip the outer quotes.
val parsed = parseJsResult(value)
if (parsed != null) {
try {
val obj = JSONObject(parsed)
var text = obj.optString("text", "")
if (text.length > MAX_READ_TEXT) {
text = text.substring(0, MAX_READ_TEXT) +
"\n\n[text truncated at 400 KB]"
}
result.set(JSONObject().apply {
put("title", obj.optString("title", ""))
put("url", obj.optString("url", ""))
put("text", text)
put("elements", obj.optJSONArray("elements") ?: JSONArray())
}.toString())
} catch (e: Exception) {
result.set(JSONObject().apply {
put("title", "")
put("url", wv.url ?: "")
put("text", parsed.take(MAX_READ_TEXT))
}.toString())
}
} else {
result.set(JSONObject().apply {
put("title", "")
put("url", wv.url ?: "")
put("text", "")
}.toString())
}
latch.countDown()
}
}
if (!latch.await(READ_SCRIPT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout waiting for page script"}"""
}
return 200 to result.get()
}
/**
* POST /screenshot {} → {"data":"<base64>","mime":"image/png"}
*
* Draws the WebView into a bitmap, downsamples by SCREENSHOT_SCALE to keep
* memory bounded, and encodes as PNG → base64. For a 1080×2400 viewport the
* full bitmap is ~10 MB; the downsampled one is ~2.5 MB. The full bitmap is
* recycled immediately after scaling.
*/
private fun handleScreenshot(): Pair<Int, String> {
val result = AtomicReference("")
val failed = AtomicBoolean(false)
val latch = CountDownLatch(1)
mainHandler.post {
val wv = ensureView()
try {
val width = wv.width.takeIf { it > 0 } ?: 1080
val height = wv.height.takeIf { it > 0 } ?: 1920
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
wv.draw(canvas)
// Downsample to bound the PNG size and the base64 string.
val scaled = Bitmap.createScaledBitmap(
bmp,
width / SCREENSHOT_SCALE,
height / SCREENSHOT_SCALE,
true,
)
if (scaled !== bmp) bmp.recycle()
val baos = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 85, baos)
scaled.recycle()
val b64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
result.set(JSONObject().apply {
put("data", b64)
put("mime", "image/png")
}.toString())
} catch (e: Exception) {
// A 200 with an error body would be read by the kernel as a
// screenshot whose data is empty, and it would build a `data:`
// URI out of nothing. The status is what tells it this failed.
result.set("""{"error":"screenshot failed: ${e.message}"}""")
failed.set(true)
}
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
if (failed.get()) return 500 to result.get()
return 200 to result.get()
}
/** POST /click {"selector":"..."} → {"ok":true|false} */
private fun handleClick(body: String): Pair<Int, String> {
val json = runCatching { JSONObject(body) }.getOrNull()
?: return 500 to """{"error":"invalid request body"}"""
val selector = json.optString("selector", "")
if (selector.isEmpty()) return 500 to """{"error":"missing selector"}"""
val result = AtomicReference("")
val latch = CountDownLatch(1)
mainHandler.post {
val wv = ensureView()
// Escape the selector for embedding in a JS string.
val escaped = selector.replace("\\", "\\\\").replace("'", "\\'")
val script = """
(function(){
try {
var el = document.querySelector('$escaped');
if (!el) return JSON.stringify({ok: false});
el.click();
return JSON.stringify({ok: true});
} catch(e) {
return JSON.stringify({ok: false});
}
})()
""".trimIndent()
wv.evaluateJavascript(script) { value ->
val parsed = parseJsResult(value)
result.set(parsed ?: """{"ok":false}""")
latch.countDown()
}
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
return 200 to result.get()
}
/** POST /eval {"script":"..."} → {"result":"..."} */
private fun handleEval(body: String): Pair<Int, String> {
val json = runCatching { JSONObject(body) }.getOrNull()
?: return 500 to """{"error":"invalid request body"}"""
val script = json.optString("script", "")
if (script.isEmpty()) return 500 to """{"error":"missing script"}"""
val result = AtomicReference("")
val latch = CountDownLatch(1)
mainHandler.post {
val wv = ensureView()
wv.evaluateJavascript(script) { value ->
// The kernel expects a plain string in "result". The value from
// evaluateJavascript is JSON-encoded; unwrap it.
val unwrapped = parseJsResult(value) ?: ""
result.set(JSONObject().apply {
put("result", unwrapped)
}.toString())
latch.countDown()
}
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout waiting for eval"}"""
}
return 200 to result.get()
}
/** POST /back → 200 */
private fun handleBack(): Pair<Int, String> {
val latch = CountDownLatch(1)
mainHandler.post {
existingView()?.let { if (it.canGoBack()) it.goBack() }
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
return 200 to "{}"
}
/** POST /forward → 200 */
private fun handleForward(): Pair<Int, String> {
val latch = CountDownLatch(1)
mainHandler.post {
existingView()?.let { if (it.canGoForward()) it.goForward() }
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
return 200 to "{}"
}
/** POST /reload → 200 */
private fun handleReload(): Pair<Int, String> {
val latch = CountDownLatch(1)
mainHandler.post {
val wv = existingView()
// A view sitting on the browser's own error page is not on the site,
// and reloading it would retry the error page itself. The address
// that failed is loaded again instead — which is the page a caller
// means by "reload" — read before `startNavigation` clears it.
val failed = if (wv != null && (wv.url ?: "").startsWith("chrome-error:"))
loadFailure()?.first else null
startNavigation()
if (wv != null) {
if (failed != null) wv.loadUrl(failed) else wv.reload()
}
latch.countDown()
}
if (!latch.await(UI_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
return 500 to """{"error":"timeout"}"""
}
return 200 to "{}"
}
/** POST /requests {} → {"requests":[{"method","url","kind"},…]} */
private fun handleRequests(): Pair<Int, String> {
val arr = JSONArray()
for (r in requests()) {
arr.put(JSONObject().apply {
put("method", r.method)
put("url", r.url)
put("kind", r.kind)
})
}
return 200 to JSONObject().apply { put("requests", arr) }.toString()
}
// ---- helpers ----------------------------------------------------------------
/**
* Unwrap the result of `evaluateJavascript`. The callback receives a
* JSON-encoded string: a JS string "hello" arrives as `"\"hello\""`, a
* number 42 as `"42"`, null as `"null"`. Strip the outer JSON encoding to
* get the raw value.
*/
private fun parseJsResult(value: String?): String? {
if (value == null || value == "null") return null
// The value is a JSON literal. If it is a JSON string, decode it.
return try {
if (value.startsWith("\"") && value.endsWith("\"")) {
JSONObject().apply { }.let {
// Use JSONObject's string decoding: wrap in an object.
JSONObject("""{"v":$value}""").optString("v", "")
}
} else {
value
}
} catch (_: Exception) {
value
}
}
}
@@ -0,0 +1,119 @@
package io.reasonix.mobile.app
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.os.Build
/**
* The phone's DNS servers, as the kernel needs them.
*
* The kernel is a CGO-free Go binary, and Go's own source explains why that
* matters:
*
* ```
* // DNS requests don't work on Android, so prefer the cgo resolver.
* // Issue #10714.
* case "android":
* ```
*
* A normal Android program resolves names through `getaddrinfo`, which reaches
* the `netd` daemon and gets the servers of the network that is actually up.
* A static binary has no cgo resolver, so Go reads `/etc/resolv.conf` instead —
* and Android has no such file. Go then falls back to `127.0.0.1:53`, where
* nothing listens, and every lookup fails: the setup screen reports
* "连接失败" for every provider, including the official ones, on a phone whose
* browser reaches them fine.
*
* So they are read here and handed to the kernel through `REASONIX_DNS`. This is
* the same source the system itself uses, so a VPN, a private DNS hostname, or a
* network that changed since boot is all reflected without this code knowing
* anything about them.
*/
object Dns {
/**
* The active network's servers, comma separated, or "" when there is none.
*
* Read from the *active* network rather than from every link, because a phone
* commonly has several at once — Wi-Fi, mobile data, a VPN — and the servers
* of a network that is not carrying traffic are the wrong ones to ask.
*/
fun servers(context: Context): String {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return ""
val properties: LinkProperties = runCatching { manager.activeNetwork }.getOrNull()
?.let { network -> runCatching { manager.getLinkProperties(network) }.getOrNull() }
?: return ""
return properties.dnsServers
.mapNotNull { address -> address.hostAddress }
.filter { it.isNotBlank() }
.filter { routable(it) }
.sortedBy { preference(it) }
.joinToString(",")
}
/**
* Whether an address can actually carry a query.
*
* A link-local address (fe80::/10) without its zone cannot: the packet has
* no interface to leave by, so it is dropped, nothing answers, and the
* lookup hangs until the kernel's own timeout expires — which is reported as
* a timeout rather than as a failure to resolve, and so reads like the
* network is slow instead of like the address is unusable.
*
* Android reports the zone as a `%wlan0` suffix on `hostAddress`, and the
* address is only usable with it kept. Nothing here trims it: an earlier
* version called `substringBefore('%')`, which turned a usable link-local
* server into an unroutable one.
*/
private fun routable(host: String): Boolean =
!(host.startsWith("fe80:", ignoreCase = true) && !host.contains('%'))
/**
* IPv4 first, then IPv6.
*
* A phone commonly reports both, and its IPv6 entry is often a router's
* link-local address — reachable on one network and not the next. The
* kernel tries the servers in the order it is given them, and stops at the
* first that answers; putting the address family that shares the network's
* own numbering first means its timeout is spent on a server that works.
*/
private fun preference(host: String): Int = if (host.contains(':')) 1 else 0
/**
* The private-DNS setting, for the one case the address list cannot answer.
*
* Since Android 9 a phone may use DNS-over-TLS, and then `dnsServers` holds a
* name rather than an address — a name this kernel cannot resolve *because*
* resolving it is the thing that is broken. There is no way for the shell to
* do that lookup for it, so this reports the situation instead of handing
* over an unusable value: the caller says so in the log, and the person can
* turn private DNS off or set it to automatic.
*/
fun looksLikeDot(servers: String): Boolean =
servers.isNotBlank() && servers.split(',').any { part ->
val host = part.trim()
host.isNotEmpty() && host.none { it == ':' } && !host.all { it.isDigit() || it == '.' }
}
/** Whether the system requires a VPN or private DNS, which changes nothing
* here but is worth saying in a bug report. */
fun describe(context: Context): String {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return "no connectivity service"
val network = runCatching { manager.activeNetwork }.getOrNull() ?: return "no active network"
val properties = runCatching { manager.getLinkProperties(network) }.getOrNull()
?: return "no link properties"
// The DHCP server arrived in API 30, not 23: asked for below that it is a
// NoSuchMethodError on a device this app supports (minSdk 26), and the
// answer is only ever read out of this string by a person debugging.
val dhcp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
properties.dhcpServerAddress?.hostAddress
} else {
null
}
return "iface=${properties.interfaceName} dns=${properties.dnsServers.joinToString(",") { it.hostAddress ?: "?" }}" +
(dhcp?.let { " dhcp=$it" } ?: "")
}
}
@@ -0,0 +1,87 @@
package io.reasonix.mobile.app
import android.content.Context
import android.net.Uri
import android.webkit.CookieManager
import android.widget.Toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.FileNotFoundException
import java.net.HttpURLConnection
import java.net.URL
/**
* Putting a file the page produced onto the phone's disk.
*
* A WebView has no downloads shelf and cannot write to the system's document
* picker itself, so the two halves are joined here: the page asks, the picker
* answers with a `content://` the app has been granted, and this copies the
* bytes across.
*
* The request carries the WebView's own cookie, read out of the jar the page
* authenticated in. That is the only reason this is not a plain `openStream`:
* the kernel's download route is behind the session the page established, and a
* shell that fetched without it would get a 403 and no explanation.
*/
object Downloader {
suspend fun run(
context: Context,
url: String,
userAgent: String,
mime: String,
name: String,
target: Uri,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 60_000
requestMethod = "GET"
if (userAgent.isNotBlank()) setRequestProperty("User-Agent", userAgent)
// The download route answers with the same session cookie the
// page is using; without it the kernel refuses the request.
cookieFor(url)?.let { setRequestProperty("Cookie", it) }
}
try {
val code = connection.responseCode
if (code !in 200..299) {
throw IllegalStateException(
context.getString(R.string.download_failed_status, code),
)
}
val resolver = context.contentResolver
val output = resolver.openOutputStream(target, "w")
?: throw FileNotFoundException(target.toString())
output.use { out ->
connection.inputStream.use { input -> input.copyTo(out) }
}
// `copyTo` answers with a byte count; what the caller wants to
// know is that there were no bytes missing, not how many.
Unit
} finally {
connection.disconnect()
}
}
}
/**
* The cookie the page authenticated with, if any.
*
* `CookieManager` is the WebView's own store, so this asks the exact jar the
* request will be checked against rather than a copy this shell kept.
*/
private fun cookieFor(url: String): String? =
runCatching { CookieManager.getInstance().getCookie(url) }.getOrNull()?.takeIf { it.isNotBlank() }
/** A short receipt for a tap that otherwise does nothing visible. */
fun report(context: Context, name: String, result: Result<Unit>) {
val message = result.fold(
onSuccess = { context.getString(R.string.download_saved, name) },
onFailure = { failure ->
context.getString(R.string.download_failed, failure.message ?: "")
},
)
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
}
@@ -0,0 +1,741 @@
package io.reasonix.mobile.app
import android.content.Context
import android.content.res.AssetManager
import org.json.JSONArray
import org.json.JSONObject
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.SecureRandom
import java.util.Locale
/**
* The Reasonix kernel, as this app runs it: one child process serving loopback,
* plus the two things it needs on disk to start — the page it serves and a
* credential for this launch.
*
* The shape is the desktop host's (`cmd/reasonix-studio-host`), narrowed to what
* a phone does not have. That host binds a socket, mints a credential, speaks a
* handshake down a pipe and holds a lease; here the credential goes through
* `--token-file` and the bound address through `--port-file`, because a phone's
* app process has no pipe to hand a child and no reason to care which port was
* free. Everything after that — the kernel's own gate, its auth middleware, its
* CSRF check — is untouched, so the security of the surface is the one upstream
* tested, not one this shell invented.
*
* One instance lives in the app process. The activity asks it for a URL; the
* foreground service asks it to stay alive. Both see the same process, so there
* is nothing to synchronise beyond a lock on start/stop.
*/
class Kernel(private val context: Context) {
/** Where everything the kernel owns lives. Nothing here is backed up. */
private val home = File(context.filesDir, "home")
/**
* The folder the agent works in, which is not the folder the kernel lives in.
*
* These used to be the same directory, and that is what made the workbench
* unreadable: the kernel's home holds `config.toml`, `projects/`, `cache/`,
* `stats/` and the rest of its own bookkeeping, and a workspace pointed at
* it shows all of that as if the person had put it there. Reported as "why
* does the default workspace have so many files, the desktop version does
* not generate these" — the desktop does generate them, in a different
* directory, because its workspace is whatever folder it was opened on.
*
* Split apart, the workbench shows what the agent produced and nothing else.
* `write_roots` follows the workspace, so the file tools stay confined to
* the folder the person actually works in rather than to the kernel's own
* home — which is the stricter answer, and the one the desktop already has.
*/
private val workspaceDir = File(context.filesDir, "workspace")
/**
* The directory the kernel calls `REASONIX_HOME`, for the one caller that
* has to put a file where the kernel can read it — a private key the person
* picked from their Documents folder.
*/
fun homeDir(): File = home
/** The folder the agent works in. */
fun workspaceDir(): File = workspaceDir
/** The built page, unpacked once per version. */
private val pageDir = File(context.filesDir, "page")
private val tokenFile = File(home, "remote/launch.token")
private val portFile = File(home, "remote/serve.port")
private val pidFile = File(home, "remote/serve.pid")
private val lock = Any()
@Volatile
private var process: Process? = null
/** The port the shell's browser HTTP server listens on, set by the
* activity before `start()` is called. Zero means no server is up, and
* the kernel leaves its browser tools suspended (the correct degradation
* on a phone with no Chromium). */
@Volatile
var browserShellPort: Int = 0
/** The bearer credential for that server, set beside the port. Sent on
* every browser call so the server can refuse anything that did not come
* from the kernel this shell started — loopback on a phone is reachable
* by every other app. Empty when there is no server. */
@Volatile
var browserShellToken: String = ""
/** What the WebView should load, once the kernel has bound a port. */
@Volatile
var origin: String? = null
private set
/**
* The launch credential, as the URL fragment the page trades for a cookie.
* Kept in memory only: the file on disk belongs to the kernel's own
* bookkeeping, and a phone's app data is not a place to leave a second copy.
*/
@Volatile
private var token: String? = null
/**
* The URL the WebView loads first. It carries the credential in the
* fragment, which the page exchanges for an HttpOnly cookie before it makes
* any request — the same path a shared `reasonix web` link takes, so the
* token never enters a request line, a history entry or a log.
*/
fun entryUrl(): String? {
val origin = origin ?: return null
val token = token ?: return null
return "$origin/#token=$token"
}
/** The plain origin, for reloads once the cookie is set. */
fun plainUrl(): String? = origin
fun isRunning(): Boolean = process?.isAlive == true
/**
* Bring the kernel up, or answer the URL of the one already up.
*
* Starting is idempotent by intent: the activity and the service both call
* this, and whichever arrives second joins the running kernel instead of
* racing it for the port.
*/
suspend fun start(): Result<String> = withContext(Dispatchers.IO) {
synchronized(lock) {
origin?.let { return@withContext Result.success(it) }
runCatching {
val binary = kernelBinary()
publishPage()
prepareHome()
val launchToken = mintToken()
token = launchToken
val child = launch(binary, launchToken)
// The port is the kernel's answer, not this shell's guess. It
// writes the file after binding, so waiting on the file is
// waiting on the bind.
val bound = awaitPort(child)
val url = "http://$bound"
// A kernel that bound a port and then died would look healthy
// here and broken in the WebView; the two facts are checked
// together, and only a kernel that is answering is adopted.
awaitServing(child, url)
origin = url
process = child
url
}.onFailure { origin = null; process = null }
}
}
/**
* Stop the kernel.
*
* The graceful signal first: the kernel drains its listeners and closes the
* runtimes it opened, which is what lets a later launch adopt the sessions
* rather than find them locked. `destroyForcibly` is the second attempt, and
* only after a wait — a phone that is being shut down should not keep a
* half-killed agent.
*/
fun stop() {
synchronized(lock) {
val p = process ?: return
process = null
origin = null
token = null
runCatching {
p.destroy()
if (!p.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)) {
p.destroyForcibly()
}
}
portFile.delete()
pidFile.delete()
}
}
// ---- the two inputs that ship inside the APK -------------------------
/**
* The kernel binary.
*
* It is packaged as a native library, so Android has already extracted it to
* a directory it allows execution from and given it the executable bit —
* which is the one thing a file under app storage does not get for free.
*/
private fun kernelBinary(): File {
val dir = File(context.applicationInfo.nativeLibraryDir)
val binary = File(dir, "libreasonix.so")
if (!binary.exists()) {
throw IllegalStateException(
"内核未随 APK 安装:${binary.path}\n这是一次构建问题,请重新安装完整 APK。",
)
}
return binary
}
/**
* Write the bundled page where the kernel can serve it.
*
* It goes to app storage rather than being served out of the APK because the
* kernel's `--page` is a directory on a real filesystem; there is no
* archive-aware variant, and adding one would be a change to the kernel for
* this shell's convenience alone.
*
* Unpacking is versioned: the marker holds the app's version name, so an
* update replaces the page and a relaunch does not. A partially written
* directory — the app killed mid-copy — is caught by the same marker never
* having been written, so the next launch rewrites it.
*/
private fun publishPage() {
val marker = File(pageDir, ".unpacked")
val stamp = "${BuildConfig.VERSION_NAME}:${BuildConfig.VERSION_CODE}"
if (marker.isFile && marker.readText().trim() == stamp) return
pageDir.deleteRecursively()
pageDir.mkdirs()
copyAssetTree(context.assets, "page", pageDir)
if (!File(pageDir, "index.html").isFile) {
throw IllegalStateException("随 APK 分发的页面不完整:缺少 index.html")
}
marker.writeText(stamp)
}
/**
* A directory tree out of assets.
*
* `AssetManager.list` on a file answers with an empty array, so a "file" is
* told from a directory by trying to open it — which is the only signal the
* API offers and the reason this is not a one-liner.
*/
private fun copyAssetTree(assets: AssetManager, from: String, to: File) {
val children = assets.list(from).orEmpty()
if (children.isEmpty()) {
// A leaf: open it as a stream. A directory that happens to be empty
// is indistinguishable and harmless to skip.
runCatching {
assets.open(from).use { input ->
FileOutputStream(to).use { output -> input.copyTo(output) }
}
}
return
}
to.mkdirs()
for (child in children) {
copyAssetTree(assets, "$from/$child", File(to, child))
}
}
/**
* Prepare the kernel's home.
*
* `REASONIX_HOME` is what every path in the kernel hangs off — config,
* sessions, the remote state directory its token file lands in — so pointing
* it at app storage is what keeps a phone's Reasonix separate from anything
* else and away from global storage entirely.
*/
private fun prepareHome() {
File(home, "remote").mkdirs()
// The workspace directory not existing yet is what marks the first run
// after this build split it from the kernel's home. That is the one
// moment a migration is due, and doing it here means it happens before
// the kernel is asked for anything.
val firstSplit = !workspaceDir.exists()
workspaceDir.mkdirs()
if (firstSplit) {
adoptSessionsFromHome()
}
// Every launch, not only the first. The rewrite is cheap and
// idempotent, and an entry that survived the first pass would otherwise
// survive forever: `firstSplit` is spent the moment the directory
// exists, so a build that cleaned the list once and missed leaves the
// home on the sidebar for good. Running it unconditionally costs one
// small read and makes the whole thing self-correcting.
forgetHomeFromWorkspaceList()
}
/**
* Drop the kernel's own home from the sidebar's remembered projects.
*
* The list is written whenever a pane opens, and until this build the pane
* opened on the home itself — so an upgraded install carries a "project"
* that is really the kernel's own directory. Leaving it there would put the
* person back where they started: one tap on it and the workbench is full
* of `config.toml`, `cache/` and `projects/` again, which is the complaint
* this whole change answers. Only that one path is removed; every folder
* someone added on purpose stays.
*
* Parsed rather than pattern-matched, because the file's two shapes — a bare
* array of paths and an object with `paths` and `launch` — both have to keep
* whatever else they hold.
*/
private fun forgetHomeFromWorkspaceList() {
val file = File(home, "serve-workspaces.json")
if (!file.isFile) return
val text = runCatching { file.readText() }.getOrNull() ?: return
val wrong = runCatching { homeNames() }.getOrNull() ?: return
val rewritten = runCatching { withoutHome(text, wrong) }.getOrNull() ?: return
if (!rewritten.dropped) return
runCatching { file.writeText(rewritten.text) }
.onSuccess { Log.i(TAG, "dropped the kernel home from the project list") }
.onFailure { Log.w(TAG, "could not rewrite the project list: ${it.message}") }
}
/**
* Every name of the kernel's home on this device — not just the one this
* process happens to write.
*
* Android shows one directory under two names: `/data/data/<pkg>` and
* `/data/user/0/<pkg>` are two mounts of the same tree, the same inode
* either way, and which name a path carries depends on how it was written
* rather than on where it points. The list is written by the kernel, whose
* working directory this shell set from a path it built itself; a lookup
* that compared against only one spelling would silently keep the entry
* this exists to remove. Both names are compared, so either spelling is
* recognised.
*/
private fun homeNames(): Set<String> {
val path = home.absolutePath
val names = mutableSetOf(path)
val user = "/data/user/0/"
val data = "/data/data/"
when {
path.startsWith(user) -> names.add(data + path.removePrefix(user))
path.startsWith(data) -> names.add(user + path.removePrefix(data))
}
return names
}
/** What a rewrite produced, and whether it actually removed anything. */
private class Rewrite(val text: String, val dropped: Boolean)
/**
* The list without the home, in whichever shape it arrived, and whether
* anything was actually dropped from it.
*
* The flag matters because `JSONObject` re-serialises: it escapes every
* `/` and does not preserve key order, so a round trip never reproduces
* the original bytes. Deciding to write on "the text differs" alone would
* therefore rewrite the kernel's file on every single launch, log a drop
* that never happened, and race the kernel for a file it also writes.
*/
private fun withoutHome(text: String, wrong: Set<String>): Rewrite {
if (text.trimStart().startsWith("[")) {
val paths = JSONArray(text)
val kept = JSONArray()
var dropped = false
for (i in 0 until paths.length()) {
val path = paths.optString(i)
if (path in wrong) {
dropped = true
continue
}
kept.put(path)
}
return if (dropped) Rewrite(kept.toString(), true) else Rewrite(text, false)
}
val list = JSONObject(text)
val kept = JSONArray()
var dropped = false
val paths = list.optJSONArray("paths")
if (paths != null) {
for (i in 0 until paths.length()) {
val path = paths.optString(i)
if (path in wrong) {
dropped = true
continue
}
kept.put(path)
}
if (dropped) list.put("paths", kept)
}
if (list.optString("launch") in wrong) {
list.remove("launch")
dropped = true
}
return if (dropped) Rewrite(list.toString(), true) else Rewrite(text, false)
}
/**
* Move the conversations that were filed under the kernel's own home.
*
* The transcript store is keyed by the workspace root — one directory per
* project, under `<state>/projects/<slug>/sessions` — and until this build
* the root was the home itself. Splitting the two therefore moves every
* existing conversation to a slug nothing looks under, which reads as "my
* history is gone" rather than as a reorganisation. So the old directory is
* renamed onto the new slug once.
*
* Renaming the project directory rather than just `sessions/` carries the
* rest of it too: `memory/` is filed the same way, and a project's memory
* lost while its transcripts were kept would be a stranger failure than
* either alone.
*
* The slug is the absolute path with its separators flattened, which is
* what `config.WorkspaceSlug` does on the kernel's side. Only the home's own
* slug is touched: a slug for some other folder is left exactly where it is,
* because those are projects someone added on purpose.
*/
private fun adoptSessionsFromHome() {
val projects = File(home, "projects")
if (!projects.isDirectory) return
val from = projects.resolve(slugOf(home.absolutePath))
if (!from.isDirectory) return
val to = projects.resolve(slugOf(workspaceDir.absolutePath))
// A target already there means a previous run half-finished, or the
// person opened this folder before. Either way its contents win:
// merging two transcript directories is not a thing this can do safely,
// and the old one is still on disk to be looked at.
if (to.exists()) {
Log.i(TAG, "${to.name} already exists; leaving ${from.name} in place")
return
}
if (!from.renameTo(to)) {
Log.w(TAG, "could not move ${from.name} to ${to.name}; history stays under the old slug")
} else {
Log.i(TAG, "moved conversations from ${from.name} to ${to.name}")
}
}
/** The kernel's `config.WorkspaceSlug`, for the two paths that matter here. */
private fun slugOf(path: String): String = path.replace('/', '-').replace('\\', '-').replace(':', '-')
/**
* The launch credential.
*
* Minted here rather than read from configuration: a credential a person can
* set is one anything that can read their files can present. 32 bytes, hex,
* the same shape the desktop host mints.
*/
private fun mintToken(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
val token = bytes.joinToString("") { "%02x".format(it) }
tokenFile.parentFile?.mkdirs()
tokenFile.writeText("$token\n")
// 0600 by intent, and the app's own private directory means it is
// already unreachable to every other app on the device.
runCatching {
tokenFile.setReadable(false, false)
tokenFile.setReadable(true, true)
tokenFile.setWritable(false, false)
tokenFile.setWritable(true, true)
}
return token
}
/**
* Start the child.
*
* `--addr 127.0.0.1:0` lets the kernel pick a free port; `--port-file`
* makes it announce which. `--auth token` is the mode a phone needs: the
* page authenticates, and a state-changing request without the credential
* is refused. `--no-open` is belt and braces — a serve never opens a
* browser, and a phone has no browser to open.
*/
private fun launch(binary: File, token: String): Process {
portFile.delete()
pidFile.delete()
// The folder the agent works in, resolved before the command line is
// built so it can be named on it. See the note under the environment
// block for why naming it is not the same as setting the directory.
val workspace = launchWorkspaceDir()
val command = listOf(
binary.absolutePath,
"serve",
"--addr", "127.0.0.1:0",
"--auth", "token",
"--token-file", tokenFile.absolutePath,
"--port-file", portFile.absolutePath,
"--pid-file", pidFile.absolutePath,
"--no-open",
"-page", pageDir.absolutePath,
// The project root, said outright. Without this the kernel derives
// it from its working directory, and on this phone the two did not
// agree: the sidebar named the shared folder, `write_roots` confined
// the file tools to the app's own directory, and every write into the
// folder the person was looking at came back
// `workspace.write_outside_scope`. A flag is a fact the kernel was
// told rather than one it inferred from a mount it sees differently.
"--dir", workspace.absolutePath,
)
val builder = ProcessBuilder(command)
builder.environment().apply {
put("REASONIX_HOME", home.absolutePath)
put("HOME", home.absolutePath)
// The kernel's own language, for its tool failures, approval
// prompts and error sentences. Without this it detects from the
// POSIX locale variables, and Android sets none of them for an
// app: the kernel then answers in English while the page in front
// of the person is Chinese, and every sentence added for a Chinese
// reader was dead text. The tag, not the bare language, so a
// Traditional Chinese device resolves to zh-TW rather than zh.
// An explicit `language` in the kernel's config.toml still wins:
// DetectLanguage applies the configured value over this.
val languageTag = Locale.getDefault().toLanguageTag()
if (languageTag.isNotBlank() && languageTag != "und") {
put("REASONIX_LANG", languageTag)
}
// Android has no /tmp. Everything that wants a scratch directory —
// the shell tool, tool output spooling, sqlite journals — reads this.
put("TMPDIR", context.cacheDir.absolutePath)
// The phone's own DNS servers. A CGO-free Go binary cannot read them
// for itself — Android keeps them in `netd`, and Go looks for a
// `/etc/resolv.conf` that does not exist — so without this the kernel
// resolves nothing and every provider probe reports "连接失败" on a
// device whose browser reaches the same address fine.
val dns = Dns.servers(context)
if (dns.isNotBlank()) {
put("REASONIX_DNS", dns)
Log.i(TAG, "kernel DNS: $dns (${Dns.describe(context)})")
if (Dns.looksLikeDot(dns)) {
// Private DNS (DNS-over-TLS) is on, and it hands back a
// hostname this kernel cannot resolve by definition. Saying
// so here is what turns "连不上" into something actionable.
Log.w(TAG, "kernel DNS looks like a private-DNS hostname; turn private DNS off or set it to automatic")
}
} else {
Log.w(TAG, "kernel DNS: none reported by the system")
}
// The shell's browser server, when one is listening. The kernel
// reads this to bind its browser tools to the shell's WebView
// instead of looking for a Chromium binary that does not exist on
// a phone. When absent (zero), the variable is not set and the
// kernel keeps the browser tools suspended — the correct
// degradation for a phone with no engine to drive.
val shellPort = browserShellPort
if (shellPort > 0) {
put("REASONIX_SHELL_BROWSER_PORT", shellPort.toString())
// The credential the server checks. Set only alongside the
// port: a token with nothing listening would be a variable the
// kernel carries for no reason.
val shellToken = browserShellToken
if (shellToken.isNotBlank()) {
put("REASONIX_SHELL_BROWSER_TOKEN", shellToken)
}
}
}
// The agent's folder, not the kernel's. `REASONIX_HOME` above is still
// where the config, the sessions and the transcripts live; this is what
// the workspace root resolves to, because the kernel reads the working
// directory for it.
//
// The folder the person last opened, when it is still usable — the same
// answer the desktop host gives (`launchWorkspace`), and the reason a
// phone session used to work in the app's own `files/workspace` while
// the sidebar said it was in the shared folder: the launch entry in
// `serve-workspaces.json` was written and then ignored, and `write_roots`
// is derived from the working directory, so the file tools were confined
// to the app's private folder and every write into the shared one was
// refused. `workspaceDir` is still the answer for a first run and for a
// folder since deleted, but the choice is never silent — see
// [launchWorkspaceDir]. `workspace` was resolved above so it could also
// be named on the command line.
builder.directory(workspace)
// `PWD` is what the kernel's own `os.Getwd` prefers: Go uses it whenever
// it names the same directory as `.`, which is exactly this case, and it
// is the only way to make the kernel resolve its root to the string this
// shell chose. Without it `os.Getwd` falls back to the kernel's view of
// the mount, and on Android `/storage/emulated/0/...` and the path the
// FUSE layer reports for it need not be spelled the same way. The kernel
// derives `write_roots` from that root, so the two spellings have to be
// the same one.
builder.environment().put("PWD", workspace.absolutePath)
builder.redirectErrorStream(true)
val process = builder.start()
// The kernel's own log is the only place a startup failure says why, and
// a phone has no terminal to read. It is drained on a thread and kept
// ring-buffered so the About screen can show the last of it.
Thread({ drain(process) }, "kernel-log").apply { isDaemon = true }.start()
return process
}
/**
* The folder the person last opened, or null when there is not one.
*
* `serve-workspaces.json` is the kernel's own record of the projects the
* sidebar lists, and its `launch` entry is which of them a session starts
* in. Both shapes the file has are read — a bare array of paths, and an
* object with `paths` and `launch` — because both are written in the wild
* and a reader that only knew one would silently fall back.
*
* The entry has to be one of the listed paths, which is the same rule the
* kernel applies when it reads this file (`workspacelist.Read` blanks a
* `launch` that is not in `paths`). It matters more here than there: the
* working directory decides `write_roots`, so whatever this answers is
* where the agent's file tools may write — and on a phone that permission
* reaches all of shared storage. A file with a `launch` outside its own
* list is one this shell did not write, and it gets the listed answer.
*
* Null for anything unusable: no file, unreadable JSON, a launch that is
* not among the listed folders, an entry that has since been deleted, or a
* path that is now a file. Every one of those means the same thing to the
* caller — start where this shell always started.
*/
private fun rememberedLaunchDir(): File? {
val file = File(home, "serve-workspaces.json")
if (!file.isFile) return null
val text = runCatching { file.readText() }.getOrNull() ?: return null
val launch = runCatching {
// A bare array is the file's other shape — the one older kernels
// wrote, and therefore the one a person upgrading is most likely to
// have. Without this branch `JSONObject(text)` throws on it and the
// whole function falls back, which is the same mismatch (the
// sidebar naming one folder, the session working in another) this
// exists to fix. `withoutHome` below reads both shapes the same way.
if (text.trimStart().startsWith("[")) {
val arr = JSONArray(text)
if (arr.length() == 0) return@runCatching null
arr.optString(0)
} else {
val json = JSONObject(text)
val paths = json.optJSONArray("paths")
?.let { arr -> (0 until arr.length()).mapNotNull { arr.optString(it).takeIf(String::isNotBlank) } }
.orEmpty()
if (paths.isEmpty()) return@runCatching null
json.optString("launch").takeIf { it.isNotBlank() && it in paths } ?: paths.first()
}
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return null
val dir = runCatching { File(launch) }.getOrNull() ?: return null
if (!dir.isDirectory) {
// A launch entry this process cannot stat is exactly the failure the
// caller exists to avoid, and falling back silently is what made it
// take three releases to notice: the session opened in the app's own
// folder while the sidebar named the shared one. A phone has no
// terminal, so the log is the only place the reason can be said —
// and `exists`/`readable` tell apart "deleted" from "not permitted",
// which need different answers from the person.
Log.w(
TAG,
"launch entry $launch is not a usable directory " +
"(exists=${dir.exists()}, readable=${dir.canRead()})",
)
return null
}
return dir
}
/**
* The folder the kernel should start in.
*
* Either the launch entry — the folder the person last opened, which is
* also the one the sidebar shows — or the app's own folder when there is
* none. Whichever it is, it is logged, because the two disagreeing is the
* bug this exists to fix and a silent choice is what let it survive.
*/
private fun launchWorkspaceDir(): File {
val remembered = rememberedLaunchDir()
if (remembered != null) {
Log.i(TAG, "kernel workspace: ${remembered.absolutePath} (the remembered launch project)")
return remembered
}
Log.i(TAG, "kernel workspace: ${workspaceDir.absolutePath} (no remembered launch project)")
return workspaceDir
}
private val logLines = ArrayDeque<String>()
private fun drain(process: Process) {
runCatching {
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
Log.i(TAG, line)
synchronized(logLines) {
logLines.addLast(line)
while (logLines.size > LOG_KEEP) logLines.removeFirst()
}
}
}
}
}
/** The kernel's tail, newest last, for a bug report or the About screen. */
fun recentLog(): String = synchronized(logLines) { logLines.joinToString("\n") }
private fun awaitPort(process: Process): String {
val deadline = System.currentTimeMillis() + START_TIMEOUT_MS
while (System.currentTimeMillis() < deadline) {
if (!process.isAlive) {
throw IllegalStateException("内核启动即退出。\n${recentLog().takeLast(2000)}")
}
val text = if (portFile.isFile) runCatching { portFile.readText().trim() }.getOrNull() else null
// The file is written by the kernel on the way to serving, and the
// kernel rewrites it with the real port when it was given :0.
if (!text.isNullOrBlank() && text.contains(":")) return text
Thread.sleep(POLL_MS)
}
throw IllegalStateException("内核在 ${START_TIMEOUT_MS / 1000}s 内没有报告监听地址。\n${recentLog().takeLast(2000)}")
}
/**
* Wait until the bound port answers.
*
* A port that is open but not yet serving is the normal first second of a
* kernel's life; a WebView pointed at it would show a connection error and
* cache the failure, so the wait belongs here rather than in the page.
*/
private fun awaitServing(process: Process, origin: String) {
val deadline = System.currentTimeMillis() + START_TIMEOUT_MS
while (System.currentTimeMillis() < deadline) {
if (!process.isAlive) {
throw IllegalStateException("内核在开始服务前退出。\n${recentLog().takeLast(2000)}")
}
if (probe("$origin/")) return
Thread.sleep(POLL_MS)
}
throw IllegalStateException("内核已监听 $origin,但页面没有响应。\n${recentLog().takeLast(2000)}")
}
private fun probe(url: String): Boolean = runCatching {
(URL(url).openConnection() as HttpURLConnection).run {
connectTimeout = 1000
readTimeout = 1000
requestMethod = "GET"
try {
responseCode in 200..399
} finally {
disconnect()
}
}
}.getOrDefault(false)
private companion object {
const val TAG = "ReasonixKernel"
const val LOG_KEEP = 400
const val POLL_MS = 120L
const val START_TIMEOUT_MS = 30_000L
}
}
@@ -0,0 +1,139 @@
package io.reasonix.mobile.app
import android.Manifest
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Keeps the kernel alive while the app is not on screen.
*
* The reason this exists at all: an agent turn that takes minutes is a normal
* turn, and Android reclaims a backgrounded app's child process within seconds
* of the screen going off. Without the service, pocketing the phone would kill
* a running task — the one failure mode that makes a phone a bad client for
* something that works on its own.
*
* A foreground service needs a notification the person can see, which is also
* the honest thing to show: the kernel is running and what it is doing lives on
* another machine.
*/
class KernelService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Promoted before `start()`: a service that starts work first and
// notifies after has a window in which the system may kill it, and the
// work it would have been protecting is exactly what is being started.
startForegroundNotification()
val kernel = (application as App).kernel
scope.launch {
kernel.start().onFailure { failure ->
// A kernel that will not start is a fact the person needs, and a
// notification that only ever said "running" would be a lie.
notify(SERVICE_NOTICE, getString(R.string.notify_failed_title), failure.message.orEmpty())
stopSelf()
}
}
// Sticky, so a kernel killed under memory pressure comes back when there
// is room — which is what a person who left a task running expects.
return START_STICKY
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun startForegroundNotification() {
val notification = buildNotification(
getString(R.string.notify_running_title),
getString(R.string.notify_running_body),
)
// `dataSync` matches the manifest's declaration. On 34+ the type is
// mandatory and a mismatch throws, so it is named in both places.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceCompat.startForeground(
this,
SERVICE_NOTICE,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
startForeground(SERVICE_NOTICE, notification)
}
}
private fun buildNotification(title: String, body: String): Notification {
val open = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, App.CHANNEL_KERNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setContentIntent(open)
// The notification is a receipt, not an alarm; swiping it away
// leaves the kernel running, which is what the person meant by
// swiping it.
.setOngoing(false)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
}
private fun notify(id: Int, title: String, body: String) {
// The notification permission is granted at runtime from Android 13 and
// the person can refuse it. Without this check the call throws
// SecurityException and takes the service down with it — the opposite of
// what a notification that could not be shown should do. Below 13 the
// permission does not exist and posting always succeeds.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
return
}
runCatching {
NotificationManagerCompat.from(this).notify(id, buildNotification(title, body))
}
}
companion object {
private const val SERVICE_NOTICE = 1
/** Idempotent: called on every start of the activity, and by the boot
* path, and safe to repeat. */
fun start(context: Context) {
ContextCompat.startForegroundService(context, Intent(context, KernelService::class.java))
}
fun stop(context: Context) {
context.stopService(Intent(context, KernelService::class.java))
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,744 @@
package io.reasonix.mobile.app
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentValues
import android.content.Context
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.webkit.JavascriptInterface
import android.webkit.MimeTypeMap
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* What the page can ask the phone for.
*
* This is the whole surface, and it is deliberately tiny. Android's
* `@JavascriptInterface` is a bridge into the app's process from a WebView whose
* content is a web page: every method here is reachable by anything that can run
* script in that page, so each one is written as a verb with no address
* parameter it does not need, and none of them can name a path outside the
* kernel's own home.
*
* It is deliberately *not* the Electron preload bridge. That one exposes window
* management, a filesystem walker, an editor launcher and a browser view host —
* all of which are meaningless on a phone, and the two that were not (open a URL,
* save a file) already have a way to happen here: `WebViewClient` routes external
* links to the browser, and the page's own download reaches the WebView's
* `DownloadListener`.
*
* The one thing a phone must add is `importIntoHome`: the kernel runs on this
* device and can only read paths under its own home, so a private key the person
* has in their Documents folder has to be copied across. Nothing else the page
* wants is out of its reach.
*/
class NativeBridge(
private val context: Context,
private val kernel: Kernel,
private val onOpen: (String) -> Unit,
) {
/**
* Whether the document calling may use this bridge.
*
* `addJavascriptInterface` makes the object reachable from whatever page the
* WebView holds, so a link the model produced — or a cleartext page on the
* LAN — would otherwise get `importIntoHome`, `pickFolder`, `hostCall` and
* the clipboard. The activity answers by comparing the main frame's origin
* against the kernel's and the one paired computer the person confirmed.
*
* Anything that reaches the device checks this first. The five methods that
* do not — version, systemTheme, safeArea, setLayerOpen, setRunning — report
* back what the page already knows about its own window and grant nothing.
*/
var trusted: () -> Boolean = { false }
/** The version the page reads to decide whether this bridge is the one it
* knows. Bumped when a method's contract changes, not when one is added. */
@JavascriptInterface
fun version(): String = BRIDGE_VERSION
/**
* Copy a file the person picks into the kernel's home, answering with its
* path there — or "" when they dismissed the picker.
*
* `kind` names the directory inside the home, because that is a fact about
* the kernel's layout and not about the file: an SSH private key belongs in
* `.ssh`, and a dotenv the kernel reads for passphrases belongs at the root
* of the home where `config.Load` looks for it.
*
* The path that comes back is *inside the home*, never the document
* provider's URI. That is the point: the kernel is a child process with no
* access to the provider, so a `content://` would be a path it cannot open.
*/
@JavascriptInterface
fun importIntoHome(kind: String, name: String): String {
if (!trusted()) return ""
val dir = when (kind) {
"identity" -> File(kernel.homeDir(), ".ssh")
"env" -> kernel.homeDir()
else -> return ""
}
val safeName = sanitise(name)
if (safeName.isEmpty()) return ""
val picked = pickFile() ?: return ""
return try {
dir.mkdirs()
val target = File(dir, safeName)
context.contentResolver.openInputStream(picked)?.use { input ->
target.outputStream().use { output -> input.copyTo(output) }
} ?: return ""
// A private key with group or world read is one OpenSSH refuses to
// use, and the refusal reads as "bad permissions" from a long way
// away. Setting it here is what makes the file usable at all.
if (kind == "identity") {
runCatching {
target.setReadable(false, false)
target.setReadable(true, true)
target.setWritable(false, false)
target.setWritable(true, true)
}
}
target.absolutePath
} catch (_: Exception) {
// A copy that failed leaves nothing behind; the caller sees "" and
// the person can try again.
""
}
}
/**
* Open a URL in the device's browser.
*
* The page asks for this when a link points somewhere outside the kernel —
* a provider's console, a documentation page — and loading it in place would
* strand the person in a window with no address bar and no way back.
*/
@JavascriptInterface
fun openExternal(url: String) {
if (!trusted()) return
// Only what a browser can open. A `file://` or `content://` here would
// be the page reaching into the device through a method named for
// leaving it.
if (!url.startsWith("http://") && !url.startsWith("https://")) return
onOpen(url)
}
/** Put text on the clipboard. A WebView's own `navigator.clipboard` write is
* gated behind a permission Android grants only to a focused document, so
* the one thing the page actually offers to copy — a token, a command, a
* path — has no other way out.
*
* Answers whether the write landed. A platform that refuses it raises
* rather than answering, and an exception must not cross
* `@JavascriptInterface` as a silent success — the page would say 已复制
* over an empty clipboard. Caught here, the answer is the one the caller
* can act on: false, and the button says 复制失败 instead. */
@JavascriptInterface
fun copyText(text: String): Boolean = if (!trusted()) false else runCatching {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("reasonix", text))
}.isSuccess
/**
* Write text where the person picks, and answer with the path it went to.
*
* The page's own route to a file — a blob URL on an `<a download>` — reaches
* nothing here: a WebView has no downloads shelf, and its `DownloadListener`
* fires only for a real navigation, not for a blob the page just built. So
* the text crosses the bridge and the shell opens the system's document
* picker, which is also the only way to reach a folder the person can find
* again afterwards.
*
* "" is a dismissal, the same answer `importIntoHome` gives, and the page
* reads the two the same way.
*/
/**
* Put a copy of a file where a phone's own file manager will show it.
*
* `saveText` asks the person where to put it, which is the right question
* on a desktop and the wrong one here: the kernel's workspace is
* app-private storage, so the file someone wants is in a place no file
* manager can open, and a picker that starts at "recent" is another place
* to lose it. This answers the actual question — "where is it" — by
* putting it in Downloads, in a folder named after the app, under a name
* that is the file's own.
*
* The answer is the path relative to shared storage, so the page can say
* where the file went rather than only that it worked. "" means the write
* failed or the platform refused it.
*
* MediaStore on Android 10 and later, a plain file before that. The two
* are not interchangeable: from 10 the app may not write another app's
* files directly, and MediaStore is the only way in; before it there is no
* MediaStore collection for downloads and the public directory is writable
* with the storage permission this app does not ask for, so that branch is
* best-effort and says so by answering "".
*/
@JavascriptInterface
fun exportToDownloads(name: String, content: String): String {
if (!trusted()) return ""
val safeName = sanitise(name).ifEmpty { "reasonix.txt" }
return exportFile(safeName, content).orEmpty()
}
@JavascriptInterface
fun saveText(name: String, content: String): String {
if (!trusted()) return ""
val safeName = sanitise(name).ifEmpty { "reasonix.txt" }
return saveFile(safeName, content) ?: ""
}
/**
* Hand text to whatever the device shares with — a chat app, the mail
* client, a note app.
*
* The share sheet rather than a second save dialog: "where is that file"
* is the question this answers, and on a phone a document someone wants to
* keep or send usually goes through one of those apps rather than a folder.
*
* Shared as text with the filename as its subject, which is what every
* target understands. A file attachment would need a `FileProvider` and a
* cache copy for the same words to arrive.
*/
@JavascriptInterface
fun shareText(name: String, content: String): Boolean {
if (!trusted()) return false
val safeName = sanitise(name).ifEmpty { "reasonix.txt" }
val posted = java.util.concurrent.CountDownLatch(1)
var ok = false
main.post {
ok = runCatching { askForShare(safeName, content) }.getOrDefault(false)
posted.countDown()
}
runCatching { posted.await(SHARE_TIMEOUT_SECONDS, TimeUnit.SECONDS) }
return ok
}
/**
* The device's own light/dark answer.
*
* The page resolves its theme from `localStorage` first and only asks the
* system when nothing is saved, so this is what makes a fresh install match
* the phone instead of defaulting to light.
*/
@JavascriptInterface
fun systemTheme(): String {
val night = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return if (night == Configuration.UI_MODE_NIGHT_YES) "dark" else "light"
}
/**
* The window's real safe-area insets, in CSS pixels, as
* `top,right,bottom,left`.
*
* A WebView does not implement `env(safe-area-inset-*)`: those are an iOS
* Safari feature, and on Android every one of them resolves to `0`. So a
* page that pads itself with them — which `mobile.css` does — is un-padded
* on exactly the platform this shell exists for, and the first line of the
* interface sits under the status bar.
*
* The values have to come from here for a second reason too: from Android 15
* the window is edge-to-edge whether the app asks for it or not, so the bars
* really are drawn over the page and there is no window-manager switch that
* could move the content back down.
*
* Answered as a string because that is what crosses the bridge; the page
* sets it as a CSS custom property and `mobile.css` reads it.
*/
@JavascriptInterface
fun safeArea(): String {
val insets = onInsets?.invoke() ?: return "0,0,0,0"
return "${insets.left},${insets.top},${insets.right},${insets.bottom}"
}
/**
* Read the pairing code a computer is showing.
*
* A phone cannot type what a computer renders as a QR code — it is a
* one-time code inside a URL, and the whole reason it is a picture is that
* nobody reads one aloud. So the page asks for the camera and gets the text
* back, which is the same direction the computer's own dialog describes.
*
* The answer is the code's own text, not a parsed result: what a pairing
* fragment means is the page's to decide, and it already knows how — the
* `#pair=` handling ships with the interface.
*/
@JavascriptInterface
fun scanPairingCode(): String = if (!trusted()) "" else scanWithCamera()
/**
* Load a URL in this window instead of handing it to the browser.
*
* The page asks for this once, right after a scan: the code it read points at
* the computer's own Studio, and the whole point of scanning is to end up
* looking at that computer. Going out to a browser would leave the app on one
* screen and the paired machine on another, with the back gesture an exit
* rather than a return.
*
* Only http and https are accepted. The page is this repository's own build
* and is trusted, but a scanner reads whatever is held in front of it, so the
* one value that arrives here from outside is checked: a `file://` or
* `javascript:` that happened to be on a printed code must not become a
* navigation.
*/
@JavascriptInterface
fun openInApp(url: String) {
if (!trusted()) return
val trimmed = url.trim()
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) return
main.post { onOpenInApp?.invoke(trimmed) }
}
/** How the activity loads a URL in the window it owns. Set by the activity. */
var onOpenInApp: ((String) -> Unit)? = null
// ---- the native browser view host bridge ----
//
// The page's `host.ts` looks for `window.reasonixHost` with Promise-based
// methods (showBrowserView, hideBrowserView, etc.). `@JavascriptInterface`
// is synchronous, so the round trip is: JS generates an id, calls
// `hostCall(id, method, args)`, Kotlin does the work asynchronously, then
// pushes the result back via `evaluateJavascript` calling
// `window.__reasonixHostReply(id, json)`. The injected script (in
// MainActivity) wires each Promise to that reply channel.
/** How the activity handles an async host call. Set by the activity. */
var onHostCall: ((id: String, method: String, args: String) -> Unit)? = null
/**
* Entry point for the page's `window.reasonixHost` Promise methods.
*
* `id` is a unique call identifier the JS side generated; `method` is the
* method name (e.g. "showBrowserView"); `args` is the JSON-encoded argument
* array. The activity handles the call asynchronously and replies through
* `evaluateJavascript` with `window.__reasonixHostReply(id, resultJson)`.
*/
@JavascriptInterface
fun hostCall(id: String, method: String, args: String) {
if (!trusted()) return
onHostCall?.invoke(id, method, args)
}
/**
* Whether the page has a panel up that the back gesture should close first.
*
* Written by the page, read by the activity's back handler. The page owns the
* answer — it is the only thing that knows what is open, and a second
* implementation of that question in the shell would be one that drifts — so
* the page reports the fact and the shell keeps the action.
*
* Volatile because the two ends are on different threads: the page sets it
* from the WebView's JavaScript thread, the back gesture reads it on the main
* one, and a stale read here is a panel that does not close.
*/
@Volatile
var layerOpen: Boolean = false
private set
@JavascriptInterface
fun setLayerOpen(open: Boolean) {
layerOpen = open
}
// ---- the picker, which is an Activity result in a synchronous world ----
private val pending = AtomicReference<Uri?>(null)
private val latch = AtomicReference<CountDownLatch?>(null)
/** The code the camera read, held between the activity's answer and the
* wait below — the same round trip the file picker takes. */
private val scanned = AtomicReference<String?>(null)
private val scanLatch = AtomicReference<CountDownLatch?>(null)
/** The main thread, because starting an Activity result must happen there. */
private val main = Handler(Looper.getMainLooper())
/**
* How to ask for a file. Set by the activity, which owns the Activity Result
* API; it answers whether the ask was dispatched, not whether the person
* picked anything — that arrives through `deliver`.
*/
var askForFile: () -> Boolean = { false }
/**
* How the activity reports the window's insets. Set by the activity, which is
* the only thing that can see them, and asked on every call rather than
* cached — a rotation, a fold, or the keyboard opening all change them, and a
* stale answer here is a stripe of interface under the status bar.
*/
var onInsets: (() -> Insets?)? = null
/**
* How to open the camera. Set by the activity, which owns the Activity
* Result API; like `askForFile` it answers whether the ask was dispatched,
* and the code itself arrives through `deliverScan`.
*/
var askForScan: () -> Boolean = { false }
/**
* How to put a file where the person picks. Set by the activity, which owns
* the Activity Result API; it answers whether the ask was dispatched, and
* the chosen place arrives through `deliverSave`.
*/
var askForSave: (name: String, content: String) -> Boolean = { _, _ -> false }
/**
* How to open the share sheet. Set by the activity, which is the only thing
* that can start an Intent; it answers whether anything on the device took
* the text.
*/
var askForShare: (name: String, content: String) -> Boolean = { _, _ -> false }
/** The place the save dialog answered with, held between the two halves. */
private val saveTarget = AtomicReference<Uri?>(null)
private val saveLatch = AtomicReference<CountDownLatch?>(null)
/**
* Ask where to put a file and write it there.
*
* The same latch round trip as `pickFile`, and for the same reason:
* `@JavascriptInterface` cannot suspend, so the WebView's thread waits while
* the picker is open — a modal panel the person is looking at.
*/
private fun saveFile(name: String, content: String): String? {
val gate = CountDownLatch(1)
saveLatch.set(gate)
saveTarget.set(null)
val posted = CountDownLatch(1)
var dispatched = false
main.post {
dispatched = runCatching { askForSave(name, content) }.getOrDefault(false)
posted.countDown()
}
val ready = runCatching { posted.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
if (!ready || !dispatched) {
saveLatch.set(null)
return null
}
return try {
if (!gate.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) null
else saveTarget.get()?.toString()
} catch (_: InterruptedException) {
null
} finally {
saveLatch.set(null)
}
}
/** Called by the activity once the bytes are written. A null uri is a dismissal. */
fun deliverSave(uri: Uri?) {
saveTarget.set(uri)
saveLatch.get()?.countDown()
}
/**
* Read the pairing code a computer is showing, and return it.
*
* The same shape as `pickFile` and for the same reason: a
* `@JavascriptInterface` method is called on the WebView's thread and must
* return a value, so the camera is opened on the main thread and this one
* waits. The block lasts as long as the person is aiming, which is why the
* timeout is generous — a scan that gave up after a few seconds would be
* one nobody could complete.
*
* An empty string is "no code", which is what a refusal, a cancellation and
* a timeout all are. The caller acts on the text, so a distinct error would
* only be a fourth way to say the same thing.
*/
private fun scanWithCamera(): String {
val gate = CountDownLatch(1)
scanLatch.set(gate)
scanned.set(null)
val posted = CountDownLatch(1)
var dispatched = false
main.post {
dispatched = runCatching { askForScan() }.getOrDefault(false)
posted.countDown()
}
val ready = runCatching { posted.await(SCAN_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
if (!ready || !dispatched) {
scanLatch.set(null)
return ""
}
return try {
if (!gate.await(SCAN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) "" else scanned.get().orEmpty()
} catch (_: InterruptedException) {
""
} finally {
scanLatch.set(null)
}
}
/** Called by the activity when the scanner answers. Null is a dismissal. */
fun deliverScan(text: String?) {
scanned.set(text)
scanLatch.get()?.countDown()
}
/**
* Ask for one file and wait for the answer.
*
* `@JavascriptInterface` methods cannot suspend: the WebView calls them on
* its own thread and expects a value back. So the ask is posted to the main
* thread and this one waits on a latch. The cost is a blocked JS thread for
* as long as the picker is open — which is a modal panel the person is
* looking at, with nothing else in the page running.
*
* A timeout is set rather than waiting forever: a picker that never answers
* (the activity destroyed underneath it) would otherwise pin a WebView
* thread until the process died.
*/
private fun pickFile(): Uri? {
val gate = CountDownLatch(1)
latch.set(gate)
pending.set(null)
val posted = CountDownLatch(1)
var dispatched = false
main.post {
dispatched = runCatching { askForFile() }.getOrDefault(false)
posted.countDown()
}
val ready = runCatching { posted.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
if (!ready || !dispatched) {
latch.set(null)
return null
}
return try {
if (!gate.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) null else pending.get()
} catch (_: InterruptedException) {
null
} finally {
latch.set(null)
}
}
/** Called by the activity when the picker answers. A null uri is a dismissal. */
fun deliver(uri: Uri?) {
pending.set(uri)
latch.get()?.countDown()
}
/** The folder under Downloads that this app's exports land in. */
private fun exportFolder(): String = "Reasonix"
/** The MIME type this name declares, or null when the platform does not know
* it.
*
* Where the type is written, the name and the type agree, and that agreement
* is the whole fix: `.md` is answered `text/markdown` here, so MediaStore
* has nothing to correct and the export stays `报告.md` instead of being
* renamed `报告.md.txt` by the `text/plain` it used to be given. Where the
* type is unknown the field is left off, which leaves MediaStore to derive
* the same `application/octet-stream` it would have been told — the name
* still stands on its own, which is all this is for. */
private fun mimeFor(name: String): String? {
val ext = name.substringAfterLast('.', "").lowercase()
if (ext.isEmpty() || ext == name.lowercase()) return null
return runCatching { MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) }.getOrNull()
}
private fun exportFile(name: String, content: String): String? = runCatching {
val bytes = content.toByteArray(Charsets.UTF_8)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, name)
// The type is derived here rather than declared, and it is
// written only when the name really has an extension that maps
// to one.
//
// It used to be `text/plain` for everything, and MediaStore
// answers a name whose extension disagrees with the declared
// type by appending its own: exporting `报告.md` landed in
// Downloads as `报告.md.txt`, and any source file came out with
// the wrong second extension. Deriving the type from the name
// instead keeps the two in agreement — which is what stops the
// rename — and a name the platform has no type for is left off
// entirely rather than declared as `application/octet-stream`,
// so nothing is corrected there either.
mimeFor(name)?.let { put(MediaStore.Downloads.MIME_TYPE, it) }
put(
MediaStore.Downloads.RELATIVE_PATH,
Environment.DIRECTORY_DOWNLOADS + "/" + exportFolder(),
)
// Hidden until the bytes are all there, so a file manager that
// reads the folder mid-write cannot show half a document.
put(MediaStore.Downloads.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
?: return@runCatching null
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: return@runCatching null
values.clear()
values.put(MediaStore.Downloads.IS_PENDING, 0)
resolver.update(uri, values, null, null)
"${Environment.DIRECTORY_DOWNLOADS}/${exportFolder()}/$name"
} else {
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
exportFolder(),
)
if (!dir.exists() && !dir.mkdirs()) return@runCatching null
val target = File(dir, name)
target.writeBytes(bytes)
target.absolutePath
}
}.getOrNull()
private fun sanitise(name: String): String {
// A name from the page is used as a filename, so it is reduced to one:
// no separators, no traversal, and nothing a filesystem treats as
// special. The default is the name the field it came from suggests.
val trimmed = name.trim().substringAfterLast('/').substringAfterLast('\\')
if (trimmed.isEmpty() || trimmed == "." || trimmed == "..") return ""
return trimmed.filter { it.isLetterOrDigit() || it in "._-" }.take(64)
}
/**
* Whether a turn is running, so the screen can be kept awake for one.
*
* The page owns the answer — it is the only thing that knows if the kernel
* is working — and the shell owns the flag, because a window flag is not
* something a page can set. Nothing is asked at launch: a window that is
* merely open is one the person may be reading, and holding the screen on
* for that is holding it on for nothing.
*/
@JavascriptInterface
fun setRunning(running: Boolean) {
onRunning?.invoke(running)
}
/** How the activity applies that flag. Set by the activity. */
var onRunning: ((Boolean) -> Unit)? = null
/**
* Whether the app may read every file, which is what lets the kernel open a
* folder on this phone as a workspace.
*
* Asked rather than assumed: the permission is granted by hand in system
* settings, so the page has to be able to say "not yet" rather than fail on
* the first `stat`. Answered `true` below API 30, where ordinary read
* permission is enough and the concept does not exist.
*/
@JavascriptInterface
fun hasAllFilesAccess(): Boolean = filesAccess?.invoke() ?: true
var filesAccess: (() -> Boolean)? = null
/** Open the system page where that permission is granted. The only place
* Android offers it — there is no dialog for this one. */
@JavascriptInterface
fun requestAllFilesAccess() {
if (!trusted()) return
askForFilesAccess?.invoke()
}
var askForFilesAccess: (() -> Unit)? = null
/**
* Whether this app is exempt from the system's battery optimisation.
*
* A turn runs in a child process under a foreground service. Under doze the
* whole app group is frozen anyway, so the turn stops making progress while
* the screen is off — which is the state the person left it in on purpose.
* Unrestricted is the answer, and it is a question Android answers.
*/
@JavascriptInterface
fun batteryUnrestricted(): Boolean = if (!trusted()) false else (batteryFree?.invoke() ?: true)
var batteryFree: (() -> Boolean)? = null
/** Ask for that exemption. Unlike the storage one, this has a real dialog. */
@JavascriptInterface
fun requestBatteryUnrestricted() {
if (!trusted()) return
askForBattery?.invoke()
}
var askForBattery: (() -> Unit)? = null
/**
* Ask for a folder on this phone and answer with its real filesystem path.
*
* "" is a dismissal, the same answer `importIntoHome` gives — and, unlike
* that one, an empty string is a normal outcome here rather than a failure.
* The `startIn` hint is a path the kernel knows; the picker may ignore it,
* which is the picker's business.
*
* The round trip is the same latch as `pickFile`, for the same reason: a
* document-tree picker is another activity, and this call cannot suspend.
*/
@JavascriptInterface
fun pickFolder(startIn: String): String {
if (!trusted()) return ""
val gate = CountDownLatch(1)
folderLatch.set(gate)
folderPending.set(null)
val posted = CountDownLatch(1)
var dispatched = false
main.post {
dispatched = runCatching { askForFolder(startIn) }.getOrDefault(false)
posted.countDown()
}
val ready = runCatching { posted.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS) }.getOrDefault(false)
if (!ready || !dispatched) {
folderLatch.set(null)
return ""
}
return try {
if (!gate.await(PICK_TIMEOUT_SECONDS, TimeUnit.SECONDS)) "" else folderPending.get().orEmpty()
} catch (_: InterruptedException) {
""
} finally {
folderLatch.set(null)
}
}
var askForFolder: (String) -> Boolean = { false }
private val folderPending = AtomicReference<String?>(null)
private val folderLatch = AtomicReference<CountDownLatch?>(null)
/** Called by the activity with the folder's real path, or null on dismissal. */
fun deliverFolder(path: String?) {
folderPending.set(path ?: "")
folderLatch.get()?.countDown()
}
companion object {
const val BRIDGE_VERSION = "4"
private const val PICK_TIMEOUT_SECONDS = 5L * 60
/** Long, because this is however long someone takes to aim. A shorter
* one would turn "still lining it up" into a failed scan. */
private const val SCAN_TIMEOUT_SECONDS = 5L * 60
/** Only as long as the sheet takes to appear: the answer is "something
* took it", which is known the moment the Intent resolves. */
private const val SHARE_TIMEOUT_SECONDS = 15L
}
}
/**
* The window's insets, in CSS pixels.
*
* A named type rather than four numbers because the page reads them as one
* thing: half an inset is a layout that is wrong in one direction, and a caller
* that passed the wrong pair would be indistinguishable from a device with no
* cutout at all.
*/
data class Insets(val left: Int, val top: Int, val right: Int, val bottom: Int)
@@ -0,0 +1,66 @@
package io.reasonix.mobile.app
import androidx.camera.core.ImageProxy
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
/**
* Reading a QR code out of one camera frame.
*
* ZXing core and nothing else: it is pure Java, so it runs identically on a
* phone with Google Play Services and on one whose HarmonyOS compatibility
* layer has none. A decoder that leaned on ML Kit would work on the machine it
* was written on and fail on the one that needs it.
*
* The frame arrives as YUV_420_888, whose Y plane is already the greyscale
* bitmap ZXing wants — no conversion, no colour maths, just the bytes. That is
* the whole reason a phone can decode 30 frames a second without warming up.
*/
object Qr {
private val reader = MultiFormatReader().apply {
setHints(
mapOf(
// Only the form the computer actually renders. Leaving the
// reader open to every 1D barcode makes each frame slower for
// a case that cannot occur, and a slower frame is a code that
// has already left the viewfinder.
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
DecodeHintType.TRY_HARDER to true,
),
)
}
/** The code's text, or null when this frame holds none. */
fun decode(image: ImageProxy): String? {
val plane = image.planes.firstOrNull() ?: return null
val buffer = plane.buffer
val data = ByteArray(buffer.remaining())
buffer.get(data)
// `rowStride` is padded to a hardware alignment and is usually wider
// than the picture. Passing the width as the row length is what tells
// ZXing where each row actually ends; getting it wrong shears the image
// and every code reads as garbage.
val source = PlanarYUVLuminanceSource(
data,
plane.rowStride,
image.height,
0,
0,
image.width,
image.height,
false,
)
return runCatching {
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
}.getOrNull().also {
// The reader keeps per-frame state between calls; resetting is what
// stops a partial match in one frame from corrupting the next.
reader.reset()
}
}
}
@@ -0,0 +1,146 @@
package io.reasonix.mobile.app
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.Gravity
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import java.util.concurrent.Executors
/**
* The camera, held open just long enough to read one pairing code.
*
* A phone that wants to drive a computer cannot type the code that computer
* shows: it is a base64 fragment inside a URL, and the whole point of showing it
* as a QR code is that nobody reads one aloud. So this is the way in — the same
* gesture the computer's own dialog asks for, from the other side.
*
* The decode is ZXing rather than ML Kit on purpose. ML Kit needs Google Play
* Services, and this app installs on phones whose HarmonyOS compatibility layer
* has none: a GMS-backed scanner would work on a developer's phone and fail on
* the device that needs it. ZXing core is pure Java, so the scanner behaves the
* same on both.
*
* It returns the first code it reads and finishes. A pairing code is
* single-use and short-lived, so "keep scanning after a hit" is not a feature —
* it is a way to spend a code that a later frame would have discarded.
*/
class ScanActivity : AppCompatActivity() {
private lateinit var preview: PreviewView
private lateinit var hint: TextView
private val executor = Executors.newSingleThreadExecutor()
/** Set once a code is found, so the frames in flight behind it do not each
* try to finish the activity. */
@Volatile
private var settled = false
private val requestCamera = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) start() else refuse()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val root = FrameLayout(this)
preview = PreviewView(this).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
// The frames are analysed, not composed: filling the view and
// cropping is right here and would be wrong for a camera app.
scaleType = PreviewView.ScaleType.FILL_CENTER
}
hint = TextView(this).apply {
text = getString(R.string.scan_hint)
setTextColor(0xFFFFFFFF.toInt())
textSize = 14f
gravity = Gravity.CENTER
val pad = (16 * resources.displayMetrics.density).toInt()
setPadding(pad, pad, pad, pad)
// Readable over whatever the camera is pointing at, which is the
// one background a design system cannot choose.
setBackgroundColor(0x99000000.toInt())
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM,
)
}
root.addView(preview)
root.addView(hint)
setContentView(root)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
) {
start()
} else {
requestCamera.launch(Manifest.permission.CAMERA)
}
}
private fun refuse() {
// No dialog of our own: the permission prompt already said no, and a
// second window repeating it is one more tap to dismiss. The caller
// gets the same answer as a cancelled scan.
setResult(RESULT_CANCELED)
finish()
}
private fun start() {
val providerFuture = ProcessCameraProvider.getInstance(this)
providerFuture.addListener({
val provider = runCatching { providerFuture.get() }.getOrNull() ?: return@addListener refuse()
val previewUseCase = Preview.Builder().build().also { it.setSurfaceProvider(preview.surfaceProvider) }
val analysis = ImageAnalysis.Builder()
// Only the newest frame matters: a stale one describes where the
// camera was, and a code that has already left the viewfinder
// must not win over one that is in it now.
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(executor, ::analyse) }
runCatching {
provider.unbindAll()
provider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, previewUseCase, analysis)
}.onFailure { refuse() }
}, ContextCompat.getMainExecutor(this))
}
private fun analyse(image: ImageProxy) {
try {
if (settled) return
val text = Qr.decode(image) ?: return
if (settled) return
settled = true
setResult(RESULT_OK, Intent().putExtra(EXTRA_TEXT, text))
// Finishing from the analysis thread is the whole reason `settled`
// exists; the activity teardown has to happen on the main one.
runOnUiThread { finish() }
} finally {
image.close()
}
}
override fun onDestroy() {
super.onDestroy()
executor.shutdown()
}
companion object {
const val EXTRA_TEXT = "text"
}
}
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The mark, drawn rather than shipped as a bitmap: one R in the app's own ink
on the palette's ground, which scales to every launcher's mask without a
density bucket per size. The hole in the R is the glyph's own counter, cut
with evenOdd rather than painted over. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#F4F1EA"
android:fillType="evenOdd"
android:pathData="M39,30 L39,78 L47,78 L47,60 L54.5,60 L65,78 L74.5,78 L62.5,58 C68.6,55.8 72.6,50.6 72.6,43.6 C72.6,35.4 66.8,30 57.6,30 Z M47,37 L56.4,37 C61.2,37 64.4,39.4 64.4,43.8 C64.4,48.2 61.2,50.9 56.4,50.9 L47,50.9 Z" />
<path
android:fillColor="#A6680C"
android:pathData="M62,66 L74,66 L74,78 L62,78 Z" />
</vector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The status-bar glyph, not the launcher icon: Android renders a notification
icon as a mask, so anything that is not a solid silhouette on transparency
comes out as a grey blob. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M3,4h18v2H3zM3,9h18v2H3zM3,14h12v2H3zM3,19h12v2H3zM17.5,13.2l1.6,2.6 1.6,-2.6h2.3l-2.8,4.3V21h-2.2v-3.5L15.2,13.2z" />
</vector>
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The screen between the tap and the page.
It exists for a reason that is not cosmetic: the kernel has to be unpacked and
started before there is a page to show, and on a cold start that is about a
second. A blank window for a second reads as a broken app; a line of text that
says what is happening does not.
The progress bar is indeterminate and driven by nothing: the WebView's own
progress is mirrored onto it while the page loads, and before that it simply
turns, which is the honest description of "we do not know how long".
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/page"
android:orientation="vertical"
android:gravity="center"
android:paddingStart="32dp"
android:paddingEnd="32dp">
<TextView
android:id="@+id/splash_wordmark"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textColor="#8A857B"
android:textSize="22sp"
android:letterSpacing="0.08"
android:fontFamily="sans-serif-medium" />
<ProgressBar
android:id="@+id/splash_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="132dp"
android:layout_height="wrap_content"
android:layout_marginTop="26dp"
android:indeterminate="true" />
<TextView
android:id="@+id/splash_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:gravity="center"
android:text="@string/splash_connecting"
android:textColor="#8A857B"
android:textSize="13sp"
android:lineSpacingMultiplier="1.5" />
<Button
android:id="@+id/splash_retry"
style="?android:attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:minHeight="44dp"
android:text="@string/splash_retry"
android:textColor="#A6680C"
android:visibility="gone" />
</LinearLayout>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The adaptive launcher icon.
minSdk is 26, which is the release that introduced adaptive icons, so this
is the only launcher asset the app needs: there is no device running it
that would ask for a bitmap fallback. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<!-- The safe zone is the middle 66dp of 108dp; the glyph is drawn inside it
so no launcher's mask can clip it. -->
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Reasonix</string>
<string name="channel_kernel">Kernel running</string>
<string name="channel_kernel_desc">Shown while the Reasonix kernel runs in the background. Can be muted on its own in system settings.</string>
<string name="notify_running_title">Reasonix is running</string>
<string name="notify_running_body">A task is still going. Switch back to see it.</string>
<string name="notify_failed_title">Reasonix could not start</string>
<string name="err_kernel_unreachable">Could not reach the kernel. Reopen the app from your home screen.</string>
<string name="download_default_name">reasonix-download</string>
<string name="download_saved">Saved: %1$s</string>
<string name="download_failed">Save failed: %1$s</string>
<string name="download_failed_status">Save failed: the server answered %1$d</string>
<string name="splash_connecting">Starting the kernel…</string>
<string name="splash_retry">Retry</string>
<string name="scan_title">Scan the code on the computer</string>
<string name="scan_hint">Point at the QR code in the computer\'s device panel to connect to it.</string>
<string name="scan_button">Scan to connect</string>
<string name="scan_denied">No camera permission, so scanning is unavailable. Allow it in system settings.</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The dark palette's page colour. `DayNight` on the theme picks this up when
the system is dark, which is what keeps the frame before the WebView from
flashing light at someone using a dark phone at night. -->
<resources>
<color name="page">#0E1014</color>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The light palette's page colour, and only what the window needs before the
WebView paints. The interface's own colours are `tokens.css`'s and are the
page's business; these two exist so the first frame is the app's ground
rather than the platform default white. -->
<resources>
<color name="page">#F4F1EA</color>
<color name="boot">#07080C</color>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The launcher ground: the interface's dark page colour. It is the same in
both themes because a launcher icon is not themed by the app. -->
<resources>
<color name="ic_launcher_background">#14161C</color>
</resources>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Every string the shell itself shows. The interface's own text belongs to
the page and lives in its i18n bundle; these are only the few lines that
exist before it, or when it cannot load. -->
<resources>
<string name="app_name">Reasonix</string>
<string name="channel_kernel">内核运行中</string>
<string name="channel_kernel_desc">Reasonix 内核在后台运行时显示,可在系统设置中单独关闭。</string>
<string name="notify_running_title">Reasonix 正在运行</string>
<string name="notify_running_body">任务继续进行,切回应用即可查看。</string>
<string name="notify_failed_title">Reasonix 无法启动</string>
<string name="err_kernel_unreachable">连接内核失败。请返回桌面重新打开应用。</string>
<string name="download_default_name">reasonix-download</string>
<string name="download_saved">已保存:%1$s</string>
<string name="download_failed">保存失败:%1$s</string>
<string name="download_failed_status">保存失败:服务器返回 %1$d</string>
<string name="splash_connecting">正在启动内核…</string>
<string name="splash_retry">重试</string>
<string name="wsperm_title">需要「所有文件访问」权限</string>
<string name="wsperm_body">要打开手机上的文件夹作为工作区,需要先允许本应用访问所有文件。点「去设置」,在列表里打开 Reasonix 的开关,然后返回这里。</string>
<string name="wsperm_go">去设置</string>
<string name="wsperm_later">以后再说</string>
<string name="scan_title">扫描电脑上的二维码</string>
<string name="scan_hint">对准电脑上「设备访问」里的二维码,识别后即可连接这台电脑。</string>
<string name="scan_button">扫码连接电脑</string>
<string name="scan_denied">没有相机权限,无法扫码。请在系统设置里允许。</string>
<string name="share_title">分享文件</string>
</resources>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The shell's own furniture: a window background matching the page's light
palette before the WebView paints, and a transparent system bar so the
page's own safe-area padding is what the notch meets. The page themes
itself; this exists only so the first frame is not a white flash. -->
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Reasonix" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@color/page</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- The page draws its own edge-to-edge layout and pads itself with the
safe-area insets from mobile.css. Laying the window out edge to edge
while leaving the bars transparent is what makes those insets real
numbers instead of zero. -->
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="p">shortEdges</item>
<item name="android:enforceStatusBarContrast" tools:targetApi="q">false</item>
<item name="android:enforceNavigationBarContrast" tools:targetApi="q">false</item>
</style>
<!-- The camera. A black ground because the preview has not drawn yet and a
light window behind a dark viewfinder is a flash in the dark; the bars
stay transparent so the preview reaches the edges, which is what makes
a code near the top of the screen scannable. -->
<style name="Theme.Reasonix.Scan" parent="Theme.Reasonix">
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Backups are off, and this file says why rather than leaving it implicit.
A Reasonix home holds provider keys, an account token and every session
transcript. Android's auto-backup would copy all of it to the person's cloud
drive and, on a device-to-device transfer, to a new phone — which is a
credential leaving the device by a route nobody chose. `allowBackup="false"`
in the manifest turns that off; these two stanzas are the belt to its braces,
excluding everything explicitly so that a later change to `allowBackup` cannot
quietly start copying credentials.
What a person wants to move between phones, the kernel already moves: its own
export writes a file the person hands to the system's picker.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Cleartext HTTP is permitted, and the platform has no way to permit less.
Two kinds of page load over plain HTTP: the kernel's own page on
`127.0.0.1:<port>`, and the same page served by a paired computer on whatever
address its router handed out. The port is chosen when the kernel starts and
the LAN address when a code is scanned, so neither can be written here — and a
`domain-config` matches literals and domains, never a subnet.
So the boundary is not this file. `MainActivity` decides which cleartext origin
may become the window: `shouldOverrideUrlLoading` sends every http(s) origin
that is not the kernel's or the one just paired out to the browser, and the
native bridge answers only those two. A plaintext page on the LAN therefore
cannot become the document holding the bridge, which is what the reviewer's
"deliberate path for LAN pairing" asks for.
HTTPS is untouched: nothing here downgrades a request, and `trust-anchors`
keeps the system roots for every TLS connection that is made.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
+15
View File
@@ -0,0 +1,15 @@
// The build itself lives in app/build.gradle; this file declares the plugin
// versions once so the app module can be read on its own.
//
// AGP 8.11.1 is the pairing for the Gradle 8.14 toolchain this machine has:
// AGP 8.7 and older refuse a Gradle that new, and AGP 8.13+ wants a Gradle this
// one does not have. Both numbers are pinned rather than ranged, because a
// build that silently moved either one would be a different build.
plugins {
id "com.android.application" version "8.11.1" apply false
id "org.jetbrains.kotlin.android" version "2.1.20" apply false
}
tasks.register("clean", Delete) {
delete rootProject.layout.buildDirectory
}
+15
View File
@@ -0,0 +1,15 @@
# AndroidX is the only copy of the support libraries this project uses; the
# non-transitive R class keeps the app's own resources from aliasing library
# ones, which is what makes `R.string.*` unambiguous here.
android.useAndroidX=true
android.nonTransitiveRClass=true
# The daemon is what keeps a second `gradle assembleDebug` from re-reading the
# whole SDK, and the heap is sized for the R8 pass over the WebView and the
# packaged page rather than for this handful of Kotlin files.
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.caching=true
kotlin.code.style=official
File diff suppressed because it is too large. Load diff
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
# Pinning the distribution's checksum is what makes the download verifiable: a
# mirror that served a different zip would fail here instead of being unpacked
# and run. The value is Gradle's own, from
# https://services.gradle.org/distributions/gradle-8.14.3-bin.zip.sha256
distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# Build the Reasonix kernel for Android and place it where the APK packager
# expects a native library.
#
# Why a shell script and not a Gradle task: the kernel is a Go module that lives
# outside this Gradle build, with its own toolchain version pinned in go.mod. A
# Gradle task that invoked `go build` would make the Android build depend on a Go
# toolchain being configured for it, and would rebuild a 21MB binary on every
# `assembleDebug` even when nothing in the kernel changed. This runs when you
# mean it to, and the APK task only checks that the result is there.
#
# Output: app/src/main/jniLibs/arm64-v8a/libreasonix.so
# The name is not cosmetic. Android extracts files under `jniLibs/` to
# `nativeLibraryDir` and permits execution *there*; a binary shipped in
# `assets/` would have to be copied to app storage and chmod'd, which fails on
# a device whose data partition is mounted noexec. `lib*.so` is the shape the
# packager recognises as a native library, so the kernel wears it.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
upstream="$android/.."
out="$android/app/src/main/jniLibs/arm64-v8a/libreasonix.so"
if [ ! -d "$upstream" ]; then
echo "kernel source not found: $upstream" >&2
echo "run this from a checkout: the parent of android/ must be the kernel source." >&2
exit 1
fi
# GOOS=android needs nothing else: the kernel is CGO-free, which is what makes a
# single static binary possible in the first place. `-s -w` drops the symbol and
# DWARF tables — about a third of the file — and nothing on a phone reads them.
export GOOS=android
export GOARCH=arm64
export CGO_ENABLED=0
# The app version, read from the module's own build.gradle so the two cannot
# drift. The official build stamps these through `-X` (see upstream's Makefile);
# without them the kernel reports `version=dev revision=unknown`, which a phone
# then shows next to the documentation it was built with and makes a bug report
# from that build impossible to place. There is no `.git` here, so the revision
# names the package the corpus shipped in rather than a commit hash — the honest
# answer when there is no commit to name.
app_gradle="$android/app/build.gradle"
version_field() {
sed -n "s/^def $1 *= *\([0-9]*\).*/\1/p" "$app_gradle" | head -n1
}
app_version="$(version_field versionMajor).$(version_field versionMinor).$(version_field versionPatch)"
build_time_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
ldflags="-s -w"
ldflags="$ldflags -X main.version=v$app_version"
ldflags="$ldflags -X main.buildTimeUTC=$build_time_utc"
ldflags="$ldflags -X reasonix/internal/tools/productdocs.linkedVersion=v$app_version"
ldflags="$ldflags -X reasonix/internal/tools/productdocs.linkedRevision=apk-v$app_version"
echo "building the kernel (android/arm64, v$app_version) ..."
mkdir -p "$(dirname "$out")"
cd "$upstream"
go build -trimpath -ldflags="$ldflags" -o "$out" ./cmd/reasonix
size=$(wc -c < "$out")
echo "kernel ready: $out ($((size / 1024 / 1024)) MiB)"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
#
# One command from a clean checkout to an installable APK.
#
# The order matters and is the whole content of this script: the kernel and the
# page are inputs to packaging, not build steps inside it, so they are produced
# first and the Gradle task only assembles what is already there. Running
# `gradle assembleRelease` directly on a fresh checkout fails on purpose (see
# `requireBuildInputs` in app/build.gradle) rather than producing an APK with no
# kernel in it.
#
# Usage: ./scripts/build.sh [debug|release] (default: release)
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
variant="${1:-release}"
case "$variant" in
debug|release) ;;
*) echo "usage: $0 [debug|release]" >&2; exit 2 ;;
esac
"$here/build-kernel.sh"
"$here/sync-page.sh"
cd "$android"
# The wrapper is committed, jar and all, and it is the only thing that runs the
# build. Generating one here would tie the dependency versions to whichever
# `gradle` happened to be on PATH, which is the opposite of a pinned build.
if [ ! -f gradlew ]; then
echo "gradlew is missing: this repository commits the Gradle wrapper, so the checkout is incomplete" >&2
echo "(a release build does not use `gradle` from PATH: that would tie dependency versions to the machine)" >&2
exit 1
fi
task="assemble$([ "$variant" = release ] && echo Release || echo Debug)"
echo "packaging the APK ($variant) ..."
./gradlew "$task" --no-daemon
apk_dir="$android/app/build/outputs/apk/$variant"
echo
echo "done. Installable artifacts:"
find "$apk_dir" -name '*.apk' -maxdepth 1 -print -exec ls -lh {} \;
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
#
# Install the built APK on a connected phone and show its log.
#
# `adb` comes from the Android SDK rather than this project, because the SDK is
# already how the app is built. Point ANDROID_HOME at it, or have `adb` on PATH.
#
# Usage: ./scripts/run.sh [debug|release]
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
variant="${1:-release}"
adb="${ADB:-${ANDROID_HOME:-$HOME/AndroidSDK}/platform-tools/adb}"
if ! command -v "$adb" >/dev/null 2>&1; then
adb="adb"
fi
apk_dir="$android/app/build/outputs/apk/$variant"
apk=$(find "$apk_dir" -name '*.apk' -maxdepth 1 2>/dev/null | head -n1)
if [ -z "$apk" ]; then
echo "no $variant APK found; run ./scripts/build.sh $variant first" >&2
exit 1
fi
echo "installing $apk ..."
"$adb" install -r "$apk"
# The device count is checked before the app is started so an emulator-less
# machine gets "no devices" rather than a launch failure that reads like a bug.
if [ "$("$adb" devices | grep -c 'device$')" -eq 0 ]; then
echo "no device attached; skipping the launch." >&2
exit 0
fi
package="io.reasonix.mobile.app"
[ "$variant" = debug ] && package="io.reasonix.mobile.app.debug"
"$adb" shell monkey -p "$package" -c android.intent.category.LAUNCHER 1 >/dev/null
echo
echo "following the log (Ctrl-C to stop) ..."
"$adb" logcat -v color "ReasonixKernel:V" "AndroidRuntime:E" "*:S"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# Build the interface and copy it where the APK will carry it.
#
# The page that ships is the same build the desktop Studio serves — that is the
# whole reason the mobile layout lives in `desktop/frontend-next` rather than in
# an Android project's own web root. Nothing is edited on the way through: the
# only thing this script does that `pnpm build` does not is copy `dist/` into the
# app's assets.
#
# Output: app/src/main/assets/page/
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
frontend="$android/../desktop/frontend-next"
dest="$android/app/src/main/assets/page"
if [ ! -d "$frontend" ]; then
echo "frontend project not found: $frontend" >&2
exit 1
fi
cd "$frontend"
# pnpm is the package manager the lockfile is written for; npm would resolve a
# different tree than the one that was tested.
if ! command -v pnpm >/dev/null 2>&1; then
echo "pnpm 10 is required: npm install -g pnpm@10" >&2
exit 1
fi
if [ ! -d node_modules ]; then
echo "installing frontend dependencies ..."
pnpm install --frozen-lockfile
fi
echo "building the frontend ..."
pnpm build
rm -rf "$dest"
mkdir -p "$dest"
cp -R dist/. "$dest/"
if [ ! -f "$dest/index.html" ]; then
echo "the build produced no index.html; the frontend build probably failed." >&2
exit 1
fi
count=$(find "$dest" -type f | wc -l)
size=$(du -sk "$dest" | cut -f1)
echo "page ready: $dest ($count files, $((size / 1024)) MiB)"
# MIT requires the copyright notice and the licence text to travel with a binary
# distribution, so the APK carries it. Generated from the repository's own
# `LICENSE` rather than committed a second time: a checked-in copy is one more
# thing that drifts when the root file is updated.
root_license="$android/../LICENSE"
if [ ! -f "$root_license" ]; then
echo "LICENSE not found: $root_license" >&2
exit 1
fi
cp "$root_license" "$android/app/src/main/assets/LICENSE"
echo "licence: app/src/main/assets/LICENSE (copied from the repository's LICENSE)"
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
#
# Reproduce what `Kernel.kt` does, on this machine, against the page that is
# actually packaged into the APK.
#
# This is the closest thing to an end-to-end test that runs without a phone: the
# launch flags, the token file, the port file and the `--page` directory are the
# same ones the Kotlin shell passes, and the page is read out of
# `app/src/main/assets/page` rather than the frontend's own `dist/`. If this
# serves a working interface, the only untested part is Android itself.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
android="$here/.."
page="$android/app/src/main/assets/page"
kernel="${KERNEL_BIN:-$android/../reasonix-verify.exe}"
work="${VERIFY_DIR:-/tmp/reasonix-verify}"
rm -rf "$work"
mkdir -p "$work/home/remote"
if [ ! -f "$kernel" ]; then
echo "no runnable kernel; build one first:" >&2
echo " (cd .. && go build -o reasonix-verify.exe ./cmd/reasonix)" >&2
exit 1
fi
if [ ! -f "$page/index.html" ]; then
echo "no packaged page: $page/index.html" >&2
exit 1
fi
# 32 random bytes as hex — the same shape `Kernel.mintToken` writes.
token=$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')
printf '%s\n' "$token" > "$work/home/remote/launch.token"
export REASONIX_HOME="$work/home"
export HOME="$work/home"
export TMPDIR="$work"
echo "kernel: $kernel"
echo "page: $page"
echo "token: ${token:0:8}... (${#token} chars)"
"$kernel" serve \
--addr 127.0.0.1:0 \
--auth token \
--token-file "$work/home/remote/launch.token" \
--port-file "$work/home/remote/serve.port" \
--pid-file "$work/home/remote/serve.pid" \
--no-open \
-page "$page" \
> "$work/kernel.log" 2>&1 &
echo $! > "$work/kernel.pid"
# Wait for the port file the kernel writes after binding, which is exactly the
# wait `Kernel.awaitPort` performs.
for _ in $(seq 1 150); do
if [ -s "$work/home/remote/serve.port" ]; then break; fi
if ! kill -0 "$(cat "$work/kernel.pid")" 2>/dev/null; then
echo "the kernel exited; log:" >&2
cat "$work/kernel.log" >&2
exit 1
fi
sleep 0.2
done
if [ ! -s "$work/home/remote/serve.port" ]; then
echo "the kernel reported no listening address; log:" >&2
cat "$work/kernel.log" >&2
exit 1
fi
port=$(cat "$work/home/remote/serve.port")
echo "listening on: $port"
# The two things the WebView will do: fetch the page, and exist on the origin.
probe=$(curl -s -o /dev/null -w '%{http_code}' "http://$port/")
echo "GET / -> $probe"
if [ "$probe" != "200" ]; then
echo "the page did not answer; log:" >&2
cat "$work/kernel.log" >&2
exit 1
fi
cat > "$work/entry.txt" <<EOF
origin=http://$port
entry=http://$port/#token=$token
token=$token
EOF
echo "entry written: $work/entry.txt"
echo "kernel log (first 10 lines):"
head -10 "$work/kernel.log"
+31
View File
@@ -0,0 +1,31 @@
// Reasonix Mobile — the Android shell around the Reasonix kernel.
//
// The shell is deliberately thin: it unpacks the kernel and the built page that
// ship inside the APK, starts `reasonix serve` on a loopback port, and points a
// WebView at it. Everything the person sees and does is the same page the
// desktop Studio and `reasonix web` serve, because it is literally the same
// build of `desktop/frontend-next` — which is what keeps one interface honest
// across the three surfaces, and why the page is packaged rather than fetched.
pluginManagement {
repositories {
// The official repositories only. A mirror listed here would be an
// unverified trust anchor for anything that ends up in a signed build:
// the artifact it serves is whichever copy landed there. A machine that
// needs a mirror belongs on a user-level init script, where the choice
// stays with the person building rather than travelling with the source.
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ReasonixMobile"
include(":app")
+1 -1
View File
@@ -24,7 +24,7 @@ func validateAnswerIsolation(tasks []task) error {
if t.answerRoot == "" {
continue
}
if !sandbox.Available() {
if !sandbox.ConfinesByDefault() {
return fmt.Errorf("%s contains answer material, but this host has no OS read sandbox", t.answerRoot)
}
roots, err := answerReadRoots(t.answerRoot)
+5 -5
View File
@@ -26,13 +26,13 @@ func TestMemoryBenchRequiresReadSandbox(t *testing.T) {
}
}
err = validateAnswerIsolation(tasks)
if sandbox.Available() && err != nil {
if sandbox.ConfinesByDefault() && err != nil {
t.Fatal(err)
}
if !sandbox.Available() && err == nil {
if !sandbox.ConfinesByDefault() && err == nil {
t.Fatal("MemoryBench accepted a host with no OS read sandbox")
}
if sandbox.Available() {
if sandbox.ConfinesByDefault() {
previous := benchStateBase
benchStateBase = func() (string, error) { return filepath.Join(tasks[0].answerRoot, "cache"), nil }
t.Cleanup(func() { benchStateBase = previous })
@@ -43,7 +43,7 @@ func TestMemoryBenchRequiresReadSandbox(t *testing.T) {
}
func TestMemoryBenchAnswerIsolationAtSandboxBoundary(t *testing.T) {
if !sandbox.Available() {
if !sandbox.ConfinesByDefault() {
t.Skip("this host has no OS read sandbox")
}
const suite = "../../benchmarks/memorybench"
@@ -282,7 +282,7 @@ func TestAnswerTrajectoryAuditScansEarlierSegments(t *testing.T) {
t.Fatal(err)
}
}
read, err := auditAnswerTrajectories(dir, task{ID: "demo", answerRoot: root}, 2)
read, err := auditAnswerTrajectories(dir, "demo", task{ID: "demo", answerRoot: root}, 2)
if err != nil || !read {
t.Fatalf("earlier answer read was missed: read=%t err=%v", read, err)
}
+4 -4
View File
@@ -133,7 +133,7 @@ func scanAnswerRead(path string, bodyLines []string) (bool, error) {
return false, nil
}
func auditAnswerTrajectories(dir string, t task, segments int) (bool, error) {
func auditAnswerTrajectories(dir, stem string, t task, segments int) (bool, error) {
if segments < 1 {
return false, fmt.Errorf("run recorded no segments")
}
@@ -142,7 +142,7 @@ func auditAnswerTrajectories(dir string, t task, segments int) (bool, error) {
return false, err
}
for index := 1; index <= segments; index++ {
path := segmentTrajectoryPath(dir, t.ID, segment{index: index}, segments)
path := segmentTrajectoryPath(dir, stem, segment{index: index}, segments)
read, err := scanAnswerRead(path, lines)
if err != nil || read {
return read, err
@@ -170,11 +170,11 @@ func answerTrajectoryDir(dir string, required bool) (string, func(), error) {
return dir, cleanup, nil
}
func auditAnswerRun(r *result, dir string, t task) bool {
func auditAnswerRun(r *result, dir, stem string, t task) bool {
if t.answerRoot == "" {
return true
}
read, err := auditAnswerTrajectories(dir, t, r.Segments)
read, err := auditAnswerTrajectories(dir, stem, t, r.Segments)
if err != nil {
markAnswerAuditIncomplete(r, err)
r.Note = "answer audit: " + err.Error()
+13 -10
View File
@@ -41,19 +41,21 @@ func requestsBySourceLine(bySource map[string]sourceUsage) string {
return "**Requests by source:** " + strings.Join(parts, " · ") + "\n\n"
}
// armStats is one arm's aggregate over a -json report, using the same
// accounting conventions as renderBody: spend totals cover accounted runs
// (failures included) and per-solved figures divide by accounted solves.
// armStats is one arm's aggregate over a -json report: spend totals and
// per-solved figures cover accounted runs (failures included), while wall
// rates (Solved/hour, wall/task) cover every run.
type armStats struct {
Ran, Pass1, Solved, AccountedSolved int
Steps, Tools, Rounds, PlannerCalls int
Tokens, Hit, Miss int
Cost float64
WallMs int64
FirstHit, FirstMiss int64
Damaged, WithCorrect int
TTCS, TTFT []int64
ByClass map[string]classStats
// WallMs is all-run wall (a killed run's wall was still spent);
// AccountedWallMs is the accounted subset the per-solved wall cost divides.
WallMs, AccountedWallMs int64
FirstHit, FirstMiss int64
Damaged, WithCorrect int
TTCS, TTFT []int64
ByClass map[string]classStats
// Fanout covers every run in the arm, no-solution tasks included: work the
// agent delegated is work it paid for whether or not the task was solvable.
Fanout fanoutMetrics
@@ -107,6 +109,7 @@ func aggregateArm(results []result) armStats {
}
c.WallMs += r.WallMs
s.ByClass[label] = c
s.WallMs += r.WallMs
if r.Unaccounted {
continue
}
@@ -119,7 +122,7 @@ func aggregateArm(results []result) armStats {
s.Hit += r.CacheHitTokens
s.Miss += r.CacheMissTokens
s.Cost += r.Cost
s.WallMs += r.WallMs
s.AccountedWallMs += r.WallMs
s.PlannerCalls += r.UsageBySource["planner"].Calls
if r.Trajectory != nil {
s.Rounds += r.Trajectory.ModelRounds
@@ -309,7 +312,7 @@ func compareReports(pathA, pathB string) (string, error) {
fmt.Fprintf(&b, "| Model rounds / solved | %s | %s |\n", perSolved(float64(a.Rounds), a.AccountedSolved), perSolved(float64(bStats.Rounds), bStats.AccountedSolved))
fmt.Fprintf(&b, "| Tool calls / solved | %s | %s |\n", perSolved(float64(a.Tools), a.AccountedSolved), perSolved(float64(bStats.Tools), bStats.AccountedSolved))
fmt.Fprintf(&b, "| Tokens / solved | %s | %s |\n", perSolved(float64(a.Tokens), a.AccountedSolved), perSolved(float64(bStats.Tokens), bStats.AccountedSolved))
fmt.Fprintf(&b, "| Wall seconds / solved | %s | %s |\n", perSolved(float64(a.WallMs)/1000, a.AccountedSolved), perSolved(float64(bStats.WallMs)/1000, bStats.AccountedSolved))
fmt.Fprintf(&b, "| Wall seconds / solved | %s | %s |\n", perSolved(float64(a.AccountedWallMs)/1000, a.AccountedSolved), perSolved(float64(bStats.AccountedWallMs)/1000, bStats.AccountedSolved))
fmt.Fprintf(&b, "| Cost / solved | %s | %s |\n", perSolved(a.Cost, a.AccountedSolved), perSolved(bStats.Cost, bStats.AccountedSolved))
b.WriteString(marginalUtilitySection(a, bStats))
b.WriteString(memoryUtilitySection(pathA, pathB))
@@ -0,0 +1,220 @@
package main
// Wall population rule: a rate figure divides every run's wall (a killed run
// still spent it); a per-solved cost divides the accounted wall of solved runs.
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
// reportSolvedPerHour reads the "Solved/hour" number the suite report prints.
func reportSolvedPerHour(t *testing.T, report string) string {
t.Helper()
m := regexp.MustCompile(`\*\*Solved/hour\*\* ([\d.]+)`).FindStringSubmatch(report)
if m == nil {
t.Fatalf("suite report has no Solved/hour line:\n%s", report)
}
return m[1]
}
// compareSolvedPerHour reads the Solved/hour column out of the compare table's
// one arm row.
func compareSolvedPerHour(t *testing.T, report string) string {
t.Helper()
for line := range strings.SplitSeq(report, "\n") {
if !strings.HasPrefix(line, "| `arm` |") {
continue
}
cells := strings.Split(line, "|")
if len(cells) < 8 {
t.Fatalf("compare row has %d cells:\n%s", len(cells), line)
}
return strings.TrimSpace(cells[7])
}
t.Fatalf("compare table has no arm row:\n%s", report)
return ""
}
// Solved/hour is a rate, not a per-solved figure: the suite report divides every
// run's wall (a killed run's tokens are unknown, its wall was still spent), so
// aggregateArm must not drop the unaccounted run's wall from that denominator.
func TestCompareSolvedPerHourUsesTheSameWallAsTheReport(t *testing.T) {
dir := testenv.TempDir(t)
results := []result{
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
{task: task{ID: "killed"}, Passed: true, WallMs: 1_800_000, Unaccounted: true},
}
path := writeArm(t, dir, "arm.json", results)
want := reportSolvedPerHour(t, renderBody(results))
report, err := multiCompareReport([]string{path, path})
if err != nil {
t.Fatalf("multiCompareReport: %v", err)
}
if got := compareSolvedPerHour(t, report); got != want {
t.Fatalf("compare Solved/hour = %s, suite report says %s for the same artifact\n"+
"1600s of known wall (one 30-minute killed run) went missing from the compare denominator:\n%s",
got, want, report)
}
}
// Same root cause, second printed number: the per-class table counts every
// run's wall (c.WallMs is accumulated before the Unaccounted continue) while the
// global marginal-utility line divided only accounted wall by the same task
// denominator. One section, two populations: the class row says A cost +870.0s
// per task more, the global line said -30.0s less.
func TestMarginalUtilityGlobalWallMatchesThePerClassRows(t *testing.T) {
dir := testenv.TempDir(t)
mk := func(id string, wallMs int64, unaccounted bool) result {
r := result{task: task{ID: id, Class: "bugfix"}, Passed: true, WallMs: wallMs}
r.Unaccounted = unaccounted
return r
}
a := []result{
mk("ok", 60_000, false),
mk("killed", 1_800_000, true),
}
b := []result{
mk("ok", 60_000, false),
mk("killed2", 60_000, false),
}
pathA := writeArm(t, dir, "a.json", a)
pathB := writeArm(t, dir, "b.json", b)
report, err := compareReports(pathA, pathB)
if err != nil {
t.Fatalf("compareReports: %v", err)
}
var row string
for line := range strings.SplitSeq(report, "\n") {
if strings.HasPrefix(line, "| bugfix |") {
row = line
}
}
if row == "" {
t.Fatalf("no bugfix class row:\n%s", report)
}
global := regexp.MustCompile(`wall/task ([+-][\d.]+)s`).FindStringSubmatch(report)
if global == nil {
t.Fatalf("no global wall/task line:\n%s", report)
}
// The one class row reports A 930.0s/task, B 60.0s/task, i.e. +870.0s.
if global[1] != "+870.0" {
t.Fatalf("global wall/task = %ss while the per-class row beside it says +870.0s "+
"(A 930.0s vs B 60.0s): the global figure drops the unaccounted run's wall, "+
"the class row keeps it\n%s\n%s", global[1], row, report)
}
}
// compareOutput pins a compare rendering byte for byte. RX_UPDATE_GOLDEN
// rewrites it; the no-unaccounted fixture is the population where the two wall
// totals coincide, so the readout must not move when only the accounting does.
func compareOutput(t *testing.T, dir, name, got string) {
t.Helper()
path := filepath.Join(dir, name)
if os.Getenv("RX_UPDATE_GOLDEN") != "" {
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read golden: %v", err)
}
if got != string(want) {
t.Fatalf("%s: compare output changed\n--- got ---\n%s\n--- want ---\n%s", name, got, want)
}
}
// A fixture with no unaccounted runs is where every wall is accounted wall:
// both rates must render exactly as they did before the population split.
func TestCompareOutputUnchangedWithoutUnaccountedRuns(t *testing.T) {
dir := testenv.TempDir(t)
goldenDir, err := filepath.Abs("testdata")
if err != nil {
t.Fatalf("testdata dir: %v", err)
}
t.Chdir(dir)
a := []result{
{task: task{ID: "a", Class: "bugfix"}, Passed: true, WallMs: 60_000, Attempt: 1, TTCSMs: 60_000,
runMetrics: runMetrics{Steps: 7, ToolCalls: 10, PromptTokens: 900, CompletionTokens: 100, Cost: 0.02, Currency: "$"}},
{task: task{ID: "b", Class: "perf"}, WallMs: 90_000, Attempt: 1,
runMetrics: runMetrics{Steps: 9, ToolCalls: 14, PromptTokens: 1200, CompletionTokens: 200, Cost: 0.03, Currency: "$"}},
}
b := []result{
{task: task{ID: "a", Class: "bugfix"}, Passed: true, WallMs: 45_000, Attempt: 1, TTCSMs: 45_000,
runMetrics: runMetrics{Steps: 5, ToolCalls: 10, PromptTokens: 700, CompletionTokens: 100, Cost: 0.01, Currency: "$"}},
{task: task{ID: "b", Class: "perf"}, WallMs: 90_000, Attempt: 1,
runMetrics: runMetrics{Steps: 9, ToolCalls: 14, PromptTokens: 1200, CompletionTokens: 200, Cost: 0.03, Currency: "$"}},
}
writeArm(t, ".", "a.json", a)
writeArm(t, ".", "b.json", b)
ab, err := compareReports("a.json", "b.json")
if err != nil {
t.Fatalf("compareReports: %v", err)
}
compareOutput(t, goldenDir, "no_unaccounted_ab.golden", ab)
multi, err := multiCompareReport([]string{"a.json", "b.json"})
if err != nil {
t.Fatalf("multiCompareReport: %v", err)
}
compareOutput(t, goldenDir, "no_unaccounted_multi.golden", multi)
}
// Wall seconds / solved divides the accounted wall by accounted solves: the
// unaccounted run's wall must stay out of that ratio even though it now enters
// every rate figure.
func TestWallSecondsPerSolvedKeepsAccountedPopulation(t *testing.T) {
dir := testenv.TempDir(t)
a := []result{
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
{task: task{ID: "killed"}, Passed: true, WallMs: 1_800_000, Unaccounted: true},
}
b := []result{
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
{task: task{ID: "ok2"}, Passed: true, WallMs: 60_000},
}
report, err := compareReports(writeArm(t, dir, "a.json", a), writeArm(t, dir, "b.json", b))
if err != nil {
t.Fatalf("compareReports: %v", err)
}
// A: 60s accounted wall over 1 accounted solve; its all-run wall (1860s)
// would print 1860.0 here, or 930.0 over all solves. B has no unaccounted
// run, so both walls coincide at 60.0.
if !strings.Contains(report, "| Wall seconds / solved | 60.0 | 60.0 |") {
t.Fatalf("Wall seconds / solved is not accounted wall / accounted solves:\n%s", report)
}
for _, wrong := range []string{"| Wall seconds / solved | 1860.0 |", "| Wall seconds / solved | 930.0 |"} {
if strings.Contains(report, wrong) {
t.Fatalf("Wall seconds / solved divided the all-run wall (%s):\n%s", wrong, report)
}
}
}
// The suite report's Solved/hour convention is the reference the compare mode
// now follows: every attempt's wall, solved runs over that total.
func TestSuiteReportSolvedPerHourCountsEveryRun(t *testing.T) {
withKilled := []result{
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
{task: task{ID: "killed"}, Passed: true, WallMs: 1_800_000, Unaccounted: true},
}
if got := reportSolvedPerHour(t, renderBody(withKilled)); got != "3.9" {
t.Errorf("suite report Solved/hour = %s, want 3.9 (2 solves over 1860s)", got)
}
allAccounted := []result{
{task: task{ID: "ok"}, Passed: true, WallMs: 60_000},
{task: task{ID: "ok2"}, Passed: true, WallMs: 60_000},
}
if got := reportSolvedPerHour(t, renderBody(allAccounted)); got != "60.0" {
t.Errorf("suite report Solved/hour = %s, want 60.0 (2 solves over 120s)", got)
}
}
+4 -3
View File
@@ -272,7 +272,7 @@ func main() {
ablateFlag := flag.String("ablate", "", "ablation arm: subsystems to switch off ("+ablation.ModuleList()+"; none|all)")
foldIndexFlag := flag.String("fold-index", "", "fold-index arm: how much of the model-visible address list to keep (default|half|quarter|off)")
outMD := flag.String("out", "", "write the markdown report here (default: stdout)")
trajDir := flag.String("trajectories", "", "suite mode: write one <task-id>.trajectory.jsonl per task into this directory")
trajDir := flag.String("trajectories", "", "suite mode: write one <task-id>.trajectory.jsonl per task into this directory (<task-id>.trial-N.trajectory.jsonl per trial under -trials)")
forcePlanner := flag.Bool("force-planner", false, "suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate")
anchorFlag := flag.String("anchor", anchorBlind, "suite mode: hypothesis arm — blind (no hypothesis) | correct | wrong; correct/wrong prefix each prompt with the task's authored seed and skip tasks that have none")
outJSON := flag.String("json", "", "write the JSON report here (optional)")
@@ -585,10 +585,11 @@ func runTask(cfg suiteConfig, t task) result {
}
mtr.record(&r)
if trajPath = lastSegmentTrajectory(runTrajDir, t.ID, r.Segments); trajPath != "" {
stem := trajectoryStem(t.ID, cfg.trial, cfg.trials)
if trajPath = lastSegmentTrajectory(runTrajDir, stem, r.Segments); trajPath != "" {
recordTaskTrajectory(&r, trajPath, t)
}
if !auditAnswerRun(&r, runTrajDir, t) {
if !auditAnswerRun(&r, runTrajDir, stem, t) {
return r
}
// A killed child never writes metrics, so the deadline is the only place
+38
View File
@@ -0,0 +1,38 @@
package main
import "reasonix/internal/contract/tool"
// callNesting tells the model's own calls from calls made on its behalf: a
// subagent's (stamped with ParentID) and those a running tool makes through
// the agent's invoker (run_script's), which carry an id derived from their
// caller's and arrive inside its dispatch→result window. Either kind ran
// inside one top-level call, so it bought no model request of its own.
type callNesting struct {
open map[string]bool // top-level calls dispatched and not yet resulted
}
// child reports whether a tool event belongs to a call made on the model's
// behalf, and tracks which top-level calls are still running.
func (n *callNesting) child(kind, id, parentID string) bool {
if parentID != "" {
return true
}
for caller := range n.open {
if tool.IsNestedCallOf(id, caller) {
return true
}
}
if id == "" {
return false
}
switch kind {
case "tool_dispatch":
if n.open == nil {
n.open = map[string]bool{}
}
n.open[id] = true
case "tool_result":
delete(n.open, id)
}
return false
}
+1
View File
@@ -99,6 +99,7 @@ type trajScan struct {
gapReason, gapCompl, gapPrompt int64
delegationToolMs map[string]int64
nesting callNesting
}
// modelAttempt is one sampling attempt's wall interval; planner marks attempts
+4 -2
View File
@@ -25,6 +25,7 @@ func splitAtCorrect(path string, cutoffUnixMs int64) boundarySplit {
}
defer f.Close()
inModel := true
var nesting callNesting
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 1<<20), 16<<20)
for sc.Scan() {
@@ -32,7 +33,7 @@ func splitAtCorrect(path string, cutoffUnixMs int64) boundarySplit {
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
continue
}
if rec.Event == nil || rec.Event.Tool == nil || rec.Event.Tool.ParentID != "" {
if rec.Event == nil || rec.Event.Tool == nil || nesting.child(rec.Event.Kind, rec.Event.Tool.ID, rec.Event.Tool.ParentID) {
continue
}
after := rec.TS > cutoffUnixMs
@@ -80,6 +81,7 @@ func roundEnds(path string) []int64 {
var ends []int64
var lastResult int64
inModel := true
var nesting callNesting
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 1<<20), 16<<20)
for sc.Scan() {
@@ -87,7 +89,7 @@ func roundEnds(path string) []int64 {
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
continue
}
if rec.Event == nil || rec.Event.Tool == nil || rec.Event.Tool.ParentID != "" {
if rec.Event == nil || rec.Event.Tool == nil || nesting.child(rec.Event.Kind, rec.Event.Tool.ID, rec.Event.Tool.ParentID) {
continue
}
switch rec.Event.Kind {
+32 -1
View File
@@ -6,6 +6,8 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
@@ -36,7 +38,7 @@ func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir str
var runErr error
for _, seg := range segs {
metricsPath := filepath.Join(metricsDir, fmt.Sprintf(".run-metrics-%d.json", seg.index))
trajPath := segmentTrajectoryPath(trajDir, t.ID, seg, len(segs))
trajPath := segmentTrajectoryPath(trajDir, trajectoryStem(t.ID, cfg.trial, cfg.trials), seg, len(segs))
args := buildSegmentArgs(cfg, seg, metricsPath, trajPath)
cmd := exec.CommandContext(ctx, cfg.bin, args...)
@@ -85,6 +87,35 @@ func legMetrics(path string) (m runMetrics, partial bool, err error) {
return m, false, err
}
// trajectoryStem names one run's trajectory files: the task id, and the trial
// when -trials repeats the task, so a later trial cannot overwrite an earlier
// one. A single run keeps the bare id every reader already knows.
func trajectoryStem(id string, trial, trials int) string {
if trials < 2 {
return id
}
return fmt.Sprintf("%s.trial-%d", id, trial)
}
// trajectoryTaskID is the task a trajectory file recorded: its name without
// the trial mark of trajectoryStem or the leg mark of segmentTrajectoryPath.
func trajectoryTaskID(path string) string {
id := strings.TrimSuffix(filepath.Base(path), ".trajectory.jsonl")
id = trimNumberedMark(id, ".seg")
return trimNumberedMark(id, ".trial-")
}
func trimNumberedMark(id, mark string) string {
i := strings.LastIndex(id, mark)
if i < 0 {
return id
}
if _, err := strconv.Atoi(id[i+len(mark):]); err != nil {
return id
}
return id[:i]
}
// segmentTrajectoryPath keeps one file per leg so a resumed leg cannot truncate
// the record of the one before it.
func segmentTrajectoryPath(dir, id string, seg segment, total int) string {
+1 -2
View File
@@ -153,8 +153,7 @@ func sftInputs(paths []string, suite, report string) (iter.Seq2[sftInput, error]
}
func sftInputFor(path, suite string, tasks map[string]task, verdicts map[string]map[string]result) (*sftInput, error) {
id := filepath.Base(path)
id = id[:len(id)-len(".trajectory.jsonl")]
id := trajectoryTaskID(path)
records, digest, err := readSFTSnapshot(path)
if err != nil {
return nil, err
+60
View File
@@ -0,0 +1,60 @@
## e2ebench A/B: `a.json` vs `b.json`
| Metric | A | B |
|---|---:|---:|
| Solved | 1/2 (50%) | 1/2 (50%) |
| Pass@1 | 50% | 50% |
| TTFT median | 0ms | 0ms |
| TTCS median | 1m00s | 45.0s |
| TTCS p90 | 1m00s | 45.0s |
| Cache hit | n/a | n/a |
| First-request cache hit | n/a | n/a |
| Overthinking damage | n/a | n/a |
| Model requests / solved | 16.0 | 14.0 |
| Planner requests / solved | 0.0 | 0.0 |
| Model rounds / solved | 0.0 | 0.0 |
| Tool calls / solved | 24.0 | 24.0 |
| Tokens / solved | 2400.0 | 2200.0 |
| Wall seconds / solved | 150.0 | 135.0 |
| Cost / solved | 0.1 | 0.0 |
**Marginal utility (A − B):** accuracy +0.0pp · wall/task +7.5s
| Class | A solved | B solved | Δ accuracy | A wall/task | B wall/task | Δ wall |
|---|---:|---:|---:|---:|---:|---:|
| bugfix | 1/1 | 1/1 | +0.0pp | 60.0s | 45.0s | +15.0s |
| perf | 0/1 | 0/1 | +0.0pp | 90.0s | 90.0s | +0.0s |
### Pareto: accuracy vs TTCS
```
Accuracy
100% |
|
|
|
|
|
| B ✗
|
40% |
+----------------------------------------------→ TTCS
43.5s 1m01s
A=a B=b
```
- ⚠️ `a` is **dominated** by `b`: at least as accurate and no slower — the unambiguous alarm
- ✅ `b` is on the Pareto frontier (50% solved, TTCS median 45.0s)
### Reproduction
- capability set: unknown (a report records no capability set)
- task prompts: same 2 task ids and prompts
- not compared: commit, build, configuration, grader, task files other than the prompt
| Rate | A | B | B − A (95% CI) | |
|---|---:|---:|---:|---|
| grader pass (solvable) | 1/2 | 1/2 | +0.0pp (-57.3, +57.3) | within noise |
<sub>Per-solved figures divide each arm's accounted totals (failures included) by its accounted solves.</sub>
+36
View File
@@ -0,0 +1,36 @@
## e2ebench comparison: 2 arms
| Arm | Pass@1 | Solved | TTFT | TTCS median | TTCS p90 | Solved/hour | 1st-req cache | Requests/solved | Tokens/solved | Cost/solved |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| `a` | 50% | 1/2 | 0ms | 1m00s | 1m00s | 24.0 | n/a | 16.0 | 2,400 | 0.0500 |
| `b` | 50% | 1/2 | 0ms | 45.0s | 45.0s | 26.7 | n/a | 14.0 | 2,200 | 0.0400 |
### Pareto: accuracy vs TTCS
```
Accuracy
100% |
|
|
|
|
|
| B ✗
|
40% |
+----------------------------------------------→ TTCS
43.5s 1m01s
A=a B=b
```
- ⚠️ `a` is **dominated** by `b`: at least as accurate and no slower — the unambiguous alarm
- ✅ `b` is on the Pareto frontier (50% solved, TTCS median 45.0s)
### Per-class winners
| Class | `a` | `b` | Winner |
|---|---:|---:|---|
| bugfix | 100% · 1m00s | 100% · 45.0s | b |
| perf | 0% · — | 0% · — | a |
<sub>Per-solved figures divide each arm's accounted totals (failures included) by its accounted solves; TTCS charges a retried solve with its failed attempts' wall.</sub>
+25 -9
View File
@@ -20,9 +20,9 @@ type trajectorySummary struct {
SpanMs int64 `json:"span_ms"`
ToolMs int64 `json:"tool_ms"`
ModelMs int64 `json:"model_ms"` // SpanMs − tool wall clock, floored at zero
// Round decomposition: a round's gap runs from the turn start (or the
// batch's last tool_result) to the next top-level tool_dispatch; the
// final segment to the last record is the answer round.
// ModelRounds counts executor round trips: one per batch of top-level calls
// plus the answer segment. Calls a subagent or a running tool (run_script)
// makes are not top-level and cut no round; retries in a gap add none.
ModelRounds int `json:"model_rounds"`
ModelGapTotalMs int64 `json:"model_gap_total_ms"`
ModelGapP95Ms int64 `json:"model_gap_p95_ms"`
@@ -105,6 +105,9 @@ type trajectorySummary struct {
PromptTokensSeen int64 `json:"prompt_tokens_seen,omitempty"` // Σ prompt tokens (schema share denominator)
PrefixResets int `json:"prefix_resets,omitempty"` // usage events with prefixChanged
ConnectCalls int `json:"connect_calls,omitempty"` // connect_tool_source dispatches
// NestedCalls counts results of calls a running tool made through the
// agent's invoker; they are outside TopLevelCalls and every round figure.
NestedCalls int `json:"nested_calls,omitempty"`
// Cold/warm evidence: the first top-level request's cache split. A cold
// session pays the whole prefix as miss; a warmed one starts near-hit.
@@ -421,9 +424,21 @@ func (t *trajScan) record(rec trajectoryRecord) {
}
t.compFrom = 0
}
if rec.Event.Tool == nil || rec.Event.Tool.ParentID != "" {
// Subagent calls overlap the parent's wall clock; counting them
// would double-book the span and split the parent's rounds.
t.recordTool(rec)
}
// recordTool books a top-level tool event to its round and batch.
func (t *trajScan) recordTool(rec trajectoryRecord) {
tl := rec.Event.Tool
if tl == nil {
return
}
if t.nesting.child(rec.Event.Kind, tl.ID, tl.ParentID) {
// Nested calls overlap their caller's wall clock; counting them would
// double-book the span and split the caller's round.
if tl.ParentID == "" && rec.Event.Kind == "tool_result" {
t.s.NestedCalls++
}
return
}
switch rec.Event.Kind {
@@ -434,9 +449,10 @@ func (t *trajScan) record(rec trajectoryRecord) {
}
}
// closeGap ends one model round; recovery-tainted rounds are booked apart so
// clean latency stays comparable across providers with different flake rates.
// The gap is queued until its batch closes and can classify the round.
// closeGap ends one model round: the gap from the turn start or the last
// top-level tool_result to the next top-level tool_dispatch. Recovery-tainted
// rounds are booked apart so clean latency stays comparable across providers
// with different flake rates. The gap is queued until its batch classifies it.
func (t *trajScan) closeGap(gap int64) {
t.gaps = append(t.gaps, gap)
t.pendingGaps = append(t.pendingGaps, gapInfo{
+59
View File
@@ -625,3 +625,62 @@ func TestSummarizeTrajectoryCountsRefusedRoundsAsWaste(t *testing.T) {
t.Error("a refused round must carry its gap time into the waste breakdown")
}
}
// A run_script call's own calls run under ids derived from the script's, inside
// its dispatch→result window. They are one model request, not one per call, so
// they must neither cut model rounds nor reach round outcomes as top-level work.
func TestSummarizeTrajectoryFoldsScriptCallsIntoTheirRound(t *testing.T) {
path := filepath.Join(testenv.TempDir(t), "script.trajectory.jsonl")
lines := []string{
`{"ts":1000,"event":{"kind":"turn_started"}}`,
`{"ts":1900,"event":{"kind":"usage","usage":{"promptTokens":10}}}`,
`{"ts":2000,"event":{"kind":"tool_dispatch","tool":{"id":"call_s","name":"run_script"}}}`,
`{"ts":2100,"event":{"kind":"tool_dispatch","tool":{"id":"call_s.1","name":"read_file","readOnly":true}}}`,
`{"ts":2200,"event":{"kind":"tool_result","tool":{"id":"call_s.1","name":"read_file","readOnly":true,"durationMs":100,"startedAt":2100,"endedAt":2200}}}`,
`{"ts":2300,"event":{"kind":"tool_dispatch","tool":{"id":"call_s.2","name":"read_file","readOnly":true}}}`,
`{"ts":2400,"event":{"kind":"tool_result","tool":{"id":"call_s.2","name":"read_file","readOnly":true,"durationMs":100,"startedAt":2300,"endedAt":2400}}}`,
`{"ts":2500,"event":{"kind":"tool_dispatch","tool":{"id":"call_s.3","name":"edit_file"}}}`,
`{"ts":2600,"event":{"kind":"tool_result","tool":{"id":"call_s.3","name":"edit_file","durationMs":100,"startedAt":2500,"endedAt":2600}}}`,
`{"ts":2700,"event":{"kind":"tool_result","tool":{"id":"call_s","name":"run_script","durationMs":700,"startedAt":2000,"endedAt":2700}}}`,
`{"ts":3900,"event":{"kind":"usage","usage":{"promptTokens":20}}}`,
`{"ts":4000,"event":{"kind":"turn_done"}}`,
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
s, err := summarizeTrajectory(path)
if err != nil {
t.Fatalf("summarizeTrajectory: %v", err)
}
if s.ExecutorRequests != 2 {
t.Fatalf("executor requests = %d, want 2", s.ExecutorRequests)
}
if s.ModelRounds != 2 {
t.Errorf("model rounds = %d, want 2 (script round + answer), one per model request", s.ModelRounds)
}
if s.ModelGapTotalMs != 2300 {
t.Errorf("model gap total = %d, want 2300 (1000 before the script, 1300 after it)", s.ModelGapTotalMs)
}
if s.TopLevelCalls != 1 || s.ToolBatches != 1 {
t.Errorf("top-level calls/batches = %d/%d, want 1/1", s.TopLevelCalls, s.ToolBatches)
}
if s.NestedCalls != 3 {
t.Errorf("nested calls = %d, want 3", s.NestedCalls)
}
if s.ToolMs != 700 {
t.Errorf("tool ms = %d, want 700 (nested time is inside the script's)", s.ToolMs)
}
total := 0
for _, n := range s.RoundOutcomes {
total += n
}
if total != 2 || s.RoundOutcomes["finalization"] != 1 {
t.Errorf("round outcomes = %v, want two rounds, one of them finalization", s.RoundOutcomes)
}
if ends := roundEnds(path); len(ends) != 1 || ends[0] != 2700 {
t.Errorf("round ends = %v, want [2700]", ends)
}
if split := splitAtCorrect(path, 0); split.RoundsAfter != 1 || split.CallsAfter != 1 {
t.Errorf("split = %+v, want one round and one call after the cutoff", split)
}
}
+85
View File
@@ -1,8 +1,13 @@
package main
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
func trialResult(id string, trial int, passed bool) result {
@@ -68,3 +73,83 @@ func TestTrialsLineIsSilentWithoutTrials(t *testing.T) {
t.Fatalf("line = %q, want nothing for a run without trials", got)
}
}
// trajectoryAgent stands in for the reasonix binary and writes a one-record
// trajectory wherever --trajectory points, so a test can see which files a run
// leaves behind.
func trajectoryAgent(t *testing.T) string {
t.Helper()
requireShellStub(t)
bin := filepath.Join(testenv.TempDir(t), "fake-agent")
script := `#!/usr/bin/env bash
while [ $# -gt 0 ]; do
case "$1" in
--trajectory) printf '{"ts":1,"event":{"kind":"turn_done"}}\n' > "$2"; shift 2;;
--metrics) printf '{"steps":1,"complete":true}' > "$2"; shift 2;;
*) shift;;
esac
done
exit 0
`
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return bin
}
// Each trial records its own trajectory: a shared name would leave only the
// last trial's file, and every digest of the earlier ones would read it.
func TestTrialsKeepOneTrajectoryPerTrial(t *testing.T) {
dir, taskDir := testenv.TempDir(t), testenv.TempDir(t)
if err := os.WriteFile(filepath.Join(taskDir, "prompt.md"), []byte("p"), 0o644); err != nil {
t.Fatal(err)
}
cfg := suiteConfig{bin: trajectoryAgent(t), trials: 2, trajDir: dir}
total := 0
results := runTrials(cfg, task{ID: "demo", Prompt: "p", MaxSteps: 4, TimeoutSec: 60, dir: taskDir}, &total)
if len(results) != 2 {
t.Fatalf("results = %d, want 2", len(results))
}
got, err := filepath.Glob(filepath.Join(dir, "*.trajectory.jsonl"))
if err != nil {
t.Fatal(err)
}
for i := range got {
got[i] = filepath.Base(got[i])
}
slices.Sort(got)
want := []string{"demo.trial-1.trajectory.jsonl", "demo.trial-2.trajectory.jsonl"}
if !slices.Equal(got, want) {
t.Fatalf("trajectory files = %v, want %v", got, want)
}
for _, r := range results {
if r.Trajectory == nil || filepath.Base(r.Trajectory.Path) != want[r.Trial-1] {
t.Errorf("trial %d digest = %+v, want it read from %s", r.Trial, r.Trajectory, want[r.Trial-1])
}
}
}
// A single trial keeps the name every reader already knows.
func TestSingleTrialKeepsTheTaskTrajectoryName(t *testing.T) {
if got := trajectoryStem("demo", 1, 1); got != "demo" {
t.Fatalf("stem = %q, want demo", got)
}
if got := trajectoryStem("demo", 0, 0); got != "demo" {
t.Fatalf("stem = %q, want demo", got)
}
}
// Readers that need the task behind a file strip the trial and segment marks.
func TestTrajectoryTaskIDReadsEveryFileShape(t *testing.T) {
for name, want := range map[string]string{
"demo.trajectory.jsonl": "demo",
"demo.trial-2.trajectory.jsonl": "demo",
"demo.seg3.trajectory.jsonl": "demo",
"demo.trial-2.seg3.trajectory.jsonl": "demo",
"a.b.trial-10.trajectory.jsonl": "a.b",
} {
if got := trajectoryTaskID(filepath.Join("d", name)); got != want {
t.Errorf("trajectoryTaskID(%s) = %q, want %q", name, got, want)
}
}
}
+22
View File
@@ -0,0 +1,22 @@
package main
import (
"os/exec"
"strings"
"testing"
)
// The host runs a scheduled child through the small schedulerun package. The
// command line is not part of what it ships, and importing it again for one
// entry point would bring back every command it carries.
func TestTheHostDoesNotCarryTheCommandLine(t *testing.T) {
out, err := exec.Command("go", "list", "-deps", ".").Output()
if err != nil {
t.Skipf("go list is not available: %v", err)
}
for pkg := range strings.FieldsSeq(string(out)) {
if pkg == "reasonix/internal/frontend/cli" {
t.Fatal("the studio host depends on the command line package")
}
}
}
+25 -5
View File
@@ -18,6 +18,7 @@ import (
"os"
"os/signal"
"path/filepath"
"reasonix/internal/ext/modhost"
"strings"
"sync"
"syscall"
@@ -33,6 +34,7 @@ import (
"reasonix/internal/contract/provider"
"reasonix/internal/contract/surface"
"reasonix/internal/frontend/remotehost"
"reasonix/internal/frontend/schedulerun"
"reasonix/internal/frontend/serve"
"reasonix/internal/frontend/traystate"
"reasonix/internal/platform/account"
@@ -48,6 +50,7 @@ import (
// Kinds register from init, so a binary builds only what it links. Without
// these every Anthropic model answers "unknown kind" at switch time.
_ "reasonix/internal/model/anthropic"
_ "reasonix/internal/model/gemini"
_ "reasonix/internal/model/openai"
_ "reasonix/internal/model/responses"
)
@@ -121,6 +124,10 @@ func main() {
if handled, code := update.MaybeRunTreeHandoff(os.Args[1:]); handled {
os.Exit(code)
}
// A scheduled run is this same binary started by the in-kernel ticker.
if scheduleChildArgs(os.Args[1:]) {
os.Exit(schedulerun.Exec(os.Args[3:]))
}
page := flag.String("page", "", "directory holding the built Studio page")
identity := flag.Bool("instance-id", false, "print the Studio instance this data home belongs to, and exit")
// The shell around this process knows which build it is; this process does
@@ -140,6 +147,8 @@ func main() {
if *stripGrants {
os.Exit(stripPackageGrants(os.Stdout, os.Stderr, *studioApp))
}
// Studio's own binary runs a Claude Code mod's module as Node.
modhost.SetEmbedder(*studioApp, os.Environ())
// Which launches are the same Studio is Reasonix's question, not a shell's:
// the answer is the canonicalized data home, and a shell asks for it rather
// than working it out from an environment it does not resolve.
@@ -250,6 +259,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
// After the socket has drained, never before: a pane torn down under a live
// request answers it with a half-closed kernel.
defer hub.Shutdown()
startScheduleTicker(ctx, lease != nil, hub, logs)
bound, err := bind(hub.Handler())
phases.mark(startupBind)
@@ -376,7 +386,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
// This window is the only client of its kernel, so a system notification
// reaches the person who asked for it. Every pane gets the same wrapper,
// not just the one the launch started with.
notifications, notifySet := hostNotifications(cfg)
notifications, notifySet, phoneLink := hostNotifications(cfg)
// One fold behind the status icon: every pane's events on the way through,
// so the tray surface answers from what the panes did rather than from a
// count the shell kept for itself.
@@ -464,7 +474,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
return nil, err
}
hub.StartRecoveryGC(ctx)
startCloudRemote(ctx, cfg, logs, hub)
startCloudRemote(ctx, cfg, logs, hub, phoneLink)
phases.mark(startupBackground)
return hub, nil
}
@@ -490,7 +500,7 @@ func runtimeOptions(root string, sink event.Sink, logs io.Writer, phases *startu
}
}
func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, tasks remotecloud.TaskService) {
func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, tasks remotecloud.TaskService, phones *notify.PhoneLink) {
httpClient, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{})
if err != nil {
fmt.Fprintf(logs, "remote cloud: %v\n", err)
@@ -519,6 +529,12 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
}
})
}
// Phone notifications leave through this connection, under this Studio's own
// device credential.
phones.Set(host)
if registrar, ok := tasks.(interface{ SetCloudPhones(serve.CloudPhones) }); ok {
registrar.SetCloudPhones(host)
}
go host.Run(ctx)
}
@@ -563,13 +579,17 @@ func hostServeConfig(cfg config.ServeConfig) config.ServeConfig {
// says today, because deciding here would make "notify me" mean "notify me
// after a restart". The shared [notifications] config is the one setting this
// window and the CLI both answer to.
func hostNotifications(cfg *config.Config) (func(event.Sink) event.Sink, *notify.Settings) {
func hostNotifications(cfg *config.Config) (func(event.Sink) event.Sink, *notify.Settings, *notify.PhoneLink) {
set := notify.NewSettings(config.NotificationsConfig{})
if cfg != nil {
set.Store(cfg.Notifications)
}
sender := notify.NewPlatformSender()
return func(sink event.Sink) event.Sink { return notify.NewSink(sink, sender, i18n.M, set) }, set
// One reporter for every runtime, so a burst across panes folds into one
// report; its sender is the account connection, attached once that is up.
link := &notify.PhoneLink{}
phone := notify.NewPhone(link)
return func(sink event.Sink) event.Sink { return notify.NewSink(sink, sender, i18n.M, set).WithPhone(phone) }, set, link
}
// studioTray answers for a shell that can put an icon back whenever the setting
+43 -1
View File
@@ -480,7 +480,7 @@ func TestHostNotificationsCarryTheSharedSettingLive(t *testing.T) {
{Notifications: config.NotificationsConfig{Enabled: true}},
nil,
} {
wrap, set := hostNotifications(cfg)
wrap, set, _ := hostNotifications(cfg)
if got := wrap(plain); got == event.Sink(plain) {
t.Fatalf("runtime was left unwrapped, so the switch could never reach it (cfg %+v)", cfg)
}
@@ -551,3 +551,45 @@ func TestAssembleAppliesTheStartupUpgrades(t *testing.T) {
t.Fatalf("after the window started, the desktop posture is %q, want the shipped auto released", got)
}
}
type phoneReports chan [2]any
func (p phoneReports) NotifyPhones(_ context.Context, kind string, count int) error {
p <- [2]any{kind, count}
return nil
}
// A runtime's own events, through the wrapper every pane gets, reach the account
// connection as a kind and a count and nothing else, and only once the switches
// ask for it.
func TestAnApprovalOnAPaneReachesThePhoneLinkAsATypeAndACount(t *testing.T) {
cfg := &config.Config{Notifications: config.NotificationsConfig{Phone: config.PhoneNotificationsConfig{Enabled: true, Approval: true}}}
wrap, set, link := hostNotifications(cfg)
reports := make(phoneReports, 4)
link.Set(reports)
sink := wrap(serve.NewBroadcaster())
sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "1", Tool: "bash", Subject: "git push --force"}})
select {
case got := <-reports:
if got != [2]any{"approval", 1} {
t.Fatalf("reported %v, want a type and a count and no text", got)
}
case <-time.After(2 * time.Second):
t.Fatal("an approval on a pane did not reach the phone link")
}
sink.Emit(event.Event{Kind: event.AskRequest})
select {
case got := <-reports:
t.Fatalf("reported %v for a kind whose switch is off", got)
case <-time.After(100 * time.Millisecond):
}
set.Store(config.NotificationsConfig{})
sink.Emit(event.Event{Kind: event.ApprovalRequest})
select {
case got := <-reports:
t.Fatalf("reported %v after the master switch was turned off", got)
case <-time.After(100 * time.Millisecond):
}
}
@@ -0,0 +1,63 @@
package main
import (
"context"
"fmt"
"io"
"path/filepath"
"reasonix/internal/base/filelock"
"reasonix/internal/frontend/serve"
"reasonix/internal/runtime/schedrun"
"reasonix/internal/runtime/scheduler"
)
// scheduleChildArgs says argv is a supervised scheduled run: the supervisor
// starts this binary as `schedule exec <id>`, and this host has no CLI of its own.
func scheduleChildArgs(args []string) bool {
return len(args) >= 2 && args[0] == "schedule" && args[1] == "exec"
}
// scheduleEligible is when this kernel may start scheduled runs: it has a
// supervised lifetime (a parent that ends it) and something the person can see.
// An orphan, or a kernel nobody can see, never spends on anyone's behalf.
func scheduleEligible(supervised, iconLive bool, panes int) bool {
return supervised && (iconLive || panes > 0)
}
// startScheduleTicker runs the in-kernel driver of scheduled runs. Every kernel
// on the machine may run it; the ticker lock lets one tick at a time.
func startScheduleTicker(ctx context.Context, supervised bool, hub *serve.Hub, logs io.Writer) {
if !supervised {
return
}
loop := &scheduler.Loop{
Eligible: func() bool { return scheduleEligible(supervised, (&studioTray{}).IconLive(), len(hub.Runtimes())) },
Lock: func() (func(), error) {
store, _, err := scheduler.OpenUserStore(nil)
if err != nil {
return nil, err
}
return filelock.TryAcquire(filepath.Join(store.Dir(), "scheduler.lock"))
},
Tick: func(ctx context.Context) (scheduler.Result, error) {
store, policy, err := scheduler.OpenUserStore(nil)
if err != nil {
return scheduler.Result{}, err
}
return (&scheduler.Scheduler{Store: store, Policy: policy, Runner: &schedrun.Supervisor{Store: store, Policy: policy}}).Tick(ctx)
},
Done: func(res scheduler.Result, err error) {
for _, r := range res.Reports {
fmt.Fprintf(logs, "schedule: ran %s: %s %s\n", r.TriggerID, r.State, r.Code)
}
for _, s := range res.Skipped {
fmt.Fprintf(logs, "schedule: skipped %s: %s\n", s.ScheduleID, s.Reason)
}
if err != nil {
fmt.Fprintln(logs, "schedule:", err)
}
},
}
go loop.Run(ctx)
}
@@ -0,0 +1,31 @@
package main
import (
"strings"
"testing"
)
func TestAScheduleChildIsRecognisedByItsArguments(t *testing.T) {
for args, want := range map[string]bool{"schedule exec sch_x/1": true, "schedule exec": true, "schedule list": false, "exec schedule": false, "": false} {
if got := scheduleChildArgs(strings.Fields(args)); got != want {
t.Errorf("%q = %v, want %v", args, got, want)
}
}
}
// An orphan, or a kernel nobody can see, never starts a scheduled run.
func TestSchedulesStartOnlyForASupervisedKernelThatCanBeSeen(t *testing.T) {
cases := []struct {
supervised, icon bool
panes int
want bool
}{
{true, true, 0, true}, {true, false, 1, true}, {true, true, 3, true},
{true, false, 0, false}, {false, true, 2, false}, {false, false, 0, false},
}
for _, c := range cases {
if got := scheduleEligible(c.supervised, c.icon, c.panes); got != c.want {
t.Errorf("%+v = %v", c, got)
}
}
}
+1
View File
@@ -12,6 +12,7 @@ import (
// Blank imports wire compile-time built-ins into their registries.
_ "reasonix/internal/model/anthropic"
_ "reasonix/internal/model/gemini"
_ "reasonix/internal/model/openai"
_ "reasonix/internal/model/responses"
_ "reasonix/internal/tools/builtin"
+267 -1
View File
@@ -2,11 +2,102 @@
status: active
owner: @esengine
backup: @SivanCola
reviewed: 2026-10-13
reviewed: 2026-10-18
---
# Foundation palette verification
## Current preset editor verification / 当前预设编辑页验收
final result: blocked
This section supersedes earlier preset-card visual acceptance below. The selected
source is the user's attached `exec-e196c6a9-d8d9-437d-a6ee-0e99a3651845.png`,
1487 × 1058 image pixels; CSS viewport and density are unspecified.
The image specifies a catalog followed by a separate content editor, not a drawer. The existing
Settings shell remains the application-owned frame.
The latest editor instruction supersedes the original advanced disclosure:
the user's `codex-clipboard-c3951359-4571-4572-ad30-9f4517a2f7db.png` reference
places both textareas and the runtime-context switch directly in the editor.
Scope and ID are also exposed. Settings navigation now reads Persona / 人设.
本节取代下方旧预设卡片的视觉验收。采用用户重新指定的首张效果图,列表与独立
编辑页切换,保留现有设置外框。编辑态为新建、名称与用途已填写、能力及高级设置
收起、个人范围、取消与保存操作固定在底部。
- Implementation screenshot: unavailable. Browser acquisition of the local
preview was rejected by the browser URL security policy. No alternative browser
surface or raw browser command was used to bypass the refusal.
- Viewport, implementation pixel dimensions, CSS size and density normalization:
unmeasured. Full-view and focused visual comparisons remain blocked.
- Typography: existing Studio family and weight tokens retained; rendering,
wrapping and optical weight require browser comparison.
- Spacing/layout: separate editor, expandable rows and flex footer implemented;
actual proportions, scrolling and narrow-screen overflow remain unverified.
- Colors: existing surface, text, focus and border tokens reused; rendered theme
contrast remains unverified.
- Assets: existing product icons retained; no generated product assets or new
handcrafted icon geometry. Source brand is supplied by the existing shell.
- Copy: name, purpose, persona, capabilities, scope and ID follow the selected
references, with Chinese/English labels. Existing backend limits remain
authoritative rather than adopting illustrative mockup character limits.
- Interaction regressions cover creation, copy, literal persona fields, invalid
parameters, failed saves, navigation/close confirmation, pending saves and focus.
These are jsdom checks and do not substitute for browser visual evidence.
The catalog-button follow-up uses the user's 1374 × 916 browser annotation as
the before evidence. The create button now uses one horizontal flex row, a
36px minimum height and 16px horizontal padding.
The heading uses a two-column
grid, with its description on a separate full-width row. After-render visual
verification remains blocked by the browser policy described above.
Remaining checklist:
1. Acquire the authorized preview and capture the specified creation state and catalog.
2. Compare the full view and editor regions; verify light/dark, narrow scrolling and console output.
3. Resolve P0–P2 findings before marking visual acceptance passed.
Earlier passing sections below are historical evidence for their own changes.
待办:恢复浏览器访问后,在相同编辑状态比较整页与局部,并检查深浅主题、窄屏
滚动及控制台;解决 P0–P2 问题后再将视觉验收标记为通过。
## Built-in cards and new-task default / 内置卡片与新任务默认
final result: blocked
Source: the user's selected Harness screenshot
`codex-clipboard-be3c8df5-ada5-4683-a66d-81cdf31010e7.png`, 1178 × 760 image pixels.
It is a section crop; viewport, CSS size and density are unspecified.
Implementation capture and full-view/focused comparison remain unavailable
because browser observation was rejected by the URL security policy. No
alternative browser surface was used to bypass that refusal.
- Typography: inherited Studio font, scale and weight tokens. Rendering remains
unverified; source-specific font substitution was not introduced.
- Layout: responsive two-column cards, large rounded borders, equal-height bodies,
separate footer actions and single-column narrow layouts. Rendered rhythm and
scrolling remain unverified.
- Color: existing surface, border, text and selected tokens. The selected badge
uses the text/page pair. Theme contrast still needs browser inspection.
- Assets: supplied product icon library reused for copy; no new raster assets
or handcrafted icon geometry.
- Content: Standard/Minimal remain the shipped modes; IDs remain canonical.
Details, usage and copy remain separate from the card's default selection.
- Behavior: card clicks persist a machine-local built-in choice. New local and
remote requests carry it explicitly; history and explicit per-task choices
retain priority. Old server preferences alone remain inert.
待浏览器恢复后,对照所选卡片截图检查字体、留白、颜色、图标和文案,并覆盖
深浅主题、窄屏、默认状态及展开的使用说明。当前功能测试不能替代视觉验收。
## Historical palette verification / 历史配色验收
final result: passed
Clear Blue and Tea Green implement the two approved colour directions for
@@ -85,6 +176,163 @@ did not specify a dark layout. Phone captures confirm no horizontal overflow.
Windows native installer acceptance and hosted CI are separate from these
local results. The production build retains its existing large-chunk warning.
# Composer composition placement / 输入框组合布局
final result: passed
The annotated request moves the existing composition selector and management
entry beside the run status. It preserves the existing screen, visual tokens
and session behavior. / 按标注将组合选择与管理入口移到运行状态旁,沿用现有界面。
## Evidence / 证据
- Source visual truth: author-approved composer annotation (1310 × 1826 pixels; browser chrome included, density unspecified). The user attachment remains private; the synthetic baseline below records the same controls.
- Matched source capture: `docs/images/agent-compositions/composer-before-light-wide.png`.
- Implementation: `docs/images/agent-compositions/composer-light-wide.png`.
- Full-view comparison: the matched before/after captures above were opened together, each 1440 × 1000 pixels, 1440 × 1000 CSS px, device scale factor 1. No density resampling.
- Focused comparison: composer controls from those captures were compared together at original pixel density; `composer-context-light.png` records the final control arrangement.
- Additional captures: `composer-light-390.png`, `composer-dark-390.png` (390 × 844 CSS/pixel dimensions), and `composer-dark-wide.png` (1440 × 1000), all in the same image directory.
- State: local Mock preview, Chinese, idle, General selected, existing input draft and missing-key notice. No provider request submitted.
## Findings / 结论
No actionable P0/P1/P2 differences remain. / 无需修复的 P0/P1/P2 差异。
- Typography: existing UI family, 11 px medium-weight controls and status typography retained; long names remain bounded by the selector.
- Layout: selector begins 16 px after run status; controls occupy the previously unused top row. The bottom toolbar fits one row at 1440 px. At 390 px the selector, status and management entry remain visible, with document width exactly 390 px.
- Colors: existing semantic theme tokens and hover/focus styles reused in both light and dark mode; no new palette.
- Assets: logo and icons preserved; no generated or replacement imagery.
- Content: composition names, source labels, error notice and management action retained. The placement is the only intentional visual change.
- Accessibility: composition controls sit outside `role="status"` and its live announcements. Accessible names and existing lock behavior are retained.
## Checks / 验证
- Switched General → Code review → General through the relocated selector.
- Opened Agent presets through the relocated management entry, then closed it.
- Confirmed the selector is absent from the bottom toolbar in the real DOM.
- Browser warning/error log after interaction: empty.
- Typecheck and `make check`: passed.
- Focused interaction tests: 92 passed; full frontend suite: 632 files, 5,220 tests passed.
- Locked-session/new-session flow covered by the existing picker interaction test.
## Comparison history / 对照记录
First rendered comparison passed; no P0/P1/P2 visual repair iteration was needed.
The matched before/after captures demonstrate the requested move and reduced
bottom-toolbar wrapping. / 首轮对照通过,上移后底栏在宽屏减少一行。
## Limits and next check / 范围
Browser-owned layout only; native titlebar and shell behavior are unchanged and
were not re-tested. No provider execution or new CI result is claimed for this
layout edit. / 本次验证浏览器布局,不涉及原生窗口或模型执行。
Checklist complete: placement, selection, management, live-region separation,
light/dark, wide/narrow, typecheck, affected and full frontend tests.
# Unified composition menu / 统一组合菜单
final result: passed
This section supersedes the separate selector/management controls above.
The annotation asks to combine those entries without changing the surrounding
composer. / 本节替代上述独立选择器与管理按钮,按标注合并为一个入口。
## Source and capture / 来源与截图
- Source visual truth: author-approved annotation showing the composition selector and adjacent management button, 452 × 312 pixels; density and full viewport unspecified. The user attachment remains private.
- Current-run baseline: `docs/images/agent-compositions/composition-menu-before.png`, 1440 × 1000 CSS/pixel dimensions, scale factor 1.
- Implementation: `composition-menu-light-wide.png`, `composition-menu-dark-wide.png` (1440 × 1000) and `composition-menu-light-390.png`, `composition-menu-dark-390.png` (390 × 844), all in the same directory, scale factor 1.
- Focused implementation: `composition-menu-detail.png`, 344 × 364 pixels at scale factor 1, including menu and trigger.
- Full-view comparison: `composition-menu-before.png` and `composition-menu-light-wide.png` were opened together. Baseline is closed; implementation is open to assess the added menu and unchanged surrounding composer.
- Focused comparison: the annotation and `composition-menu-detail.png` were opened together without density resampling. Source typography cannot support a 1:1 pixel comparison; the matched viewport supplies the product typography/layout reference.
- State: local Mock preview, Chinese, idle, Code review selected. No provider execution.
## Flow audit / 流程检查
1. Read the active composition: healthy after combining the entries. A single trigger shows name and localized source. The separate ellipsis had no visible explanation; its action is now explicit.
2. Choose a composition: healthy. Menu rows show capability notes and source, mark the current choice, and close after selection. Evidence: `composition-menu-light-wide.png` and `composition-menu-selected.png`.
3. Manage presets: healthy. A separated footer opens the existing management page. Evidence: `composition-menu-manage.png`. Management costs one extra click; frequent switching retains the same click count.
## Fidelity and interaction / 视觉与交互
- Typography: existing UI family and weights; 13 px choice titles and 11 px notes. Source labels sit beside names rather than consuming another row.
- Spacing: existing composer-menu recipe, 320 px menu width, 12 px radius and 8 px inner padding. At 390 px, the menu spans x=64…384 and the document remains exactly 390 px wide.
- Colors: existing surface, overlay, text, muted and focus tokens work in both themes; no new palette.
- Assets: existing chevron and brand assets retained; no new image assets.
- Copy: localized Built in/Personal/Project labels replace raw source identifiers. Management is a named footer action.
- Accessibility: trigger name includes the active composition and lock state; selected rows expose current state. Keyboard arrows skip disabled rows; Enter selects, Escape closes and restores trigger focus. This is scoped interaction evidence, not a full accessibility compliance claim.
- Safety of interaction: started/busy conversations never rebind through this menu. The same menu offers explicitly labeled new-conversation actions and management; hosts without creation support still expose management.
## Iteration and validation / 迭代与验证
- First inspection found source metadata on an extra line, making each choice unnecessarily tall. The item title now uses a flex row; revised captures show compact name/source pairs. No actionable P0/P1/P2 visual issues remain.
- A later resize check found a P2 shared positioning defect: after 390 → 1440 px, menu left remained 88 px while its trigger settled at 570 px. Window resize preceded fold publication and sidebar transition completion.
- Shared floating placement now responds to fold publication and transition completion. The revised dark-wide capture and browser rectangles show both left edges at 570 px.
- A deterministic real-Picker regression fails without the positioning fix and also checks cleanup after closing.
- Verified selection, management, Escape and responsive placement in the browser; warning/error log was empty.
- Regression tests cover keyboard focus, distinct action identities, failed/pending selections, locked sessions, management without creation support, equal names from different sources, and deleted bound presets.
- Final validation after the resize repair: typecheck passed; frontend full suite passed, 633 files and 5,225 tests. Repository checks passed without increasing a budget.
- Native shell behavior and provider execution are outside this layout change; hosted CI and publication status are reported separately in the pull request.
Checklist complete: one entry, grouped choices/actions, clear current state,
retained locks, keyboard access, themes, narrow viewport and regression checks.
## Studio integration verification / Studio 合并验证
The upstream integration preserves the top-row Agent composition trigger and
the unified selection/management menu. The upstream screen-reader-only run
label remains inside the live status region; interactive controls remain
outside it. Composition menu text follows the shared text-size setting.
合并后保留顶部 Agent 组合入口与统一的选择、管理菜单,同时沿用上游运行状态的
读屏处理。浏览器复核覆盖组合切换、管理入口,以及菜单展开时从宽屏缩至窄屏
的定位与换行。完整前端测试通过:651 个文件、5,342 个用例。
| Browser | Light | Dark |
| --- | --- | --- |
| 1440 × 1000 | Capture (local capture) | Capture (local capture) |
| 390 × 844 | Capture (local capture) | Capture (local capture) |
These captures use synthetic Mock data. Native Electron shell behavior was
not requalified by this browser integration check.
## Standard and Minimal modes / 标准与精简模式
The management page references the supplied Harness card hierarchy while using
Studio typography and surface tokens. Only Standard and Minimal are shipped;
custom definitions remain separate. Standard retains its existing stable ID.
Minimal uses native terminal/image tools and compaction without Cordis plugins.
管理页保留内置卡片、默认标记、模式说明与独立自定义区域。宽屏并排,390px
窄屏纵向排列且可滚动。默认选择与当前会话绑定分离;复制后继续使用原编辑器。
入口只展示模式名和箭头,位于左侧;运行状态与 token 位于右侧,来源与锁定
信息保留在菜单、提示和无障碍名称中。
| Browser | Light | Dark |
| --- | --- | --- |
| 1440 × 1000 | Cards (local capture) | Cards (local capture) |
| 390 × 844 | Cards (local capture) | Cards (local capture) |
Composer idle (local capture) and
running (local capture) captures
show the compact mode trigger with separate run information. These are browser
Mock captures; the source annotation image is not published.
The full frontend suite passes 651 files and 5,343 tests. Mechanism checks retain
explicit custom read-only fixtures for proxy, late-registration and child-agent
ceilings. Minimal's terminal is intentionally allowed. Text-only models report
an unavailable image dependency instead of silently changing the tool set.
After the subsequent MCP-follow integration, the full frontend suite passes
651 files and 5,347 tests. Provider-boundary and race regressions confirm that
automatic server-tool approval cannot replace an existing composition's pinned
contract; a new conversation can bind the updated catalog.
The native-video integration passes 651 frontend files and 5,348 tests. Combined
projection regressions retain bindings/video references in stored history while
removing local binding metadata and answered video payloads from model requests.
## Workspace preparation / 工作目录准备
The workspace palette and preparation states reuse existing typography and
@@ -96,3 +344,21 @@ QA passed. Captures remain in
占用状态、路径展开、创建、成功、失败重试与取消。无残留的 P0/P1/P2 视觉问题,
截图仅保留于 PR。清理失败时保留外部修改,并明确提示保存任务、核对目录后关闭
会话,从源目录新建会话重试;安装包与上游 CI 验收单独记录。
## Maintainer opt-in revision / 默认关闭调整
Standard and Minimal cards, the left-side name-only picker and right-side run
information are preserved. Preferences are labeled as preferences; they do not
activate a mode. No selection and Standard remain unbound. Complete replacement
is removed from the editor; selected personas supplement first-party guidance.
保留标准/精简卡片、左侧仅模式名的入口及右侧运行信息。首选项不自动启用;
未选择及标准模式不绑定。编辑器移除完整替换开关,人设仅补充内置指令。
Per maintainer request, composition screenshots are local evidence and are not
committed. Historical capture references above describe earlier verification.
Current browser checks and hosted workflow results are reported separately.
按维护者要求,组合截图仅保留为本地证据,不提交到仓库。上方记录为历史验证;
当前浏览器检查与远端工作流结论分开报告。
+6 -4
View File
@@ -135,14 +135,16 @@ func releaseAsset(repo, tag, name string, mirrored bool) update.Asset {
}
}
// installable reports whether an artifact installs itself rather than expecting
// the reader to place it: a disk image to drag from, an installer to run, a
// package for dpkg.
// installable reports whether an artifact is a download a person is offered: a
// disk image to drag from, an installer to run, a package for dpkg or rpm, or an
// AppImage to run in place. The AppImage is a download only; no native_packages
// key names it, so no client replaces one.
func installable(name string) bool {
return strings.HasSuffix(name, ".dmg") ||
strings.HasSuffix(name, "-installer.exe") ||
strings.HasSuffix(name, ".deb") ||
strings.HasSuffix(name, ".rpm")
strings.HasSuffix(name, ".rpm") ||
strings.HasSuffix(name, ".AppImage")
}
// macArchiveKeys maps a macOS archive to the platforms it can install. The
+33
View File
@@ -340,3 +340,36 @@ func TestLinuxArm64PackagesAreKeyedBesideAmd64(t *testing.T) {
}
}
}
// An AppImage is a download a person is offered and nothing an updater
// replaces: it must not appear as a platform asset or a native package, and
// adding it must not move any entry a shipped client reads.
func TestAnAppImageIsOfferedButNeverResolvedAsAnUpdate(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.AppImage", "ReasonixStudio-linux-arm64.AppImage"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", true); err != nil {
t.Fatalf("run: %v", err)
}
var m update.Manifest
raw, _ := os.ReadFile(filepath.Join(dir, "latest.json"))
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, name := range []string{"ReasonixStudio-linux-amd64.AppImage", "ReasonixStudio-linux-arm64.AppImage"} {
a, ok := m.Downloads[name]
if !ok || a.Sig != a.URL+".minisig" {
t.Errorf("downloads[%s] = %+v, %v; want a signed download", name, a, ok)
}
}
if len(m.NativePackages) != 1 || !strings.HasSuffix(m.NativePackages["linux-amd64"].URL, "linux-amd64.deb") {
t.Errorf("native packages = %+v, want only the .deb", m.NativePackages)
}
if len(m.Platforms) != 0 {
t.Errorf("platforms = %+v: an AppImage must not resolve as an update", m.Platforms)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

+8
View File
@@ -99,11 +99,19 @@ linux:
target:
- deb
- rpm
# Download only: it does not update itself, and nothing here installs it.
- AppImage
icon: assets/icon.png
category: Development
maintainer: esengine <[email protected]>
artifactName: ReasonixStudio-linux-${arch}.${ext}
appImage:
# Stated rather than inherited: the release vocabulary is the same for every
# Linux file, and canonical-artifact-names.js turns whatever architecture
# spelling electron-builder puts here (x64, x86_64) into amd64 or fails.
artifactName: ReasonixStudio-linux-${arch}.${ext}
deb:
# Named, not defaulted. electron-builder takes the package name from
# package.json's `name` unless told otherwise, which would publish
+7 -3
View File
@@ -15,6 +15,9 @@
"build:dist": "electron-builder",
"smoke": "electron test/smoke.js",
"browser-live": "electron test/browser_live.js",
"browser-resize-live": "electron test/browser_resize_live.js",
"browser-fit-live": "electron test/browser_fit_live.js",
"browser-fit-pages": "electron test/browser_fit_pages.js",
"image-live": "electron test/image_live.js",
"image-pane-live": "electron test/image_pane_live.js",
"sidepanel-live": "electron test/sidepanel_live.js",
@@ -22,12 +25,13 @@
"ime-live": "electron test/ime_live.js",
"freeze-live": "electron test/freeze_live.js",
"windowstate-live": "electron test/windowstate_live.js",
"computer-live": "electron test/computer_live.js"
"computer-live": "electron test/computer_live.js",
"launcher-live": "node test/launcher_live.mjs"
},
"devDependencies": {
"electron": "44.7.0",
"electron": "44.7.2",
"electron-builder": "^26.15.3",
"playwright": "1.64.0"
"playwright": "1.64.1"
},
"pnpm": {
"onlyBuiltDependencies": [
+11
View File
@@ -13,6 +13,10 @@ const ICO_SIZES = [16, 20, 24, 32, 40, 48, 64, 256];
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [1.75, "@1.75x"], [2, "@2x"], [2.5, "@2.5x"], [3, "@3x"]];
// The app icon's margin is the platform's to keep; a tray slot has none to spare.
const TRAY_VIEWBOX = 'viewBox="103 103 818 818"';
// macOS tints a template image itself, so the menu-bar glyph is the mark alone,
// black on transparent, framed square around it with a one-pixel margin at 18px.
const TEMPLATE_VIEWBOX = 'viewBox="277 277 470 470"';
const TEMPLATE_SCALES = [[18, ""], [36, "@2x"]];
// Windows draws the .ico edge to edge beside neighbours that fill their cell, so
// its tile is cropped to a 2% margin and the glyph enlarged within it; macOS and
// Linux keep the full-canvas artwork the platform templates expect.
@@ -79,6 +83,13 @@ async function main() {
for (const [scale, suffix] of TRAY_SCALES) {
fs.writeFileSync(path.join(ASSETS, `tray${suffix}.png`), await render(page, traySvg, 16 * scale));
}
const markSvg = svg
.replace(/<rect [^>]*\/>\s*/g, "")
.replace('fill="url(#gold)"', 'fill="#000"')
.replace('viewBox="0 0 1024 1024"', TEMPLATE_VIEWBOX);
for (const [size, suffix] of TEMPLATE_SCALES) {
fs.writeFileSync(path.join(ASSETS, `trayTemplate${suffix}.png`), await render(page, markSvg, size));
}
const winSvg = svg.replace('viewBox="0 0 1024 1024"', WIN_VIEWBOX).replace("scale(1.9)", WIN_GLYPH_SCALE);
const icoPngs = [];
for (const size of ICO_SIZES) icoPngs.push([size, await render(page, winSvg, size)]);
+14 -14
View File
@@ -9,14 +9,14 @@ importers:
.:
devDependencies:
electron:
specifier: 44.7.0
version: 44.7.0([email protected])
specifier: 44.7.2
version: 44.7.2([email protected])
electron-builder:
specifier: ^26.15.3
version: 26.15.3([email protected])([email protected])
playwright:
specifier: 1.64.0
version: 1.64.0
specifier: 1.64.1
version: 1.64.1
packages:
@@ -374,8 +374,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
[email protected].0:
resolution: {integrity: sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==}
[email protected].2:
resolution: {integrity: sha512-u3Pbz0Hi1sSSwBMz2v8LiagsfUG4QURPUe/qphM+g4THKP8o4BYdnuboyhlLO8HIC/KfV5rvltyacbP4jEq9yw==}
engines: {node: '>= 22.12.0'}
hasBin: true
@@ -762,13 +762,13 @@ packages:
resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
engines: {node: '>=16.0.0'}
[email protected].0:
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
[email protected].1:
resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
engines: {node: '>=20'}
hasBin: true
[email protected].0:
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
[email protected].1:
resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==}
engines: {node: '>=20'}
hasBin: true
@@ -1551,7 +1551,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected].0([email protected]):
[email protected].2([email protected]):
dependencies:
'@electron-internal/extract-zip': 1.0.5
'@electron/get': 5.1.0([email protected])
@@ -1942,11 +1942,11 @@ snapshots:
pvutils: 1.2.0
tslib: 2.8.1
[email protected].0: {}
[email protected].1: {}
[email protected].0:
[email protected].1:
dependencies:
playwright-core: 1.64.0
playwright-core: 1.64.1
[email protected]:
dependencies:
+2
View File
@@ -1,4 +1,5 @@
"use strict";
const { handOver } = require("./launcher");
// The acts the kernel asks of the application, because only this process can do
// them. relaunch and quit are a handover and arrive in that order, so arming
@@ -11,6 +12,7 @@ function actHandler({ app, showWindow, log = () => {} }) {
return (act) => {
if (act === "show" || act === "relaunch" || act === "quit") log(`kernel act: ${act}`);
if (act === "relaunch") {
handOver();
app.relaunch();
return;
}
+96 -5
View File
@@ -116,7 +116,7 @@ class BrowserViews {
}
onEvent(method, params);
});
const entry = { view, onDownload, typed: 0, synthUntil: 0, attached: false };
const entry = { view, onDownload, typed: 0, synthUntil: 0, attached: false, panel: null, override: null, scale: 1 };
this.keyboard.watch(contents, () => Date.now() < entry.synthUntil);
this.watchLoads(targetId, entry);
let closed = false;
@@ -140,6 +140,15 @@ class BrowserViews {
return {
targetId,
send: (method, params) => {
if (method === "Emulation.setDeviceMetricsOverride") return this.emulate(entry, params);
if (method === "Emulation.clearDeviceMetricsOverride") return this.emulate(entry, null);
try {
params = scaleInput(method, params, entry.scale);
} catch (e) {
return Promise.reject(e);
}
// A capture puts the page's emulation back without its scale.
if (method === "Page.captureScreenshot") return contents.debugger.sendCommand(method, params).finally(() => void this.fit(entry, true));
if (!method.startsWith("Input.")) return contents.debugger.sendCommand(method, params);
entry.synthUntil = Date.now() + 250;
return this.wake.run(() => contents.debugger.sendCommand(method, params));
@@ -164,7 +173,7 @@ class BrowserViews {
// which during a turn is constantly, and nearly always the same rectangle:
// re-adding the view and setting the bounds it already has is work for
// every one of those.
if (this.shown === targetId && sameRect(entry.at, at)) return;
if (this.shown === targetId && sameRect(entry.panel, at)) return;
this.placed++;
for (const [id, other] of this.entries) {
if (id !== targetId) this.putAway(other.view, other);
@@ -176,9 +185,49 @@ class BrowserViews {
if (entry.attached && !entry.at) this.win.contentView.removeChildView(entry.view);
this.win.contentView.addChildView(entry.view);
entry.attached = true;
entry.panel = at;
this.fit(entry);
this.keepKeyboard();
}
// fit puts the view where its page is drawn whole and tells the page how it is
// scaled; a page parked out of sight is not scaled. force sends the scale again
// though it has not changed, for a capture that put the page's emulation back.
fit(entry, force = false) {
if (!entry.panel) return Promise.resolve();
const { at, scale } = placement(entry.panel, entry.override && { width: entry.override.width, height: entry.override.height });
entry.view.setBounds(at);
entry.at = at;
this.keepKeyboard();
return this.scaled(entry, scale, force);
}
scaled(entry, scale, force = false) {
if (!entry.override || (entry.scale === scale && !force)) return Promise.resolve();
entry.scale = scale;
return this.override(entry).catch(() => {});
}
override(entry) {
return entry.view.webContents.debugger.sendCommand("Emulation.setDeviceMetricsOverride", { ...entry.override, scale: entry.scale });
}
// emulate records the viewport a page was told to lay out in, which the view
// is then fitted to; null is the page going back to the size of its view.
async emulate(entry, params) {
entry.override = sized(params) ? { ...params } : null;
if (!entry.override) {
entry.scale = 1;
if (entry.panel) this.fit(entry);
return entry.view.webContents.debugger.sendCommand("Emulation.clearDeviceMetricsOverride", {});
}
entry.scale = 1;
if (entry.panel) {
const { at, scale } = placement(entry.panel, entry.override);
entry.view.setBounds(at);
entry.at = at;
entry.scale = scale;
}
return this.override(entry);
}
// Attaching a view can leave nobody holding the keyboard, and the platform
@@ -253,7 +302,11 @@ class BrowserViews {
putAway(view, entry) {
const { height } = this.win.getContentBounds();
view.setBounds({ x: 1 - AGENT_VIEWPORT.width, y: height - 1, ...AGENT_VIEWPORT });
if (entry) entry.at = null;
if (entry) {
entry.at = null;
entry.panel = null;
void this.scaled(entry, 1);
}
}
control(targetId, action) {
@@ -420,6 +473,44 @@ async function loadResult(contents, url) {
}
}
// Where a page's view sits in the rectangle the panel gave it. A page laid out
// in a viewport of its own is shown whole: scaled down to fit and never up,
// centred, with the view sized to exactly what it draws.
const sized = (v) => !!v && Number.isFinite(v.width) && Number.isFinite(v.height) && v.width > 0 && v.height > 0;
function placement(panel, emulated) {
if (!sized(emulated)) return { at: panel, scale: 1 };
const scale = Math.min(1, panel.width / emulated.width, panel.height / emulated.height);
const width = Math.max(1, Math.round(emulated.width * scale));
const height = Math.max(1, Math.round(emulated.height * scale));
const x = panel.x + Math.round((panel.width - width) / 2);
const y = panel.y + Math.round((panel.height - height) / 2);
return { at: { x, y, width, height }, scale };
}
// Input that carries a point. The page reads it in the viewport it was told it
// has, the view draws that viewport scaled, and a point meant for the page lands
// where it is drawn only after the same scaling. The mouse is measured to; the
// rest are not, and a point that silently lands elsewhere is worse than a
// refusal, so they are refused while the page is scaled.
const POINTED_INPUT = new Set([
"Input.dispatchTouchEvent",
"Input.dispatchDragEvent",
"Input.emulateTouchFromMouseEvent",
"Input.synthesizeTapGesture",
"Input.synthesizeScrollGesture",
"Input.synthesizePinchGesture",
]);
function scaleInput(method, params, scale) {
if (scale === 1) return params;
if (method === "Input.dispatchMouseEvent") return { ...params, x: params.x * scale, y: params.y * scale };
if (POINTED_INPUT.has(method)) {
throw Object.assign(new Error(`${method} is not available while the page is scaled to fit the panel`), { code: "input_unscaled" });
}
return params;
}
function sameRect(a, b) {
return !!a && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
}
@@ -428,4 +519,4 @@ function validRect(rect) {
return rect && [rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) && rect.width > 0 && rect.height > 0;
}
module.exports = { BrowserViews, AGENT_VIEWPORT, LEAVE_LIMIT_MS };
module.exports = { BrowserViews, AGENT_VIEWPORT, LEAVE_LIMIT_MS, placement, scaleInput };
+22
View File
@@ -0,0 +1,22 @@
"use strict";
// The kernel's gate answers every request without the launch credential with
// 403, and a cookie belongs to the host, not the port, so anything that removes it (an
// eviction, a page clearing cookies for 127.0.0.1) leaves the window refused
// until a relaunch. The shell holds the token, so it puts the cookie back.
function keepCredential(ses, cookie, log) {
const host = new URL(cookie.url).hostname;
const restore = (cause) => {
log(`credential cookie removed (${cause}); restoring`);
ses.cookies.set(cookie).catch((err) => log(`credential cookie restore failed: ${err.message}`));
};
const onChanged = (_event, changed, cause, removed) => {
if (!removed || changed.name !== cookie.name || cause === "overwrite") return;
if (changed.domain.replace(/^\./, "") !== host) return;
restore(cause);
};
ses.cookies.on("changed", onChanged);
return () => ses.cookies.removeListener("changed", onChanged);
}
module.exports = { keepCredential };
+8 -5
View File
@@ -62,11 +62,14 @@ async function startEmbedded(executable, contract, onStderr = () => {}, spawnDri
if (child.exitCode !== null || child.signalCode !== null) throw new Error("Embedded CUA exited during startup");
try {
const current = await fs.lstat(socket);
if (!current.isSocket() || (current.mode & 0o777) !== 0o600) throw new Error("Invalid private CUA socket");
const reply = await metadata(socket);
if (!accepts(reply, child.pid, contract)) throw new Error("Embedded CUA identity/version mismatch");
identity = current;
return;
if (!current.isSocket()) throw new Error("Invalid private CUA socket");
// The driver binds before chmod: existence alone does not establish readiness.
if ((current.mode & 0o777) === 0o600) {
const reply = await metadata(socket);
if (!accepts(reply, child.pid, contract)) throw new Error("Embedded CUA identity/version mismatch");
identity = current;
return;
}
} catch (error) { if (!["ENOENT", "ECONNREFUSED"].includes(error.code)) throw error; }
await new Promise(resolve => setTimeout(resolve, 50));
}
+66
View File
@@ -0,0 +1,66 @@
"use strict";
// A development shell belongs to the process that launched it: `pnpm start`,
// a live test, an agent's background job. Killed outright, that launcher takes
// nothing with it, so the shell has to notice it is gone and leave on its own.
// Packaged shells, and shells a relaunch handed over, belong to nobody.
const HANDED_OVER = "REASONIX_STUDIO_HANDED_OVER";
const EVERY_MS = 1000;
// handOver marks the next instance as nobody's. The launcher waits on this
// process and exits with it, so a relaunched shell would otherwise read its own
// start as the launcher's death.
function handOver(env = process.env) {
env[HANDED_OVER] = "1";
}
function alive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (err) {
return err?.code === "EPERM";
}
}
// POSIX gives an orphan a new parent, which settles it. Windows leaves the dead
// pid as the parent, so there it is probed instead, and a pid already reused
// leaves the shell running as it would without this watch.
function watchLauncher({ packaged, env = process.env, onLost, parent = () => process.ppid, alive: isAlive = alive, everyMs = EVERY_MS }) {
const handedOver = env[HANDED_OVER] === "1";
delete env[HANDED_OVER];
const launcher = parent();
if (packaged || handedOver || launcher <= 1) return { dispose() {} };
const timer = setInterval(() => {
if (parent() === launcher && isAlive(launcher)) return;
clearInterval(timer);
onLost();
}, everyMs);
timer.unref?.();
return { dispose: () => clearInterval(timer) };
}
// consoleEcho writes to a console the launcher may already have closed. An
// EPIPE nobody handles opens Electron's modal error box, which stops the main
// process; shell.log and host.log keep every line, so a failed or backed-up
// console is dropped rather than retried.
function consoleEcho(stream) {
let failed = false;
let blocked = false;
stream.on("error", () => {
failed = true;
});
stream.on("drain", () => {
blocked = false;
});
return (chunk) => {
if (failed || blocked || stream.destroyed || stream.writableEnded) return;
try {
blocked = !stream.write(chunk);
} catch {
failed = true;
}
};
}
module.exports = { watchLauncher, handOver, consoleEcho };
+19 -4
View File
@@ -13,6 +13,7 @@ const { showStarting } = require("./starting");
const { StudioHost } = require("./hostclient");
const { installTray } = require("./tray");
const { instanceID, profileFor } = require("./instance");
const { watchLauncher, consoleEcho } = require("./launcher");
const { installApplicationMenu, installContextMenu } = require("./menu");
const { uiLanguage } = require("./uilang");
const { installFullScreenKey } = require("./fullscreen");
@@ -32,10 +33,12 @@ const { groundFor, themeSourceFor } = require("./ground");
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
const { stateFile, openWindow } = require("./windowstate");
const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics");
const { processMemoryHandler } = require("./procmem");
const { captureHandler } = require("./capture");
const { createPowerGuard, keepsAwake } = require("./powerguard");
const { openLogs, redactArgv, failStartup } = require("./shelllog");
const { raise } = require("./raise");
const { keepCredential } = require("./credential");
const { actHandler } = require("./acts");
const { crashDir, recordHostExit, pendingHostExit, clearHostExit, hostExitNotice } = require("./hostexit");
@@ -72,6 +75,7 @@ const computerDriver = layout.computerDriver(where);
const pageDir = layout.pageDir(where);
const acts = actHandler({ app, showWindow: () => showWindow(), log: (line) => logs?.shell.line(line) });
const toConsole = consoleEcho(process.stderr);
let kernel = null;
let cua = null;
let win = null;
@@ -124,7 +128,7 @@ async function launchKernel(args) {
},
onStderr: (text) => {
logs.host.raw(text);
process.stderr.write(text);
toConsole(text);
},
onExit: (code, signal) => {
logs.shell.line(`host: exited code=${code} signal=${signal}${handshaken ? "" : " before its handshake"}`);
@@ -139,6 +143,7 @@ async function launchKernel(args) {
onAct: (act) => acts(act),
});
const current = kernel;
logs.shell.line(`host: pid ${current.child.pid ?? "(none)"}`);
current.child.on("error", (err) => logs.shell.line(`host: spawn failed: ${err.message}`));
current.child.stderr.on("close", () => logs.host.flush());
try {
@@ -280,7 +285,7 @@ function cleanUpLegacyInstalls() {
// it, Strict because no cross-site navigation ever needs to carry it, and no
// expiry so it dies with this session rather than outliving the launch.
async function armCredential(ready) {
await session.defaultSession.cookies.set({
const cookie = {
url: ready.origin + "/",
name: TOKEN_COOKIE,
value: ready.token,
@@ -288,7 +293,9 @@ async function armCredential(ready) {
httpOnly: true,
secure: false,
sameSite: "strict",
});
};
await session.defaultSession.cookies.set(cookie);
keepCredential(session.defaultSession, cookie, (line) => logs.shell.line(line));
}
// The close button hides where an icon can bring the window back, and quits
@@ -392,6 +399,7 @@ nativeTheme.on("updated", () => {
});
ipcMain.handle("shell:graphics", (event) => graphicsHandler(app, fromWindow, gpuLaunch)(event));
ipcMain.handle("shell:process-memory", (event) => processMemoryHandler(app, fromWindow)(event));
ipcMain.handle("window:unread-badge", (event, unread) => {
const target = fromWindow(event);
@@ -508,7 +516,7 @@ if (gpuLaunch.launchedOff) app.disableHardwareAcceleration();
logs = openLogs(app.getPath("userData"));
logs.shell.line(
`shell: start version=${app.getVersion()} packaged=${app.isPackaged} platform=${process.platform}/${process.arch} ` +
`instance=${identity || "(unknown)"} argv=${JSON.stringify(redactArgv(process.argv))}`,
`instance=${identity || "(unknown)"} pid=${process.pid} argv=${JSON.stringify(redactArgv(process.argv))}`,
);
const primary = app.requestSingleInstanceLock();
if (!primary) {
@@ -519,6 +527,13 @@ if (!primary) {
app.on("second-instance", showWindow);
// macOS reopens an existing app through activation, without a second process.
app.on("activate", showWindow);
watchLauncher({
packaged: app.isPackaged,
onLost: () => {
logs.shell.line("shell: launcher gone; quitting");
app.quit();
},
});
grants = stripPackageGrants(hostBinary, { ...where, execPath: process.execPath });
if (grants?.stripped.length) {
const note = `removed app-package grants that stop sandboxed processes loading: ${grants.stripped.join(", ")}`;
+2
View File
@@ -1,4 +1,5 @@
"use strict";
const { handOver } = require("./launcher");
const X11 = "--ozone-platform=x11";
@@ -33,6 +34,7 @@ function wantsX11(platform, env, args) {
function relaunchForOzonePlatform(app, { platform, env, argv }) {
const args = argv.slice(1);
if (!wantsX11(platform, env, args)) return false;
handOver(env);
app.relaunch({ args: [X11, ...args] });
app.exit(0);
return true;
+2
View File
@@ -30,6 +30,8 @@ contextBridge.exposeInMainWorld("reasonixHost", {
captureWindow: () => ipcRenderer.invoke("window:capture"),
// What the shell's graphics are running as this launch.
graphics: () => ipcRenderer.invoke("shell:graphics"),
// The window's processes as type and two sizes, for the memory a report may carry.
processMemory: () => ipcRenderer.invoke("shell:process-memory"),
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
// A pane and a path inside its workspace; main asks the kernel where that is.
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
+31
View File
@@ -0,0 +1,31 @@
"use strict";
const TYPES = { Tab: "renderer", Browser: "browser", GPU: "gpu" };
// The kernel adds its own row, and a report holds a dozen.
const MAX_ROWS = 11;
const kb = (v) => (Number.isFinite(v) && v > 0 ? Math.min(Math.round(v), 2 ** 31) : 0);
// What app.getAppMetrics says, cut down to what a report may carry: a fixed
// type per process and two whole numbers. No pid, name, path or command line
// is read, so none can be sent.
function processMemoryRows(metrics) {
return (Array.isArray(metrics) ? metrics : []).slice(0, MAX_ROWS).map((m) => ({
process: TYPES[m?.type] ?? "utility",
workingSetKB: kb(m?.memory?.workingSetSize),
privateKB: kb(m?.memory?.privateBytes),
}));
}
function processMemoryHandler(app, fromWindow) {
return (event) => {
if (!fromWindow(event)) return [];
try {
return processMemoryRows(app.getAppMetrics());
} catch {
return [];
}
};
}
module.exports = { processMemoryRows, processMemoryHandler };
+2 -2
View File
@@ -2,8 +2,8 @@
const { BrowserWindow } = require("electron");
const TEXT = {
en: ["Reasonix Studio is starting", "The first start after installing or rebooting can take a minute."],
zh: ["Reasonix Studio 正在启动", "安装或重启电脑后的首次启动可能需要一分钟。"],
en: ["Reasonix Studio is starting", "Waiting for the local service to be ready. This window will close automatically."],
zh: ["Reasonix Studio 正在启动", "正在等待本地服务就绪,完成后此窗口会自动关闭。"],
};
function startingPage(locale) {
+5 -3
View File
@@ -19,10 +19,12 @@ function trayAsset(scaleFactor) {
return { file: path.join(ASSETS, `tray${hit[1]}.png`), pixels: Math.round(16 * hit[0]) };
}
// Windows rebuilds the tray icon from the 1x bitmap, resizing it to the small
// icon metric, so the file is loaded as 1x pixels of exactly that size. Other
// platforms choose among the @Nx files themselves.
// macOS draws a Template-suffixed pair (black on transparent) in the menu bar's
// own tint. Windows rebuilds the tray icon from the 1x bitmap, resizing it to
// the small icon metric, so the file is loaded as 1x pixels of exactly that
// size. Other platforms choose among the @Nx files themselves.
function trayImage(nativeImage, scaleFactor) {
if (process.platform === "darwin") return nativeImage.createFromPath(path.join(ASSETS, "trayTemplate.png"));
if (process.platform !== "win32") return nativeImage.createFromPath(path.join(ASSETS, "tray.png"));
return nativeImage.createFromBuffer(fs.readFileSync(trayAsset(scaleFactor).file));
}
+106
View File
@@ -0,0 +1,106 @@
"use strict";
// A page the agent lays out in a viewport of its own (a resize) is shown whole in the panel, not cropped to its
// top-left corner: scaled down to fit and centred, with the agent's clicks still landing where they were aimed.
// Run with `pnpm browser-fit-live`; it needs no network. SHOTS=<dir> also writes pictures of the window.
const fs = require("node:fs");
const path = require("node:path");
const http = require("node:http");
const { execFileSync } = require("node:child_process");
const { app, BrowserWindow } = require("electron");
const { BrowserViews } = require("../src/browserviews");
const { checker } = require("./livekit");
const PAGE = `<!doctype html><title>wide</title><style>body{margin:0;font:20px sans-serif}.bar{display:flex;justify-content:space-between;background:#1a56db;color:#fff;padding:14px;width:1280px;box-sizing:border-box}.c{width:1280px;height:600px;background:linear-gradient(90deg,#fde68a,#a7f3d0);position:relative}.r,.l{position:absolute;top:10px;background:#111;color:#fff;padding:8px}.r{right:10px}.l{left:10px;top:60px}</style><div class=bar><b>LEFT edge</b><b>RIGHT edge</b></div><div class=c><span class=r id=rc onclick="document.title='hit'">right column</span><span class=l id=lc onclick="document.title='hit'">left column</span></div>`;
// Its own profile, so another Electron run on this machine does not hold this one back.
app.setPath("userData", fs.mkdtempSync(path.join(require("node:os").tmpdir(), "rx-fit-live-")));
const { check, failures } = checker();
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
app.whenReady().then(async () => {
const server = http.createServer((_q, s) => { s.writeHead(200, { "Content-Type": "text/html" }); s.end(PAGE); });
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const win = new BrowserWindow({ width: 1000, height: 760, show: true });
await win.loadURL("data:text/html,<body style='margin:0;background:%23ddd;font:14px sans-serif'><div style='position:absolute;left:20px;top:20px'>app chrome</div>");
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const page = views.create({ partition: "fit", url: `http://127.0.0.1:${server.address().port}/`, onEvent() {}, onClosed() {}, onPopup() {}, onDownload() {} });
const view = views.entries.get(page.targetId).view;
await wait(1500);
const evalIn = (expression) => page.send("Runtime.evaluate", { expression, returnByValue: true }).then((r) => r.result.value);
const shot = (name) => {
if (!process.env.SHOTS) return;
fs.mkdirSync(process.env.SHOTS, { recursive: true });
win.focus();
const b = win.getBounds();
execFileSync("screencapture", ["-x", "-R", `${b.x},${b.y},${b.width},${b.height}`, `${process.env.SHOTS}/${name}.png`]);
};
const click = async (id = "rc") => {
await evalIn("document.title='reset'");
const r = JSON.parse(await evalIn(`JSON.stringify(document.getElementById('${id}').getBoundingClientRect())`));
for (const type of ["mouseMoved", "mousePressed", "mouseReleased"]) {
await page.send("Input.dispatchMouseEvent", { type, x: r.x + r.width / 2, y: r.y + r.height / 2, button: "left", clickCount: 1 });
}
await wait(250);
return evalIn("document.title");
};
const panel = { x: 380, y: 60, width: 560, height: 620 };
views.show(page.targetId, panel);
await wait(600);
check("no resize: the page fills the panel as before", JSON.stringify(view.getBounds()) === JSON.stringify(panel));
await page.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 0, mobile: false });
await wait(600);
const fit = view.getBounds();
check("a 1280x900 viewport in a 560x620 panel: the view is scaled to 560x394 and centred", fit.width === 560 && fit.height === 394 && fit.x === 380 && fit.y === 173, JSON.stringify(fit));
check("the page still lays out at the viewport it was given", (await evalIn("innerWidth")) === 1280);
check("an agent click aimed at the right column lands on it", (await click()) === "hit");
shot("fit-in-panel");
views.show(page.targetId, { x: 380, y: 60, width: 400, height: 500 });
await wait(600);
const narrower = view.getBounds();
check("a narrower panel re-fits the view", narrower.width === 400 && narrower.height === 281, JSON.stringify(narrower));
check("and the click still lands", (await click()) === "hit");
views.show(page.targetId, { x: 20, y: 20, width: 1400, height: 1000 });
await wait(600);
const roomy = view.getBounds();
check("a panel larger than the viewport shows it at full size, centred, never enlarged", roomy.width === 1280 && roomy.height === 900 && roomy.x === 80 && roomy.y === 70, JSON.stringify(roomy));
check("and the click lands at full size", (await click()) === "hit");
views.hide();
await wait(600);
check("parked, the page is not scaled and the click lands", (await click()) === "hit");
views.show(page.targetId, panel);
await wait(600);
check("shown again it is fitted again", view.getBounds().width === 560 && (await click()) === "hit");
const small = { x: 20, y: 20, width: 900, height: 700 };
views.show(page.targetId, small);
await page.send("Emulation.setDeviceMetricsOverride", { width: 390, height: 600, deviceScaleFactor: 0, mobile: false });
await wait(600);
const phone = view.getBounds();
check("a viewport smaller than the panel is shown at its own size, centred, not in the panel's corner", phone.width === 390 && phone.height === 600 && phone.x === 20 + (900 - 390) / 2 && phone.y === 20 + (700 - 600) / 2, JSON.stringify(phone));
check("and a click on it lands", (await click("lc")) === "hit");
views.show(page.targetId, panel);
await page.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 0, mobile: false });
await wait(600);
const taken = await page.send("Page.captureScreenshot", { format: "png" });
const png = Buffer.from(taken.data, "base64");
check("the agent's screenshot is the size of the viewport it asked for", png.readUInt32BE(16) === 1280 && png.readUInt32BE(20) === 900, `${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`);
await wait(600);
check("after the agent takes a screenshot the page is still fitted and a click still lands", (await click()) === "hit");
await page.send("Emulation.setDeviceMetricsOverride", { width: 0, height: 0, deviceScaleFactor: 0, mobile: false });
await wait(600);
check("a zero-sized override is no override: the page fills the panel and a click lands", JSON.stringify(view.getBounds()) === JSON.stringify(panel) && (await evalIn("innerWidth")) === 560 && (await click("lc")) === "hit", JSON.stringify(view.getBounds()));
await page.send("Emulation.clearDeviceMetricsOverride", {});
await wait(600);
check("clearing the viewport gives the page its panel back", JSON.stringify(view.getBounds()) === JSON.stringify(panel) && (await evalIn("innerWidth")) === 560);
shot("cleared");
console.log(failures.length ? `\n${failures.length} failed` : "\nall passed");
app.exit(failures.length ? 1 : 0);
});
@@ -0,0 +1,80 @@
"use strict";
// Local html previews, an oversized image document and a PDF in the built-in browser, each with and without an
// agent viewport: the view must be filled by the page, measured on the pixels, not just sized right.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { app, BrowserWindow } = require("electron");
const { BrowserViews } = require("../src/browserviews");
const { checker } = require("./livekit");
app.setPath("userData", fs.mkdtempSync(path.join(os.tmpdir(), "rx-fit-pages-")));
const { check, failures } = checker();
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const PANEL = { x: 380, y: 60, width: 560, height: 620 };
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rx-pages-"));
function png(w, h, rgb) {
const zlib = require("node:zlib");
const crc = (buf) => { let c, t = []; for (let n = 0; n < 256; n++) { c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } let r = 0xffffffff; for (const b of buf) r = t[(r ^ b) & 255] ^ (r >>> 8); return (r ^ 0xffffffff) >>> 0; };
const chunk = (type, data) => { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(body)); return Buffer.concat([len, body, c]); };
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
const row = Buffer.concat([Buffer.from([0]), Buffer.from(Array.from({ length: w }, () => rgb).flat())]);
return Buffer.concat([Buffer.from("\x89PNG\r\n\x1a\n", "binary"), chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(Buffer.concat(Array.from({ length: h }, () => row)))), chunk("IEND", Buffer.alloc(0))]);
}
fs.writeFileSync(path.join(dir, "big.png"), png(3200, 1800, [37, 99, 235]));
fs.copyFileSync("/tmp/fl/preview.html", path.join(dir, "preview.html"));
fs.copyFileSync("/tmp/fl/page.pdf", path.join(dir, "page.pdf"));
// Share of the view's sampled pixels that are the near-white or light-grey empty background of an unfilled view.
async function emptyShare(view) {
const img = await view.webContents.capturePage();
const { width, height } = img.getSize();
const bm = img.toBitmap();
let empty = 0, blue = 0, n = 0;
for (let y = 2; y < height - 2; y += Math.max(1, Math.floor(height / 40))) {
for (let x = 2; x < width - 2; x += Math.max(1, Math.floor(width / 40))) {
const i = (y * width + x) * 4;
const b = bm[i], g = bm[i + 1], r = bm[i + 2];
n++;
if (Math.min(r, g, b) > 225 && Math.max(r, g, b) - Math.min(r, g, b) < 12) empty++;
if (b > 180 && r < 90 && g < 150) blue++;
}
}
return { share: empty / n, blue: blue / n, width, height };
}
app.whenReady().then(async () => {
const win = new BrowserWindow({ width: 1000, height: 760, show: true });
await win.loadURL("data:text/html,<body style='margin:0;background:%23ddd'>app");
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const cases = [
["local html preview, no override", "preview.html", null],
["local html preview, 1280x900 override", "preview.html", { width: 1280, height: 900 }],
["local html preview, oversized 3200x1800 override", "preview.html", { width: 3200, height: 1800 }],
["3200x1800 image document, no override", "big.png", null],
["3200x1800 image document, 3200x1800 override", "big.png", { width: 3200, height: 1800 }],
["PDF, no override", "page.pdf", null],
["PDF, 1280x900 override", "page.pdf", { width: 1280, height: 900 }],
];
for (const [name, file, override] of cases) {
const page = views.create({ partition: "p" + Math.random(), url: "file://" + path.join(dir, file), onEvent() {}, onClosed() {}, onPopup() {}, onDownload() {} });
const entry = views.entries.get(page.targetId);
await wait(file.endsWith(".pdf") ? 3500 : 1500);
views.show(page.targetId, PANEL);
await wait(500);
if (override) await page.send("Emulation.setDeviceMetricsOverride", { ...override, deviceScaleFactor: 0, mobile: false });
await wait(file.endsWith(".pdf") ? 2500 : 1200);
const at = entry.view.getBounds();
if (process.env.SHOTS) { fs.mkdirSync(process.env.SHOTS, { recursive: true }); fs.writeFileSync(path.join(process.env.SHOTS, name.replace(/[^a-z0-9]+/gi, "-") + ".png"), (await entry.view.webContents.capturePage()).toPNG()); }
const m = await emptyShare(entry.view);
const inside = at.x >= PANEL.x && at.y >= PANEL.y && at.x + at.width <= PANEL.x + PANEL.width && at.y + at.height <= PANEL.y + PANEL.height;
const shown = file.endsWith(".html") ? m.share < 0.15 : m.share < 0.15 && m.blue >= (file.endsWith(".png") ? 0.5 : 0.05);
const detail = `view ${JSON.stringify(at)}, empty ${(m.share * 100).toFixed(0)}%, page-coloured ${(m.blue * 100).toFixed(0)}% of ${m.width}x${m.height}`;
console.log(" measured:", name, detail);
check(`${name}: the view sits inside the panel and the page is drawn in it`, inside && shown, detail);
views.close?.(page.targetId);
}
console.log(failures.length ? `\n${failures.length} failed` : "\nall passed");
app.exit(failures.length ? 1 : 0);
});
@@ -0,0 +1,89 @@
"use strict";
// A page the agent lays out at a viewport wider than the pane has to stay whole for the
// person watching: the view shows all of it, scaled to the pane, not its left part.
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const { app } = require("electron");
const { checker, listen, scriptedModel, seedHome, settledWindow, until, wait } = require("./livekit");
const PAGE = `<!doctype html><title>Wide</title><body style="margin:0">
<div style="width:100vw;height:100vh;background:linear-gradient(90deg,#e33,#33e);color:#fff;font:48px sans-serif">
<span style="position:absolute;left:12px;top:12px">LEFT</span>
<span style="position:absolute;right:12px;top:12px">RIGHT</span>
<button id="go" style="position:absolute;right:40px;top:300px;font:32px sans-serif" onclick="document.title='clicked'">Go</button></div>`;
const { check, failures } = checker();
const SIZE = { width: 1440, height: 1000 };
async function main() {
const page = await listen((req, res) => {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(PAGE);
});
let calls = 0;
const { handler, requests } = scriptedModel((tools) => {
calls++;
if (calls === 1) return { name: "browser_open", arguments: { url: `${page.url}/` } };
if (calls === 2) return { name: "browser_act", arguments: { tab: "t1", steps: [{ action: "resize", ...SIZE }] } };
if (calls === 3) return { name: "browser_read", arguments: { tab: "t1", what: "screenshot" } };
if (calls === 4) {
const ref = (tools.join("\n").match(/button "Go" \[(e\d+)\]/) || [])[1];
return { name: "browser_act", arguments: { tab: "t1", steps: [{ action: "click", ref }] } };
}
return null;
});
const model = await listen(handler);
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-resize-live-"));
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "rx-resize-ws-"));
process.env.REASONIX_HOME = home;
seedHome(home, model.url, workspace);
const { current } = require("../src/main.js");
try {
const win = await settledWindow();
const { client } = current();
const runtimes = await until("a pane", async () => {
const list = await client.json("GET", "/runtimes");
return Array.isArray(list) && list.length ? list : null;
});
await client.request("POST", `${runtimes[0].base}/submit`, { input: "open the wide page and lay it out wide" });
await until("the scripted turn", async () => requests.length >= 5, 60000);
const guests = () => win.contentView.children.filter((v) => v.webContents && v.webContents !== win.webContents);
const guest = await until("a page view", async () => guests()[0]);
await until("the page beside the conversation", async () => (guest.getBounds().x >= 0 ? true : null));
await wait(1500);
const bounds = guest.getBounds();
const inner = await guest.webContents.executeJavaScript("[innerWidth, innerHeight]");
check("the page is laid out at the viewport the agent asked for", inner[0] === SIZE.width && inner[1] === SIZE.height, inner);
const draw = (name) => {
const file = path.join(os.tmpdir(), name);
try {
execFileSync("screencapture", ["-x", "-o", "-l", win.getMediaSourceId().split(":")[1], file]);
process.stdout.write(` screenshot: ${file}\n`);
} catch (err) {
process.stdout.write(` (no screenshot: ${err.message})\n`);
}
};
const shown = await guest.webContents.capturePage();
const size = shown.getSize();
const px = shown.toBitmap();
const at = (x, y) => { const i = (y * size.width + x) * 4; return { b: px[i], g: px[i + 1], r: px[i + 2] }; };
const edge = at(size.width - 6, 6);
check("the right edge of the page is what the view draws at its right edge", edge.b > edge.r, edge);
check("the agent's click on a control at the right of the page landed", (await guest.webContents.getTitle()) === "clicked", await guest.webContents.getTitle());
draw("rx-resize-live.png");
const taken = await guest.webContents.debugger.sendCommand("Page.captureScreenshot", { format: "png" });
const dims = require("electron").nativeImage.createFromBuffer(Buffer.from(taken.data, "base64")).getSize();
process.stdout.write(` agent screenshot ${JSON.stringify(dims)}\n`);
check("a screenshot taken the way the agent takes one shows the page at the size it asked for", dims.width === SIZE.width && dims.height === SIZE.height, dims);
process.stdout.write(` bounds ${JSON.stringify(bounds)} inner ${JSON.stringify(inner)}\n`);
} catch (err) {
check("the run completed", false, String(err && err.message ? err.message : err));
}
process.stdout.write(failures.length ? `${failures.length} check(s) failed\n` : "all checks passed\n");
app.exit(failures.length ? 1 : 0);
}
app.whenReady().then(main);
+7 -2
View File
@@ -1,12 +1,17 @@
"use strict";
const net = require("node:net");
const fs = require("node:fs");
const args = process.argv.slice(2);
const socket = args[args.indexOf("--socket") + 1];
// The socket must exist at 0600 from the first instant: a reader that looks between listen and a later chmod sees 0755.
process.umask(0o177);
const deferred = args.includes("--defer-permissions");
process.umask(deferred ? 0o022 : 0o177);
const server = net.createServer(peer => {
if ((fs.statSync(socket).mode & 0o777) !== 0o600) process.exit(2);
peer.on("data", () => peer.end(JSON.stringify({ ok: true, result: { pid: process.pid, embedded: true, host_bundle_id: "io.reasonix.studio", driver_version: "0.34.1", contract_version: "0.8.0", tools_list_schema_version: "1", capability_version: "1", mcp_protocol_version: "2025-06-18" } }) + "\n"));
});
server.listen(socket);
process.on("message", message => {
if (message === "secure") fs.chmodSync(socket, 0o600);
});
process.stdin.resume();
process.stdin.on("end", () => server.close(() => process.exit(0)));
+130
View File
@@ -0,0 +1,130 @@
// Drives the real shell and kernel through the ways a development launch ends:
// its launcher killed outright, before and after the handshake, its console
// closed underneath it, and an ordinary stop. Each runs in a throwaway data home.
// Run `pnpm build:host` first, then `pnpm launcher-live`.
import assert from "node:assert/strict";
import { spawn, execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const { hostBinary } = require("../src/layout.js");
const host = hostBinary({ packaged: false, dirname: path.join(appDir, "src"), platform: process.platform, env: process.env });
const cli = path.join(path.dirname(require.resolve("electron")), "cli.js");
if (!existsSync(host)) throw new Error(`no kernel at ${host}; run pnpm build:host first`);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const alive = (pid) => {
try {
process.kill(pid, 0);
return true;
} catch (err) {
return err.code === "EPERM";
}
};
async function until(predicate, what, ms) {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (predicate()) return;
await sleep(100);
}
throw new Error(what);
}
// userData is <platform app data>/Reasonix Studio/<instance id>, and the id is
// the kernel's answer for this data home.
function profileDir(env) {
const id = execFileSync(host, ["-instance-id"], { env, encoding: "utf8" }).trim();
const base =
process.platform === "darwin" ? path.join(os.homedir(), "Library", "Application Support")
: process.platform === "win32" ? process.env.APPDATA
: process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
return path.join(base, "Reasonix Studio", id);
}
async function scenario(name, { kernel = host, end }) {
const home = mkdtempSync(path.join(os.tmpdir(), "reasonix-launcher-live-"));
const page = path.join(home, "page");
mkdirSync(page);
writeFileSync(path.join(page, "index.html"), "<!doctype html><title>launcher live</title>");
const env = { ...process.env, REASONIX_HOME: home, REASONIX_STATE_HOME: path.join(home, "state"), REASONIX_STUDIO_PAGE: page, REASONIX_STUDIO_HOST: kernel };
const profile = profileDir(env);
const shellLog = () => {
try {
return readFileSync(path.join(profile, "logs", "shell.log"), "utf8");
} catch {
return "";
}
};
const launcher = spawn(process.execPath, [cli, appDir], { cwd: appDir, env, stdio: ["ignore", "pipe", "pipe"] });
launcher.stdout.resume();
launcher.stderr.resume();
const pids = [];
try {
await until(() => /host: pid \d+/.test(shellLog()), "the shell never started its kernel", 60000);
const log = shellLog();
pids.push(Number(/shell: start .* pid=(\d+)/.exec(log)[1]), Number(/host: pid (\d+)/.exec(log)[1]));
await end({ launcher, shellLog });
await until(() => pids.every((pid) => !alive(pid)), `${name}: the shell or its kernel outlived the launch`, 20000);
assert.doesNotMatch(shellLog(), /uncaught/i);
console.log(` ok ${name}`);
} catch (err) {
console.log(` FAIL ${name}: ${err.message}\n${shellLog().split("\n").slice(-10).join("\n")}`);
process.exitCode = 1;
} finally {
launcher.kill("SIGKILL");
for (const pid of pids) if (alive(pid)) process.kill(pid, "SIGKILL");
await sleep(500);
rmSync(profile, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}
}
const handshaken = ({ shellLog }) => until(() => /host: handshake/.test(shellLog()), "no handshake", 60000);
await scenario("a launcher killed after the handshake takes the shell with it", {
end: async (run) => {
await handshaken(run);
run.launcher.kill("SIGKILL");
await until(() => /launcher gone/.test(run.shellLog()), "the shell never noticed its launcher was gone", 10000);
},
});
// A kernel that takes its time to handshake: the launch is still starting when
// its launcher goes.
if (process.platform !== "win32") {
const dir = mkdtempSync(path.join(os.tmpdir(), "reasonix-slow-host-"));
const slow = path.join(dir, "host.sh");
writeFileSync(slow, `#!/bin/sh\nsleep 3\nexec ${JSON.stringify(host)} "$@"\n`, { mode: 0o755 });
await scenario("a launcher killed before the handshake takes the starting shell with it", {
kernel: slow,
end: async (run) => {
assert.doesNotMatch(run.shellLog(), /host: handshake/);
run.launcher.kill("SIGKILL");
},
});
rmSync(dir, { recursive: true, force: true });
}
await scenario("a shell whose console was closed still stops when asked", {
end: async (run) => {
await handshaken(run);
run.launcher.stdout.destroy();
run.launcher.stderr.destroy();
// The kernel logs a request every few seconds, and each one is echoed.
await sleep(8000);
run.launcher.kill("SIGTERM");
},
});
await scenario("a launcher asked to stop stops the shell", {
end: async (run) => {
await handshaken(run);
run.launcher.kill("SIGTERM");
},
});
@@ -0,0 +1,62 @@
"use strict";
// With the project folder gone, a tool call is refused with the typed cause and the
// window says so above the composer, with a way to choose the folder again.
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const { app } = require("electron");
const { checker, listen, scriptedModel, seedHome, settledWindow, until, wait } = require("./livekit");
const { check, failures } = checker();
async function main() {
let calls = 0;
const { handler, requests } = scriptedModel(() => {
calls++;
if (calls === 1) return { name: "grep", arguments: { pattern: "hello" } };
return null;
});
const model = await listen(handler);
const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-root-live-"));
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "rx-root-ws-"));
process.env.REASONIX_HOME = home;
seedHome(home, model.url, workspace);
const { current } = require("../src/main.js");
try {
const win = await settledWindow();
const { client } = current();
const runtimes = await until("a pane", async () => {
const list = await client.json("GET", "/runtimes");
return Array.isArray(list) && list.length ? list : null;
});
process.chdir(os.tmpdir());
const gone = `${workspace}-moved`;
fs.renameSync(workspace, gone);
await client.request("POST", `${runtimes[0].base}/submit`, { input: "look for hello" });
await until("the scripted turn", async () => requests.length >= 2, 60000);
const result = String((requests[1].messages || []).filter((m) => m.role === "tool").at(-1)?.content || "");
check("the model was told the typed cause, not a bare system error", result.startsWith("root missing: the project folder ") && result.includes("not a permission or argument problem"), result.slice(0, 200));
const js = (src) => win.webContents.executeJavaScript(src);
const banner = await until("the banner above the composer", async () => js(`(() => { const b = document.querySelector('[data-cause="root-missing"]'); return b ? b.textContent : null; })()`), 20000).catch(() => null);
check("the window shows the banner", !!banner, banner);
check("the banner offers to choose the folder again", await js(`!!document.querySelector('[data-action="root-missing.reselect"]')`));
await wait(3000);
process.stdout.write(` page: ${await js("document.body.innerText.slice(0, 300).replace(/\\n/g, ' | ')")}\n`);
const png = path.join(os.tmpdir(), "rx-root-missing-live.png");
try {
execFileSync("screencapture", ["-x", "-o", "-l", win.getMediaSourceId().split(":")[1], png]);
process.stdout.write(` screenshot: ${png}\n`);
} catch (err) {
process.stdout.write(` (no screenshot: ${err.message})\n`);
}
fs.renameSync(gone, workspace);
} catch (err) {
check("the run completed", false, String(err && err.message ? err.message : err));
}
process.stdout.write(failures.length ? `${failures.length} check(s) failed\n` : "all checks passed\n");
app.exit(failures.length ? 1 : 0);
}
app.whenReady().then(main);
+416
View File
@@ -1573,6 +1573,155 @@ test("a host that exits before its handshake is logged and shown, not swallowed"
}
});
test("a development shell leaves once the process that launched it is gone", () => {
const { watchLauncher } = require("../src/launcher.js");
mock.timers.enable({ apis: ["setInterval"] });
try {
// POSIX hands an orphan to another parent; Windows leaves the dead pid in place.
for (const [why, end] of [["reparented", (s) => { s.ppid = 1; }], ["died in place", (s) => { s.alive = false; }]]) {
const state = { ppid: 4242, alive: true };
let lost = 0;
watchLauncher({ packaged: false, env: {}, onLost: () => lost++, parent: () => state.ppid, alive: () => state.alive });
mock.timers.tick(5000);
assert.equal(lost, 0, `${why}: left while the launcher was still there`);
end(state);
mock.timers.tick(1000);
assert.equal(lost, 1, `${why}: stayed after its launcher was gone`);
mock.timers.tick(5000);
assert.equal(lost, 1, `${why}: told more than once`);
}
const state = { ppid: 4242 };
let lost = 0;
const watch = watchLauncher({ packaged: false, env: {}, onLost: () => lost++, parent: () => state.ppid, alive: () => true });
watch.dispose();
state.ppid = 1;
mock.timers.tick(5000);
assert.equal(lost, 0, "a disposed watch still acted");
} finally {
mock.timers.reset();
}
});
test("a packaged, handed-over or parentless shell belongs to no launcher", () => {
const { watchLauncher, handOver } = require("../src/launcher.js");
mock.timers.enable({ apis: ["setInterval"] });
try {
const handed = {};
handOver(handed);
for (const [why, options] of [
["packaged", { packaged: true, env: {}, parent: () => 4242 }],
["handed over by a relaunch", { packaged: false, env: handed, parent: () => 4242 }],
["started by init", { packaged: false, env: {}, parent: () => 1 }],
]) {
let lost = 0;
let ppid = options.parent();
watchLauncher({ ...options, onLost: () => lost++, parent: () => ppid, alive: () => false });
ppid = 7;
mock.timers.tick(5000);
assert.equal(lost, 0, why);
}
assert.deepEqual(handed, {}, "the hand-over mark reached this shell's own children");
} finally {
mock.timers.reset();
}
});
test("a relaunch hands the shell over before it asks for the restart", () => {
const { actHandler } = require("../src/acts.js");
const { relaunchForOzonePlatform } = require("../src/ozone.js");
const key = "REASONIX_STUDIO_HANDED_OVER";
const saved = process.env[key];
delete process.env[key];
try {
const seen = [];
actHandler({ app: { relaunch: () => seen.push(process.env[key]) }, showWindow: () => {} })("relaunch");
assert.deepEqual(seen, ["1"]);
} finally {
if (saved === undefined) delete process.env[key];
else process.env[key] = saved;
}
const env = { XDG_SESSION_TYPE: "wayland", DISPLAY: ":0" };
const seen = [];
relaunchForOzonePlatform({ relaunch: () => seen.push(env[key]), exit: () => {} }, { platform: "linux", env, argv: ["x"] });
assert.deepEqual(seen, ["1"]);
});
test("output for a console the launcher closed is dropped, never thrown", () => {
const { EventEmitter } = require("node:events");
const { consoleEcho } = require("../src/launcher.js");
const stream = Object.assign(new EventEmitter(), { written: [], full: false, broken: false });
stream.write = (chunk) => {
if (stream.broken) throw Object.assign(new Error("write EIO"), { code: "EIO" });
stream.written.push(chunk);
return !stream.full;
};
const echo = consoleEcho(stream);
echo("a");
stream.full = true;
echo("b");
echo("c");
assert.deepEqual(stream.written, ["a", "b"], "a backed-up console kept queueing");
stream.emit("drain");
stream.full = false;
echo("d");
stream.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
echo("e");
assert.deepEqual(stream.written, ["a", "b", "d"], "a failed console was written to again");
const sync = Object.assign(new EventEmitter(), { broken: true, calls: 0 });
sync.write = () => {
sync.calls++;
throw Object.assign(new Error("write EIO"), { code: "EIO" });
};
const echoSync = consoleEcho(sync);
assert.doesNotThrow(() => echoSync("x"));
echoSync("y");
assert.equal(sync.calls, 1);
});
test("a shell whose console pipe was closed keeps running", async () => {
const { spawn } = require("node:child_process");
const script = `
const { consoleEcho } = require(${JSON.stringify(require.resolve("../src/launcher.js"))});
const echo = consoleEcho(process.stderr);
process.stdin.once("data", () => {
let n = 0;
const timer = setInterval(() => {
echo("x".repeat(512) + "\\n");
if (++n === 50) { clearInterval(timer); process.stdout.write("survived\\n"); }
}, 2);
});`;
const child = spawn(process.execPath, ["-e", script], { stdio: ["pipe", "pipe", "pipe"] });
child.stderr.destroy();
let out = "";
child.stdout.on("data", (chunk) => { out += chunk; });
child.stdin.end("go\n");
const code = await new Promise((resolve) => child.on("exit", resolve));
assert.equal(code, 0);
assert.equal(out, "survived\n");
});
test("the launch credential cookie is put back when something removes it, and only then", async () => {
const { keepCredential } = require("../src/credential.js");
const { EventEmitter } = require("node:events");
const jar = new EventEmitter();
const sets = [];
const ses = { cookies: Object.assign(jar, { set: async (c) => void sets.push(c) }) };
const cookie = { url: "http://127.0.0.1:5000/", name: "reasonix_token", value: "t" };
const lines = [];
keepCredential(ses, cookie, (l) => lines.push(l));
const gone = { name: "reasonix_token", domain: "127.0.0.1" };
jar.emit("changed", {}, { ...gone, name: "other" }, "explicit", true);
jar.emit("changed", {}, { ...gone, domain: "example.com" }, "explicit", true);
jar.emit("changed", {}, gone, "overwrite", true);
jar.emit("changed", {}, gone, "explicit", false);
assert.equal(sets.length, 0, "an unrelated change must not touch the jar");
jar.emit("changed", {}, gone, "evicted", true);
assert.deepEqual(sets, [cookie]);
assert.match(lines[0], /evicted/);
});
test("the tray has a file for every Windows scale, at exactly 16 * scale pixels", () => {
const { trayAsset, SCALES } = require("../src/trayimage.js");
const dim = (f) => {
@@ -1590,6 +1739,25 @@ test("the tray has a file for every Windows scale, at exactly 16 * scale pixels"
assert.equal(trayAsset(NaN).pixels, 16);
});
test("the macOS tray is a black-on-transparent Template pair, not the colour badge", () => {
const { trayImage } = require("../src/trayimage.js");
const asked = [];
const nativeImage = { createFromPath: (f) => (asked.push(f), { isEmpty: () => false }) };
const platform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "darwin" });
try {
trayImage(nativeImage, 2);
} finally {
Object.defineProperty(process, "platform", platform);
}
assert.match(path.basename(asked[0]), /^trayTemplate\.png$/);
for (const [file, px] of [["trayTemplate.png", 18], ["[email protected]", 36]]) {
const b = fs.readFileSync(path.join(path.dirname(asked[0]), file));
assert.deepEqual([b.readUInt32BE(16), b.readUInt32BE(20)], [px, px], file);
assert.equal(b[25], 6, `${file} must carry an alpha channel`);
}
});
function pe(machine) {
const b = Buffer.alloc(0x100);
b.writeUInt16LE(0x5a4d, 0);
@@ -2281,6 +2449,115 @@ test("a parked page is attached again when it is shown, and a page already on sc
assert.deepEqual(calls, ["remove", "add", "bounds 10"], "a page put away and shown again is attached again");
});
test("a page laid out wider than the pane is scaled to fit it, and keeps the viewport the agent asked for", async () => {
const Module = require("node:module");
const load = Module._load;
Module._load = function (request, ...rest) {
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
return load.call(this, request, ...rest);
};
let BrowserViews;
try {
({ BrowserViews } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const win = {
on() {},
isDestroyed: () => false,
isFocused: () => false,
webContents: Object.assign(new EventEmitter(), { isFocused: () => false }),
getContentBounds: () => ({ width: 1200, height: 800 }),
contentView: { addChildView() {}, removeChildView() {} },
};
const sent = [];
const view = {
setBounds() {},
webContents: { isDestroyed: () => false, debugger: { sendCommand: async (method, params) => sent.push([method, params]) } },
};
const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
const entry = { view, at: null, attached: true };
views.entries.set("t1", entry);
views.keepKeyboard = () => {};
entry.override = { width: 1440, height: 1000, deviceScaleFactor: 0, mobile: false };
views.show("t1", { x: 0, y: 0, width: 720, height: 800 });
assert.equal(sent.length, 1);
assert.equal(sent[0][1].scale, 0.5, "a 1440 wide layout in a 720 wide pane is halved");
assert.equal(sent[0][1].width, 1440, "the layout viewport is still the one the agent asked for");
views.show("t1", { x: 0, y: 0, width: 720, height: 800 });
assert.equal(sent.length, 1, "the same pane sends nothing again");
views.show("t1", { x: 0, y: 0, width: 360, height: 800 });
assert.equal(sent.at(-1)[1].scale, 0.25, "a narrower pane scales further");
views.show("t1", { x: 0, y: 0, width: 2000, height: 1200 });
assert.equal(sent.at(-1)[1].scale, 1, "a pane larger than the layout never enlarges it");
const settled = sent.length;
views.fit(entry);
assert.equal(sent.length, settled, "an unchanged scale is not sent again");
views.fit(entry, true);
assert.equal(sent.length, settled + 1, "a capture that dropped the scale gets it back");
assert.equal(sent.at(-1)[1].scale, 1);
entry.override = undefined;
const before = sent.length;
views.show("t1", { x: 0, y: 0, width: 500, height: 800 });
assert.equal(sent.length, before, "with no override there is nothing to fit");
});
test("a page given its own viewport is fitted whole into its panel, centred, and never enlarged", () => {
const Module = require("node:module");
const load = Module._load;
Module._load = function (request, ...rest) {
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
return load.call(this, request, ...rest);
};
let placement;
try {
({ placement } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const panel = { x: 380, y: 60, width: 560, height: 620 };
assert.deepEqual(placement(panel, null), { at: panel, scale: 1 }, "a page without a viewport of its own fills the panel");
for (const none of [{ width: 0, height: 0 }, { width: 0, height: 600 }, { width: 390, height: 0 }, { width: NaN, height: 600 }, { width: 390, height: Infinity }, { width: -1, height: 600 }]) {
assert.deepEqual(placement(panel, none), { at: panel, scale: 1 }, `${JSON.stringify(none)} is no viewport at all`);
}
const wide = placement(panel, { width: 1280, height: 900 });
assert.equal(wide.scale, 0.4375);
assert.deepEqual(wide.at, { x: 380, y: 173, width: 560, height: 394 });
const phone = placement(panel, { width: 390, height: 844 });
assert.ok(phone.scale < 1 && phone.at.height <= panel.height && phone.at.width <= panel.width);
assert.deepEqual(placement({ x: 20, y: 20, width: 1400, height: 1000 }, { width: 1280, height: 900 }), { at: { x: 80, y: 70, width: 1280, height: 900 }, scale: 1 });
});
test("input that carries a point is scaled with the page or refused, never sent to the wrong place", () => {
const Module = require("node:module");
const load = Module._load;
Module._load = function (request, ...rest) {
if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
return load.call(this, request, ...rest);
};
let scaleInput;
try {
({ scaleInput } = require("../src/browserviews.js"));
} finally {
Module._load = load;
}
const mouse = { type: "mousePressed", x: 1200, y: 800, button: "left" };
assert.equal(scaleInput("Input.dispatchMouseEvent", mouse, 1), mouse, "an unscaled page gets the point as it is");
assert.deepEqual(scaleInput("Input.dispatchMouseEvent", mouse, 0.5), { type: "mousePressed", x: 600, y: 400, button: "left" });
for (const method of ["Input.dispatchTouchEvent", "Input.dispatchDragEvent", "Input.emulateTouchFromMouseEvent", "Input.synthesizeTapGesture", "Input.synthesizeScrollGesture", "Input.synthesizePinchGesture"]) {
const params = { x: 10, y: 20, touchPoints: [{ x: 10, y: 20 }] };
assert.equal(scaleInput(method, params, 1), params, `${method} passes on an unscaled page`);
assert.throws(() => scaleInput(method, params, 0.5), { code: "input_unscaled" }, `${method} must not reach a scaled page unscaled`);
}
const key = { type: "keyDown", key: "a" };
assert.equal(scaleInput("Input.dispatchKeyEvent", key, 0.5), key, "keys carry no point");
assert.equal(scaleInput("Input.insertText", { text: "x" }, 0.5).text, "x");
});
test("rewind notice dismissal survives the desktop preference disk round trip", (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rewind-notice-prefs-"));
const file = path.join(dir, "prefs.json");
@@ -2368,6 +2645,109 @@ test("CUA embedded control stops the old service before admitting a fresh one",
assert.equal(fs.existsSync(owned.abortSocket), false);
});
test("CUA waits for socket permissions on initial launch and abort restart", { skip: process.platform === "win32" }, async (t) => {
const { spawn } = require("node:child_process");
const net = require("node:net");
const promises = require("node:fs/promises");
const { startEmbedded, metadata, stopProcess } = require("../src/cua");
const fake = path.join(path.dirname(require("node:url").fileURLToPath(import.meta.url)), "cua-service-fixture.js");
const children = [];
const observed = new Set();
const ready = new Set();
let owned;
t.after(async () => {
await owned?.stop();
for (const child of children) await stopProcess(child);
});
const lstat = promises.lstat;
t.mock.method(promises, "lstat", async (...args) => {
const current = await lstat(...args);
if (path.basename(args[0]) === "driver.sock" && (current.mode & 0o777) !== 0o600) {
const child = children.at(-1);
if (!observed.has(child.pid)) {
observed.add(child.pid);
child.send("secure");
}
} else if (path.basename(args[0]) === "driver.sock") {
ready.add(children.at(-1).pid);
}
return current;
});
const connect = net.createConnection;
t.mock.method(net, "createConnection", (...args) => {
if (path.basename(args[0]) === "driver.sock") assert.ok(ready.has(children.at(-1).pid), "metadata was requested before private permissions were observed");
return connect(...args);
});
owned = await startEmbedded(process.execPath, cuaContract, () => {}, (_exe, args, options) => {
const child = spawn(process.execPath, [fake, ...args, "--defer-permissions"], { ...options, stdio: [...options.stdio, "ipc"] });
children.push(child);
return child;
});
const before = await metadata(owned.socket);
const reply = await new Promise((resolve, reject) => {
const peer = net.createConnection(owned.abortSocket);
let text = "";
peer.once("error", reject);
peer.on("connect", () => peer.write('{"op":"abort"}\n'));
peer.on("data", bytes => { text += bytes; if (text.includes("\n")) { peer.destroy(); resolve(JSON.parse(text)); } });
});
assert.equal(reply.ok, true);
const after = await metadata(owned.socket);
assert.notEqual(after.result.pid, before.result.pid);
assert.deepEqual([...observed], [before.result.pid, after.result.pid]);
assert.equal((await lstat(owned.socket)).mode & 0o777, 0o600);
assert.throws(() => process.kill(before.result.pid, 0));
});
test("CUA permission readiness remains bounded without connecting to a non-private socket", { skip: process.platform === "win32" }, async (t) => {
const { spawn } = require("node:child_process");
const promises = require("node:fs/promises");
const { startEmbedded, stopProcess } = require("../src/cua");
const fake = path.join(path.dirname(require("node:url").fileURLToPath(import.meta.url)), "cua-service-fixture.js");
let child;
t.after(async () => { if (child) await stopProcess(child); });
const now = Date.now();
let expired = false;
t.mock.method(Date, "now", () => now + (expired ? 10001 : 0));
const lstat = promises.lstat;
t.mock.method(promises, "lstat", async (...args) => {
const current = await lstat(...args);
if (path.basename(args[0]) === "driver.sock" && (current.mode & 0o777) !== 0o600) expired = true;
return current;
});
await assert.rejects(startEmbedded(process.execPath, cuaContract, () => {}, (_exe, args, options) => {
child = spawn(process.execPath, [fake, ...args, "--defer-permissions"], options);
return child;
}), /Embedded CUA failed readiness/);
assert.equal(expired, true);
assert.equal(child.exitCode, 0);
});
for (const kind of ["file", "symlink"]) {
test(`CUA readiness rejects a ${kind} instead of waiting for permissions`, { skip: process.platform === "win32" }, async (t) => {
const { spawn } = require("node:child_process");
const { startEmbedded, stopProcess } = require("../src/cua");
let child, socket;
t.after(async () => {
if (child) await stopProcess(child);
if (socket) fs.rmSync(path.dirname(socket), { recursive: true, force: true });
});
const script = `
const fs = require("node:fs");
const socket = process.argv[1];
if (process.argv[2] === "symlink") fs.symlinkSync("missing-target", socket);
else fs.writeFileSync(socket, "", { mode: 0o600 });
process.stdin.resume();
process.stdin.on("end", () => process.exit(0));`;
await assert.rejects(startEmbedded(process.execPath, cuaContract, () => {}, (_exe, args, options) => {
socket = args[args.indexOf("--socket") + 1];
child = spawn(process.execPath, ["-e", script, socket, kind], options);
return child;
}), /Invalid private CUA socket/);
assert.equal(child.exitCode, 0);
});
}
test("CUA repeated quit waits for owned driver cleanup", async () => {
const { createQuitGate } = require("../src/cua");
@@ -2759,3 +3139,39 @@ test("a window that fails to raise is given its opacity and mouse back", async (
await assert.rejects(wakeOf(win).run(async () => {}), /no window/);
assert.deepEqual(calls.slice(-3), ["ignore false", "opacity 1", "minimize"]);
});
const { processMemoryRows, processMemoryHandler } = require("../src/procmem.js");
test("the shell's process memory is only a fixed type and two whole numbers per process", () => {
const metrics = [
{ pid: 4740, type: "Tab", name: "C:\\Users\\someone\\x", cmd: "--user-data-dir=/home/someone", memory: { workingSetSize: 3992908, privateBytes: 3900000, peakWorkingSetSize: 9 } },
{ pid: 11, type: "Browser", memory: { workingSetSize: 120000, privateBytes: 90000 } },
{ pid: 12, type: "GPU", memory: { workingSetSize: 76000 } },
{ pid: 13, type: "Utility", memory: { workingSetSize: 6000, privateBytes: 5000 } },
{ pid: 14, type: "Zygote", memory: { workingSetSize: 1000, privateBytes: 900 } },
{ pid: 15, type: "Tab", memory: { workingSetSize: Number.NaN, privateBytes: -4 } },
];
const rows = processMemoryRows(metrics);
assert.deepEqual(rows, [
{ process: "renderer", workingSetKB: 3992908, privateKB: 3900000 },
{ process: "browser", workingSetKB: 120000, privateKB: 90000 },
{ process: "gpu", workingSetKB: 76000, privateKB: 0 },
{ process: "utility", workingSetKB: 6000, privateKB: 5000 },
{ process: "utility", workingSetKB: 1000, privateKB: 900 },
{ process: "renderer", workingSetKB: 0, privateKB: 0 },
]);
assert.ok(rows.every((r) => Object.keys(r).sort().join() === "privateKB,process,workingSetKB"));
assert.doesNotMatch(JSON.stringify(rows), /Users|pid|user-data|cmd/i);
});
test("the shell reports at most eleven processes, since the kernel adds its own row to a dozen", () => {
const many = Array.from({ length: 30 }, (_, i) => ({ pid: i, type: "Utility", memory: { workingSetSize: 1, privateBytes: 1 } }));
assert.equal(processMemoryRows(many).length, 11);
});
test("only the app's own window is answered, and nothing is read from the asker", () => {
const app = { getAppMetrics: () => [{ type: "Tab", memory: { workingSetSize: 5, privateBytes: 4 } }] };
assert.deepEqual(processMemoryHandler(app, () => true)({}), [{ process: "renderer", workingSetKB: 5, privateKB: 4 }]);
assert.deepEqual(processMemoryHandler(app, () => null)({}), []);
assert.deepEqual(processMemoryHandler({ getAppMetrics: () => { throw new Error("x"); } }, () => true)({}), []);
});
+7 -7
View File
@@ -39,8 +39,8 @@
"codemirror": "^6.0.2",
"katex": "^0.19.0",
"maplibre-gl": "6.14.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-map-gl": "8.1.3",
"react-markdown": "^10.1.0",
"recharts": "3.10.1",
@@ -56,14 +56,14 @@
"devDependencies": {
"@babel/parser": "^8.0.7",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@testing-library/user-event": "^14.6.8",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"jsdom": "^30.1.2",
"playwright": "^1.64.0",
"jsdom": "^30.1.3",
"playwright": "^1.64.1",
"typescript": "^7.0.2",
"vite": "^8.3.3",
"vitest": "^5.0.3"
"vite": "^8.3.4",
"vitest": "^5.0.4"
}
}
Loaded 100 of 1719 files, more files were not shown because too many files have changed in this diff. Show more