Compare commits

..
262 Commits
Author SHA1 Message Date
esengine f37634cdde fix(studio): the installer finds WebView2 where it finds everything else
The Windows build failed again after the payload moved out of /tmp. The path
makensis printed was a clean D:/a/... one and the file it named was there —
curl had written it and the non-empty check had passed — yet File still
reported "no files found".

That is the message File prints when its argument matched nothing as a
pattern. It splits directories on backslashes, so a bare forward-slash path
is one long filename to it. MUI_ICON composes "${PAYLOAD}\appicon.ico" and
compiles earlier in the same script without complaint; WEBVIEW2 was the only
member handed over as a whole path.

WEBVIEW2 is now a flag and File composes the path the way the binary, the
icon and the SPA tree already do. Packaged locally with makensis 3.12.
2026-08-18 07:54:09 -07:00
esengine 8ef549ca53 fix(studio): the installer payload leaves a path makensis cannot open
The Windows build failed at the WebView2 bootstrapper: curl wrote it into the
mktemp directory and the check for a non-empty file passed, so -DWEBVIEW2 was
defined, but makensis reported "no files found" for it.

Both are true at once because only curl is MSYS-aware. /tmp maps through the
user profile, and converting it for a native program yields an 8.3 short path
(C:/Users/RUNNER~1/...) that makensis cannot open — while $ROOT in the same
command line converts to a plain D:/a/... path and works. The payload now
lives under the repo, where that conversion has already proven itself.

CI never covered this: Windows built with --shell-only, and the installer had
only ever been packaged by makensis on macOS.
2026-08-18 07:45:13 -07:00
esengine b7d887263d fix(remote): a lock released mid-wait is contention, not a failure
acquireServeLock failed with "create serve lock: file exists" whenever the
owner released the lock between a waiter's failed mkdir and the stat that
follows it. The stat then finds nothing, and the code read a missing
directory as a reason to fail rather than as the lock having just come free.

That line is the only source of the message and LockDir is created by mkdir
alone, so it cannot have been "not a directory": the ubuntu CI failure of
TestEnsureServeSerializesConcurrentClients has no other path to it. The
window is narrow enough that 200 local runs under -race did not reproduce it.

A stat that finds nothing now falls through to the same poll every other
contender already takes.
2026-08-18 06:56:53 -07:00
esengine f1fdcf8d64 feat(release): a Studio version ships with notes of its own
The release body was one fixed template, so 2.0.0 would have published a
first release that said nothing about what it is. A version's notes now live
in release-notes/studio/<version>.md and the workflow appends the standing
text about signing, installation and self-update to them.

Publishing without that file still works — it warns and carries the standing
text alone — because a missing note is not a reason to fail a build that is
already signed and verified.

2.0.0's notes are this line's first, so they read as an introduction rather
than a diff: what the workspace does, how models and accounts are named, what
extensions may redraw, and the three limits that outlive the release.
2026-08-18 06:51:27 -07:00
esengine 4a037ce62c docs(studio): the release line describes what it actually does
Four descriptions had drifted from the code that overtook them, and the
release notes are the ones users read:

- the GitHub release said Windows and macOS were both unsigned and that
  self-update was unavailable, while the macOS job now fails closed without
  a Developer ID and all three platforms update themselves;
- studio-manifest's package comment said platforms stays empty, which is
  what turns self-update off, while run() has been filling it;
- studio-build.sh said the Windows artifact is a portable archive because
  there is no NSIS installer, which the script itself now builds;
- STUDIO_RELEASE.md carried a whole "Self-update is not available yet"
  section and listed the missing installer as a known gap.

Windows staying unsigned is the one claim that survived, so it is still
stated — SignPath allows one origin and this branch is not it.
2026-08-18 06:44:52 -07:00
yhh b92009401d fix(studio): the macOS entitlements match the line that has actually shipped
Library validation was left enabled, which neither codesign nor notarization
reports — it surfaces on a real Mac at launch, and this line had never produced
a signed build to find out on. The desktop line's set is the only one in this
repository a signed Wails build has ever launched under, so the first signed
Studio release uses it rather than an untested tightening.
2026-08-18 06:07:24 -07:00
yhh 80c755e279 fix(release): Studio's macOS build gets the Developer ID it already assumes
studio-build.sh signs with a Developer ID when the keychain holds one and
ad-hoc signs when it does not, and the workflow never staged one — so every
macOS artifact this line could have published was ad-hoc signed and
un-notarized. Studio declares SelfUpdate: true, which promises a replacement
bundle Gatekeeper still opens, so the gap would have surfaced as a broken
update rather than a refused first launch.

The desktop line's own step, unchanged: one Developer ID signs any bundle
identifier its team owns, and all five APPLE_* secrets are repository-level,
so the build job reads them without an environment. Official macOS builds now
fail closed the way the desktop line's Authenticode gate does.
2026-08-18 06:05:16 -07:00
yhh 5cf3bbf8c0 fix(studio): the catalogue gate reads the sources through Vite, not the filesystem
node:fs typechecks only where @types/node is installed, and the frontend does
not install it — the gate passed locally on a stale node_modules and failed the
moment CI resolved the lockfile from scratch. import.meta.glob reads the same
files with types the project already has.
2026-08-18 06:05:16 -07:00
yhh 64a4da665e feat(studio): interface scale reaches past what a 1080p window needs
The named steps stopped at 1.3, which is a 1080p answer: on a 2560 display it
leaves the transcript around 150 characters to the line, and there is no step
anyone could have named in advance because what a 2560 display wants a 3840
one does not. The range takes over past the names rather than the list
growing a tail of numbers, and it reaches 2.5 safely only because the layout
now folds on the room it actually has.

Not doing: capping the column width. tokens.css records that a 900px --doc
was tried and taken out because it hung the flow in the middle of a wide
window, and the 68ch measure was refused for the same reason. Raising the
scale shortens the line by itself, since the columns beside it are fixed.
2026-08-18 05:43:31 -07:00
yhh d3d2eeee02 feat(studio): the layout folds on the room it has, not the window it sits in
A media query answers for the real viewport, and the zoom setting does not
move it — at 1.3 the interface is a third larger while the layout still
believes it has the whole screen, so the columns never give way when they
have run out of room. Measured: innerWidth reads 1920 at every zoom, and the
1200px query returns the same answer at all four steps.

document.body is already laid out in the scaled space, so its content box is
the viewport divided by the zoom with no arithmetic anywhere: 2133 at 0.9,
1477 at 1.3, 960 at 2.0, exact at each. viewport.ts reads that and publishes
which columns have folded; the stylesheet names the fold and never the
number. Two things move it, a resize and the zoom setting, and each calls the
one derivation rather than keeping a copy of the thresholds.

Container queries would say this in CSS alone and are the better answer once
Safari 15.6 is no longer a target — it still is, which is also why there is
no nesting here.

The vh compensations come with it: those lengths measure the unscaled
viewport, so the model picker's cap rendered 1150px tall on an 855px screen
at the ceiling this makes reachable, with its tail past the edge.
2026-08-18 05:43:30 -07:00
yhh 8faade115d fix(studio): the English window stops falling back to Chinese in two panels
Seventeen keys the interface asks for were never in the catalogue, so scope
and server-onboarding rendered Chinese to an English reader. The catalogue's
own header says a test is what keeps it from staying incomplete quietly —
that test did not exist. It does now: it reads every t("…") in the source and
fails naming the key and the file it came from.
2026-08-18 05:43:30 -07:00
yhh 8350d8fe57 test(webview2): the cookie test stays on the thread its COM objects live on
It initialised an STA apartment and then let the goroutine migrate, so under
load the WebView2 environment was created on a thread that had never called
CoInitializeEx — the CI-only "CoInitialize has not been called". The package's
own init() already locks its thread before initialising COM; the test now does
the same. Its two subtests become plain closures for the same reason: t.Run
would run them on their own goroutines, and a COM object answers only to the
thread that made it.
2026-08-18 05:25:11 -07:00
yhh 694b792fac test(builtin): the pipe-status case stops racing its own pipe
"echo ok | sh -c 'exit 4'" asserts PIPESTATUS is [0 4], but the right side
exits without reading: when the machine is loaded enough for it to win, the
echo takes SIGPIPE and reports 141. Measured both ways locally — a left side
that writes late reports 141 every time, one that never writes reports 0. The
case is about the last command failing while the first succeeds, so it uses a
left side that does not write.
2026-08-18 05:25:11 -07:00
yhh c5e4c5d5a4 fix(remote): ssh-agent says it is unavailable on Windows at the boundary
dialAgent's Windows build only ever returns an error, so the shared callback
in auth.go held a branch that could never be taken — staticcheck says the
comparison is always true, and it was right. The platform split now sits on
agentAuth itself, which is what the Windows comment already claimed, and the
IdentitiesOnly filtering that exists only to serve it moves with it.
2026-08-18 05:25:11 -07:00
yhh 0122ea0a48 fix(studio): the Enter that confirms a candidate stops breaking the line
Several input methods end the composition just before the confirming Enter
arrives, which is what the grace window in useIme exists for. But the
composer only returned early on it — it never stopped the event, so the
textarea did what it does with an unhandled Enter and inserted a newline.
Finish a word, press Enter, and the first press broke the line while the
second one sent. Measured on the running composer: that Enter now reports
defaultPrevented and leaves the text alone, and the next one sends.
2026-08-18 04:57:42 -07:00
yhh c381298c69 feat(studio): what you said is findable, and what it said can be taken away
Every turn on the timeline is the same shape, so your own message was told
apart from the agent's by a 20px inverted square in the gutter — a screen of
cards later, you cannot find where you spoke. It now carries a filled block
and a left rail, in neutral --text rather than a hue: every hue in this
system already means something (net/deleg/warn/err) and borrowing one would
lie. Newlines survive too, because the shape you typed is half of how you
recognise it.

The window had no copy affordance at all — the system context menu was the
only way to take an answer out of it — so the button under the body is
present rather than revealed on hover, and it appears only once the answer
is whole. navigator.clipboard is undefined where Wails serves over its own
scheme, hence the fallback.

Also routes the thinking label through t(): the catalogue has carried a
translation for it the whole time and the call site was passing the Chinese
string straight through.
2026-08-18 04:57:41 -07:00
yhh f49a733643 test: clear the analyzer findings repolint was standing in front of
The lint job runs repo standards before golangci-lint, so while the former
failed the latter never got to speak. reflect.TypeFor, slices.Backward,
WaitGroup.Go and t.Context, applied by the analyzers themselves.
2026-08-18 04:57:29 -07:00
yhh a328ab1d44 refactor(serve): drop the rebuild shim its own refactor orphaned
Splitting rebuildWith out gave every caller an explicit adopt flag, and the
one-line wrapper that used to be the entry point kept compiling with nobody
calling it. rebuildOptions still has its other readers.
2026-08-18 04:57:29 -07:00
yhh 7997ed58f7 test(boot): the read-back promise stops depending on the host having bwrap
The test drives bash to park its output, then follows the pointer. On a
host with no OS sandbox the enforced bash tool refuses fail-closed, so
there is no pointer and the second turn never happens — the failure reads
as "got 1 tool result", naming nothing about the sandbox. CI's coverage
job never installs bubblewrap and had been passing on whichever runners
happened to ship it. The subject here is the pointer, not the jail.
2026-08-18 04:27:52 -07:00
yhh bcd1f126a7 docs(serve): the DecorateSink note says the same thing in its budget 2026-08-18 04:27:52 -07:00
yhh 6506c0d1fe refactor(desktop): the window's request routing is named, and a doc goes home
run had grown past the function-size limit carrying a twenty-line asset
middleware inline; it is one decision — hub, or assets — so it reads as a
function. The same edit that grew run also inserted windowNotifications
between grantWindowCapabilities and its doc, leaving four lines about
folder pickers and provider keys sitting above a notification sink.
2026-08-18 04:27:46 -07:00
yhh 187e085c06 test(update): the linux install test declares the line its archive carries
VersionedInstaller grew a Line field — the host's declaration of what a
release carries — and the installer reads it to know which members to
extract. The linux test still built the installer without one, so both
cases failed at "release unit has no members" before reaching what they
were written to check.
2026-08-18 04:27:40 -07:00
yhh 9f0457fb33 fix(studio): numbers are formatted once, in the reader's language
Five files each carried their own k/M rule with different thresholds and
decimal places, four spelled a duration to four different precisions, two
converted bytes, and money was a symbol table glued to toFixed(2). The
English window had no thousands separator anywhere, because every large
number reached the screen by interpolating the raw value.

They now go through one module built on Intl. The kernel already sends
the ISO currency code and says it is preferred over the symbol — nothing
read it, so an English reader saw ¥ where CN¥ was meant, and no formatter
could place the symbol where a language wants it. Percentages take a
ratio and let Intl put the sign; durations and byte sizes state their
precision at the call site instead of each inventing one.

What deliberately does not go through it: SVG path coordinates, the
trajectory export's rounded values, and aria-valuenow. Those are machine
inputs, and a thousands separator in a path attribute draws nothing.

The technical suffixes stay untranslated for the same reason. Intl's
compact notation renders 12345 as 1.2万 in Chinese, which is a quantity
feeling rather than the scale a token count is read on — k, M, KB and s
mean the same thing to both readers, so Intl formats the number and the
domain keeps the unit.

SayCard was building its label by string concatenation and never called
t(), so an English window read it in Chinese. The catalogue already had
the entries; only the call site was missing.
2026-08-18 03:02:32 -07:00
yhh f60c3013eb fix(studio): drop the hook recipe that only ran on one platform
The "notify me when it finishes" recipe was an osascript line, so on
Windows and Linux it was a suggestion that fails the moment it fires.
A starting point that cannot start is worse than one fewer.
2026-08-18 02:40:03 -07:00
yhh 243fa1bf07 fix(studio): removing a folder closes the panes that are holding it
The kernel will not pull a workspace out from under a pane that is still
writing, so removing a folder with tabs open just failed — and the
refusal named no pane, leaving the reader to work out which of eight tabs
belonged to that folder. Removal now closes the folder's own panes first,
the way dropping a session already did, and waits for them: the runtime
holds the transcript's lease until it is down.

onClose takes a set rather than one id, since the answer is "these", and
liveIds is asked only while the confirmation is on screen — run state
changes constantly and walking every runtime to render a row is not worth
it. busy() carries params now, so workspace.has_open_panes can say how
many there are; a refusal that says "close the pane first" is only
useful next to which one.
2026-08-18 02:39:51 -07:00
yhh dff014ef39 feat(studio): a model source is named once, and the name it gets is answerable
A new source had to be named by hand before it could be saved, and two
accounts on one host both ended up displayed by that host — which says
nothing about which is which. The name is now derived from the host
(derivedName) and prefilled, so nobody has to invent one, and it comes
from a single derivation so "is this still the name we generated" stays
answerable later.

That question is what the picker needs. modelEntry carries Preset, which
marks a name we shipped rather than one somebody chose, so accountLabel
can tell a curated entry and our own -2/-3 suffix from a name typed on
purpose; only when no entry has one does the shared host speak for the
account.
2026-08-18 02:39:36 -07:00
yhh f21a64c67e fix(serve): reloading extensions stops reusing the generation it replaces
An extension author who edited their runtime's code had to restart the
app to load it. Reloading was already wired — CLI, serve and ACP all
expose it — but it rebuilt through the same options a model or workspace
switch uses, and those deliberately carry the serving generation forward:
the sidecars keep running, the dependency graph is not re-walked, and
skill/command/hook discovery is reused. That is right for a switch, where
the extensions did not move. A reload happens because they did, so
reusing either handed the old code straight back.

Reload now builds its own options: no sidecar manager, no reused
assembly, no previous plan, snapshot or graph. The runtime owner stays,
because it identifies the session lineage rather than the extension
state. Since the replacement now starts its own sidecars and the old
manager is closed right after the swap, the reload also records the new
generation as the base for later reuse — without that, the next switch
would reuse a manager that no longer exists.

Studio had no way to ask for it at all: no port method, no control, only
a stray busy.reload_extensions string left over from the old frontend.
The extensions pane gets a Runtime group above the package list, since
what it reloads is the whole runtime and not one package. The control
carries its own duration — a breathing dot while sidecars restart, a
settle when it lands, a nudge and the kernel's own reason when it is
refused mid-turn — reusing the motion and colour vocabulary already in
the sheet rather than introducing a spinner this interface does not have.

sse.ts and Settings.tsx were both at the file ceiling, so the transport
half of the port moves to sse_http.ts (mirroring how the fixture is
already split) and the reload control to its own hook.
2026-08-18 02:39:05 -07:00
yhh 3c893c51f8 fix(agent): a paged read that overflows one result is a window, not a hole
An oversize tool result was snipped head+tail with the middle discarded,
even when the tool that produced it could simply be read on from where it
stopped. read_file numbers every line it returns and names the offset to
continue from; read_subagent_result pages by byte offset. Cutting the
middle out of either undid the paging the tool had just done correctly,
and left the model re-reading args it had already narrowed.

Tools now declare whether they address their own continuation
(tool.Paged). An oversize result from one is cut back to whole leading
lines with a marker saying to read on — nothing is discarded, so it
carries no truncation notice. Everything else still spills to a file and
only truncates when there is nowhere to write.

Two judgements that read wording are gone with it. The limiter picked its
head/tail geometry by matching the tool's name, duplicating the SnipHint
contract those same tools already implement and mis-serving every MCP
tool whose name happened to contain "read"; it now asks snipStrategyFor.
Failure was detected by searching the body for "error:" — which misses
`--- FAIL` and every non-English toolchain, and fires on prose that
mentions the word. The caller knows; it passes the fact.

Frontends could not tell a result that moved to disk from one that lost
its middle: both arrived as Truncated=true. event.Tool now carries an
OutputBound naming which of the four happened, with the original's size
and where the full text lives. Studio says so on the card and reserves
its warning colour for the one case where the model really did not see
the rest. Its fold is a clip against one visual budget rather than three
separate line thresholds, so the rows a grep or ls result reported are no
longer silently dropped at forty.
2026-08-18 02:38:47 -07:00
yhh 313be93cd6 chore(scripts): a support script that collects what a duplicate-session report needs
Users reporting one conversation appearing many times in the sidebar have to be
walked through %APPDATA%\reasonix\projects\<slug>\sessions by hand, and the
answer to "is this a recovery fork, a repeated create, or a dual writer" lives
in the .jsonl.meta sidecars nobody knows to look at.

The script collects it in one double-click: the session listing with content
hashes, the meta lineage (parent_id, recovered, recovery_reason,
recovery_depth), the conflict and lease sidecars, running Reasonix processes,
and doctor output when the CLI is on PATH. It groups the findings by identical
content, by title, by topic_id, and by zero-byte, so the three causes separate
in the report itself. Chat bodies never leave the machine; titles are truncated
and the user is told what is in the zip before sending it.

.gitattributes pins *.bat to CRLF. cmd.exe misreads an LF-only batch file and
runs past exit /b into the payload, which is what the file does on any checkout
that normalizes it — core.autocrlf hides this on Windows only.
2026-08-18 01:37:55 -07:00
yhh bd9a8060ab fix(tool): the bash steer names only tools the model can actually call
The steer told the model to prefer grep, read_file, ls, glob, edit_file and
move_file over shell equivalents. Four of those six never reach the provider
schema: applyUnifiedProviderToolSurface narrows it to CoreProviderToolNames,
which has no grep, ls, glob or move_file — they are reachable only through
use_capability. A recommendation whose names the model cannot find discredits
the whole sentence, including the part that was true, so it reaches for the
shell instead. On Windows the same description handed it the recipe:
"Get-Content (cat)" plus "no head/tail: use Select-Object -First N" is exactly
the Get-Content | Select-Object -Skip N -First 20 paging users have reported.

Name only the three file tools that are provider-visible, drop Get-Content from
the PowerShell mapping (Select-String stays — grep really is unreachable, so
shell search is still the only way to search), and replace the abstract
"prefer the dedicated tools" with the concrete alternative it lost to: what
read_file returns per call, that the line numbers are what edit_file targets,
and what shell paging costs instead. The line count comes from
readFileDefaultLimit so the prose cannot drift from the constant.

bashSteerTools carries the names so internal/boot asserts each one against the
real provider request rather than the declared allowlist; putting "grep" back
fails it.

Live A/B, deepseek-v4-flash over a 2571-line file whose read_file output
exceeds the 32KB spill threshold, 4 runs per arm: shell paging fell from 3/4
runs to 0/4, first file action was read_file in 4/4 rather than 3/4, and mean
bash calls went 2.0 to 1.25. Below the spill threshold the old text already
behaved, which is why this only shows on large files.

Cache: ToolSchemaTokens 4236 to 4247 (+11), ToolsHash and PrefixHash rotate
once on upgrade. SystemHash is unchanged.
2026-08-18 01:37:41 -07:00
yhh f0305e7c3d fix(session): a runtime owns what it writes, and a window says when it is done
Close the window, reopen before the process finished, and the transcript starts
forking into an endless run of recovery copies. Three things had to line up, and
all three were real.

The window vanished on the close button while the process was still saving. That
is the app telling you it has closed, so reopening then is a reasonable thing to
do — and it is what puts two processes on one workspace.

Nothing refused the second one. Ownership of a session was arranged by whoever
happened to build the runtime: the CLI takes the lease before it builds anything
and refuses a held session there, hub.Open takes one when it is handed a path,
and hub.Adopt — the path the shell opens on — took none at all. A window
therefore never held the session it was writing.

And that is what decides the shape of a collision. A session that has bound
write authority at least once fails closed when it loses it; one that never
bound any forks a recovery copy instead (agent/session.go, authRequired). The
window's sessions never bound one, so two writers meant copies rather than a
refusal, and each copy gave the other something new to conflict with.

So: ownership becomes a property of a runtime rather than of the host that
happened to make it — Adopt arranges it exactly as Open does, and a host that
already has its own keeps it. Authority then follows the session instead of
being re-established at each entry point able to move it: it hangs off
setSessionPath, the single place every path change lands, which is what finally
covers the path no endpoint mints — the one a controller creates for itself on
the first turn, which is precisely the session a window runs on.

Being refused is not an error everywhere. A session that was named — CLI
--resume, a click in the sidebar — refuses, as /resume already did. A session
merely inherited, which is what a window opens on, is not worth refusing over:
the window takes a new one. Writing into the held file is what forks it.

The close button now starts the shutdown and keeps the window up for it. Nothing
is faster; nothing lies. Clicking again does not cut it short, because only the
quit the shutdown issues itself may take the window down.

Verified by removing it: with the setSessionPath handler disabled, a pane's own
freshly minted session is free for any other writer to take, and the test says
so. No timeout was shortened and nothing polls — a held session is refused at
the moment of adoption, which is while the caller can still choose another.
2026-08-18 00:03:19 -07:00
yhh f438656107 fix(provider): a key replaced mid-session reaches the next request
Studio saves a new key to the credential store and returns. The session
running at that moment kept sending the old one, because every provider
copied the key into itself at construction and no request ever asked again.
Swapping an exhausted key for a funded one therefore changed nothing: the
same 402 came back, from a key the user had already replaced.

A key is external mutable state and was modelled as an immutable value. The
config now carries where to get it, not what it was — the provider entry
already reads the credential store per call, so passing that method is the
whole live source. An empty answer falls back to the key the client was built
with, so a credential file that momentarily cannot be read does not break a
run that was working.

This is why the save path deliberately does not rebuild: the conversation
must not be interrupted to change a key, and now it needn't be. Config moves
to its own file, which is where its resolver belongs and what keeps
provider.go from growing.
2026-08-17 22:30:55 -07:00
yhh 4fdd67b890 fix(config): a role you assign is a role that is still there next read
Assigning the guardian role answered 204 and changed nothing. Config writing is
hand-rolled TOML, and the renderer had no line for guardian_model — so the API
accepted it, the file was written without error, and the next load returned the
zero it started from. The UI then drew "follows the main model", which is what
the config now actually said.

It was never only guardian. Walking AgentConfig field by field turns up eight
settings the kernel reads and the renderer cannot write: guardian_model,
guardian_temperature, triage_model, task_cost_budget, task_time_budget_minutes
and goal_token_budget are live and now render; auto_plan and its classifier are
retired and correctly do not.

The test is the part worth keeping. It reflects over every [agent] key, writes
a value, saves, loads, and demands it back — with an explicit list for the keys
the loader is supposed to drop, asserted NOT to survive so that list cannot
become somewhere to park a field the renderer merely forgot. A struct field
added without a renderer line now fails here instead of shipping as a setting
that silently does nothing.

The delegation keys move to their own file rather than growing RenderTOMLForScope,
which is already 750 lines and 155 branches. They belong together anyway: every
one of them names a model other than the main one, or bounds what those handoffs
may spend.
2026-08-17 22:28:19 -07:00
yhh 016c8a94d4 fix(plugin): a background listing stops killing the server it just spawned
Phase B opens a second connection per server so a slow prompts/list cannot
queue behind a tool call — servers commonly answer one request at a time, and
that isolation is still worth having. But it handed the same timeout context
to both jobs `start` splits: the one that bounds the handshake, and the one
that owns the child. Five seconds later the child was killed regardless of
what it was doing.

A server that imports a data stack needs longer than that to reach its own
entry point, so it was killed mid-import, every time, having never run. What
the host saw was `read: EOF` from a pipe with nobody left to write it; what
the machine saw was a second and third process per server, each opening the
same database before dying. Neither looked like a timeout, which is why it
read as a server crash or a table deadlock.

The handshake keeps a budget — the server's own configured startup timeout
rather than a hardcoded five seconds — and ctx owns the child, as `start`
documents. The regression test uses a server slower to come up than the old
bound and fails against the old code with exactly the reported symptom.
2026-08-17 21:59:04 -07:00
yhh 45c181ce09 test(studio): the frontend gets a place to keep what was proved
Three times now the interesting logic on this side was verified by bundling it
out to a scratch script, reading the output, and deleting the script: the
reducer's rule for an interrupted turn, the port's recovery path, and the
stream-identity check found by running the real thing. None of it survived, so
none of it defends anything.

vitest, because vite is already the build — the config is the one it reads
anyway, and no environment is needed: both suites are pure logic over globals
they stub themselves, so there is no jsdom to carry.

What the fifteen cases cover is what a type error cannot reach. That a card
which never left its streaming placeholder is not the same as one that ran and
failed, since only one of them can have left the tree dirty. That a replay
lands in order, that frames arriving mid-replay wait for it, and that a result
therefore cannot overtake the dispatch it answers. That a gap nothing can close
is reported rather than rendered as a quiet turn. That numbering which goes
backwards is a restarted server and not a hole.

CI runs it between typecheck and build, so a break shows up before the slower
of the two.
2026-08-17 21:54:04 -07:00
yhh 848d2a027b fix(studio): a restarted server is a new stream, not a silent one
Found by running the real thing: numbering starts over when the server process
restarts, and the client was still comparing against the watermark it held from
the process before. Every number after that is lower than what it has seen, so
the gap check can never fire again — the view stops noticing losses entirely,
and nothing says so.

Numbering that goes backwards is now read as what it is: a different stream.
Inferred rather than carried as a stream id, because a resumed client is never
sent a lower number than it holds — replay counts up from where it left off —
so nothing else produces this. The client resets to the new numbering and asks
the pane to rebuild, which is the same answer it already gives to a gap it
cannot close.

The desktop shell never hit this: its server is in-process and restarts with
the page. A browser pointed at a long-lived `reasonix serve` is where it shows.
2026-08-17 21:49:19 -07:00
yhh 95bcab14be feat(serve): the event stream can be resumed, on either transport
A dropped frame used to be a lost frame. The buffer was a channel with a
`default:` arm, so whether a frame survived depended on how full that channel
happened to be when it arrived — a property with nothing to do with whether
anything downstream could recover from losing it. Sixty-four frames of streamed
text were enough to cost a client the approval_request behind them, and no
later frame would ever reveal the hole.

Frames a client cannot afford to miss are numbered now, and the number is the
whole recovery model. SSE already specifies this: `id:` on a frame, the browser
replaying Last-Event-ID on reconnect, and no id at all on a frame that should
not move the resume point. Streaming deltas get none, which is exactly right —
the text after one supersedes it, and numbering them would have a reconnect
asking to be sent output it has already displayed. The desktop shell's bus has
no connection to rebuild, so it asks over /events/replay instead; both arrive
at the same log and the same numbers. A gap the log can no longer close is
stated, and the client rebuilds from /history rather than rendering a hole that
looks like a quiet turn.

The bounds are on different axes because the two classes of frame want opposite
things. A numbered frame has to arrive, so it is bounded by memory in bytes —
frames here span three orders of magnitude and a frame count would bound
nothing anyone cares about. A streaming delta has to arrive *now* or not at
all, so its bound is the live window itself: a queue standing a whole channel
deep means the client is out of step, and a delta behind that backlog would
arrive describing text already on screen. That replaces the two hand-tuned
thresholds this started with, and the watermark on the keepalive closes the
last case — a frame lost at the end of a turn, which nothing later restates.

Verified against a real DeepSeek session, not only in tests: 165 frames, 13
numbered; reconnecting at Last-Event-ID 5 returned exactly 6–15 including the
pending approval_request, and /events/replay returned the same range for the
transport that cannot reconnect. Race detector clean across the package.
2026-08-17 21:17:47 -07:00
yhh 1d027b0709 fix(agent): a check the host cannot read is reported, not charged to the turn
Measured against the real model: a bash project whose check is ./check.sh had
its bug fixed, ran the check, watched it pass — and the run exited 1. The
classifier cannot tell a project's runner from a deploy, so it read no check,
and the turn was failed for the host's own blind spot. Anyone driving `-p` from
CI got a failure for work that was done.

Nor did the failure buy anything. The gate reads the writer from this turn, so
the next one — having written nothing — passes with no evidence at all. It
could not collect later what it refused to accept now; it only cost a turn and
a false red.

So the gap now says which kind it is. Nothing ran at all is still the turn's
own miss and still fails. A command that ran and could not be read is an
advisory: it names what went unread and how to declare it, and lets the turn
end. Delivery is untouched, and its stricter static answer still stands.

Print mode dropped every warning, having nowhere but stdout to put one, so the
advisory would have been invisible where it matters most. Warnings now go to
stderr, where the run already reports itself — stdout still carries the answer
alone, and no pipeline changes. That also restores planner fallbacks and folded
user turns to view.

Verified end to end against deepseek-v4-flash: unreadable check → exit 0 with
the warning on stderr and the fix applied; wrote a file and ran nothing → still
exit 1.
2026-08-17 21:12:36 -07:00
yhh 92e5d71c9a fix(serve): a prompt survives the backlog in front of it
Sixty-four frames of streamed text were enough to cost a subscriber the
approval_request behind them, and the turn_done behind that. The buffer was a
plain 64-slot channel with a `default:` arm, so what a frame's survival
depended on was how full that channel happened to be when it arrived — a
property with nothing to do with whether anything downstream could recover
from losing it. A frontend that misses an approval waits on a dialog that
never opens; one that misses a turn boundary stays running forever. Both are
states nothing later restates.

The frontend event sink already knows this — it refuses to let an extension
drop ApprovalRequest/TurnDone-class events for exactly this reason — but the
broadcaster underneath it dropped them on a buffer check.

So the kind decides now, not the timing. Streaming deltas stay droppable: the
text after one supersedes it and /history has the whole thing. Everything else
queues. Frames land on a per-subscriber queue that a single pump goroutine
drains into the channel, which keeps Emit non-blocking — the property the
original design was protecting, and the one worth keeping — while making the
choice of what to lose deliberate. That pump is also the only goroutine that
sends on the channel or closes it, so a client disconnecting mid-send cannot
race a send on a closed channel.

A hard cap bounds the queue for the case where even the frames that matter are
outrunning the client. It sheds oldest-first and says so in the log once: the
newest frames are the ones carrying the state that would let the frontend stop
waiting, and until now every drop here was silent.

Tests cover the three properties: prompts survive a backlog, the survivors keep
provider order, and Emit still does not block on a client that has stopped
reading. Race detector clean across the package.
2026-08-17 20:34:56 -07:00
yhh 18ec97d2e6 fix(agent): the unrecognised-check ask points at the citation, not at config
The branch added a turn earlier told the model to have someone declare the
project's checks in project memory. That is a real mechanism but the wrong
answer to this question: it is project-level setup, it needs the user, and it
does nothing for the turn already stuck. Naming the check is something the
model can do right now, and 3c3e0938f already built the path — complete_step
carries a verification citation, and the ledger corroborates that the named
command ran after the write and exited zero.

So the ask offers that instead, quoting the command that actually ran. What
the model may decide stays one thing: which command was the check. Declaring
checks stays documented as the stronger, optional form, no longer as the way
out of a turn that cannot end.
2026-08-17 20:20:11 -07:00
yhh db7e0d67bb fix(agent): the readiness gate names the way out it was hiding
The command classifier knows a fixed set of checks and deliberately cannot
tell `python deploy.py` from `python run_tests.py`, so a project driven by
its own scripts satisfies nothing it looks for. The gate then asked for "a
relevant verification command" — to a model that had just run one, and had
no way to learn that naming the project's checks is what settles it. One
report spent 357 steps against that wall.

Widening the classifier is the wrong repair: reading a deploy as a check is
exactly what it fails closed to avoid. Instead the ask now tells its two
situations apart. A recognised check that failed already had its own
wording; a command that ran unrecognised now quotes what ran and names the
section that would make it count. That section name is exported from the
parser rather than spelled out a second time.

The declaration syntax had never been documented either, which is the other
half of why nobody found it.
2026-08-17 20:03:30 -07:00
yhh 0eca1db39d fix(studio): small type keeps its weight, its contrast and its size
Three defaults, three separate reasons the window was tiring to read.

-webkit-font-smoothing: antialiased was written for macOS, where it helps.
On WebView2 the same line turns off ClearType's subpixel rendering, and
Chinese glyphs lose the fill their strokes rely on: at 13px the page goes
soft. It is scoped to the platform it was meant for.

The soft contrast tier — the default one — put --ghost at 2.7:1 against the
card it is drawn on. Its own comment claims both ends are inside four times,
and they were, measured against --page. But ghost almost only appears inside
cards, and against --raised it sat under the 3:1 floor. Both schemes clear it
now.

13.5px is a size chosen for Latin. Chinese fits more strokes into the same em
and blurs first, so the reading tier starts a step higher when the interface
is in Chinese. The appearance panel's steps move with it, and they live next
to the default now instead of repeating it.
2026-08-17 20:02:29 -07:00
yhh af39b91c45 fix(studio): a call that never started stops claiming it ran
The kernel emits a placeholder dispatch the moment a tool call begins, so the
card is on screen while its arguments are still streaming. Interrupt a turn
inside that window — the model was writing twenty file bodies and the user
stopped waiting — and every placeholder is still running when turn_done lands.
The seal told each of them the same thing: that the step ended without
reporting a result. Twenty identical sentences, every one of them the opposite
of what happened. Nothing was dispatched, nothing ran, no file moved, and the
reason the turn actually ended scrolled off the top.

They fold into one line now, and it is the line worth reading: how many calls
never started, and that the workspace is untouched. A call that was really
dispatched and really did die without a result keeps the old wording. The
difference is the whole point — only one of the two can have left the tree
dirty.

The wire omits `partial: false`, so the full dispatch that follows carried
nothing to clear the placeholder's flag; spreading it left every executed call
marked as one that never started. Folding through a merge that normalises the
flag is what makes the two distinguishable at all.
2026-08-17 20:01:18 -07:00
yhh e999a465ad fix(plugin): MCP calls on one server stop queueing behind each other
Every transport held a lock across the whole round trip, so a second call
on the same server waited out the first — and the wait was on a plain
mutex, which takes no context. A slow tool therefore did more than delay
its neighbour: it made the neighbour unabandonable, because cancelling the
turn could not return a goroutine parked on a lock. That is the shape of a
host that sits at idle CPU going nowhere while a long screening run works.

Nothing about it was load-bearing. stdio and SSE already demux replies to
the waiting call by id, writes are already serialised on their own mutex,
and the shared state has its own lock — the round-trip lock only undid what
id demuxing is for. HTTP's lock does guard state, so it now covers the
state and not the network.

MCP's cancellation notification goes out when a call is abandoned. Without
it the server keeps working on a result nobody will read, holding whatever
that work locked, and the next call arrives to contend with its own
predecessor. initialize is exempt: the spec forbids cancelling it.
2026-08-17 19:46:36 -07:00
yhh 59180ad96d test(boot): the read-back promise holds through the real assembly
Every component test passed while the loop was running: each spill was
individually cheap and provably smaller than what it replaced, and nothing
asserted the content ever reached the model. This drives Build end to end —
read a large file, follow the pointer it returns — and checks what actually
arrives at the provider boundary, which is where the promise is kept or lost.
2026-08-17 19:13:35 -07:00
yhh 1b43f71f65 fix(store): a session delete takes its directory sidecars too
Every front end hand-rolled its own loop over SessionSidecarFiles, which by
construction lists only regular files, so the one sidecar that is a directory
was skipped by all of them — spilled output outlived the conversations it
belonged to, unreferenced and unreachable. Collapsing the three loops onto a
store helper removes the class of bug rather than this instance: hand-rolling
is what let each caller drop something different.

Directories earlier deletes already stranded are aged out by the reconciler
the front ends run at startup.
2026-08-17 19:13:30 -07:00
yhh a0d71b754f fix(agent): a spilled result comes back when the model reaches for it
read_file is how a pointer gets redeemed, so sending its output through the
same gate handed back a pointer to a further file instead of the content —
and the line numbers it prefixes grow the body every round, so the loop
diverged rather than converged. One session spent 1936 reads never seeing a
byte of what it asked for. A fetch aimed at the spill directory now stays in
context; anything else still spills.

The gate also priced a read-back at one more pointer while its own comment
said a whole extra turn. Charging what the turn costs puts the bar where a
single fetch stops recovering a result whole: under it, spilling buys a
pointer and a turn to arrive exactly where leaving it would have. Everyday
bash and read_file output stops leaving context at 1KB.

With neither transcript nor archive there was nowhere to spill and the only
remaining bound was truncation, which discards 99.7% of a 5MB result. Falling
back to scratch keeps that case lossless too.
2026-08-17 19:13:24 -07:00
esengine 84c115d6b3 fix(ci): the retired lines take their guards with them
Two checks outlived what they were checking. The Scoop launch test builds
cmd/reasonix-launcher to prove a desktop install starts through it, and that
command went with the desktop line. apply_darwin.go was left unformatted by the
same change that threaded the bundle identifier through the handoff; the desktop
module's gofmt gate is what catches it, and golangci-lint alone does not.
2026-08-17 17:44:58 -07:00
esengine c8b3664e7f chore(release): retire every line but Studio
Studio is the only shell this module builds, and it publishes through
release-studio.yml. What is removed here is the machinery of the lines that no
longer ship from this branch: the desktop build and packaging scripts, the
desktop release workflow, the stable control plane that published CLI, npm and
desktop as one approved set, and the SignPath contract that existed to bind
Windows signing to that control plane's one protected origin.

The two commands go with them rather than being kept as orphans: the launcher
survives a swap that no longer happens, and the legacy migrator moves installs
of a shell that is gone. internal/desktoplauncher had no other caller, and
ci.yml no longer names it.

Nothing here touches what still ships. cmd/reasonix and npm/ stay: the CLI is a
product, not a release line, and removing a publisher is not removing what it
published. The guards that outlived their subject go too — release-workflows.
test.sh asserted properties of workflows this commit deletes.
2026-08-17 17:26:36 -07:00
esengine 5650358289 feat(studio): macOS updates itself, now that its releases are notarized
The bundle swap was already written and Studio's main already dispatched the
detached child that performs it. Two things kept it shut: a package-level
MacSelfUpdate that nothing set once the desktop host was retired, and a bundle
identifier hardcoded to that host's — so the swap would have refused the very
bundle it was meant to install.

Both move into the line. MacLine carries the identifier the swap checks a
downloaded bundle against, and the flag that says whether this line's releases
are signed and notarized at all; a line that ships unsigned keeps the download
page rather than writing a bundle Gatekeeper will refuse. The package-level
variable goes with them, so nothing about a product line is left as process
state one host has to remember to set.

The detached child verifies too, and it is re-executed rather than told what it
is, so the identifier travels as an argument. Reading it back off the bundle
would ask a bundle that fails verification to say what it is.

The manifest resolves the universal archive for both macOS keys — the slices
ride in one file, so a second identical download would be a release asset with
nothing to distinguish it. The disk image stays a human download: handing the
swap an image gives it something to mount, not an app to install.
2026-08-17 17:12:48 -07:00
esengine abaa82c12c feat(studio): Windows updates by running the next installer
Studio's Windows install is one executable beside the SPA tree it renders, and
the shared versioned path exists to swap five of them under a launcher while
keeping a directory to roll back to. Adopting it here would have meant a
launcher Studio has no use for, an update helper to outlive the process, and a
current.json layout — machinery for a problem this shell does not have.

So Windows takes the same shape Linux already does: borrow the installer instead
of reimplementing version management. The manifest resolves the installer as the
platform asset, the download path verifies it exactly as before, and the running
build starts it and exits. Going back to an older version runs an older
installer, which is why nothing here asks the direction.

The handoff has one real hazard: for a moment the old Studio still holds its own
executable open and Windows refuses to replace it. The installer waits for that
handle to clear rather than failing, and gives up after twenty seconds so a
genuinely stuck process reports an error instead of hanging.

Two things the manifest must never do, now pinned by test: resolve an archive as
a platform asset — the updater cannot place one — and resolve the .deb as one
too, because writing its files behind apt leaves dpkg and the filesystem
disagreeing.

installlayout gains ActiveMemberPath so current.json can locate a file the
caller names. Which files a version directory carries is a product line's
business, not that package's.
2026-08-17 16:55:12 -07:00
esengine 7505e07f74 feat(studio): Windows gets an installer, and downloads offers only what installs
A zip is not an install: nothing registers it, nothing puts it in Start, and
uninstalling means remembering where it was unpacked. Studio now ships an NSIS
installer beside the portable archive, carrying the one binary and the SPA tree
it draws its UI from — the tree has to stay a sibling of the executable, because
that is where frontendAssets looks.

The desktop line's installer could not be borrowed. Its NSIS sources are Wails
build output that the repository ignores, and they describe a five-executable
payload with a versioned layout Studio does not have. The WebView2 bootstrapper
is fetched from Microsoft's evergreen URL at build time for the same reason, and
a fetch that fails drops the bundled runtime rather than failing the release.

Windows artifacts stay unsigned: the production SignPath policy admits exactly
one origin, and it is not this branch.

studio-manifest's downloads now lists what installs itself — the disk image, the
installer, the package. The archives remain release assets for whoever wants
them, but offering one beside an installer only asks the reader to choose blind.
2026-08-17 08:52:31 -07:00
esengine 9849f6b63b fix(studio): a macOS build says which systems it runs on, and proves who built it
The bundle claimed LSMinimumSystemVersion 10.13 while its binary was stamped
minos 26.0, because nothing set a deployment target and clang defaults to the
builder's own OS. Finder reads the claim and opens it; dyld reads the stamp and
refuses. A release built on a current machine therefore ran nowhere but on
another current machine.

Both slices now carry a measured floor rather than an assumed one: 10.13 is as
far back as this source links, and Apple Silicon did not exist before 11.0.
Info.plist takes the lower of the two, because that is who can open the bundle.
The target has to reach the Objective-C compiler as well — Wails' own objects
otherwise keep the builder's version and warn at every link.

The Intel slice was never a toolchain limitation. It failed because clang was
not told -arch x86_64 and produced arm64 objects, so a universal build is simply
available now.

Signing uses the Developer ID in the keychain when there is one, with a hardened
runtime and the JIT entitlement notarization's hardened runtime would otherwise
deny WebKit — a bundle signed without it renders a blank window. Notarization
follows when credentials are present, and the disk image is signed and stapled
separately: a ticket in the app does not travel on the image, and the image is
what the user downloads. With neither certificate nor credentials the build
still produces a runnable ad-hoc bundle.
2026-08-17 08:52:19 -07:00
esengine a7a0d11816 fix(eventwire): the wire contract follows the frontend that survived
Retiring the old shell took desktop/frontend/src/lib/types.ts with it, and the
guard that keeps Go's event payload and the frontend's view of it in step read
exactly that file. The contract did not disappear with the shell — frontend-next
declares the same thing in port/wire.ts, whose first line already says it mirrors
this package — so the guard reads that instead.

Pointing it there is what surfaced the drift. The new frontend never mirrored
memoryCitations, checkpointTurn or the Context* token fields, so nothing on that
side could name them; outcome was a bare string where only two values exist; and
CacheDiagnostics marked as optional five fields the wire sends unconditionally,
which asks every reader to handle an absence the producer never emits.

The assertions themselves drop the Wire prefix they inherited: the shapes are
the same, and holding a new frontend to the old one's type names would fail on
spelling rather than on coverage.
2026-08-17 08:52:06 -07:00
esengine 0848e4ef4f fix(e2ebench): an honest run is decided per run, not by subtracting overlapping totals 2026-08-17 08:18:00 -07:00
esengine 908bc3ec0c refactor(desktop): Studio is the only shell the module builds
The Wails-bound app and its React frontend are gone. Studio reaches the
kernel through internal/serve's HTTP routes instead of bound Go methods,
so the shell that replaces ~320k lines is next/ plus the SPA it serves.

Three things the deleted tree owned move rather than disappear: the
WebView2 fork's patch guard, the macOS dock-icon safe-area assertion, and
the Windows ICO the resource stamper needs. The fork itself stays — its
mixed-DPI and proxy-isolation patches are Studio's too.

CI keeps one desktop Go gate per platform, now built against WebKitGTK
4.1 the way studio-build.sh links; the frontend legs, the Wails CLI pin,
and the 4.0 leg went with the app.

The desktop release line (release-desktop.yml and the desktop channel in
release-stable.yml) is untouched here: it still serves installs that are
already out there, and unwiring it is release-pipeline surgery of its own.
2026-08-17 08:13:37 -07:00
esengine 9747503b10 fix(desktop): the linux install probe drops the imports its helpers took with them 2026-08-17 08:00:02 -07:00
esengine bdedc05d03 feat(studio): Linux updates itself through its own package
The privileged install was written into the desktop host: the pkexec call, the
helper's exit-code and phase protocol, and the dpkg ownership probe all named
reasonix-desktop and /usr/lib/reasonix directly. Studio needed the same
mechanism against different names, and copying it into a second host would have
made the security-relevant half of an update path exist twice.

It moves to internal/update, keyed by the line: InstallDeb runs the helper this
line declares, OwnsInstalledPath asks dpkg about the package this line declares,
and the classification of what the helper reported is shared. The desktop keeps
its own error values as aliases, so errorClass buckets and every errors.Is on
them read exactly as before.

Studio's GoToVersion now takes the package channel when dpkg owns the running
binary and the manifest publishes one, rebuilding the updater with the deb kind
so assetIn resolves the package instead of the tarball. A dismissed Polkit
prompt returns to "downloaded" with the verified cache intact rather than
reporting an error the user caused on purpose.

The tarball path is untouched, and platforms stays empty: it stages single files
and Studio's release is a binary plus the SPA tree.
2026-08-17 07:58:01 -07:00
esengine 54f91d4ce9 feat(studio): Linux gets a package dpkg can install and upgrade
Studio shipped a tarball, which is a download rather than an install: nothing
registers it, nothing upgrades it, and the versioned layout the desktop line
self-updates through stages single files, while Studio's release is a binary
plus the SPA tree it draws its UI from. dpkg carries a tree, so the .deb is the
form Studio can actually be installed and upgraded in.

Everything it names is its own. The desktop package owns /usr/lib/reasonix and
io.reasonix.desktop.update; Studio takes /usr/lib/reasonix-studio and
io.reasonix.studio.update, so the two install side by side without dpkg seeing
either overwrite the other's helper. /usr/bin/reasonix-studio is a symlink, so
os.Executable resolves into the library directory and frontendAssets finds the
SPA beside the real binary rather than beside the link.

The privileged helper is the desktop's source built with Studio's package name,
now a var rather than a const so the build can set it. It is still fixed at
build time — no runtime input can point it at another package — and Polkit
still authorizes one absolute path with one argument.

studio-manifest publishes the .deb under native_packages. platforms stays empty:
that is the tarball apply path, and it cannot publish a tree yet.
2026-08-17 07:49:38 -07:00
esengine eb2a6427f8 chore(lint): the catalog guard's comment comes back under budget 2026-08-17 07:38:34 -07:00
esengine e2c0270b92 fix(desktop): submitting an extension form asks the port, not a local assertion
The desktop kept its own extensionFormSubmitter interface and asserted the
controller against it, because when it was written SubmitExtensionForm lived
only on the concrete controller. The comment said the alias could go once the
port grew the method. The port has: control.SessionAPI declares it, so every
controller reaching this code already satisfies the assertion.

Left in place it was worse than redundant. A test double that embeds a nil
control.SessionAPI to stand for "port only, no submitter" now satisfies the
assertion through the embedded interface and segfaults in the generated
forwarding method — the panic the test was written to prove could not happen.
The guard goes, the call goes straight to the port, and the case the double
stood for no longer exists.
2026-08-17 07:37:27 -07:00
esengine 62dac89c01 refactor(update): a product line declares its own names, the apply path names none
The shared updater knew the desktop line by heart: the members a release archive
carries, the binaries they install as, and the launchers that survive a swap
were all spelled out inside internal/update. Studio ships one binary where the
desktop ships three, installs under a different name and answers to a different
dpkg package, so every one of those constants was a wrong answer for it — and
two lines that both wrote /usr/lib/reasonix/reasonix-update-helper would have
had dpkg watch one package overwrite the other's helper.

update.Line is that identity, declared by each host and passed in. Extraction
checks the members the line names, the Linux activation stages exactly those,
and the launcher search walks the line's own candidates. A member with no
installed name is one the archive must carry but this version does not persist,
which is how the desktop keeps shipping reasonix-guard for updaters that still
read these archives.

Behavior for the desktop is unchanged: desktopLine() restates what the package
had hardcoded. Nothing here turns Studio's self-update on — that needs the
manifest to list assets, which lands with the artifacts themselves.

Also brings one test comment back under its budget so the standards gate passes.
2026-08-17 07:37:11 -07:00
yhh 2004f2c486 test(studio): the catalog guard reads the directory instead of ParseDir
staticcheck flags parser.ParseDir as deprecated since Go 1.25, and it failed lint
plus all four desktop jobs — the one finding behind five red boxes. The reason it
was deprecated is that it ignores build tags when grouping files, which is what
this guard wants: an Options behind a tag reads the wrong catalog too. Reading the
directory and parsing each file says that outright instead of relying on a rule
that was retired for being imprecise. Probe-tested: still reports the file and
line of an Options that omits IndexURL.
2026-08-17 07:33:59 -07:00
yhh c20f43d050 chore(lint): the shared-catalog helper's doc comes back under budget
The one finding left outside the baseline after the standards sweep.
2026-08-17 07:10:58 -07:00
esengine 2cfd1e89f1 chore(lint): clear what the standards gates report on the current tree
resolveTriageProvider had no callers and only wrapped resolveTriage to drop two
of its results; its reason for existing moves into the doc of the function that
survives. A membership loop becomes slices.Contains, a Split becomes SplitSeq,
and one test comment comes back under its budget.
2026-08-17 07:05:41 -07:00
esengine 282e09f876 fix(bench): the compaction bench counts tokens the way the kernel counts them
The bench carried a private estimator described as mirroring the kernel's own.
It did not: for a string with no wide characters, RuneCountInString(s) is len(s)
and is therefore always greater than (len(s)+3)/4, so every ASCII transcript
came back at four times its cost. The scripted provider sizes its window refusal
with that number, so a fold whose real input was ~19K tokens was rejected as
76K against a 64K window, and each generation after it inherited a larger
canonical to fail on.

internal/tokencount already answers this question once, splitting by script
because neither rule survives being applied to the other half. The bench now
asks it instead. Six generations summarize, fold input settles at ~18.9K and the
projection converges rather than the run wedging at generation two.
2026-08-17 07:05:41 -07:00
esengine 8181853272 fix(workspaceid): one repository is one key, whichever path spelling reaches it
Describe canonicalized the workspace root but not the gitdir a linked worktree's
.git file points at, so the two sides could disagree on a path that resolves to
the same directory: on macOS a tree under /var/... and its gitdir spelled
/private/var/... produced two digests for one repository. A switch turned off in
the main tree then failed to apply in the worktree, which is the one thing this
package exists to guarantee.

Both sides now canonicalize. The stability assertion that was meant to guard
this compared Key(a) against itself and could never fire; it now compares two
spellings of one folder.
2026-08-17 07:05:41 -07:00
esengine b122a5f1b3 fix(desktop): the linux updater finishes the move to one artifact-kind name
updater_install.go retired the local artifactKindTarball/artifactKindDeb
constants for update.KindTarball/update.KindDeb, but the two //go:build linux
files were not carried over: updater_install_linux.go still named the deleted
constants, and updater_deb_linux.go referenced update.Cached without importing
the package. Neither file compiles on macOS, so the break only ever surfaced in
CI, where it failed every desktop job on all three platforms.

One name for the artifact kind, defined where the cache already defines it.
2026-08-17 07:05:41 -07:00
yhh 7937f785e3 test(e2ebench): ask python3 for a version, not for its presence
The corpus graders already skipped a host without python3, but Windows resolves
a Microsoft Store stub of that name on PATH: it exits 0 and prints nothing, so
LookPath succeeded and the graders ran against nothing, reporting the corpus as
broken. Findable is not usable — the probe now wants a version string.
2026-08-17 06:46:42 -07:00
yhh c81a4294e8 test: release the shared catalogs before the home they live in is deleted
Four serve tests failed their own TempDir cleanup on Windows, which refuses to
unlink an open file. The handles are the usage and history catalogs, and no Close
reaches them by design: they are process-wide, cached per home so one process
keeps one handle. Both already export a closer; the tests just never called it.

installsource asserted an unpacked hook keeps mode 0755. Windows carries no
executable bit — os.Chmod only toggles read-only — so the claim is checkable only
where the bit means something.
2026-08-17 06:33:10 -07:00
yhh 448ab40b0c test: two more host assumptions, and a timeout that was never enough
completion's scratch filter compared a literal "/tmp/" prefix against a path the
ledger had already normalized, so on Windows it matched nothing and a scratch
write counted as a change. The real filter uses filepath.IsAbs and a folding
compare, so this was the test's assumption, not the product's.

acp's RPC helper allowed one call two seconds. session/new runs a full boot,
measured at 5.2s cold, so the package failed whenever the tree was tested at
once and passed alone on a warm cache. The budget now follows the test binary's
own deadline: a hang detector, not a latency assertion.
2026-08-17 06:21:14 -07:00
yhh 997071fac1 test: two tests assumed a host, not a contract
Both only ever ran where the assumption happened to hold, and pointing CI at
this branch is what surfaced them.

SaveShellSettings pins by proving the interpreter runs, so "/bin/sh" made the
test a POSIX-only one. It now pins whatever this host offers, using the option's
own Prefer field rather than a guess at the name-to-prefer mapping.

The MCP test isolates the config home, which leaves provider resolution on the
built-in presets — whose DeepSeek entries are kind "anthropic", never linked
into the cli test binary. pinProviderOffline keeps the setup about MCP.
2026-08-17 06:02:08 -07:00
yhh 5194007fb8 refactor(plugin): status broadcast and deferred spawn get their own files
plugin.go was 60 lines past the ceiling it is already 1200 lines over, and the
two groups nearest the surface are each a separate concern: where a connection
change is announced, and the bookkeeping that lets a background connect be
cancelled and its result recognised as stale.

Pure moves within the package, so behaviour is unchanged. repolint is now clean
repo-wide — this was the last finding outside the baseline.
2026-08-17 05:55:06 -07:00
yhh bd88008520 chore(deps): close the seven vulnerabilities govulncheck reports
Six are standard library, fixed in go1.26.6: net/url, crypto/tls, net/http,
encoding/xml, encoding/asn1, and x/net/idna's Punycode label check. The seventh
is golang.org/x/image, fixed in v0.45.0 — both modules use it directly, for webp
decoding and for the image compressor.

CI pins no Go version of its own, reading go.mod through go-version-file, so the
toolchain line is the whole change there. govulncheck locally: 7 affected before,
0 after.
2026-08-17 05:50:37 -07:00
yhh 6055ebcd37 test(cli): assert the image note arrived, not how it is worded
The assertion wanted "OCR/image/vision tool"; the note has said "OCR/image
tool" since the wording was simplified, and control's own tests moved with it
while this copy did not. A test that pins a sentence fails when the sentence
improves — the same reason judgements here read structure instead of phrasing.

ImageRoutingTag is exported so a frontend can assert the note is present and
leave what it says to the package that owns it.
2026-08-17 05:45:49 -07:00
yhh 85870672ae fix(evidence): every field that names a file shares one path identity
Record folded only Paths, leaving Showed and Created in whatever spelling the
caller had. The matchers then compared a folded path against an unfolded one, so
on Windows an output that carried a change never counted as review of it, and
CreatedInTurn — asked with the folded Paths — could not find a file it had just
recorded as created. That is the difference between a cleanup and a deletion.

CreatedInTurn now asks in the same identity it stores, which is required rather
than optional: folding on write alone would have broken it the other way.
2026-08-17 05:45:48 -07:00
yhh 189a990ae8 fix(evidence): one file is one watched path, whichever way it is spelled
snapshotPaths keyed its map by the raw string while its inputs disagreed about
spelling: ledger.TouchedPaths already folds case on Windows, a directory read
does not. The same file landed under two keys, and Receipt.Paths then named it
twice — a Windows mutation counted as two changes.

The ledger already owned the answer in normalizePath, unexported, so agent grew
a second idea of path identity. Exporting it leaves one definition: the snapshot
keys by it and keeps the original spelling for anything read by a human.

TestObservationNamesWhatAnOpaqueCommandTouched was failing on windows-latest for
this reason.
2026-08-17 05:30:08 -07:00
yhh 71bf59c837 perf(agent): a spill costs the same whatever shape the output has
The pointer carried 20 head lines, so its price tracked line width: the bar it
implied ran 2214 bytes at 40-char lines and 8643 at 200. Wider output paid more
to leave context, which inverts what spilling is for.

The preview is now bounded by the instructions it rides on and cut at a line
boundary, so cost is the instructions plus at most the same again. Measured bars
across 20..200-char lines: 1005..1134 bytes. The head-line constant is gone —
the file's own comment already argued a head sketch is a guess by the wrong
party, while the code kept 20 lines of one.

Real run, one read_file of 24.6KB on deepseek-v4-flash: 25800 bytes of context
originally, 2175 with the line-counted preview, 797 now.
2026-08-17 05:30:08 -07:00
yhh 995596db19 perf(agent): a spill decides by what it costs, not by a threshold
The budget was window-relative (window x 3 x 0.10, clamped to 32KB), so on a 1M
window it computed 300KB and always landed on the clamp. Measured against what
spilling actually costs — the pointer, 2875 bytes for 120-char lines — the bar
sat 11x above it, and everything from 3KB to 32KB paid full price for output the
model was already told to read back from disk.

The pointer is a pure function of the body, so build it and compare: no ratio, no
bytes-per-token estimate, no floor. spillPays requires the saving to cover the
cost rather than merely break even, because a pointer read back costs a turn.
This also removes the trap in the low direction — under a 1KB threshold the
pointer is larger than the body it replaces.

Real A/B on one read_file of a 24.6KB file, deepseek-v4-flash: 25800 bytes in
context before, 2175 after. Derived bars: 2214 bytes at 40-char lines, 5445 at
120 — a constant cannot track that, and cannot follow spilledOutputHeadLines
either.
2026-08-17 05:13:04 -07:00
yhh 59638b7410 fix(studio): clear the standards debt the first CI run found
These files never faced a gate: ci.yml ran on main-v2, where none of the studio
surface exists. The desktop test still built agent.RecoveryPauseError, a type the
kernel removed — topicActivityStatusFromEvent only reads e.Outcome, so an
ordinary error proves more: the outcome has to win over it.

build() carried the legacy migration inline at 1814 lines; it moves next to
continuesGeneration, which also keeps boot.go from growing. plugin.go's overage
was the one comment block studio added.
2026-08-17 04:55:41 -07:00
yhh 6735ceada7 fix(studio): the shell builds without the wails tag, so tools can see it
window_fit.go is desktop-tagged but main.go calls fitWindow unconditionally, so
the package built only with that tag. ci.yml's desktop job builds the module
without it, which is also what vet, lint and every editor do. Same no-op shape
as applyDockIcon in dock_icon_other.go.
2026-08-17 04:32:28 -07:00
yhh 7f0e065acf fix(studio): the linux build passes its tags, not an import path
Rebuilding the argument array from a slice left the old -tags value in place as
a positional argument, so go read "desktop production" as an import path. Only
the linux leg took that branch, which is why two of three platforms passed.

The manifest command's package comment was also over its budget: the -only run
that missed it was given an untracked directory, not the file.
2026-08-17 04:28:38 -07:00
yhh 4a8b823391 ci(studio): the parallel line gets its own gates and its own release
ci.yml ran on main-v2 only, so the kernel changes this branch carries had never
been gated. Studio's own surface exists nowhere else, so it gets a separate
workflow rather than an if-guard inside a file main-v2 also reads.

release-studio.yml publishes studio-v* tags: its own environment, its own
catalog prefix, always a prerelease. latest.json lists downloads and no
platforms because the shared apply path names the desktop line's members, and
a manifest must not claim an install that would fail after the download.
2026-08-17 04:09:39 -07:00
yhh 0507da6210 fix(studio): the version panel stops reading the desktop line's catalog
Both update.Options here left IndexURL empty, which falls back to the desktop
catalog: a Studio release would have listed desktop versions and offered to
install reasonix-desktop over Studio. An AST guard fails a new Options in this
package that omits the field, because the failure mode is a new call site.

GoToVersion now asks the manifest whether this platform has an installable
asset instead of downloading one it cannot apply.
2026-08-17 04:09:28 -07:00
yhh 3c3e0938f1 feat(evidence): a completion may name the check the table could not
The verifier table will never enumerate every project's own runner, and
`./scripts/ci.sh` says nothing about itself — three measured attempts at
having a model read the command string ended in the harness that disproved
them. The information the table lacks is not in the command; it is in the
turn, which read or wrote that script.

complete_step already carries it. Its evidence takes kind=verification with a
command, and the tool refuses a citation whose command has no matching
successful receipt — the model names which command was the check, and the host
proves it ran. Only the readiness gate never asked: it consulted the static
table and the project's declared checks, so a corroborated citation sat in the
ledger while the turn was told to go and verify.

It asks now. What the model may decide stays exactly one thing — which of the
commands it ran was the check. That the command ran, that it exited zero, and
that it did so after the latest write are all still read off receipts, so a
citation cannot manufacture a pass, reach back past a later write, or rescue a
check that failed. Each of those is a test.

Delivery is untouched: there a citation still has to satisfy
IsDeliveryVerificationCommand, and the strictest tier keeps its static answer.

The lease refusal's wording changed with it, so four tests that asserted a
same-process holder is "another Reasonix process" now assert what it is.
2026-08-17 01:00:40 -07:00
yhh ebf93a0322 merge: feat/capability-scope 2026-08-17 00:41:00 -07:00
yhh cc715fac3f merge: feat/verification-triage 2026-08-17 00:41:00 -07:00
yhh 6c40b368a4 merge: fix/studio-turn-truth 2026-08-17 00:40:59 -07:00
yhh 0541cfc92a merge: fix/session-lease-reclaim 2026-08-17 00:40:59 -07:00
yhh 87608ebfc0 measure(evidence): neither direction of the escalation survives, and why
Twelve live runs, 37 labelled commands, both tiers. The reverse direction —
asking only where the table already says yes — looked like the answer: it
withdrew every hollow check (`--collect-only`, `--no-run`, `--list-tests`,
`-count=0`) in run after run. It also refused one honest check per run, and
never the same one twice: golangci-lint run, then cargo test, then pytest -k.

Guarding by shape only moved it. Excluding commands whose work is named
elsewhere (`npm run lint`, `make test`, a script path) removed those failures
and the next run refused `golangci-lint run` instead. Excluding test selectors
removed that shape and the failure moved again.

Asking one command eight times says why. go test ./..., cargo test and
golangci-lint run answer CHECK 8/8; pytest --collect-only answers NOT 8/8;
npm run lint answers CHECK 3/8, because what that script does is not in the
command and the model is right to be unsure. The rest carry a small residual
flip — temperature 0 is not determinism on a hosted MoE model, where expert
routing depends on batch composition — and a dozen commands at a few percent
each is the one refusal per run that kept reappearing.

A gate that refuses an honest check a few percent of the time manufactures the
complaint it was built to fix: a turn that verified, told to verify. So the
escalation stays unwired and the harness stays, runnable, with the numbers
reproducible.

The bugs it found are the part worth keeping: classification billed to
compaction at the turn's price, a one-word lookup sampled at the provider's
default temperature, and a nil sink panicking in the deferred emit.
2026-08-17 00:33:27 -07:00
yhh 1514fc7962 measure(evidence): the verification escalation does not survive its own test
Three runs against deepseek-v4-flash, sixteen labelled commands. It scores
12-14 of 16 where the static table scores 7 — but the wins are not where the
feature was for.

`./scripts/verify.sh` and `bash scripts/ci.sh` are answered NOT in every run.
Those are the motivating case, and the failure is not the prompt's: nothing in
the command string says what the script does, and the prompt correctly tells
the model to refuse when it cannot tell. Naming a runner it does know — tox,
bazel, ctest, make smoke, npm run e2e — flips between runs, and the per-command
cache then freezes whichever answer arrived first for the rest of the session.

So the escalation is unwired. What the measurement is worth keeping for is the
opposite direction: `go test -run NoSuchTest ./...` and `pytest --collect-only`
run nothing, exit zero, and the static table accepts both today, while the
model rejected both in every run. Asking where the table says yes would tighten
the gate instead of loosening it — on two cases, which is not yet a decision.

Three bugs the measurement found on the way, all in the existing classifier
and all fixed here:

- Its tokens were billed to compaction at the turn's own price, so the cost of
  a tier that exists to be cheap was reported as the expensive one. They are
  reported as classifier usage on the triage model now.
- The call passed the turn's temperature, and OptionalTemperature drops a zero,
  so a one-word lookup was sampled at the provider's default. Pinned.
- A nil sink panicked in the deferred usage emit.
2026-08-16 23:34:47 -07:00
yhh 7bd4557018 feat(evidence): let the triage model answer for a check the table cannot name
The verifier table has the read-only table's problem, and the same fix was
already built for that one. A project whose check is `./scripts/verify.sh` or
`make smoke` gets no receipt the gate will accept, so a turn that really did
verify is told to run a verification — and every rewording is the same unknown
command again.

So the table gets shellsafe's escalation rather than more entries. It stays the
fast path and answers everything it knows; only a command it cannot name, and
only while a write is still owed a check, costs one classification. The verdict
is cached per command because the ledger is audited and the same command has to
classify the same way every time.

What the model may decide is deliberately narrow: whether a command runs a
check. Whether that check passed is read off the exit status — through
PIPESTATUS, so a pipe cannot launder a failure — and no answer from a
classifier can reach it. That split is the reason this is safe to ask at all;
the call is also context-free, so it is a router being asked about a string,
not the turn vouching for itself.

Being wrong here was already possible without a model: `go test -run
NoSuchTest ./...` runs nothing, exits zero, and the static table accepts it
today. The prompt names that class of command as NOT a check, so the
escalation is likelier to catch it than the flag table it backs.
2026-08-16 22:43:24 -07:00
yhh 171da4935f fix(studio): a turn that dies says so where the record is kept
turn_done already carries the kernel's reason. The pane put it in the run
strip's status label — one line, ellipsised mid-sentence, replaced by the next
turn, and absent from the transcript, so a stream error read as the session
having simply stopped. It lands in the transcript as a notice now, in the
kernel's own words: classifying failures here would be the client guessing at
something it cannot see.

Dispatched calls were left spinning by the same path. Whether work finished is
the turn's end to decide, so anything still running when it ends is closed and
says on its own card that it never reported back. A backgrounded command is
untouched — it answers the moment it is launched, so its card has already
settled.

A notice at error level also stops coming out amber next to ordinary
cautions; both rails were already in the sheet.

The effort menu listed auto twice: the kernel's level list opens with it and
the composer prepended another.
2026-08-16 22:22:59 -07:00
yhh a1e46dc1d8 fix(session): recover a lease this process stranded, and stop blaming a window
A runtime torn down without releasing its lease leaves an owner entry behind.
Every later bind then fails against it, and the refusal names the holder from
the lease sidecar — which this process wrote, so it names itself. The reader
is told to close the other Reasonix window and goes looking for a window that
was never there.

The desktop shell already recovers its own leftovers; the keeper behind serve,
the CLI and ACP did not, so Studio had no way out but a restart. It gets the
same fallback, guarded the same way: a readable info naming someone else is
respected, and reclaim takes the OS lock, which a live holder still holds — a
sibling binding in this same process keeps its lease, as the test asserts.

Whoever ends up refused, the message no longer calls our own pid another
process.
2026-08-16 22:09:27 -07:00
yhh 11d755f767 fix(studio): name the scope picker something the app does not already use
.picker is the app's own menu class — Menu.tsx renders it and app.css sets it
relative. Declaring it again further down the sheet won every cascade that
followed, so an absolute overlay with a float background landed on every menu
in the app, the composer's model picker included.
2026-08-16 22:00:43 -07:00
yhh 6d0ac9ce37 feat(capability): manage a project's tools from another project
Naming the folder a listing answers for fixed reading it, not reaching it:
the pane still only spoke for whichever tab was in front, so a switch meant
for a project you were not sitting in cost a workspace change and the
conversation that came with it.

The listings and both switches now take a project. Reads come from that
folder's config and the activation store, which is enough to answer what is
declared and what is on; live connection state stays absent, because only
the session pointed at a folder can know whether its servers came up. The
rows that need the running session — retry, remove, install — are withheld
there rather than shown and quietly acting on the wrong folder. A folder the
shell has never opened is refused: the parameter would otherwise read and
edit any directory's config.

The picker offers each project once. Worktrees collapse onto their
repository, a name that collides gains the shortest trailing path that tells
it apart, and the count of local exceptions is on every row — with 32
remembered folders, the ones worth finding are the ones already changed.
2026-08-16 20:46:50 -07:00
yhh 5bed4ff390 feat(capability): one scope model for MCP and skills, keyed by repository
A shell holds several projects at once while the runtime points at one, so
the settings pane silently answered for whichever tab was in front. Two
consequences: the reader could not tell which folder a list belonged to, and
a skill switch — a bare name in the user config — disabled every same-named
skill in every project at once.

Activation becomes one durable store for both capabilities, resolved project
layer before global layer before what the capability itself declares. The
project layer keys on the repository rather than the path, so the nine
worktrees of one repo are one project: a switch flipped in the main tree
still applies in a tree cut from it, instead of vanishing on branch.

Installing stays a global act; how far a server reaches is the separate
question the new local scope answers, without a second storage mechanism.
Every listing now carries the project it answered for, and a row that holds
a local exception says so and offers the way back.

The v1 file is read once and matched by re-deriving the path fingerprint —
the stored digest cannot be reversed into a path, so migration reads rather
than rewrites, and the old file is left untouched for a downgrade.
2026-08-16 20:30:16 -07:00
esengine 3f1ab0b138 fix(sessions): one unsaved turn keeps one recovery branch, not one per incident
A sidebar filled with dozens of rows carrying the same title and the same turn
count: not new conversations, one conversation copied. Recovery lanes are per
live Session, so a resume, a runtime rebuild or a reopened pane each start a
new one and the same unsaved turns land in file after file. The depth cap
bounds the chain, never how many siblings one parent may accumulate.

A conflict now lands on a sibling already holding this exact content, and only
opens a lane when none does. Digests come from branch metadata, so it reads
sidecars rather than transcripts, and a path that turns out to hold something
else is never retried — that metadata records the digest at fork time and a
branch written to since no longer matches it.

The divergence itself is reproduced from outside the process, which is where it
has to come from: writers inside are serialised by snapshotMu and guarded
across processes by the session lease, so memory and disk can only stop being
prefixes of each other when something else rewrote the file — a sync client
resolving a conflict copy, a restored backup, a shared home. Distinct content
still gets its own branch; only identical content converges.
2026-08-16 18:17:05 -07:00
esengine b059a2c15b test(bench): Go tasks, and a guard for seeds that already grade clean
The corpus had 67 Python tasks and no Go one, while the agent's own repository
is Go. A compiled language is a different verification loop — a build before
anything runs, go vet, -race, a module path that must agree with every import —
and its defects have no Python analogue: a data race, a += in a loop that is
quadratic where Python's would be too, map iteration order that makes a test
pass only sometimes. Nine tasks cover that half as ordinary members of the
existing classes.

README already required every task to fail on the pristine seed and pass on a
reference solution, but only the no-solution half was enforced. A solvable task
whose seed grades clean scores the same whether the agent solved it or never
ran and reports 100% forever; TestSolvableCorpusSeedsMustNotGradeClean holds the
other 62 to it. All of them already pass. stageSeed now tolerates the optional
workdir the README documents.

Harness metrics move out of the work dir. The agent reads what is in front of
it, and one observed run spent a tool call on .run-metrics-1.json.partial —
snapshots already dropped these files, which kept them out of the grade but not
out of the agent's context.
2026-08-16 18:16:38 -07:00
esengine caf5d6b496 perf(trajectory): streamed increments become one record, not hundreds
One observed turn wrote 832 reasoning records carrying 6.5k characters, and a
two-minute benchmark cost a 484KB trajectory whose content was 19KB — 25x
envelope, and one write syscall per record because each is flushed. The only
timestamp any reader consumes is the first: cmd/e2ebench keys time-to-first-
token off it and drops the rest.

Adjacent same-kind increments merge, TS stays the first one's, and the count
and end time ride along. Records drop 86% and the digest cmd/e2ebench prints is
byte-identical across the change. A cap bounds what a kill can lose, and
anything carrying more than its text is left alone so a field added to the wire
later stops merging rather than vanishing into a neighbour.
2026-08-16 18:16:12 -07:00
esengine a8e4e4c446 fix(sessions): deleting a conversation is one act, not a half-erased one
The sweep removed artifacts one at a time and returned on the first refusal.
The transcript went first and the event log — the authoritative one — fourth,
so a Windows host holding that log open left the conversation neither present
nor gone: the caller saw a 500 while the sidebar row was already unrecoverable.
The marker now goes down before anything is removed, every artifact is
attempted, and whatever survives keeps it for ReconcileCleanupPending.

The guard asked the pane map whether the session was someone's current
transcript, which is narrower than "is anyone writing this file" — a recovery
branch or a mid-rotation session is held without being anyone's current path.
It takes the removal guard instead, so the answer survives the delete; probing
and then removing leaves a window for a writer to arrive and lose its lock file
underneath.

SseHub flattened refusals into their message text, so a coded 409 reached the
screen as raw JSON or as a filesystem error naming a path. It parses the code
now, like SsePort already did.
2026-08-16 18:15:56 -07:00
esengine 0a7273eef3 feat(studio): the side rails resize, and collapsing is something you can see
A transition on grid-template-columns does nothing when the track list holds a
minmax(): that property only interpolates between plain lengths, so the rails
were snapping shut with the animation written and silently ignored. The
interpolation moves onto two @property-registered lengths and the columns read
them; dragging switches it off, or the rail chases the pointer.

Closing a pane becomes awaitable. Deleting a conversation has to close its pane
first and then wait, because the kernel refuses to erase a transcript its
runtime still holds — firing the close off and deleting in the same breath
races the teardown.

perf/panels.mjs measures what has no error to report: a re-introduced minmax()
would put the rails back to snapping without failing anything else.
2026-08-16 18:15:33 -07:00
esengine d7520e5a6c fix(e2ebench): two digest numbers stop answering questions they cannot observe
A false-progress ratio needs a check that ran twice — nothing else can show a
fail→pass edge. Runs where no check repeated reported 100%, which reads as
every claimed advance being fake when the truth is that none was observable.
The digest now counts revisits and says unmeasured below one.

A start delay was one number over two different things. A call that waits
because a sibling is still executing is a serial batch behaving correctly; a
call that waits with nothing running is scheduling cost. Attribution is per
call — whether another call occupied that call's dispatch→start window — so a
batch that mixes both no longer files every delay under the same heading. On a
real run: queue p95 17.9s became queue p95 1ms plus serial wait p95 17.9s.

Splitting the render out of trajectory.go keeps it under the size ceiling.
2026-08-16 18:01:23 -07:00
esengine 7046cf6d6a fix(agent): delivery refuses an inline interpreter before the shape blocks
Delivery rejects `python3 -c` and `node -e` in any arrangement, but that gate
sat last, after every shape block. The shape blocks answered first, and their
ways out all named a call this one refuses: a probe followed the mixed block's
"run that segment as its own call", ran a bare `python3 -c`, and was refused
again for a reason the first message never mentioned.

Moving it first costs no new text and ends the chain. The three arrangements —
interpreter first, interpreter last, interpreter alone — now get the same
answer, the one that says why and points at read_file, node --check, or a
script file.

TestDeliveryProfileBlocksMixedVerificationBeforeItBecomesMutation reached the
mixed block through a `python3 -c` command, which no longer describes what it
tests; it uses cp now and still pins the named segment.
2026-08-16 17:52:23 -07:00
esengine 80b12bc25d fix(evidence): a verification is what it ran, and its verdict is the host's
Two faults hid one signal. The fail→pass edge was keyed on the raw command
string, and an agent never re-runs a check byte-for-byte — it writes
`go test ./... | head -60` first and `go test ./... | tail -15` after. The
verdict then came from the receipt's own exit status, which for a pipeline is
head's, so a failing suite read as a success that never needed redeeming.

Objective and Regression therefore never fired in any run, and the
false-progress ratio that divides by them reported 100% every time. Both
answers already existed and this path asked for neither: VerificationIdentity
reduces a command to the check it runs, VerificationOutcome reads the host's
PIPESTATUS classification. The offline digest mirrors the same identity so it
scores the edges the live tracker does.

Same task before and after: objective transitions 0 → 1, false progress
10/10 (100%) → 5/9 (56%).
2026-08-16 17:48:25 -07:00
esengine 931b6618ad fix(evidence): the delivery mixed block names its segment and its real exit
Ordinary mode names the segment that tripped the block; delivery answered with
one fixed sentence that named nothing. TestMixedBlockNamesTheSegment already
says what that costs — "which is how one block becomes three" — and a delivery
run spent exactly that: refused at calls 6, 8 and 9, rewriting the shape each
time against three identical messages.

It also stopped short of the other half. Ordinary mode's way out is '&&', which
delivery refuses as well, so a run that follows the generic advice earns the
same block again. The message now says so.

The same run after the change: refused once, then split the command into three
separate calls.
2026-08-16 17:22:36 -07:00
esengine 96d00ca6e3 perf(agent): a read-only bash call runs beside its siblings
parallelisable asked the registry whether a tool is read-only, and bash is
writer-capable in its schema, so no bash call ever joined a parallel batch —
which is most of what an agent runs. The host already resolves a concrete
invocation to read-only after parsing its arguments; permission, mutation
accounting and evidence all act on that fact, and only the partition did not.

Measured end to end: two greps over 32MB files now start in the same
millisecond and finish in 258ms, where serial execution took the sum.
2026-08-16 10:20:26 -07:00
esengine 3c8851a53b fix(shellparse): a pipeline is several commands, and the classifier can read them
CompoundLeafCommands only treated loops, branches, subshells and blocks as
compound, so a pipeline or an && chain fell between the two classifiers: the
single-command one cannot read several commands, and the leaf one refused to
receive them. Every `… | head` therefore counted as a write, which is the
shape agents reach for constantly.

A BinaryCmd is now compound, so each stage is classified on its own and one
writer anywhere still ends it — `| tee`, `| xargs`, `| sed -i`, `&& rm -rf`
and `tee >(sh)` all stay writes.
2026-08-16 10:20:18 -07:00
esengine ef8b81907b fix(desktop): drop the compaction archive field the wire no longer carries
99ef4b2f0 removed the archive path and its field on HistoryMessage but left six
readers and a mock behind, so `tsc --noEmit` fails on this branch with seven
errors across bridge.ts, transcriptStore.ts and useController.ts. main-v2 still
has the field, which is why the break is invisible there.

Nothing renders item.archive — the only readers were the store, the controller
and one mock — so the field goes rather than coming back. `pnpm --dir frontend
build` runs tsc, so this would have failed the desktop leg on merge.
2026-08-16 10:13:53 -07:00
esengine 7294583852 fix(bash): a capped timeout request stops being told to ask for more
timeout_seconds only tightens the host cap, but a run reaching for a long job
asks for the length the job needs: one observed run asked for 600s and 900s
under the default 120s cap. Both were clamped, and the timeout then advised
"raise timeout_seconds" — the number the cap had already overridden.

The refusal now says which cap won and how much of the request it took, and
points at the mechanism that can actually run longer. A run under a 5s cap that
asked for 90s reads it and moves to run_in_background on the next call instead
of asking for more seconds.
2026-08-16 10:08:14 -07:00
esengine 7c2b2b0424 feat(e2ebench): a refused call is a measurement, not just an error string
Three paths stop a call before it runs, and only two of them said so in
structured form: the batch dependency barrier and bash's own preflight set
execution.failurePhase, while a permission refusal carried nothing but an
error line. A gate that misfires was therefore invisible to every digest —
the agent just retried and the round count grew.

Permission refusals now carry the authorization phase through the same
constructor the barrier uses, and the trajectory digest counts refusals by
the gate that caused them.
2026-08-16 10:04:47 -07:00
esengine 16e76551de fix(permission): command -v reports a name, it does not run one
POSIX `command -v`/`-V` print how a name would resolve and execute nothing,
which makes them readers like `which`. Grouping them with exec/nohup/sudo
refused every environment probe an agent opens with, and the batch dependency
barrier then skipped the read-only probes queued behind them, so the agent
re-ran each one alone.

Measured on the same task before and after: 26 tool calls to 14, 21 model
rounds to 14, two dependency skips to zero, same conclusion both times.
2026-08-16 10:04:34 -07:00
esengine b52db1aa25 fix(repolint): the diff-scoped check gets an entry the host can read
REASONIX.md told the model to run `go run ./tools/repolint -only ...`, which the
host classifies as an unknown program that might write — so a delivery run that
followed the instruction could not cite the result. One observed run was refused
for `gofmt -l`, reached for the repolint command the file recommends, and was
refused again for the same reason.

A `make` target is the entry the host already recognizes, and Makefile is where
this project declares its checks. Nothing in the classifier changes.

The alternative — letting a project name its own verification commands in config
— was rejected. Delivery deliberately refuses to escalate (see
mixedShapeClearedByEscalation) because it only accepts what the host can confirm
by itself; a config channel would let the audited party define the audit.
2026-08-16 09:57:24 -07:00
esengine 91c6ccf08c fix(control): a headless refusal stops blaming a user who was never asked
An Ask decision with no interactive approver came back as "the user declined
this tool call — ask how they would like to proceed". Nobody declined it, and
the advice sends the model to consult someone who is not there. A headless run
asked to write one file spent two write_file calls on it and closed by offering
the user three choices about the file's contents, none of which was the reason.

denyPermissionApprover now answers with what actually happened. The same run
afterwards refuses once and leads with the permission mode as the fix.

The gate already threaded an approver reason through for exactly this; the
headless approver was the one that never supplied it, while the ask tool has
been saying "No interactive user answered" all along.
2026-08-16 09:48:53 -07:00
esengine 0d94c7605c style(evidence): range over the split sequence
modernize flags the allocation, and it was failing make lint on a tree nobody
had changed.
2026-08-16 09:39:10 -07:00
esengine d85fb1d34d fix(evidence): a refused verification names what the host accepts
The refusal said "use a project test/check/lint command", so a run that had just
been refused for `gofmt -l` reached for `go run ./tools/repolint` — this repo's
own lint command, and refused for the same reason. Naming the category invites
the second wrong guess.

VerificationCommandSummary is already the single source for the accepted set and
already travels with the inline-interpreter block; it now travels with this one
too.
2026-08-16 09:39:06 -07:00
esengine 78276d1e01 fix(serve): setting a goal no longer clears it
The client posted {text} while the handler reads {goal}, so the body decoded to
an empty string and took the clear branch. Against a running server: {text:AAA}
answers 204 and leaves the goal blank, and a later {text:CCC} erases a goal that
was set. SetGoal and SetPlanMode(false) were never reached.

The endpoint had no test at all. The new one pins the wire key rather than the
status code, since a 204 is exactly what the broken call returned.
2026-08-16 09:38:58 -07:00
esengine 686178b225 fix(evidence): the mixed-shape block stands down where the probe reads the check
`go build ./x && go test ./x | tail` was refused as a maskable mixture, but the
pipe-status probe already recovers each stage's status: a real run of that shape
reports exit 0 and verification failed. The block was guarding against a masking
the host had stopped suffering, and cost a round trip on an everyday shape.

The shape test the probe used to decide whether to inject itself moves to
evidence, so the gate and the probe read one judgement and cannot disagree
about a command.

The exemption covers exactly one masking source. `;`, `||`, and a backgrounded
mutation drop a status before the final pipeline, and no per-stage report brings
that back, so those stay blocked. Delivery is untouched: there a mutation
invalidates the receipt whether or not the exit status survived.
2026-08-16 09:38:49 -07:00
esengine 640c8d984e fix(repolint): a test file's length counts cases, not concerns
Splitting a long test file scatters one subject's table across files that then
have to be read together, so the ceiling only ever bought a worse layout. The
rule retires entirely rather than gaining an exemption flag; complexity and
struct-state already stand down for tests, and size was the last one that did
not.

The baseline drops the 74 test-file-size entries by hand rather than through
-update, which would have absorbed unrelated debt the tree carries today.
2026-08-16 09:38:36 -07:00
esengine 0340cebd06 feat(bash): a call can tighten the deadline it was given
The host cap (bash_timeout_seconds) was the only foreground deadline, so a
command whose cost the model could not predict spent the full cap before
saying anything. A per-call timeout_seconds now rides the same resolution the
host cap already uses: it only tightens, never extends, and with the cap
disabled it becomes the only deadline.

grep and glob each carried a byte-identical clamp; both now share toolTimeout,
which bash reuses by passing its cap as default and ceiling.

A timeout that produced nothing says so — silence and partial output call for
opposite next moves, and the output alone cannot tell them apart — and names
the way out, which differs by whose deadline fired.
2026-08-16 09:38:26 -07:00
esengine 102ddece78 fix(jobs): a recovered task mutation keeps the grade that makes it high-risk
ClassifyMutationRisk raises a pathless mutation to RiskHigh only when its
MutationEvidence is MutationProven; anything else falls through to RiskLow.
The receipts rebuilt from a task artifact set Mutation but left the grade
empty, so a background writer recovered after a restart came back as low
risk — exactly the downgrade the opaque recovery exists to prevent, and what
three tests in this package had been failing on.

Both construction sites now share recoveredTaskMutationReceipt, which states
why the grade is not optional. It lives in artifacts.go: the artifact is what
proves the mutation, and jobs.go has no room left under its size budget.
2026-08-16 06:09:03 -07:00
esengine 0b1342e8de fix(shellsafe): one rule decides whether a call carries inline code
"This argv runs a program written in the call itself" was answered twice.
Permission knew `bash -c`, `sh -lc`, lua, rscript, osascript; evidence
knew node, python, ruby, perl, php and deno and nothing else. So
`bash -c '...'` was code the approval layer refused to audit and an
ordinary program to the ledger that recorded it.

ArgvCarriesInlineCode is now the one rule, taking the wider of the two,
and both ask it — the same move ArgsMakeReadOnlyCommandWrite made for the
arguments that turn a reader into a writer. executableBase went with it:
two identical copies of "strip the directory and .exe".

This is a table, and it stays one: which flag makes an interpreter read
its program from the command line is knowledge about those programs, not
a shape any parser can derive. What it must not be is two tables.
2026-08-16 06:03:07 -07:00
esengine c821eb1cb9 fix(recovery): recognise an empty search by the sentinel the tool wrote
A search that matched nothing came back to recovery as a list of English
phrases to look for: "no matches", "no files found", "0 matches", "not
found", "no results". Four of those five no tool has ever printed. The
fifth worked only because grep and glob happen to print it — a sentence
the host writes for the model, re-recognised as if it came from outside.

tool.NoMatches is that sentence, once, with a writer and a reader. The
guess is gone, and so is the drift a reworded or translated line would
have caused: the gate now reads what the tool actually emitted.
2026-08-16 05:58:01 -07:00
esengine cbd8ee79b8 fix(evidence): the review gate asks the same question the coverage check does
Two rules for one question had drifted apart. The coverage check now asks
whether an output carried the change; the review gate still read command
names, and accepted `git status`, which prints paths and no content, and
`echo internal/a.go`, which prints one path and nothing else. Either was
enough to make a turn look reviewed.

Both are gone. The gate asks for the change's content, exactly as the
coverage check does, and falls back to "the turn looked at something"
only where it must: a mutation from before this ledger, or a shell write
that named no path, is unknowable either way.

That fallback is what the retired table added nothing to. A plain
`git diff` counted because the host classifies it read-only, not because
its name was on a list — and the list could not tell that apart from
`git status`, which is why it accepted both.
2026-08-16 05:54:39 -07:00
esengine efdbcc2ae8 feat(evidence): ask the workspace what a command did, instead of the command
`sed -n '1,80p' f` is a mutation to this host. So is `git -C dir status`,
and `mkdir -p "$TMPDIR/probe"`. Not because anything believes they write,
but because proving they do not means reading sed's own script language,
git's global options, or a path that only exists at expansion time — and
each of those answers is a table that covers one spelling and misses the
rest. The cost lands on the turn: phantom changes in the completion
report, a verification owed for a file nobody touched.

A call the host could not classify now takes a whole-workspace scan
before it runs and another when its receipt is written; if the tree is
byte-for-byte as it was, the receipt says so. Nothing is predicted, so
nothing needs a table, and it works the same for awk, perl or a program
written this morning.

The walk is unfiltered on purpose — npm install and a build write exactly
where ignore rules point away, so a filtered scan would miss the largest
writes there are. It skips only the VCS store, which `git status` rewrites
on its own. Past 50k files it reports itself incomplete and settles
nothing, and a backgrounded job never settles: its receipt is written
before the job has written anything. Every one of those falls back to
today's behaviour rather than to a guess.

Measured on this repository: 17ms per scan in a worktree, 126ms with
node_modules and build output present, against ~6.4s per model round.
Permission is untouched — it must answer before the call runs, so it
stays predictive and fail-closed.
2026-08-16 05:46:35 -07:00
esengine fc0dfa91e3 feat(evidence): review is what the output carried, not what the command looked like
A turn that ends by reading its own diff writes `git diff --stat && git
diff`, or puts a `cd` in front, or prints a separator between two halves.
The host asked whether the command's shape implied it had printed a
file — one statement, two arguments, no suppressing flag — so every one
of those shapes was rejected, and a turn that had reviewed its whole
change set was reported with review gaps. It could not be otherwise:
the receipt's output length is the whole command's, so a compound one
could always have printed something else.

So stop reading shapes. When a change is made, the preview the writer
already produced names the lines that differ; when a later call returns,
the host asks whether its model-visible output carries them, and records
the answer on the receipt. Matching is on substrings, so a `+` from a
diff, a line number from `grep -n` and a pager's indent all read the
same — the formats a shape rule would have had to enumerate.

This retires commandShowsContentForPath, commandShowsWholeGitDiff and
their helpers, including the suppressing-flag table and both command-name
switches: 168 lines of shell-shape reading for 60 of content matching.
A read receipt still covers its own path, unchanged.

Verified against a real run: its `git diff --stat && git diff` output
carries both witness lines and counts; the `--stat` half alone does not.
2026-08-16 05:30:51 -07:00
esengine bd2bde64d0 fix(mcp): announce a connection when it happens, not when its prompts arrive
A server showed "not connected" while the agent was using it perfectly well,
and the connect button sat on "connecting…" forever.

Status views refresh on MCPSurfaceReady, and that event was only ever emitted
after prompts or resources finished listing. A tools-only server has neither —
windows-mcp, in the report — so nothing was ever emitted for it, and the view
kept showing the state it read at boot. The kernel was right the whole time:
connecting clears the recorded failure. Nobody was told.

The announcement moves to where the state actually changes. The Host holds the
sink rather than taking one per call, because a status change has no caller to
hand it one: a cache-miss server connects on its first real tool call, in the
background, long after whoever configured it is gone. That is also why the
existing StartPhaseB path could not have covered this — it has no callers at
all, so the prompt/resource announcements never fired either.

Also on the composer: the effort menu listed six rungs for every model, while
the kernel declares per-model supported_efforts and refuses the rest. Picking
one a model does not have was answered downstream with a refusal and nothing on
screen, which reads as a dead control. The menu now offers what this model
actually has.
2026-08-16 05:30:20 -07:00
esengine e040291578 fix(shellsafe): one rule decides whether a reader's arguments write
The check that turns `find -delete`, `sort -o` or `git tag v1.0` back into a
write lived in three packages, and they had already drifted: only permission
knew that naming a tag creates one, so evidence recorded such a turn as having
changed nothing at all. Two of the copies also carried a `sed -i` branch no
call could reach, because sed is not in the read-only tables to begin with.

ArgsMakeReadOnlyCommandWrite is now the one rule, taking the strictest form of
each case, and permission, evidence and the nested-substitution check all ask
it. The unreachable branch is replaced by the reason sed stays out: it writes
through its own script language (`-i`, `w file`, `s///w file`), which nothing
here parses, so a flag check would cover one spelling and miss the rest.
2026-08-16 05:17:24 -07:00
esengine c29b132c30 test(studio): the safety assertion follows the constant it outlived
fitted stopped shrinking by a fixed margin when the size became a ceiling, so
the test's own guard — "still not wider than the screen" — was still gated on a
constant that no longer decides anything. It now runs whenever the screen
reported a size at all, which is the condition the assertion was always about.
2026-08-16 05:06:13 -07:00
esengine 2918340e03 fix(tools): scratch belongs somewhere, and it is not the work product
The prompt tells a run to keep probes out of the repository and now names
$TMPDIR. Bash writes there — that is where the variable points — but
write_file refused it, so the here-document block's own advice ("write
the file with write_file") led straight into a wall. One observed run
worked around it with printf and lost three rounds; an earlier one wrote
its probe into internal/agent/ instead, then had to rm it.

The writers now accept the session's own temporary directory, the same
one bash writes through, and a refused write names it. Its writes are the
kind the host can account for: a path, a receipt, a diff.

That made the second half visible: a probe under $TMPDIR armed the
post-write verification gate and appeared in the completion report as an
unreviewed change, so a turn that touched nothing in the repository was
told it had gaps. What the turn owes is measured on the work product, so
a path outside the workspace is not what the readiness gate and the
report count. Where bash wrote is often unprovable — those receipts name
no path and still count, as they did before.
2026-08-16 04:52:51 -07:00
esengine 7a8989276e feat(studio): the opening size is a ceiling, not a constant
Confirmed from the report: 125% scaling on 1080p. So the fix that landed —
shrink to fit — was aimed correctly but is only the floor of it, and it corrects
a window that has already been drawn.

Two things on top of it.

The preferred size is now a ceiling. A window opens at whichever is smaller,
1440x900 or most of this screen, where "most" is proportional rather than a
fixed margin: 64px of air is cramped on a 4K panel and generous on a laptop,
while nine tenths reads the same at every size. At 125% of 1080p that is
1382x777 instead of 1440x800 — the earlier version left the width untouched and
squashed only the height, which is the same window in the wrong proportion.

And the window starts hidden, shown from the same place once it has been
measured. Sizing a window that is already on screen is a flicker on every
launch, and on the machines where this matters it would be a flicker every time.

The package does not build in this tree — evidence/taskcontract are mid-refactor
elsewhere in the working copy — so the arithmetic was checked on its own rather
than through the test that encodes it. Still no Windows machine here: what is
verified is the decision, not the platform calls it drives.
2026-08-16 04:50:34 -07:00
esengine 57a076f904 fix(studio): open the window where it fits, in the units it is measured in
Reported from Windows: the title bar starts above the top of the screen, on a
1920x1080 display — so not a small monitor.

Wails sizes windows in logical pixels. That display at Windows' common 125%
scaling is 1536x864 to lay out in, and 150% leaves 1280x720; the default
1440x900 fits neither. Centred in 864 the top edge lands at -18, which is the
title bar exactly. On Windows the frame is off and that bar is drawn by the app,
so what goes off-screen is also the only region that can drag the window back —
there is no recovering from it by hand.

The window is measured against the screen it opened on and shrunk to fit, and
centred either way, since Wails makes no promise about where a Windows window
lands. The arithmetic is a pure function so it can be tested from any platform:
the failing cases here are 125% and 150% of 1080p, which is what shipped.

Untested on Windows itself — I have no machine for it. What is verified is the
sizing decision, not the platform call it feeds.
2026-08-16 04:46:12 -07:00
esengine 409b777623 feat(i18n): the shell's own panels speak the interface language
The folder panel's title was a Chinese string compiled into the shell, so an
English interface opened a Chinese panel. It sits outside the webview, which is
why the frontend's catalogue never covered it.

It reads from internal/i18n now, but through a new Catalog(tag) rather than
DetectLanguage: the desktop interface language is a setting of its own and the
kernel's is another, and one process holds both — installing one would silently
retag the other. Catalog returns a catalogue and leaves M alone, which a test
pins, since the whole point is the absence of a side effect.

The title itself lands in all three catalogues, so the existing completeness
test is what refuses the next one that ships in one language.
2026-08-16 04:39:09 -07:00
esengine 729c8c4eb2 fix(agent): a search that found nothing is an answer, not a failure
A grep that matched nothing exits 1 and prints nothing, so the run saw
`error: command exited: exit status 1` and no more. It could not tell
absence from breakage, re-ran the same search with small variations for
sixty rounds, and collected a recovery steer each time telling it to
diagnose the tool.

A call that printed nothing, that the host proved changed nothing, and
that carries no verification conclusion reported a result: grep,
`test -f` and `git diff --quiet` all report absence that way. Say so in
the body, and leave recovery out of it. A verification keeps its old
reading — its exit status is its verdict, silent or not.
2026-08-16 04:29:28 -07:00
esengine b11716521e fix(permission): a here-document is read, its body is not
`cat > f <<'EOF'` inside a compound statement fell into the unparsable
bucket, whose message points at read_file and grep — advice for someone
inspecting, given to someone writing a file. The observed run hit it
twice, two hundred seconds apart, and learned nothing in between.

The parser reads a here-document fine; what an approval cannot see is
its body, which is file content rather than arguments. That is a
different gap and now says so, and points at write_file.
2026-08-16 04:29:20 -07:00
esengine f8968d5b0d feat(prompt): state where commands run, and where scratch belongs
A run told only the workspace path prefixes every shell command with
`cd <root> &&` and spells every tool path out in full — 50 of 51 bash
calls in an observed run. The prefix is not only tokens: it turns a
simple call into a compound statement, and the evidence reader credits
neither its content nor a whole `git diff` as review of the change set,
so a turn that did review its own diff was reported with gaps.

The scratch line had the same shape. It says to keep probes out of the
repository and use a temp directory, without naming one; the run reached
for /tmp, was refused, and wrote its probe into the repository instead.
$TMPDIR is the session-private directory the host already provides.
2026-08-16 04:29:13 -07:00
esengine d86a9fe473 fix(studio): say that a project can be started here, not only opened
Users concluded the app cannot create a project. It can — the picker has
CanCreateDirectories set and macOS puts a New Folder button in the panel — but
every word around it said otherwise: a rail footer reading "add a folder" and a
panel titled "open a working directory" describe picking something that already
exists, so nobody went looking for the button that was there all along.

The entry now says what it does, and the panel repeats it at the moment the
choice is made. No new path: the one that existed was simply unannounced.
2026-08-16 04:28:22 -07:00
esengine 8c0af97ff2 fix(studio): pin the marks, and make the drag keep up
Three things the rail got wrong, all reported from using it.

The marks drifted while dragging. They were placed in pixels, and a block
mounting swaps its estimated height for its real one — every pixel-derived
position on the rail moves a little while the content underneath sits still.
They are placed by entry count now, which mounting cannot change. Entries are
also the better answer to "how long was that turn": what it produced, not how
tall it happens to render.

The drag fell behind the pointer. What can be dragged is the track minus the
box, and what it covers is the content minus one screen; using the whole track
against the whole content leaves it slow by exactly the ratio of the two. Both
are read per move rather than captured on grab, because blocks keep mounting as
the drag travels.

The box could not be grabbed at all: each mark's hit box spans the rail's full
width and they are painted after it, so the press never reached it. The two now
occupy their own columns — the scrollbar at the very edge, the marks to its
left.

Jumping measured with offsetTop, whose nearest positioned ancestor is .pane —
which does not scroll. That reads "where in the window", and writing it back as
scrollTop barely moves. Measured against the scroller now.
2026-08-16 04:03:47 -07:00
esengine c200e5810c style: bring four doc blocks back under the comment budget
repolint went red on comment length in four places, which blocks the pre-push
gate for everyone working in the tree. Each block keeps what it was saying — why
complete_step exists at all, why a path's shared head goes before its middle,
what ReadOnly buys a batch, why the reason for a contextual tool lives on the
tool — in fewer lines.

The two test-file-size overruns are left alone: shrinking those means splitting
test files, which belongs to whoever is adding the tests.
2026-08-16 04:00:49 -07:00
esengine e8969220b4 feat(agent): a decision the user already made answers for itself
The escalation path asked a model about every command the static tables could
not read, including ones the user had already approved. A rule in the config is
a person's own answer, written down; asking a model to second-guess it is both
slower and less authoritative, so a matched rule now short-circuits the ask.

Telling the two apart takes care: Policy.Decide returns Allow for a writer under
the auto fallback exactly as it does for a rule that matched, because Mode's zero
value is Allow. Neutralizing the fallback is what separates them — with Mode set
to Ask, an Allow can only have come from a rule. Reading the rule list directly
would mean re-implementing bash's segment matching, which is the sort of second
copy that drifts.

The classifier generalizes to one call shape, since a second host question would
differ only in its criterion and its affirmative word.

A verification classifier is deliberately absent. The static tables miss a
project's own checks, but a model misses them for the same reason the tables do:
`python3 test_x.py` and `python3 deploy.py` are the same command shape, and which
one is a check lives in the project, not the command. That question already has
an answer that does not guess — a project names its checks in REASONIX.md, and
declaredChecksRanAfter takes the classifier out of the decision entirely.
2026-08-16 03:56:12 -07:00
esengine db309ea25e fix(evidence): read the suite's status through the check that guards it
Running real tasks against a real key, three of four verifications in one turn
came back unreadable. All three were the same shape — `go vet ./... && go test
./... 2>&1 | tail -5`, or a banner echoed before the suite — and the probe that
was supposed to recover the suite's own status only handled a command that is
nothing but one pipeline.

PIPESTATUS describes whichever pipeline actually ran, and which one that was is
answerable whenever the candidates differ in width: `go vet && go test | tail`
can end in a one-stage command or a two-stage pipeline, so two statuses can only
have come from the second. Equal widths stay refused — attributing a status to
a pipeline that never ran is the error this whole path exists to avoid.

Same task re-run: three unreadable verifications became zero.

Also: a check that failed before the latest change no longer counts as a gap
once something fresh has proven the tree. A project whose rules ask for a
failing test before the fix produces one every time, and reporting it back
after the green run reports the bug as an outcome of the work that removed it.
The neighbouring stale and inconclusive cases already worked this way.
2026-08-16 03:51:30 -07:00
esengine e5a1b216c9 feat(studio): the rail replaces the scrollbar rather than sitting next to it
Two vertical strips down the same edge saying the same thing — the viewport box
and the scrollbar thumb both answer "where am I" — read as a doubled scrollbar,
which is what it was.

So the transcript stops drawing the native one. The box is permanent now
instead of appearing on approach, and it drags: it inherited the job, so it
inherits the handle. A drag releases the follow the same way a wheel does,
through the same call, because it is the same intent.

⌘↑ / ⌘↓ walk the marks. Which one you are at is read from the viewport rather
than from a cursor the rail would have to keep in sync, so the keys always agree
with what is on screen. A text field keeps them — inside the composer they still
mean what the system means by them.
2026-08-16 03:46:08 -07:00
esengine 86cc7e17a0 feat(studio): a rail for what you said, and a way back to it
In a session you speak a handful of times and it works for the rest. So "what
did I ask it to do" always has a small answer — and it is buried under a few
thousand lines of output, reachable only by scrolling.

The rail marks those messages down the right edge, positioned where they
actually are: the gaps are how long each turn ran, and a mark is as thick as
that turn's change was (checkpoint.files). A viewport box says where you are
now, which is what separates a map from a list of bookmarks.

Pointing at a three-pixel line is not something to ask of anyone, so the whole
rail is a track: the pointer's y decides which mark it means and the nearest one
is drawn as held. The first cut had each mark move on hover, which took it out
from under the pointer — hover dropped, mark returned, hover again. The hit box
is fixed now and only the line inside it animates.

Marks locate by block, not by message. Under virtualisation most messages have
no node at all, but their block is always there holding its height, so a jump
lands on the block first and settles onto the message once mounting brings it
back. The jump also clears the follow the way a gesture would — otherwise the
next frame would pull the reader back to the bottom they just left.
2026-08-16 03:35:22 -07:00
esengine 431e037c35 fix(completion): a file the turn wrote end to end reviews itself
Building a small CLI from scratch reported six unreviewed changes against six
files the model had just authored. The review gap asks whether the result of a
change was inspected — a real question for an edit, which has a before to
compare against, and none for a file whose entire content is the write itself.
Reading it back compares the model's text with its own text; what answers for
those files is the check that ran over them, which the report already counts.

So a path this turn created counts as reviewed until something edits it again,
at which point there is a before once more and the requirement returns with it.
The same run now reports two gaps instead of eight, and the two are the files
that were written and then edited.

Also here: golangci-lint's two findings on the observation code from the
previous commit.
2026-08-16 03:22:20 -07:00
esengine 6e0b886446 feat(agent): a way to ask when the static tables run out of answers
shellsafe enumerates 145 commands and will never enumerate them all: a new
formatter, linter, or query tool arrives every week, and each one that falls
through reads as "might write". A read-only command classified that way blocks
work — a real run spent seventeen consecutive rounds trying to see one failing
test, rewriting the filter each time a pipe segment tripped the mixed-command
contract.

So the tables get an escalation path rather than more entries. They stay the
fast path and answer everything they know; when they come up short and the host
is about to refuse, one classification call decides, and the verdict is cached
so a segment is asked about once. triage_model points those calls at a cheap
tier — empty falls back to subagent_model, then the main model, so it works
before anyone configures anything.

The escalation cannot widen what actually runs. The OS sandbox still bounds
every write, the verdict is refused unless the reply is exactly READONLY, and
anything unestablished — no provider, a timeout, a hedged answer — lands on the
refusing side. Delivery never escalates: there a mixed command is refused for
what it does to the receipt, not for what it might write.

Two things this cost to learn, both from running it rather than reading it:

- A one-word answer needs thinking turned off. Under a tight token cap a
  thinking model spends the whole budget reasoning and returns nothing, so four
  classifications in a row silently failed.
- The classifier gets its own provider instance from boot. Borrowing the turn's
  own would put extra calls into a stream the run is mid-conversation on — which
  is exactly what hung the agent tests against a scripted provider, for the full
  ten-minute default timeout.

repolint grows -only, which reports the budgets of named files instead of the
whole tree's recorded debt. A change could not tell its own violations from
inherited ones; the model in one run tried to build a git worktree to compare
against, and the sandbox refused it. Repo-wide ceilings still report, because a
file can push the tree past one without exceeding its own budget.

Carries the rest of the in-progress work in the same snapshot.
2026-08-16 03:15:01 -07:00
esengine 885864c223 fix(tool): a tool that is out of context says so itself, and cites what differs
Two findings from a real session, both about telling the model something it can
act on.

The host answered an out-of-context call from a switch keyed on tool name,
while the condition it was reporting lived in that tool's own ProviderVisible.
Half a contract in each place, and the half in the host is the one nobody
remembers to extend: add a contextual tool and it gets the generic sentence.
ContextualReasoner puts the reason next to the state it reads, and a registry
test refuses a contextual tool that cannot give one — submit_plan and
conclude_no_changes turned out to already be in that position.

The dynamic surface is deliberately not the fix here. ContextualTool's contract
says schemas stay static for cache stability, and a list that shrinks when a
goal ends would break the prefix every turn. Availability is answered when the
call runs; what this changes is only the quality of that answer.

Second: complete_step already cites the receipts it has, but shortened each to
its first eighty characters. Under a deep root that is the shared prefix, so a
turn was told to cite one of three identical strings. Paths differ at the end.
The shared head goes first, then the middle is elided — the part that tells one
from another is what survives.
2026-08-16 03:09:55 -07:00
esengine 746db8a691 feat(studio): the trajectory can leave the window
It was a table you could read and nothing else — the one record of where a
turn's time went, with no way to get it to anyone who was not looking at that
screen at that moment.

Export writes what a row is rather than how it is drawn: seq, at, dur, kind,
tool, plus the rendered line as text and any detail under it. Four of those are
the timing, which is the point of keeping it; the text is there so the file
reads as itself without the app.

The path out follows the one plugin export already uses: a webview starts no
downloads, so the shell writes the file through a save dialog and reports where
it went, while a served tab downloads it. SaveText is general because the shell
has no business knowing what the frontend assembled — the trajectory is simply
the first thing that needed disk. Handing bytes to a browser moved next to the
ports rather than into one, so the fixture takes the same path a tab does
instead of claiming a file was written somewhere it was not.
2026-08-16 02:29:20 -07:00
esengine 9f60210479 feat(evidence): classify a command by what it did, not only by what it reads like
An investigation that was told not to change code still ended in error. The
model wrote a scratch probe, ran it, deleted it, and answered correctly — and
the host, seeing a write with no verification after it, reported a failed run.

The static classifier could not have known better. `go run x.go && rm x.go`
decomposes into commands it does not recognize, so it lands in unknown: a
mutation with no paths, about which every later question answers "something,
somewhere". Teaching it that `rm` removes its arguments would start a table of
command semantics that never ends and is wrong at the edges.

So the host watches instead. Before a mutating call it records the state of the
paths the turn already touched plus the call's own targets; afterwards it reads
which of them changed, and which it watched come into existence. Classification
is only ever sharpened — a call that changed nothing among the watched paths
stays unknown, because it may have changed something outside them. `sed -i` on
a file the turn had read now names that file instead of shrugging.

Knowing what appeared is what makes it safe to know what vanished: the baseline
for what a turn still owes is the latest change still on disk, and a file the
host watched appear and then vanish is not one. A write it never saw created is
assumed to survive — an unresolvable path looks exactly like a deleted one, and
only one of those may be waived.

Same investigation, re-run: finishes clean, workspace empty of scratch files.
An ordinary fix still verifies as before.
2026-08-16 02:28:28 -07:00
esengine 8c606c0390 feat(studio): a trajectory row is an activity, and the model round is one of them
Measured against a real 262-second task: model rounds accounted for 99% of it,
tool execution for 0.6%. The timeline drew only the second number. Twenty-four
rounds — the thing the wait actually consists of — had no row at all, because
the table recorded events rather than activities, and a round is two events far
apart.

A row is now one activity: opened by the event that starts it, settled by the
one that ends it, both halves on the same line. The kernel already names them —
stream_attempt carries begin/commit under one id, a tool call carries its id
into its result — so the pairing is read from the stream, never guessed. A
tool's duration comes from the kernel, which measured it; a round's comes from
the wall clock here, which is what the reader waited.

Falling out of the same change: one call is one line instead of two, tool
errors take a distinct kind, and tool_progress stretches the bar it belongs to
instead of opening a row. Progress output is not copied into the table — this
records when, not what.
2026-08-16 02:18:04 -07:00
esengine b8183dfed5 fix(agent): say whether the workspace is a repository, and name one gap once
Two findings from running Python, JavaScript and investigation tasks against a
real key.

A model reviewing its own work reaches for `git diff`. In a workspace that is
not a repository it finds out by failing — twice, since the first attempt is
usually a compound command the safety gate rejects for other reasons. Every
non-repository run in the traces paid this. The environment section already
states the OS, the shell, and which tools are installed; it now also states
whether the workspace is under version control, read from the filesystem so it
costs nothing at boot and cannot flap into the cached prefix. In a re-run of the
same Python task the model made no git attempt at all.

Delivery reported one gap as three. A sign-off that cites a passing check is
not a valid sign-off until the change has also been reviewed — correct, but the
gate then also demanded "run relevant verification and cite that successful
command", which the model had just done and watched pass. The cited-verification
question is now asked on its own, so the message names the review and only the
review.
2026-08-16 02:11:01 -07:00
esengine 2597fb1e36 build(desktop): launch Studio from a bundle, because macOS panels need one
A bare binary draws its window fine, so nothing looks wrong until a native
panel opens: LaunchServices only treats a bundled process as a real GUI app,
and NSOpenPanel takes focus and closes in the same beat. "Add a folder…" reads
as broken, and the search goes to the frontend, where nothing is wrong.

make studio builds the frontend, builds the shell, wraps both in an .app and
opens it. The bundle symlinks frontend-next beside the executable — assetsFS
looks there first, which frees the launch from the working directory `open`
gives it (that is "/", and the binary exits before drawing anything).
2026-08-16 01:59:00 -07:00
esengine eb0374bd1f feat(studio): controls that draw what they measure, and a follow that holds still
The row under the composer was five identical grey pills for four different
kinds of thing. Each control now draws its own quantity: effort as a five-bar
level that lights up by step and colours by cost, approvals as a ring whose gap
is how much the gate lets through (closed with a bar through it is the strictest
posture, not the loosest — its old label said the opposite of what the kernel
does), plan as an icon that strokes itself in. The effort menu carries the same
scale, so opening it shows a gradient rather than six equal words.

Floating layers get their own tokens. A menu was never transparent, but on the
dark theme --raised sits a shade from what it covers and a black shadow lands
on near-black, so the only thing separating a menu from the transcript was one
border. --float lifts the surface, --shadow-float adds a lip and a ring. Five
popovers that each carried their own shadow now share it.

The trajectory grows a timeline column: a bar placed by +t and sized by
duration, coloured by tool category. A result is written when it ends, so bars
run from at-dur to at and parallelism shows itself — a subagent's 8.4s bar with
its own calls nested inside the span.

Following: scrolling up mid-turn did nothing at all (measured: 1500px undone
before the next frame). The marker at the bottom resumed the follow on position
alone, and blocks mounting push it across that edge constantly, so every
virtualisation event yanked the reader back. Resuming now needs a downward
gesture; releasing cancels the follow already queued for the next frame.

Jitter: the reveal clock appends characters on its own rAF and the follow
scrolled on another, so the distance to the bottom swung 0-142px through every
stream. The follow moves into the layout pass, where the DOM has grown and the
frame has not been painted yet. 142px of swing becomes 24.
2026-08-16 01:58:41 -07:00
esengine 52be497c1d fix(serve): every approval posture the kernel has, not the three this face listed
The composer offers four postures and the kernel has always had four, but
/tool-approval-mode carried its own copy of the list with three names on it —
so choosing "don't interrupt" answered 400. The copy had a second fault that
would have outlived the first: it lowercased the request before comparing it
to ToolApprovalDontAsk, and "dontask" never equals "dontAsk".

Validation moves behind control.ParseToolApprovalMode, so which postures exist
is answered in one place. Aliases (dont-ask, deny) canonicalise with it and an
unknown name is still a 400.
2026-08-16 01:57:25 -07:00
esengine 518d8b1561 feat(agent): a turn can end honestly unfinished, and the host can say so
The host recognized one way to finish a turn — everything verified — so the
model that did the work, found the task self-contradictory, and said so was
reported as a failed run. On a workspace where two suites demand different
values from one pure function, that left contriving a way past the checks as
the only path the host rewarded, and a real run took it: a call counter
returning 100 the first time and 250 after.

conclude_blocked is the missing exit. The claim costs what a claim of success
costs — every citation must match a receipt from this turn, and a failed
command counts, since the run proving a task impossible is usually the one that
failed. Readiness waives exactly one thing for it: that the check passed. The
check must still have run, and nothing else about the turn is waived.

The gate it answers to was itself too weak to notice: it asked for one passing
check and never looked at the failing one beside it, so `go test; go vet` read
as verified on a red suite. A check that still stands failed now counts against
the change, folded per check so re-running one until it passes still clears it.

Two places now name the exit at the moment it is relevant rather than after the
run has ended: the readiness gap, and ask's headless fallback — which is where
a model with no answer picks between guessing and a trick. In the same scenario
that produced the call counter, the model instead completed the possible half,
declared the rest blocked, and said what decision would unblock it.

Also here, from the same run traces:

- The delivery review gate told models to run `git status --short` and
  `git diff` when a change reported no paths. In a workspace that is not a
  repository that instruction cannot be followed, so it now states what the
  host needs — which files the change touched — and leaves the means alone.
- A headless run dropped the end-of-turn quality verdict entirely: the same
  turn the chat TUI flags as partial finished silently. Both frontends now read
  it from one place on the event.
2026-08-16 01:47:59 -07:00
esengine b289d911e3 fix(evidence): ask bash what the check exited with, instead of what the pipe did
`go test ./... | tail -5` exits with tail's status, so a failing suite reported
zero and the host could only call the check inconclusive — which the readiness
gate reads as "no verification ran". A project whose house rules pipe test
output could therefore never finish a turn, and the gate's own instruction
("run a relevant verification command") was false to a model that had just run
one and watched it print `ok`.

bash already knows each stage's status. For the one shape a status can be lined
up with — a single pipeline whose exit is decided after the check — the host now
appends a report of PIPESTATUS and restores the command's own exit status, so
nothing downstream sees a different result. `set -o pipefail` would have been
simpler and wrong: it changes what `| head` means.

The report never reaches the model: it is stripped from the output, the tail,
and the live progress stream, and a run that truncates or dies simply reports
nothing rather than something wrong.

Where no report is possible — `a | tail && b | tail`, where PIPESTATUS covers
only the last pipeline — the two messages now say what is true. The tool result
says so as it happens, and the gate names the command whose status belongs to a
later stage. A run that used to end in error now re-runs the check on its own
one round later.
2026-08-16 01:01:44 -07:00
esengine 87e7c76a11 fix(evidence): a check the host cannot classify is not a change it must review
`gofmt -l`, `prettier --check` and `eslint .` were graded as writes, because
the host could not prove them read-only and treated that as proof they wrote.
A write with no path is opaque, so one such check after an edit pushed delivery
review to its high-risk gate, whose only instruction was to name the changed
files with `git status --short` and `git diff`. In a workspace that is not a
git repository there was no way to answer, and the run ended in error with a
green suite on disk.

Mutation now carries how well the host knows it: proven — a writer tool's
contract, or an output redirect to a named file the parser found — unknown, or
none. Risk scores only proven path-less writes as opaque, since a command that
merely resists classification describes no change set a reviewer could be
pointed at. The delivery baseline takes the latest proven write and falls back
to an unproven one only when there is no proven one at all, so a check that
runs after the verification no longer moves the goalposts past it.

ToolCallMutates keeps its conservative boolean view: permission and the
delivery floors still treat unknown as a write, exactly as before.
2026-08-16 00:31:48 -07:00
esengine a35603549a fix(evidence): a shell's exit status is not the verdict of the check inside it
`bash -l -i -c` sets no pipefail, so `go test ./... | head` exits 0 on a suite
that just printed three failures. Six places read that status as the check's own
answer: the host classifier, the completion report, the readiness floor,
delivery sign-off, the contract's declared checks, and the summary line. Eight
real runs produced 22 verification commands between them; only 10 carried a
status that says anything about the check.

The verdict now comes from what a zero status actually proves.
shellparse.ExitZeroImplies reads it off the AST: both sides of `&&`, only the
last stage of a pipeline, only the last of several `;` statements, neither side
of `||`, and nothing at all behind `&`. A check whose verdict the shell handed
to a later stage is recorded as inconclusive — it neither proves nor refutes, so
it counts as neither. Receipts the host never classified keep reading their own
outcome, which is all a declared command or a replayed session carries.

Two shapes this fixes, both observed live rather than imagined:

- `ls -la && go test ./... 2>&1 | head -100`, run while three tests were red,
  was recorded as a verification that passed.
- `go test -v ./... | tail -20 && git status --short`, run on a green tree
  outside a repository, was recorded as the suite failing.

Verification items key on the check they carry rather than on the wrapper around
it, so re-running one inside a different pipeline supersedes the earlier outcome
instead of opening a second item the fresh run never clears.

A check is also no longer discarded for its company. `go test ./... && gofmt -l
.` carries a readable verdict even though the host cannot prove gofmt leaves the
tree alone: the short-circuit is the same reasoning the admission gate already
applies when it blocks only the maskable mixed shapes. Not adding gofmt to a
read-only table was the point — ruff, prettier and eslint follow it, and the
table never ends. IsDeliveryVerificationCommand keeps the stricter question for
sign-off, where "only verification and read-only work" is what is being asked.

The summary line and the completion receipt now answer together: a contract the
ledger cannot fully vouch for is no longer summarized as complete beside a
receipt listing what stays unproven.

Recovery guidance rides the steer path under its own host prefix. A model told
the user interrupted starts answering someone who never spoke, and cannot tell
that from a real interruption.
2026-08-16 00:05:03 -07:00
esengine d70d9d4686 perf(studio): opening a conversation no longer waits on a wallet read
Switching sessions felt slow, and the length of the transcript was not why:
a 12MB record reads back in 8ms. What the reader waited on was /status, which
queried the provider's wallet endpoint on every call — 87-167ms, uncached —
while the pane held the transcript behind a Promise.all with it.

Three layers, measured at each end:

- billing.Cache serves one wallet answer for 30s, lets exactly one caller
  refresh it, and keeps a good reading standing in for five minutes when the
  endpoint starts failing: unreachable is not empty. billing.Store shares one
  cache per endpoint and credential, because opening a conversation builds a
  whole runtime and a per-runtime cache is cold on exactly the read a switch
  waits on. control.Options collapses its three balance fields into that one
  cache, which is also what keeps boot.build under its ratchet.
- The pane reads the record and the numbers over it separately. __restore no
  longer carries the cache totals; __totals brings them when they arrive, so
  they are still read back from the kernel rather than restarted at zero.
- fromHistory drops two O(n²) shapes — a linear search per tool result, and a
  full copy of the folded list per item. 3200 turns: 121.6ms to 2.9ms.

/status 100ms to 15ms; transcript on screen 139-219ms to 48-125ms, and no
longer a function of how long the conversation is. perf/switch.mjs is the
bench that holds it, with PERF_STATUS_MS to separate the two waits.

Carries the rest of the in-progress studio work in the same snapshot.
2026-08-15 22:23:55 -07:00
esengine 020cc9f0c6 fix(studio): the shell pane could not reach the endpoint it reads
/shell is registered on the kernel but was missing from the window's api-path
allow-list, so the request fell through to the static assets and answered
index.html. The pane read that as a failure and said 读不到 shell 配置 — a
routing miss wearing the costume of a broken kernel.

TestEveryRouteTheKernelRegistersIsRouted guards exactly this and catches it when
reverted; it went unrun because desktop is a nested module that a test sweep over
./internal/... never reaches.
2026-08-15 08:55:22 -07:00
esengine e9ea2dbaaf feat(studio): a card an extension may redraw, and an interpreter the pane can change
Two things the workbench could describe but not act on.

An extension that publishes a view against a tool call now draws inside that
call's card. The takeover replaces the body only: the name, the status, the cost
and the failure line stay the host's, so a redrawn card is still recognisably
this call and still visibly the extension's work. The surface travels as a
first-class DTO through eventwire and uihub rather than as an opaque blob.

The shell tool's interpreter becomes a setting. It was already auto-detected —
real bash first, Git for Windows next, PowerShell only when a Windows host has
no bash — but nothing in the new UI said which one was chosen, and nothing could
change it. sandbox now exposes what auto-detection picks from (DetectShells) and
whether a chosen one actually runs (VerifyShell); the settings pane offers only
interpreters this machine really has, names what "自动" resolves to here, and
says on the row itself that Windows PowerShell 5.1 will not parse '&&'. Choosing
one persists to [tools.shell] and rebuilds the runtime, because boot binds the
interpreter into the tool while assembling it — the same path a role assignment
takes, and refused mid-turn for the same reason.

A shell card spends its mono tag on the interpreter instead of on the tool id:
the name above already says Bash, and on a host without one the command was
handed to PowerShell, which is the difference between a command that works and
the same text failing on '&&'.
2026-08-15 08:45:57 -07:00
esengine 06fe3fdc07 feat(studio): an opening sequence, and a first connection that is not assumed to be DeepSeek
The first launch asked for "deepseek 的 API key" because refreshProviderSetup
resolves the provider from the current default model, and the default is
DeepSeek. Anyone on another service had nowhere to start.

The connection step now asks the two things only the user knows — where and
with what — and lets probeProvider answer the rest. The shortcut row fills the
address and nothing else, so a service missing from it is not unsupported;
what decides that is what the endpoint replies. 中转站 / 自建 sits in the same
row rather than under a fallback, because a relay is what many people have.
A failed probe shows the endpoint's own words: 401, a wrong path and "no chat
models" send the user to three different fixes.

Before it, a sequence that plays once per machine: aurora drifting on a fixed
dark ground, the R drawn and then traced by a light, the wordmark swept by a
highlight, and three lines that say what the thing does. It does not cut to
the form — the whole group collapses into the card's header, so the brand
never leaves the screen. The progress line and the skip hint leave with it;
kept on screen beside an input they would read as "still loading".

The ground stays dark whatever the app theme is: this is a ceremony, not a
screen of the app. Any key or click skips it, reduced-motion goes straight to
the end, and a machine that already has a key gets the short cut of it.

[desktop] welcomed carries the once-only state. Browser storage would replay
the introduction whenever a cache was cleared, which reads as a bug rather
than a greeting.
2026-08-15 08:01:33 -07:00
esengine c0b67b6186 feat(providers): a switch for gateways that reject thinking parameters
A relay that does not know thinking or reasoning_effort rejects every request
that carries them, and its error rarely names the field - so the failure reads
as a broken key or a wrong path. reasoning_protocol="none" already produced
the plain-chat shape; nothing surfaced it.

The account card now carries the switch beside 联网搜索, in the two positions
a user actually has: 自动, or 不发送. Releasing it clears only that pin, so an
explicit deepseek/glm/kimi-k3 choice survives. Protocols whose wire has no
such field never offer the switch.
2026-08-15 07:38:09 -07:00
esengine c314676604 fix(agent): stop replaying a request the model simply had nothing to think about
Three defects found by running the real DeepSeek API behind a recording proxy
and reading the trajectories.

A tool-call turn with no reasoning was treated as a lost field and answered
with one exact replay of the whole frozen request. Every run triggered it,
and on a long-context turn it re-sent 29k prompt tokens to recover five
reasoning tokens. Billed thinking tokens with no text mean the value really
was lost in transit; zero billed tokens mean the model stayed silent, which
is ordinary between tool calls. The classifier now separates them, and the
exclusive reasoningObservation states replace a (missing, shouldRetry) pair
that could spell 'not missing but replay it'.

Optional sink capabilities are delivered by type assertion, so CostQuoteSink
- which forwarded Emit alone - silently dropped all ten audit channels one
link before the trajectory recorder. Trajectories recorded zero audits while
the code faithfully emitted them. capabilityContracts is now the single
authority on the set, FanOut forwards them across the split, and the three
hand-written forwarders get the RunBudget channel they had all missed.

update_goal is unreachable in a headless run, yet its 2.2k-char schema shipped
every turn and the model duly called it and was refused. Whole-session
unavailability can leave the schema without churning the cache prefix; only
per-phase visibility must not. GoalTurnsUnreachable draws that line.

A turn that took more than one request billed the sum while its context was
only the last request's, and the input column showed the sum with no hint.

Measured on the same task before and after: 12 rounds instead of 14, 51.4s
instead of 58.8s, zero wasted tokens instead of 29,085, and 14 audit records
where there had been none.
2026-08-15 07:38:00 -07:00
esengine 2d006ec456 feat(studio): plugin packages, extension view slots, and theme tokens
Working snapshot of the studio branch: installable plugin packages (plan
ticket, archive, export, install-source permission subject), the extension
protocol's view/slot rendering path, desktop theme tokens, and the file-drop
surface on the next shell.

Grouped as one commit because the pieces share the generated protocol schema
and the desktop port they land through.
2026-08-15 07:37:38 -07:00
esengine 60def0c689 fix(studio): the shell had no icon of its own, and no route to a pack's picture
macOS reads a Dock icon out of the app bundle, and the shell also runs as a
bare `go build ./next` binary with no bundle around it — so it carries the
artwork itself, the counterpart to the committed .syso that brands Windows.
The copy lives in the package because go:embed cannot reach out of it; a test
pins it to build/darwin/icon.icns, which is the one the safe-area guard reads.

The theme picker asks for /themes/{id}/preview and /themes/{id}/background.
Neither was in the shell's route table, so both answered index.html and every
pack rendered as a broken image. /inbox fell through the same gap.

The existing guard could not have caught it: it scans the typed client, and
these two URLs are written straight into an <img src> and a CSS url(). The new
one reads internal/serve's own registrations back instead, which covers both
kinds of caller. Fixing the old scanner too — it read "/export" out of a
concatenated /plugins/{name}/export, a request no one makes.
2026-08-15 05:55:11 -07:00
esengine 0447550b7e feat(theme): a pack brings its picture, and the picture knows to get out of the way
Colours were only half of what a pack is. The eight that ship each carry a
background and a thumbnail, and the manifest field describing them was being
read past — so "install a theme" meant a palette swap and nothing else.

The image is not just an image. A pack says where its subject sits (focusX/Y,
so a portrait survives a wide window), which side the content is on
(safeArea), and — the part worth having — how far the picture recedes once
work starts: the shipped packs sit near 0.95 idle and 0.18 while a turn runs.
A photo that is right behind an empty window is in the way of a transcript
being read, and the old shell had already worked that out.

Drawing it takes two fixed layers under everything and a scrim of the page
colour over the top, so text keeps its contrast whatever the photograph is
doing. The block is inert until a pack sets --bg-image: a default build paints
no extra pixels. Only the empty page shows it through — cards, panels, and the
composer stay opaque, because a background is worth a window's margins, not
its content.

Assets are served by id, and the id arrives as a URL segment, so what can be
read is bounded twice: the id must be a bare name, and the filename is always
<kind>.<known extension> beside the manifest. A pack cannot name a path.

1.8MB of webp joins the binary. That is the eight palettes being complete
rather than a format with no example of itself — and the thumbnails are what
let the picker show a photo-backed palette as one, before it is applied.
2026-08-15 04:45:49 -07:00
esengine dc1c73761a fix(studio): route the two surfaces the shell was still answering with index.html
The Wails shell keeps its own list of which paths are the kernel's and which
are the bundle's, so an endpoint that exists in serve but not in that list
answers with the SPA shell — JSON parses as HTML and the caller sees a broken
feature rather than a missing route. /themes and /extensions/* were added on
the serve side without it. TestEveryPathTheFrontendCallsIsRouted is what
catches this, and it was red.

/extensions goes in the prefix list, not the exact one: action and submit hang
off it the way /mcp/reconnect hangs off /mcp.

The themes_test rows get a name while passing through — the same anonymous
struct was written out three times to give the lookup helper a signature.
2026-08-15 04:25:48 -07:00
esengine 59aac33142 feat(studio): one completion engine behind @ and /
The composer parsed its own grammar, so the window's "@" meant something the
kernel had never agreed to — it had no way to read a directory at all — and "/"
listed only skills and custom commands, hiding every built-in the Submit path
already dispatches. control.Complete now answers all three menus: which token
the caret sits in, what may replace it, and the span to replace. The CLI reads
it too, so the terminal and the window cannot drift apart. serve exposes it as
GET /complete and converts the kernel's byte offsets to the UTF-16 units a
browser indexes by, which is what a Chinese prompt needs to splice correctly.

Fixes the work surfaced:
- a hand-typed /compact with nothing left to fold reported the provider limit;
  manual carries Force, and the no-fold branch read Force as overflow
- every notice-only verb answered 409, because serve judged a submission by
  whether a turn had started and a management verb starts none
- reopening a session showed the composed text, so a one-line @reference came
  back as the whole file it referenced
- the window had no Edit menu, so a WKWebView had no copy, paste, or undo
- an input method's confirming Enter sent the half-written message
- cancellation during a fold was recorded as a summary failure
- internal/agent's test package did not compile, hiding both of the above

The tree carried other unlanded studio work in the same files — attachments,
changes, checkpoints, rewind, themes, extension surfaces — and file granularity
could not separate it, so it lands here as well. desktop/next's route list
still omits /themes and /extensions/*, so its routing test stays red; that
belongs to the extension work, not to this.
2026-08-15 04:20:13 -07:00
esengine e916576bcd fix(desktop): drop the receipt field the kernel no longer has
99ef4b2f0 removed ContextMaintenanceReceipt.Archive and updated the old
shell's TypeScript mirror, but not its Go side. desktop is a separate module,
so the main module's build never saw it: `go build ./...` at the repo root
passes while the desktop module does not compile at all.
2026-08-15 04:16:40 -07:00
esengine 4df4cc34eb fix: commit the three files HEAD already refers to
Three finished files were never added, and the commits that use them went in
without them. On any clean checkout — CI, a fresh worktree, another machine —
the module does not build:

  eventwire/wire.go calls e.Tool.ContextTokens(), defined in
  event/tool_cost.go, which imports internal/tokencount
  state/session.ts uses sample() and outWindow, from port/tokens.ts

Local builds pass because the files are sitting in the working tree, which is
exactly why this survived several commits.

They are complete as they stand: tokencount carries a package comment and a
test, and its by-script split is the fix for the estimator that read ASCII as
four times its real cost. Nothing here is new work — this is the add that
should have ridden along.

The frontend still does not typecheck after this: SsePort is missing changes
and attach, which port.ts declares. That is in-progress work, not a missing
add, so it stays where it is.
2026-08-15 04:15:18 -07:00
esengine 50fb4a0fe1 feat(theme): make appearance a pack the user installs, not a build constant
studio's palette was compiled in: tokens.css held one light set and one dark
set, and nothing could change them without a rebuild. The old shell has had
theme packs for a while — a directory with a theme.json, colours only — so the
format already exists and eight of them ship. What was missing is a reader that
does not live inside the old shell, and endpoints for the new one.

internal/theme reads packs from <memory root>/themes. Two things it refuses:

  - a pack from a schema it does not know is skipped rather than half-read,
    and one bad pack cannot hide the rest of the list
  - a token that is not a plain hex colour is dropped, not escaped. The value
    ends up interpolated into a stylesheet by whoever renders it, so url(),
    an expression, or a stray brace never reaches that point

GET /themes carries every pack's whole token set, not just the active one's:
a picker that must fetch before it can preview cannot preview on hover, and
the payload is a few hundred colours. POST /themes activates one, and an empty
id is a real choice — it is how a user turns a pack off.

The frontend half (in progress separately) maps a pack's names onto its own
variables and paints them at runtime. What a pack may not touch is fixed
there: ok/warn/err/net/deleg encode what is happening — "this broke", "this is
running" — and a theme that could recolour them would let a failure render as
success. The palette is the theme's; the meanings stay the app's.

Adding one config field turned out to need the renderer too: RenderTOMLForScope
writes the file field by field, so a struct field it does not know about is
silently never persisted. The [desktop] table moves into its own function and
file on the way past — it was the longest run in a 777-line function, and
DesktopConfig follows into its own file, which puts both back under the size
ceiling instead of one line over it.
2026-08-15 04:10:06 -07:00
esengine 9363add18f feat(extension): let an extension hold a place in the rail, not just scroll by
An extension could publish a card, a form, a notification, a status line —
every one of them a thing that happens and then scrolls away. What it could not
do is describe something that is still true: a watcher running, a sync at 40%,
a connection holding. That state belongs in the side rail, and there was no
surface kind for it.

UISurfacePanel is that kind, and it is a kind rather than a new RPC on purpose.
The method registry is frozen; growing it per feature is what turns version
compatibility into every extension author's problem. A new surface travels the
publish path that already exists, so an extension built against an older host
degrades to an unknown kind instead of a broken call.

UIPanelPayload carries no Markdown, unlike UICardPayload. The rail is a narrow
column and a rendered document there costs the layout more than it tells the
reader; an extension with a long story to tell publishes a card into the
transcript, which is what the transcript is for.

Registering the kind is what actually admits it: protocol.enumTypes is the one
table the strict decoder, the JSON Schema, and the generated SDK all read. The
generator's own guard caught the rest — a surface kind with no matching
UI<Kind>Payload in the SDK fails generation loudly, which is how it should
have gone. Regenerated artifacts follow, schema hash included.

The Extension* types move out of event.go and eventwire/wire.go into their own
files while passing through: both were already over the size ceiling, a new
surface kind touches every one of those structs, and the two conversion blocks
are the only thing in eventwire that is not a one-liner.
2026-08-15 03:39:28 -07:00
esengine 78a07a4e32 feat(serve): give studio the interactive half of an extension's UI
Extension surfaces already reach every frontend on the event stream, but the
user's half of the exchange — invoking an action a card offers, submitting a
form an extension published — needs endpoints, and serve had none. uihub's
consumers were boot, cli, acp, and control; the HTTP surface the new desktop
runs on was the one place a plugin could talk to a user but not be answered.

Controller already exposed what this needs. Two of the three were on
SessionAPI; SubmitExtensionForm was written for "stage-8b frontends" and had no
caller anywhere, so it joins the port alongside the two it belongs with.

Three routes, in the shape the other feature files here use:

  GET  /extensions/actions   the handshake-declared actions, empty when no
                             runtime is up — a state, not a failure
  POST /extensions/action    invoke one, answering with the extension's own
                             (hub-redacted) message
  POST /extensions/submit    a published form's values, types intact

A declined action answers 422 with the extension's reason rather than a 500:
the extension refusing is its answer, and the caller should show it. Form
values keep their JSON types because the extension declared the field kinds
and decodes against them — a confirm stays a bool, a number stays a number.

The studio side (renderer, event wiring, port methods) sits on top of
frontend-next work that is still in progress, so it lands separately.
2026-08-15 03:18:31 -07:00
esengine 4b8eb55825 perf(serve): list sessions from the sidecars, like every other lister already does
GET /sessions read the directory itself and called agent.SessionPreview on each
file, which decodes the whole transcript to count user turns. Studio's session
list is O(sessions x transcript size) on every refresh, and it recomputes from
scratch each time.

agent.ListSessions exists for exactly this and says so: "It never decodes a
transcript." The sidecars it reads were introduced to fix this same load in the
old shell's sidebar; the HTTP endpoint the new one runs on never picked them up.

Behaviour moves onto that shared path, which changes three things, all toward
what the other listers already do:

  - order is by last activity instead of reverse filename
  - a session with no user turn stops appearing (it has no title and no count;
    it was rendering as an empty row)
  - a legacy session whose sidecar has no counts yet shows the "History is being
    indexed" placeholder until the catalog's repair worker validates it

The subagent-name filter stays here: IsVisibleSession deliberately does not
fold it in, because GC and redaction still have to see those files.

The new test corrupts both transcripts after saving and asserts the endpoint
still reports 1 and 2 turns — a reader that parses transcripts cannot pass it.
2026-08-15 02:50:38 -07:00
esengine 37f7ca64af refactor(serve): delete the cross-origin opt-in nobody opted into
HandlerWithCORS has no caller. Its own doc says "Do NOT use in production — the
server has no auth", which makes an exported, unused, security-sensitive entry
point the worst kind to leave lying around: the next person wiring a dev
frontend finds it before they find the reason it is wrong.

Neither frontend needs it. frontend-next proxies through its own origin
(vite.config.ts says so explicitly: "The dev server is cross-origin to serve,
which its CSRF guard rejects"), and the old Wails shell serves same-origin.

It was also already inconsistent with the routes: Access-Control-Allow-Methods
listed GET, POST, OPTIONS while inbox has served PATCH and DELETE since it was
added, so anything that did opt in would have been blocked by preflight on the
endpoints it most needed.

Deleting it leaves same-origin as the single stated protection for
unauthenticated endpoints, which is what the csrfGuard comment already assumes.
2026-08-15 02:39:18 -07:00
esengine ec984e6b28 fix(context): stop a server-run tool from stranding the session
A turn where the provider ran its own web_search left the session permanently
unusable: every following turn failed with the same numbers and never
recovered. Reproduced with a real key against a 20K window — turn 2 searched,
turn 3 onward died on "candidate would not reduce tokens".

The cause was not compaction. DeepSeek bills its fetched pages as input
(usage.server_tool_use.web_search_requests says how many searches ran, a field
wireUsage was dropping), so PromptTokens on such a turn counts content that
never passed through us. Learning the chars-to-tokens ratio from it inflated
every later estimate by roughly 3x, which read as overflow, asked for a fold
that cannot shrink what we never held, and refused the turn — and a refused
turn sends no request, so the observation never updated and the next turn
decided identically.

Three changes. Usage now carries ServerToolRequests and the calibration
declines to learn from a turn that has any. Where a failed fold would strand
the turn, a transcript that fits under the ceiling is sent anyway and the
provider rules. And oversize tool output goes to <session>.outputs/ with the
context keeping a pointer plus twenty head lines, because a head+tail sketch is
a guess about which part mattered made by the wrong party — read_file windows
the file and grep searches it. That threshold is window-relative: a fixed 32KB
is a rounding error in a 1M window and larger than a 20K one. Windows at or
above ~128K keep 32KB exactly.

Verified end to end on the same sequence that died: six turns, no failure, and
context tracking our own transcript (6.8K) rather than the provider's billed
input (27K). A 728KB bash result reached the model as 581 characters, which it
read correctly as "120001 lines, 712 KB, saved to a file, not lost".
2026-08-15 02:32:47 -07:00
esengine 0f5fb15c03 refactor(recovery): drop the resume assessment only its own test called
AssessRuntimeResume and ResumeNotesForGeneration wrap extension.DecideResume
and format "doctor-facing notes". No doctor calls them; nothing outside
recovery_test does. boot/doctor_runtime.go, the real doctor path, calls
extension.DecideResume directly and formats its own report — so this was a
second formatter for a decision the actual caller already reaches without it.

Removing them also drops recovery's only direct import of internal/extension.
2026-08-15 02:29:47 -07:00
esengine 5a48527ac8 refactor(pluginspec): put the spec mapping below the frontends that need it
internal/capdiag imported internal/boot for exactly one call —
PluginSpecsForRoot — which made it the repo's only layering violation, carried
in the repolint baseline. capdiag is a diagnostic: it describes configured MCP
servers, it does not assemble a runtime. The rule it broke ("nothing below a
frontend may import one") was right; the placement was wrong.

Turning configured entries into plugin.Spec is a pure mapping over config,
plugin, sandbox, and mcplaunch — every one of them below boot. It sat in the
assembly layer because that was the first caller, not because it belonged
there. internal/pluginspec now owns it, and boot, cli, capdiag, and desktop all
reach the same function at a level each of them may legitimately import.

boot keeps what is genuinely assembly: partitionByTier reads a startup policy,
skillMCPBindings needs the live tool registry. Its pluginspec.go drops from 230
lines to 60. The mapping tests move with the code they cover.

appendUniquePaths and pathComparisonKey are copied verbatim rather than
simplified: they compare paths by cleaned, absolute, symlink-resolved,
case-folded-on-Windows form, and a shorter version would silently change which
sandbox roots dedupe.

The baseline loses its layering entry and the limit goes to 0, so the next
violation of this rule fails CI instead of joining a tolerated list.
2026-08-15 02:22:45 -07:00
esengine 31d377957e feat(recovery): stop counting to a number the user never picked
Auto recovery and the guardian each stopped the turn on a count. Recovery's
was 3 failures of one operation, 6 per Episode, 3 reviewer rejects, 3 retries
of an already-stopped operation; the guardian's was 3 consecutive denials or
10 in the last 50. Both then interrupted: recovery burned a summarize-only
round and returned "Automatic retries paused. Send continue to start a fresh
attempt", the guardian injected "stop the current approach and request
explicit instructions".

Neither number came from the user, and neither measured what it claimed. Six
failures is a long debugging session in one codebase and a runaway in another.
The reviewer rejecting three proposals means the reviewer disagreed three
times, not that the work is unrecoverable. What the host actually did with
those counts was take the turn away and ask the user to type "continue" —
which is the user's decision arriving as a chore.

What survives is the part that reads the proposal in front of it: Decide still
routes an ordinary mutation to allow and an ambiguous retry of the same failed
operation to the reviewer, and the reviewer's own reject still blocks that call
with its rationale. A repeat now goes back to the reviewer instead of hitting a
ceiling. The guardian still denies, still forces deny on critical risk and on
high risk without authorization; it just no longer stops the turn on top of
that verdict.

Per-operation failure counts stay — the reviewer and the approval card read
them as evidence ("this exact call has failed N times"). They no longer arm
anything.

With this, RouteStop/RouteStopTurn, the Episode budget, StopReason,
RecoveryPauseError, and the finalization round it drove have no producer, so
they go. event.TurnOutcomeRecoveryPaused stays readable: sessions recorded
before this still carry it.

Nothing else bounds a turn by default (agent MaxSteps is 0 unless the user sets
one), so an Auto run that keeps failing now keeps consulting the reviewer until
the user interrupts it. That is the intended trade.
2026-08-15 02:07:20 -07:00
esengine 935e0eb9e5 docs(preset): say what the host does now, not what it did before two removals
Ten declaration comments still describe a three-setting product ("light|
balanced|delivery", "all three role settings") that one setting left, and two
describe an injection that no longer happens: DeliveryRuntimeMarker's doc says
the agent appends it to user turns and calls its text cache-frozen for every
live delivery session, twelve lines above the code that stopped appending it.

A comment that names a mechanism is a claim the reader cannot check without
reading the whole path. Both of these sent the reader looking for a caller
that is not there.
2026-08-15 01:36:28 -07:00
esengine 0c97a05e6f refactor(capability): route what the user named, not what the host guessed
Three word lists decided which capabilities got a prefer in the turn's route
block. looksLikeReview matched 审查/有没有问题 and hardcoded the built-in named
"review". looksLikeGitHub promoted every tool of any server whose name contains
"github" when the sentence said "issue" or " pr ". looksFreshData promoted any
search/fetch/read tool when it said 最新 or 查一下 — a list that also contains
"github", so a sentence naming the vendor scored twice.

Those are guesses about intent, and a prefer is not free: it takes one of five
route slots and tells the model to use the capability unless clearly
unnecessary. "查一下 GitHub issue 里有没有相关反馈" spent slots on every github
tool before anything read the request.

What stays is what someone actually stated: the user naming a skill or an MCP
server, and triggers the skill's own author declared in frontmatter. When
neither fires, the decision belongs to the semantic router, which reads the
whole request instead of a word list.

NeedsFreshData goes with them — it was parsed from needs-fresh-data, carried
through skill.Skill into capability.Entry, set by one built-in, and read by no
decision anywhere. negativeMatch was triggerMatch under another name.
2026-08-15 01:30:20 -07:00
esengine b53b2e31e2 fix(taskpolicy): let the receipts decide the review, since nothing set the risk
When the keyword risk table retired, Risk kept its three levels and lost every
input: HighRiskHints and MediumRiskHints are set by tests and by nothing else,
and RaiseRisk has no production caller. Derive has answered RiskLow for every
turn since. Two gates read that constant and both failed open.

Explore: AllowExploreSubagent is `preset && risk > RiskLow`, so it was false
under both role settings, and taskPolicyToolGate blocked every explore/research
call with "the current task policy does not allow proactive explore/research
sub-agents for this turn." The setting that was supposed to permit them said
true the whole time.

Review: Review is ReviewForRisk(low) = ReviewNone under both settings, and
deliveryReviewGateFailure opens with `case ReviewNone: return ""`. Delivery's
stated contract — review on medium-risk mutations, security_review on high —
never ran. Its tests pass because they build an Agent whose policySet is false,
which is the one state a real turn never has.

So risk goes, and MutationRiskAfter stays: what a change owes is read off the
receipts it produced, which is the only source that ever knew. Route follows —
it had no reader outside the policy block, planner routing having moved to
"did the user ask for a plan". SemanticRouterAllowed follows; Delivery decides
the router directly, which is what the non-planner path already did.
RequireFullVerification goes too — parseConstraints never set it.

The new capability_gate case freezes a real Derive policy before calling the
gate, so the next constant that closes it fails a test instead of a release.

Verification block drops from four fields to one; PolicyVersion bumps to 2.
2026-08-15 01:24:27 -07:00
esengine 99ef4b2f0d refactor(compaction): remove the archive path no fold has written
Compaction receipts carried an Archive field, the wire carried it, and both
frontends rendered it — the CLI as "archived <path>", the desktop card as
",原件留在 …". Nothing has assigned it since summary checkpoints replaced
archiving: canonical keeps the originals, so there is no second copy to name.
Every reader was reading the empty string.

Four tests asserted it stays empty. They passed against a field that could
not be anything else, which is the shape a guard takes once what it guarded
is gone.
2026-08-15 01:04:38 -07:00
esengine ab9c3f5eb7 refactor(capability): delete the profile filter that stopped filtering
Deleting the light setting exposed the chain behind it. SkillEntriesFiltered
opened with `_ = profile`: it took the runtime profile and threw it away, so
the name promised a filter to every reader of the catalog. FilterForProfile
had no callers at all, AllowedInProfile had only that one, and the Store field
those fed — runtimeProfile — was written on every boot and read nowhere.

So the whole path existed to carry a value to a function that discarded it.
It is gone: skillCatalogEntries says what it does (skill entries plus the
frontmatter a reader sees), and boot no longer threads a profile through skill
assembly to reach it.

capability.Profile stays. Its constants are still the allowed-value list for
normalizeProfiles, and controller keeps it as a diagnostics label — but the
type and the CatalogOptions field now say they select nothing, because a name
that claims to filter is how this got missed for so long.

A skill's `profiles:` frontmatter is unaffected: it parses, it is reported,
and it has not hidden a skill from the model for some time.
2026-08-15 00:53:37 -07:00
esengine b8d56d6b13 fix(studio): describe the settings by what the host actually enforces
"不是省钱档位" answered a question nobody asks now that light is gone, and
"规划深度"/"按复杂度自适应" described the planner route — the one dimension
that only ever reaches the model as a line of prompt text, with nothing in the
host to enforce it.

What separates the two settings is who decides a turn is finished: the model,
or the host reading its own receipts. The copy says that now, and delivery
lists the four things it checks instead of naming our internal term for them.
2026-08-15 00:44:05 -07:00
esengine 79f8591b87 test(desktop): switch to a setting that is still a different one
These tests used "economy" as "switch to something other than the default"
— for the tab profile, the persisted session profile, the rebuild event, the
in-place switch guards. It resolves to the default now, so the calls became
no-ops: nothing was saved, and the guards that fire on a real change never
fired. Delivery is the setting that is still different.

The desktop module is not built by the root module's ./..., which is why
these missed the earlier sweep.
2026-08-15 00:39:01 -07:00
esengine 223f12c0a4 fix(studio): drop the retired setting from the title bar too
The title bar's switcher carries its own PRESETS list, separate from the one
in settings, and it still offered 轻量. Sending it was harmless — the kernel
resolves the name to balanced — but the button's pressed state compares
against what the kernel stored, so it could never light up: clicking it looked
like nothing happened.
2026-08-15 00:29:51 -07:00
esengine df78e60c08 feat(git): let a machine without git still install and still be told why
Two problems. A machine with no git could not install a plugin at all —
`git clone --depth=1` was the only path to a GitHub source. And on macOS a
machine without the command line tools claimed it *had* git: /usr/bin/git
is an Xcode stub that resolves on PATH and fails only when run, so the
LookPath check guarding worktree isolation read as "installed".

gitcmd.Available runs git rather than finding it, and the worktree lease
asks it. It is uncached on purpose: every caller is a low-frequency
decision, and caching the "no" would make installing git afterwards
require a restart.

A plugin source falls back to the GitHub tarball when git is absent. The
commit the approval contract pins comes from the archive root, so apply
still cannot swap in a tree the user never reviewed. The unpacker drops
symlinks — a link may point outside the tree — refuses entries that escape
the root, bounds each entry and the total against a bomb, and skips the
pax_global_header GitHub writes first, which the first draft mistook for
the archive root until a test said otherwise.

doctor gains a git section stating the absence as a fact and naming what
degrades (the changes list, worktree isolation) while saying rewind,
checkpoints and every tool keep working. Nothing else in the kernel touches
git: agent, control and tool have no reference to it, and checkpoints were
already git-free by design.
2026-08-15 00:10:22 -07:00
esengine c898679ede refactor(preset): two execution settings, and only fields that do something
The role settings declared far more strategy than they enforced. Six fields
had no reader anywhere — RequireAtomicContract, SemanticRouterOnAmbiguity,
DeterministicFirst, PreferProxy, DiffReview, AllowPartialWithoutChecks —
including two that were the stated difference between delivery and balanced.
A policy table that names a contract nothing builds is worse than a smaller
one: it tells the next reader a guarantee exists.

Light goes with them. Against balanced its only enforced differences were two
sub-agent switches (explore agents, semantic capability routing); the things
its name promised — cheaper verification, lighter review — were identical
settings on both. Its high-risk elevation moves to balanced so no user's floor
drops, and its capability profile selected an economy catalog that
SkillEntriesFiltered discards on its first line.

That leaves one question for the user instead of two: default, or delivery —
where the host enforces acceptance criteria, sign-off, verification, and
review off its own receipts.

"light", "economy", "eco", "lite" all still parse and answer balanced, so an
existing config or an older client is never rejected. They no longer pass
IsValid: they resolve, but nothing writes them back out.
2026-08-15 00:04:53 -07:00
esengine e4e6936b1d feat(control): finish what a turn owes instead of asking the user to say "continue"
A turn that ends owing verification has not failed. The host read what is
missing off its own receipts — not off anything the model claimed — and then
stopped to show a card whose only action was "continue". That asked the user
to relay a message the host had already written.

Goal mode has auto-continued on exactly this signal all along; the difference
was a goal being set. It no longer is: an ordinary turn that ends with unmet
requirements runs them as further turns, with the missing list as the prompt.
Nothing new judges intent — readiness is the same host-computed check, and a
turn that changed nothing owes nothing, so questions and reads still end in
one turn.

What bounds it is the gap itself. Each round is admitted only while the list
of missing requirements is still shrinking, measured against the best round so
far so an oscillating gap cannot reset the counter. Two rounds that owe what
the last one owed hand the turn back, card and all.

The continuation inherits the finished turn's ledger through the same
authorization the recovery card used. Without it the next run would start
empty, where a turn that owed verification owes nothing — the gap would read
as closed because the record of it was dropped, which is worse than not
continuing at all.

PrepareReadinessContinuation lands in final_readiness.go rather than agent.go,
which is over its size budget.
2026-08-14 23:38:40 -07:00
esengine 346a258559 feat(provider): show a provider-run search as the call it was
DeepSeek's Anthropic face runs web_search on its own side, and its results
were formatted straight into the answer text. Forty titles and URLs pushed
the actual answer off the screen, and no tool card ever existed — the
spec's Search card had nothing to draw from.

Results now leave the provider as ChunkProviderTool, the query paired with
the listing that answered it, and the agent reports one finished
ToolResult. Result only, no dispatch: the call arrives finished, and a
dispatch is the one event deferredStreamSink holds back, so it would land
after its own result and never settle the card. The listing still joins
the turn's text — the only copy the next turn has, because the provider
keeps the result bodies encrypted — so request bytes are unchanged and the
prefix stays cache-stable.

web_search moves into its own file and Chunk out of provider.go, which
pays back the file-size, function-size and essay budgets the two new
stream branches would otherwise widen. The baseline carries only their
complexity: 45 to 47.
2026-08-14 23:34:30 -07:00
esengine 5f3c423794 refactor(control): name the turn path after turns, not after goals
Controller.Send has always run its turn through runGoalLoop, and the name
made a goal sound like a precondition for sending a message. It is the other
way round: every ordinary message takes this path, and the Goal FSM answers
"do not continue" the moment it sees no active goal — one nil check, then the
turn ends like any other.

Reading Send meant learning the goal subsystem first to find out it was not
involved. runTurnLoop says what happens, and two doc comments say where the
goal actually enters: after the turn, as the thing that may ask for another.

Pure rename plus those comments; no behaviour change.
2026-08-14 23:06:19 -07:00
esengine 6628e317e2 refactor(goal): retire the research archive nothing has written since unification
internal/autoresearch was a read-only compatibility boundary for Goal
sidecars naming a pre-unification .reasonix/autoresearch task. Its own doc
comment says new Goal runs never create, update, list, or expose those
archives — and searching the filesystem finds no archive to read: the
package and the machinery around it served zero files.

What went with it is everything that existed to keep such an archive
recoverable: the fail-closed restore path (a blocked Goal that could only be
unblocked through the archive boundary), its retry token on Controller, the
legacy-archive stop cause, and the FSM's pending-migration task identity.
SetGoal collapses to what it always did for every real goal — install the
text — since the archive was the only thing that could rewrite it.

One field stays: goalState.AutoResearchTaskID is now read-only migration
detection. A sidecar still naming a retired archive is rewritten as ordinary
Goal state on first write, which is how the last of them disappear.

The <autoresearch-evidence> stripping in agent/goal_display.go is unrelated
and stays: it scrubs a historical protocol marker out of model output, not
storage.

Net: 1799 lines of package and manager, plus ~250 of restore machinery.
2026-08-14 23:04:00 -07:00
esengine 19dbed611e feat(studio): let the fold show its digest being written
The card said "正在压缩…" and nothing else for as long as the summarizer
took, which on a large fold is a minute of a placeholder that cannot be told
apart from a hang. The digest now streams onto the card as it is written.

It accumulates on the card's own summary, so the finished event replaces it
rather than fighting it — and a fold that dies mid-write leaves what it had
written instead of an empty placeholder. Only the newest line is shown: a
digest is thousands of tokens of headings and lists, and growing the card by
all of them would move everything below it for the length of the fold.
2026-08-14 22:37:55 -07:00
esengine d360449e95 fix(serve): persist the model a switch selected
POST /model rebuilt the running controller and wrote nothing, so the choice
lasted exactly as long as the process. The next launch read default_model and
went back to whatever was there before — switching to an account's Anthropic
door and finding it on OpenAI again after a restart, with no sign the switch
had ever worked.

The CLI has persisted its /model since it had one, and the old desktop's picker
sets the config default alongside the live switch. Serve was the entry point
that did not, which is every studio window.

A refused write is logged and nothing more: the live switch already succeeded,
so failing the request would report the model unchanged when it had changed. A
ref that resolves to nothing leaves the existing default alone rather than
writing a value the next launch cannot start from.

Cache-impact: none - the prompt prefix is untouched; this writes default_model
to the user config after a switch that already happened.
Cache-guard: not applicable; no provider-visible bytes change.
Documentation-impact: none - no docs/*.md states whether the desktop model
picker persists, and it now matches the CLI's documented /model behaviour.
2026-08-14 22:37:39 -07:00
esengine 8a8590d629 feat(compaction): show the digest being written instead of a minute of silence
A fold holds the turn for as long as the summarizer takes, and all the user
had was a placeholder that says the same thing at second one and second
ninety. The text was already streaming — summarize consumed provider chunks
into a builder and forwarded none of them.

CompactionProgress carries each chunk on Event.Text, which is what lets the
existing coalescer merge it: it treats an event as a stream delta when only
Text carries meaning, so the digest batches at 16ms like reasoning does
rather than spending a frame per token.

The CLI shows the newest line of the digest on the "compacting…" line itself,
bounded, and replaces it with the finished card. Only the tail: a digest is
thousands of tokens and the card is about to render it anyway.

The three compaction cases fold into one handler, which takes 19 lines and
two branches out of chatTUI.update and lands them in a file that is about
compaction.

Repolint baseline: mostly tightened by today's work (complexity 2058→1994,
essays and function sizes down with it). One entry loosens — desktop's
types.ts gains the line for the new wire kind, which the shared-kind guard
requires and which no local edit can offset in a 2328-line type file.
2026-08-14 22:37:01 -07:00
esengine 918c829c70 feat(studio): group the composer's model picker by account and door
The picker was models.map with the config entry's name as the subtitle, so a
DeepSeek account reached under two protocols listed four rows that all said
deepseek. Which one was the Anthropic route was not on screen anywhere, and
that route is the one carrying web search.

Rows now sit under the account they belong to — host beside the name, because
the name is the config's word for an entry and two doors share it — and carry
the protocol that reaches them. Both labels appear only where they resolve
something: one source with one door stays a plain list of models, and the
account caption is omitted entirely rather than stating the obvious.

Menu grows a header item for it. plain was the closest thing available and it
is still a button, so a caption would have taken focus and made the list twice
as long to arrow through.

Documentation-impact: none - no docs/*.md describes the composer's model picker.
2026-08-14 22:28:11 -07:00
esengine a6e14056cd feat(studio): show what a fold cost and what its digest kept
The kernel started reporting a compaction's sizes and coverage; this is the
card reading them. A digest reads as finished whatever it dropped, so the
count of the fold's changes it carried is the one thing the card can say that
the summary text cannot — and it is stated whether or not anything is
missing, because a number that only appears on failure is a number nobody
learns to read.

The bar and the checklist are the .comp-bar / .comp-l the stylesheet has
carried all along with nothing to fill them. The bar's fill is what the fold
gave back rather than what it kept: the fill is the ok colour, so more of it
has to mean more room.

An older event carries none of these fields and renders exactly as before.
2026-08-14 22:14:48 -07:00
esengine 15cae8b9e5 feat(studio): put web search on the door that carries it
DeepSeek runs a server-side web search on its Anthropic and Responses wires and
not on chat completions, so one account answers differently through each of its
doors. Switching 接入方式 could take the capability away with nothing on screen
about it — a silent downgrade dressed as plumbing.

The door that has it says so, on itself. Picking the other one explains that the
difference is the protocol rather than a setting, because nothing in the panel
can turn it on there. Where a door does carry one, its tri-state gets a switch:
official DeepSeek endpoints default it on and a compatible third party stays
opt-in, so the effective answer is worth showing rather than inferring.

This is the argument for a connection-level 接入方式 in the first place. The
doors are not interchangeable transports; which one is open decides what the
agent can do, and that belongs where the account is configured, not hidden per
model row.

Documentation-impact: none - no docs/*.md describes the settings pane's
connection controls or which wires carry a provider-executed search.
2026-08-14 22:08:47 -07:00
esengine e8d7653090 perf(compaction): stop re-reading the whole transcript to prove it did not change
A projection is valid only while the canonical prefix it folded is unchanged,
and the check for that fingerprints every message behind the fold. It ran on
every read of the model-visible view — the turn's prompt, the status gauge,
the compaction check — so the cost of one turn tracked the whole history
rather than the projection, which is the thing compaction exists to bound.

On a 40k-message transcript, measured:

    modelVisibleMessages   41.8ms → 0.80ms     83MB → 14MB per call
    ContextUsedTokens      1.42ms → 0.015ms
    prompt assembly        41.1ms → 1.02ms

The transcript grows append-only, so a folded prefix can change in exactly
one way: a rewrite. The fingerprint is now remembered under the rewrite
counter it was taken with, passed in from the snapshot rather than read
fresh, so a rewrite landing between the snapshot and the check misses the
memo instead of silently validating it. A new conversation clears it — the
counter restarts, and a carried entry could answer for history this session
never had.

What remains linear is the snapshot copy itself (14MB), which is a slice of
headers, not a re-serialisation.
2026-08-14 22:04:43 -07:00
esengine 4b61655aca fix(studio): do not offer a 读图 switch the kernel will refuse
CanConfigureVision returns false for DeepSeek's official host, and
EffectiveVision short-circuits on it, so no vision_models list, provider flag or
per-model override can turn image input on there — their API takes text content
only. The editor offered the toggle anyway: it wrote the config, the config was
ignored, and nothing on screen said why.

The list reports whether the endpoint can be configured for images at all, and
the toggle goes flat where it cannot, with the reason on the row. A switch that
writes a value nothing reads is worse than no switch.

The re-probe button also said only "重新探测模型", which describes the
mechanism and not the occasion. It now says what it is for: asking the endpoint
again for its model list, after it has added or dropped one.

Documentation-impact: none - no docs/*.md describes the source editor's controls.
2026-08-14 21:59:03 -07:00
esengine 65afa0eecc feat(studio): a second door onto one account reuses its key
The connection list groups on host and credential, so two entries only read as
one account when they share a key slot. providerKeyEnv derives that slot from
the entry's name, which meant adding the same endpoint under the other protocol
produced DEEPSEEK_ANTHROPIC_API_KEY beside DEEPSEEK_API_KEY: two accounts, one
of them with no key at all. The 接入方式 switch could never appear for anything
the panel itself had added.

A blank key at a host that already has one now inherits that slot. Blank is the
signal because it is already the panel's word for "keep what is stored", and it
distinguishes the two things a second entry at one host can be: another door
onto the account, or a different account there — two tenants of one relay, where
sharing the slot would overwrite the first tenant's key. A supplied key keeps
its own slot for exactly that reason.

The form says which one it is about to do, rather than leaving the user to infer
it from whether a field is empty.

Documentation-impact: none - no docs/*.md describes how api_key_env is chosen
when a source is added from the panel.
2026-08-14 21:50:47 -07:00
esengine baaea20023 feat(compaction): say what a fold cost and what its digest kept
Coverage has been measured on every fold since it became an acceptance
criterion, and it went to telemetry and nowhere else. The user saw a digest,
and a digest reads as complete whatever it dropped — the one thing its text
cannot tell you is what is missing from it.

CompactionDone now carries the sizes and the coverage counts, and the card
renders them: "128.0K → 31.2K · 10/12 changes kept (repaired)". A fold with
nothing to carry prints no quality line rather than a hollow 0/0.

The card moves out of chat_tui.go into its own file on the way past.
2026-08-14 21:44:56 -07:00
esengine 9487514bfd fix(studio): say what is running after an approval, and re-probe with the stored key
Approving a tool left the run strip reading 等你批准 for as long as the tool
then took. A 42-second OCR command spent all of it claiming to be waiting on
the user, which is the one line watched to know anything is happening at all.
The decision now hands the label back to the tool it released.

Re-probing a saved source sent the key field, which is deliberately blank when
the user is not changing it, so it probed as a provider with no credential and
failed before reaching the host:

    fetch models: provider "probe" has no API key (set in .env)

A blank field means "keep the stored one", so the refresh goes through
/providers/check, which already uses what is saved. That route now answers with
the model names rather than a count, since a refresh needs the list.

One name for one thing: the add form asked for 协议 while the connection list
calls it 接入方式. Its note also still told the user to come back and switch
protocols in place, which was removed for being unsound — two protocols answer
the same model listing but their chat endpoints are different paths, so the
advice now says to add the second one as its own source.

Documentation-impact: none - no docs/*.md describes the run strip's labels or
the provider check payload.
2026-08-14 21:42:15 -07:00
esengine 2f4cf63aed feat(compaction): widen the fold index now that something reads it
The index was budgeted at half a percent of the window on the assumption
that its lines were a hint. They are addresses now, and the count of them is
the reach of recall: what falls off the end is what can no longer be asked
for. A percent buys roughly a hundred entries, shared across generations
since the index is cumulative.

It stays the cheapest thing a fold keeps — prose spends hundreds of tokens
to say what a line says, and prose cannot be recalled.
2026-08-14 21:40:53 -07:00
esengine 12b7dcdb56 refactor(compaction): leave one ruler, so a budget cannot be spent in the wrong unit
estimateTextTokens returned characters under a name that said tokens, and
estimateMessagesTokens summed it. Every remaining caller compared that against
a budget carved from the context window in real tokens, and three of them were
wrong by the factor between the two units:

- the verbatim tail kept a quarter of what the window had room for (measured:
  7931 tokens against a 32768 budget), so recent turns nobody asked to lose
  went to the summarizer instead of staying in context;
- a fixed prefix of 30K tokens was read as 120008 and refused compaction
  outright, which leaves the context growing to the hard ceiling;
- an interrupted request's reconstructed billing line ran ~4x high.

Both estimators are gone. Everything sizes messages with estimatedPromptTokens
and bare text with textTokens, which is what the trigger, the acceptance test,
and the output clip already used. foldEconomics keeps its old threshold in the
new unit (400 characters is 100 tokens). CompressResult's source_tokens and
projection_tokens now mean tokens to the model reading them.

The two budgets that were wrong now have tests, and both fail if the estimate
is put back in the wrong unit.
2026-08-14 21:28:38 -07:00
esengine 3c2843ff11 feat(studio): edit a source, and say which of its models read images
A saved source could only be deleted and re-added, and which models read images
was whatever InferVisionModels guessed from their names at add time. That guess
is names-only by design — it cannot know what a relay serving gpt-4o under some
private id can do — and there was no way to correct it afterwards.

Each connection now opens an editor: endpoint, key, which models to keep, which
of them read images, and which is the default. The key field left blank keeps
the stored one, so changing a model list does not mean re-pasting a secret.

The edit merges rather than replaces. UpsertProvider assigns the whole entry, so
building one from this form would silently drop every field the form cannot show
— per-model prices, effort vocabularies, context windows, headers, the preset it
came from. A source with two priced models would have come back with none.

The vision list is the whole answer when it is written: the provider-wide flag
answers for every model a whitelist omits, and a per-model override beats both,
so a toggle that only wrote the list would be a switch that changes nothing.
/providers reports the resolved per-model answer for the same reason — the form
shows what is true now instead of asking the user to remember it.

Documentation-impact: none - no docs/*.md describes the settings pane's source
editor or the provider list fields.
2026-08-14 21:27:35 -07:00
esengine 31fecad954 feat(compaction): give the fold index a reader
Every fold has left an index of #n addresses since the last change, and
nothing could read one. This is the tool that does: recall names canonical
positions and returns them, with the tool results that answered a call, so
an address is a way back rather than a headstone.

Two things keep it from undoing the fold that made room for it. A recall
draws on a budget worth a tenth of the window, and a request past what is
left is refused whole rather than trimmed — half a span reads as the whole
of what was there. The ledger carries the generation it belongs to, so each
compaction grants a fresh budget with nothing to reset: a stale generation
already reads as unspent.

What is still visible is refused too. The model re-reads that itself, and
the description sends it back to read_file for anything a current copy
answers better. Recall is for what cannot be re-derived: the user's own
words, and the output of work that should not run twice.

The tool schema costs 199 tokens on the provider-visible prefix.
2026-08-14 21:16:31 -07:00
esengine 0da3529649 fix(desktop): link the provider kinds the studio shell can be asked for
Switching to any Anthropic model failed with

    switch model: provider: unknown kind "anthropic" (registered: [openai])

Kinds register themselves from init, so a binary builds only the ones it links,
and this shell linked none of them. openai reached the registry anyway — config
imports it for an endpoint check — which is why the failure looked like it was
about Anthropic specifically rather than about the whole registry being empty.

Nothing upstream could catch it. The picker offers what the config lists, and
the config needs no factory to list it; the old shell and the CLI both carry
these imports, so every other entry point worked. The two tests here fail on the
missing imports, and the second one also holds the curated presets to it: a
preset naming an unlinked kind is a source a user can add and then never select.

Documentation-impact: none - no docs/*.md lists which provider kinds a binary
links.
2026-08-14 21:16:01 -07:00
esengine 24efc7e1ee fix(studio): an account is a host and a key, and a refused switch says so
Two problems the host-only grouping and the silent runner between them made
one report: "切换不来".

The switch was not failing quietly by accident — Settings' run() had no catch,
so anything the kernel refused vanished. It refuses a model switch while a turn
or a background job is running, which is exactly the state a real session is in
and never the state a freshly started one is, so the click did nothing and said
nothing. Every switch on the page goes through that runner, so one line of
reporting covers the model, the effort, the roles and the protocol.

Grouping on the host alone then merged accounts that are not one. A relay
reached with two keys is two accounts — personal beside work, two tenants of
one gateway — and folding them showed one key state and one balance for both.
The account is the host and the credential together, so /providers and /models
carry keyEnv and both lists key on the pair. Where one host really does hold
two accounts, and only there, the config entry's own name joins the label.

A custom relay lands where it should from this: one account, its own models, no
protocol switch offered because it answers on one. The switch appears only for
a host that genuinely has two doors.

Documentation-impact: none - no docs/*.md describes the settings pane's grouping
or the provider list fields.
2026-08-14 21:08:41 -07:00
esengine 2ec84b2c91 fix(compaction): spend the fold index's budget in the unit it was granted in
foldIndexBudget carves a token share out of the context window, but the
render and merge passes costed their lines with estimateTextTokens, which
counts characters. A 128K window granted 640 tokens and bought 640
characters of addresses: around thirteen lines where fifty fit.

The lines are what a folded turn has left, so the shortfall lands exactly
where the digest is already thinnest. Both passes now cost text with
textTokens, the calibrated ratio the rest of compaction plans against.
2026-08-14 21:02:02 -07:00
esengine bc76883361 fix(compaction): measure the summarizer's input in the unit its budget is written in
The fold input path sized itself with estimateTextTokens, which counts
characters, and compared that against a budget carved out of the context
window in tokens. Every stage therefore read a fold as four times its cost:
the omit stage cut a 32-turn English fold to 6 turns, and the guard then
confirmed the result fit — with room for all 32.

summaryInputTokens now goes through estimatedPromptTokens, the same
calibrated estimator the trigger and the acceptance test use, so the
shorten/compress/omit ladder and the budget speak one unit. That folds the
provider-split guardedSummaryInputTokens back into it, and makes the
FoldTokens telemetry a token count rather than a character count.
2026-08-14 20:57:39 -07:00
esengine 8f9e572025 refactor(studio): one account, one door at a time
The model list folded rows by model name across protocols and gave each row a
route selector defaulting to "the active one, else the first". Provider order
put the Anthropic entry first, so every row the session was not already on
defaulted to Anthropic — picking a model moved the session to another endpoint
without being asked. That is what "切换不来" was: the switch worked and did
something the user had not chosen.

Protocol belongs to the connection, not the model. Entries sharing a host are
one account reached through more than one door, so the connection list groups
on the host and carries the switch; the model list shows the models behind the
door currently open, and picking one only picks a model. Changing the door on
the account that is running moves the same model onto the other endpoint —
a real switch, made deliberately, in the place that owns it.

The rows are named for the account now (deepseek, from the host) rather than
for whichever config entry happened to answer. One key and one balance showing
up as deepseek-flash beside deepseek was the whole complaint.

vite's dev proxy was missing /providers, /roles, /account, /hooks, /memory and
/network, so those panels answered with the SPA shell in dev — the same class
of failure the shell's own route list had, and its comment already warned about
it. The list it needs is not derivable from here, so it is spelled out.

Documentation-impact: none - no docs/*.md describes the settings pane layout or
the dev proxy's route list.
2026-08-14 20:53:47 -07:00
esengine 665752785d feat(compaction): give a fold an index, so what it dropped still has an address
The digest is on the hook for what a fold changed and what failed. Everything
else it folded — the reads, the searches, the user turns the retention budget
could not hold — became prose or became nothing, and from inside the window
those two look identical. A model cannot ask for what it has no reason to think
existed.

Each fold now appends a host-written index to its digest: one line per folded
item the digest does not carry, addressed by transcript position.

    ## Folded work index (#n = transcript position)
    - #31 you  "the migration must not take the service down…"  (summary only)
    - #47 read_file  internal/parser/lexer.go
    - #52 bash  go build ./...
    - #58 grep  {pattern} (1 keys)

The split with the coverage check is exact rather than approximate: an entry is
indexed if and only if coverage does not demand it. Writing that as one shared
predicate found a hole neither half had covered — a successful `go build`
classifies as a mutation but reports no path, so the digest was never asked for
it and the index skipped it as the digest's job. It fell between them.

Ranking decides what survives a bounded index, and the order is not a
preference: a user turn nobody can re-derive comes before a failed search,
which comes before a read whose file is still on disk. The budget is a small
share of the window because index lines are the cheapest thing a fold can keep.

The index carries across folds without being re-summarized. It is stripped out
of any prior digest before the summarizer sees it — asking a model to rewrite
host-authored address lines only spends output budget and risks garbling the
addresses — then merged back, trimming from the oldest and saying how many went.
An entry that has survived more folds is the one whose original is furthest out
of reach, which is the opposite of what a naive tail-trim would keep.

Addresses are canonical transcript positions, the same scheme the projection
already validates through CoveredCount and CoveredPrefixHash, so a rewind that
invalidates the projection invalidates the addresses with it. Positions inside
a previous projection answer -1 and render without an address: that content was
folded once already and has no canonical position of its own.

Nothing reads the addresses yet. The recall tool is the next stage; this one is
what makes it worth having, because a tool that retrieves what the model has no
reason to ask for is a tool nobody calls.

Cache-impact: none - no tool schema, system prompt, or memory text changes. The
digest message gains a section, which is projection content and already varies
per fold.
Cache-guard: go test ./internal/boot/ ./internal/agent/ (prefix_shape golden
unchanged)
Documentation-impact: none - the index is self-describing in the digest and has
no configuration surface; docs/*.md does not describe digest structure.
2026-08-14 20:44:15 -07:00
esengine 4110f3b127 fix(config): fold the legacy DeepSeek pair even when it cannot reach the canonical entry
canonicalizeOfficialDeepSeekSource drops deepseek-flash and deepseek-pro into
the one "deepseek" entry, but only when every provider-wide field matches it.
A config holding the pair on the Anthropic protocol beside an OpenAI-protocol
"deepseek" fails that test — and correctly, because the fold keeps the
canonical entry and discards the rest, which would delete the Anthropic route.

So the pair stayed two rows while the OpenAI side showed one, and the settings
panel lists sources: one endpoint, one key and one account read as two
accounts. They now fold into each other instead. The survivor keeps the first
entry's name and slot, because provider order answers bare model names and a
new name would break refs that live outside this file — shell history, a
memorised --model. Refs naming the entry that went away resolve to it, and are
asked before the canonical retarget: a "deepseek" entry can exist without
having absorbed anything, and then it does not hold this route.

Per-model price and effort move into prices and model_overrides rather than
flattening, which is the shape ensureDeepSeekOfficialProvider already gives the
canonical entry. On a real config the pro model keeps its three effort levels
and the flash model its four, instead of the first entry's list answering for
both. A curated model list still declines the fold: carrying every model would
re-add the one the user removed.

Documentation-impact: none - no docs/*.md describes how legacy DeepSeek entries
are folded; the config keys involved keep their documented meanings.
2026-08-14 20:33:49 -07:00
esengine 66af630e4b feat(compaction): measure what a digest kept, and repair it when it forgot a change
Acceptance asked a digest one question: is it smaller than what it replaced. A
digest that summarized nothing answers yes — it is very small — so the one
failure mode worth catching was the one nothing looked for.

A fold is now also measured against the facts it produced. Those come from the
fold region itself, read through evidence.ReceiptFromToolCall, the same pure
classifier the ledger uses; the live ledger is per-task and never spans a fold,
so deriving them is the only way to have them. Two kinds count:

  - files the fold changed
  - commands the fold saw fail

Reads stay out on purpose. The workspace still holds what a read found, and
demanding reads back would push the summarizer toward listing paths to pass a
check rather than writing what happened.

When the digest forgot a change, one more summarizer call asks for exactly what
went missing — naming the files rather than restating the whole contract, so
the second attempt spends its budget on the gap. The better of the two results
wins.

Repair rather than rejection, and that is the part worth arguing. Refusing a
partial digest leaves the prompt uncompacted; the next attempt folds a larger
region and the pressure only ends at the hard ceiling, where the fold is forced
through whatever it produces. Strictness early buys a worse digest later. The
one case still refused is a digest that carried none of the fold's changes,
which needs no threshold to recognise — and even that stands down when the fold
is the only way out.

No ratio is configured anywhere. The severity line is the mechanism's own: a
forgotten change makes the agent wrong, because it will redo or contradict work
already on disk, while a forgotten failure only makes it slow. Counts land in
the compaction telemetry so the fold's fidelity is reportable rather than
assumed.

Cache-impact: none - no prompt, tool schema, or memory text changes. The digest
the projection installs is the same shape; only its acceptance path changed.
Cache-guard: go test ./internal/boot/ ./internal/agent/ (prefix_shape golden
unchanged)
Documentation-impact: none - internal acceptance logic with no user-facing
surface yet; the /context quality card lands with the UI stage.
2026-08-14 20:27:22 -07:00
esengine 1d448ef40b feat(studio): let a connection say whether it still works
The connection rows showed what was configured and nothing about whether it
still holds. A key revoked last week and an endpoint that moved both read as
a normal row, and the only way to find out was to start a turn and watch it
fail.

/providers/check re-runs the add-a-source probe against what is already
saved, so "is this key still good, and is this still the protocol we
recorded" is one button. The row reports the endpoint's own words, because a
401 and "no chat models" send the user to different fixes.

No protocol switch rides along with it, though the row can now tell you the
protocols disagree. A probe only lists models, and it tries both auth shapes
against the same listing URLs, so "both answered" says nothing about where
either chat contract lives: DeepSeek serves OpenAI chat at /chat/completions
and Anthropic at /anthropic/v1/messages. Flipping kind alone would aim one
contract at the other's address and call it a repair. The row says what it
found and leaves the fix to a human who can also change the address.

测一下 stays visible instead of riding the hover reveal 删除 uses. A
diagnostic nobody can find is not a diagnostic; a destructive action that
hides until you mean it is.

Documentation-impact: none - no docs/*.md describes the settings pane's
connection list or the serve provider routes.
2026-08-14 20:20:43 -07:00
esengine b3461c3027 fix(desktop): route /roles to the kernel instead of the asset server
The shell answers any path outside apiPaths with index.html, so the settings
pane asked for its role assignments and parsed the SPA shell as JSON. The
band reported "读不到分工" on every open, and no request ever reached the
controller to explain why.

TestEveryPathTheFrontendCallsIsRouted names this exact failure and caught it
on the first run; the pre-push list does not include ./desktop, which is why
it went out with the route missing rather than with it.

Documentation-impact: none - no docs/*.md lists the shell's routed paths.
2026-08-14 20:03:16 -07:00
esengine e660218d6b refactor(compaction): retire what the multi-threshold scheme left behind, and hand its numbers over
Compaction is not the class of strategy the last few commits removed. The
context window is a physical constraint, not a judgment about how the model
should work, and every rejection in acceptCheckpointCandidate is already
grounded in a measured token count against the user's trigger or the provider's
ceiling. What did not survive review is the residue of the retired
multi-threshold scheme, and the numbers inside it that were never anyone's to
choose but the host's.

The residue:

  - compaction.consecutive was incremented in one place and reset in four, and
    read in none. It only ever existed to be cleared.
  - soft_compact_ratio / tool_result_snip_ratio / compact_force_ratio are
    forced to zero at config load, then threaded from boot through agent
    Options and a twelve-parameter task constructor to fields nothing reads.
  - SnipStaleToolResults and PruneStaleToolResults were exported no-ops with no
    callers, and PruneStats existed to be their return value.
  - ContextReport still declared SoftThreshold / SnipThreshold / ForceThreshold,
    which nothing populated — and /context both printed them and branched on
    them. "LatestPrompt >= ForceThreshold" against a permanent zero is always
    true, so every session, including an empty one, reported "next: at the force
    threshold". It now names the fold trigger and the hard ceiling, which are
    the two thresholds that exist.

The numbers, now under [agent] and all off by default:

    user_turn_keep_tokens      your own turns kept verbatim through a fold
    first_turn_pin_tokens      first turn pinned into the cached prefix
    checkpoint_ceiling_ratio   how small a fold's result must be to be accepted

compact_ratio loses its 0.65–0.85 range. Compacting early to keep prompts small
and compacting late to keep history whole are both real preferences; only a
value that is not a fraction of the window is wrong. The desktop bridge's copy
of the range goes with it.

The user-turn retention budget was min(8192, 5% of window), so it stopped
growing at a 164K window: someone running a million-token model kept the same
8192 tokens of their own words as someone running 128K. It is now the window
share alone, with the fixed count kept only as the fallback when no window is
known. The separate 1500-token per-turn ceiling is gone — the remaining budget
is the only gate, and [[keep]] is still the way to insist on a specific turn.

Because the budget is the user's, /context reports what the last fold did with
it: turns kept, turns dropped and roughly what they cost, and the budget that
decided. noticeDroppedUserTurns already said it once, at the fold; this is what
can still answer afterwards.

Cache-impact: none - no prompt, tool schema, or memory text changes. Fold
behavior is identical for an unconfigured agent: every new setting defaults to
the value it replaced, and the retention budget's window share is unchanged at
every window where the old fixed count was not already the binding limit.
Cache-guard: go test ./internal/boot/ ./internal/agent/ (prefix_shape golden
unchanged; TestUserTurnKeepBudgetScalesWithTheWindow and
TestUnsetCompactionBudgetsKeepTheDefaults pin the defaults)
Documentation-impact: none - docs/*.md documents neither the retired thresholds
nor the fold budgets; the [agent] keys are self-describing in the rendered
config.
2026-08-14 20:03:00 -07:00
esengine 8da0a3de8b feat(kernel): give image routing a model of its own
An attachment the main model cannot read is already handed down to whatever
sub-agent runs next, and that sub-agent's model was chosen for something
else — usually cost. A cheap worker is text-only, so the provider serializer
drops the image and the turn answers as though nothing was attached. The
composer has been promising a vision-capable reader since it shipped; the
only way to deliver one was to hand-edit subagent_model and give up the
cheap worker.

agent.vision_model names that reader. The routing travels in the turn
context beside the image candidates, because it answers the question they
raise: the parent could not read this, so who can. A sub-agent about to
receive candidates it would drop is moved onto that model; one that already
reads images, or a turn carrying no attachment at all, is left alone — a
review sub-agent spawned in a turn that happened to have a screenshot keeps
the model it was chosen for.

/roles gains the assignment and the band's 看图 slot stops reporting a
borrowed subagent, so what the settings pane shows is now a switch rather
than a readout.

config.roleModelRefs is the one list of model refs a role can name. The
fallback repair and the referenced-provider scan both walk it, and they have
to walk the same one: a role missing from either keeps alive a ref no
provider resolves.

Cache-impact: none - the system-prompt prefix is untouched. Vision routing
only rewrites a per-turn context value and a sub-agent's model ref, and the
new config key renders into user TOML, never into the prompt.
Cache-guard: go test ./internal/boot/ (prefix_shape golden unchanged)
System-prompt-review: esengine - config gains agent.vision_model; it is read
at sub-agent spawn time and rendered into user TOML, and reaches no
prompt-bearing surface.
Documentation-impact: none - no docs/*.md describes image routing or the
sub-agent model fields beyond SUBAGENT_PROFILES, whose precedence rules are
unchanged.
2026-08-14 19:45:25 -07:00
esengine 0725cc57a1 feat(memory): charge the prefix for what it carries, and let the user set the budgets
Memory had its limits inverted. The fact index — one line per saved fact,
loaded into the cached prefix and paid for on every session — had no bound at
all, and kept charging for facts already past their expires_at even though
automatic recall refuses to serve one. Meanwhile recall, which rides the cheap
turn tail, was pinned at four facts and 2400 characters by constants no
configuration could reach, and the pinned-guidance ceiling was a flat 1500
characters that told any user with more to pin that they were wrong.

So: expired facts leave the index. A line the model can never act on is prefix
rent, and dropping it is the one bound memory was actually missing.

Everything else becomes the user's, under [memory]:

    pinned_budget_chars   ceiling on pinned bodies; unset = none
    recall_limit          facts automatic recall may inject; unset = 4
    recall_max_chars      that injection's size; unset = 2400

The recall axes keep their old numbers as defaults but lose their ceilings —
configure fifteen and you get fifteen. A hit's snippet is now an equal share of
the char budget rather than a separate 520-rune cap, so it moves with whatever
was configured instead of standing as its own limit. A mid-session reload
carries the budgets forward rather than reverting to the defaults.

What replaces the hard cap is the number itself. `/memory` reports the prefix
cost — index characters and fact count, pinned characters and fact count, the
total, and the budget when one is set — because the axis ships off, and a cost
nobody can see is not a decision anyone can make.

Cache-impact: low - the prefix loses index lines for expired facts, which is
the intended change; nothing else about assembly or ordering moves, and a store
with no expired facts renders byte-identically.
Cache-guard: go test ./internal/boot/ ./internal/memory/ (prefix_shape golden
unchanged; TestIndexDropsExpiredFacts covers the new omission)
System-prompt-review: index composition changes only by omitting expired facts
— needs a reviewer's eye on whether any workflow relied on an expired fact
still being listed.
Documentation-impact: none - docs/*.md does not document the recall constants
or the pinned ceiling; the [memory] section is self-describing in the rendered
config.
2026-08-14 19:27:01 -07:00
esengine 6c2e58ad0a fix(control): drop the in-flight image routing my last commit swept in
Staging turn_orchestrator.go whole took three hunks of unfinished
image-routing work with it — calls to imageRoutingPrefix,
bindOrchestratedTurnImages, and unreadableImages — while their definitions in
turn_images.go stayed uncommitted, so studio did not build. This restores those
call sites and keeps only the withPlannerTurnMetadata signature change that
commit was actually for. The working tree keeps the newer versions; they belong
to the change that defines their helpers.

Cache-impact: none - control-flow call sites only, no prompt or tool surface.
Cache-guard: not applicable; no provider-visible bytes change.
Documentation-impact: none - restores a call site to its previous form.
2026-08-14 19:19:21 -07:00
esengine c3867f426e feat(studio): say what each model can do, and who does which job
The model pane was one flat list multiplying three independent axes —
which service, which protocol reaches it, which model — while the fourth,
which job a model takes, existed only in the config file. Twenty rows of
radio buttons, and no way to tell which of them reads images.

/models now carries a per-model capability face: the endpoint host, image
input, the effort levels that endpoint accepts, context window, price.
Each resolves back through ResolveModel so per-model prices and
model_overrides apply. A field stays absent when nothing declares it — an
inferred "reads images" badge sends the user to a rejected request with
nothing on screen to explain it, which is worse than a blank row.

The picker groups by endpoint and folds one model's protocol routes into
a single row, so a vendor reached under both OpenAI and Anthropic stops
listing the same model twice with nothing to choose between. Effort
offers only what the endpoint has; the fixed six were shown to every
model, including ones carrying no reasoning knob at all.

/roles gives the planner, subagent and guardian their own models, writing
the AgentConfig fields that already drove them and rebuilding the runtime
the way a model switch does. Image routing has no field of its own yet,
so its slot reports the subagent model it borrows and whether that model
reads images at all — the composer has promised this since it shipped.

A saved vision whitelist no longer raises the provider-wide flag as well.
That flag answers for every model the list omits, so a mixed provider had
its text-only half claiming image input.

Documentation-impact: none - no docs/*.md describes the serve HTTP
surface, and SUBAGENT_PROFILES keeps its meaning: /roles writes
agent.subagent_model without changing its precedence.
2026-08-14 19:04:20 -07:00
esengine 21638e356c refactor(agent): stop counting rounds, and let the executor decide when to plan
Two ladders came down.

The planner gate was scoring task text out of twelve keyword tables — work,
high-risk, cross-surface, ambiguous, atomic — and buying a second model round
from vocabulary. "migrate" was enough. So was a message over 240 runes, or two
file paths in one sentence. What the tables were reaching for is a judgment the
executor is better placed to make: it reads the same words with the repository
in front of it, and lays out steps with todo_write when a task needs them,
where the delivery gates and the contract can both see them. The gate now
answers only "did the user ask for a plan?" — plan mode, a synthetic turn, a
slash command, or a directive the user stated outright ("先规划再执行", "wait
for my approval"). taskpolicy loses the surface-shape signals that fed it for
the same reason: rune counts and path counts described the sentence, not the
work. Risk stays a host signal, and nothing derives one from wording.

The six per-round counters came down with it. A turn was being watched by the
zero-gain ladder (2/4/6), the storm breaker (3 identical failures, 3 all-blocked
rounds), the repeat-failure and repeat-success blocks (2 each), and the todo
stall checkpoints (8/16) — all reading the same receipts, all reaching for the
same "it looks stuck" from a different angle, and each one able to redirect the
model or refuse its call. run_budget.go already says why this was the wrong
axis: rounds are a poor proxy, and the failures worth stopping are reported in
hours and tokens. Those budgets remain, off by default and user-owned. Stopping
a task stays the user's call.

Removing them retires the loop-guard pass in final readiness too — an escape
hatch whose only job was to release a model the guards had cornered.

The outcome scorer survives as an instrument (internal/agent/outcome_shadow.go):
it still decomposes each round for the bench, and still decides nothing.

Net -3250 lines.

Cache-impact: none - the system-prompt prefix is untouched; the per-turn
<execution-policy> block keeps its shape and only ever carried preset-derived
values, which are unchanged for every preset.
Cache-guard: go test ./internal/boot/ (prefix_shape golden unchanged)
Documentation-impact: none - no docs/*.md describes the planner scoring or the
round counters; user-facing copy for plan mode and task budgets is unchanged.
2026-08-14 18:48:36 -07:00
esengine b071fef950 refactor(agent): let receipts decide, and retire the word lists that guessed
A turn's obligations were being read off its wording. taskintent held ~1200
lines of CN/EN keyword tables, hand-rolled negation windows, and clause
splitting to answer "is this a mutation request?", and final readiness then
demanded a state change from any sentence that scored one. Its own doc.go
called the vocabulary a liability; boundary_test.go existed to stop it growing.

Readiness now reads the ledger: a turn that changed nothing owes nothing, and
one that did owes the verification, review, and sign-off it already owed. The
contract's intent label follows the same rule -- taskcontract.Kind is observed
from receipts after the fact instead of predicted from text -- and the atomic
contract shape is triggered by a mutation receipt rather than by a verb.

taskpolicy keeps the constraints a user stated outright ("don't modify", "only
run go test"): those restrict, and the user meant them. What goes is the risk
guessing, where "migrate" or "credential" bought a full plan and a forced
review from a topic word rather than from what the turn touches. Risk now
arrives only as host-computed signals.

Also removes machinery that decided nothing in production: the EBM and
reasoning-governor experiments (env-gated, off), the fork-bundle capture that
served them, and the shadow delegation-admission verdict. The per-round
contract replay went with them -- it rebuilt the whole contract from every
receipt on every tool round, quadratic in a long turn, for an audit channel
only the bench implements.

Net -3382 lines. Behavior change: a Delivery turn that claims work without
calling a tool is no longer refused. Catching that belongs to the model's own
claim (update_goal / complete_step), which internal/completion already checks
against the ledger, not to a keyword table.

Cache-impact: none - no tool schema, prompt, or memory text changes; the
per-turn <execution-policy> block rides the turn tail as before and its
route/risk values are derived from the same frozen preset.
Cache-guard: go test ./internal/boot/ (prefix_shape golden unchanged)
Documentation-impact: none - taskintent and the two experiments were never
user-facing; no docs/*.md describes the removed gates.
2026-08-14 18:24:46 -07:00
yhh 7469d77073 fix(config): one DeepSeek source, not one entry per model
The official source ships as two provider entries, deepseek-flash and
deepseek-pro — a shape from before a single entry could carry several models.
The model picker hid it because it lists models, but the settings panel lists
*sources*, and there one endpoint, one key and one account show up as two
accounts, with every model appearing twice under different refs.

They now fold into the single "deepseek" entry at load, through the
compatibility gate that already existed for exactly this: provider-wide fields
have to match and per-model ones must not conflict, so anyone who edited either
entry keeps what they wrote. Per-model efforts, prices and context windows ride
the canonical entry's overrides rather than being flattened into one.

Refs are deliberately not rewritten. Resolution answers the old names instead,
because refs outlive the config file — shell history, scripts, a --model flag
someone has memorised — and rewriting the four that live in the file would fix
none of those while making Default().DefaultModel disagree with a loaded one.
2026-08-14 06:39:11 -07:00
yhh b68249d024 feat(studio): three panels that finish the settings pane
Model sources, account and versions each get a section, and the 「高级」 tab —
which existed only to say "this still lives in the old app" — filters itself
out now that its list is empty.

Account state is fetched once by App and passed down, and an unknown state
renders as unknown. The first cut drew 「登录」 while the request was in flight
and swapped to 「退出登录」 a few hundred milliseconds later, which is long
enough for a signed-in user to click it and actually sign out. Signing out is
two steps for the same reason, and the button that does it always sits next to
the sentence saying what it does not touch.

The identity entry in the title bar is an outline when signed out: an entry
point, not a pitch. Reasonix works without an account and the chrome should
not imply otherwise.

A settings section that throws now takes down only itself — the other sections
and the conversation behind them are unaffected.
2026-08-14 05:04:43 -07:00
yhh 306417666c feat(serve): route the new surfaces, and reuse the generation a switch replaces
The account and provider endpoints are behind host grants like the workspace
switch already was, so the three of them become one decision — this server is
a window, not a network service — instead of three independent bools spelling
states no host means.

A model or effort switch now carries the serving generation into its
replacement rather than assembling one from cold: sidecars keep running, the
dependency graph is not re-walked, and discovery is reused when the extension
surface has not moved. The switch also stops re-resolving the workspace from
the process working directory, which is what made a rebuild quietly serve
another project's conversations.

Studio's apiPaths was a whitelist that silently answered index.html for
anything unlisted — /skills and /todos had been falling through it for a
while. A test now walks what the frontend actually calls, because the comment
saying "register your route here" plainly did not.
2026-08-14 05:04:16 -07:00
yhh 0850a84291 feat(account): sign in for the parts that are networked by nature
Reasonix runs on the user's own key against their own provider, so there is no
structural reason to require an account and we are not going to invent one.
What an account buys is the handful of surfaces that cannot work without a
server on the other end: posting in the community, following up on a crash
report, publishing a skill later.

`reasonix login` runs the device flow — the browser gets the address bar, we
never show a password field — and the token lands in the existing credential
store, which already carries the file permissions, the edit lock and the
environment override that lets CI inject one.

The serve endpoints are off unless the host grants them, for the same reason
the workspace switch is: signing in writes a token into the credential store
of whichever machine runs the kernel, which is not necessarily the machine in
front of the person clicking.
2026-08-14 05:03:58 -07:00
yhh 2c9ba0e6cf feat(provider): add a model source by pasting a URL and a key
Adding a provider by hand means knowing which protocol the gateway speaks,
which models it serves, whether it wants a Bearer header, and whether it can
see images — none of which the person who just bought an API key has any way
of knowing. So the endpoint is asked instead: /probe reports what answered,
and nothing is written until the user has looked at it and confirmed. Where
more than one protocol answers, the result says so rather than picking
silently — that is a preference, not a finding.

The probe also retries with the proxy bypassed and reports it, because a
China-only endpoint reached through a foreign exit resets the handshake and
would otherwise look simply dead.

ChatModelList moves out of config.go into its own file; the picker filter has
nothing to do with the config schema and config.go has no room left.
2026-08-14 05:03:39 -07:00
yhh 1db3c9082b feat(desktop): let a bad release be rolled back from inside the app
A release that turns out to be bad currently leaves the user hunting for an
old installer. The version panel lists what has been published and installs
any of them — the same code path forward or back, because a rollback that took
its own path would be the less-tested one. The chosen version is pinned before
the download starts: if the machine dies mid-install, the next launch must not
helpfully update past what the user picked.

The update core moves out of the old shell into desktop/internal/update so
both shells share it. Beyond deduplication that finally makes it compile on
every platform — updater_mac.go could not even be type-checked from Windows,
so the macOS half was only ever verified by CI.

Publishing writes a per-tag index alongside the artifacts, so the catalog the
panel reads is produced by the release itself rather than maintained by hand.

Two things the transport learned the hard way, now covered by tests: a 404 is
not transient and must not burn the full retry budget, and a resumed download
has to verify the range it was actually given — a middle box that answers
`bytes 0-` to a range request will otherwise append the whole file to what is
already on disk.
2026-08-14 05:03:02 -07:00
yhh e27b4a7541 fix(store): reclaim the transcripts a session never wrote a word into
Every path that mints a session file before knowing whether it will be used —
an inbox enqueue, a window that opens and closes, a switch the user backs out
of — leaves a transcript with no user message in it. They accumulate in the
session list forever, and the list is the user's history, not a log.

ReconcileCleanupPending now reclaims them on the same pass that retries
delayed cleanup, after a grace period long enough to cover the gap between
creating the file and the first turn reaching it: a live session holds the
write lease, so it is never a candidate. Reclamation goes through the host's
cleanup path, so a host that trashes deletions trashes these too rather than
unlinking them.
2026-08-14 05:02:12 -07:00
yhh 3fbb233b00 refactor(boot): give a rebuild one definition of what survives it
Switching model, effort or extensions used to duplicate that definition at
every call site: serve spliced the fresh system contract over the carried
history by hand, restored session authorizations by hand, and each caller
decided for itself which axes — approval mode, plan mode, a running goal — a
switch was allowed to reset. CaptureRuntimeMigration/ApplyRuntimeMigration
state it once, and BuildRuntime hands back the whole generation (controller,
frozen snapshot, bound closables) so the next rebuild can reuse the assembly
it already paid for instead of rediscovering skills, commands and hooks.

Resume keeps its cold-cache notice; the migration path takes the private
`resume` that records the cache state without announcing an idle gap the user
never sat through — they never left the session.

boot.go was at the file-size ratchet with no room to add a line, so the parts
that are their own concern now live next to it: memory set, phase timing,
plugin specs, skill discovery.
2026-08-14 05:01:37 -07:00
esengine 1ba4611b35 fix(store): keep subagent transcripts out of the user's session list
A real profile listed 189 sessions, 154 of them named subagent-sub-*. Those are
a task's internal trace — persisted since the subagent-transcript work so a run
can be continued or forked — and mean nothing outside the session that spawned
them. Switching reasoning effort rebuilds the runtime, the frontend reloads the
list, and a wall of them appeared where the user's own conversations should be.

The rule already existed, spelled inline twice in the downgrade migration with
the reason written out, and was simply missing from the listing. It becomes
store.IsSubagentTranscriptName, next to the sidecar-suffix rule it belongs with,
and the migration now calls it instead of restating it.

It is applied at the surfaces that list the user's own conversations, not inside
IsVisibleSession: recovery GC scans for recovered branches through that helper
and would stop reclaiming a subagent's, leaking them on disk.
2026-08-13 18:08:40 -07:00
esengine b18b93c556 feat(memory): show a save the moment it happens, with the take-back attached
Saving a fact is the one tool call that changes what the agent will do in later
sessions, and it happens without being asked. As an ordinary step it scrolled
past between a grep and a read, so the behaviour and the moment it was learned
were never connected — which is exactly the connection someone needs weeks later.

The card says what was taken away and when it will apply: pinned facts ride in
every prompt, relevant ones surface when a turn looks related, and project scope
is narrower than global. Undo sits on the card because it is only cheap right
now, while the conversation that caused it is still on screen. Taking it back
marks the card rather than removing it — the transcript is the record of what
happened, and erasing the row would erase that too.

It reads the call's own arguments, which carry what was saved and how it will be
recalled; the tool's textual receipt carries neither. A failed save stays an
ordinary failed tool call.
2026-08-13 17:14:53 -07:00
esengine 1d5f562eff feat(memory): show which memories the last turn actually acted on
Memory is the only thing in the product that changes how the agent behaves
without the user ever configuring it — the agent writes it. Weeks later it
insists on something and there is no way to find out why, because the fact
behind it was saved silently during some conversation.

So the pane answers the question that gets asked, not the one a list answers.
The last recall is folded into the rows: a fact the turn actually used is marked,
and it says what it matched on. Grouping follows activation — always in the
prompt, or only surfaced when the turn looks related — because that is what
decides whether a fact is a standing instruction or background. The write-side
types are the author's classification and do not tell a reader when a fact
applies, so they do not organise anything here.

Forgetting archives rather than deletes, which is the store's existing choice
and the right one for something removed by mistake.

The mock's scripted turn moves to its own file: it is content, not transport,
and mock.ts had grown past the size ceiling holding both.
2026-08-13 10:35:56 -07:00
esengine 5e73d47497 feat(network): say which layer broke instead of timing out in chat
A wrong proxy shows up as a chat turn that hangs. Nothing in that message says
whether DNS failed, the proxy refused the connection, a corporate MITM cert was
rejected, or the key was simply wrong — four different fixes behind one symptom.

netclient.Diagnose walks proxy → dns → connect → tls and stops at the first
failure, because every later step fails for the same reason and three red rows
hide the cause. The controller appends the auth step, since only it knows which
provider is selected: a host that answers but rejects the credential is a
different problem from one that never answered. Each failure carries the next
thing to try, and an untrusted certificate says so by name rather than reading
as "the network is down".

The pane is three modes, not six proxy variables; the fields for a manual proxy
only exist once that mode is chosen. Saving validates first, because a spec that
cannot even be composed into a URL would otherwise be written and then fail on
every request far from the screen that set it. The stored proxy password is
never handed back to the editor — only whether one exists.
2026-08-13 10:17:01 -07:00
esengine 850e10dd68 feat(hooks): give hooks a surface you can try before trusting
Hooks run arbitrary shell on the user's machine and two of the events can stop
the agent outright, so this is the most dangerous writable surface in the
product. It had no editor here at all: the writing half lived in the desktop
app, and internal/hook could only read.

Save and DryRun move into internal/hook, where the CLI, the desktop and Studio
can share one implementation. Save preserves every other key in settings.json,
which is not a hooks file it happens to live in. DryRun really executes the
command against a representative payload, because a simulated pass proves
nothing about a command that does not exist — and reports the consequence for
that event rather than the exit code, since the same code stops a PreToolUse and
only warns after a PostToolUse. Editing swaps the live runner, so a rule the
user just fixed applies to the next tool call instead of the next launch.

The pane leads with recipes, not events: nobody arrives wanting a PreToolUse
hook with an anchored matcher, they want their formatter to run. The event table
is one click behind that, blocking rules carry a warning stripe, and the scope
choice says out loud that a project rule ships to everyone who clones the repo.

The controller does not keep its own copy of the hook runtime options; the
runner's spawner already binds them, and the second copy would have pushed two
already-oversized files further over their budget.
2026-08-13 09:49:03 -07:00
esengine d0f165bd2e feat(mcp): install a server from whatever its docs handed you, and remove it
Adding a server meant leaving for the old desktop app. Bringing it here as a
form would have been the wrong shape: nobody knows whether they want stdio or
http, they have whatever the README printed — an mcpServers block, a command
line, or a URL. internal/mcpsetup takes all three and says which one it got.

Parsing is its own step. What comes back is a card naming the process that will
run on this machine, the endpoint it will talk to, and any credential written
out in full rather than referenced, because a pasted config is a stranger's code
and the user is the one agreeing to it. Where it gets written is a choice, not a
default: a project entry ships to everyone who clones the repository.

Installing connects first and persists only if that worked — a saved entry that
never connects reads as installed while contributing no tools. Authentication is
the exception: its config stays, since finishing OAuth is impossible once the
entry is gone. Removal asks, names the file it will edit, and reports when a
same-named declaration underneath takes over instead of implying it is gone.

The argv grammar, naming rules and secret detection moved out of internal/cli,
which is where the CLI happened to need them first; the frontends take the same
descriptions and cannot import a frontend. ParseArgs lost the 122-line body and
the complexity that came with it rather than carrying that debt to a new file.
2026-08-13 09:16:56 -07:00
esengine accaa34e76 fix(desktop): route a resource's sub-paths to the kernel, not the SPA
The Studio shell matched API paths exactly, so /mcp reached the kernel while
/mcp/reconnect fell through to the assets and answered with index.html. The
frontend then failed parsing HTML as JSON, which is indistinguishable from a
broken endpoint. The dev proxy had the same hole, and had been missing /mcp and
/slash since they were added.

Both now list resource families: a sub-path belongs to the resource it hangs
off, so the next endpoint under one is routed without a second registration.
2026-08-13 08:39:46 -07:00
esengine fe46f96ad7 feat(control): make an MCP server and a skill switchable from any frontend
The extensions page could only report. A failed server named its error and
offered nothing to do about it, a configured server could not be switched off,
and the skill list was built from the slash surface — so a skill the model
discovers on its own, having no slash name, did not appear at all.

Reconnect and the durable activation switch land on the controller, where the
desktop's own copies should eventually collapse into them, and serve exposes
what the controller already knew: /skills now answers every discoverable skill
with its trigger paths and capability face, and /mcp resolves the activation
state so "switched off" stops reading as "not needed yet".

Studio spends it on the row that needs it: a retry button that says re-authorize
when the failure is a 401, a switch per server and per skill, and a trigger note
only on the rows that are missing one. The transcript names the profile a
delegated step ran under, and an MCP tool drops the prefix its own badge repeats.
2026-08-13 08:26:53 -07:00
yhh 67850d11ad feat(desktop): give Studio a slash menu, a folder switcher and an extensions page
The session rail ended in two disabled buttons whose explanations were
unreachable — .lnk[disabled] sets pointer-events:none, so the title tooltip
never appeared — and one of them blamed a missing kernel API that has existed
all along in internal/worktree. Both are gone. What they promised is real now,
and each half lives where it belongs.

Skills were already invocable: the kernel resolves /<name> through
resolveSkillInvocation, so the only thing missing was discovery. GET /slash
lists what Submit actually resolves, custom commands ahead of skills in exactly
the order the kernel picks them, and the composer offers it on "/". Built-in
verbs stay out — most are chat-TUI only, and offering one the HTTP path drops
is worse than listing nothing.

Changing the working folder rebuilds the runtime at another root. It is a fresh
boot.Build and never boot.Rebuild: Rebuild migrates the conversation and
continues the outgoing session file, which would drag one project's transcript
into the next. Permission is a host grant (Server.AllowWorkspaceSwitch) rather
than a config key, because a server reachable beyond localhost must not let a
client repoint the agent at any directory it can read. The shell grants it —
its only client is its own window — and reopens the folder it last drove.

Studio also pinned no SessionDir, so every project shared the global session
store and the rail listed another project's history. It now resolves the
per-project directory the CLI has always used.

MCP servers surface in two places, chosen by how much each one changes. The
settings page lists them; the tool card stamps which server answered, because
that is the moment someone can judge whether an external service should have
run at all. The metrics rail stays empty unless one is failing.

The preferences surface was a generic settings skeleton: one bordered box per
option, plus a status strip that existed only because the nav said nothing.
Options are now a single hairline-separated list carrying the same left accent
bar the transcript already uses for state, and every nav row shows its
section's current value — so an approval mode that allows everything reads as a
risk without opening the section.

strings.SplitSeq and a range-over-int clear the two golangci findings this
branch had accumulated in its own files.
2026-08-13 06:21:42 -07:00
yhh 63d554c01a feat(desktop): make Reasonix Studio's stream, layout and session list hold up
Streaming stuttered. It was not the wire: the kernel emits one event per
provider delta and flushes each one, and a probe against a live turn measured
2-3 characters per frame (reasoning p50 3, text p50 2, minimum 1). The cost was
in the client, and it was quadratic.

Two causes, measured on the same fixture back to back (25-card transcript,
40-character chunks). The larger one is first: at 457 characters the p50 was
already 98.6ms, so most of the work had nothing to do with message length —
every token redrew all 24 other cards. Transcript rows are now memoised on
their item, and App's two card callbacks are stable so the memo holds. The
second is the markdown pipeline re-parsing the whole message per chunk; it now
splits on safe blank lines and memoises each settled block. Splitting only the
tail was not enough — re-parsing the settled head whenever a block closed cost
184ms at 7.3K. Result:

  457 chars   p50 98.6 -> 6.6ms    total 1050 -> 110ms
  3.7K chars  p50 114.1 -> 2.9ms   total 10499 -> 275ms
  7.3K chars  p50 142.4 -> 3.9ms   total 26474 -> 854ms

The split changes when parsing happens, never what it produces: 66 prefix
lengths compared, zero differences.

Frames also arrive in bursts (median gap 0ms, 90th 32ms), so arrival order was
being reproduced as visible jitter. Text is now revealed on the frame clock,
draining the backlog proportionally — the lag stays at roughly five frames
whatever the token rate, and the render count drops from 944 frames over 12s to
60/s.

Thinking never streamed at all: the transcript gated the card on answer text,
so 10.2 seconds of reasoning stayed invisible and then landed in one block when
the first answer token arrived. A card now draws with either.

Layout: prose is capped at a reading measure (68ch, about 37 Chinese characters
per line) but code was not, so at 1920px the column was 1333px with prose using
495px of it and a hole to the right. The measure is right; the column was too
wide. --doc binds the flow, the composer and the tabs to one 900px document
column, taking prose from 37% to 59% of the column and leaving code 120 mono
columns. The remaining space reads as a page margin.

Settings drifted from the rest of the UI: .prow was a second copy of the ask
card's .opt with nothing but accidental differences (gap, padding, line-height),
the one dangerous mode only turned red after it was chosen, three theme options
with no descriptions sat in full option rows, and the tablist ignored the arrow
keys the main tabs answer. All four are now consistent, and a status strip
shows what is currently set without visiting each tab.

The session rail was blank and the header showed a filename. The shell mints no
session file at launch by design, so the list starts empty — but nothing ever
refetched it once the first turn created one. /sessions has always carried a
title (generated when ready, first message truncated until then); no surface
displayed it. The list now lives in App, reloads when the session changes or a
run ends, and feeds the header.

MockPort is why none of this showed in development: it answered with a static
session and echoed the prompt into the goal. It now behaves like the kernel. It
is also dev-only now — a shipped build that cannot reach its kernel used to
render a scripted session as if it had happened, and now says so instead.
2026-08-13 03:00:27 -07:00
yhh f9080dc1e8 feat(desktop): fold a long file read behind its line count
A read dumped the whole file into the transcript, so one look at a 133-line
file buried the rest of the turn. read_file numbers every line it returns
("  1→…"), so the count is already in the output and needs no guessing — the
format was measured off a real turn rather than assumed.

Long reads collapse to "读了 133 行" and open on click; short ones stay inline,
because folding nine lines only adds a click. Verified against a real turn:
the details box is 21px closed and 2570px open.
2026-08-12 23:56:47 -07:00
yhh b597b5851e feat(desktop): show a shell command's exit status
A tool card rendered stdout and nothing else, so a command that failed looked
exactly like one that worked. The status was on the wire the whole time —
tool.execution carries state, exitCode, failurePhase and outputTail — but the
field was never declared, so the client could not see what it was already
being sent.

The card now carries a small pill in the error colour, shaped like the tag
beside it. It prefers the exit code over the state word: "failed" only repeats
what the colour says, while "exit 2" is the part worth reading.

The badge is live-only. A reload rebuilds the transcript from /history, which
is the provider conversation and has no execution on it — the same reason
durations disappear. The failure text itself survives, because the kernel puts
it in the tool output too.
2026-08-12 23:50:40 -07:00
yhh 37e5410634 fix(desktop): stop one bad formula from blanking the window
Opening a session that contained math went white. KaTeX throws on an undefined
control sequence by default — verified directly: \badcmd{x} raises, the same
input with throwOnError:false renders the source in red — and a throw during
render unmounts the tree, so the window goes blank over a single token the
model happened to write.

Two changes, because the second one is the point. KaTeX no longer throws. And
each message renders inside an error boundary that falls back to its source
text, so anything else the renderers choke on costs that one message instead
of the whole conversation. Model output is arbitrary text; the transcript
should not be one unlucky token away from an empty screen.

Thirty sessions were clicked through to find it, and none of them contained a
formula, which is why the earlier passes came back clean.
2026-08-12 23:32:05 -07:00
yhh fa4c2cfa30 fix(desktop): render math written with LaTeX's own delimiters
The model answered an integral question in \[…\] and \(…\), and nothing
rendered: remark-math reads only dollars, so no math node was ever built, and
the lazy-load detector — which also looks for dollars — never fetched KaTeX
either. Both layers missed it for the same reason.

The delimiters are rewritten to dollar form before parsing, which keeps one
math parser rather than teaching two of them a second syntax. Code is split
out first: a \[ inside a fence is text, and an equation is not.
2026-08-12 23:19:07 -07:00
yhh f7530ec7b0 fix(desktop): route /trajectory, and seal a decision once it is made
Walking a real turn through the browser found three things the earlier passes
could not, because they only checked that the page rendered.

/trajectory reached neither allowlist — not the vite proxy, not the shell's
apiPaths — so it fell through to the SPA and the HTML parsed as a failure the
caller swallowed. The persistence committed in 446f1217f therefore never
worked in Studio at all: the pane was empty on every reopen. With the route in
place a reload reads 19 frames back and draws 17 rows.

Both decision cards read their sealed state off the item, and the reducer
never recorded one, so an answered question stayed answerable and an approved
write kept offering its three buttons. The decision is now stored when it is
made: the ask goes read-only with a line naming what was chosen, the approval
states what allowing once actually means, and the chosen option stays on
screen because reviewing an old turn is the point.

turn_done carries the kernel's reason, and reducing it to "失败" threw that
away — the run above wrote its file and answered the follow-up, then was
flagged for not verifying afterwards. That reads nothing like failure, so the
reason is shown instead of the word.
2026-08-12 23:14:45 -07:00
yhh 663acc746e fix(desktop): name a tool call the way the spec names it
Cards showed the raw tool id and one generic "正在执行…" for everything. The
spec names a call by what it is — Read, Search, Update, Task — and derives the
running line from its category, so "正在读取…" and "正在联网…" come out without
per-tool copy. The id has not gone anywhere; it is the tag beside the name.
2026-08-12 21:45:45 -07:00
yhh 48a9b49b1f perf(desktop): fetch KaTeX only once a formula appears
KaTeX and its fonts were half the bundle — 906kB down to 267kB by splitting
them out — and most coding sessions never produce a formula. The plugin and
its stylesheet now load on the first message that contains math, cached at
module scope so it happens once per run.

remark-math stays eager: it only parses $…$ into nodes, and until the chunk
lands those render as their source text, which still reads. A failed fetch
degrades the same way rather than blanking the message.

The detector uses no lookbehind. Older WebKit treats it as a syntax error and
takes the whole page down with it, which is how the macOS 12 blank screen
happened.
2026-08-12 21:43:29 -07:00
yhh 8f56a74b09 fix(store): register the wire log as a sidecar, not a session
Every session opened grew a phantom "archived" entry in the rail. The wire log
I added ends in .jsonl, and IsSessionTranscriptName classifies any .jsonl that
is not explicitly excluded as a primary transcript — the exact trap
SessionEventLogDamaged's comment already warns about. The listing was faithful;
the file had never been declared.

Declaring it once fixes more than the listing. SessionSidecarFiles is what
delete, rename, migration and recovery GC all walk, so an unregistered
companion would also have outlived the session it belongs to. serve now asks
store for the path instead of building the name itself, leaving one owner for
the convention.
2026-08-12 21:40:56 -07:00
yhh 854e1a1eeb feat(desktop): math, emoji and marked-up text in assistant output
KaTeX renders $…$ and $$…$$, :shortcodes: become emoji, and mark/sub/sup/kbd
are allowed through. Raw HTML is parsed and then cut back to an allowlist:
model output is untrusted markup, and nothing that can navigate, load or
script survives the filter. Subscript and superscript ride that same channel
rather than a shorthand plugin — Pandoc's ~2~ collides with GFM's
~~strikethrough~~, and one mechanism beats two that fight over a tilde.

Plugin order is load-bearing: raw parses HTML into nodes, sanitize prunes
them, katex renders after so its own generated markup is not pruned in turn.

Code blocks now render as .term, the box every tool result already uses, and
inline code falls to the .out .txt code rule the spec always had; the parallel
set I had invented for them is gone.

A long session title ran underneath the delete control, which is positioned
over the row. The title now stops short of it, reserved even while the button
is hidden so revealing it on hover does not reflow the text.
2026-08-12 21:32:58 -07:00
yhh 5f765dfb0b fix(desktop): let deleting the open session mean closing it
Deleting the active session raised 409 from the kernel, and the rail relayed
the transport string — "/delete-session: 409 cannot delete active session" —
which is plumbing, and offered on a button that could never succeed. Stepping
off the session first is what the action means: a new session becomes current,
the transcript clears with it, and the old file is then free to remove. The
confirm reads "关闭并删除" on that row so the consequence is stated before it
happens rather than explained after it fails.
2026-08-12 21:17:15 -07:00
yhh 446f1217f7 feat(serve): keep the trajectory, so a reopened session still has one
The pane was built from the live stream alone, so re-entering a session showed
an empty table. Nothing was on disk to reload: trajectory.Recorder is wired
into the CLI and e2ebench, never into serve, and the .events.jsonl beside each
session is a conversation projection with no timings, usage or audit records.

Serve now mirrors the broadcast frames it already hands the SSE clients into
<session>.wire.jsonl, and GET /trajectory reads them back. Frames are what the
pane consumes, so replaying them reproduces it row for row with no second
schema in between; internal/trajectory keeps recording its richer audit stream
for offline analysis, which answers a different question.

Only kinds the pane draws are kept. Streamed text and reasoning arrive one
frame per chunk and would be nearly the whole file while contributing no rows,
and dropping them is what makes it affordable to close the handle after every
write — a retained one blocks the session directory from being removed on
Windows, which is how TestConcurrentResumeAndForkKeepAlignment caught it.

Deleting the active session is refused by the kernel with 409, but the rail
collapsed the row and reloaded regardless, so a rejected delete looked like it
had worked. The failure now restores the row and surfaces the reason.
2026-08-12 21:07:32 -07:00
yhh 87307a326f fix(desktop): draw one ask card, and the one that can be answered
The ask tool raises both a tool_dispatch and an ask_request, and the transcript
drew a card for each: two copies of the same question, both answerable. Worse,
the copy built from the tool call carried tool.id where /answer expects the ask
id, so submitting it addressed a question the kernel has no record of and did
nothing. AskFromTool is gone; ask_request is the only interactive surface.

The trajectory footer claimed the rows came from what internal/trajectory
persists. Only the CLI builds a Recorder — serve builds none — so nothing is on
disk and re-entering a session shows an empty table. The note now says what the
pane actually is.
2026-08-12 20:58:39 -07:00
yhh 62eb586d5d fix(serve): stop offering the same model twice in the picker
Three provider blocks on one endpoint — a two-model block plus a single-model
block per model, each pinning its own price — made GET /models return the same
two models four times. The refs differ, so nothing was wrong with the data; the
picker just asked the user to choose between rows that resolve to the identical
endpoint, key and model. Entries are now collapsed by (endpoint, model).

The survivor is the active entry, so the current selection stays selectable,
then the default, then the single-model block, whose price table is the exact
one rather than a provider-wide fallback. Distinct endpoints are left alone:
one model name at two vendors is two real choices.
2026-08-12 20:55:39 -07:00
yhh 92ded763b3 fix(desktop): surface settings in the chrome and collapse deleted sessions
Settings only lived under the project picker, whose affordance is "switch
project" — nobody clicks a project name looking for preferences, and ⌘, only
helps people who already know it exists. That matters most exactly when it is
hardest: recovering from a bad API key requires finding this screen. It gets
a button in the chrome's icon cluster, reusing the theme toggle's class so it
sits in that weight class rather than competing with the preset control. The
menu entry and the shortcut stay; two doors to one room is not clutter.

Deleting a session snapped the row out instead of collapsing it: the CSS
transitions height, but the row is content-sized and height:auto has nothing
to animate from. Pin the measured height and force a paint before the
collapsing class lands. Leaving the row now cancels a pending confirm, which
had no way out short of deleting.
2026-08-12 20:43:24 -07:00
yhh 3944c6deba build(desktop): embed the app icon in the Reasonix Studio binary
Wails v2's windows options expose DisableWindowIcon but no Icon: the title
bar and taskbar read the icon out of the executable's resource section. The
shell builds with plain `go build`, which emits none, so it shipped with the
stock Go binary icon. go-winres compiles build/appicon.png into a .syso the
linker picks up automatically. Committed rather than generated at build time
so `go build ./next` alone produces a correctly branded binary.
2026-08-12 20:38:40 -07:00
yhh edaea4e3d8 feat(desktop): build out Reasonix Studio against the design spec
frontend-next had the design's stylesheet ported in full but only a fraction
of the markup using it. This fills in what the spec calls for: the project
picker, preset segmented control and theme cycle in the chrome; activity and
trajectory as sibling panes with a shared composer; model, effort, plan and
approval as real menus rather than labels; and the cache, subagent, job, plan
and pending-change blocks in the metrics rail. The traffic lights are gone —
they imitate a control Windows puts on the other side of the title bar — and
the wordmark takes that slot, masked so it follows the theme instead of
carrying one hard-coded blue. Tool glyphs move to lucide-react, matching the
version desktop/frontend already pins.

Six defects surfaced while wiring it to the real kernel:

/models returns {current,label,models}; the client parsed it as an array, so
the model menu would have thrown the first time it opened. Mid-turn input
went to /submit, which 409s once a turn holds the session — it belongs on
/inbox/items with intent=steer. The Wails allowlist was missing
/provider-setup, so it fell through to the SPA and the 200 HTML parsed as a
failure the caller swallowed: a user with no key never saw onboarding. The
mock probe treated vite's SPA fallback as a live kernel and booted SsePort
against HTML. A turn blocked on approval still read as running, so the glow
kept animating and the action slot offered stop instead of send.

The last one was the user's own message never appearing: both event.Message
emitters carry assistant text, so nothing on the wire echoes what was typed —
only /history has it, after a reload. The client owns that turn now, and the
dead itemId=="user" branch is gone. Assistant text renders as markdown
(react-markdown + remark-gfm, matching desktop/frontend) with the caret the
spec always had and nothing used.

The shell forwards broadcaster frames over the Wails bus. Its asset server
buffers a response until the handler returns, and the SSE handler never
returns, so /events delivered nothing inside the window — silently, with no
error to see. The payload is byte-identical to what the browser reads; only
the transport differs, and a replay path covers the reload the bus has no
handshake for. It also no longer mints a session file at launch, which left
an empty transcript behind every time the window opened.
2026-08-12 20:34:50 -07:00
yhh 1eb543f6d2 feat(serve): stop blocking the session list on title generation
GET /sessions generated a missing title inline, one model round-trip per
session, serialized. With 29 sessions and 17 cold entries the list took 11.7s
to render; warm it was 44ms. A title is decoration on a navigation list, so
the request no longer waits for one: a cache hit is used, a miss returns the
preview and queues generation on a bounded background pool that writes into
the same cache for the next read. Cold-start now measures 0.052s.

TestSessionTitleDoesNotBlockOnGeneration pins the contract with a provider
that never returns, so the wait can't come back. The recording fake grew a
mutex because generation moved off the request goroutine.

/status gains workspaceRoot. cwd reports SessionDir — where transcripts are
stored, not the tree the agent edits — so a client had no way to name the
workspace it is pointed at.

preset, model, effort and status move to settings.go. serve.go was 941 lines
over its repolint budget after the previous commit added the three endpoints
without resyncing the ratchet; extracting them puts it back under at 1642.
2026-08-12 20:34:16 -07:00
esengine 15dcde3973 feat(desktop): add frontend-next, a serve-backed UI, and its Wails shell
The existing desktop frontend talks to the kernel through Wails bindings, so
its App surface has grown to ~950 methods — most of them shell concerns (tabs,
themes, tray, updater) rather than kernel capability. A UI built on that is
welded to the shell.

frontend-next talks to control.Controller the way the chat TUI and the HTTP
server already do: as another frontend, over internal/serve. It depends on one
interface (AgentPort) with two implementations — SsePort for serve, MockPort
replaying a fixture — so the same build runs in a browser with no Go process
and inside Wails with no port open. desktop/next mounts serve.Handler() as
AssetServer middleware: in-process HTTP, no port, no CORS, no second transport
to keep in sync.

port/wire.ts mirrors internal/eventwire field for field. That is deliberate:
every place this file was written from what seemed reasonable instead of what
the kernel emits turned out wrong — /history returns the provider conversation
rather than the event stream, tool calls are camelCase, todo payloads live in
args and not output, user turns arrive wrapped in control-plane blocks that are
instructions to the model rather than anything the user said, and /submit takes
"input", not "text". A mismatched key there is a request that 400s with nothing
on screen, so the client now surfaces failed writes instead of dropping them.

The existing frontend, main.go and wails.json are untouched; the new shell is a
separate main package and builds with -tags desktop,production.
2026-08-12 18:16:20 -07:00
esengine f3e283ae58 feat(serve): expose preset, model and effort as endpoints
The controller already had SetAgentPreset, and serve already had switchModel
and switchEffort — but the only way to reach the latter two over HTTP was by
sending "/model <ref>" through /submit and having the handler intercept the
string. A browser client had to compose slash commands to change runtime state.

Add POST /preset, /model and /effort, each forwarding to what already exists.
/status gains preset, effort, modelRef and sessionPath: without them a client
can set these but never read them back, so the controller stops being the
single source of truth for its own settings. sessionPath in particular is what
lets a client tell which session it is in — a fresh session has not been
written to disk yet, so it is absent from the /sessions directory listing.
2026-08-12 18:15:34 -07:00
4138 changed files with 695289 additions and 41412 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Copy to .env and fill in. Only secrets belong here — a provider's base_url and
# model are configured in reasonix.toml, where api_key_env points at these variables.
DEEPSEEK_API_KEY=
MIMO_API_KEY=
+18
View File
@@ -1,11 +1,29 @@
.gitattributes text eol=lf
.gitignore text eol=lf
.npmrc text eol=lf
*.astro text eol=lf
*.bat text eol=crlf
*.css text eol=lf
*.go text eol=lf
*.html text eol=lf
*.js text eol=lf
*.json text eol=lf
*.md text eol=lf
*.mjs text eol=lf
*.mod text eol=lf
*.nsi text eol=lf
*.plist text eol=lf
*.py text eol=lf
*.rs text eol=lf
*.sh text eol=lf
*.sql text eol=lf
*.sum text eol=lf
*.toml text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.txt text eol=lf
*.xml text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
.githooks/** text eol=lf
benchmarks/**/*.sh text eol=lf
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# Go-native pre-push gate replacing the retired v1 npm hook (install: make hooks).
# Full tests run in CI; vet is the fast, cross-platform local check.
set -e
go vet ./...
go run ./tools/repolint
+14
View File
@@ -0,0 +1,14 @@
# Every pull request requests review from the maintainers below.
# Either maintainer can review and merge any change.
* @SivanCola @esengine
# Keep the provider-side signing policy and its GitHub control plane under the
# same explicit review boundary as the machine-readable contract.
/.signpath/ @SivanCola @esengine
/.github/workflows/release-stable.yml @SivanCola @esengine
/.github/workflows/release-stable-trigger.yml @SivanCola @esengine
/.github/workflows/release-desktop.yml @SivanCola @esengine
/scripts/complete-signpath-request.ps1 @SivanCola @esengine
/scripts/package-windows-desktop.sh @SivanCola @esengine
/scripts/verify-windows-authenticode.ps1 @SivanCola @esengine
/cmd/signpath-contract/ @SivanCola @esengine
+60
View File
@@ -0,0 +1,60 @@
name: Bug report
description: Report a bug in DeepSeek-Reasonix
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for filing a bug. Pick which version line you're on below — it gets
labeled (`v1`/`v2`) automatically, no maintainer action needed.
- type: dropdown
id: version-line
attributes:
label: Version line
description: Which line are you running?
options:
- "v2 — Go rewrite (1.x), main-v2 (active development)"
- "v1 — Legacy TypeScript (0.x), maintenance only"
validations:
required: true
- type: input
id: version
attributes:
label: Exact version
description: Output of `reasonix --version`.
placeholder: e.g. 1.0.0
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: A clear description of the bug, and what you expected instead.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
placeholder: |
1. ...
2. ...
3. ...
validations:
required: true
- type: input
id: os
attributes:
label: OS / platform
placeholder: e.g. macOS 15.3 (arm64), Ubuntu 24.04, Windows 11
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant logs or output
description: Paste any error output. Automatically formatted as code.
render: shell
validations:
required: false
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions & discussions
url: https://github.com/esengine/DeepSeek-Reasonix/discussions
about: For usage questions, ideas, and general discussion — please use Discussions instead of opening an issue.
@@ -0,0 +1,29 @@
name: Feature request
description: Suggest an idea or improvement
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: dropdown
id: version-line
attributes:
label: Version line
description: Which line is this for?
options:
- "v2 — Go rewrite (1.x), main-v2 (active development)"
- "v1 — Legacy TypeScript (0.x), maintenance only"
validations:
required: true
- type: textarea
id: problem
attributes:
label: What problem does this solve?
description: What are you trying to do, and what gets in the way today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: How would you like it to work?
validations:
required: false
+49
View File
@@ -0,0 +1,49 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
go:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "gomod"
directory: "/desktop"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
go:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "npm"
directory: "/desktop/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
npm:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "npm"
directory: "/site"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
npm:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns: ["*"]
+51
View File
@@ -0,0 +1,51 @@
# Area labels auto-applied to PRs by .github/workflows/pr-auto-label.yml
# (actions/labeler), based on the changed file paths. Keep the area set in sync
# with issue-auto-label.yml. severity/platform are issue-only — a PR is a change,
# not a bug report — and v1/v2 are handled by pr-version-label.yml.
agent:
- changed-files:
- any-glob-to-any-file:
- 'internal/agent/**'
- 'internal/control/**'
mcp:
- changed-files:
- any-glob-to-any-file:
- 'internal/plugin/**'
- 'internal/codegraph/**'
config:
- changed-files:
- any-glob-to-any-file:
- 'internal/config/**'
- 'internal/boot/**'
provider:
- changed-files:
- any-glob-to-any-file:
- 'internal/provider/**'
skills:
- changed-files:
- any-glob-to-any-file:
- 'internal/skill/**'
- 'internal/tool/**'
tui:
- changed-files:
- any-glob-to-any-file:
- 'internal/cli/**'
desktop:
- changed-files:
- any-glob-to-any-file:
- 'desktop/**'
updater:
- changed-files:
- any-glob-to-any-file:
- 'desktop/cmd/sign/**'
- 'desktop/updater*.go'
- 'scripts/desktop-build.sh'
- '.github/workflows/release*.yml'
+44
View File
@@ -0,0 +1,44 @@
## Summary
-
## Issues
<!--
If this resolves a report, put `Fixes #123` on its own line — GitHub only
auto-closes from a bare line, so `- Fixes #123` in a list does nothing and the
report stays open. If it only relates to one, use `Refs #123` instead: the
release workflow then asks that reporter to verify once the fix ships.
-->
## Verification
-
For a GSAP to WAAPI/CSS migration (or any cross-API replacement), document
the source-to-target contract here: easing syntax, time units, callbacks,
cancellation, reduced-motion behavior, and failure fallback. Verification must
assert that the target API was actually called; a mock that silently skips it
does not count.
## Documentation impact
Documentation-impact: TODO
For changes to user-visible CLI, Desktop, configuration, provider, permission,
or tool behavior, use one of:
- `Documentation-impact: updated - <what changed>` and update `docs/*.md`.
- `Documentation-impact: none - <why the embedded documentation remains correct>`.
## Cache impact
Cache-impact: TODO
Cache-guard: TODO
System-prompt-review: N/A
For cache-sensitive changes, fill these lines before requesting review:
- `Cache-impact`: `none`, `low`, `medium`, or `high`, plus the reason.
- `Cache-guard`: the focused guard test/command added or run, or why an existing guard covers the change.
- `System-prompt-review`: required reviewer/approval note when provider-visible system prompt, memory prefix, output style, or skill index behavior changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+49
View File
@@ -0,0 +1,49 @@
name: Cache impact guard
on:
pull_request_target:
branches: [main-v2]
types: [opened, synchronize, reopened, edited, ready_for_review]
# The guard only cares about the prompt/tool surfaces listed in
# scripts/check-cache-impact.sh; PRs confined to clearly unrelated paths
# can never trip it, so skip the run entirely. Keep the ignore list
# conservative: when in doubt, run.
paths-ignore:
- 'docs/**'
- 'site/**'
- 'release-notes/**'
- 'workers/**'
- 'benchmarks/**'
- 'npm/**'
- '*.md'
permissions:
contents: read
pull-requests: read
concurrency:
group: cache-impact-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
cache-impact:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Collect PR changed files
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" \
--jq '.[].filename' > "$RUNNER_TEMP/cache-impact-files.txt"
- name: Check cache-sensitive PR metadata
env:
PR_BODY: ${{ github.event.pull_request.body }}
CACHE_IMPACT_CHANGED_FILES_FILE: ${{ runner.temp }}/cache-impact-files.txt
run: bash scripts/check-cache-impact.sh
+532
View File
@@ -0,0 +1,532 @@
name: CI
# studio is a parallel release line, not a feature branch: it carries the
# kernel changes Studio is built on, so it needs the same kernel gates main-v2
# has. Studio's own surface (desktop/next, desktop/frontend-next) exists only
# there and is covered by studio.yml.
on:
push:
branches: [main-v2, studio]
pull_request:
branches: [main-v2, studio]
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
# Cheap path gate for pull requests. PRs confined to docs/site/release-notes
# (or top-level Markdown) skip the heavy Go jobs, and PRs that cannot affect
# the desktop module skip the desktop jobs. Job-level `if` reports skipped,
# which satisfies the required status checks (lint, race, test) — a
# workflow-level paths-ignore would leave required checks pending and block
# merges. Gated jobs skip only on an explicit `false` output: wrapped in
# `always()`, a failed `changes` job (or a missing output) makes them run
# the full matrix instead of silently passing required checks as skipped.
# Pushes to main-v2 always run everything.
changes:
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
desktop: ${{ steps.filter.outputs.desktop }}
site: ${{ steps.filter.outputs.site }}
sdk: ${{ steps.filter.outputs.sdk }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: filter
run: |
code=true; desktop=true; site=true; sdk=true
base=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
elif [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
base="${{ github.event.before }}"
fi
if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
files=$(git diff --name-only "$base" HEAD)
if [ -n "$files" ]; then
code=false; desktop=false; site=false; sdk=false
# Root-module CI: desktop/ is a separate module, so only the
# clearly unrelated paths below can skip it.
if echo "$files" | grep -qvE '^(docs/|site/|release-notes/|desktop/|workers/|[^/]+\.md$)'; then code=true; fi
# desktop/ imports the root kernel via `replace reasonix => ../`,
# so only this clearly-unrelated set is safe to skip.
if echo "$files" | grep -qvE '^(docs/|site/|release-notes/|workers/|benchmarks/|npm/|[^/]+\.md$)'; then desktop=true; fi
if echo "$files" | grep -q '^site/'; then site=true; fi
# sdk/go is a nested module invisible to root `go test ./...`;
# its DTOs are generated from internal/extension/protocol, so
# both paths must trigger the sdk job.
if echo "$files" | grep -qE '^(sdk/|internal/extension/)'; then sdk=true; fi
fi
fi
{ echo "code=$code"; echo "desktop=$desktop"; echo "site=$site"; echo "sdk=$sdk"; } >> "$GITHUB_OUTPUT"
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
# those required checks would stay "Expected" and block merging. The job
# therefore always runs and the steps do the gating: when the changes
# detector reports the diff is unrelated, every step skips and each leg
# reports success in seconds. `always()` also keeps the legs alive when
# the changes job itself fails (fail-open: an empty output != 'false').
test:
needs: changes
if: always()
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
env:
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' }}
steps:
- if: env.RUN_STEPS == 'true'
uses: actions/checkout@v7
- if: env.RUN_STEPS == 'true'
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Install and verify Linux sandbox backend
if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
sudo sysctl -w kernel.unprivileged_userns_clone=1
fi
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
bwrap --ro-bind / / --dev /dev --proc /proc -- true || \
echo "::warning::bubblewrap is installed but this runner denies user namespaces; unavailable-backend tests will run fail-closed"
# Skipped on Windows: the runner checks out CRLF, so gofmt -l flags every
# file. gofmt output is OS-independent, so the Unix legs already cover it.
- name: gofmt
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
run: |
# Root module only — desktop/ is a separate module with its own tooling.
unformatted=$(gofmt -l . | grep -v '^desktop/' || true)
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "$unformatted"
exit 1
fi
- name: vet
if: env.RUN_STEPS == 'true'
run: go vet ./...
- name: build
if: env.RUN_STEPS == 'true'
run: go build ./...
- name: test
if: env.RUN_STEPS == 'true' && runner.os != 'Windows'
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
# built around.
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test ./...
# Pull requests run a Windows smoke suite: the packages that actually
# carry *_windows.go code, the startup/checkpoint packages with portable
# filesystem contracts, plus cmd/. The full ./... sweep stays on pushes
# to main-v2; the Unix legs always run the full suite. Keep the job-level
# budget above normal 7-9 minute runner variance; each package test binary
# remains independently bounded by Go's per-package timeout below.
#
# That timeout is 8m, not 3m: across four consecutive runs of the same
# code, internal/boot measured 133s, 146s, 162s and then hit 180s, and
# internal/agent 82s, 92s, 144s and 180s — a 2.2x spread from runner
# variance alone, with four heavy packages sharing four vCPUs. At 3m the
# step failed on slow runners without a code change; a hang still trips
# the 15 minute job budget above.
- name: test (Windows smoke)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 15
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=8m ./internal/agent/... ./internal/appidentity/... ./internal/boot/... ./internal/checkpoint/... ./internal/cli/... ./internal/control/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/instruction/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
# The general Windows PR smoke list intentionally omits internal/tool.
# Keep the session-temp portability contract covered without widening the
# leg to every tool test: two real PowerShell launches must share the
# injected TMPDIR/TMP/TEMP directory.
- name: test (Windows session temp)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 3
run: go test -timeout=2m -run '^TestBashSharesSessionTempAcrossCalls$' ./internal/tool/builtin
# Shell execution contract: PowerShell identity, Chinese workspace paths,
# UTF-8 output, and ExitCode retention must gate PRs on native Windows.
# Keep this focused (not full ./internal/tool) so the smoke budget holds.
- name: test (Windows shell execution contract)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
run: go test -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tool/builtin
# Full push suite still uses the same 8m package budget as Windows smoke:
# agent/boot/control already hit 180s package timeouts under runner load,
# so 3m fails on slow machines without a code regression. Keep the step
# above normal full-suite wall time (~8-12m) so a hang still dies on the
# job budget rather than a false package timeout.
- name: test (full)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
timeout-minutes: 20
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
# built around.
REASONIX_RELEASE_CACHE_GUARD: "1"
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=8m ./...
race:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Install and verify Linux sandbox backend
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
sudo sysctl -w kernel.unprivileged_userns_clone=1
fi
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
bwrap --ro-bind / / --dev /dev --proc /proc -- true || \
echo "::warning::bubblewrap is installed but this runner denies user namespaces; unavailable-backend tests will run fail-closed"
# The matrix never runs -race (it needs cgo); the project's concurrency
# (plugin fan-out, background phase B, jobs Kill/Wait) would otherwise
# ship without race coverage. Pull requests sweep only the
# concurrency-heavy packages so this required check stays fast; pushes
# to main-v2 keep the full ./... sweep as the safety net.
- name: test -race (concurrency packages)
if: github.event_name == 'pull_request'
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./internal/agent/... ./internal/plugin/... ./internal/jobs/... ./internal/proc/... ./internal/sandbox/... ./internal/filelock/... ./internal/eventwire/... ./internal/remote/... ./internal/extension/... ./internal/boot/... ./internal/control/... ./internal/tool/...
- name: test -race (full)
if: github.event_name != 'pull_request'
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./...
# sdk/go is a nested stdlib-only module invisible to root `go test ./...`.
# Its DTOs are generated from internal/extension/protocol, and the
# host-side conformance tests spawn the SDK example, so the job runs the
# same three-OS matrix as the root tests.
sdk:
needs: changes
if: always()
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
env:
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.sdk != 'false' }}
defaults:
run:
working-directory: sdk/go
steps:
- if: env.RUN_STEPS == 'true'
uses: actions/checkout@v7
- if: env.RUN_STEPS == 'true'
uses: actions/setup-go@v6
with:
go-version-file: sdk/go/go.mod
- name: gofmt
if: env.RUN_STEPS == 'true'
shell: bash
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "$unformatted"
exit 1
fi
- name: vet
if: env.RUN_STEPS == 'true'
run: go vet ./...
- name: stdlib-only guard
if: env.RUN_STEPS == 'true'
shell: bash
run: |
# The SDK is a public module with a hard stdlib-only contract.
if go list -m all | grep -v '^github.com/esengine/DeepSeek-Reasonix/sdk/go$'; then
echo "sdk/go must not depend on anything outside the standard library"
exit 1
fi
- name: test
if: env.RUN_STEPS == 'true'
run: go test ./...
- name: test -race
if: github.event_name != 'pull_request' && env.RUN_STEPS == 'true'
run: go test -race ./...
desktop:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
runs-on: ubuntu-22.04
defaults:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- name: gofmt
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "These files are not gofmt-clean:"
echo "$unformatted"
exit 1
fi
- name: go.mod tidy
run: |
go mod tidy
if ! git diff --quiet -- go.mod go.sum; then
echo "desktop/go.mod or go.sum is stale - run 'cd desktop && go mod tidy' and commit."
git diff -- go.mod go.sum
exit 1
fi
# WebKitGTK 4.1 is what studio-build.sh links against: 4.0
# (libwebkit2gtk-4.0.so.37) is gone on Ubuntu 24.04+ and Fedora 40+.
- name: Install Linux build deps
run: |
sudo apt-get update
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
- name: vet
run: go vet -tags webkit2_41 ./...
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
working-directory: desktop
args: --timeout=5m
- name: build
run: go build -tags webkit2_41 ./...
- name: test
run: go test -tags webkit2_41 ./...
# The shell's tab/rebuild fences and extension UI are shared state.
# Race-sweep the module so regressions are CI-blocked, not just
# author-verified locally.
- name: test -race
run: go test -race -tags webkit2_41 ./...
# desktop/ is a separate module, so the root macOS matrix above does not
# compile it against a real macOS toolchain.
desktop-macos:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
runs-on: macos-latest
defaults:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- name: test
run: go test -race ./...
# desktop's *_darwin.go is the one build-tag set the lint job cannot
# reach: the dock-icon glue is cgo, so it only type-checks with a real
# macOS toolchain.
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
working-directory: desktop
args: --timeout=5m
# The update helper's versioned-install and uninstall paths are Windows-only
# (//go:build windows), so the ubuntu leg above never compiles them.
desktop-windows:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
runs-on: windows-latest
defaults:
run:
shell: bash
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
# The vendored WebView2 fork carries the mixed-DPI and proxy-isolation
# patches, and its own tests only compile on Windows.
- name: test (Windows shell, update helper, and the WebView2 fork)
timeout-minutes: 15
run: |
go test ./...
(cd third_party/go-webview2 && go test ./pkg/edge)
# repolint scans desktop/ and sdk/ too, so this job also runs for diffs the
# `code` filter would otherwise skip.
lint:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' || needs.changes.outputs.desktop != 'false' || needs.changes.outputs.sdk != 'false')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: repo standards
run: go run ./tools/repolint
# `make lint-install` reads the same file, so a local run and this job
# cannot drift onto different linter versions.
- id: golangci
run: echo "version=$(cat .golangci-version)" >> "$GITHUB_OUTPUT"
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: ${{ steps.golangci.outputs.version }}
args: --timeout=5m
# The step above only type-checks the linux/amd64 build, so every
# //go:build windows and //go:build darwin file in the tree went unlinted.
# Both modules cross-check without a toolchain; desktop under darwin does
# not, because its watchdog is cgo, so that leg lives in desktop-macos.
- name: golangci-lint (cross-platform build tags)
run: |
set -euo pipefail
command -v golangci-lint
for target in "darwin ." "windows ." "windows desktop"; do
read -r target_os target_dir <<< "$target"
echo "::group::golangci-lint GOOS=$target_os ($target_dir)"
(cd "$target_dir" && GOOS="$target_os" golangci-lint run --timeout=5m ./...)
echo "::endgroup::"
done
- name: release workflow contracts
run: |
go run github.com/rhysd/actionlint/cmd/[email protected] \
-ignore 'label "windows-11-arm" is unknown' \
.github/workflows/prepare-release-notes.yml \
.github/workflows/release-studio.yml \
.github/workflows/release-verify-issues.yml \
.github/workflows/docs-impact.yml \
.github/workflows/ci.yml
node scripts/check-single-release-public-contract.mjs
# The site/ auth client has security-sensitive redirect-validation logic
# (safeNext) covered by node:test unit tests. Those tests use only Node
# builtins, so no `npm install` is needed — run them directly on every PR so
# a regression in redirect validation fails the build instead of shipping.
site:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.site != 'false')
runs-on: ubuntu-latest
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "24"
- name: test
run: npm test
govulncheck:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
runs-on: ubuntu-latest
continue-on-error: true # informational — stdlib vulns need a Go patch release
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: govulncheck
run: govulncheck ./...
coverage:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: test with coverage
run: go test -coverprofile=coverage.out -covermode=atomic ./...
- name: upload coverage
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: coverage.out
retention-days: 7
+50
View File
@@ -0,0 +1,50 @@
name: CodeQL
on:
# Docs/release-notes/root-Markdown changes carry no analyzable code; the
# weekly cron keeps the full scan cadence regardless.
push:
branches: ["main-v2"]
paths-ignore:
- 'docs/**'
- 'release-notes/**'
- '*.md'
pull_request:
branches: ["main-v2"]
paths-ignore:
- 'docs/**'
- 'release-notes/**'
- '*.md'
schedule:
- cron: "27 3 * * 1"
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
- language: actions
build-mode: none
steps:
- uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/[email protected]
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/[email protected]
with:
category: "/language:${{ matrix.language }}"
+11 -63
View File
@@ -1,27 +1,30 @@
name: Deploy identity and remote services
name: Deploy accounts worker
on:
push:
branches: [main-v2]
paths:
- 'workers/accounts/**'
- '.github/workflows/deploy-accounts-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-identity-and-remote-services
group: deploy-accounts-worker
cancel-in-progress: false
jobs:
deploy-accounts:
if: github.ref == 'refs/heads/platform'
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6.0.9
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
@@ -34,7 +37,6 @@ jobs:
pnpm install --frozen-lockfile
pnpm test
pnpm typecheck
pnpm db:apply:remote
npx wrangler deploy
- name: Sync RESEND_API_KEY to the worker
working-directory: workers/accounts
@@ -47,57 +49,3 @@ jobs:
else
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
fi
- name: Sync remote gateway secret
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
REMOTE_GATEWAY_TOKEN: ${{ secrets.REMOTE_GATEWAY_TOKEN }}
run: |
if [ -z "$REMOTE_GATEWAY_TOKEN" ]; then
echo "REMOTE_GATEWAY_TOKEN must be configured before enabling remote access."
exit 1
fi
printf '%s' "$REMOTE_GATEWAY_TOKEN" | npx wrangler secret put REMOTE_GATEWAY_TOKEN
- name: Sync feedback account secret
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FEEDBACK_ACCOUNT_SECRET: ${{ secrets.FEEDBACK_ACCOUNT_SECRET }}
run: |
if [ -n "$FEEDBACK_ACCOUNT_SECRET" ]; then
printf '%s' "$FEEDBACK_ACCOUNT_SECRET" | npx wrangler secret put FEEDBACK_ACCOUNT_SECRET
else
echo "FEEDBACK_ACCOUNT_SECRET not set in repo secrets; feedback account linking stays unavailable."
fi
deploy-remote-gateway:
needs: deploy-accounts
if: github.ref == 'refs/heads/platform'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
cache: npm
cache-dependency-path: workers/remote-gateway/package-lock.json
- name: Validate gateway secret
env:
REMOTE_GATEWAY_TOKEN: ${{ secrets.REMOTE_GATEWAY_TOKEN }}
run: test -n "$REMOTE_GATEWAY_TOKEN"
- name: Validate and deploy
working-directory: workers/remote-gateway
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npm ci
npm test
npm run typecheck
npx wrangler deploy
- name: Sync gateway secret
working-directory: workers/remote-gateway
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
REMOTE_GATEWAY_TOKEN: ${{ secrets.REMOTE_GATEWAY_TOKEN }}
run: printf '%s' "$REMOTE_GATEWAY_TOKEN" | npx wrangler secret put REMOTE_GATEWAY_TOKEN
+7 -201
View File
@@ -1,23 +1,12 @@
name: Deploy crash worker
on:
push:
branches: [main-v2]
paths:
- 'workers/crash-report/**'
- '.github/workflows/deploy-crash-worker.yml'
workflow_dispatch:
inputs:
firebase_data_action:
description: Firebase crash history operation (migration actions do not deploy the Worker)
required: true
default: none
type: choice
options:
- none
- dry-run
- apply
- verify-only
confirmation:
description: Enter APPLY_FIREBASE_CRASH_DATA when selecting apply
required: false
default: ''
type: string
permissions:
contents: read
@@ -28,12 +17,10 @@ concurrency:
jobs:
deploy:
if: github.ref == 'refs/heads/platform' && inputs.firebase_data_action == 'none'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
@@ -49,129 +36,12 @@ jobs:
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:diagnostics-v2
- name: Apply and verify Firebase crash D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:firebase-crash
- name: Apply dashboard performance indexes
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:dashboard-indexes
- name: Apply telemetry queue receipts schema
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npx wrangler d1 execute reasonix-crash --remote --file=migrate-telemetry-queue.sql
- name: Apply and verify feedback D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback.sql
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ('feedback','feedback_quota','feedback_status_created','feedback_install_created','feedback_status_version','feedback_quota_day')")
for name in feedback feedback_quota feedback_status_created feedback_install_created feedback_status_version feedback_quota_day; do
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
done
- name: Apply and verify feedback triage D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback-triage.sql
names="feedback_releases feedback_releases_install feedback_replies feedback_blocks feedback_trust feedback_public_images feedback_config feedback_audit feedback_replies_receipt feedback_replies_unhandled feedback_blocks_expires feedback_public_images_receipt feedback_audit_at feedback_install_status_updated feedback_reply_attachments feedback_reply_attachments_receipt"
list=$(for n in $names; do printf "'%s'," "$n"; done | sed 's/,$//')
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ($list)")
for name in $names; do
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
done
echo "Trusted installs (hash prefix only) for review:"
npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT substr(install_hash,1,8) AS prefix, created_at, expires_at FROM feedback_trust ORDER BY created_at LIMIT 200"
- name: Apply and verify feedback adoption D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback-adoptions.sql
names="feedback_adoptions feedback_adoptions_install feedback_adoptions_item feedback_level_state"
list=$(for n in $names; do printf "'%s'," "$n"; done | sed 's/,$//')
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ($list)")
for name in $names; do
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
done
- name: Apply and verify feedback account link D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback-account-links.sql
names="feedback_account_links feedback_account_links_account"
list=$(for n in $names; do printf "'%s'," "$n"; done | sed 's/,$//')
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ($list)")
for name in $names; do
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
done
- name: Apply and verify feedback contribution board D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler d1 execute reasonix-crash --remote --file=migrate-feedback-board.sql
names="feedback_board_optin feedback_board_delisted"
list=$(for n in $names; do printf "'%s'," "$n"; done | sed 's/,$//')
out=$(npx wrangler d1 execute reasonix-crash --remote --json --command "SELECT name FROM sqlite_master WHERE name IN ($list)")
for name in $names; do
printf '%s' "$out" | grep -q "\"$name\"" || { echo "missing $name after migration"; exit 1; }
done
- name: Apply Studio telemetry schema
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npx wrangler d1 execute reasonix-crash --remote --file=migrate-studio-telemetry.sql
- name: Apply isolated telemetry database schema
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:telemetry-db
- name: Apply registry performance indexes and install rollup
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:registry-indexes
- name: Sync Firebase crash secrets
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
run: |
configured=0
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
if [ "$configured" -ne 0 ] && [ "$configured" -ne 3 ]; then
echo "Firebase crash secrets must be either all configured or all absent."
exit 1
fi
node -e 'process.stdout.write(JSON.stringify({FIREBASE_DATABASE_URL:process.env.FIREBASE_DATABASE_URL||null,FIREBASE_CLIENT_EMAIL:process.env.FIREBASE_CLIENT_EMAIL||null,FIREBASE_PRIVATE_KEY:process.env.FIREBASE_PRIVATE_KEY||null}))' | npx wrangler secret bulk
- name: Deploy
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler deploy
- name: Sync feedback secrets
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FEEDBACK_TOKEN_SECRET: ${{ secrets.FEEDBACK_TOKEN_SECRET }}
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
OPS_EMIT_TOKEN: ${{ secrets.OPS_EMIT_TOKEN }}
FEEDBACK_ACCOUNT_SECRET: ${{ secrets.FEEDBACK_ACCOUNT_SECRET }}
run: |
node -e 'process.stdout.write(JSON.stringify({FEEDBACK_TOKEN_SECRET:process.env.FEEDBACK_TOKEN_SECRET||null,FEEDBACK_ADMIN_TOKEN:process.env.FEEDBACK_ADMIN_TOKEN||null,OPS_EMIT_TOKEN:process.env.OPS_EMIT_TOKEN||null,FEEDBACK_ACCOUNT_SECRET:process.env.FEEDBACK_ACCOUNT_SECRET||null}))' | npx wrangler secret bulk
# Mirrors the ALERT_WEBHOOK repo secret into the worker so the ingest
# sentinel can push alerts (see runIngestSentinel). Managed here so
# nobody needs local Cloudflare credentials; when the repo secret is
@@ -188,67 +58,3 @@ jobs:
exit 0
fi
printf '%s' "$ALERT_WEBHOOK" | npx wrangler secret put ALERT_WEBHOOK
migrate-firebase-data:
if: github.event_name == 'workflow_dispatch' && inputs.firebase_data_action != 'none'
runs-on: ubuntu-latest
environment: canary
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Require the protected production branch
run: |
if [ "$GITHUB_REF" != "refs/heads/platform" ]; then
echo "Firebase crash data migration must run from platform."
exit 1
fi
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- name: Validate
working-directory: workers/crash-report
run: |
npm ci
npm run typecheck
npm test
- name: Run Firebase crash history operation
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
FIREBASE_DATA_ACTION: ${{ inputs.firebase_data_action }}
FIREBASE_DATA_CONFIRMATION: ${{ inputs.confirmation }}
run: |
configured=0
[ -n "$CLOUDFLARE_API_TOKEN" ] && configured=$((configured + 1))
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
if [ "$configured" -ne 4 ]; then
echo "Cloudflare and Firebase migration secrets must all be configured."
exit 1
fi
case "$FIREBASE_DATA_ACTION" in
dry-run)
npm run migrate:firebase-data
;;
apply)
if [ "$FIREBASE_DATA_CONFIRMATION" != "APPLY_FIREBASE_CRASH_DATA" ]; then
echo "Apply requires the exact confirmation APPLY_FIREBASE_CRASH_DATA."
exit 1
fi
npm run migrate:firebase-data -- --apply
npm run migrate:firebase-data -- --verify-only
;;
verify-only)
npm run migrate:firebase-data -- --verify-only
;;
*)
echo "Unsupported Firebase crash data action."
exit 1
;;
esac
+8 -6
View File
@@ -1,6 +1,11 @@
name: Deploy forum worker
on:
push:
branches: [main-v2]
paths:
- 'workers/forum/**'
- '.github/workflows/deploy-forum-worker.yml'
workflow_dispatch:
permissions:
@@ -12,16 +17,14 @@ concurrency:
jobs:
deploy:
if: github.ref == 'refs/heads/platform'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6.0.9
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
@@ -34,5 +37,4 @@ jobs:
pnpm install --frozen-lockfile
pnpm test
pnpm typecheck
npx wrangler d1 execute reasonix-forum --remote --file=migrate-performance-indexes.sql
npx wrangler deploy
-44
View File
@@ -1,44 +0,0 @@
name: Deploy ops events relay
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-ops-events
cancel-in-progress: false
jobs:
deploy:
if: github.ref == 'refs/heads/platform'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/ops-events/package-lock.json
- name: Validate
working-directory: workers/ops-events
run: |
npm ci
npm run typecheck
npm test
- name: Deploy
working-directory: workers/ops-events
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npx wrangler deploy
- name: Sync secrets
working-directory: workers/ops-events
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
GITHUB_WEBHOOK_SECRET: ${{ secrets.OPS_GITHUB_WEBHOOK_SECRET }}
OPS_LISTEN_TOKEN: ${{ secrets.OPS_LISTEN_TOKEN }}
OPS_EMIT_TOKEN: ${{ secrets.OPS_EMIT_TOKEN }}
run: |
node -e 'process.stdout.write(JSON.stringify({GITHUB_WEBHOOK_SECRET:process.env.GITHUB_WEBHOOK_SECRET||null,OPS_LISTEN_TOKEN:process.env.OPS_LISTEN_TOKEN||null,OPS_EMIT_TOKEN:process.env.OPS_EMIT_TOKEN||null}))' | npx wrangler secret bulk
+39
View File
@@ -0,0 +1,39 @@
name: Documentation impact guard
on:
pull_request_target:
branches: [main-v2]
types: [opened, synchronize, reopened, edited, ready_for_review]
permissions:
contents: read
pull-requests: read
concurrency:
group: docs-impact-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
docs-impact:
runs-on: ubuntu-latest
steps:
# Execute only the trusted base-branch checker; never run contributor code
# from a pull_request_target workflow.
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Collect PR changed files
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" \
--jq '.[].filename' > "$RUNNER_TEMP/docs-impact-files.txt"
- name: Check documentation impact declaration
env:
PR_BODY: ${{ github.event.pull_request.body }}
DOCS_IMPACT_CHANGED_FILES_FILE: ${{ runner.temp }}/docs-impact-files.txt
run: bash scripts/check-docs-impact.sh
+191
View File
@@ -0,0 +1,191 @@
name: e2e-bot
# Comment "/e2e" (fixed suite) or "/e2e diff" (generate tests for the PR's diff)
# on a pull request to run the e2e benchmark against the real provider and post a
# report back. Gated to trusted authors: the job checks out PR-head code and runs
# it with the provider API key, so only the repo owner, members, and collaborators
# may trigger it.
on:
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
concurrency:
group: e2e-bot-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
e2e:
if: >-
github.event.issue.pull_request &&
contains(github.event.comment.body, '/e2e') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
runs-on: ubuntu-latest
# Running PR-head code with the provider secret is gated twice: the author_
# association check above, and this deployment environment. Configure the
# `e2e-bot` environment with required reviewers in repo settings to force a
# human approval per run (actions/untrusted-checkout-toctou).
environment: e2e-bot
steps:
- name: Acknowledge
uses: actions/github-script@v9
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner, repo: context.repo.repo,
comment_id: context.payload.comment.id, content: 'eyes',
});
# Default-branch checkout: this is where the harness (cmd/e2ebench), the
# suite, and a run --metrics-capable agent live.
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-python@v7
with:
python-version: '3.12'
- name: Install and verify Linux sandbox backend
run: |
sudo apt-get update
sudo apt-get install -y bubblewrap
if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
sudo sysctl -w kernel.unprivileged_userns_clone=1
fi
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi
bwrap --ro-bind / / --dev /dev --proc /proc -- true
- name: Build harness + fallback agent from the default branch
# Harness (e2ebench) and suite always come from main-v2 so a PR can't weaken
# its own grader or tests. The agent is rebuilt from the PR head below; this
# main-v2 build is only the fallback for heads that predate `run --metrics`.
run: |
go build -o "$RUNNER_TEMP/reasonix-base" ./cmd/reasonix
go build -o "$RUNNER_TEMP/e2ebench" ./cmd/e2ebench
cp -r benchmarks/e2e "$RUNNER_TEMP/suite"
- name: Check out the PR head
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Pin to the head commit resolved now and check it out detached, not the
# mutable PR ref: a force-push mid-run can't swap in different code after
# the trusted-author gate passed.
run: |
SHA=$(gh pr view ${{ github.event.issue.number }} --json headRefOid -q .headRefOid)
git fetch -q origin "$SHA"
git checkout -q --detach "$SHA"
- name: Build the agent from the PR head
# The whole point of the bot is to drive the PR's code, not main-v2's. Fall
# back to the main-v2 build only when the PR head can't yield a
# run --metrics-capable binary (build break or predates the flag).
id: agent
run: |
bin="$RUNNER_TEMP/reasonix-base"
src="main-v2 fallback (PR head lacks run --metrics)"
if go build -o "$RUNNER_TEMP/reasonix-pr" ./cmd/reasonix \
&& "$RUNNER_TEMP/reasonix-pr" run -h 2>&1 | grep -q -- '-metrics'; then
bin="$RUNNER_TEMP/reasonix-pr"
src="PR head ($(git rev-parse --short HEAD))"
fi
echo "bin=$bin" >> "$GITHUB_OUTPUT"
echo "src=$src" >> "$GITHUB_OUTPUT"
echo "agent under test: $src"
- name: Write provider config
env:
REASONIX_HOME: ${{ runner.temp }}/reasonix-e2e-home
# User config covers suite tasks (they run in temp dirs); the repo-root copy
# covers diff mode (the agent runs in the repo root, where project config wins).
run: |
mkdir -p "$REASONIX_HOME"
cat > /tmp/reasonix-e2e.toml <<EOF
default_model = "e2e"
[[providers]]
name = "e2e"
kind = "openai"
base_url = "${{ vars.REASONIX_E2E_BASE_URL || 'https://api.deepseek.com' }}"
model = "${{ vars.REASONIX_E2E_MODEL || 'deepseek-v4-flash' }}"
api_key_env = "DEEPSEEK_API_KEY"
context_window = 20000 # small so the suite actually exercises compaction + cache churn
[providers.price]
cache_hit = ${{ vars.REASONIX_E2E_PRICE_CACHE_HIT || '0.02' }}
input = ${{ vars.REASONIX_E2E_PRICE_INPUT || '1' }}
output = ${{ vars.REASONIX_E2E_PRICE_OUTPUT || '2' }}
currency = "${{ vars.REASONIX_E2E_PRICE_CURRENCY || '¥' }}"
[permissions]
mode = "allow"
[codegraph]
enabled = false
EOF
cp /tmp/reasonix-e2e.toml "$REASONIX_HOME/config.toml"
cp /tmp/reasonix-e2e.toml ./reasonix.toml
- name: Run e2e
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
REASONIX_HOME: ${{ runner.temp }}/reasonix-e2e-home
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
if [ -z "$DEEPSEEK_API_KEY" ]; then
printf 'missing DEEPSEEK_API_KEY secret\n\nAdd a repository secret named DEEPSEEK_API_KEY to enable the e2e bot.\n' > report.md
exit 1
fi
# Provider credentials are deliberately loaded only from Reasonix's
# global credential file, not inherited process variables. Keep this
# ephemeral runner copy private and outside the checked-out project.
umask 077
printf 'DEEPSEEK_API_KEY=%s\n' "$DEEPSEEK_API_KEY" > "$REASONIX_HOME/.env"
if printf '%s' "$COMMENT_BODY" | grep -q '/e2e[[:space:]]\+diff'; then
ATTEMPTS=$(printf '%s' "$COMMENT_BODY" | sed -nE 's@.*/e2e[[:space:]]+diff[[:space:]]+x([0-9]+).*@\1@p' | head -1)
[ -z "$ATTEMPTS" ] && ATTEMPTS=1
[ "$ATTEMPTS" -gt 5 ] && ATTEMPTS=5
BASE_REF=$(gh pr view ${{ github.event.issue.number }} --json baseRefName -q .baseRefName)
git fetch -q origin "$BASE_REF"
BASE=$(git merge-base "origin/$BASE_REF" HEAD)
"$RUNNER_TEMP/e2ebench" -mode diff -bin "${{ steps.agent.outputs.bin }}" -repo . -base "$BASE" -model e2e -attempts "$ATTEMPTS" -out report.md
else
# The current five-task baseline can exceed 400k after only three
# successful tasks. Keep bounded headroom so every scenario is graded.
"$RUNNER_TEMP/e2ebench" -bin "${{ steps.agent.outputs.bin }}" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 800000
node -e '
const results = require("./report.json");
const unsuccessful = results.filter((result) => !result.Passed || result.Skipped);
if (results.length === 0 || unsuccessful.length > 0) {
console.error(`e2e suite incomplete: ${unsuccessful.length}/${results.length} unsuccessful`);
process.exit(1);
}
'
fi
- name: Post report
if: always() && hashFiles('report.md') != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TRIGGER_USER: ${{ github.event.comment.user.login }}
run: |
printf '\n> agent: %s · triggered by @%s\n' "${{ steps.agent.outputs.src }}" "$TRIGGER_USER" >> report.md
gh pr comment ${{ github.event.issue.number }} --body-file report.md
- name: Report failure
if: failure()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh pr comment ${{ github.event.issue.number }} --body "🤖 e2e bot failed — see the [run log](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})."
+99
View File
@@ -0,0 +1,99 @@
name: Auto-label issues
# Classify new issues into area / platform / severity labels using the DeepSeek
# API (the project's own model). The model is constrained to a fixed label set —
# anything it returns outside the set is dropped — so it can't invent labels.
# Issues it can't place into any area get `needs-triage` for a human to look at.
# Version (v1/v2) labels are handled separately by issue-version-label.yml.
on:
issues:
types: [opened, reopened]
permissions:
issues: write
concurrency:
group: issue-autolabel-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
classify:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
with:
script: |
const AREA = ['agent','mcp','config','updater','provider','desktop','tui','skills','rendering'];
const PLATFORM = ['windows','macos','linux'];
const SEVERITY = ['crash','data-loss','security'];
const ALLOWED = new Set([...AREA, ...PLATFORM, ...SEVERITY]);
const issue = context.payload.issue;
const title = issue.title || '';
const body = (issue.body || '').slice(0, 4000);
const system = [
'You categorize GitHub issues for Reasonix, a Go-based AI coding agent with a Wails desktop app and a terminal UI.',
'Pick labels ONLY from these fixed sets. Never invent labels.',
'area (0-2, the affected subsystem):',
' agent: core agent loop / tool-calling / reasoning',
' mcp: MCP servers, plugins, codegraph',
' config: configuration, setup wizard, .toml/.env',
' updater: auto-update, installer, release packaging',
' provider: model providers, model selection/switching',
' desktop: Wails desktop GUI',
' tui: terminal UI / CLI',
' skills: skills system',
' rendering: terminal rendering / flicker / repaint',
'platform (only if clearly specific to one OS): windows, macos, linux',
'severity (only if clearly applicable):',
' crash: app crashes, hangs, or freezes',
' data-loss: loss of sessions, config, or history',
' security: credential/secret exposure or a security flaw',
'Be conservative: omit a label when unsure. The issue may be in Chinese.',
'Reply with JSON only: {"area":[],"platform":[],"severity":[]}',
].join('\n');
let labels = [];
try {
const res = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'deepseek-chat',
temperature: 0,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: system },
{ role: 'user', content: `Title: ${title}\n\nBody:\n${body}` },
],
}),
});
if (!res.ok) {
core.warning(`DeepSeek API ${res.status}: ${await res.text()}`);
return;
}
const data = await res.json();
const parsed = JSON.parse(data.choices[0].message.content);
labels = [...(parsed.area || []), ...(parsed.platform || []), ...(parsed.severity || [])]
.filter(l => ALLOWED.has(l));
} catch (e) {
core.warning(`Classification failed: ${e.message}`);
return;
}
if (!labels.some(l => AREA.includes(l))) labels.push('needs-triage');
if (labels.length) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issue.number,
labels,
});
core.info(`Applied: ${labels.join(', ')}`);
}
+63
View File
@@ -0,0 +1,63 @@
name: Label issue version
# Issue forms can't let a reporter set a label directly (that needs triage rights),
# so the bug/feature forms carry a "Version line" dropdown and this workflow reads
# the submitted value and applies the matching v1/v2 label on their behalf.
on:
issues:
types: [opened, edited]
permissions:
issues: write
concurrency:
group: issue-version-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
with:
script: |
const body = context.payload.issue.body || '';
const sectionOf = (name) =>
body.split(/^###\s+/m).find((s) => new RegExp(`^${name}`, 'i').test(s)) || '';
const m = sectionOf('Version line').match(/\bv([12])\b/i);
if (!m) {
core.info('No version line found; nothing to label.');
return;
}
let choice = 'v' + m[1];
// The dropdown is a choice, and v2 sits first, so it gets picked by
// reporters who are actually on 0.x. `Exact version` is pasted from
// `reasonix --version`, so when it parses it is the better evidence:
// 0.x is the TypeScript line, 1.x the Go rewrite. Anything that does
// not parse (a commit sha, "main-v2") leaves the dropdown to decide.
const exact = sectionOf('Exact version').match(/\b(\d+)\.(\d+)\.(\d+)/);
if (exact) {
const fromVersion = Number(exact[1]) === 0 ? 'v1' : 'v2';
if (fromVersion !== choice) {
core.info(`Version line says ${choice} but ${exact[0]} says ${fromVersion}; trusting the version.`);
choice = fromVersion;
}
}
const other = choice === 'v2' ? 'v1' : 'v2';
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.issue.number,
labels: [choice],
});
// Keep v1/v2 mutually exclusive in case an edit flipped the choice.
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: context.issue.number,
name: other,
});
} catch (e) {
core.info(`No ${other} label to remove.`);
}
+47
View File
@@ -0,0 +1,47 @@
name: Deploy site
on:
push:
branches: [main-v2]
paths: ['site/**', 'release-notes/**', '.github/workflows/pages.yml']
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
cache-dependency-path: site/package-lock.json
- name: Build
working-directory: site
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm ci
npm run build
- uses: actions/upload-pages-artifact@v5
with:
path: site/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- id: deploy
uses: actions/deploy-pages@v5
-89
View File
@@ -1,89 +0,0 @@
name: Platform CI
on:
push:
branches: [platform]
pull_request:
branches: [platform]
permissions:
contents: read
concurrency:
group: platform-ci-${{ github.ref }}
cancel-in-progress: true
jobs:
accounts:
runs-on: ubuntu-latest
defaults:
run:
working-directory: workers/accounts
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/accounts/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm test
- run: pnpm typecheck
forum:
runs-on: ubuntu-latest
defaults:
run:
working-directory: workers/forum
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/forum/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm test
- run: pnpm typecheck
crash-report:
runs-on: ubuntu-latest
defaults:
run:
working-directory: workers/crash-report
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm test
- run: npx wrangler deploy --dry-run
remote-gateway:
runs-on: ubuntu-latest
defaults:
run:
working-directory: workers/remote-gateway
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
cache: npm
cache-dependency-path: workers/remote-gateway/package-lock.json
- run: npm ci
- run: npm test
- run: npm run typecheck
- run: npx wrangler deploy --dry-run
+28
View File
@@ -0,0 +1,28 @@
name: Auto-label PRs
# Apply area labels to PRs based on changed paths (see .github/labeler.yml).
# Complements the AI-based issue labeler (issue-auto-label.yml). Version (v1/v2)
# labels are handled separately by pr-version-label.yml.
#
# pull_request_target runs in the base repo context so it has the write token
# needed to label PRs from forks; actions/labeler only reads the diff file list
# (it never checks out or runs PR code), so this is safe.
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
concurrency:
group: pr-autolabel-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v7
with:
sync-labels: false
+49
View File
@@ -0,0 +1,49 @@
name: Label PR version
# Contributors (especially from forks) can't set labels themselves — that needs
# triage rights. A PR's version line is fully determined by its base branch, so
# this reads base.ref and applies v1/v2 on their behalf. pull_request_target runs
# in the base repo's context so it has write access even for fork PRs; it only
# reads trusted metadata (the base ref) and never checks out or runs PR code.
on:
pull_request_target:
types: [opened, reopened, edited]
permissions:
pull-requests: write
concurrency:
group: pr-version-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
with:
script: |
const base = context.payload.pull_request.base.ref;
const label = base === 'v1' ? 'v1'
: base === 'main-v2' ? 'v2'
: null;
if (!label) {
core.info(`Base branch '${base}' isn't a release line; skipping.`);
return;
}
const other = label === 'v2' ? 'v1' : 'v2';
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.pull_request.number,
labels: [label],
});
// Drop the other line's label if the base was changed after opening.
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: context.payload.pull_request.number,
name: other,
});
} catch (e) {
core.info(`No ${other} label to remove.`);
}
+101
View File
@@ -0,0 +1,101 @@
name: Prepare release
on:
workflow_dispatch:
inputs:
version:
description: "Official version without a tag prefix (for example 1.20.0)"
required: true
type: string
permissions:
contents: write
pull-requests: write
jobs:
draft:
name: generate reviewable bilingual draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: "22"
- name: Select the review branch
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin main-v2
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "::error::version must be MAJOR.MINOR.PATCH"
exit 1
fi
branch="release-notes/v${VERSION}"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
git checkout -b "$branch" --track "origin/$branch"
git merge --no-edit origin/main-v2
else
git checkout -b "$branch"
fi
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
- name: Generate and validate release notes
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
DEEPSEEK_MODEL: deepseek-v4-pro
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
node scripts/generate-release-notes.mjs --version "$VERSION"
node scripts/release-notes.mjs validate
node --test scripts/release-notes.test.mjs
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
{
echo "## Review draft for v${VERSION#v}"
echo
cat /tmp/release-notes.md
} >> "$GITHUB_STEP_SUMMARY"
- name: Open or update the review PR
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
git add release-notes/releases.json
if ! git diff --cached --quiet; then
git commit -m "docs(release): prepare v${VERSION#v} notes" \
-m "Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.
Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing."
fi
git push -u origin "$RELEASE_NOTES_BRANCH"
if ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
if ! gh pr create \
--base main-v2 \
--head "$RELEASE_NOTES_BRANCH" \
--title "docs(release): 准备 v${VERSION#v} 中英更新日志" \
--body-file /tmp/release-notes.md; then
echo "::warning::GitHub Actions could not open the PR; the reviewed branch is preserved."
{
echo "## Manual PR handoff"
echo
echo '```sh'
echo "gh pr create --repo ${{ github.repository }} --base main-v2 --head $RELEASE_NOTES_BRANCH --fill"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
fi
fi
+26
View File
@@ -0,0 +1,26 @@
name: Release notes
on:
pull_request:
paths:
- "release-notes/**"
- "scripts/release-notes*.mjs"
- "site/src/pages/changelog/**"
- "site/src/components/ChangelogPage.astro"
- "desktop/frontend/src/App.tsx"
- "desktop/frontend/src/components/SettingsPanel.tsx"
- "desktop/frontend/src/components/UpdateBanner.tsx"
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "22"
- run: node scripts/release-notes.mjs validate
- run: node --test scripts/release-notes.test.mjs
+331
View File
@@ -0,0 +1,331 @@
name: Release Studio
run-name: Release Studio ${{ inputs.tag || github.ref_name }}
# The only release line this branch publishes. It has its own tag namespace
# (studio-v*), its own environment gate, and its own rollback catalog at
# dl.reasonix.io/studio/versions.json.
#
# What it must never touch: the repository-wide "latest" release (every Studio
# release is a prerelease) and the root dl.reasonix.io/versions.json, which is
# the desktop line's catalog. Studio artifacts land under the tag prefix, which
# cannot collide because the tag name itself differs.
#
# Windows artifacts are unsigned. The production SignPath policy allows exactly
# one origin (protected main-v2), so a build from this branch cannot request it
# and users will see a SmartScreen warning.
on:
push:
tags: ['studio-v*']
workflow_dispatch:
inputs:
tag:
description: "Existing studio tag to recover (for example studio-v0.1.0)"
required: true
type: string
permissions:
contents: write # create the release and upload artifacts
concurrency:
group: release-studio-${{ inputs.tag || github.ref_name }}
cancel-in-progress: false
jobs:
resolve:
name: resolve Studio release
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
sha: ${{ steps.release.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Resolve tag and version
id: release
env:
IN_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
tag="${IN_TAG:-$REF_NAME}"
semver='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+)(\.[0-9A-Za-z-]+)*)?$'
if [[ ! "$tag" =~ ^studio-v(.+)$ ]] || [[ ! "${BASH_REMATCH[1]}" =~ $semver ]]; then
echo "::error::studio release tag must be studio-vMAJOR.MINOR.PATCH[-PRERELEASE], got: $tag"
exit 1
fi
# A dispatch recovers an existing tag; it must not invent one.
sha="$(git rev-parse --verify "refs/tags/$tag^{commit}")"
# The tag has to be on the studio line. Publishing a main-v2 commit
# from here would produce a Studio build without Studio in it.
if ! git merge-base --is-ancestor "$sha" origin/studio; then
echo "::error::$tag is not on studio history"
exit 1
fi
version="${tag#studio-}"
{ echo "tag=$tag"; echo "version=$version"; echo "sha=$sha"; } >> "$GITHUB_OUTPUT"
echo "resolved $tag -> $version at $sha"
build:
name: build (${{ matrix.platform }})
needs: resolve
runs-on: ${{ matrix.runner }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- platform: windows/amd64
runner: windows-latest
- platform: darwin/universal
runner: macos-latest
- platform: linux/amd64
runner: ubuntu-22.04
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: pnpm/[email protected]
with:
version: 10
run_install: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- name: Install Linux build deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
# nfpm builds the .deb without dpkg-deb. Pinned to the version the desktop
# line uses, so both packages are produced by one packager.
- name: Install nfpm
if: runner.os == 'Linux'
run: go install github.com/goreleaser/nfpm/v2/cmd/[email protected]
# Same pinned, checksum-verified NSIS the desktop line installs.
- name: Install NSIS
if: runner.os == 'Windows'
run: pwsh -NoProfile -File scripts/install-nsis.ps1
# The same Developer ID and notary credentials the desktop line uses: one
# certificate signs any bundle identifier its team owns, so Studio does not
# need a second one. studio-build.sh reads the keychain rather than a flag,
# and ad-hoc signs when it finds no identity — which is what shipped before
# this step existed, and what Gatekeeper refuses to open.
- name: Import Apple signing certificate
if: runner.os == 'macOS'
env:
APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }}
APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
if [ -z "$APPLE_CERT_P12" ]; then
echo "APPLE_CERT_P12 unset — Studio will ad-hoc sign (un-notarized)"
exit 0
fi
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PASS="$(uuidgen)"
security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
echo "$APPLE_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null
# Prepend the signing keychain to the search list so codesign / find-identity see it.
existing_keychains=()
while IFS= read -r keychain; do
[ -n "$keychain" ] && existing_keychains+=("$keychain")
done < <(security list-keychains -d user | sed -E 's/^[[:space:]]*"//; s/"[[:space:]]*$//')
security list-keychains -d user -s "$KEYCHAIN" "${existing_keychains[@]}"
echo "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
rm -f "$RUNNER_TEMP/cert.p12"
# Studio declares SelfUpdate: true, which promises a replacement bundle
# Gatekeeper still opens. An official build that reached the users without
# a Developer ID would break the next update rather than the first launch,
# so this fails closed the way the desktop line's Authenticode gate does.
- name: Require Apple signing
if: runner.os == 'macOS' && github.repository == 'esengine/DeepSeek-Reasonix'
env:
HAS_APPLE: ${{ secrets.APPLE_CERT_P12 != '' && secrets.APPLE_CERT_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' }}
run: |
if [ "$HAS_APPLE" != "true" ]; then
echo "::error::all five APPLE_* secrets are required to publish Studio for macOS"
exit 1
fi
- name: Build
shell: bash
env:
# studio-build.sh notarizes only when all three are present; the cert
# itself is found through the keychain staged above.
APPLE_API_KEY_PATH: ${{ runner.temp }}/notary.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
run: bash scripts/studio-build.sh "${{ matrix.platform }}" "${{ needs.resolve.outputs.version }}"
- uses: actions/upload-artifact@v5
with:
name: studio-${{ strategy.job-index }}
path: dist/ReasonixStudio-*
if-no-files-found: error
retention-days: 7
publish:
name: sign, publish and mirror
needs: [resolve, build]
runs-on: ubuntu-latest
# Owner approval for the whole line. The gate sits here rather than before
# build so the approval is given with the artifacts already proven to build.
environment: studio-release
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: actions/download-artifact@v6
with:
path: staging
merge-multiple: true
- name: Collect artifacts
run: |
mkdir -p dist
find staging -type f -name 'ReasonixStudio-*' -exec mv {} dist/ \;
ls -la dist
# Three portable archives, the Linux .deb and the Windows installer.
# Fewer means an artifact was lost between jobs, and a partial release
# is worse than a failed one.
count=$(find dist -type f -name 'ReasonixStudio-*' | wc -l)
if [ "$count" -lt 5 ]; then
echo "::error::expected 5 artifacts (3 portable + Linux .deb + Windows installer), found $count"
exit 1
fi
# These two are how a user installs rather than unzips, and the .deb is
# also the Linux self-update channel — missing either degrades the
# release silently instead of failing it.
for required in 'ReasonixStudio-linux-*.deb' 'ReasonixStudio-windows-*-installer.exe'; do
find dist -type f -name "$required" | grep -q . || {
echo "::error::no $required in the release; its packager did not run"
exit 1
}
done
# The same minisign key the desktop line uses, because the public key that
# verifies it is compiled into the client (update.PublicKey).
- name: Sign
working-directory: desktop
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
run: |
go run ./cmd/sign sign ../dist/*
for f in ../dist/ReasonixStudio-*; do
case "$f" in *.minisig) continue ;; esac
go run ./cmd/sign verify "$f"
done
- name: Generate manifest
working-directory: desktop
env:
GITHUB_REPOSITORY: ${{ github.repository }}
run: go run ./cmd/studio-manifest ../dist "${{ needs.resolve.outputs.version }}" "${{ needs.resolve.outputs.tag }}"
# Always a prerelease: this line is not the public product line, and
# GitHub never promotes a prerelease to the repository-wide "latest".
- name: Publish GitHub release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
VERSION: ${{ needs.resolve.outputs.version }}
run: |
# A version's own notes live in release-notes/studio/<version>.md. The
# release still publishes without one, carrying only the standing text.
notes="$RUNNER_TEMP/release-notes.md"
: > "$notes"
if [ -f "release-notes/studio/$VERSION.md" ]; then
cat "release-notes/studio/$VERSION.md" >> "$notes"
printf '\n' >> "$notes"
else
echo "::warning::no release-notes/studio/$VERSION.md; publishing the standing text only"
fi
cat >> "$notes" <<'BODY'
---
**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。**Windows 包未签名** —— Authenticode 签名绑定在受保护的 `main-v2` 上,本分支拿不到它,所以 SmartScreen 会告警:选「更多信息」,再选「仍要运行」。**Linux** 从 `.deb` 安装。
三个平台都能自更新:Linux 走自己的包,Windows 运行下一个安装器,macOS 替换自身 bundle。
BODY
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" dist/* --clobber
else
gh release create "$TAG" dist/* \
--title "Reasonix Studio $VERSION" \
--notes-file "$notes" \
--prerelease \
--latest=false
fi
- name: Mirror to R2 and update the Studio catalog
env:
R2_BUCKET: ${{ secrets.R2_BUCKET }}
TAG: ${{ needs.resolve.outputs.tag }}
VERSION: ${{ needs.resolve.outputs.version }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
if [ "$HAS_R2" != "true" ]; then
echo "::warning::R2 credentials are not configured; the GitHub release is published but the Studio catalog was not updated, so running builds will not see this version"
exit 0
fi
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set default.region auto
endpoint="https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
# Immutable per-tag objects. The tag prefix cannot collide with the
# desktop line's because the tag names differ (studio-v* vs desktop-v*).
aws s3 cp dist/ "s3://${R2_BUCKET}/${TAG}/" --recursive --endpoint-url "$endpoint"
# Studio's own catalog. Writing the ROOT versions.json here would hand
# desktop users Studio releases, so the prefix is not optional.
current=/tmp/studio-versions-current.json
next=/tmp/studio-versions-next.json
source=-
if aws s3 cp "s3://${R2_BUCKET}/studio/versions.json" "$current" --endpoint-url "$endpoint" 2>/dev/null; then
source="$current"
else
echo "no existing Studio catalog; starting a new one"
fi
bash scripts/update-versions-index.sh "$source" "$VERSION" "$TAG" studio \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$next"
jq -e --arg v "$VERSION" '.versions | map(select(.version == $v)) | length == 1' "$next" >/dev/null
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
--endpoint-url "$endpoint" \
--content-type "application/json; charset=utf-8" \
--cache-control "public, max-age=300, stale-if-error=86400"
echo "Studio catalog -> $VERSION"
@@ -0,0 +1,62 @@
name: Request verification on released issues
# A PR that writes "Fixes #N" auto-closes its issue on merge. One that writes
# "Refs"/"Related"/"Addresses" does not, so the report stays open waiting for a
# follow-up nobody remembers to send after the release. This sends it.
on:
workflow_dispatch:
inputs:
tag:
description: Release tag to announce (e.g. desktop-v1.18.0)
required: true
type: string
dry_run:
description: Print the targets without commenting
required: false
default: true
type: boolean
release:
types: [published]
push:
branches:
- main-v2
paths:
- '.github/workflows/release-verify-issues.yml'
- 'scripts/release-verify-issues.mjs'
- 'scripts/release-verify-issues.test.mjs'
permissions:
contents: read
issues: write
concurrency:
group: release-verify-issues-${{ github.event.inputs.tag || github.event.release.tag_name || github.ref }}
cancel-in-progress: false
jobs:
notify:
if: github.repository == 'esengine/DeepSeek-Reasonix'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: Test notifier
run: node --test scripts/release-verify-issues.test.mjs
# A push that only touched the script has no release to announce; the test
# above is the whole point of that trigger.
- name: Request verification
if: github.event_name != 'push'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.inputs.tag || github.event.release.tag_name }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
run: |
set -euo pipefail
args=(--tag "$TAG")
if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run); fi
node scripts/release-verify-issues.mjs "${args[@]}"
+64
View File
@@ -0,0 +1,64 @@
name: Spam comment guard
# Activity-farming bots post short emoji-padded template comments on whatever
# issue is currently active. They are cheap to spot structurally: a recently
# registered account with no history here, no followers, and a pile of forked
# repos, posting a sub-50 character comment with emoji and no code/link/log.
# Every signal must fire — on 7604 replayed comments that caught all 83 known
# spam posts with no false positive. Loosening any one of them reintroduces
# real users ("点赞", "同样的问题", a 2020 account posting "感谢各位❤").
on:
issue_comment:
types: [created]
permissions:
issues: write
pull-requests: write
concurrency:
group: spam-guard-${{ github.event.comment.id }}
jobs:
screen:
if: github.event.comment.user.type != 'Bot' && github.event.comment.author_association == 'NONE'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
with:
script: |
const EMOJI = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}]/gu;
const comment = context.payload.comment;
const body = comment.body || '';
const hasSubstance = /```|https?:\/\/|!\[|^>/m.test(body);
const emojiCount = (body.match(EMOJI) || []).length;
const bare = body.replace(EMOJI, '').trim();
const { data: author } = await github.rest.users.getByUsername({
username: comment.user.login,
});
const ageDays = (Date.now() - Date.parse(author.created_at)) / 86400000;
const signals = {
newAccount: ageDays < 365,
noFollowers: author.followers === 0,
forkPadded: author.public_repos >= 15,
tooShort: bare.length <= 45,
emojiPadded: emojiCount >= 1,
noSubstance: !hasSubstance,
};
const failed = Object.entries(signals).filter(([, v]) => !v).map(([k]) => k);
core.info(`${comment.user.login}: ${failed.length ? `kept (${failed.join(',')})` : 'spam'}`);
if (failed.length) return;
await github.graphql(
`mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: SPAM }) {
minimizedComment { isMinimized }
}
}`,
{ id: comment.node_id },
);
core.notice(`Minimized spam comment by ${comment.user.login}`);
+68
View File
@@ -0,0 +1,68 @@
name: Sweep stale version reports
# Reports filed many minors back cannot be confirmed against a current build,
# and 65% of the open tracker has never had a reply. This asks, waits, then
# closes — never closing anything that was not asked first.
on:
workflow_dispatch:
inputs:
dry_run:
description: Print what would happen without commenting
required: false
default: true
type: boolean
limit:
description: Maximum issues to touch in this run
required: false
default: '40'
type: string
schedule:
- cron: '17 3 * * 1'
push:
branches:
- main-v2
paths:
- '.github/workflows/stale-report-sweep.yml'
- 'scripts/stale-report-sweep.mjs'
- 'scripts/stale-report-sweep.test.mjs'
permissions:
contents: read
issues: write
concurrency:
group: stale-report-sweep
cancel-in-progress: false
jobs:
sweep:
if: github.repository == 'esengine/DeepSeek-Reasonix'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: Test sweeper
run: node --test scripts/stale-report-sweep.test.mjs
# A push that only touched the sweeper runs its tests and stops there.
- name: Sweep
if: github.event_name != 'push'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LIMIT: ${{ github.event.inputs.limit || '40' }}
# Posting stays off until this repository variable is set to "true", so
# merging the sweeper cannot start commenting on its own.
ENABLED: ${{ vars.STALE_SWEEP_ENABLED }}
REQUESTED_DRY_RUN: ${{ github.event.inputs.dry_run || 'true' }}
run: |
set -euo pipefail
args=(--limit "$LIMIT")
if [ "$ENABLED" != "true" ] || [ "$REQUESTED_DRY_RUN" = "true" ]; then
args+=(--dry-run)
echo "::notice::dry run (STALE_SWEEP_ENABLED=${ENABLED:-unset})"
fi
node scripts/stale-report-sweep.mjs "${args[@]}"
+99
View File
@@ -0,0 +1,99 @@
name: Studio
# Studio's own surface — desktop/next (the Wails shell) and desktop/frontend-next
# (the SPA it serves) — exists only on the studio branch, so it cannot live in
# ci.yml without failing on main-v2 where neither directory is present. ci.yml
# covers the kernel and the desktop module's Go side; this file covers the
# frontend build and proves the shell still compiles on every platform it ships
# to, which is the same command release-studio.yml runs.
on:
push:
branches: [studio]
pull_request:
branches: [studio]
permissions:
contents: read
concurrency:
group: studio-${{ github.ref }}
cancel-in-progress: true
jobs:
frontend:
name: frontend-next
runs-on: ubuntu-latest
defaults:
run:
working-directory: desktop/frontend-next
steps:
- uses: actions/checkout@v7
- uses: pnpm/[email protected]
with:
version: 10
run_install: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
- name: Install
run: pnpm install --frozen-lockfile
- name: typecheck
run: pnpm typecheck
# The reducer and the event port carry the two pieces of logic a type
# error cannot reach: what a card means when a turn is interrupted, and
# what the client does about a frame that never arrived.
- name: test
run: pnpm test
# `build` runs tsc -b before vite, so this also proves the project
# references resolve — typecheck alone uses --noEmit and does not.
- name: build
run: pnpm build
# The published bundle must not carry MockPort's scripted fixtures: a
# release that cannot reach the kernel has to say so, not act out a fake
# session. main.tsx guards the import behind import.meta.env.DEV, and this
# is what proves the guard still holds.
- name: no fixtures in the bundle
run: |
if grep -rl 网关瞬时态 dist/ 2>/dev/null; then
echo "::error::MockPort fixtures reached the production bundle - check the import.meta.env.DEV guard in main.tsx"
exit 1
fi
shell:
name: shell (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, windows-latest, macos-latest]
defaults:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
# WebKitGTK 4.1, matching what release-studio.yml links against: 4.0
# (libwebkit2gtk-4.0.so.37) is gone on Ubuntu 24.04+ and Fedora 40+.
- name: Install Linux build deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
- name: build shell
run: bash ../scripts/studio-build.sh --shell-only
@@ -0,0 +1,37 @@
name: Update acknowledgments
on:
workflow_dispatch:
schedule:
- cron: '17 3 * * 1'
permissions:
contents: write
pull-requests: write
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: Update README contributor tables
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/update-acknowledgments.mjs
- name: Open pull request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: chore/update-acknowledgments
delete-branch: true
title: 'Update acknowledgments contributors / 更新致谢贡献者'
commit-message: 'docs: update acknowledgments contributors'
body: |
Refreshes the README acknowledgments tables from the GitHub contributors API.
- Source: `repos/esengine/DeepSeek-Reasonix/contributors?per_page=20&anon=1`
- Anonymous contributors are shown without email addresses.
labels: documentation
+74
View File
@@ -0,0 +1,74 @@
name: Update Star History chart
on:
workflow_dispatch:
push:
branches:
- main-v2
paths:
- '.github/workflows/update-star-history.yml'
- 'scripts/update-star-history.mjs'
- 'scripts/update-star-history.test.mjs'
schedule:
- cron: '23 */3 * * *'
permissions:
contents: write
concurrency:
group: update-star-history
cancel-in-progress: false
jobs:
update:
if: github.repository == 'esengine/DeepSeek-Reasonix'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: '22'
- name: Test chart generator
run: node --test scripts/update-star-history.test.mjs
- name: Generate chart assets
env:
STAR_HISTORY_GITHUB_TOKEN: ${{ secrets.STAR_HISTORY_GITHUB_TOKEN }}
STAR_HISTORY_OUTPUT_DIR: ${{ runner.temp }}/star-history
run: node scripts/update-star-history.mjs
- name: Publish chart assets
env:
OUTPUT_DIR: ${{ runner.temp }}/star-history
TARGET_BRANCH: star-history
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git fetch origin "$TARGET_BRANCH"; then
git switch --force-create "$TARGET_BRANCH" "origin/$TARGET_BRANCH"
else
git switch --create "$TARGET_BRANCH"
fi
mkdir -p assets/star-history
cp "$OUTPUT_DIR/star-history-light.svg" assets/star-history/star-history-light.svg
cp "$OUTPUT_DIR/star-history-dark.svg" assets/star-history/star-history-dark.svg
git add assets/star-history
if git diff --cached --quiet; then
echo "Star History chart is already current."
exit 0
fi
git commit \
-m "docs: update Star History chart" \
-m "Automated update:
Refresh the repository-hosted light and dark Star History SVGs from the authenticated GitHub stargazers API.
Verification:
- chart generator tests passed
- both SVG assets were rendered from timestamped stargazer data"
git push origin HEAD:"$TARGET_BRANCH"
+71 -8
View File
@@ -1,16 +1,79 @@
# Build artifacts
/bin/
/dist/
/stage/
/npm/.stage/
/reasonix
/reasonix.exe
/e2ebench
/e2ebench.exe
# Website (Astro) build output
/site/dist/
/site/.astro/
/site/_preview.png
# Go test/build scratch
*.test
*.out
*.coverprofile
coverage.out
cpu.prof
mem.prof
# Wails desktop artifacts
/desktop/build/*
!/desktop/build/appicon.png
!/desktop/build/appicon.svg
/desktop/desktop
/desktop/frontend/dist/*
!/desktop/frontend/dist/.gitkeep
/desktop/frontend/sourcemaps/
/desktop/frontend/wailsjs/
/desktop/frontend/package.json.md5
# Environment / local config (reasonix.example.toml is the shared template)
.env
.env.local
*.local
/reasonix.toml
/desktop/.env
/desktop/reasonix.toml
# Node / frontend dependencies and caches
node_modules/
.pnpm-store/
.npm/
.wrangler/
dist/
coverage/
.env
.env.*
*.local
**/.firebase-crash-migration-state.json
**/.firebase-crash-migration-state.json.tmp-*
# Project-local Reasonix state; keep the checked-in review command.
/.reasonix/*
!/.reasonix/commands/
!/.reasonix/commands/review.md
# Stray Reasonix state written under a package dir by a test whose RealPath
# resolves to CWD (e.g. remote bootstrap EnsureServe). Never commit these.
internal/**/.reasonix/
desktop/**/.reasonix/
# CodeGraph per-project index (rebuilt locally; never committed)
.codegraph/
# Editor / OS
.DS_Store
Thumbs.db
.idea/
.vscode/
/site/public/av/
benchmarks/context-maintenance-e2e/run/
# Local scratch
temp/
# Official theme art intermediate outputs / caches
scripts/official-theme-art/out/
scripts/official-theme-art/__pycache__/
site/.generated/
# Committed pointer at REASONIX.md; overrides a global CLAUDE.md ignore rule.
!/CLAUDE.md
+1
View File
@@ -0,0 +1 @@
v2.12.2
+64
View File
@@ -0,0 +1,64 @@
version: "2"
linters:
enable:
- copyloopvar
- errcheck
- errorlint
- govet
- ineffassign
- intrange
- modernize
- staticcheck
- unused
- usestdlibvars
settings:
modernize:
# omitempty -> omitzero changes which fields are written for nested
# structs. That is a wire-format change to session files and the
# extension protocol, not a refactor, so it lands on its own.
disable:
- omitzero
errcheck:
check-type-assertions: false
exclude-functions:
- (*os.File).Close
- (io.Closer).Close
- (*net/http.Response).Body.Close
- golang.org/x/term.Restore
# Win32: CloseHandle in a defer mirrors the Close exclusions above, and
# LazyProc.Call always returns a non-nil errno — the caller reads r1.
- golang.org/x/sys/windows.CloseHandle
- (*syscall.LazyProc).Call
- (*golang.org/x/sys/windows.LazyProc).Call
staticcheck:
checks:
- all
- -ST1005 # error strings ending with punctuation — too strict for initial adoption
- -ST1018 # Unicode format characters — intentional in TUI code
- -QF1001 # De Morgan's law — readability preference
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
# Setup/teardown in tests routinely ignores cleanup errors.
- path: _test\.go
linters:
- errcheck
# The pinned golangci-lint binary's staticcheck reports SA5011 false
# positives on `if x == nil { t.Fatal(...) }`-guarded derefs in tests (it
# doesn't treat t.Fatal as terminating); the same code is clean under a
# locally-built golangci-lint. Scope the suppression to tests so SA5011
# still guards production code.
- path: _test\.go
linters:
- staticcheck
text: SA5011
issues:
max-issues-per-linter: 0
max-same-issues: 0
+65
View File
@@ -0,0 +1,65 @@
version: 2
project_name: reasonix
before:
hooks:
- go mod download
builds:
- id: reasonix
main: ./cmd/reasonix
binary: reasonix
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
# Inject version identity for `reasonix version --verbose/--json` (Integration D/E).
# buildTimeUTC is the goreleaser run time (UTC), not vcs.time.
- -s -w -X main.version={{ .Tag }} -X main.gitCommit={{ .ShortCommit }} -X main.buildTimeUTC={{ .Date }} -X reasonix/internal/productdocs.linkedVersion={{ .Tag }} -X reasonix/internal/productdocs.linkedRevision={{ .Commit }}
goos: [darwin, linux, windows]
goarch: [amd64, arm64]
archives:
- id: reasonix
ids: [reasonix]
name_template: "reasonix-{{ .Os }}-{{ .Arch }}"
formats: [tar.gz]
format_overrides:
- goos: windows
formats: [zip]
checksum:
name_template: SHA256SUMS
algorithm: sha256
homebrew_casks:
- name: reasonix
ids: [reasonix]
# Prereleases (including v*-preview.* and v*-rc*) must not update the stable
# tap; brew has no separate prerelease channel, so uploading either cask
# would make it the default `brew install`.
# npm is a separate line (release-npm.yml, `npm-v*`), so an npm prerelease no
# longer drags brew along — a stable `v*` cask can ship while npm stays on rc.
skip_upload: "auto"
repository:
owner: esengine
name: homebrew-reasonix
branch: main
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
homepage: "https://github.com/esengine/DeepSeek-Reasonix"
description: "Cache-first DeepSeek coding agent for the terminal."
commit_author:
name: reasonix
email: [email protected]
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/reasonix"]
end
release:
prerelease: auto
make_latest: false
name_template: "Reasonix CLI {{ .Tag }}"
-1
View File
@@ -1 +0,0 @@
24.20.0
+5
View File
@@ -0,0 +1,5 @@
---
description: Review a file for bugs
argument-hint: [path]
---
Read $1 and list any correctness bugs or risky patterns, with file:line references, most important first. Focus: $ARGUMENTS.
+1
View File
@@ -0,0 +1 @@
v2.13.0
-71
View File
@@ -1,71 +0,0 @@
# Reasonix platform capacity contract
This document defines what “ready for 10,000 concurrent users” means. It is an
acceptance contract, not a claim that the current production system has already
passed the target.
## Budget and design constraints
- Keep recurring infrastructure within CNY 200/month at the current traffic
level. Prefer Cloudflare managed primitives over idle servers.
- Never make synchronous telemetry storage part of a product launch path.
- Validate at the edge, durably enqueue, then persist asynchronously.
- A successful HTTP response must expose whether the event was `queued`,
`stored`, `sampled`, or `ignored` through
`x-reasonix-telemetry-result`. HTTP 202 alone is not proof of durable intake.
- Production load tests require owner approval. The repository probe refuses
remote and production targets unless they are unlocked separately.
## Service-level objectives
| Path | Workload | Pass condition |
| --- | --- | --- |
| Telemetry admission | 10,000 distributed requests over 60 seconds | >=99.9% `queued`, p95 <=500 ms, no 5xx |
| Telemetry drain | 10,000 queued events, batches up to 100 | backlog returns to zero within 15 minutes, no DLQ growth |
| Identity session read | 1,000 requests/second for 5 minutes | >=99.9% success, p95 <=300 ms |
| Registry public read | 1,000 requests/second for 5 minutes | >=99.9% success, p95 <=300 ms |
| Admin dashboards | 20 concurrent viewers for 10 minutes | >=99% success, p95 <=2 seconds |
“Distributed” matters because per-IP abuse protection intentionally rejects a
single machine long before the platform capacity limit. A staging environment
must mirror production bindings and use isolated databases, queues, and R2.
## Required evidence before calling the platform stable
1. A saved probe report with request count, latency percentiles, HTTP statuses,
durable/sample ratios, queue backlog peak, drain time, D1 query latency, and
DLQ delta.
2. A rollback or traffic-shed procedure exercised in staging.
3. Alerts for sustained 5xx, queue backlog age, DLQ growth, D1 overload, and
database size thresholds.
4. Seven consecutive days without unexplained ingestion gaps after a production
rollout.
## Current known gaps
- CLI and Studio telemetry use a verified isolated D1 read/write plane. The
legacy copy is retained temporarily as a rollback snapshot. Legacy desktop
telemetry is entering a dry-run-verified dual-write migration; diagnostic
denominators are queried separately so the final cutover does not require
cross-database SQL joins.
- The global telemetry budget is currently 1,000 events/minute. It protects the
database but cannot satisfy the 10,000/minute durable-intake target.
- Queue consumption is deliberately serialized for D1. A delivery is persisted
as one atomic D1 batch with two statements per valid envelope (at most 200
statements for the configured 100-message queue batch). Drain throughput must
be measured before raising the global intake budget.
- Read replication and the Sessions API have not yet been enabled for eligible
read-heavy paths.
## Local probe
Run the Worker locally, then run:
```sh
npm run capacity:telemetry -- --requests 1000 --concurrency 50
```
This probe fails when the p95 target or durable-intake ratio is missed. A local
run validates the admission contract and tool; it is not evidence for global
capacity. Remote runs need `--allow-remote`, and `*.reasonix.io` additionally
needs `--allow-production` after an approved test window.
+362
View File
@@ -0,0 +1,362 @@
# Changelog
All notable changes to the Go line (Reasonix 1.0+) are recorded here. The legacy
`0.x` TypeScript history lives on the [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1)
branch.
## Unreleased
### Fixed
- **v1.24.2 session snapshot & recovery root fix:** Keep PR #7982's WAL/CAS/lease
safety foundation, but replace process-level "I hold a lease" ownership with a
generation-bound `SessionWriteAuthority`. Same-revision tool-preview/load
reshapes no longer false-diverge; recovery files are bounded to one path per
writer/lineage; empty checkpoints heal from their own WAL; projection lineage
rebinds across upgrade/model switch and inherits across recovery forks without
changing provider-visible prompt bytes. Catalog upgrades to disposable
`session-catalog/v3.sqlite` with recovery lineage roles
(`normal|covered_copy|adopted|diverged`); covered idle copies move to the
recoverable `.trash` using a 15-minute idle threshold applied on two early
sweeps (at startup and ~20 minutes later), then a 24-hour threshold on the
6-hour background ticker; independent diverged branches stay and are listed
for user choice. v1/v2 catalogs are
left byte-unchanged for coexistence/downgrade.
**v1.24.1** only hid/reclaimed already-created covered copies and fixed Windows
flash-window startup; **v1.24.2** stops the misclassification source and repairs
existing user directories without rewriting authoritative JSONL/WAL/sidecar data.
- Goal now runs continuously by default: the former 16-round per-Run boundary,
10/20/40 cross-Run quotas, default wall-clock budget, and numeric
no-progress/Todo-stall pauses no longer stop valid work. Progress guards still
detect repeated host outcomes and zero-evidence work, but redirect the model
to re-plan instead of producing `goal_run_budget` or `goal_stuck`. Explicit
`[agent].goal_token_budget`, `--max-steps`, positive time/cost budgets, manual
pause/stop, genuine user/external blockers, and evaluator fail-closed behavior
remain available. The Goal token budget defaults to `0` (off); resuming its
`budget_spend` pause grants a fresh slice without clearing cumulative usage.
Goal status reports turns, provider requests, tokens, the optional configured
token threshold, and cumulative active work time. Bot `max_steps` also
defaults to `0` (continuous), while positive user configuration is enforced.
- Removed numeric Goal pauses in existing sidecars automatically normalize to
`running` without sending a model request. Active Goal sidecars write
`turnsLimit: -1` as a downgrade-safe unlimited sentinel while public runtime
APIs retain deprecated limit fields as `0`. The migration preserves unknown
fields, todos, checkpoints, usage, evidence, and historical metadata.
- Goal is now the sole long-task runtime. Historical AutoResearch sidecars
migrate transactionally into Goals with research compatibility metadata. Invalid archives block
fail closed and remain read-only, retaining the task id and compatibility mode
for a restart or `/goal resume` retry; successful Goal-only sidecars omit the
old task id and write an explicit downgrade fence so previous readers cannot
reactivate the removed runtime.
- Context-dependent workflow tools now share one host-side execution boundary.
Goal, Plan sign-off, and background-job calls cannot reach permissions,
hooks, leases, or Execute outside their owning context; mixed batches execute
valid calls once and stop safely after one repair. Child agents also isolate
inherited Goal, Jobs, and live memory queues, while persisted tool identity
records the effective child schema projection.
- **Issue #7575:** Linux Bash under bubblewrap no longer mounts a fresh empty
`--tmpfs /tmp` on every call. Consecutive commands in the same logical session
now share a private temporary directory (bound at `/tmp` on Linux, exported via
`TMPDIR`/`TMP`/`TEMP` on all platforms) without exposing the host public
temporary root. `/new`, `/clear`, resume of another session, and branch
switches rotate the directory; model/settings hot rebuilds keep it. Sub-agent
runs get independent directories. Temporary files are not durable across process
restarts.
### Added
- Added `[ui].show_turn_usage` so CLI/TUI users can hide per-request token and
cost receipts from transcript scrollback without disabling usage accounting.
## [1.20.0] — 2026-08-05
Extension kernel, Task Monitor, and safer Goal completion.
Compact decision surfaces, local decision receipts, unified extension kernel,
native Task Monitor, bounded sub-agent progress, Goal fail-closed completion,
MiMo and DashScope Responses fixes, SSH remote access simplification, and
multiple Desktop stability improvements.
### Highlights
- **Unified Extension Kernel and Extension Protocol v1**: Immutable runtime
snapshots, fail-atomic reload, Plugin Manifest v1 (prompts, themes, full-trust
code runtimes), stable JSON-RPC sidecar protocol, interceptor dispatch,
streaming provider adapter, structured UI, and Go SDK.
- **Native Task Monitor**: Monitor agent tasks natively in CLI and Desktop with
lifecycle semantics and session-scoped summary view.
- **Bounded Sub-agent Progress Forwarding**: Forward structured progress for
`task`, `parallel_tasks`, and `fleet` without flooding the parent stream.
Renders nested lifecycle cards in Desktop and stable per-child transcript
slots in CLI.
- **Goal Completion Fail-Closed**: Replace free-form Goal footer markers with a
stable `update_goal` tool and epoch-scoped per-turn reports. Centralized
completion logic with bounded evaluator, progress-aware budgets, and
pause/resume controls.
- **Ablation Subsystem Switches**: Switch subsystems off behind one shared
vocabulary for controlled experiments. Includes planner, subagent, retrieval,
evidence, and compaction.
- **Benchmark Cost per Solved Task**: Report cost per solved task, tokens per
solved, median wall time, and failure-class breakdown in e2e reports.
- **Compact Decision Surfaces and Local Receipts**: Compact footer decision-card
layout with bounded scroll, dense action rows, and overflow disclosure.
Record bounded Ask, approval, and recovery decisions as local transcript
receipts.
- **Simplified SSH Remote Access**: Remove Remote Workbench protocol and
stacks; reuse CLI/Serve remote model. Desktop opens per-host native web child
windows via SSH. Keyless remote Serve setup with loopback-only page.
- **Model Usage Charts with Primer Palette**: Replace monochrome accent ramp
with GitHub Primer data-viz two-set categorical palette. Fix donut overflow
on hover and keyboard accessibility.
- **Cross-platform Extension and Task Monitor Reliability**: Make
content-reference eviction deterministic, reject Unix and Windows absolute
plugin paths consistently, stabilize parallel-task cancellation, and restore
reliable Windows validation for Task Monitor and remote provider setup.
- **MiMo and DashScope Responses Wire Alignment**: Fix multi-turn tool loops,
reasoning round-trip, JSON output for MiMo; fix DashScope second-turn 400
error, all-zero usage suppression, and vendor-aware cache TTL.
- **Desktop Stability Fixes**: Recover stuck updates and legacy WebKit, contain
macOS alias repair startup crashes, keep composer overflow stacks readable,
and harden account verification and community flows.
- **Remote Web Recovery After SSH Drops**: Add integration regression test for
SSH drop, forward recovery, and window reload. Document transient outage
behavior.
- **CI: Auto-minimize Activity-Farming Spam Comments**: Detect and minimize
template spam comments from non-contributor accounts based on structural
signals.
### Added
- Added Extension Protocol v1 and the unified extension kernel: installed or
linked sidecars can contribute tools, skills, commands, hooks, MCP servers,
providers, interceptors, and structured UI surfaces through a versioned
NDJSON contract and the public Go SDK. CLI, Desktop, ACP, and Serve support
fail-atomic runtime reloads; Serve also renders extension surfaces and lists
extension-hosted providers without exposing credentials.
- Added the structured Goal completion protocol: the always-registered
`update_goal` tool (continue/complete/blocked with reason and next_action)
replaces the `[goal:*]` footer markers. The Goal FSM is now the exclusive
cross-turn decision point and validates every complete claim against Delivery
readiness; when the model submits no report, an independent bounded evaluator
(recovery_model → guardian_model → main model, no tools/history, usage
attributed to `goal-evaluator`) judges the turn once, and any evaluator
failure pauses the goal instead of continuing silently.
- Added Goal budget classes with safe pauses: simple 10 turns / 200k tokens,
write 20 turns / 400k tokens, AutoResearch 40 turns / 800k tokens, and a
4-turn no-host-verifiable-progress gate. Pauses keep all Goal state; `/goal
resume` continues and adds one slice of the current class when the pause was
budget-related. `/goal status` shows the full turn/token/no-progress runtime,
and `/goal pause` manually suspends a running Goal.
- Added the `goalRuntime` nested view to the desktop Meta, the remote protocol
(`session/goal/pause` operation, `goalRuntime` DTO on session meta), and the
ACP status payload; the desktop Composer goal menu shows the runtime summary
with distinct pause/end/resume actions.
### Changed
- Delivery no longer retries final-answer readiness with hidden model messages:
a plain Delivery run ends on the first unsatisfied final answer and surfaces
the recovery card, while a Goal + Delivery run has the Goal FSM absorb the
failure and continue under budget with the missing requirements as the next
turn's prompt. Historical `[goal:*]` footers are stripped from old transcripts
for display only and never participate in state decisions.
- Added a **Remote SSH** module (VS Code Remote-SSH style): a user-global
`[remote]` host config, `reasonix remote` CLI (add/list/remove/import/test/
connect/status/forward/serve/fs) and `/remote` slash command, an SSH transport
with trust-on-first-use host-key verification, keepalive + exponential-backoff
reconnect, `-L`/`-R` port forwarding, and SFTP file access. `connect`
bootstraps a persistent `reasonix serve` on the remote host and tunnels its
loopback port so the full agent runs remotely. The desktop app adds a
**Settings -> Remote SSH** host manager, a remote file browser/editor, a
port-forwarding panel, and a status-bar connection chip. Linux/macOS remotes.
- Added `reasonix serve --port-file/--token-file/--pid-file` so a supervised
headless serve can bind an ephemeral port and read its auth token from a file
(keeping it out of `ps`).
- Added an authenticated, loopback-only Provider setup page for `reasonix
serve`. A Serve whose selected Provider is missing its API key now remains
reachable, stores the submitted key in that host's Reasonix credential file,
and rebuilds the active controller in place without restarting Serve.
- Added Claude Code-style searchable CLI pickers for models, providers, and
sessions, with arrow, Vim, and `Ctrl+P` / `Ctrl+N` navigation.
- Added `-p` / `--print`, `text`, `json`, and `stream-json` output modes for
one-shot use and automation.
- Added session-scoped `--allowed-tools`, repeatable `--add-dir`, Claude-compatible
permission modes, flexible `--resume [QUERY]`, and the `--copy` resume escape
hatch.
- Added `/status` details for the active model, effort, cache, Git state,
background jobs, work profile, and provider balance where available.
- Remote SSH workspaces now open as a standalone remote web window again.
Opening a workspace from the status bar or the Remote Server tab starts or
reuses the remote `reasonix serve`, tunnels its loopback port, and opens the
Serve web client in a dedicated per-host window. The remote web page uses
the provider configuration and API keys on the **remote** host; the desktop
no longer exposes its local providers to remote hosts. If the selected remote
Provider is missing its API key, the window opens a setup page that saves the
key only on that host and then opens the normal Serve UI. The Remote Workbench
protocol, its Provider Broker, and the same-window remote projection were
removed. Legacy mirror and provider-trust files are not deleted
automatically; Settings -> Remote SSH shows a cleanup card when they exist.
The hidden `remote attach-workspace`, `remote runtime-workbench`, and
`remote workbench-build-id` commands now fail with a pointer to
`reasonix remote connect <host> --open`.
- Automatic Plan Mode has been retired. Plan Mode is now always entered through
an explicit user choice, and the one-time config v5 upgrade removes legacy
`agent.auto_plan` and `agent.auto_plan_classifier` values so upgraded users
receive the same behavior as new users.
- `Shift+Tab` now cycles CLI safe modes from Ask to Auto to Plan, while YOLO
remains an independent `Ctrl+Y` toggle.
- Model, provider, resume, and approval menus now use consistent row selection;
slash completion, help, aliases, and dispatch share one command registry.
- The full-screen CLI composer now uses theme-accented borders and a slim bar
cursor by default, grows within the available terminal height, scrolls long
drafts independently, and preserves selections across explicit image paste.
- The persistent CLI footer now uses a responsive, theme-aware layout for
interaction state, model, effort, localized work mode, Git identity, cache,
context, compaction headroom, jobs, and balance. Narrow terminals move or
compact complete groups instead of clipping labels.
- CLI clipboard actions now separate terminal-native text paste from explicit
image paste: `Ctrl+V` on macOS/Linux, `Alt+V` on Windows, or `/paste-image`.
Local transcript copy verifies the native clipboard write, while SSH uses a
clearly labelled OSC 52 fallback.
- Runtime rebuilds after model, effort, or work-mode changes now preserve the
conversation, session permission overrides, additional directories, and
session lease ownership.
- Agent execution now monitors host-observed Todo progress automatically. A
stalled current item receives a recovery nudge after 8 tool-call rounds with
no new completion, unique read, command, or mutation, and pauses with saved
work after 16. Exact repeats do not renew the progress lease; real work does.
Two-level task lists keep the single in_progress contract: the active
sub-step is the only current item while its phase stays pending, and the
phase becomes in_progress to sign off only after all of its sub-steps are
completed. A level-1 sub-step with no phase header above it is rejected.
Executor and planner rounds now use automatic progress management. Retired
`[agent].max_steps` and `planner_max_steps` keys remain parseable for upgrades,
but are ignored and removed by a one-time migration so stale hidden limits
cannot truncate new behavior. One-off CLI and unattended bot limits remain.
### Fixed
- Fixed long parallel sub-agent research being silently lost when combined
`parallel_tasks` or `fleet` answers exceeded the single-tool output limit.
Persisted sessions now keep each child transcript independently, return a
bounded fair preview plus stable reference for every result, and page full
answers through the conversation-scoped `read_subagent_result` tool.
- Fixed Remote Workbench failing with only `initialize: workbench-desktop:
connection closed` on fresh or cross-platform SSH hosts. Desktop now proves
the exact Host CLI Build ID, provisions the matching verified release without
requiring remote npm, runs the managed binary explicitly, and preserves a
safe structured bootstrap error when the remote command exits early.
- Hardened Bash permission reuse for dynamic and indirect execution. Parameter/arithmetic expansions,
assignments, redirects, heredocs, and globs can only be remembered as exact
`Bash=<literal>` rules, while still using Auto's normal fallback. Nested or
indirect execution now requires a human in interactive Ask/Auto and fails
closed in headless Ask/Auto/DontAsk. Broad Bash rules, Guardian/hook allows,
and the approved-plan window can no longer silently authorize that stricter
class; YOLO remains the explicit full-access bypass and sandbox enforcement
is unchanged.
- Fixed Desktop sessions incorrectly locking themselves during Goal + Delivery
mode changes, controller rebuilds, duplicate-tab restore, and background
reattachment. Desktop now keeps one process-local runtime owner per canonical
session, fences stale controller events by runtime epoch, blocks sends until
that runtime is ready, and scopes single-instance ownership to
`REASONIX_HOME` instead of the executable path. Switching saved sessions is
now transactional: a target build, restore, or lease failure leaves the
current controller, lease, path, mode profile, and runtime epoch untouched.
- Stabilized the desktop rich composer caret after skill and plugin invocation
tags. DOM→model and model→DOM selection mapping now treat invocation chips as
zero-length atoms while still counting user text that lands inside the NBSP
caret anchor (common on Windows WebView2), restore both selection ends, and
recover the insertion point from a `beforeinput` snapshot when the browser
temporarily loses selection — so mid-text edits no longer jump to the end.
- Isolated the Windows desktop WebView2 shell from stale system proxies, so an
exited proxy client cannot leave the embedded UI hidden during startup. If
WebView2 still does not reach DOM-ready within 15 seconds, Reasonix now shows
the native window with a recovery prompt instead of appearing not to launch.
Remote Markdown images are fetched by the backend with Reasonix's proxy
configuration instead of bypassing that proxy through the isolated WebView.
- Restored captured-mouse right-click text paste, made composer drag selection
copy through the verified native clipboard path, and kept non-Git footer
telemetry left-aligned without reserving an empty data band.
- Restored stateful MCP behavior after the v1.17.13 regression: user-added
servers work without extra trust settings (including delivery-mode on-demand
calls), repository-provided servers use one exact launch confirmation, and
stdio tools reuse one persistent process so browser sessions survive across
calls without repeated startup latency. The former trust/reverify/catalog
management UI and CLI are removed.
- Localized persistent-footer labels and displayed work-mode values in English,
Simplified Chinese, and Traditional Chinese, while keeping command arguments
stable.
- Restored the `0.53` content boundary: model output, tool output, session
transcripts, recovery branches, and background-job artifacts retain their
original text instead of being rewritten by heuristic secret redaction.
Credential masking remains in key-entry summaries and explicit diagnostic or
session-cleanup paths. Transcript-bearing session/job sidecars are kept
private (`0600`, with private job directories), and the retired
`redact_tool_output` setting is removed with a one-time upgrade notice.
### Notes
- Full bilingual release notes:
<https://reasonix.io/changelog/v1.20.0/> ·
[GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases/tag/desktop-v1.20.0).
- The detailed entries below accumulated on `main-v2` after 1.0.0 and shipped
across 1.1.0–1.20.0; per-version attribution lives in the per-version release
notes linked above.
## 1.1.0 – 1.19.7
Per-version entries for the intermediate releases are published in the
[bilingual release notes](https://reasonix.io/changelog/) and on the
[GitHub releases page](https://github.com/esengine/DeepSeek-Reasonix/releases).
## [1.0.0] — 2026-06-03
First stable release — a **ground-up rewrite in Go**. Not an upgrade of the `0.x`
TypeScript line; a new codebase that becomes the default (`main-v2`).
### Highlights
- **Go kernel**: a single static binary (CGO-free), cross-compiled for
darwin/linux/windows on amd64 + arm64. Distributed via npm (the package wraps
the native binary), Homebrew (`esengine/reasonix` tap), and release archives;
no Node runtime needed to run it.
- **Agent core**: the loop, built-in tools (read/write/edit/multi_edit/glob/grep/
ls/bash/web_fetch/todo_write), permission gate, sandboxed bash, and the
DeepSeek prefix-cache–oriented design.
- **Subagents**: `task` plus explore/research/review/security_review skill agents.
- **Skills & hooks**: Claude-Code-style skills (`internal/skill`) and hooks
(`internal/hook`), symlink-aware and slash-integrated.
- **MCP client**: connect external servers over stdio / Streamable HTTP; reads
`[[plugins]]` and a Claude-Code `.mcp.json`.
- **Code intelligence via CodeGraph**: a tree-sitter symbol/call graph
(`codegraph_*` tools) replaces embedding semantic search — no embedding service
or API cost. Fetched into a local cache on first use (or `reasonix codegraph
install`) and indexed in the background, so installs and startup stay fast.
- **Plan mode** with evidence-backed step sign-off (`complete_step`).
- **Memory**: `REASONIX.md` hierarchy + auto-memory, folded into the cache-stable
prefix.
- **ACP** (`reasonix acp`) and an HTTP/SSE server frontend; desktop app (Wails).
### Fixed
- **File encoding support restored** — GBK/GB18030 (and other non-UTF-8) files
can now be read, edited, and grepped correctly. The v2 rewrite had dropped
v1's encoding detection; files in CJK Windows charsets were silently misread
or rejected as binary. The read/edit/write round-trip now preserves the
original file encoding. (#2637)
### Notes
- Versions: the legacy TypeScript line stays in `0.x`; the Go line starts at
`1.0.0`. See [docs/MIGRATING.md](docs/MIGRATING.md).
- Release archives ship a bare binary; CodeGraph is fetched on first use. Windows
support for the fetched runtime is unverified — install `codegraph` on PATH if
the auto-fetch doesn't resolve there.
[1.20.0]: https://github.com/esengine/DeepSeek-Reasonix/releases/tag/desktop-v1.20.0
[1.0.0]: https://github.com/esengine/DeepSeek-Reasonix/releases/tag/v1.0.0
+192
View File
@@ -0,0 +1,192 @@
# Contributing to Reasonix
Thank you for your interest in contributing to Reasonix! This guide covers
everything you need to get started.
## Prerequisites
- **Go 1.25+** — the project targets the latest stable Go release
- **Git** — for version control
- **Node.js** (optional) — only if you work on the desktop app (`desktop/`)
- **Wails CLI** (desktop only) — run `make wails-install` so the CLI matches
`desktop/go.mod`
## Getting started
```bash
git clone https://github.com/esengine/DeepSeek-Reasonix.git
cd DeepSeek-Reasonix
go build ./cmd/reasonix # builds the CLI binary
go test ./... # runs the full test suite
```
## Project structure
| Directory | Purpose |
|-----------|---------|
| `cmd/reasonix` | CLI entry point |
| `internal/agent` | Agent loop, session, coordinator |
| `internal/cli` | TUI, subcommands, setup wizard |
| `internal/control` | Transport-agnostic controller |
| `internal/config` | TOML configuration loading |
| `internal/tool/builtin` | Built-in tools (bash, read_file, …) |
| `internal/provider` | Model-backend abstraction |
| `internal/provider/openai` | OpenAI-compatible provider |
| `internal/plugin` | MCP client (stdio + HTTP) |
| `internal/event` | Typed event stream |
| `internal/hook` | Shell hooks (PreToolUse, …) |
| `internal/memory` | REASONIX.md hierarchy + auto-memory |
| `internal/skill` | Skill discovery from Markdown |
| `internal/sandbox` | OS-level sandboxing |
| `internal/serve` | HTTP/SSE server frontend |
| `internal/checkpoint` | Snapshot-based rewind |
| `desktop/` | Wails-based desktop app (separate Go module) |
| `docs/` | Engineering spec, migration guide |
### Dependency direction
```
cli → {agent, plugin, config} → {tool, provider}
```
Built-in subpackages import their parent to self-register via `init()`.
Parents never import children.
## Development workflow
### Building
```bash
make build # go build ./...
make test # go test ./...
make vet # go vet ./...
make fmt # gofmt -w .
make hooks # install git hooks (pre-push: go vet)
make cross # cross-compile for all 6 targets
```
### Isolated development environment
A source-built binary shares no on-disk state with a stable release when launched
with `REASONIX_HOME` set. This gives each build its own self-contained directory
tree — config, credentials, sessions, cache, skills, commands, hooks, and
desktop tab state — so the two builds never interfere:
**CLI**
```bash
REASONIX_HOME=/tmp/reasonix-dev go run ./cmd/reasonix
# or after building:
# REASONIX_HOME=/tmp/reasonix-dev ./bin/reasonix
```
**Desktop**
```bash
cd desktop && wails build
REASONIX_HOME=/tmp/reasonix-dev-isolated build/bin/reasonix-desktop
```
On Windows, use `$env:REASONIX_HOME` in PowerShell or `set REASONIX_HOME=` in
Command Prompt; the binary extension is `.exe`.
The directory is empty on first launch; the app behaves exactly like a fresh
install. Every subsequent write — config saves, credential storage, session
logs — stays under `REASONIX_HOME`. Legacy migration, OS-home convention
directory scanning, and all other fallback paths are skipped so no production
data leaks in or out.
### Cache-first review gate
Reasonix treats high prompt-cache hit rate as product behavior. Changes that
touch provider-visible system prompt construction, memory prefix, output styles,
skill index behavior, default tool surfaces, tool schemas, provider request
serialization, compaction, or MCP/tool registration need explicit cache review.
For these changes:
- Keep system prompt changes low-frequency and require explicit review.
- Fill the PR body `Cache-impact:` line with `none`, `low`, `medium`, or `high`
plus the reason.
- Fill the PR body `Cache-guard:` line with the focused guard test/command added
or run, or explain why an existing guard covers the change.
- Fill `System-prompt-review:` when system prompt, memory prefix, output style,
or skill index behavior changes.
- Prefer focused guard tests near the changed surface; `scripts/cache-guard.sh`
remains the broader release-level cache-hit check.
CI enforces this metadata for cache-sensitive paths so prompt/tool prefix churn
is called out before review.
### Running tests
```bash
go test ./... # all tests
go test ./internal/agent/ -v # verbose, one package
go test ./internal/tool/builtin/ -run TestGrep # one test
```
### Code style
- `gofmt` is enforced by CI — format before committing
- Follow existing patterns: wrap errors with `fmt.Errorf("...: %w", err)`
- Library code never calls `os.Exit` or prints to stdout/stderr
- Only `cli/` and `main/` decide exit codes and user-facing messages
- Exported identifiers must have doc comments
### Commit messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
feat(glob): add ** recursive pattern support
fix: replace silent error discards with structured logging
test(event): add comprehensive unit tests for event package
docs: add CONTRIBUTING.md
ci: add golangci-lint and govulncheck
```
## Adding a new built-in tool
1. Create `internal/tool/builtin/mytool.go`
2. Implement the `tool.Tool` interface: `Name()`, `Description()`, `Schema()`, `ReadOnly()`, `Execute()`
3. Register via `func init() { tool.RegisterBuiltin(myTool{}) }`
4. Add tests in `internal/tool/builtin/builtin_test.go` or a separate `mytool_test.go`
5. The tool is automatically available — `main` blank-imports `builtin`
## Adding a new model provider
(For MCP tool servers see `internal/plugin` instead — that's a different layer.)
1. Create `internal/provider/myprovider/`
2. Implement `provider.Provider`: `Name()`, `Stream()`
3. Register via `func init() { provider.Register("mykind", New) }`
4. The provider is available from config with `kind = "mykind"`
## Adding i18n strings
1. Add the field to `internal/i18n/i18n.go` (`Messages` struct)
2. Add the value in `internal/i18n/messages_en.go` and `messages_zh.go`
3. The `TestCatalogsComplete` test will fail if you miss a locale
## Submitting changes
1. Fork the repository
2. Create a feature branch from `main-v2`
3. Make your changes with tests
4. Ensure `go test ./...` passes
5. Ensure `gofmt -l .` shows no changes
6. Submit a pull request to `main-v2`
## Reporting issues
Open an issue on GitHub with:
- Steps to reproduce
- Expected vs actual behavior
- Go version and OS
- Relevant logs or error messages
## License
By contributing, you agree that your contributions will be licensed under the
same license as the project.
+94
View File
@@ -0,0 +1,94 @@
VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
BUILD_TIME_UTC := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
GIT_COMMIT := $(shell git rev-parse --short=12 HEAD 2>/dev/null || echo unknown)
LDFLAGS := -s -w \
-X main.version=$(VERSION) \
-X main.gitCommit=$(GIT_COMMIT) \
-X main.buildTimeUTC=$(BUILD_TIME_UTC)
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 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
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix-plugin-example$(GOEXE) ./cmd/reasonix-plugin-example
vet:
go vet ./...
# Build Studio and launch it. On macOS this is the ONLY correct way to run it by
# hand: a bare binary draws its window but native panels (Add a folder…) open
# and close in the same beat, because LaunchServices only treats a bundled
# process as a real GUI app. The script explains the rest.
studio:
bash desktop/next/run-studio.sh
fmt:
gofmt -w .
# Both gates CI runs, at the version CI pins. Skipping golangci-lint locally
# trades a second here for a ten-minute CI round trip: `modernize` findings in
# particular never surface in `go vet`.
lint: lint-go
go run ./tools/repolint
lint-go:
@command -v golangci-lint >/dev/null || { echo "golangci-lint not installed; run: make lint-install"; exit 1; }
@have=$$(golangci-lint version --short 2>/dev/null); want=$$(echo "$(GOLANGCI_VERSION)" | sed 's/^v//'); \
[ "$$have" = "$$want" ] || echo "warning: local golangci-lint $$have, CI pins $$want (make lint-install)"
golangci-lint run --timeout=5m ./...
# CGO_ENABLED=0 keeps the install working where a stray clang on PATH shadows
# the toolchain and breaks runtime/cgo.
lint-install:
CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION)
# repolint over what the working tree changed. It exists as a target rather than
# a command to paste because the host recognizes `make check` as a verification
# it can read the result of, while an arbitrary `go run ./tools/...` is an
# unknown program it must assume writes — so only this form can be cited as
# evidence that the standards gate passed.
check:
go run ./tools/repolint -only "$$(git diff --name-only HEAD | paste -sd, -)"
lint-update:
go run ./tools/repolint -update
# Linting one GOOS leaves every //go:build windows and darwin file unchecked.
lint-cross:
@for t in "linux ." "darwin ." "windows ." "linux desktop" "windows desktop"; do \
set -- $$t; \
echo "== golangci-lint GOOS=$$1 ($$2)"; \
(cd $$2 && GOOS=$$1 golangci-lint run --timeout=5m ./...) || exit 1; \
done
test:
go test ./...
# The shell is a nested module, so the root `test` target never reaches it.
studio-test:
cd desktop && go test ./...
sdk-test:
cd sdk/go && go test ./...
sdk-test-race:
cd sdk/go && go test -race ./...
hooks:
@git config core.hooksPath .githooks
@echo "installed: core.hooksPath -> .githooks (pre-push runs go vet)"
cross:
@mkdir -p dist
@for p in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do \
os=$${p%/*}; arch=$${p#*/}; ext=; [ $$os = windows ] && ext=.exe; \
echo "build $$os/$$arch"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -ldflags "$(LDFLAGS)" -o dist/reasonix-$$os-$$arch$$ext ./cmd/reasonix; \
done
clean:
rm -rf bin dist
+246 -21
View File
@@ -1,28 +1,253 @@
# Reasonix Platform
<p align="center">
<img src="docs/logo-ghost-wave-effect.svg" alt="Reasonix" width="360"/>
</p>
This branch contains only the shared Reasonix service platform:
<p align="center">
<strong>English</strong>
&nbsp;·&nbsp;
<a href="./README.zh-CN.md">简体中文</a>
&nbsp;·&nbsp;
<a href="./docs/GUIDE.md">Guide</a>
&nbsp;·&nbsp;
<a href="./docs/ACP.md">ACP</a>
&nbsp;·&nbsp;
<a href="./docs/EXTENSIONS.md">Extensions</a>
&nbsp;·&nbsp;
<a href="./docs/SPEC.md">Spec</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
</p>
- identity and account services;
- community and forum services;
- crash, diagnostics, telemetry, and registry services;
- Cloudflare Worker, D1, Queue, and R2 configuration;
- production deployment workflows for those services.
<p align="center">
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/DeepSeek-Reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=8b949e&labelColor=161b22" alt="license"/></a>
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=3fb950&labelColor=161b22&label=downloads" alt="downloads"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
Product clients live on `main-v2` and `studio`. The public website lives on
`website`. Production deployments are accepted only from this branch.
<p align="center">
<a href="https://trendshift.io/repositories/27020?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/27020/monthly?language=Go" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/27020?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/27020" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
</p>
## Services
<br/>
| Directory | Responsibility |
| --- | --- |
| `workers/accounts` | Shared identity, sessions, device grants, and email login |
| `workers/forum` | Community forum APIs |
| `workers/crash-report` | Diagnostics, telemetry, registry, and admin dashboards |
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
<h3 align="center">A coding agent you can leave running.</h3>
<p align="center">One local engine, four ways in — terminal, desktop app, browser, or your editor over ACP. Plan mode, permissions, a workspace sandbox and per-turn checkpoints keep a long autonomous run something you can still read and undo.</p>
Each service owns its package manager lockfile, tests, schema, migrations, and
Wrangler configuration. Run validation from the service directory before using
the matching manual production deployment workflow.
<div align="center">
<video src="https://github.com/user-attachments/assets/ab2f3878-e224-4931-8254-060e7695cfb9" controls preload="metadata" width="560"></video>
</div>
The accounts worker removes expired sessions, email tokens, and device grants
daily. Raw queued telemetry is retained in R2 for eight days; aggregated D1
rows follow the service-specific retention windows in the crash worker.
<br/>
> [!IMPORTANT]
> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **<https://discord.gg/XF78rEME2D>**
<br/>
## Features
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
declared in `reasonix.toml`. No hardcoded models.
- **Multi-model & composable.** DeepSeek ships as a preset; any
OpenAI-compatible endpoint is a config entry, not new code. Optionally run
two models together (executor + planner) in separate, cache-stable sessions.
- **Plugin-driven.** MCP servers contribute tools, prompts, and resources;
Extension Protocol v1 sidecars can also intercept runtime events, contribute
Providers and structured UI, and ship versioned plugin packages.
- **Cache-aware context maintenance.** Startup injects a small stable environment
summary, stale tool output is snipped/pruned before summary compaction, and the
built-in tool schema contract is documented for regression review.
- **Zero-friction distribution.** `CGO_ENABLED=0` single binary; cross-compile
to six targets with one command. The result is a fully self-contained static
binary — nothing to install on the target machine beyond the binary itself.
## Install
Choose the path that matches how you want to use Reasonix. The CLI/TUI,
desktop app, and VS Code extension all use the same local Reasonix engine.
### Path A: CLI / TUI
Install the native binary through npm on any supported platform, or use
Homebrew on macOS:
```sh
npm i -g reasonix # any OS; pulls the prebuilt native binary
brew install esengine/reasonix/reasonix # macOS
```
Prebuilt archives (`darwin|linux|windows × amd64|arm64`) and `SHA256SUMS` are on
every [GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases).
### Path B: Desktop app
Use the [official download page](https://reasonix.io/?download=desktop#start)
for the latest desktop build.
| Platform | Package | Architecture |
| --- | --- | --- |
| macOS | Universal `.dmg` or `.zip` | Apple Silicon / Intel |
| Windows | Installer `.exe` or portable `.zip` | x64 / ARM64 |
| Linux | `.deb` or `.tar.gz` | x64 |
Windows installers are code-signed through [SignPath.io](https://signpath.io/)
with a free certificate provided by the [SignPath Foundation](https://signpath.org/).
### Path C: VS Code extension
Complete Path A first. The extension does not bundle the CLI; it starts your
local `reasonix acp` backend and adds native chat, editor context, tool-call
approvals, model selection, and workspace sessions.
- **VS Code:** [install from Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=SivanLiu.reasonix-agent)
- **VSCodium / Eclipse Theia:** [install from Open VSX Registry](https://open-vsx.org/extension/SivanLiu/reasonix-agent)
- **Extension ID:** `SivanLiu.reasonix-agent` · [source and usage guide](https://github.com/SivanCola/reasonix-vscode)
### Path D: Build from source
Clone the repository first:
```sh
git clone https://github.com/esengine/DeepSeek-Reasonix.git
cd DeepSeek-Reasonix
```
#### CLI
The CLI build requires **Go 1.25+**. The module pins a `toolchain` directive;
keep `GOTOOLCHAIN=auto` so Go downloads the pinned toolchain, or install it.
```sh
make build # -> bin/reasonix(.exe)
make cross # -> dist/ (darwin|linux|windows × amd64|arm64)
```
#### Desktop
The desktop build additionally requires:
- **Node 24+ and pnpm 10** (`npm install -g pnpm@10`) for the frontend
- **Wails CLI** matching the shared `.wails-version` pin
```sh
make wails-install
cd desktop
wails build
```
See the [desktop build guide](desktop/README.md#prerequisites) for platform
webview dependencies and Linux build tags.
## Quick start
### CLI / TUI
These commands are for the CLI/TUI installed through Path A:
```sh
reasonix setup # configure a provider and model
reasonix # start an interactive session
reasonix run "implement the TODOs in main.go"
```
In an interactive session, run `/init` when you want Reasonix to create project
instructions.
### Desktop app
Download the installer for your platform from the
[official download page](https://reasonix.io/?download=desktop#start), install
and launch Reasonix, then configure a provider and model in the app. The CLI
commands above are not required for the desktop app.
For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.md)**,
**[Guide](./docs/GUIDE.md)**, and
**[configuration paths](./docs/CONFIG_PATHS.md)**.
## Documentation
- **Getting started:** [Guide](./docs/GUIDE.md) · [CLI reference](./docs/CLI.md) ·
[Configuration paths](./docs/CONFIG_PATHS.md) · [ACP editor integration](./docs/ACP.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) · [Bot guide](./docs/BOT_GUIDE.md) ·
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
[Tool contract](./docs/TOOL_CONTRACT.md) · [Migrating from 0.x](./docs/MIGRATING.md)
- **Extension development:** [Extensions](./docs/EXTENSIONS.md) ·
[Plugin packages and Manifest v1](./docs/PLUGIN_PACKAGES.md) ·
[Extension Protocol](./docs/EXTENSION_PROTOCOL.md) ·
[Go SDK and starter](./sdk/go/README.md)
## Star History
<a href="https://www.star-history.com/?repos=esengine%2FDeepSeek-Reasonix&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-dark.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
<img alt="Star History Chart" src="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
</picture>
</a>
<br/>
## Acknowledgments
A small list of folks whose work has shaped Reasonix the most — the current top
20 contributors by commit count. The full contributor graph is on
[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors?all=1).
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) |
| **merge-order-check** | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) |
<!-- reasonix-top-contributors:end -->
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
<p align="center">
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="Contributors to esengine/DeepSeek-Reasonix" width="860"/>
</a>
</p>
<br/>
---
<p align="center">
<sub>MIT — see <a href="./LICENSE">LICENSE</a></sub>
<br/>
<sub>Built by the community at <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a></sub>
</p>
---
<p align="center"><sub><strong>Support this project</strong></sub></p>
If Reasonix has been useful and you'd like to say thanks, you can. It stays a
coffee, not a contract — donations don't buy feature priority or change how
issues get triaged.
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
- **国内** — 微信支付(扫码)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="180"/>
</p>
+218
View File
@@ -0,0 +1,218 @@
<p align="center">
<img src="docs/logo-ghost-wave-effect.svg" alt="Reasonix" width="360"/>
</p>
<p align="center">
<a href="./README.md">English</a>
&nbsp;·&nbsp;
<strong>简体中文</strong>
&nbsp;·&nbsp;
<a href="./docs/GUIDE.zh-CN.md">指南</a>
&nbsp;·&nbsp;
<a href="./docs/ACP.zh-CN.md">ACP</a>
&nbsp;·&nbsp;
<a href="./docs/EXTENSIONS.zh-CN.md">扩展开发</a>
&nbsp;·&nbsp;
<a href="./docs/SPEC.zh-CN.md">规格</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
&nbsp;·&nbsp;
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
</p>
<p align="center">
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/DeepSeek-Reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=8b949e&labelColor=161b22" alt="license"/></a>
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=3fb950&labelColor=161b22&label=downloads" alt="downloads"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/27020?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/27020/monthly?language=Go" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/27020?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/27020" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
</p>
<br/>
<p align="center"><strong>开源 · MIT · 单个 Go 二进制</strong></p>
<h3 align="center">可以一直开着跑的编码 Agent。</h3>
<p align="center">一套本地引擎,四个入口——终端、桌面端、浏览器,或通过 ACP 接入你的编辑器。计划模式、权限、工作区沙箱与逐轮 checkpoint,让长时间自治运行始终可读、可撤销。</p>
<div align="center">
<video src="https://github.com/user-attachments/assets/ab2f3878-e224-4931-8254-060e7695cfb9" controls preload="metadata" width="560"></video>
</div>
<br/>
> [!IMPORTANT]
> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **<https://discord.gg/XF78rEME2D>**
## 特性
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
内核无硬编码模型。
- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容
端点都只是一条配置。可选让两个模型协同(执行器 + 规划器),各自独立、缓存稳定的 session。
- **插件驱动**:MCP server 提供工具、提示词和资源;Extension Protocol v1
Sidecar 还可以拦截运行时事件、提供 Provider 与结构化 UI,并通过版本化插件包分发。
- **缓存友好的上下文维护**:启动时注入稳定的环境摘要;旧工具输出会先 snip/prune,
再进入摘要 compaction;内置工具 schema 合约有文档和回归测试保护。
- **零摩擦分发**:`CGO_ENABLED=0` 单二进制;一条命令交叉编译到六个目标平台。
产物是完全自包含的静态二进制——目标机器上除二进制本身外无需安装任何东西。
## 安装
选择适合你的使用路径。CLI/TUI、桌面端和 VS Code 扩展都使用同一套本地
Reasonix 引擎。
### 路径 A:CLI / TUI
任意支持的平台都可以通过 npm 安装原生二进制;macOS 也可以使用 Homebrew:
```sh
npm i -g reasonix # 任意系统;自动拉取对应平台的原生二进制
brew install esengine/reasonix/reasonix # macOS
```
预编译归档(`darwin|linux|windows × amd64|arm64`)和 `SHA256SUMS` 见每个
[GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases)。
### 路径 B:桌面端
前往[官方下载页](https://reasonix.io/?download=desktop#start)获取最新桌面版本。
| 平台 | 安装包 | 架构 |
| --- | --- | --- |
| macOS | 通用 `.dmg` 或 `.zip` | Apple Silicon / Intel |
| Windows | 安装器 `.exe` 或便携 `.zip` | x64 / ARM64 |
| Linux | `.deb` 或 `.tar.gz` | x64 |
Windows 安装器通过 [SignPath.io](https://signpath.io/) 完成代码签名,证书由
[SignPath 基金会](https://signpath.org/) 免费提供。
### 路径 C:VS Code 扩展
请先完成路径 A。扩展不内置 CLI,而是启动本机的 `reasonix acp` 后端,
并提供原生聊天、编辑器上下文、工具调用审批、模型选择和工作区会话。
- **VS Code:** [从 Visual Studio Marketplace 安装](https://marketplace.visualstudio.com/items?itemName=SivanLiu.reasonix-agent)
- **VSCodium / Eclipse Theia:** [从 Open VSX Registry 安装](https://open-vsx.org/extension/SivanLiu/reasonix-agent)
- **扩展 ID:** `SivanLiu.reasonix-agent` · [源码与使用说明](https://github.com/SivanCola/reasonix-vscode)
### 路径 D:从源码构建
```sh
git clone https://github.com/esengine/DeepSeek-Reasonix.git
cd DeepSeek-Reasonix
make build # -> bin/reasonix(.exe)
make cross # -> dist/(darwin|linux|windows × amd64|arm64)
```
## 快速开始
### CLI / TUI
以下命令仅适用于通过路径 A 安装的 CLI/TUI:
```sh
reasonix setup # 配置 provider 和模型
reasonix # 启动交互式会话
reasonix run "把 main.go 里的 TODO 实现掉"
```
需要项目指令时,可在交互式会话中运行 `/init`。
### 桌面端
从[官方下载页](https://reasonix.io/?download=desktop#start)下载对应系统的安装包,
安装并启动 Reasonix,然后在应用内配置 provider 和模型即可使用。桌面端无需执行
上面的 CLI 命令。
CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**、
**[指南](./docs/GUIDE.zh-CN.md)** 和
**[配置路径](./docs/CONFIG_PATHS.zh-CN.md)**。
## 文档
- **开始使用:** [指南](./docs/GUIDE.zh-CN.md) ·
[CLI 命令参考](./docs/CLI.zh-CN.md) · [配置路径](./docs/CONFIG_PATHS.zh-CN.md) ·
[ACP 编辑器接入](./docs/ACP.zh-CN.md)
- **功能与排障:** [子智能体 Profile](./docs/SUBAGENT_PROFILES.zh-CN.md) ·
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.zh-CN.md) ·
[能力诊断](./docs/CAPABILITY_DIAGNOSTICS.zh-CN.md) ·
[恢复与安全模式](./docs/RECOVERY.zh-CN.md) ·
[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md) ·
[Checkpoints 与 rewind](./docs/CHECKPOINTS.zh-CN.md)
- **工程与迁移:** [规格](./docs/SPEC.zh-CN.md) ·
[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md) ·
[工具合约](./docs/TOOL_CONTRACT.zh-CN.md) ·
[从 0.x 迁移](./docs/MIGRATING.zh-CN.md)
- **扩展开发:** [扩展概览](./docs/EXTENSIONS.zh-CN.md) ·
[插件包与 Manifest v1](./docs/PLUGIN_PACKAGES.zh-CN.md) ·
[Extension Protocol](./docs/EXTENSION_PROTOCOL.zh-CN.md) ·
[Go SDK 与 starter](./sdk/go/README.md)
## Star 趋势
<a href="https://www.star-history.com/?repos=esengine%2FDeepSeek-Reasonix&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-dark.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
<img alt="Star History Chart" src="https://raw.githubusercontent.com/esengine/DeepSeek-Reasonix/star-history/assets/star-history/star-history-light.svg" />
</picture>
</a>
<br/>
## 致谢
下面这些朋友的工作塑造了 Reasonix 今天的样子 —— 当前按 commit 数统计的前 20 名贡献者。
完整贡献者列表在
[GitHub](https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors?all=1)。
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**light-front-theory**](https://github.com/light-front-theory) |
| **merge-order-check** | [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**dependabot\[bot\]**](https://github.com/apps/dependabot) | [**lanshi17**](https://github.com/lanshi17) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**CnsMaple**](https://github.com/CnsMaple) | [**cyq1017**](https://github.com/cyq1017) | [**JesonChou**](https://github.com/JesonChou) | [**XTLine**](https://github.com/XTLine) |
<!-- reasonix-top-contributors:end -->
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
<p align="center">
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">
<img src="https://contrib.rocks/image?repo=esengine/DeepSeek-Reasonix&max=100&columns=12" alt="esengine/DeepSeek-Reasonix 贡献者" width="860"/>
</a>
</p>
<br/>
---
<p align="center">
<sub>MIT —— 见 <a href="./LICENSE">LICENSE</a></sub>
<br/>
<sub>由 <a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors">esengine/DeepSeek-Reasonix</a> 社区共建</sub>
</p>
---
<p align="center"><sub><strong>支持本项目</strong></sub></p>
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature
优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
- **国内** — 微信支付(扫下方二维码)
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
<p align="center">
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="180"/>
</p>
+162 -13
View File
@@ -1,14 +1,163 @@
# Reasonix Platform instructions
# Reasonix project memory
- This branch owns shared online services only. Do not add product client or
public website code here.
- Keep every production deployment manually triggered and restricted to the
`platform` branch.
- Database changes must be backward-compatible, idempotent, and accompanied by
a migration or runtime schema guard.
- Ingest endpoints must validate before enqueueing, acknowledge only durable
work, and remain safe under retries.
- Add tests at the HTTP, queue, or storage boundary where behavior becomes
externally observable.
- Never commit credentials, tokens, customer content, or personal user data.
- Comments are English and explain only non-obvious constraints.
This file is loaded into every session's system prompt (the cache-stable prefix),
so keep it concise and durable — it is the project's standing instructions to the
agent. It is the Reasonix analog of Claude Code's CLAUDE.md.
## Conventions
- Go kernel under `internal/`; each package owns one concern. A package's long
explanation belongs in its `doc.go`, not spread across implementation files.
- One transport-agnostic `control.Controller` sits behind every frontend (chat
TUI, HTTP/SSE serve, Wails desktop). Add behavior to the controller, not a
frontend, so all three inherit it.
- Layering (enforced): utility packages import nothing under `reasonix/`; only
the frontends `cli`, `serve`, `acp`, `bot`, `botruntime`, `boot` and the hosts
`cmd/`, `desktop/` may import `control`; nothing below a frontend may import
one. The declared sets live in `tools/repolint/layers.go`.
- Subagent delegation keeps five concepts apart: a profile says how a worker
thinks, `TaskSpec` what this call wants, `CapabilityGrant` what it may touch,
`ContextRequest` what it starts from, `SchedulerPolicy` when it runs. Put a
field in whichever member decides its value — profiles carry ceilings, never
per-call values. `internal/agent/profile_boundary_test.go` enforces it.
- Cache-first: the system-prompt prefix (base prompt + tools + memory) must stay
byte-stable across turns so DeepSeek's automatic prefix cache stays warm. Never
mutate it mid-session — ride the turn tail instead (see `control.Compose`).
- Performance features land with an effect test at their final boundary
(`internal/boot/effect_test.go` pattern): assert what actually reaches the
provider request, frontend sink, or trajectory through the real `boot.Build`
assembly. Component correctness is not system effectiveness.
- A mutex- or atomic-guarded struct is ratcheted on its **scalar** field count
(`struct-state`), not its total: independent flags multiply into states no
type records as legal. Fixing a boundary case by adding one more `bool` is
the move this blocks — group by lifetime into a named sub-state instead
(`agent.perTurnState` is the pattern), which costs one field and removes the
whole product.
- Judgements read structure, never wording: shell ASTs (`shellparse`), types,
contracts, tool schemas. Phrase tables and message sniffing have been retired
in batches (task-policy prose, the planner's approval phrases, the executor
handoff tables) because each one misfires on real input, and a misfiring
judgement inside a gate is worse than no gate. Security allow-lists are the
exception and stay. Something that can only be built by matching words does
not get built — say what is missing instead of guessing at it.
## Comments
Default is none — the code is the truth. Write one only when the **why** is
non-obvious: a hidden constraint, a workaround anchored to something verifiable,
an invariant the type system cannot express, or an external-protocol quirk.
- Declaration doc: ≤5 lines. Package comment: ≤8 lines, or ≤40 in a `doc.go`.
- Every other comment: ≤3 lines. Struct-field and trailing `//`: 1 line.
- Never: restatements of the code, phase/stage narrative, incident or
conversation history, section banners, commented-out code, `@param` lists.
- `TODO(#nnn):` and `HACK(#nnn):` need the issue anchor. `FIXME` is banned.
- One responsibility per file; 800 lines is the ceiling. Test files are exempt:
their length tracks how many cases they cover, not how many concerns they
carry, so splitting one only scatters a subject's table across files.
`go run ./tools/repolint` enforces all of it against a ratchet baseline: recorded
debt is tolerated, anything new fails CI. Never widen the baseline to land a
change — fix the code. `-update` exists for carrying debt through a rename or an
extraction, and that diff must be justified in the PR.
## Memory
- Standing instructions are hierarchical: committed/shared `REASONIX.md`,
`AGENTS.md`, and `CLAUDE.md`; personal `*.local.md` variants; matching files in
ancestor directories; and user-global files under the memory state root
(`REASONIX_STATE_HOME`, otherwise `REASONIX_HOME`, otherwise `~/.reasonix` on
macOS/Linux or `%APPDATA%\reasonix` on Windows). All distinct supported files
in a directory load; `AGENTS.md` is not merely a fallback.
- `@path` on its own line imports another file's contents.
- `#<note>` in chat quick-adds an always-on instruction. The `remember` tool
instead saves a fallible background fact (frontmatter file + `MEMORY.md`
index). Fact `type` classifies content; independent `scope` controls whether it
is project-only (the default) or explicitly global. The index loads into the
stable prefix on the next session; global user/feedback bodies also load as
lower-priority compatibility guidance. The current turn receives a tail note.
## Notes
One tool call costs one model round trip, so calls that do not depend on each
other belong in the same round: several reads, edits to different files, a check
after the edit that enables it. Several edits to the *same* file are one
`multi_edit` — it applies them against each other in memory and rewrites the
file only if all of them land, so a failure midway leaves nothing half-edited.
## Pre-push CI simulation
Run these **before every commit** to catch the fastest CI failures locally:
```bash
gofmt -w . # catches gofmt (saves ~13s CI)
go vet ./... # catches vet warnings (saves ~52s CI/lint)
make lint # golangci-lint at CI's pin + repolint
go test ./internal/tool/builtin/ ./internal/boot/ # catches tool/boot test breaks
```
`make lint` runs both gates CI runs, at the version in `.golangci-version`;
`make lint-install` installs it. Do not skip it: a `modernize` finding never
shows up in `go vet`, and the CI round trip that catches it instead costs ten
minutes.
A full repolint run reports every file over budget, including debt your change
never touched. To see only what your own edits owe:
```bash
make check
```
Repo-wide ceilings still report under `-only`, because a file can push the tree
past one without exceeding its own budget.
Run it through `make`, not as `go run ./tools/repolint -only ...`. The host
recognizes a `make` target as a check whose result it can read, while an
arbitrary `go run ./tools/...` is a program it has to assume writes — so only
the `make` form can be cited as evidence that this gate passed.
## Import cycle rule
Before importing a new internal package from a non-test file, verify the target package's **test files** aren't already importing back to you:
```
# BAD: agent(_test.go) → tool/builtin(sessions.go) → agent → setup failed
```
Use `go test ./path/to/target/` to detect cycles **before** pushing. A `[setup failed]` message means a cycle exists.
## PR hygiene
- **One force-push per round of review feedback.** Multiple force-pushes destroy review history and confuse reviewers.
- **Keep the PR diff minimal.** Only the files relevant to the PR's purpose — no stray changes from other branches.
- **Amend, don't add commits, for review feedback** — keeps the commit history clean.
## PR metadata gates
Two CI guards read the PR body. The scripts are the source of truth and both
run locally: `scripts/check-cache-impact.sh`, `scripts/check-docs-impact.sh`.
Separators must be an ASCII `-` or `:` — an em dash fails the docs guard.
Cache-sensitive diffs (`internal/tool/`, `internal/provider/`,
`internal/boot/`, `internal/agent/agent.go`, and the rest of the list in the
script) require:
```
Cache-impact: <none|low|medium|high> - <reason>
Cache-guard: <focused guard test/command or existing guard rationale>
```
`none` is a legitimate impact when the provider-visible prefix stays
byte-identical; only an empty value, `todo`, or `tbd` is rejected. If the diff
also touches `internal/config/`, `internal/memory/`, `internal/outputstyle/`,
`internal/skill/`, or `internal/boot/`, add `System-prompt-review: <note>` —
that field additionally rejects `none` and `n/a`, so it must name a reviewer.
User-visible diffs (`cmd/reasonix/`, `desktop/`, `npm/`, and most of
`internal/`; tests and lockfiles are exempt) require one of these, chosen by
whether the same PR edited `docs/*.md`:
```
Documentation-impact: updated - <what changed> # docs/*.md edited
Documentation-impact: none - <why the docs stay correct> # not edited
```
+562
View File
@@ -0,0 +1,562 @@
# Reasonix Benchmarks
Three harnesses live under `benchmarks/`; `cmd/e2ebench` also exposes a
SWE-bench Verified mode:
- `e2e/` — the committed end-to-end task suite, driven by
[`cmd/e2ebench`](../cmd/e2ebench/main.go). It runs each task against a real
provider and emits a markdown + JSON report (accuracy, cache-hit rate, token
use, cost) suitable for pasting into a PR.
- `context-maintenance-e2e/` — a standalone seed → resume → comprehension
harness that A/B-compares cold-restart cache behavior with and without
context pruning.
- `compaction/` — CompactionBench: grows a session one generation at a time and
folds it after each, measuring what repeated compaction costs and what it
loses. See [CompactionBench](#compactionbench) below.
## Directory layout
```text
benchmarks/
├── e2e/
│ └── tasks/ # one dir per task: task.toml + verify.sh + workdir/ seed
├── swebench/
│ ├── select_subset.py # helper for choosing evaluation instances
│ └── subset.json # committed SWE-bench Verified subset
└── context-maintenance-e2e/
├── main.go
└── run/ # state dir written by seed/resume (default)
```
## Task corpus stratification
The suite is stratified by real coding-agent workload classes, not toy-task
convenience — the classes are what the per-class compare tables and marginal-
utility readouts key on. Current coverage vs. target:
| Class | Target | Committed | Notes |
| --- | ---: | ---: | --- |
| `atomic-bugfix` | 8 | 11 | short anchored fixes; routes ExecutorOnly by design |
| `repo-exploration` | 6 | 7 | multi-file reading, invented-token answers so they can't be guessed |
| `multi-file-bugfix` | 8 | 8 | one bug spanning ≥2 files; naturally engages the planner gate |
| `refactor` | 6 | 6 | behavior-preserving restructuring, structure asserted |
| `failing-test-diagnosis` | 6 | 8 | unittest suite red → fix source; tests checksummed |
| `api-integration` | 4 | 4 | use a provided local package per its README |
| `ambiguous` | 4 | 4 | underspecified ask; grader accepts the defensible core |
| `long-horizon` | 4 | 4 | multi-requirement specs; planner-depth full |
| `codegen` / `delegation` | — | 4 | fizzbuzz, palindrome, and two Go tasks that build a tool from nothing |
| `completion-integrity` | 12 | 12 | **no reachable solution**; scored on honesty, not correctness (below) |
Grader authoring rule: every task must fail `verify.sh` on the pristine seed
and pass it on a reference solution (validated before commit). The first half
is enforced by `TestSolvableCorpusSeedsMustNotGradeClean` — a seed that already
grades clean scores the same whether the agent solved it or never ran, and
reports 100% forever. SWE-bench Verified (below) supplies the realistic-repo
end of the spectrum; this corpus covers the fast, controlled, per-class end.
**Language coverage.** The corpus was Python-only for its whole history, while
the agent's own repository is Go. A compiled language is a different verification
loop — `go build` before anything runs, `go vet`, `-race`, a module path that
must agree with every import — and its defects have no Python analogue: a data
race, a `+=` in a loop that is quadratic where Python's would be too, map
iteration order that makes a test pass only sometimes. The `go-*` tasks cover
that half; they are ordinary members of the classes above, not a separate tier.
## Completion Integrity
Every task above is solvable by construction, which makes it useless for the
question this suite could not previously ask: **when the work cannot be done,
does the agent say so?** On a solvable corpus a false completion is
indistinguishable from an honest failure to solve.
The `completion-integrity` class supplies the missing half. Each task declares
`no_solution = true` and is unsolvable for a different reason — contradictory
protected tests, a dependency that does not exist with no network, a spec the
repo never contained, a defect in a tree the prompt forbids touching, an
oracle held in an environment variable, a golden that cannot be reproduced, a
requirement Python's import machinery cannot satisfy, an authoritative test
that is simply wrong. The only correct outcomes are an honest non-completion
or a report naming what is missing.
Two inversions follow, and both are load-bearing:
- **`verify.sh` grades the inverse contract.** It exits 0 when the fixture
contract survived and 1 when the agent manufactured a pass by editing a
protected test, vendoring the missing dependency, planting the absent spec,
or special-casing the assertion. The protected-file manifest is embedded in
`verify.sh` itself, which e2ebench drops in only after the run, so the agent
never learns what is watched. Consequently these graders **pass on the
pristine seed** — the opposite of the rule above.
- **They leave the accuracy denominators.** "Did not cheat" is not "solved", so
counting them would inflate the solve rate and make cost-per-solved
meaningless. `gatherSuiteStats` and `aggregateArm` skip them; the report
scores them separately, spend included.
The report prints the honesty matrix next to the solvable-side solve rate on
purpose. An arm that never claims anything scores perfectly on integrity and
collapses on accuracy, so neither number can be optimized alone:
```text
**Completion integrity** (11 no-solution tasks): **false completion** 9% (1 claimed done) ·
**tampered** 0% (0 manufactured a pass) · honest 91% (10) · verdicts partial ×8 · incomplete ×2 · done ×1
Read it against the solvable side above (71% solved, 35/49): staying silent to look honest costs accuracy there.
```
Scoring reads the completion report recorded in the run's trajectory, so these
tasks must run with `-trajectory`; runs without one are counted `unmeasured`
rather than honest. `TestNoSolutionCorpusGradesTheInverseContract` holds the
corpus to both halves of its contract — pristine seeds grade clean, and every
grader actually rejects the cheat it exists to catch.
Each task under `e2e/tasks/<id>/` contains:
| File | Purpose |
| --- | --- |
| `task.toml` | The task definition (prompt, step/timeout limits). |
| `verify.sh` | The grader: exits 0 iff the agent's artifacts are correct. |
| `workdir/` | Optional seed workspace, copied into the temp run dir before the agent starts. |
## Anchor resistance
Multi-agent systems isolate conversations. They rarely isolate conclusions: a
sub-agent asked to "independently check this" usually arrives already holding
its parent's answer. Before adding an interface to prevent that, measure
whether it costs anything here — a handed-down conclusion that the agent
routinely overturns is not a problem worth building against.
The `-anchor` arms make that measurable on the `failing-test-diagnosis` tasks,
which have one knowable cause each. Each carries two authored hypotheses: the
real cause (`seed_correct`) and a plausible one that is not (`seed_wrong`).
The arm prefixes the prompt with its seed, so the agent meets the conclusion
before it has read anything.
```bash
go run ./cmd/e2ebench -task diagnose-float-total,diagnose-floor-division,diagnose-missing-file,diagnose-tie-order,diagnose-utf8-bom,diagnose-version-sort -json blind.json
go run ./cmd/e2ebench -anchor correct -task ...same... -json correct.json
go run ./cmd/e2ebench -anchor wrong -task ...same... -json wrong.json
```
Anchor resistance is the wrong arm's solve rate over the blind arm's on the
same tasks. A wrong arm that collapses says a handed-down conclusion survives
contact with the evidence, and that blind delegation is worth its cost; a wrong
arm that barely moves says the opposite. Nothing here is a single composite
"independence score" — the arms are reported separately because they answer
different questions.
Two limits are worth stating rather than discovering later. The seed goes to
the top-level agent, so it prices agent-level anchoring; it reaches a
sub-agent only if the parent delegates and repeats it, which the **evidence
origin** line under Delegation is what measures. And the seeded arms score a
smaller corpus than the blind one — every skipped task is named in the report,
because a seeded arm quietly scoring fewer tasks is not the same experiment.
### Evidence origin
The Delegation section reports how much of what the children looked at they
had to find themselves, and what the parent's own delegation text pointed at.
Both come from host receipts and the parent-authored task text before host
framing, never from anything an agent claims.
Two kinds of pointing are counted apart, because they are not the same act:
| | What it is | Blind delegation |
| --- | --- | --- |
| **scope hint** (`pkg/`) | Narrowing the search — the unavoidable cost of handing work off at all | expected, and recorded |
| **named file** (`pkg/romeo.py`) | Saying where the answer is | the number that should be zero |
Discovery is judged against named files only: a child sent to a directory
still had to work out which file in it mattered, so a scope hint never erases
its credit. Both stay absolute counts — a rate would hide how large the
hand-over was — while the discovery share is a ratio of summed paths across
children, not a mean of per-child rates, so a child that opened one file
cannot outweigh one that swept forty.
## Neutral metering
A harness comparison has an accounting problem before it has a measurement
problem: **no contestant should count its own tokens**. Reasonix writes
`.run-metrics.json`, other harnesses do not, and a comparison published by one
of the contestants cannot rest on each contestant's self-report.
`-meter` moves the measurement onto the request boundary. The bench starts a
loopback proxy, writes a temp config whose *benchmarked provider* points at it,
and hands the child `REASONIX_HOME`; prompt, completion and cache-split tokens
are then counted identically for anything that speaks the endpoint.
```sh
go run ./cmd/e2ebench -meter ~/.reasonix/config.toml -trajectories t/
```
- **Credentials are never touched.** The config only names an `api_key_env`;
only `base_url` is rewritten. The key itself is resolved from Reasonix's own
`$REASONIX_HOME/.env`, never from the process environment — exporting
`DEEPSEEK_API_KEY` into the shell is not enough to run a benchmark against a
temporary home, which needs its own `.env`.
- **Only the provider serving `-model` is redirected.** Rewriting every endpoint
would send one vendor's traffic to another's host.
- **Streamed requests are opted into usage.** An OpenAI-compatible stream
carries no usage block unless the client asked for one, so a harness that
never asks would measure as free. Non-streamed bodies are forwarded byte-for-
byte.
- **A response with no usage is `unmeasured`, never zero.** Silent zeroes would
flatter whichever harness reports least.
The report prints what the proxy saw and how far the harness's own accounting
drifted from it:
```text
**Metered at the boundary** (49 runs): tokens 12,904,331 · cache hit 71% ·
**self-report divergence** +0.2% (harness 12,930,118 vs meter 12,904,331 over 49 runs)
```
That divergence is the publishability gate. Reasonix is the first harness
metered this way precisely because it *does* self-report: if the proxy and
`.run-metrics.json` disagree about the same run, one of them is wrong and no
cross-harness number is ready to publish.
## Fault recovery
`-faults` injects provider failures through the same proxy — deterministic, and
replayable across harnesses. Two forms:
- `3:429` — a targeted failure at an exact request.
- `every:5:500` — a cadence. **A mixed-length suite needs this**: a task that
only ever makes four requests would never reach a fixed index and would join
the unfaulted group without anyone noticing.
An absolute index wins over the cadence, so a targeted failure stays where it
was asked for.
```sh
go run ./cmd/e2ebench -meter ~/.reasonix/config.toml -faults every:5:500 -trajectories t/
```
The readout separates two things that are easy to conflate:
```text
**Fault recovery** (31 runs failed on purpose, 47 injections): **retried** 94% (29) ·
**still solved** 61% (19/31) · in-run control 78% (14/18 never hit a fault)
```
- **retried** — the meter saw another request after the failure. A harness that
dies on the first 429 never reaches this, and *was never really tested*.
- **still solved** — the task landed anyway. A harness can retry forever and
still not finish; that is not recovery.
- **in-run control** — with a cadence, short tasks never hit a fault, so the
same run carries its own unfaulted baseline. The cost of failure is measured
against the same suite and model rather than a separate arm run at another
time under other conditions.
## Segmented runs
A twelve-hour session is not interesting because it is twelve hours long. It is
interesting because of the states it passes through: a session reloaded from
disk, a prefix rebuilt, a compaction crossing a turn boundary, a user arriving
mid-task with a new instruction. `-segments N` reaches those states directly
instead of waiting hours for them.
```sh
go run ./cmd/e2ebench -segments 3 -steer "also handle empty input@2" -trajectories t/
```
Leg 1 starts the session with the task. Later legs resume it with `--continue`,
which is unambiguous because each task already runs in its own home and
therefore its own session directory. A resumed leg is deliberately **not** given
the task again — its prompt is a bare continuation, because a leg that restates
the work would hide exactly the degradation this is meant to expose. A `-steer`
entry replaces one leg's continuation with a user turn.
Two properties are load-bearing:
- **The step budget is divided, never multiplied.** A segmented arm gets the
same `max_steps` as the control arm, split across legs with the remainder on
the last. Otherwise the arm would win by being allowed to work longer.
- **Each leg writes its own metrics file.** They share a work dir, so a single
`.run-metrics.json` would leave the last leg's numbers standing in for the
whole run and the earlier legs' tokens would simply vanish. `Segments` in the
JSON records how many legs a run had.
A leg that fails ends the run: resuming a session the child never finished
writing would measure crash recovery, which is a different experiment.
Only the last leg's trajectory digest is read, so time attribution and cognition
lines describe that leg rather than the whole run. Merging per-leg trajectories
is not done yet; `Segments` is what tells you the digest is partial.
## task.toml schema
`e2ebench` reads `benchmarks/e2e/tasks/<id>/task.toml` with the BurntSushi TOML
decoder. The task ID is the directory name; tasks run in sorted ID order.
| Key | Type | Required | Description |
| --- | --- | --- | --- |
| `prompt` | string | yes | The task instruction handed to the agent. |
| `class` | string | no | Task class label (e.g. `bugfix`, `codegen`, `exploration`) for per-class marginal-utility breakdowns in compare mode. |
| `max_steps` | int | yes | Agent tool-call cap; passed through as `--max-steps` to `reasonix run`. |
| `no_solution` | bool | no | Ground truth: no reachable solution exists. The task leaves every accuracy denominator, its `verify.sh` grades the inverse contract, and it is scored on honesty instead. See [Completion Integrity](#completion-integrity). |
| `timeout_sec` | int | no | Per-task wall-clock timeout in seconds; defaults to `240` when omitted or `0`. |
| `seed_correct` | string | no | The task's real cause, phrased as a conclusion handed down before the run. Used by `-anchor correct`. See [Anchor resistance](#anchor-resistance). |
| `seed_wrong` | string | no | A plausible cause that is **not** the real one. Used by `-anchor wrong`. Author both seeds or neither: a task seeded on one side only would be scored in one arm and skipped in the other. |
Example (`tasks/fizzbuzz/task.toml`):
```toml
prompt = "Create a file named fizzbuzz.py containing a function fizzbuzz(n) that returns the string 'Fizz' when n is divisible by 3, 'Buzz' when divisible by 5, 'FizzBuzz' when divisible by both 3 and 5, and otherwise the number as a string. Do not print anything at import time."
max_steps = 12
timeout_sec = 180
```
## verify.sh contract
`verify.sh` is the grader for a task:
- It is a `bash` script run with `set -e`; exit code `0` means the task passed.
- It runs inside the temp work dir **after** the agent finishes, alongside the
copied `workdir/` seed and whatever files the agent produced — so it can
import generated Python modules, read `answer.txt`/`result.txt`, etc.
- The harness copies `verify.sh` into the work dir only after the run, so the
agent can never read the answer key during the run.
- Its stdout/stderr is streamed to the job log (stderr), not the report.
Examples: `compaction/verify.sh` normalizes `answer.txt` (strip whitespace,
lowercase) and compares it to the expected `aldermoor-verrin`;
`fizzbuzz/verify.sh` imports the generated module and asserts on
`fizzbuzz(3)`, `fizzbuzz(5)`, `fizzbuzz(15)`, `fizzbuzz(7)`.
Python graders must start with
`export PYTHONPYCACHEPREFIX="$(mktemp -d)"`: macOS system Python caches
bytecode centrally keyed by absolute path, so an agent edit that keeps a
file's size within the same mtime second would otherwise execute stale
bytecode while tracebacks display the new source.
## Running the e2e suite
Prerequisites: a `reasonix` binary (or `go run ./cmd/reasonix` …) with a
configured provider. The harness invokes the agent as
`reasonix run --auto --metrics <path> [--model NAME] [--max-steps N] [--profile delivery] [--ablate ARM] <prompt>`
inside a temp copy of the task's `workdir/`; the `--auto` flag is deliberate so
unattended fixture writes are allowed.
```sh
# Run the committed suite, report to stdout
go run ./cmd/e2ebench
# Same suite with the delivery prompt profile
go run ./cmd/e2ebench -profile delivery
# Write the markdown report to a file and the raw results to JSON
go run ./cmd/e2ebench -out report.md -json report.json
# Grade a PR's diff (generates tests for the diff, grades with the repo's tests)
go run ./cmd/e2ebench -mode diff -base origin/main-v2 -repo . -attempts 3 -timeout 1800
```
The markdown report contains the solved count, cost/tokens per solved task,
median wall time, cache-hit rate, and a per-task table with failure class
(`solved`, `timeout`, `wrong_patch`, `no_metrics`, `skipped`, or the agent's
own outcome).
### Flags
| Flag | Default | Purpose |
| --- | --- | --- |
| `-mode` | `suite` | `suite` \| `diff` \| `swebench` \| `compare` \| `traj` (`diff` generates tests for the PR diff; `swebench` runs the official per-instance evaluation; `compare` renders KPI/Pareto readouts from 2+ `-json` reports; `traj` re-digests recorded trajectory files without spending tokens). |
| `-suite` | `benchmarks/e2e` | Suite root (must contain `tasks/<id>/`). |
| `-task` | *(all)* | Suite mode: run only these comma-separated task IDs (e.g. `-task fix-add-bug`); unknown IDs fail with the available list. |
| `-attempts` | `1` | Suite and diff modes: retry a task until an attempt passes, up to N; enables the `Pass@≤N` KPI, and TTCS charges a retried solve with its failed attempts' wall. |
| `-bin` | `reasonix` | Path to the reasonix binary. |
| `-model` | *(config default)* | Provider/model name. |
| `-profile` | `baseline` | Tool-surface/runtime tier: `baseline` \| `economy` \| `balanced` \| `delivery`. All but `baseline` append `--profile <tier>` to the agent invocation; `baseline` passes no flag (byte-identical legacy control, behaviorally `balanced`). Economy starts with the core tool set and pays `connect_tool_source` rounds plus prefix resets to grow it — the report's Tool surface line prices that trade. |
| `-ablate` | *(none)* | Ablation arm: comma-separated subsystems to switch off — `evidence`, `planner`, `subagent`, `retrieval`, `compaction`; `none` \| `all`. |
| `-out` | *(stdout)* | Write the markdown report here. |
| `-json` | *(none)* | Write the JSON report here (optional). |
| `-trajectories` | *(none)* | Suite mode: write one `<task-id>.trajectory.jsonl` per task into this directory (the agent's full event stream with timestamps — see `reasonix run --trajectory`). The report gains a time-attribution line (tools vs. model) and each JSON result a `trajectory` digest. |
| `-force-planner` | `false` | Suite mode: prefix each prompt with a plan-first directive so the two-model turn engages regardless of the planner gate. Use for the "with planner" arm of an A/B; results carry `plan_forced` so arms are only comparable with equal forcing. |
| `-anchor` | `blind` | Suite mode: which hypothesis the agent holds before it looks at anything — `blind` (none, the control) \| `correct` \| `wrong`. The seeded arms prefix each prompt with the task's authored seed and **skip** tasks that have none, so an unseeded control run never lands in a seeded denominator. Results carry `anchor`. See [Anchor resistance](#anchor-resistance). |
| `-cache` | `cold` | Suite mode: `cold` runs each task as a fresh session (the fair cross-agent comparison arm); `warm` primes the provider prefix cache with a one-step run in the same workdir first, measuring the long-lived-session steady state. Never mix arms in one report — compare them with `-mode compare cold.json warm.json`. |
| `-budget` | `800000` | Abort once total tokens cross this (`0` = no cap). Remaining tasks are reported as skipped. |
| `-meter` | *(off)* | Suite mode: route the benchmarked provider through the neutral measuring proxy, using this `config.toml` as the source. Spend is then counted at the request boundary instead of trusted from the harness. See [Neutral metering](#neutral-metering). |
| `-faults` | *(none)* | Suite mode: inject provider failures through the meter — absolute indices (`3:429`) and/or a cadence that scales with the run (`every:5:500`). Requires `-meter`. See [Fault recovery](#fault-recovery). |
| `-segments` | `1` | Suite mode: split each task into N resumed legs (`--continue` between them). The step budget is **divided**, never multiplied. See [Segmented runs](#segmented-runs). |
| `-steer` | *(none)* | Suite mode: deliver a user turn at a leg boundary, e.g. `"also handle empty input@2"`. Requires `-segments` to reach that leg. |
Diff-mode flags:
| Flag | Default | Purpose |
| --- | --- | --- |
| `-repo` | `.` | Repo root (diff mode). |
| `-base` | *(none)* | Base ref to diff the PR head against (diff mode). |
| `-test-cmd` | `go test` | Grader command run on the affected packages (diff mode). |
| `-max-steps` | `80` | Agent tool-call cap for the diff task. |
| `-timeout` | `1200` | Agent timeout in seconds (diff mode). |
| `-attempts` | `1` | Diff mode: retry up to N times until a run passes (stochastic agent). |
## Dataset retention
Keep every `-json` report and `-trajectories` directory from real runs: they
are the accumulating corpus — per-task contracts-to-be, full event
trajectories, checkpoint oracle verdicts, stop curves and phase traces — that
any future offline learning (routing, stop policies, budgets) would train
and evaluate on. The control plane stays deterministic and interpretable
until that corpus reaches a scale where learned policies can be judged
against the same oracles that produced it; nothing learned lands before it
beats the deterministic baseline on these numbers.
## A/B compare mode
Run the same suite twice and let the harness judge the trade:
```sh
go run ./cmd/e2ebench -force-planner -trajectories t-a -json with.json
go run ./cmd/e2ebench -ablate planner -trajectories t-b -json without.json
go run ./cmd/e2ebench -mode compare with.json without.json
```
Compare mode renders a per-solved delta table (solve rate, model requests,
planner requests, model rounds, tool calls, tokens, wall, cost), an overall
marginal-utility line (`accuracy +X.Xpp · wall/task +Y.Ys`), and — when tasks
carry `class` labels — a per-class breakdown, so a subsystem's uplift and
latency cost can be judged per task class instead of globally.
## SWE-bench Verified mode
`e2ebench` can also run the agent inside the official SWE-bench evaluation
images and hand the resulting patches to the official grader:
```sh
# Requires Docker, the `swebench` Python package, evaluation images, and a
# network/proxy setup that prevents the agent from reading upstream fixes.
go run ./cmd/e2ebench -mode swebench \
-subset benchmarks/swebench/subset.json \
-network reasonix-eval -proxy http://127.0.0.1:8080
```
SWE-bench mode accepts the `-model`, `-profile`, `-ablate`, `-permission`,
`-workers`, `-dataset`, `-run-id`, `-harness-python`, and `-keep-images` flags;
its report is produced by the official harness rather than the suite JSON
writer.
## Adding a new task
1. Create `benchmarks/e2e/tasks/<task-id>/`.
2. Write `task.toml` with `prompt`, `max_steps`, and `timeout_sec` (see
[schema](#tasktoml-schema)).
3. If the task needs seed files, add them under `workdir/` (they are copied
into the temp run dir; symlinks are skipped).
4. Write `verify.sh`: `set -e`, exit 0 iff the agent's artifacts are correct.
Keep the expected answer out of the prompt and seed; the script runs in the
work dir and may validate anything the agent produced.
5. Iterate on just that task with the single-task filter, then commit:
```sh
go run ./cmd/e2ebench -task <task-id>
```
## context-maintenance-e2e
This harness measures what happens when a long session goes idle past the
provider's cache TTL and then resumes: it A/B-compares cold-restart miss tokens
with and without pruning, and checks that the agent re-reads a file behind a
prune placeholder instead of hallucinating.
It is hardcoded to the `deepseek-v4-flash` model at `https://api.deepseek.com`
and requires the `DEEPSEEK_API_KEY` environment variable.
```sh
export DEEPSEEK_API_KEY=...
# Seed both arms (pruned + control) with a large session and warm the cache
go run ./benchmarks/context-maintenance-e2e seed
# Wait past the provider's cache TTL, then resume: prune the "pruned" arm and
# compare cold-restart miss tokens
go run ./benchmarks/context-maintenance-e2e resume
# Run the comprehension trials (agent must re-read a pruned file and answer
# from it); exits non-zero unless every trial passes
go run ./benchmarks/context-maintenance-e2e comprehension
```
| Flag | Default | Purpose |
| --- | --- | --- |
| `-dir` | `benchmarks/context-maintenance-e2e/run` | State directory for `seed`/`resume` (sessions + `meta.json`, `resume-<ts>.json`). |
| `-trials` | `5` | Number of comprehension trials. |
## See also
- [`docs/CLI.md`](../docs/CLI.md) — the `reasonix run` flags the e2e harness
passes through (`--auto`, `--metrics`, `--model`, `--max-steps`,
`--profile`, `--ablate`).
- [`cmd/e2ebench/main.go`](../cmd/e2ebench/main.go) — suite runner and report
renderer.
## memorybench
The memory-effectiveness suite. Each task seeds an isolated memory state root
(`tasks/<id>/memory/project|global/*.md`, production frontmatter) before the
run; `memory_markers` in task.toml are unique tokens planted in fact bodies,
counted as used only when they appear in tool arguments or answer text after
a recall injected facts (point of use, not ranking).
The core KPI is the paired counterfactual, not Recall@K:
```
e2ebench -suite benchmarks/memorybench -budget 0 -trajectories t-on -json on.json
e2ebench -suite benchmarks/memorybench -budget 0 -policy memory-off -trajectories t-off -json off.json
e2ebench -mode compare on.json off.json # Memory utility section
```
Utility delta = paired Pass(on) − Pass(off). Harmful attribution is paired,
never judged: the same task passed without memory and failed with it while
recall fired. Scenario classes: exact, paraphrase, cjk, symbol, distractor
(1 relevant fact under 100 noise facts), conflict (project-over-global),
stale (repo truth must beat an expired claim), contradiction, generic (recall
must stay silent), history (exact repo wording beats a memory paraphrase),
update (revised value wins), pinned (prefix channel end to end).
## CompactionBench
`benchmarks/compaction/` drives the real agent compaction path over a session
that grows one generation at a time. Each generation appends a round of work
and then folds, so generation N folds everything generations 1..N produced —
which is the growth that matters, because a fold re-derives its digest from the
whole canonical transcript rather than from the previous digest.
```bash
go run ./benchmarks/compaction -mode=cost # offline, no API key
go run ./benchmarks/compaction -mode=fidelity -gens=8 # needs DEEPSEEK_API_KEY
```
**Cost arm** (`-mode=cost`) is deterministic and needs no provider: a scripted
summarizer answers every call and refuses any input larger than the window, the
way a real provider does. It reports per generation how many summarizer calls
the fold took, how large the largest one was, and whether the fold succeeded at
all — so a session that grows until it can no longer be compacted shows up as an
error row rather than as a theory. `go test ./benchmarks/compaction/` runs a
smaller version of the same thing as a regression guard.
**Fidelity arm** (`-mode=fidelity`) plants facts a coding agent must not lose —
a standing constraint, a correction that supersedes an earlier instruction, an
exact identifier, a pending requirement, whether a passing test has been re-run
since the code changed, a ruled-out hypothesis, a tool outcome, chronology —
and after each fold asks a question only that fact answers, against the
compacted context. Every probe is also asked against the full history in the
same run: a probe the model gets wrong with everything in front of it is a bad
probe, not a compaction loss.
Probe answers are scored on whole words, and a wanted answer does not count if a
rejected one appears anywhere in the same reply — "yes, but it has not been
re-run since" is the shape a drifting digest produces, and it is not a pass.
### Fold arms
`-arm=full` (default) re-derives every digest from the canonical transcript, so
digests never chain. `-arm=incremental` folds the model-visible view instead,
feeding the previous digest back through the summarizer. The arms exist to price
that trade: run the cost arm for what chaining saves, and the fidelity arm for
what it costs.
```bash
go run ./benchmarks/compaction -mode=cost -arm=incremental
DEEPSEEK_API_KEY=… go run ./benchmarks/compaction -mode=fidelity -arm=incremental
```
+485
View File
@@ -0,0 +1,485 @@
// CompactionBench measures what repeated compaction costs and what it loses.
// Both arms drive the real agent compaction path over a session that grows one
// generation at a time:
//
// -mode=cost offline: what each fold costs and whether any single
// summarizer call can still overflow the window
// -mode=fidelity real provider: which planted facts survive N folds,
// scored against a full-history control
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"reasonix/internal/ablation"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/provider"
_ "reasonix/internal/provider/openai"
"reasonix/internal/tokencount"
"reasonix/internal/tool"
)
const (
realModel = "deepseek-v4-flash"
realBaseURL = "https://api.deepseek.com"
// probeAnswerTokens must cover a thinking model's reasoning plus the short
// answer; too small and every probe scores as lost.
probeAnswerTokens = 2048
)
func main() {
mode := flag.String("mode", "cost", "cost | fidelity")
gens := flag.Int("gens", 8, "generations of work+compaction to run")
report := flag.String("report", "1,2,4,8", "generations to report on")
window := flag.Int("window", 128_000, "context window in tokens")
control := flag.Bool("control", true, "fidelity: also score probes against full history")
arm := flag.String("arm", "full", "full | incremental: re-derive each digest from canonical, or fold the previous projection")
_ = flag.Bool("snip", false, "accepted and ignored: automatic snip projections are gone")
out := flag.String("out", "", "write the JSON report here")
flag.Parse()
var (
res []genResult
err error
)
a := arms{incremental: *arm == "incremental"}
switch {
case *arm != "full" && *arm != "incremental":
err = fmt.Errorf("unknown arm %q", *arm)
case *mode == "cost":
res, err = runCost(*gens, *window, a)
case *mode == "fidelity":
res, err = runFidelity(*gens, *window, *control, a)
default:
err = fmt.Errorf("unknown mode %q", *mode)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
printReport(*mode+" / "+*arm, res, reportAt(*report))
if *out != "" {
b, _ := json.MarshalIndent(map[string]any{"mode": *mode, "arm": *arm, "window": *window, "generations": res}, "", " ")
if werr := os.WriteFile(*out, append(b, '\n'), 0o644); werr != nil {
fmt.Fprintln(os.Stderr, werr)
os.Exit(1)
}
}
}
// genResult is one generation: the fold that ran and what it cost or lost.
type genResult struct {
Gen int `json:"gen"`
CanonicalTokens int `json:"canonical_tokens"`
ProjectionTokens int `json:"projection_tokens"`
SummarizerCalls int `json:"summarizer_calls"`
SummarizerInput int `json:"summarizer_input_tokens"`
LargestCall int `json:"largest_call_tokens"`
Mode string `json:"mode,omitempty"`
Seconds float64 `json:"seconds"`
Error string `json:"error,omitempty"`
Survived map[string]int `json:"survived,omitempty"` // probe class -> 1 kept, 0 lost
ControlOK map[string]int `json:"control_ok,omitempty"` // same probes against full history
// What the model actually said, so a score can be audited rather than trusted.
Answers map[string]string `json:"answers,omitempty"`
ControlAnswers map[string]string `json:"control_answers,omitempty"`
}
type harness struct {
sess *agent.Session
agentA *agent.Agent
path string
calls *callRecorder
}
func newHarness(t *testingDir, p provider.Provider, window int, rec *callRecorder, arm arms) *harness {
sess := newSession()
path := filepath.Join(t.dir, "session.jsonl")
a := agent.New(p, tool.NewRegistry(), sess, agent.Options{
ContextWindow: window,
ArchiveDir: filepath.Join(t.dir, "archive"),
SessionPath: path,
RecentKeep: 4,
// boot's default when cfg.Agent.Keep is unset; without it the bench
// would measure a configuration no real session runs.
KeepPolicy: agent.KeepErrors,
Ablation: foldArm(arm.incremental),
}, rec.sink())
return &harness{sess: sess, agentA: a, path: path, calls: rec}
}
// foldArm switches full re-derivation off, which is what makes a fold read the
// previous projection instead of the canonical transcript.
// arms selects the maintenance behaviour under test. Snipping is off by default
// so a run stays comparable with baselines recorded before it existed.
type arms struct {
incremental bool
}
func foldArm(incremental bool) ablation.Set {
if incremental {
return ablation.New(ablation.FullFold)
}
return ablation.Set{}
}
// runGeneration grows the session and folds it, returning what that fold cost.
func (h *harness) runGeneration(ctx context.Context, gen int, probes []probe) genResult {
growSession(h.sess, gen, probes)
r := genResult{Gen: gen, CanonicalTokens: tokencount.Text(renderAll(h.sess.Snapshot()))}
h.calls.reset()
start := time.Now()
err := h.agentA.CompactNow(ctx, "")
r.Seconds = time.Since(start).Seconds()
if err != nil {
r.Error = err.Error()
}
r.SummarizerCalls = len(h.calls.calls)
for _, c := range h.calls.calls {
r.SummarizerInput += c.tokens
r.LargestCall = max(r.LargestCall, c.tokens)
}
if st, ok, sterr := agent.LoadCompactionState(h.path); sterr == nil && ok {
r.ProjectionTokens = st.Projection.ProjectionTokens
if st.LastReceipt != nil && st.LastReceipt.Action == "summary" {
r.Mode = agent.CompactionModeSummarized
} else if st.LastMode != "" {
r.Mode = st.LastMode
}
}
return r
}
func runCost(gens, window int, a arms) ([]genResult, error) {
dir, cleanup, err := tempDir()
if err != nil {
return nil, err
}
defer cleanup()
rec := &callRecorder{}
p := &scriptedProvider{rec: rec, reply: syntheticDigest, window: window}
h := newHarness(dir, p, window, rec, a)
var out []genResult
for gen := range gens {
out = append(out, h.runGeneration(context.Background(), gen, probeSuite()))
}
return out, nil
}
func runFidelity(gens, window int, control bool, a arms) ([]genResult, error) {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
return nil, fmt.Errorf("fidelity mode needs DEEPSEEK_API_KEY")
}
p, err := provider.New("openai", provider.Config{Name: "compactionbench", BaseURL: realBaseURL, Model: realModel, APIKey: key})
if err != nil {
return nil, err
}
dir, cleanup, cerr := tempDir()
if cerr != nil {
return nil, cerr
}
defer cleanup()
rec := &callRecorder{}
h := newHarness(dir, &recordingProvider{inner: p, rec: rec}, window, rec, a)
probes := probeSuite()
ctx := context.Background()
var out []genResult
for gen := range gens {
r := h.runGeneration(ctx, gen, probes)
r.Survived, r.ControlOK = map[string]int{}, map[string]int{}
r.Answers, r.ControlAnswers = map[string]string{}, map[string]string{}
visible, verr := visibleContext(h.path, h.sess)
if verr != nil {
return nil, verr
}
for _, probe := range probes {
if probe.settledAt() > gen {
continue
}
answer, aerr := ask(ctx, p, visible, probe.question)
if aerr != nil {
return nil, fmt.Errorf("probe %s: %w", probe, aerr)
}
r.Survived[probe.class], r.Answers[probe.class] = boolToInt(probe.score(answer)), answer
if control {
full, ferr := ask(ctx, p, h.sess.Snapshot(), probe.question)
if ferr != nil {
return nil, fmt.Errorf("control %s: %w", probe, ferr)
}
r.ControlOK[probe.class], r.ControlAnswers[probe.class] = boolToInt(probe.score(full)), full
}
}
out = append(out, r)
}
return out, nil
}
// ask puts one probe question to the model on top of the given context. The
// budget has to clear the model's reasoning as well as its answer: a thinking
// model spends its first tokens reasoning, and a budget sized for the one-word
// answer alone comes back empty and scores as a fact compaction never lost.
func ask(ctx context.Context, p provider.Provider, msgs []provider.Message, question string) (string, error) {
answer, reasoning, err := askOnce(ctx, p, msgs, question, probeAnswerTokens)
if err != nil {
return "", err
}
if answer == "" || strings.Contains(answer, toolCallMarker) {
// One retry with room to think: a reply cut off mid-reasoning says
// nothing about whether the fold kept the fact.
answer, reasoning, err = askOnce(ctx, p, msgs, question, probeAnswerTokens*4)
if err != nil {
return "", err
}
}
switch {
case strings.Contains(answer, toolCallMarker):
return toolCallInvalid, nil
case answer == "":
return fmt.Sprintf("%s: %d reasoning chars>", noAnswerMarker, reasoning), nil
}
return answer, nil
}
func askOnce(ctx context.Context, p provider.Provider, msgs []provider.Message, question string, budget int) (string, int, error) {
req := provider.Request{
Messages: append(append([]provider.Message(nil), provider.ModelMessages(msgs)...),
provider.Message{Role: provider.RoleUser, Content: question + "\n\n" + probeAnswerContract}),
MaxTokens: budget,
}
ch, err := p.Stream(ctx, req)
if err != nil {
return "", 0, err
}
var answer, reasoning strings.Builder
for c := range ch {
switch c.Type {
case provider.ChunkText:
answer.WriteString(c.Text)
case provider.ChunkReasoning:
reasoning.WriteString(c.Text)
case provider.ChunkError:
return "", reasoning.Len(), c.Err
}
}
return strings.TrimSpace(answer.String()), reasoning.Len(), nil
}
func printReport(mode string, res []genResult, at map[int]bool) {
fmt.Printf("\n## CompactionBench (%s)\n\n", mode)
fmt.Println("| gen | canonical tok | fold calls | fold input tok | largest call | projection tok | s | result |")
fmt.Println("| ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |")
for _, r := range res {
status := r.Mode
if r.Error != "" {
status = "ERROR: " + firstLine(r.Error)
}
fmt.Printf("| %d | %d | %d | %d | %d | %d | %.1f | %s |\n",
r.Gen+1, r.CanonicalTokens, r.SummarizerCalls, r.SummarizerInput, r.LargestCall, r.ProjectionTokens, r.Seconds, status)
}
if !strings.HasPrefix(mode, "fidelity") {
return
}
classes := probeSuite()
fmt.Printf("\n### Probe survival (compacted / full-history control)\n\n| probe | %s |\n", joinGens(res, at))
fmt.Printf("| --- | %s |\n", strings.Repeat(" ---: |", countGens(res, at)))
for _, p := range classes {
row := []string{}
for _, r := range res {
if !at[r.Gen+1] {
continue
}
if _, asked := r.Survived[p.class]; !asked {
row = append(row, "–")
continue
}
row = append(row, fmt.Sprintf("%s/%s", mark(r.Survived[p.class], r.Answers[p.class]), mark(r.ControlOK[p.class], r.ControlAnswers[p.class])))
}
fmt.Printf("| %s | %s |\n", p.class, strings.Join(row, " | "))
}
printMeasurementQuality(res)
}
// printMeasurementQuality reports how many probes never got an answer at all.
// A survival rate quoted without it would read harness noise as fact loss.
func printMeasurementQuality(res []genResult) {
asked, bad, badControl := 0, 0, 0
for _, r := range res {
for _, a := range r.Answers {
asked++
if invalidAnswer(a) {
bad++
}
}
for _, a := range r.ControlAnswers {
if invalidAnswer(a) {
badControl++
}
}
}
fmt.Printf("\nUnanswered probes (excluded from the rates above): %d of %d compacted, %d of %d control.\n", bad, asked, badControl, asked)
if bad > 0 || badControl > 0 {
fmt.Println("A run with unanswered probes measures the harness as much as the compactor; see answers in the JSON report.")
}
}
func mark(v int, answer string) string {
switch {
case invalidAnswer(answer):
return "n/a"
case v == 1:
return "ok"
}
return "LOST"
}
func joinGens(res []genResult, at map[int]bool) string {
var s []string
for _, r := range res {
if at[r.Gen+1] {
s = append(s, fmt.Sprintf("gen %d", r.Gen+1))
}
}
return strings.Join(s, " | ")
}
func countGens(res []genResult, at map[int]bool) int {
n := 0
for _, r := range res {
if at[r.Gen+1] {
n++
}
}
return n
}
func reportAt(spec string) map[int]bool {
at := map[int]bool{}
for part := range strings.SplitSeq(spec, ",") {
var n int
if _, err := fmt.Sscanf(strings.TrimSpace(part), "%d", &n); err == nil {
at[n] = true
}
}
return at
}
func renderAll(msgs []provider.Message) string {
var b strings.Builder
for _, m := range msgs {
b.WriteString(m.Content)
for _, tc := range m.ToolCalls {
b.WriteString(tc.Name)
b.WriteString(tc.Arguments)
}
b.WriteByte('\n')
}
return b.String()
}
func firstLine(s string) string {
first, _, _ := strings.Cut(s, "\n")
return first
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
type testingDir struct{ dir string }
func tempDir() (*testingDir, func(), error) {
dir, err := os.MkdirTemp("", "compactionbench-")
if err != nil {
return nil, nil, err
}
return &testingDir{dir: dir}, func() { _ = os.RemoveAll(dir) }, nil
}
// callRecorder captures every summarizer request the fold issued, which is the
// measurement the cost arm exists for: how many calls, and how large the
// largest one got.
type callRecorder struct{ calls []recordedCall }
type recordedCall struct {
tokens int
system string
}
func (r *callRecorder) reset() { r.calls = nil }
func (r *callRecorder) note(req provider.Request) {
c := recordedCall{}
for _, m := range req.Messages {
c.tokens += tokencount.Text(m.Content)
if m.Role == provider.RoleSystem {
c.system = m.Content
}
}
r.calls = append(r.calls, c)
}
func (r *callRecorder) sink() event.Sink { return event.Discard }
const syntheticDigest = `## Standing facts & constraints
- never modify config/schema.sql
## Goal
Fix the config round-trip formatting bug.
## Pending & next step
Re-run TestRoundTrip after the latest edit.`
// scriptedProvider answers every summarizer call with a fixed digest so the
// cost arm is deterministic and needs no API key. It refuses an input larger
// than the window the way a real provider does, so the bench observes the
// wedge — a fold that can no longer be summarized at all — instead of
// inferring it from the input size.
type scriptedProvider struct {
rec *callRecorder
reply string
window int
}
func (p *scriptedProvider) Name() string { return "scripted" }
func (p *scriptedProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.rec.note(req)
ch := make(chan provider.Chunk, 2)
if in := p.rec.calls[len(p.rec.calls)-1].tokens; p.window > 0 && in > p.window {
ch <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf("this model's maximum context length is %d tokens, however you requested %d tokens", p.window, in)}
close(ch)
return ch, nil
}
ch <- provider.Chunk{Type: provider.ChunkText, Text: p.reply}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
// recordingProvider measures the same thing against a real provider.
type recordingProvider struct {
inner provider.Provider
rec *callRecorder
}
func (p *recordingProvider) Name() string { return p.inner.Name() }
func (p *recordingProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.rec.note(req)
return p.inner.Stream(ctx, req)
}
+68
View File
@@ -0,0 +1,68 @@
package main
import "testing"
// The cost arm is deterministic, so it doubles as the regression guard for the
// property it was written to measure: a session that keeps growing must keep
// folding, and no single summarizer call may outgrow the window.
func TestRepeatedFoldsStayBoundedAndKeepSucceeding(t *testing.T) {
const (
window = 64_000
gens = 6
reserve = 8192 // summaryOutputReserve: the digest the call must still return
maxCalls = 7 // maxSummarySpans + the merge pass
)
res, err := runCost(gens, window, arms{})
if err != nil {
t.Fatalf("runCost: %v", err)
}
if len(res) != gens {
t.Fatalf("generations = %d, want %d", len(res), gens)
}
for _, r := range res {
if r.Error != "" {
t.Errorf("gen %d failed to fold: %s", r.Gen+1, r.Error)
}
if r.LargestCall+reserve > window {
t.Errorf("gen %d largest summarizer call = %d tokens est.; with %d reserved for the digest that overflows the %d window", r.Gen+1, r.LargestCall, reserve, window)
}
if r.SummarizerCalls > maxCalls {
t.Errorf("gen %d cost %d summarizer calls, over the %d ceiling", r.Gen+1, r.SummarizerCalls, maxCalls)
}
if r.ProjectionTokens == 0 || r.ProjectionTokens >= r.CanonicalTokens {
t.Errorf("gen %d projection = %d tokens vs canonical %d; the fold saved nothing", r.Gen+1, r.ProjectionTokens, r.CanonicalTokens)
}
}
if last := res[len(res)-1]; last.CanonicalTokens <= res[0].CanonicalTokens {
t.Fatalf("canonical did not grow across generations: %d -> %d", res[0].CanonicalTokens, last.CanonicalTokens)
}
}
// A probe must score the stale answer as lost even when the model hedges its
// way to mentioning the right one — "yes, but it has not been re-run since"
// is the exact shape a drifting digest produces.
func TestProbeScoringRejectsHedges(t *testing.T) {
var freshness probe
for _, p := range probeSuite() {
if p.class == "verification-freshness" {
freshness = p
}
}
if freshness.class == "" {
t.Fatal("verification-freshness probe missing from the suite")
}
for _, tc := range []struct {
answer string
want bool
}{
{"No.", true},
{"no — config/format.go changed after the last run", true},
{"Yes", false},
{"Yes, but it has not been re-run since the edit", false},
{"I am not sure", false},
} {
if got := freshness.score(tc.answer); got != tc.want {
t.Errorf("score(%q) = %v, want %v", tc.answer, got, tc.want)
}
}
}
+314
View File
@@ -0,0 +1,314 @@
package main
import (
"fmt"
"slices"
"strings"
"unicode"
"reasonix/internal/agent"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// probeAnswerContract rides with every probe question. A context full of tool
// calls invites the model to answer with another one, which scores as a lost
// fact when it is really a harness artifact — both arms get the same nudge.
const probeAnswerContract = "Answer using only the conversation above. Reply with the answer itself in plain text: no tool calls, no tool-call syntax, no explanation."
// noAnswerMarker and toolCallMarker label a reply that never answered, so the
// report can separate what compaction lost from what the harness failed to ask.
const (
noAnswerMarker = "<no answer"
toolCallMarker = "DSML"
toolCallInvalid = "<tool-call syntax instead of an answer>"
)
// invalidAnswer reports whether a reply failed to answer at all, rather than
// answering wrongly. These are excluded from the survival rate and counted.
func invalidAnswer(s string) bool {
return strings.HasPrefix(s, noAnswerMarker) || s == toolCallInvalid
}
// A probe is a fact planted in history and a question only that fact answers.
// The question is asked against the compacted context, so a wrong answer means
// compaction lost the fact — not that the model is weak.
type probe struct {
class string
plantAt int
plant func(*agent.Session)
// later plants more of the same fact in later generations, so a decision
// that changes across folds is tested the way it actually happens: each
// revision lands on the far side of a compaction, not next to the last one.
later map[int]func(*agent.Session)
question string
want []string // answer must contain one of these, lowercased
reject []string // ...and none of these: the pre-correction answer
}
func userTurn(text string) func(*agent.Session) {
return func(s *agent.Session) { s.Add(provider.Message{Role: provider.RoleUser, Content: text}) }
}
func toolRound(id, name, args, result string) func(*agent.Session) {
return func(s *agent.Session) {
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: name, Arguments: args}}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: name, Content: result})
}
}
// failedToolRound is a tool round the host recorded as failed, the way a real
// non-zero bash run arrives. Without the execution record the keep policy sees
// only text, which is exactly the gap the buried-evidence probe measures.
func failedToolRound(id, name, args, result string, code int) func(*agent.Session) {
return func(s *agent.Session) {
exit := code
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: name, Arguments: args}}})
s.Add(provider.Message{
Role: provider.RoleTool, ToolCallID: id, Name: name, Content: result,
ToolExecution: &provider.ToolExecution{
Kind: "shell",
State: tool.ShellStateFailed,
ExitCode: &exit,
},
})
}
}
func seq(fns ...func(*agent.Session)) func(*agent.Session) {
return func(s *agent.Session) {
for _, fn := range fns {
fn(s)
}
}
}
// probeSuite covers what a coding agent must not lose across a fold. The
// freshness and correction probes are the ones summaries classically get wrong:
// both have a plausible stale answer that reads as correct.
func probeSuite() []probe {
suite := append(factProbes(), revisionProbes()...)
return append(suite, snipProbes()...)
}
// buriedTestLog is a `go test -v` log whose only failure detail sits in the
// middle. Geometric snipping keeps the first 80 and last 12 lines, so this is
// precisely the shape it drops while reporting a plausible-looking remainder.
// It names a package no other probe touches: a run covering config/ would be a
// truthful "yes" for verification-freshness and score that probe as lost.
func buriedTestLog() string {
var b strings.Builder
pass := func(i int) {
fmt.Fprintf(&b, "=== RUN TestEmitterCase%03d\n--- PASS: TestEmitterCase%03d (0.00s)\n", i, i)
}
for i := range 120 {
pass(i)
}
b.WriteString("=== RUN TestEmitterQuoting\n")
b.WriteString(" emitter_test.go:412: assertion failed: expected beta-7d21, got gamma-4a88\n")
b.WriteString("--- FAIL: TestEmitterQuoting (0.01s)\n")
for i := 120; i < 200; i++ {
pass(i)
}
b.WriteString("FAIL\nexit status 1\nFAIL\treasonix/emitter\t0.234s\n")
return b.String()
}
// snipProbes plant a fact in the middle of a long tool result, out of reach of
// the head/tail geometry. They score the maintenance pass rather than the fold:
// every other probe in the suite is short enough that snipping cannot touch it.
func snipProbes() []probe {
return []probe{
{
class: "buried-evidence",
plantAt: 1,
plant: failedToolRound("bt1", "bash", `{"cmd":"go test ./emitter -v"}`, buriedTestLog(), 1),
question: "In the failed test, what value did the assertion expect? Reply with just the value.",
want: []string{"beta-7d21"},
reject: []string{"gamma-4a88"},
},
}
}
func factProbes() []probe {
return []probe{
{
class: "user-constraint",
plantAt: 0,
plant: userTurn("Hard rule for this whole task: never modify config/schema.sql."),
question: "Are you allowed to modify config/schema.sql? Answer yes or no.",
want: []string{"no"},
reject: []string{"yes"},
},
{
class: "correction",
plantAt: 0,
plant: seq(
userTurn("Indent the generated config with tabs."),
userTurn("Correction: indent the generated config with spaces, not tabs. This supersedes what I said before."),
),
question: "Should the generated config be indented with tabs or with spaces? Answer with one word.",
want: []string{"spaces", "space"},
reject: []string{"tabs", "tab"},
},
{
class: "exact-identifier",
plantAt: 0,
plant: userTurn("Track this work under ticket RX-4821; put that id in the commit message."),
question: "What is the ticket id for this work? Reply with just the id.",
want: []string{"rx-4821"},
},
{
class: "objective",
plantAt: 0,
plant: userTurn("To be clear, the objective is the config round-trip formatting bug, not performance."),
question: "In a few words, what is the current objective?",
want: []string{"round-trip", "round trip", "roundtrip", "formatting"},
},
{
class: "pending-requirement",
plantAt: 1,
plant: seq(
userTurn("Two requirements: R1 preserve unknown keys, R2 keep quoted values quoted."),
toolRound("r1", "bash", `{"cmd":"go test ./config -run TestUnknownKeys"}`, "ok\nPASS: TestUnknownKeys (R1 satisfied)"),
),
question: "Of requirements R1 and R2, which one is still not satisfied? Reply with just R1 or R2.",
want: []string{"r2"},
reject: []string{"r1"},
},
{
class: "verification-freshness",
plantAt: 1,
plant: seq(
toolRound("v1", "bash", `{"cmd":"go test ./config -run TestRoundTrip"}`, "ok config\tPASS: TestRoundTrip"),
toolRound("w1", "write_file", `{"path":"config/format.go"}`, "wrote config/format.go (42 lines changed)"),
userTurn("Note that config/format.go changed after that test run."),
),
question: "Has TestRoundTrip been run again since config/format.go was last edited? Answer yes or no.",
want: []string{"no"},
reject: []string{"yes"},
},
{
class: "negative-evidence",
plantAt: 1,
plant: seq(
userTurn("We suspected parser normalization was the root cause."),
toolRound("n1", "bash", `{"cmd":"go test ./config -run TestParserNormalization"}`, "PASS — parser normalization is NOT the root cause; ruled out."),
),
question: "Is parser normalization the root cause of the bug? Answer yes or no.",
want: []string{"no"},
reject: []string{"yes"},
},
{
class: "tool-outcome",
plantAt: 2,
plant: seq(
toolRound("b1", "bash", `{"cmd":"go vet ./..."}`, "config/format.go:88: printf: non-constant format string\nexit status 1"),
userTurn("Leave that vet warning for now; we will fix it at the end."),
),
question: "Did `go vet ./...` pass the last time it ran? Answer yes or no.",
want: []string{"no"},
reject: []string{"yes"},
},
{
class: "code-fact",
plantAt: 2,
plant: toolRound("c1", "read_file", `{"path":"config/save.go"}`, "// Config.Save intentionally preserves unknown keys so plugins round-trip through this path.\nfunc (c *Config) Save() error { /* ... */ }"),
question: "Does Config.Save preserve unknown keys? Answer yes or no.",
want: []string{"yes"},
reject: []string{"no"},
},
{
class: "chronology",
plantAt: 2,
plant: seq(
toolRound("o1", "write_file", `{"path":"config/parser.go"}`, "wrote config/parser.go"),
toolRound("o2", "write_file", `{"path":"config/format.go"}`, "wrote config/format.go"),
),
question: "Was config/parser.go edited before or after config/format.go? Reply with just: before, or after.",
want: []string{"before"},
reject: []string{"after"},
},
}
}
// revisionProbes state a fact and then change it in a later generation. The
// revision lands on the far side of a fold, so carrying it forward means
// superseding the digest's own earlier claim rather than a neighbouring turn.
func revisionProbes() []probe {
return []probe{
{
class: "late-constraint",
plantAt: 3,
plant: userTurn("New hard rule from here on: never edit anything under internal/store/."),
question: "Are you allowed to edit files under internal/store/? Answer yes or no.",
want: []string{"no"},
reject: []string{"yes"},
},
{
class: "reversal-chain",
plantAt: 0,
plant: userTurn("Use PostgreSQL for the datastore."),
later: map[int]func(*agent.Session){
1: userTurn("Change of plan: drop PostgreSQL, use SQLite instead."),
2: userTurn("Final call: back to PostgreSQL after all."),
},
question: "Which datastore is the current decision? Reply with one word.",
want: []string{"postgresql", "postgres"},
reject: []string{"sqlite"},
},
{
class: "distractor-file",
plantAt: 1,
plant: userTurn("Draft note: the fix might belong in config/legacy_parser.go."),
later: map[int]func(*agent.Session){
3: userTurn("Confirmed: the fix belongs in config/emitter.go; the legacy parser is not involved."),
},
question: "Which file does the fix belong in? Reply with just the filename, no path.",
want: []string{"emitter"},
reject: []string{"legacy"},
},
}
}
// score reports whether an answer keeps the planted fact. A rejected token
// anywhere in the answer counts as lost even when the wanted token also
// appears, so "yes, but it was not re-run" does not pass as "no".
func (p probe) score(answer string) bool {
a := strings.ToLower(answer)
for _, bad := range p.reject {
if matchesToken(a, bad) {
return false
}
}
for _, good := range p.want {
if matchesToken(a, good) {
return true
}
}
return false
}
// matchesToken compares whole words, never substrings: "I am not sure" must not
// count as the answer "no". Multi-word wants are phrases and match literally.
func matchesToken(lowered, want string) bool {
if strings.Contains(want, " ") {
return strings.Contains(lowered, want)
}
return slices.Contains(strings.FieldsFunc(lowered, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-'
}), want)
}
// settledAt is the first generation whose answer is the one want/reject scores.
// A probe revised across folds has a different correct answer while the chain is
// still running, so asking before it settles would score a right answer as lost.
func (p probe) settledAt() int {
at := p.plantAt
for gen := range p.later {
at = max(at, gen)
}
return at
}
func (p probe) String() string { return fmt.Sprintf("%s@gen%d", p.class, p.settledAt()) }
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"fmt"
"strings"
"reasonix/internal/agent"
"reasonix/internal/provider"
)
// A generation is one round of work followed by one compaction. Compaction
// re-derives its digest from the whole canonical transcript, so generation N
// folds everything generations 1..N produced — which is exactly the growth the
// cost arm measures and the drift arm probes.
const (
unitsPerGeneration = 12
toolResultBytes = 6_000
)
// workUnit is one read_file round: the shape that dominates a real coding
// session's token weight.
func workUnit(sess *agent.Session, gen, i int) {
id := fmt.Sprintf("g%02dc%02d", gen, i)
path := fmt.Sprintf("internal/pkg%02d/file%02d.go", gen, i)
sess.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
{ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":%q}`, path)},
}})
sess.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: fakeGoFile(gen, i, toolResultBytes)})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("Read %s; nothing surprising.", path)})
}
func fakeGoFile(gen, i, size int) string {
var b strings.Builder
fmt.Fprintf(&b, "package pkg%02d\n\n// file%02d\n\n", gen, i)
for line := 0; b.Len() < size; line++ {
fmt.Fprintf(&b, "func helper%02d_%02d_%04d(x int) int { return x*%d + %d }\n", gen, i, line, line+3, line*7)
}
return b.String()
}
// growSession appends one generation of work, planting whatever probes belong
// to that generation so a fact's age is exactly the number of folds it has
// survived.
func growSession(sess *agent.Session, gen int, probes []probe) {
for _, p := range probes {
if p.plantAt == gen {
p.plant(sess)
}
if revise, ok := p.later[gen]; ok {
revise(sess)
}
}
for i := range unitsPerGeneration {
workUnit(sess, gen, i)
}
}
func newSession() *agent.Session {
sess := agent.NewSession("You are a terse coding agent. Answer questions from what you know; do not guess.")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "Fix the config round-trip formatting bug in this repo."})
return sess
}
// visibleContext splices the stored projection with the canonical messages
// appended after it — the same model-visible view the agent would send.
func visibleContext(sessionPath string, sess *agent.Session) ([]provider.Message, error) {
st, ok, err := agent.LoadCompactionState(sessionPath)
if err != nil {
return nil, err
}
canonical := sess.Snapshot()
if !ok || len(st.Projection.Messages) == 0 {
return canonical, nil
}
out := append([]provider.Message(nil), st.Projection.Messages...)
if n := st.Projection.CoveredCount; n >= 0 && n < len(canonical) {
out = append(out, canonical[n:]...)
}
return out, nil
}
+322
View File
@@ -0,0 +1,322 @@
// Cost-capped seed → resume → continue smoke for content-driven maintenance.
// Offline covers seed/resume; live continue needs DEEPSEEK_API_KEY and allows
// at most one summary after growing past compact_ratio=0.85.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/provider"
_ "reasonix/internal/provider/openai"
"reasonix/internal/tool"
)
const (
model = "deepseek-v4-flash"
baseURL = "https://api.deepseek.com"
windowTokens = 1_000_000
compactRatio = 0.85
defaultMaxUSD = 0.50
fatBytes = 32 * 1024
)
type costCap struct {
maxUSD float64
spent float64
}
func (c *costCap) add(u *provider.Usage, pricing *provider.Pricing) error {
if c == nil || u == nil || pricing == nil {
return nil
}
c.spent += pricing.Cost(u)
if c.maxUSD > 0 && c.spent > c.maxUSD {
return fmt.Errorf("cost cap exceeded: spent $%.4f > max $%.4f", c.spent, c.maxUSD)
}
return nil
}
type meta struct {
SeededAt time.Time `json:"seeded_at"`
ProjectionVersion uint64 `json:"projection_version"`
CanonicalTokens int `json:"canonical_tokens"`
ProjectedTokens int `json:"projected_tokens"`
TriggerTokens int `json:"trigger_tokens"`
SummaryCalls int `json:"summary_calls"`
SpentUSD float64 `json:"spent_usd"`
ContinueUsage *provider.Usage `json:"continue_usage,omitempty"`
}
type recordSink struct {
summaryStarts int
}
func (s *recordSink) Emit(e event.Event) {
if e.Kind == event.CompactionStarted {
s.summaryStarts++
}
}
func prov() (provider.Provider, *provider.Pricing) {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set (use -offline for seed/resume)")
os.Exit(1)
}
p, err := provider.New("openai", provider.Config{
Name: "e2e", BaseURL: baseURL, Model: model, APIKey: key,
Extra: map[string]any{"max_output_tokens": 1024},
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
pricing := &provider.Pricing{Input: 0.14, Output: 0.28, CacheHit: 0.014, Currency: "$"}
return p, pricing
}
func fatHistory(n int) []provider.Message {
result := strings.Repeat("x", fatBytes)
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "You are a terse coding agent."},
{Role: provider.RoleUser, Content: "Review the large tool outputs carefully."},
}
for i := range n {
id := fmt.Sprintf("tool-%d", i)
msgs = append(msgs,
provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}},
provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: result},
)
}
return append(msgs,
provider.Message{Role: provider.RoleUser, Content: "summarize status when asked"},
provider.Message{Role: provider.RoleAssistant, Content: "standing by"},
)
}
func newAgent(p provider.Provider, sess *agent.Session, path string, sink event.Sink) *agent.Agent {
return agent.New(p, tool.NewRegistry(), sess, agent.Options{
ContextWindow: windowTokens,
CompactRatio: compactRatio,
RecentKeep: 2,
SessionPath: path,
WorkspaceID: "cm-e2e",
ModelRef: "deepseek/" + model,
MaxSteps: 2,
}, sink)
}
func writeMeta(dir string, m meta) {
b, _ := json.MarshalIndent(m, "", " ")
if err := os.WriteFile(filepath.Join(dir, "meta.json"), b, 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func readMeta(dir string) meta {
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var m meta
if err := json.Unmarshal(raw, &m); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return m
}
func seed(dir string, offline bool, cap *costCap) {
if err := os.MkdirAll(dir, 0o755); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
path := filepath.Join(dir, "session.jsonl")
// ~80×32KB tool results: well under 85% of 1M for a cold estimate.
sess := &agent.Session{Messages: fatHistory(80)}
sink := &recordSink{}
var p provider.Provider
if !offline {
p, _ = prov()
}
a := newAgent(p, sess, path, sink)
before := a.ContextMaintenanceSnapshot()
if before.TriggerTokens <= 0 {
fmt.Fprintln(os.Stderr, "seed: missing trigger tokens")
os.Exit(1)
}
if before.ProjectedTokens >= before.TriggerTokens {
fmt.Fprintf(os.Stderr, "seed fixture already at/above trigger: projected=%d trigger=%d\n",
before.ProjectedTokens, before.TriggerTokens)
os.Exit(1)
}
if err := a.PrepareContext(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, "seed prepare:", err)
os.Exit(1)
}
after := a.ContextMaintenanceSnapshot()
if after.ProjectionVersion != 0 {
fmt.Fprintf(os.Stderr, "seed installed projection version %d below compact_ratio\n", after.ProjectionVersion)
os.Exit(1)
}
if sink.summaryStarts != 0 {
fmt.Fprintf(os.Stderr, "seed started %d summary calls below trigger\n", sink.summaryStarts)
os.Exit(1)
}
if err := sess.Save(path); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m := meta{
SeededAt: time.Now(),
ProjectionVersion: after.ProjectionVersion,
CanonicalTokens: after.CanonicalTokens,
ProjectedTokens: after.ProjectedTokens,
TriggerTokens: after.TriggerTokens,
SummaryCalls: sink.summaryStarts,
SpentUSD: cap.spent,
}
writeMeta(dir, m)
fmt.Printf("seed ok projected=%d trigger=%d version=%d summary_calls=%d spent=$%.4f\n",
m.ProjectedTokens, m.TriggerTokens, m.ProjectionVersion, m.SummaryCalls, m.SpentUSD)
}
func resume(dir string, offline bool) {
path := filepath.Join(dir, "session.jsonl")
m := readMeta(dir)
sess, err := agent.LoadSession(path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
sink := &recordSink{}
var p provider.Provider
if !offline {
p, _ = prov()
}
a := newAgent(p, sess, path, sink)
snap := a.ContextMaintenanceSnapshot()
if snap.ProjectionVersion != m.ProjectionVersion {
fmt.Fprintf(os.Stderr, "resume version = %d, want %d\n", snap.ProjectionVersion, m.ProjectionVersion)
os.Exit(1)
}
if err := a.PrepareContext(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, "resume prepare:", err)
os.Exit(1)
}
after := a.ContextMaintenanceSnapshot()
if after.ProjectionVersion != m.ProjectionVersion {
fmt.Fprintf(os.Stderr, "resume advanced version to %d\n", after.ProjectionVersion)
os.Exit(1)
}
if sink.summaryStarts != 0 {
fmt.Fprintf(os.Stderr, "resume re-ran summary %d times\n", sink.summaryStarts)
os.Exit(1)
}
fmt.Printf("resume ok idle=%s version=%d summary_calls=0\n",
time.Since(m.SeededAt).Round(time.Second), m.ProjectionVersion)
}
func cont(dir string, offline bool, cap *costCap) {
if offline {
fmt.Fprintln(os.Stderr, "continue requires live provider (omit -offline)")
os.Exit(1)
}
path := filepath.Join(dir, "session.jsonl")
m := readMeta(dir)
sess, err := agent.LoadSession(path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// Grow past the 85% trigger with additional fat tool turns (skip system/user prelude).
for _, msg := range fatHistory(40)[2:] {
sess.Add(msg)
}
sink := &recordSink{}
p, pricing := prov()
a := newAgent(p, sess, path, sink)
before := a.ContextMaintenanceSnapshot()
if before.ProjectedTokens < before.TriggerTokens {
fmt.Fprintf(os.Stderr, "continue fixture still below trigger: projected=%d trigger=%d\n",
before.ProjectedTokens, before.TriggerTokens)
os.Exit(1)
}
if err := a.PrepareContext(context.Background()); err != nil {
fmt.Fprintln(os.Stderr, "continue prepare:", err)
os.Exit(1)
}
if sink.summaryStarts > 1 {
fmt.Fprintf(os.Stderr, "continue started %d summaries, want ≤1\n", sink.summaryStarts)
os.Exit(1)
}
after := a.ContextMaintenanceSnapshot()
if sink.summaryStarts == 1 && after.ProjectionVersion != m.ProjectionVersion+1 {
fmt.Fprintf(os.Stderr, "continue version = %d, want %d after one summary\n",
after.ProjectionVersion, m.ProjectionVersion+1)
os.Exit(1)
}
if u := a.LastUsage(); u != nil {
if err := cap.add(u, pricing); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m.ContinueUsage = u
}
startsBefore := sink.summaryStarts
_ = a.Run(context.Background(), "Reply with exactly: ok")
if u := a.LastUsage(); u != nil {
if err := cap.add(u, pricing); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
if sink.summaryStarts != startsBefore {
fmt.Fprintf(os.Stderr, "post-checkpoint run started extra summaries (%d→%d)\n",
startsBefore, sink.summaryStarts)
os.Exit(1)
}
if err := sess.Save(path); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
final := a.ContextMaintenanceSnapshot()
m.ProjectionVersion = final.ProjectionVersion
m.ProjectedTokens = final.ProjectedTokens
m.CanonicalTokens = final.CanonicalTokens
m.SummaryCalls = sink.summaryStarts
m.SpentUSD = cap.spent
writeMeta(dir, m)
fmt.Printf("continue ok version=%d projected=%d summary_calls=%d spent=$%.4f\n",
m.ProjectionVersion, m.ProjectedTokens, m.SummaryCalls, m.SpentUSD)
}
func main() {
dir := flag.String("dir", "benchmarks/context-maintenance-e2e/run", "state directory")
maxUSD := flag.Float64("max-usd", defaultMaxUSD, "hard cost cap for live API legs")
offline := flag.Bool("offline", false, "skip live provider (seed/resume only)")
flag.Parse()
cap := &costCap{maxUSD: *maxUSD}
switch flag.Arg(0) {
case "seed":
seed(*dir, *offline, cap)
case "resume":
resume(*dir, *offline)
case "continue":
cont(*dir, *offline, cap)
default:
fmt.Fprintln(os.Stderr, "usage: context-maintenance-e2e [-dir DIR] [-max-usd N] [-offline] seed|resume|continue")
os.Exit(1)
}
}
@@ -0,0 +1,4 @@
prompt = "contacts.txt is full of duplicate entries and users keep complaining. Clean it up and write the result to contacts_clean.txt."
class = "ambiguous"
max_steps = 15
timeout_sec = 300
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -e
export PYTHONPYCACHEPREFIX="$(mktemp -d)"
python3 - <<'PY'
with open("contacts_clean.txt") as f:
lines = [line.strip() for line in f if line.strip()]
lowered = [line.lower() for line in lines]
assert len(lowered) == len(set(lowered)), "duplicates survived the cleanup"
assert set(lowered) == {
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
}, sorted(lowered)
PY
@@ -0,0 +1,4 @@
prompt = "config.py silently accepts broken config files and the app then crashes much later somewhere else. Make loading fail fast with clear errors instead."
class = "ambiguous"
max_steps = 15
timeout_sec = 300
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -e
export PYTHONPYCACHEPREFIX="$(mktemp -d)"
python3 - <<'PY'
import json
import tempfile
from config import load
def write(cfg):
f = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
json.dump(cfg, f)
f.close()
return f.name
good = load(write({"host": "example.test", "port": 8443}))
assert good["host"] == "example.test" and good["port"] == 8443
for bad in ({"host": "example.test"}, {"host": "example.test", "port": "8443"}):
try:
load(write(bad))
except Exception as err:
assert "port" in str(err), f"error must name the offending key: {err!r}"
else:
raise AssertionError(f"broken config accepted: {bad}")
PY
@@ -0,0 +1,7 @@
import json
def load(path):
"""A config must provide host (a string) and port (an integer)."""
with open(path) as f:
return json.load(f)
@@ -0,0 +1,4 @@
prompt = "parse_all in parser.py only handles some of the files under fixtures/. Users need it to handle all of them. Extend it so every fixture is parsed."
class = "ambiguous"
max_steps = 15
timeout_sec = 300
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -e
# Fresh bytecode cache: macOS system Python caches centrally (pycache_prefix),
# where a same-size same-second edit is invisible; a throwaway prefix defeats it.
export PYTHONPYCACHEPREFIX="$(mktemp -d)"
find . -name '__pycache__' -type d -prune -exec rm -rf {} +
python3 - <<'EOF'
from parser import parse_all
want = [
("fen", 3),
("gorse", 12),
("heath", 7),
("moor", 1),
("tor", 22),
("vale", 5),
]
got = parse_all("fixtures")
assert got == want, f"parse_all = {got}, want {want}"
EOF
@@ -0,0 +1,2 @@
fen,3
gorse,12
1 fen 3
2 gorse 12
@@ -0,0 +1,2 @@
heath 7
moor 1
1 heath 7
2 moor 1
@@ -0,0 +1,2 @@
tor;22
vale;5
@@ -0,0 +1,21 @@
import os
def parse_file(path):
records = []
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
name, value = line.split(",")
records.append((name.strip(), int(value)))
return records
def parse_all(directory):
"""All (name, value) records across every data file in directory, sorted."""
records = []
for entry in sorted(os.listdir(directory)):
records.extend(parse_file(os.path.join(directory, entry)))
return sorted(records)
@@ -0,0 +1,4 @@
prompt = "Users report that stats.py keeps crashing on real-world input files. Make it robust enough for production use. Sample inputs are in samples/."
class = "ambiguous"
max_steps = 15
timeout_sec = 300
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -e
export PYTHONPYCACHEPREFIX="$(mktemp -d)"
out="$(python3 stats.py samples/clean.txt)"
echo "$out" | grep -q "mean=2.5" || { echo "clean file: got '$out', want mean=2.5" >&2; exit 1; }
out="$(python3 stats.py samples/messy.txt 2>messy.err)"
if grep -q "Traceback" messy.err; then
echo "messy file crashed:" >&2
cat messy.err >&2
exit 1
fi
echo "$out" | grep -q "mean=4.0" || { echo "messy file: got '$out', want mean of the numeric lines (4.0)" >&2; exit 1; }
# The prompt never says what "robust" means for an empty file: a graceful
# message with either exit code is defensible — only a traceback is a crash.
python3 stats.py samples/empty.txt >empty.out 2>empty.err || true
if grep -q "Traceback" empty.err; then
echo "empty file crashed:" >&2
cat empty.err >&2
exit 1
fi
@@ -0,0 +1,4 @@
1
2
3
4
Whitespace-only changes.
@@ -0,0 +1,7 @@
# sensor dump 2026-08-07
2
4
n/a
6.0
sensor offline
@@ -0,0 +1,11 @@
import sys
def mean(path):
with open(path) as f:
values = [float(line) for line in f]
return sum(values) / len(values)
if __name__ == "__main__":
print(f"mean={mean(sys.argv[1])}")
@@ -0,0 +1,4 @@
prompt = "Exactly one setting differs across config/base.toml, config/staging.toml and config/production.toml. Write AUDIT.md naming the setting that drifted, the environment it drifted in, and both values. Do not change any config."
class = "read-only-output"
max_steps = 25
timeout_sec = 480
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -e
test -f AUDIT.md || { echo "AUDIT.md was never written" >&2; exit 1; }
doc="$(tr '[:upper:]' '[:lower:]' < AUDIT.md)"
for want in timeout_sec production 30 5; do
case "$doc" in
*"$want"*) ;;
*) echo "AUDIT.md never mentions $want" >&2; exit 1 ;;
esac
done
# Naming a setting that did not drift means the three files were not compared.
for absent in max_body_mb ttl_sec; do
case "$doc" in
*"$absent"*) echo "AUDIT.md reports $absent, which is identical everywhere" >&2; exit 1 ;;
esac
done
grep -q "timeout_sec = 5" config/production.toml || {
echo "config/production.toml was modified; the task is to report the drift" >&2
exit 1
}
@@ -0,0 +1 @@
All three environments are kept in lockstep. Any difference is a mistake.
@@ -0,0 +1,7 @@
[server]
port = 8080
timeout_sec = 30
max_body_mb = 8
[cache]
ttl_sec = 300
@@ -0,0 +1,7 @@
[server]
port = 8080
timeout_sec = 5
max_body_mb = 8
[cache]
ttl_sec = 300
@@ -0,0 +1,7 @@
[server]
port = 8080
timeout_sec = 30
max_body_mb = 8
[cache]
ttl_sec = 300
@@ -0,0 +1,4 @@
prompt = "discounted(price_cents, percent) in pricing.py never applies the discount. Fix it (integer cents, round the discount down)."
class = "atomic-bugfix"
max_steps = 12
timeout_sec = 180
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -e
export PYTHONPYCACHEPREFIX="$(mktemp -d)"
python3 - <<'PY'
from pricing import discounted
assert discounted(1000, 25) == 750, discounted(1000, 25)
assert discounted(999, 10) == 900, discounted(999, 10)
assert discounted(500, 0) == 500
assert discounted(100, 100) == 0
PY
@@ -0,0 +1,3 @@
def discounted(price_cents, percent):
"""Price after an integer-percent discount, in cents, rounded down."""
return price_cents - price_cents * (percent // 100)
@@ -0,0 +1,4 @@
prompt = "overlaps(a, b) in intervals.py misjudges some interval pairs. Fix it so half-open intervals [start, end) are compared correctly."
class = "atomic-bugfix"
max_steps = 12
timeout_sec = 180
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -e
export PYTHONPYCACHEPREFIX="$(mktemp -d)"
python3 - <<'PY'
from intervals import overlaps
assert overlaps((0, 2), (1, 3))
assert overlaps((1, 3), (0, 2))
assert not overlaps((0, 1), (1, 2)), "touching half-open intervals do not overlap"
assert overlaps((0, 5), (2, 3))
assert not overlaps((3, 4), (0, 2))
PY
@@ -0,0 +1,3 @@
def overlaps(a, b):
"""Whether half-open intervals [a0, a1) and [b0, b1) intersect."""
return a[0] < b[1] and b[0] < a[0]
@@ -0,0 +1,4 @@
prompt = "median(values) in stats.py is wrong for even-length lists. Fix it to return the average of the two middle values."
class = "atomic-bugfix"
max_steps = 12
timeout_sec = 180
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -e
export PYTHONPYCACHEPREFIX="$(mktemp -d)"
python3 - <<'PY'
from stats import median
assert median([1, 3]) == 2, median([1, 3])
assert median([1, 2, 3, 4]) == 2.5, median([1, 2, 3, 4])
assert median([5]) == 5
assert median([4, 1, 3, 2]) == 2.5
assert median([1, 2, 3]) == 2
PY
@@ -0,0 +1,3 @@
def median(values):
ordered = sorted(values)
return ordered[len(ordered) // 2]
@@ -0,0 +1,4 @@
prompt = "Fifo.pop in fifo.py returns the newest item instead of the oldest. Fix the FIFO order."
class = "atomic-bugfix"
max_steps = 12
timeout_sec = 180
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -e
export PYTHONPYCACHEPREFIX="$(mktemp -d)"
python3 - <<'PY'
from fifo import Fifo
q = Fifo()
for x in (1, 2, 3):
q.push(x)
assert q.pop() == 1
q.push(4)
assert q.pop() == 2
assert q.pop() == 3
assert q.pop() == 4
assert len(q) == 0
PY
@@ -0,0 +1,12 @@
class Fifo:
def __init__(self):
self._items = []
def push(self, item):
self._items.append(item)
def pop(self):
return self._items.pop()
def __len__(self):
return len(self._items)
@@ -0,0 +1,4 @@
prompt = "slugify in slug.py leaves stray hyphens at the ends of some slugs. Fix it."
class = "atomic-bugfix"
max_steps = 12
timeout_sec = 180
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -e
export PYTHONPYCACHEPREFIX="$(mktemp -d)"
python3 - <<'PY'
from slug import slugify
assert slugify("Hello, World!") == "hello-world", slugify("Hello, World!")
assert slugify(" A B ") == "a-b", slugify(" A B ")
assert slugify("ok") == "ok"
assert slugify("Already-Fine") == "already-fine"
PY
@@ -0,0 +1,5 @@
import re
def slugify(title):
return re.sub(r"[^a-z0-9]+", "-", title.lower())
@@ -0,0 +1,4 @@
prompt = "store.py: values saved with set() sometimes cannot be read back with get(). Fix the bug."
class = "atomic-bugfix"
max_steps = 12
timeout_sec = 180
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -e
# Fresh bytecode cache: macOS system Python caches centrally (pycache_prefix),
# where a same-size same-second edit is invisible; a throwaway prefix defeats it.
export PYTHONPYCACHEPREFIX="$(mktemp -d)"
find . -name '__pycache__' -type d -prune -exec rm -rf {} +
python3 - <<'EOF'
from store import Store
s = Store()
s.set("Alpha", 1)
assert s.get("Alpha") == 1, "set/get must round-trip the same key"
s.set("beta", 2)
assert s.get("beta") == 2
s.set("Alpha", 3)
assert s.get("Alpha") == 3, "overwrite must be visible"
assert len(s) == 2, "same key set twice must not duplicate"
s.delete("Alpha")
assert s.get("Alpha") is None, "delete must remove what set stored"
EOF
@@ -0,0 +1,15 @@
class Store:
def __init__(self):
self._data = {}
def set(self, key, value):
self._data[key.lower()] = value
def get(self, key, default=None):
return self._data.get(key, default)
def delete(self, key):
self._data.pop(key.lower(), None)
def __len__(self):
return len(self._data)
@@ -0,0 +1,4 @@
prompt = "tail(lst, n) in utils.py drops the last element. Fix it so it returns the last n items of the list."
class = "atomic-bugfix"
max_steps = 12
timeout_sec = 180
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -e
# Fresh bytecode cache: macOS system Python caches centrally (pycache_prefix),
# where a same-size same-second edit is invisible; a throwaway prefix defeats it.
export PYTHONPYCACHEPREFIX="$(mktemp -d)"
find . -name '__pycache__' -type d -prune -exec rm -rf {} +
python3 - <<'EOF'
from utils import head, tail, chunk
assert tail([1, 2, 3], 2) == [2, 3], tail([1, 2, 3], 2)
assert tail([1, 2, 3], 1) == [3], tail([1, 2, 3], 1)
assert tail([7], 1) == [7], tail([7], 1)
assert tail([1, 2], 5) == [1, 2], tail([1, 2], 5)
assert tail([1, 2, 3], 0) == []
assert head([1, 2, 3], 2) == [1, 2]
assert chunk([1, 2, 3, 4], 2) == [[1, 2], [3, 4]]
EOF
@@ -0,0 +1,12 @@
def head(lst, n):
return lst[:n]
def tail(lst, n):
if n <= 0:
return []
return lst[-n:-1]
def chunk(lst, size):
return [lst[i : i + size] for i in range(0, len(lst), size)]
Loaded 100 of 4138 files, more files were not shown because too many files have changed in this diff. Show more