heartbeat-tasks.json is documented as human- and AI-editable (file
header + the heartbeat panel tip), but the engine loaded it once at
startup and then treated its in-memory copy as the source of truth:
- an externally added task was never scheduled until a manual Refresh
or an app restart, and
- any run-state save (mergeRunUpdatesLocked writes the full list after
a task fires) silently rolled the external edit back — the same
"my write did not take" shape the session-data guard exists for,
except here the product explicitly invites the edit.
Two changes make the contract real:
- mergeRunUpdatesLocked now rebases onto the on-disk list before its
full-list save: the engine owns only the run-state fields (TopicID,
LastRunAt, CreatedAt backfill), so external additions, edits, and
deletions survive the save. A missing/unreadable file falls back to
the in-memory list, preserving the historical behavior (and the
existing concurrent-edit test).
- tick() probes the config mtime and adopts external edits before
scheduling, so an agent-added task runs on the next tick. The engine
records the mtime after its own writes to avoid re-reading them.
Review finding: heartbeat-tasks.json is a documented human/AI-editable
contract (desktop/heartbeat.go header; the heartbeat panel tip says
"AI agents can also edit heartbeat-tasks.json"), so the runtime-ledger
guard must not hard-refuse it — that broke the advertised flow of
asking the agent to create or update heartbeat tasks. Drop it from the
deny list and the bash hint needles, and pin the contract with a
positive regression so the guard can never swallow it again.
Follow-up sweep on the remaining lexical gaps of the session-data
bash warning (write tools were never affected — relative paths get
absolutized before the hard deny):
- $HOME/${HOME} and %APPDATA%/$env:APPDATA spellings of the guarded
trees now match, alongside the existing absolute and ~/ forms.
- The hint is workDir-aware: the desktop Global workspace lives at
<state root>/global-workspace, so ../sessions reached the stores
without an absolute path in the command text. Relative forms are
matched from a state-root workDir, and a workDir already inside a
guarded store warns on every command.
- workDir goes through realPath so a symlinked cwd (or /var vs
/private/var on macOS) cannot dodge the workDir checks.
Remaining lexical limits (paths hidden in script files, runtime
variable concatenation) are inherent to text matching; the writer-tool
hard deny and the conflict-copy CAS remain the backstops.
Same-shape sweep after the initial guard landed:
- Case-variant bypass (confirmed by repro): EvalSymlinks does not
normalize case, and default macOS/Windows volumes are
case-insensitive, so ~/.reasonix/SESSIONS/x.jsonl reached the real
store while sliding past the deny check — the same failure shape as
the Windows lease-key case split (#6023). Deny-side checks (the
guard and forbid_read's confineRead) now fold case on those
platforms; allow-side confine keeps exact matching, since folding an
allow rule on a case-sensitive filesystem would wave a genuinely
different directory through.
- State-root-direct runtime ledgers (desktop-tabs.json and friends,
heartbeat-tasks.json, metrics/crash-pending.json) are rewritten
wholesale by the app, so agent edits vanish the same way session
edits do — the "index files" from the user report. They join the
guard; config.toml/credentials/skills stay writable on request.
- reasonix review built its subagent bash from the init template,
skipping the guard; it now carries the same session-data warning.
Recovery-copy fixes (user-reported screenshots):
- agent:ready could reuse an in-flight hydrate and keep a stale
ready=false tab meta, locking the composer until an unrelated
settings reload re-emitted ready. The ready handler now refreshes
the active tab metadata from ListTabs first (preserving cached
history), so the send gate opens as soon as the backend is ready.
- Recovery notices no longer expose internal recovery-branch ids;
the frontend maps both the new and the legacy English notice
wording to localized, user-facing text so old transcripts render
friendly too. The recovery banner no longer builds a tooltip from
digest/parent-id internals, and the now-dead locale keys are gone.
Session-data write guard (root cause of the token-burn loop):
- An agent asked to "fix Reasonix" from inside Reasonix would write
the app's own session JSONL/meta files via bash or file tools. The
runtime is the only writer of those stores; external edits race its
autosaves, fork endless conflict copies, and look to the agent like
failed writes, so it retries in a loop.
- New SessionDataGuard rejects writer-tool targets under
<state root>/sessions and <state root>/projects/*/sessions even when
workspace write roots cover them (e.g. a home-directory workspace);
explicit [sandbox] allow_write entries remain a sanctioned escape
hatch. bash appends a warning when a command references those trees
(never blocks: diagnostic reads are legitimate) so the retry loop is
broken at the model level.
- Wired through ConfineWriters/ConfineBash/Workspace at the boot and
ACP composition roots; zero-value guard keeps init-registered tools
unconfined as before.
Review catch: the freshness check raced normal releases. The runbook
pushes v*/npm-v*/desktop-v* together, the two workflows wait on the
release environment independently, and npm dist-tags propagate
asynchronously — so a single read of dist-tags.latest could fail the
CLI release while the npm publish was merely awaiting approval or
propagating, telling the operator to push a tag that was already
pushed.
Split the check into the two states the review named:
- npm-v<version> tag missing (git ls-remote) -> hard fail. This is the
#5822 gap: nobody released to npm at all.
- tag pushed but latest lagging -> poll 6x10s (matching release-npm's
own verify cadence), then WARN and pass: the publish may still be
awaiting its environment approval, and release-npm.yml's verify step
owns asserting the dist-tag lands.
RELEASING.md wording updated to match (fails on missing tag; pending
publish only warns).
The migration guide and release runbook still documented the retired
"latest stays pinned to 0.x, v2 lives under @next" policy — the exact
policy that caused #5822. Update both to the current reality:
- MIGRATING.md: bare `npm i -g reasonix` installs the current 1.x
stable; @next is the rc channel; 0.x stays installable by pinning
[email protected].2.
- RELEASING.md: channels table shows latest = current 1.x stable with
next/canary as pre-release buffers; the stable-ship step notes the
npm-v* tag moves `latest` automatically and must not be skipped
(release.yml now fails when npm latest lags); drop the manual
"promote to default install" step — promotion is automatic.
The overlong-name pass filtered by a bare .jsonl suffix, so an
.events.jsonl or .conflicts.jsonl whose name crossed the 224-byte bound
(owner stem between 209 and 224 bytes) was renamed into a fake session
with fabricated meta, while the real transcript's rename orphaned both
logs under the retired stem. Filter with store.IsSessionTranscriptName
and carry the event log, event index, and conflict log through
migrateSessionSidecars like the other owned sidecars.
Also accept --out=PATH (equals form) in reasonix doctor session.
The controller kept savedRewriteVersion as external bookkeeping about
the executor's session object, so every SetSession had to be paired
with a manual baseline reset and snapshot() had to guess whether
recovery had swapped the object — the guess that broke depth-cap
force-saves (#6120).
Store persistedRewriteVersion on the Session itself: save paths advance
it with the rewriteVersion captured in the same lock window as the
message snapshot (a compaction landing mid-save stays unpersisted, and
a slower save cannot roll it backwards), clones carry it, and swapped
sessions take their baseline with them. snapshotRewriteDecision and
both mark helpers plus their seven call-site pairings collapse into
Session.NeedsRewriteSave.
Behavior fix folded in: installing a clone that still holds an
unpersisted rewrite no longer stamps it persisted-by-construction; the
next snapshot correctly runs as an owned rewrite.
The caller inferred what recovery did from path and session-pointer
comparisons; the depth-cap baseline bug hid in exactly that inference.
Declare the outcome (adopted / force-saved / forked / dropped) at the
site that performs it, and let replaceSessionAfterCancel log when its
cancel cleanup was dropped instead of silently discarding the result.
v1.17.5 shipped as binaries and Homebrew while the npm `latest`
dist-tag still pointed at 0.53.2 (#5822) — every `npm update -g` /
`pnpm update -g` user was silently downgraded to a months-old version.
The npm line is triggered by its own tag namespace (npm-vX.Y.Z), and
the stable npm tag was simply never pushed: 1.0.0 through 1.17.5 all
went to npm as -rc.1 prereleases under `next`. release-npm.yml's verify
step (added for #5822) only guards runs that happen; nothing caught the
run that DIDN'T.
Add a freshness gate to the stable CLI release: after the public smoke,
compare `npm view reasonix dist-tags.latest` against the version being
released (sort -V). A stale `latest` fails the release with the exact
remediation (push the npm-vX.Y.Z tag, or `npm dist-tag add` for an
already-published version). Prereleases skip — npm latest does not move
on prereleases. A `latest` NEWER than the tag passes, so re-releasing
an older CLI tag is not blocked.
Review catch: a typed /new or /clear rotates the session through the
controller slash path and never reaches App.NewSession/ClearSession, so
desktop-tabs.json can keep the pre-rotation goal. On the next restart,
restoreOrBuildTabs read entry.Goal unfiltered and the tab build seeded
it into the rotated session via SetGoal(buildGoal) — resurrecting the
goal the rotation had cleared. (The goal-state filter I pointed at in
the PR description only runs on the runtime-profile path, not startup
restore.)
Restore now validates entry.Goal through runningTabSessionGoal: the
rotation writes a stopped goal-state sidecar onto the fresh path, so a
restart reads stopped → no goal; a tab still pointing at the old
session reads running → goal preserved (resume semantics); a session
without a sidecar keeps the persisted goal (legacy).
TestRestoredTabGoalFollowsSessionGoalState pins the cross-layer
contract with a real controller rotation: rotated path → cleared, old
path → preserved, no sidecar → fallback.
NewSession and ClearSession reset the executor session, planner,
guardian, and checkpoints — but not the goal machine. A running goal's
text (plus its autoresearch runtime block) kept injecting into the
fresh session's first turns, silently steering a conversation the user
had explicitly reset.
Controller: both rotations now ClearGoal() after rebindCheckpoints has
moved the goal-state path to the fresh session, so the cleared
(stopped) state lands on the NEW path while the old session's sidecar —
persisted before the rotation — keeps its running goal for /resume.
This covers every frontend (typed /new, CLI, serve, bot, desktop).
Desktop: the tab's persisted goal copy follows the controller —
App.NewSession and ClearSession clear tab.goal (otherwise the next
rebuild or restart would re-seed the old goal via SetGoal(tab.goal)),
and clearActiveSessionRuntime no longer seeds the destroyed
conversation's goal into the replacement controller. The model/effort/
settings rebuild paths keep the goal: those are same-session rebuilds.
TestSessionRotationClearsActiveGoal pins all four properties: goal
empty and no <active-goal> injection after /new and /clear, the old
session's sidecar keeps the running goal, and the new session's sidecar
does not carry it. Verified it fails without the ClearGoal calls.
Run ApplyUserConfigUpgradesOnStartup from migrateLegacyConfigForCLI so
Windows CLI-only users also get the one-time bash = "enforce" to off
migration instead of waiting for a desktop launch. The upgrade is
config_version-gated, so desktop and CLI never double-apply it, and
metadata commands (version/help) still leave the config untouched.
Also drop the unused bool return of the migration helper.
Drop the dead BashMode comparison in the project delta condition (an
empty raw value always resolves to the platform default on both sides)
and replace per-test runtimeGOOS save/restore boilerplate with a
setRuntimeGOOS helper whose t.Setenv call panics under t.Parallel,
keeping the package-global override race-free.
macOS CI caught a race in the new divergent-handshake test: the stale
cache the test writes is itself loadable, so waitForCachedSchema could
return it before the background saveLazyCachedSchema landed. Poll until
the cache actually contains the live tool set (bounded by the same 5s).
The provider request's tools array is part of the cached prompt prefix,
and cache_shape.go already tracked mid-session tool changes as a "tools"
prefix invalidation (a full-conversation cache miss at 10x pricing).
The lazy-placeholder mechanism registered byte-stable cached schemas at
boot — and then trySwap replaced them with the live tools as soon as
the background handshake finished. Whenever the live handshake differed
from the cache (description tweaks, schema upgrades, newly added
tools), the swap rewrote the tools array mid-session and cold-started
the conversation's provider cache.
Pin cache-hit placeholders instead: trySwap no longer touches the
registry for them. The lazyTools keep presenting the cached bytes the
model has seen since boot and forward Execute to the real tools through
the shared spawn state (which already worked — the swap was never
functionally necessary). Live drift still lands in the schema cache via
saveLazyCachedSchema, so the NEXT session presents the updated surface:
freshness deferred one session for byte-stable tool bytes within this
one — the same trade the environment-probe snapshot makes for the
system prompt.
The cache-miss connect stub still swaps (there was nothing real to
present); that is a one-time cost per server, and the background kick
usually completes before the first user message anyway. A snapshot with
zero cached tools now also takes the stub path — previously its live
tools joined the registry mid-session with no placeholders at all.
Covers all trySwap callers including the IsServerAlreadyConnected
shared-host recovery paths. Verified by reverting the guard: the new
divergent-handshake test fails with the old swap behavior.
Review catch: startup launches restoreOrBuildTabs on a goroutine and
started the GC immediately after, so the first sweep could run against
the pre-restore empty tab map. Two consequences: sessionOpenInAnyTab
would judge every saved tab's session as closed (a recovery tab about
to be restored could be swept to trash), and DeleteSession's tab-list
persistence would overwrite desktop-tabs.json with the empty snapshot,
wiping the user's saved tab layout.
Add a tabsRestored gate: armed in startup before the restore goroutine
launches, closed via defer when restoreOrBuildTabs finishes (both the
saved-tabs and first-launch paths, and the panic-recover path). The
GC's first sweep waits on the gate or ctx cancellation. markTabsRestored
is idempotent; tabsRestoredSignal reports already-restored when the gate
was never armed, so App instances built directly in tests keep working.
Regression test forces the exact race: gate closed → sweep provably
blocked and the branch untouched; restore lands the tab holding the
branch and opens the gate → the sweep runs and skips the now-open
branch.
Conflict recovery forks a copy of the transcript on every save conflict
(#5993). The triggers are fixed, but every fork that ever happened still
sits in the session list until the user trashes it by hand — the
"会话列表被污染" half of that report was never addressed.
Add a conservative GC. agent.ReclaimableRecoveryBranches reclaims a
branch only when EVERY condition holds, because a recovery branch exists
precisely to prevent data loss:
- meta says Recovered with a fork digest;
- the transcript still matches that digest (never continued on — one
follow-up turn disqualifies it permanently);
- the parent (meta.ParentID) still exists and COVERS the branch content
(equal digest, or branch is a compatible prefix — the same checks
SaveRecoveryBranch uses to declare recovery unnecessary);
- no runtime holds the branch's session lease;
- it has been idle past a 24h grace period.
The desktop sweeps every session directory it lists from, once at startup
and every 6h, re-checking liveness (lease + open-in-tab) immediately
before disposal and routing removal through DeleteSession — so a swept
branch is moved to the recoverable session trash, never hard-deleted.
Verified the coverage guard by reverting it: a diverged fork (content
present nowhere else) then becomes reclaimable and the test fails.
Review catch on this PR: the panel's session cache average still
preferred usage.sessionCacheHitTokens — the wire session counters the
Go agent scopes to the executor only — over the all-sources telemetry,
while the same panel's session cost and per-source rows (and StatusBar
after this PR) already report all sources. Because the reducer keeps
s.usage across context refreshes, the executor-only number would stick
indefinitely.
Extract contextSessionCache: panel info first, ContextInfo second, wire
counters only as the pre-refresh bridge for a fresh session — and pick
hit/miss as a pair from a single source so the computed rate can never
mix scopes (the old per-field ?? chains could).
Four labeled-scope mismatches in cost/cache displays, bundled as the
tail of the cost-display cleanup line (#6075 fixed the big one):
- Desktop status bar 平均命中 silently switched scope: the live value
used the wire session counters (executor agent only) while the
fallback used tab telemetry (all request sources). Prefer the
all-sources telemetry — matching the 会话费用 tooltip's "includes
main model, subagents and auxiliary calls" — with the executor-only
live counters only bridging a fresh session's first turn.
- CLI status line painted "turn hit 0.00%" for providers that report
no prompt-cache fields, by falling back to PromptTokens as the
denominator. The tag now hides when cache fields are absent; a
genuine full miss (fields present, hit zero) still renders.
- serve web UI "总费用" accumulated since page load across session
rotations and fell back to a single request's cost. The counters now
reset on /new and /resume, the single-request fallback is gone, and
the card is relabeled 会话费用/Session Cost.
- Desktop ContextPanel's session-cost fallback chain could render one
request's usage.cost under the 会话费用 label; single-request
branches removed (usage now contributes currency only).
- Launch sandboxed user commands and internal icacls/taskkill with
CREATE_NO_WINDOW + STARTF_USESHOWWINDOW/SW_HIDE so no black console
flashes over the desktop (npm run dev / icacls windows in the report).
- Record each root lock's holder (pid + command preview) in a sidecar
file; the queue notice and timeout error now name the blocking command
so users know what to stop instead of only being told to raise
WINDOWS_SANDBOX_LOCK_MS.
- Drop the interactive lock wait default to 1 minute so a queued
foreground command fails fast into the existing escape-approval path;
background bash jobs keep the patient 10-minute wait via Spec.LockWait
(WINDOWS_SANDBOX_LOCK_MS still overrides both).
Review feedback on the rebind guard: comparing after[:len(before)]
against the FIRST turn's request only proved the system prompt and
first user message stayed stable — the resumed prefix's assistant/tool
messages were never compared.
Strengthen to the full comparison: from one transcript saved after turn
one, run the same follow-up turn twice — once on the original
controller (no rebind, the cache-warm baseline) and once after the
desktop rebind path — and require the two requests to be byte-identical
END TO END. The rebind now loads an independent copy of the saved
transcript, since the baseline turn autosaves onto the original path.
The drifted-prompt canary gets the same baseline structure and
additionally pins the divergence to the leading system message.
The marker transplant stops new recovery forks from leaving in-flight
turn markers behind, but every fork made by an older runtime already
left one on its forked-from branch. Opening such a branch fired
recoverInterruptedTurn against a turn that completed on the recovery
child, stripping the transcript back to the turn boundary and
force-saving the truncation.
recoverInterruptedTurn now recognizes the leftover: a recovery branch
whose parent is this session and whose creation postdates the marker
means the marked turn moved there rather than dying with a runtime.
Clear the stale marker and keep the messages; a marker with no such
child still strips the partial tail as before.
Final defense line for the provider prefix cache, closing the CI gaps
the desktop-cost triage identified: nothing verified that (a) prompt
composition is deterministic and (b) the desktop rebind path preserves
request bytes.
- TestBuildComposesByteStableSystemPrompt: two Builds over the same
workspace/config must compose byte-identical system prompts; a
failure prints a window around the first diverging byte so the
drifting section is named. Probes are covered cross-process by the
persisted-snapshot tests; this pins the rest of the composition
(memory, skills index, output style, policies).
- TestRebindKeepsRequestPrefixBytes: after the desktop rebind shape
(LoadSession + sessionWithFreshSystemPrompt + Resume on a fresh
controller), the next request's leading messages must be
byte-identical to the request sent before the rebind.
- TestRebindWithDriftedPromptBreaksRequestPrefix pins the failure mode
the guard exists for (a drifted prompt breaks the prefix), so a
future swap-policy change updates the guards consciously.
A snapshot-conflict recovery fork switches the controller onto the new
branch mid-turn, but the in-flight turn marker written at turn start
stayed on the original path — turn end then cleared the (absent) marker
on the recovery path, leaving the original's marker behind forever. The
next open of the original branch fired recoverInterruptedTurn against a
turn that in fact kept running on the recovery branch, stripping every
message after the turn boundary and force-saving the truncation.
Transplant the marker when the fork switches paths: mark the recovery
branch (so a crash before turn end still strips correctly there) and
clear the forked-from branch.
Two more sites judged the normalized load as if it were the disk truth:
- checkSnapshotWrite let a session resumed from a repaired transcript take
the append-only shortcut with appendFrom measured against the normalized
view. The event log replays the raw transcript, so the append event's
index broke the replay chain and the whole appended turn silently
vanished from disk on the next load (until a later save healed the log).
A pending repair now falls through to the full rewrite, which persists
the repair and the new turn together.
- SaveRecoveryBranch decided "recovery not needed" only against the
normalized view, so a mid-history repair (e.g. an empty tool-call name
backfilled on load) made a fully-covered snapshot look uncovered and
forked a pointless recovery branch. Coverage now falls back to the raw
transcript, mirroring checkSnapshotWrite.
A mid-turn autosave can legitimately cut an assistant tool call from its
still-running result, leaving a dangling call at the tail of the saved
transcript. checkSnapshotWrite compared the next snapshot against the
LoadSession view of disk, where normalization has already fabricated a
placeholder answer for that call. The live session's real tool result
collides with the placeholder, so a pure append misread as diverged and
forked a "snapshot conflict" recovery branch 30 seconds after the first
mid-turn save of any tool-heavy turn.
Keep the pre-repair transcript on loaded sessions and fall back to it in
checkSnapshotWrite when the normalized prefix checks fail: append shape,
the owned-rewrite digest, and conflict-kind classification are now judged
against the bytes actually on disk. Appending the real results also lands
the transcript well-formed, so no repair is left pending in that path.
The environment section sits inside the provider-cached system-prompt
prefix, but probe results were point-in-time observations with only a
5-minute in-memory cache: a 2s timeout flips a slow tool between
"found" and "timeout", a GUI-launched desktop resolves a different PATH
than a login shell, a cold docker daemon reports an exit error. Every
flip rewrote the prefix; the desktop's fresh-system-prompt swap then
propagated the drifted prompt onto every resumed session — a
whole-conversation cache miss at 10x pricing plus a persisted rewrite.
This was the main mechanism behind "desktop costs more than CLI"
(#2945, #5850 comments).
- Persist one probe snapshot per fingerprint under the shared cache
root (ProbeOptions.SnapshotDir, opt-in; boot passes config.CacheDir).
A snapshot younger than 24h serves without re-probing, so rebuilds,
app relaunches, and the CLI and desktop on the same machine render
identical environment bytes. 24h aligns with cacheColdAfter: past a
day idle the provider cache is dead anyway, so a refresh then is free.
- Flap merge on refresh: transient failures (timeout, nonzero exit)
keep the previous successful observation; definitive results (found,
not found, not trusted) always win, so uninstalled tools still drop
out.
- Desktop resume logs when it swaps a system prompt whose bytes differ
from the persisted one — with deterministic composition this should
only fire on genuine config changes, so a field log without one
points at a new nondeterminism source.
The snapshot dir is host state and stays out of the probe fingerprint.
Raw OS errors from session trash/restore/purge — Windows sharing
violations while antivirus or sync tools hold a transcript, access
denied, disk full — previously reached the UI toast verbatim. Map them
at the App entry points (TrashTopic, DeleteTopic, DeleteSession,
RestoreSession, PurgeTrashedSession) to sanitized, path-free messages
with retry guidance, following the errSessionBusyElsewhere precedent.
The original error is logged so diagnostics keep the path and errno;
unrecognized errors pass through unchanged.
SummarizeFrom/SummarizeUpTo still used the bare Running() check before
rewriting the live session — and their window is the widest of the
class: the summarizer performs a provider round-trip before
Session.Replace, leaving seconds for a turn to start and then have the
log replaced under it. Reachable from desktop summarize actions, the
TUI rewind menu, and serve /summarize.
Route summarizeAt through beginRotation()/endRotation() from the
boundary read through the post-rewrite snapshot, keeping the original
"cannot summarize while a turn is running" message on the running path.
Also strengthen TestSessionMutationsRefuseWhileRotating: every gated
method now asserts the errRotationInProgress sentinel instead of any
non-nil error, so each method's wiring is independently
regression-proof (a bare-Running() revert previously slipped past the
weaker checks by failing later for an unrelated reason). Verified the
summarize assertions fail with the gate removed.
The Windows bash sandbox relaunches os.Executable() as a hidden helper
process, but the desktop binary never routed that subcommand: every
sandboxed command started a second GUI instance that forwarded to the
running app via the single-instance lock and exited 0 with no output, so
bash silently returned empty on Windows (#6046, #6051, #6067, #6070,
#6072). The 1.17.3 escape prompt never fired because it keys on the
helper's exit-code-126 failure marker, which a dispatch-less binary
never prints.
- desktop/main.go dispatches the helper subcommand before any
Wails/single-instance setup, with tests pinning the route.
- Entry points now declare the route via
sandbox.RegisterHelperDispatch(); on Windows, Available() stays false
without it and Command/CommandArgs refuse to wrap, so a future
dispatch-less embedder fails closed with the existing clear error and
escape-approval path instead of swallowing output.
- Queueing behind another sandboxed command's per-root lock now emits a
one-line notice (sliced mutex waits) instead of blocking silently for
up to 10 minutes; recorded as a local modification in the winsandbox
NOTICE.
- reasonix doctor reports the resolved shell (Git Bash vs PowerShell is
the first thing to check for low-integrity launch failures) and warns
when a project reasonix.toml pins [sandbox], which overrides
Settings/user-config edits (#5961 confusion).
- GUIDE/GUIDE.zh-CN gain a Windows sandbox troubleshooting note.
If the source disappears while the copy fallback is running, copyAndRemove
now removes the partially written destination before treating the artifact
as already moved, so the trash never keeps a truncated transcript that a
later restore would resurrect. Real copy failures still propagate and leave
the source untouched.
NewSession/ClearSession closed their turn↔swap TOCTOU window with the
rotation gate, but the same bare-Running()-then-mutate-later shape lived
in every other interactive session-mutating entry point:
- forkNamed: Running() check, then Snapshot(), then SetSession
- Branch: read running, then Snapshot(), then SetSession
- SwitchBranch: read running, then Branches()/LoadSession, then SetSession
- Rewind: Running() check, then Session().Replace + SnapshotRewrite
- Compact: Running() check, then CompactNow rewrites the live session
All are dispatched from submit()'s synchronous slash-command path (or
Fork wrappers), so a user can trigger them while a turn streams — the
guard existed for exactly that reason, but a turn starting after the
check and before the mutation slipped through, replacing/rewriting the
session under the run loop.
Route all five through beginRotation()/endRotation(), same as
NewSession/ClearSession: the gate is held from before the snapshot/load
through the swap, and runGuarded/RunTurn already refuse to start while
c.rotating. Each keeps its original "while a turn is running" message on
the running path and reports errRotationInProgress for the newly-closed
window. None of these methods is called from inside a turn body, so the
gate is never re-entered (no deadlock).
TestSessionMutationsRefuseWhileRotating asserts all five (plus RunTurn)
refuse while a rotation holds the gate and the live session is untouched,
and that the gate cannot be claimed while a turn runs. Verified it fails
if the rotating check is removed.
The running guard checked c.running once at entry, released c.mu, and
only swapped the executor session after Snapshot(). A turn could start
during that snapshot window (running was false at the check) via
runGuarded/RunTurn and then have its live session replaced by
SetSession — the exact race the guard meant to prevent, just moved later.
Replace the point-in-time check with a rotation gate: beginRotation
atomically refuses when a turn is running or another rotation holds the
gate, and stays held (c.rotating) from before Snapshot() through the
swap. runGuarded and RunTurn now also refuse to start while c.rotating,
so turn-start and session-rotation are mutually exclusive in both
directions and the run loop's session reference cannot change under it.
Regression test forces the interleaving deterministically by parking
NewSession inside Snapshot()'s recovery callback, then asserting a
concurrent RunTurn is refused, running stays false, and the session is
not mutated until the rotation completes. Verified it fails without the
c.rotating checks.
ClearSession and Compact both guard against rotating the session out
from under a running turn, but NewSession did not. Both the Submit
"/new" path (go c.NewSession()) and the bot gateway invoke it
asynchronously, so a mid-turn /new could swap the executor session
while the run loop reads it directly and race the turn-end snapshot
onto the fresh path.
Mirror ClearSession's running guard in NewSession. The bot gateway
cancels the in-flight turn before rotating, and that cancel is
asynchronous — give it a bounded window to unwind (same 5s/10ms shape
as the desktop's waitControllerStopped) and report the failure to the
chat instead of claiming a new session started.
replaceSessionAfterCancel took snapshotMu only around the SaveRewrite/
recovery flush, but the in-memory Replace ran before it. A mid-turn
autosave already holding the lock could then capture the truncated
transcript mid-save, read the longer partial autosave on disk as a
stale-prefix conflict, and adopt it back into the executor — silently
undoing the cancel cleanup before the flush could persist it, and
pointing the later SaveRewrite at the resurrected session.
Hold snapshotMu from the top of the cleanup so the truncation, todo
rebuild, path read, and flush are one atomic handoff.
The CancelFlush regression case now performs a real truncation (drop the
assistant reply, the shape stripTurnMessagesAfter produces) instead of a
same-content no-op, asserts the in-memory transcript is untouched while
the blocked handoff is still in flight, and verifies the truncation
lands on the recovery transcript afterwards. The blocked-phase assertion
fails against the previous code.
A save-conflict loop used to fork recovery branches from recovery
branches without bound — #5993 reached 8 nested levels, each fork
polluting the session list. The conflict triggers behind that loop are
fixed, but nothing prevents a future regression from spawning chains
again: BranchMeta.Recovered was display-only and SaveRecoveryBranch
never looked at the parent's recovery state.
- Stamp BranchMeta.RecoveryDepth on every recovery fork (parent depth
+ 1; legacy recovery metas count as depth 1).
- SaveRecoveryBranch refuses to fork a parent already at
SessionRecoveryMaxDepth (3) with ErrSessionRecoveryDepthExceeded.
- At the cap the controller force-saves the transcript onto the
current branch instead: this runtime is the only writer of recovery
branches it created, so the force write keeps the data, re-anchors
the persistence baseline (no immediate re-conflict), and stops the
file proliferation. A warn log plus a distinct notice make the
condition visible.
- Replace the readiness loop-guard text scan with host state armed by the
storm breaker itself: a guard on any result of a multi-call batch now
unlocks the final blocker report, and tool output that merely quotes
"[loop guard]" no longer does.
- Keep the pass across the bookkeeping the guard recommends (ask,
todo_write, complete_step); revoke it once a successful write or command
receipt proves receipts are obtainable again.
- Add a blocked-turn streak detector so rotating tools, reordering batches,
or blockers whose text varies per attempt still trip the guard after
three consecutive fully-blocked turns.
snapshotMu closed the snapshot-vs-snapshot recovery race, but snapshot
still reads sessionPath and the executor session as two separate steps,
and every session/path swap mutated that state without the lock. A swap
landing between the two reads pairs the old path with the new session:
best case it manufactures the same spurious conflict/recovery cascade,
worst case an append-shaped mismatch writes one transcript's messages
into another transcript's file (the loaded session has no persisted
baseline for the old path, so the revision CAS is disarmed).
Take snapshotMu across the swap sections of NewSession, ClearSession,
forkNamed, Branch, SwitchBranch, Resume, and SetSessionPath. ClearSession
holds it from artifact removal onward so a racing save cannot resurrect
the just-removed transcript. Resume releases it before
recoverInterruptedTurn/maybeColdResumePrune, which snapshot on their own
(the lock is not reentrant).
replaceSessionAfterCancel was a second save/recovery handoff outside the
lock: its post-cancel flush calls SaveRewrite and recoverSnapshotConflict
directly. Serialize it the same way and read the path under the lock so
an in-flight recovery retarget cannot leave it stale.
Regression test blocks the first recovery callback and races
SetSessionPath, Resume, and the cancel flush against the in-flight
handoff: none may complete until the handoff commits, and only one
recovery transcript may exist afterwards.
Field reports of frequent "session changed on disk" notices (#6069)
are undiagnosable today: the stale-prefix adoption and the
recovery-not-needed adoption emit the same notice text, nothing logs
which trigger fired, and the revision ledger that drove the conflict
decision is discarded.
- Log every recoverSnapshotConflict outcome (stale-prefix adopt,
recovery-not-needed adopt, failed adoption, recovery fork) with the
conflict kind, save mode, message counts, and the
BaseRevision/DiskRevision pair from SessionSnapshotConflictError.
- Differentiate the recovery-not-needed notice text ("local changes
already covered") so user screenshots identify the trigger.
- The recovery-not-needed path whose disk adoption fails used to drop
the snapshot silently; it now leaves a warning so "my last turns
vanished" reports can be tied to it.
The cache-impact gate watched only internal/ prompt surfaces, but two
cache-sensitive surfaces lived outside it:
- desktop/session_prompt.go rewrites the persisted leading system
message with the freshly composed prompt on every tab resume/rebind;
any byte difference there invalidates the whole conversation's prefix
cache and persists the rewrite.
- internal/environment/* composes the environment section embedded in
the cached system prefix; probe output changes shift the prefix bytes
for every session.
Add both to the cache-sensitive list and to the system-prompt-sensitive
list so changes require Cache-impact/Cache-guard lines and an explicit
System-prompt-review note.
The per-tab usage telemetry that feeds the status bar 会话费用 had no
session identity, so its totals leaked across session rotations and were
then persisted into the wrong session's .telemetry.json sidecar:
- App.NewSession and the clear-while-running rebuild never reset it
(only ClearSession did), so a "new session" kept the old running total.
- Rebinding a tab to a session without a telemetry sidecar (fresh
session, CLI-created session) kept the previous session's totals and
saved them into the new session's sidecar on the next event, so the
contamination compounded into an all-sessions running total (#5850).
- A typed /new routes through Controller.Submit and rotates the session
without the App layer ever noticing, so no App-side reset can cover it.
- A save-conflict recovery fork carried cost in memory but never wrote
the fork's sidecar, undercounting to zero after a restart.
Fix: key the in-memory telemetry with the tab's sessionRuntimeKey and
re-sync at every telemetry writer (usage / turn start / turn done /
read-file recorders) and reader (ContextUsageForTab, ContextPanel). On a
key mismatch the new session's persisted sidecar is swapped in, or zero
when none exists. NewSession and the clear-while-running path reset the
telemetry to the rotated session; the build/rebind restore is now
unconditional and keys the telemetry; recovery forks re-key the carried
totals and persist the fork sidecar at retarget time; detach/attach tab
clones carry the key.
A mirror runtime healing the revision ledger over identical content
(same-content save recording a missed revision) advanced the revision
under its own writer id. The next owned rewrite on this side — a
cancelled turn's truncation or an auto-compaction — then read as a
stale-runtime conflict and forked a recovery branch, even though the
on-disk transcript was byte-for-byte what this session last persisted.
ownsPersistedState now consults the ledger's recorded content digest:
when the baseline digest, the on-disk transcript digest, and the
ledger digest all describe the same bytes, a foreign revision stamp is
bookkeeping over content this session owns, and rewriting it destroys
nothing of the stamper's. At worst the conflict relocates to the
stamper's next divergent save, where its own in-memory history forks a
recovery branch as usual — no content-loss path opens.
A stamp whose digest disagrees with the on-disk transcript still
revokes ownership: that is the aftermath of a save whose bytes landed
but whose record failed, the bytes cannot be attributed to a writer
(event writer ids are per-process, not per-session), and only the
conservative conflict path preserves them.
Replace the exact-append CAS exemption with a reachability guard:
locate the persisted baseline digest among the pending snapshot's
incremental prefix digests (re-anchored on the disk system message when
a resume swapped the prompt) and require the on-disk transcript to
still reach that depth.
This extends the stale-revision exemption to compatible-system appends
(resume swapped the leading system prompt, then a routine turn append
forked a spurious recovery branch), while closing the gap the
exact-append exemption opened: an append over a transcript another
runtime deliberately rewound below the baseline now conflicts again
instead of silently resurrecting the removed suffix.
ownsPersistedState no longer lets an absent revision ledger revoke
rewrite ownership: recorded revisions start at 1, so a disk revision of
0 means the meta sidecar was deleted or rebuilt by a listing-only
writer after this session's save — the digest+version match already
proves the transcript is ours. Cancel-turn truncations and compaction
rewrites therefore survive a swept sidecar. A foreign recorded revision
still revokes ownership, preserving the guard against stale controllers
force-rewinding a ledger another runtime has stamped.
sessionRecoveredHandler moved the live session's transcript and lease to
the recovery file, but persisted lookups still treated the id-keyed path
as canonical: after a process restart, session/load and session/resume
reopened the pre-recovery transcript (the user's recovered work silently
vanished from ACP's view), loadMeta read the stale sidecar, non-live
session/delete removed the original while the recovery file lingered as
an undeletable orphan, and session/list could surface the stale sidecar's
title and timestamps.
Leave an ActiveTranscript redirect on the id-keyed sidecar when a recovery
commits (always written against the id-keyed path, so resolution stays a
single hop even for recovery-of-recovery), and resolve ids through it:
resolveTranscriptPath validates the redirect stays inside the session dir,
exists, and claims the same session id before following, falling back to
the id-keyed path otherwise. session/load, session/resume, loadMeta, and
the non-live delete fallback all resolve through it; session/delete also
removes the id-keyed twin so neither file resurfaces as a ghost; and
session/list reduces the two sidecars claiming one id to the active
transcript's meta.
Regression tests cover restart-time load (resolved transcript holds the
recovered content), restart-time delete (both transcripts and sidecars
removed, list empty), and list showing exactly one entry backed by the
active transcript's metadata.
When a snapshot conflict retargeted the controller to a recovery branch,
the ACP session object kept sess.transcript and sess.lease on the original
file: session/prompt reported the stale transcriptPath, session/delete
destroyed the wrong file (orphaning the live recovery transcript), and the
controller kept writing the recovery file with no session lease guarding
it against other runtimes.
Wire control's OnSessionRecovered hook through SessionParams and the acp
factory into every controller the service builds. The handler acquires the
recovery lease before releasing the original (Rebind order), moves
sess.transcript, and saves ACP meta against the recovery file at commit
time; a lease failure aborts the recovery commit so the controller stays
on the original path. This covers both recovery triggers: the turn-end
autosave in persistAfterTurn and the pre-rebuild snapshot in
rebuildSession.
Regression tests assert transcript, lease, and controller agree on the
recovery path after both triggers, the original transcript's lease is
released, and follow-up saves do not derive a second recovery branch.
Controller rebuild paths (desktop model/effort/token-mode/settings switch,
serve switchModel, TUI /model and /effort, ACP rebuildSession) captured the
session path before snapshotting the old controller. A snapshot conflict can
retarget the controller to a recovery branch, so the stale pre-snapshot path
bound the just-recovered transcript back to the original file - every later
save re-conflicted and derived yet another recovery branch, looping recovery
notices on the affected session.
Capture the continue/resume/adopt path (and carried history) after Snapshot
instead, so a rebuild keeps writing the recovery branch the snapshot just
committed to. Desktop gains sessionPathAfterSnapshot to re-read the old
controller's path on all four rebuild paths; CLI/serve/ACP move the capture
below the snapshot call.
Regression tests cover each surface: the rebuilt controller must land on the
recovery path and a follow-up snapshot must not derive a second recovery
branch.
Both manifest endpoints (dl.reasonix.io, crash.reasonix.io) share one
Cloudflare zone, so bot protection that 403s a user's egress IP takes out
the whole chain at once (#6005). Three changes:
- Send a descriptive User-Agent on manifest and artifact requests so the
edge can allowlist updater traffic instead of scoring it as a generic
Go client.
- Append the GitHub compatibility manifest as the stable channel's last
resort. release.yml pins the desktop-manifest mirror to the repo-wide
latest release (the compatibility asset), so the URL always serves the
desktop manifest, from infrastructure outside that Cloudflare zone.
- Keep every endpoint's failure via errors.Join instead of surfacing only
whichever endpoint died last, so reports like #6005 show the full chain.
The authenticated R2 smoke verifies the mirror landed but says nothing
about what end users' updaters actually reach — #5826/#5858 (manifest 404)
shipped broken for weeks because no release step exercised the public
edge, and #6005 (gateway 403) was only visible in user reports.
- release.yml: after attaching the desktop-manifest compatibility asset,
fetch it back anonymously with a Go client UA via GitHub's public
releases/latest URL — the exact path pre-v1.16 updaters poll. GitHub
serves its own runners (no bot wall), so this asserts hard.
- release-desktop.yml: probe the release gateway the same way. 200 must
serve the just-released version; 403 is the known bot-protection gap
(#6005) and only warns until a WAF skip rule lands; 404/5xx/stale
version fail the release.
The "0.x stable owns latest" rule was a migration guard from when the 1.x
Go line was rc-only. Once 1.x went stable the guard inverted into a trap:
latest stayed pinned at 0.53.2, so pnpm update -g / npm update -g silently
downgraded every 1.x user back to the retired line, and the documented
manual promotion (npm dist-tag add) never happened (#5822).
Stable versions now always publish under latest; rc/beta stay on next and
canary stays on canary. A post-publish verify step polls the registry and
fails the release if the tag did not land — the guard #5822 lacked.
Review finding on this PR: /resume, /new, and /fork ran their
snapshot -> lease rebind -> controller bind sequences without any
cross-request serialization. net/http runs handlers concurrently and
one serve instance backs multiple browser tabs, so two interleaved
requests could leave the controller writing session B while the lease
keeper guards session C - B unprotected, C wrongly occupied - exactly
the split this PR exists to prevent. switchModel was serialized by its
own switchMu but nothing stopped the other handlers from rebinding the
same controller mid-switch.
Widen switchMu into bindMu and take it in every entry point that
changes the active session path (/resume, /new, /fork, switchModel).
The lock is acquired after body decoding and path validation, so a
slow client cannot hold the binding lock while uploading; lock order
stays bindMu -> s.mu with no reverse path.
Regressions: a deterministic interleave test parks the first resume
between its rebind and its Resume via a test hook (gated by an atomic
CAS, not sync.Once - Once.Do's internal mutex would accidentally
re-serialize the window) and asserts the second resume waits and both
land with controller and lease aligned; removing the guard makes it
fail. Plus two hammer tests (concurrent resume x resume and
resume x fork) asserting the same alignment invariant under -race.
Refs #6034 review.
Phase 2b of #6027: the desktop has held session leases since v1.16, but
CLI --resume/--continue, the chat TUI's session switches, serve, and ACP
never acquired one — leaving desktop+CLI on the same session as the last
real silent dual-writer entrance (CAS only detected it after the fact by
forking recovery copies).
- control.SessionLeaseKeeper: single-holder lease follower for the
non-desktop frontends. Rebind acquires the new path before releasing
the old one, no-ops on the already-held canonical path, releases on
empty path, and leaves the previous lease untouched on failure.
control.SessionInUseMessage renders the shared holder line (pid, host,
since; never the writer id or the session path).
- CLI: run/chat resume targets fail fast before setup when held, with
the holder line plus the two ways out (close the holder, or rerun
with --copy). New --copy flag duplicates the resume target beside the
original (event-log-aware Session.Save; parent/preview/turns/model
meta; ' (copy)' title suffix) and continues writable on the copy.
In-TUI /resume, /switch refuse held targets and keep the current
session; /switch re-points the lease if the switch itself fails after
the lease moved. /new, /clear, /branch, and rewind-fork follow the
controller onto fresh paths. Leases release after the controller
closes on exit.
- serve: POST /resume returns 409 with the holder line when held (state
unchanged); /new, /fork, and model-switch path rotations follow their
new paths. Read-only endpoints are unaffected.
- ACP: session/new, session/load, and session/resume guard their bind;
held sessions surface a protocol error with the holder line.
session/close, session/delete, and closeAll release; delete releases
before removing files so no lock sidecar is left behind on Windows.
- controller: export ResolveBranchRef (wrapper over resolveBranch) so
the TUI can learn a /switch target's path before moving its lease.
- Crash leftovers need no special casing: the OS lock is the sole
arbiter, so a dead holder's lease is simply acquirable.
Not wired in this round (documented in #6027): the bot gateway's
attached-session path (needs desktop-style lease handover semantics)
and destructive-op guards for serve/ACP delete endpoints.
Refs #6027.
Cache-impact: none - CLI/serve/ACP session binding and help text only;
no provider-visible prompts, tool schemas, or request serialization.
clearActiveSessionRuntime's swap-time identity check silently dropped
the freshly built controller when the tab had been closed or replaced
while the clear ran off-lock: newCtrl was never closed and the session
lease it had just bound stayed held by the abandoned tab struct.
Mirror rebuildSettingLocked's failure branch: close the fresh
controller, release the lease, still finish the destroyed runtime's
teardown, and report the lost swap instead of returning success.
Two more sync-swap paths shared the race fixed for settings rebuilds:
- removeBuiltInProviderAccessAndRetargetTabs and
deleteProviderAndRetargetTabs cleared tab.Ctrl without bumping the
build generation, so a startup build planned against the removed
provider could finish later, pass its generation check, and reinstall
a controller for it - racing the generation-0 retarget rebuild.
- clearActiveSessionRuntime installed the fresh controller without
invalidating an in-flight build, whose completion would overwrite the
swap and resurrect a runtime bound to the just-destroyed session.
Supersede the build inside the same critical sections that perform the
swap, and extract a shared assertion helper for the regression tests
covering all four protected paths.
Follow-up polish on the lease/CAS stack after the #6023/#6025 hotfixes,
closing the remaining ways a healthy single-window session could still
present as busy, leak holder details, or litter the session directory.
- Reclaim now arbitrates on the OS lock alone: a missing or unreadable
lease.json (user cleanup, AV quarantine, crash tear) with a free lock is
a leftover, not a holder. Previously an orphaned in-process entry whose
info file disappeared could never be reclaimed - the fallback acquire
re-hit the orphaned entry forever and every rebuild stayed busy. The
desktop gate mirrors this: nil lease info allows the reclaim attempt,
foreign holders are still refused.
- Raw lease errors (session path + host-pid-writer id) can no longer reach
the UI: startup bind failures (StartupErr feeds the topbar banner and tab
metas), ClearSession's bridge returns, and the recovery-lease callback
(chat notices via snapshot errors) all render the user-facing busy
message; raw details stay in slog. The busy message drops its dangling
'before changing this setting' clause when no setting applies.
- SessionLease.Release retires the .lock/.lease.lock sidecars through the
existing atomic take-then-remove helpers (non-blocking, no-op against any
live holder or in-flight save), so ordinary use stops accumulating lock
files that previously waited for the next boot reconcile (#6014).
- Autosave failures warn once per burst and at most once per 5 minutes per
tab: retries are slog-only, the user notice fires when the burst gives
up. Explicit action saves keep immediate feedback. Every failure is still
logged.
- SaveRecoveryBranch tolerates event-index write failures like the other
save paths: the recovery transcript and meta are already durable, so a
failed listing accelerator no longer discards a successful recovery.
- Lease test suite now feeds user-shape (mixed-case) paths to the APIs
under test instead of pre-canonicalized ones - the pattern that let the
Windows case-fold mismatch (#5999) escape the suite - plus regressions
for orphan-without-info, corrupt-info, foreign-info-refused, sidecar
retirement on release, StartupErr sanitization, and the autosave warning
debounce.
Refs #6014#5999#6027.
rebuildSettingLocked installed its controller without bumping the tab's
build generation, so a startup build still in flight would later pass
its generation check, overwrite the just-installed controller, and leak
it - leaving the session on a runtime built before the new credentials
were saved. Provider-key saves hit this window most often: users
typically save a key right while the initial startup build is running.
Apply the same supersede-after-successful-swap the model/effort/token
switches use, and add a regression test that models an in-flight build
(live generation plus cancellable build context) and asserts the rebuild
supersedes and cancels it.
A save that lands its transcript bytes and then fails to record the
revision (fail-closed recordSessionContentRevision, or a crash between
the two writes) leaves the sidecar describing older content. The
same-content retry then took the up-to-date shortcut and never touched
the ledger again, so revision/content_digest stayed stale until the
next content-bearing save — contradicting the "later save can bump the
revision" recovery the fail-closed path promises. Idle sessions never
healed at all.
checkSnapshotWrite now surfaces the ledger digest it already reads, and
the up-to-date path records the deferred revision when that digest no
longer matches the on-disk transcript, reproducing the exact state the
interrupted save would have left. A missing or digest-less legacy
sidecar stays untouched: stamping those would bump revisions other
runtimes still hold as baselines.
ReplaceFile fell back to copyOnto after exhausting its rename retries,
regardless of why the rename failed. copyOnto truncates dest in place,
so a reader racing the copy observes an empty or half-written file —
silently breaking the atomic-publication contract every AtomicWriteFile
caller relies on (session leases, credentials, plugin state, mcp.json).
CI caught it once the window lined up: TestStateLoadDuringSaveNeverSees
TornFile failed on windows-latest with 'unexpected end of JSON input'
after a transient dest lock outlived the ~0.7s retry budget and the
copy truncated the state file under the reader.
Split the failure classes. Transient locks (antivirus, search indexer,
a reader without delete sharing) now retry longer — 12 attempts, ~1.5s
of backoff — and then surface the rename error; a loud failure beats a
silently torn file. The cross-device class (ERROR_NOT_SAME_DEVICE /
EXDEV, reported by encryption-software filter drivers even for a
same-dir rename, #2696) still takes the copy fallback because rename is
structurally impossible there — but now immediately, instead of first
sleeping through a retry ladder that cannot succeed, which also cuts
~0.7s of latency from every save on such hosts.
The rename seam (renameFile) exists because neither failure class can
be provoked portably on a real filesystem; the new tests pin dest
integrity plus the surfaced error for the transient class, and the
immediate no-retry copy for the cross-device class.
Picks up b79e9a5: the crash-residue marker is keyed by PID alone, so a
helper process that drew a crashed predecessor's PID used to skip the
stale marker in its run-start sweep (pid == self), append its own lines
into the same file, and then delete the whole file on exit — orphaning
the predecessor's forbid_read deny or tool-directory grant beyond any
future sweep's reach. The library now tracks marker ownership
in-process: cleanup only deletes a marker this process wrote, and an
unowned file at the run's own PID is consumed at run start as a dead
predecessor's residue. The filename stays <pid>.txt so older binaries
sharing the marker directory keep parsing PIDs correctly during
upgrade windows.
The transcript CAS keyed off the .jsonl.meta sidecar, but the ledger was
maintained unprotected. On Windows this manufactured conflicts in normal
single-window use: recovery branches forked on every app start, recovery
files accumulated, and 'adopted the newer transcript' notices looped.
- Retry transient sidecar reads (I/O and JSON-decode failures, 20/50/100ms)
before giving up: ReplaceFile's non-atomic copyOnto fallback tears
concurrent readers, and AV/indexer handles fail reads transiently.
- Stop swallowing unreadable-ledger errors into revision 0: a persistently
unreadable ledger now fails the save (transcript bytes are already
durable; the autosave retry heals it) instead of desyncing the baseline
and misreading the next honest save as a foreign write.
- LoadSession with an unreadable sidecar still opens the session but marks
the baseline revision-unknown: revision CAS is disarmed (digest/prefix
checks still catch real divergence) and owned rewrites anchor on
digest+version, so compaction cannot become a permanent conflict. A
missing sidecar remains a known revision-0 baseline.
- Hold the per-path meta lock across the whole read-modify-write in
RenameSession, topic-title propagation, legacy topic migration, and
restoreSessionTopicIndex, so a concurrent save's revision bump can no
longer be rolled back by a stale read-back.
- Downgrade event-index write failures to a warning at both save() sites:
the index is a listing accelerator and must not strand the baseline
behind a transcript+revision that already landed.
- Capture save() snapshots under the save locks so concurrent in-process
savers can no longer land out of order and misread the newer transcript
as a stale-prefix conflict.
Regression tests cover fail-closed saves on a corrupt ledger, unknown-
revision baselines (load, append, owned rewrite), missing-meta behavior,
index-failure tolerance, a concurrent writers hammer asserting revision
monotonicity, and deterministic under-lock capture.
Fixes#6014. Fixes#5998. Fixes#5996. Refs #6003.
The lease registry keys sessions through agent's canonical form (clean,
absolute, lowercased on Windows), while desktop sessionRuntimeKey kept the
raw path case. On Windows every tab path contains uppercase segments
(C:\Users\...), so the tab's own lease never matched its session key:
ensureSessionLease re-acquired against the process's own registry entry,
reclaim then hit the tab's live OS lock handle, and every model / effort /
token-mode / provider-key rebuild failed with the "already open in another
Reasonix window" error on a single window — including fresh installs.
- Export agent.CanonicalSessionPath and layer it into sessionRuntimeKey so
desktop identity checks agree with the lease registry byte-for-byte.
- Normalize the canonicalTabSessionPath fallback (project-scope sessions)
with clean+abs so shape variants cannot split one file into two keys.
- Keep case-folded keys out of SessionPath: detach/attach paths now carry
the display path separately from the runtime key.
- Release only the build's own lease on startup-build failure, and skip
claiming a lease the build merely reused (a concurrent switch owns it).
- Supersede in-flight startup builds when a model/effort/token switch
installs its controller, so a slow blank-session build can no longer
overwrite the new runtime or strip its lease.
- Regression tests: lease/runtime key agreement, idempotence, Windows case
folding, project-scope fallback normalization, fast-path lease reuse.
Fixes#5999. Fixes#6006. Fixes#5989. Fixes#5990. Refs #5996.
Picks up the follow-up (3058fd1) that re-checks the system-root
invariant at sweep time: the crash-residue marker lives in the user's
%TEMP% and is untrusted, so a legacy or planted line naming System32 /
Program Files could previously drive the next run's sweep to strip the
broad built-in package SIDs from a system directory's factory ACLs. The
sweep now refuses system paths independently of the recorder-side
filter.
Use the same authoritative question turn mapping for the last-turn check that TurnActions uses for rewind actions. This keeps "Summarize after this" disabled on the last visible turn even when restored or paged history uses non-contiguous checkpoint turns.
Co-authored-by: SivanCola <[email protected]>
When REASONIX_HOME is set, legacy session migration is skipped to
prevent production data leaking into the isolated environment. Update
TestBuildMigratesLegacyXDGAndProjectSessions to verify this behavior.
When REASONIX_HOME is set, the runtime is now fully self-contained: all
configuration, state, cache, skills, commands, hooks, trust, credentials,
migration, and desktop tab/project data live under that directory tree.
Legacy fallback paths and auto-migration are skipped so a source-built
binary can run side-by-side with a stable release without interfering.
Changes:
- config/paths.go: add IsolatedHomeDir(), gate legacyOSSupportDir /
legacyXDGConfigPaths, make userCacheDir() fall back to
REASONIX_HOME/cache when isolated
- config/migrate.go: skip MigrateLegacyIfNeeded when isolated
- config/mcpjson.go: skip legacyConfigPath when isolated
- config/ccswitch.go: skip LoadCCSwitchMCP when isolated
- config/credentials.go: skip ~/.env credential source when isolated
- skill/skill.go: skip OS-home convention dir scan when isolated
- hook/hook.go: skip legacy reasonix home fallback when isolated
- migration/migration.go: skip auto migration entry points when isolated
- outputstyle/outputstyle.go: skip home dir scan when isolated
- desktop/tabs.go: desktopConfigDir / globalWorkspaceRoot now use
config.ReasonixHomeDir() instead of raw os.UserConfigDir()
- desktop/updater.go: defaultUpdateCacheBaseDir uses config.CacheDir()
- CONTRIBUTING.md: document isolated development workflow
- docs/CONFIG_PATHS.md: document full isolation behavior
Closes#5988
The "Summarize after this" (summ-from) button was enabled on the last
user message even though there is nothing after it to summarize. This
mirrors the existing check that disables "Summarize before this" on
the first message (turn <= 0).
- Add isLastTurn prop to TurnActions component
- Check isLastTurn in actionDisabledReason for summ-from scope
- Pass isLastTurn from Transcript hot zone and WarmTurnItems
- Add i18n key rewind.disabledNoLater (en/zh/zh-TW)
Closes#5744
release-desktop.yml's header reserves GitHub's repository-wide "latest"
for the CLI line, but gh release create was not passing --latest=false,
so whichever line published second won the badge — desktop-v1.17.0
stole it this cycle (fixed by hand with gh release edit). Pin desktop
releases to --latest=false so the badge stays on the v* line.
dl.reasonix.io (Cloudflare) persistently serves 403 to GitHub Actions
egress IPs, so every stable release run failed at the public-edge curl
steps even though the publish itself succeeded, and the failure skipped
the two real follow-up steps (attach compatibility latest.json to the
CLI release, refresh the site version).
Read the mirrored manifests and head the platform objects over the
authenticated S3 endpoint instead:
- release-desktop.yml smoke: aws s3 cp / s3api head-object against the
bucket, mapping each public URL back to its object key. Verifies the
mirror landed; the public edge is unreachable from CI by Cloudflare
policy and is exercised by real user traffic.
- release.yml compatibility asset: fetch latest/latest.json via S3
using the same R2 secrets, skipping cleanly when they are absent
(matching the desktop workflow's HAS_R2 guard).
Picks up two upstream fixes: exe-dir contention coverage with the
close of the deny-residue window (2a86cf8, 0e09612), and the follow-up
that stops grantAppContainerExecutable from snapshotting, granting, or
recording crash-residue on system directories (1a3e77e) — previously a
crashed elevated run could leave a residue marker that made the next
run strip built-in package SIDs from System32 / Program Files ACLs.
Pick up the follow-up that pins the per-root lock to one OS thread, so a Go
goroutine migrating threads cannot release a Windows mutex from a non-owning
thread or re-enter it within the process. Cross-process serialization (each
sandboxed command is its own helper process) was already correct; this
tightens the within-process semantics the library's unit tests cover.
Update the native Windows sandbox backend to the release that fixes the
mutation-based-isolation failure modes: recursive icacls timeout on large
workspaces, residual Low integrity labels after cleanup, concurrent runs
corrupting each other's ACL boundary, forbid_read deny residue after a crash,
forbid_read entries pointing at files being silently unprotected, and
PATH-resolved icacls/taskkill.
Correct the docs to match the actual Windows boundary: writable low-integrity
commands can still write the narrow set of locations Windows leaves writable to
any low-integrity process (e.g. AppData\LocalLow) in addition to the configured
roots, concurrent commands on a shared workspace are serialized, forbid_read
now covers files, and crash residue is swept by the next run.
Review follow-ups on the event-log hardening:
- Close the probe-then-delete lease window: desktop trash, reconcile, and
permanent delete now take a SessionRemovalGuard that holds the session's
save lock and lease lock across the whole operation (built on #5977's
delete-while-holding-flock primitive), so another process can no longer
acquire the lease between a busy probe and the sidecar removal and then
lose its freshly locked lease file. Live owners surface as a sanitized
busy error; the lock files are deleted atomically with the release.
- Run overlong-named sessions (pre-bounded recovery cascade names) in
checkpoint-only mode: their event log/index sidecars would exceed the
filesystem name limit, so the probe treats them as non-native instead of
failing saves with ENAMETOOLONG.
- Fix staticcheck QF1003: tagged switch for the save-reason mapping.
Review follow-up: rebindTabToLoadedSessionPath observed tab.Ctrl (and ran
loadTabSessionProfile's disk I/O) before invalidating the in-flight async
build. runtimeRebuildMu does not cover startTabControllerBuild's goroutine,
so inside that window the async build still passed its swap-time generation
check: it could install its controller right after rebind observed
ctrl == nil, the loaded build then silently overwrote it, and the stale
runtime plus its shared-host reference leaked (its lease was already covered
by the adopt-on-ensure path).
The validation section now runs in one a.mu critical section that checks tab
membership, compares session keys (same-key resume stays a no-op and leaves
an in-flight build alone), bumps buildGeneration + cancels the build context,
and only then snapshots tab.Ctrl — making the snapshot authoritative: after
the bump the async build can only fall into its superseded branches, which
release exactly what it acquired via abandonSupersededBuild. The duplicate
bump at the ctrl!=nil branch tail is gone, and the build now re-checks
ownership right after its lease bind so a superseded build cannot write its
stale session path back onto the rebound tab.
New regression stalls a real async build inside its lease bind, rebinds the
tab to another session mid-flight, then releases the build to attempt its
swap: the rebound controller must survive, and the stale build's lease and
shared-host reference must both be released (exactly one host ref remains).
Verified: cd desktop && go test -race . passes; golangci-lint v2.12.2
(CI version) 0 issues; gofmt/go vet clean.
- Replay the event log tolerantly: torn tails are truncated by the next
save instead of bricking LoadSession; foreign/legacy .events.jsonl files
squatting the native log path are read-ignored and never written; logs
from a newer schema fail loudly instead of being silently truncated.
- Bound log growth: past a size threshold the log compacts to a single
replace event, and every rewrite/compaction refreshes the .jsonl
checkpoint so direct readers and older binaries stay bounded-stale.
- Keep force saves (subagents, guardian, migrations, forks) single-file:
they never bootstrap an event log and fold an existing one instead.
- Sweep the event log/index from every deletion surface through
store.SessionSidecarFiles — /clear, serve delete, ACP delete, subagent
delete, desktop trash — so deleted conversations cannot leak or be
resurrected; guardian sidecars are excluded from transcript discovery.
- Guard desktop trash/delete with an external-lease busy check so removing
lease files cannot break cross-process mutual exclusion.
- Migrate frozen-checkpoint readers to event-aware APIs: prompt history,
serve session list, auto topic titles, duplicate-trash comparison,
history search recency, and doctor byte totals.
- Count recovery-copy activity in topic summaries, hide recovery-only
topics only when unpinned with no live runtime session, and mark
conflict copies in the session tree and History drawer.
The Windows delete-disposition path only works when the handle carries
DELETE access, but os.OpenFile requests just GENERIC_READ|GENERIC_WRITE, so
RemoveAndUnlock always fell through to the post-close os.Remove fallback —
reopening the exact cleanup-vs-saver window the unlink-under-lock ordering
was meant to close (invisible to single-threaded CI). Open the lock file
with CreateFile(GENERIC_READ|GENERIC_WRITE|DELETE) and full share mode so
the disposition call runs on the locked handle; a Windows-only test pins
that the fallback counter never advances. A plain open of a lock file now
reads as held (sharing violation), matching the flock-held contract.
renameOverlongSession committed the transcript rename and then folded any
sidecar-migration error into a single return that dropped newID, so the
caller skipped re-parenting even though the old name was already gone —
orphaning child branches with no way to recover the mapping on a later run.
Split the rename (retryable: on failure nothing moved) from post-rename
migration (best-effort: newID is returned with joined warnings) so children
always re-parent once the transcript has moved.
Review follow-up on the generation-guard commit: the superseded branches in
buildTabControllerWithContext cleaned up by taking SharedHostKey (and, in the
final branch, the session lease) from the tab itself. Before generation
guards those branches only ran for removed tabs, where the tab's fields were
the stale build's own; now a rebind supersedes builds on a *live* tab whose
replacement build may already have published its own host key and lease, so
the stale build was releasing the new runtime's host reference (leaking the
host on same-root rebinds, or closing a host still in use on root changes)
and could strip the new session's lease.
Superseded cleanup now goes through abandonSupersededBuild, which releases
only what the stale build itself acquired: its controller, its own rootKey
reference, and — via the new releaseSessionLeaseForKey — only a lease still
bound to its own session path (removal keeps working because the keys match;
a rebind always changes the key). Owned-failure branches keep the
take-from-tab behavior, which is correct there.
Also drop tabWorkspaceName and tabRemovedForBuild, left unused by the
snapshot/generation refactor and flagged by golangci-lint (unused) in CI.
Verified: cd desktop && go test -race . passes; golangci-lint v2.12.2 (CI
version) reports 0 issues in both modules; new regressions cover
releaseSessionLeaseForKey key-matching and the stale-build-vs-replacement
ownership scenario from the review.
Windows refuses a path-based os.Remove of a file this process still holds
open, so the unlink-under-lock cleanup introduced for lock/lease sidecars
failed with a sharing violation on every removal (windows-latest CI).
Replace the closure-based try-lock with a sessionLockFile handle whose
RemoveAndUnlock deletes the file atomically with the release: Unix unlinks
while the flock is still held, Windows marks the delete disposition on the
held handle so the name dies with it. Lease info sidecars keep the plain
remove-under-lease-lock path — nothing ever holds them open. The overlong
rename pass retires the old .lock through the same primitive.
Capture the rewrite version when the snapshot decision is made and record
that captured version monotonically after a successful write, instead of
re-reading it afterwards: a compaction landing mid-save could be marked
persisted without ever reaching disk, turning the next autosave into a
spurious conflict and a recovery branch. A stale no-rewrite decision now
retries once as an owned rewrite, and disk-adoption resets the baseline to
the adopted session instead of inheriting the replaced session's counter.
Unlink stale lock/lease sidecars while still holding their flocks so a
concurrent saver cannot acquire an inode that is being deleted under it.
Rename transcripts whose basenames outgrew the sidecar name limit (the
leftover shape of the unbounded recovery cascade, #5923): conversation
bytes move verbatim to a bounded name, branch meta migrates with its ID
rewritten, children are re-parented, and disposable lock/lease files are
retired. Sessions past the limit could never be saved again because their
lock sidecars were uncreatable.
Extends the #5955 session-lease fix (#5968) across the remaining shared-state
races found in a full desktop concurrency audit:
- sessionLease: route the five remaining raw accesses (detach clone, detach
nil write, applyRuntimeTab transfer, rebuild reclaim, cross-tab probe)
through new sessionLeaseMu helpers (take/adopt/key), so no path can leak or
resurrect a lease and re-trigger the false 'another Reasonix window' error.
- tabEventSink: tabID/app now live under the sink mutex like ctx; Emit
snapshots the binding, and detach/reattach/clear rebind via setBinding
(same reasoning as the #5352 clearContext fix).
- detachRuntimeForReplacement: clone, lease/sink handover, membership check
and re-publication now happen in one a.mu critical section, so a runtime
unlinked by DeleteSession/TrashTopic cannot resurrect (#4384 class) and the
disabledMCP map is no longer read lock-free while the event goroutine
writes ActivityStatus.
- rebindTabToLoadedSessionPath and clearActiveSessionRuntime take
runtimeRebuildMu like every other build+swap path; Controller.close is
idempotent (sync.Once) so racing teardowns cannot re-fire SessionEnd hooks
or double-close.
- bound methods (MetaForTab, Submit-family readiness/read-only checks,
ReplayPendingPrompts, shutdown, MCP toggles, AutoResearch, ReloadCommands)
read per-tab fields via locked snapshots instead of check-then-use.
- build/rebuild goroutines (buildTabControllerWithContext, SetModelForTab,
SetEffortForTab, SetTokenModeForTab, rebuildSettingLocked,
clearActiveSessionRuntime) capture a tabRuntimeSnapshot under a.mu before
the off-lock boot, and SharedHostKey is published/taken under a.mu only.
- teardown/autosave paths (CloseTab, keepOnlyVisibleTab, closeTabRuntime,
detachSessionRuntime, maybeAutoTitleTopic, topic assignment) snapshot
fields under the lock before I/O.
- App-level fixes: bot install poll copies the session under a.mu, tray menu
items are published under a.mu, bot runtime lazy init removed.
go test -race ./... passes in desktop and internal/control, including new
regression tests for concurrent lease helpers, detach membership/transfer,
sink rebind during Emit, MetaForTab vs build swap, and idempotent Close.
A skill published with a bare GitHub repo-root source (owner/repo, or a branch
root with no sub-path) silently mass-installs every SKILL.md in the repo: the
installsource planner misses plugin detection for repos that only ship a
.claude-plugin manifest and falls through to a full-repo skill scan.
Validate at publish time — a kind:skill submission whose source resolves to a
whole repo is now rejected with a 422 and guidance to point source at a single
skill (a /tree/<branch>/<path> subtree or a raw SKILL.md URL). MCP kinds and
non-GitHub sources are unaffected.
The community moderation table used the browser's auto table-layout, so the
long summary in the first column starved the meta columns: version/installs/
stars and the submitted date wrapped character-by-character. Switch that table
to table-layout:fixed with explicit column widths (new .reg-table + colgroup)
so no column is squeezed below its content.
Add a per-row "Copy for review" button that copies the whole submission (kind,
source, repo_url, homepage, tags, summary, README) to the clipboard in one
click, reusing the existing [data-copy] handler and .copy-btn feedback. A
reviewer can hand a submission off without hand-copying fields one by one. The
manifest snapshot lives on package_versions (frontmatter only), so source and
repo_url stay the pointers to the actual reviewable content.
Resolve overlap with #5948 (orphaned session lease reclaim):
- Keep runtimeRebuildMu serialization on SetEffortForTab and
SetTokenModeForTab together with main-v2's detached-runtime attach
preamble.
- Adopt ensureTabSessionLeaseForRebuild (lease reclaim + user-facing
error) in rebuildSettingLocked so all four rebuild paths share the
same lease semantics; drop the now-duplicate userFacingSessionLeaseError
wrappers at helper call sites.
- Clear the deferred-rebuild pending entry after successful effort and
token-mode swaps, matching model switch and settings rebuilds.
- Add a background retry loop that re-runs the deferred rebuild once the
session lease held by another Reasonix process is released; the warning
and the retry bind to the tab whose refresh failed instead of whichever
tab is focused when the notice fires.
- Serialize rebuildSetting, SetModelForTab, SetEffortForTab, and
SetTokenModeForTab behind runtimeRebuildMu: two concurrent build+swap
sequences on the same tab both pass the tab-identity check, so the
loser's controller would replace the winner's and leak it without Close.
- Keep the old controller alive in SetEffortForTab until the replacement
holds the session lease; the old code closed it up front, leaving a dead
tab when the lease was held. Wrap effort/token-mode lease failures in
the user-facing error instead of leaking session paths and writer IDs.
- Route the remaining bare rebuild call sites (auto-plan classifier,
provider access removal, provider delete, reload settings, refresh
skills) through the deferred-rebuild warning so a held lease no longer
fails operations whose config change already persisted.
The oosmetrics achievement service has shut down (api.oosmetrics.com and
the site no longer resolve), so the three badges rendered as broken images.
Drop the block from both the English and Simplified Chinese READMEs.
The first attachExistingSessionRuntime branch in SetEffortForTab and
SetTokenModeForTab recomputed prevPath, but the value is never read
before the unconditional recompute that follows
ensureTabControllerWorkspace, so golangci-lint (ineffassign) failed the
desktop CI job. Drop the dead recompute and call attach as a statement,
mirroring SetModelForTab; behavior is unchanged.
Model, effort, and token-mode switches failed with "session is already
open in another Reasonix window" in a single-window session whenever an
in-process lease entry was orphaned by an interrupted runtime (for
example after a canceled turn in a recovered session), and the effort
and token-mode paths surfaced the raw lease error including the session
path and writer id.
- Route all three rebuild flows through ensureTabSessionLeaseForRebuild,
which reclaims a same-process orphaned lease only when no other tab or
detached runtime uses the session, and always maps lease conflicts to
the user-facing busy error.
- Attach an existing detached runtime before rebuilding in the effort
and token-mode paths, mirroring the model switch.
- Close the old controller only after the replacement is fully
installed, and keep the tab's lease on build failures, so a failed
switch leaves the current session usable for chat.
- Treat a live session that is byte-identical to its existing trash copy
as removable on delete, but refuse while another runtime holds the
session lease so a foreign window's next save cannot be silently
dropped; empty-stub removal gets the same guard.
TryReclaimCurrentProcessSessionLease deleted the in-process owner entry
before re-acquiring, so a racing reclaim -- or one aimed at a lease that
was actually alive -- evicted the live holder's fast-fail sentinel. The
OS file lock kept mutual exclusion intact, but the process-local layer
degraded permanently once hit.
Rework the owner registry around identity:
- Store a unique per-lease id in sessionLeaseOwners and remove entries
with CompareAndDelete, so acquire failure paths and stale releases can
only ever remove their own entry.
- Take the OS lease lock first in reclaim; an active holder keeps the
lock held for its whole lifetime, so reclaiming from it now fails with
ErrSessionLeaseHeld without touching its entry. Only a truly orphaned
entry (lock free, lease info naming this process) is taken over.
- Fall back to a plain acquire when the lease info vanished because the
holder finished releasing, and report unreadable lease info as a held
lease instead of leaking a raw filesystem error to callers that show
it to users.
- Add SessionLeaseHeldByOtherRuntime so destructive flows can tell a
live foreign holder from a crashed-process leftover, without touching
the lock file in the uncontended case.
Match RemoveWorkspace: sessionRemovalMu now covers snapshotting, pruning,
and closing the removed runtimes via a closure, and emitProjectTreeChanged
fires after it is released — a tree-changed listener re-entering a removal
path can no longer deadlock on the held lock.
Two leftovers from the edit-lock rollout: the desktop config-load helpers
persisted one-time legacy migrations (provider-access normalize, legacy
bot-config move) from plain read paths — Settings panel, tray locale,
single-surface probe, bot runtime load — as an unlocked read-modify-write
racing the locked bot-mapping writer exactly at first launch; and the TUI
/reasoning-language slash command still saved the user config bare.
Split the migrations into in-memory-only variants used by the read-only
View helpers (zero SaveTo; the migrated form reaches disk via the first
locked write path), document loadDesktopUserConfigForEdit as
lock-required, move the read-only callers onto View — with a credential-
loading variant where the consumer resolves env-based secrets (bot
runtime, MCP connect, provider-fallback planning) — and wrap the slash
command's load-modify-save in the edit lock.
A queued pendingConfig could be jumped: rebuildSession's defer released
maintenanceDone before applyPendingSessionConfig ran, and begin() only
checked running/deleted/maintenance — so a prompt arriving in between
started a turn on the outgoing config. begin now also refuses while a
pendingConfig is queued; the queue is claimed in the same critical
section that raises maintenanceDone so no promptable instant exists in
between, and a failed apply drops the parked config only when nothing in
flight is left to retry it (keeping the session promptable instead of
wedging begin forever). Regression tests pin both the direct invariant
and the exact end-of-maintenance window.
The edit lock landed on the bot mapping, pairing, and desktop
applyConfig* paths, but its own comment promised more: every other
in-process load-modify-save of the user config could still overwrite a
locked writer's fields with a stale copy. Cover the stragglers — desktop
settings/MCP writers, serve's effort switch, the controller skill toggle
and /memory-v5 slash path, and the CLI TUI + reasonix config writers.
Lock sections hold only load-mutate-save; controller rebuilds, snapshots,
and fan-out stay outside. The provider-removal paths re-load under the
lock after their off-lock snapshot planning instead of saving the stale
planning copy, so the lock never spans controller work (no ABBA). Add a
regression test racing a bot-mapping writer against a settings writer,
asserting every round survives.
RemoveWorkspace held sessionRemovalMu to the end of the function, spanning
startTabControllerBuild, project-file updates, and the synchronous
emitProjectTreeChanged hook. The lock only needs to cover snapshotting,
binding removal, and closing the removed runtimes (a detached runtime must
finish its in-flight autosave before DeleteSession can see the files);
scope it with a closure — mirroring DeleteSession/TrashTopic — so a future
tree-changed listener that re-enters a removal path cannot deadlock.
ReplaceFile fell back to copyOnto on the first rename failure. copyOnto
truncates dest in place, so a reader racing a transient rename failure
(Windows sharing violation from AV / indexer / a concurrent reader) could
observe an empty or half-written file — windows-latest caught exactly that
('unexpected end of JSON input') in the pluginpkg torn-file test. Exhaust
the rename retries first and keep the in-place copy strictly as the last
resort for filter-driver hosts where a same-dir rename never succeeds.
AtomicWriteFile chmod'd the temp file by path after closing it, leaving a
window where the Windows AV / search indexer could grab or move it and make
the chmod fail with 'file not found' — observed flaking the concurrent
pluginpkg state test on windows-latest. Chmod the still-open handle instead.
The reader-side test now retries the transient sharing violation the publish
rename can trigger on Windows (a real torn file still fails via SyntaxError).
The lock-probe regression tests acquire and immediately release the owner
mutex from a helper goroutine to prove the code under test is not holding
it across a callback — the empty critical section is the point. Annotate
the four probes the same way the boot connector probe already is.
Model/effort/token-mode/settings rebuilds published the new controller onto the
tab without re-checking it still lived in a.tabs: a concurrent CloseTab left the
fresh controller (and its session lease) orphaned — the user-visible 'lease
held, session won't open' bug. Revalidate under a.mu and close+release on a
mismatch; route the stray tab.Ctrl reads through controllerForTab. Move
plugin.Host.Close (which reaps MCP subprocesses) out of a.mu. Serialize
clearActiveSessionRuntime/RestoreSession with sessionRemovalMu. Snapshot tab
fields under a.mu in the meta/recovery writers. Tear down the bot gateway off
r.mu via a dedicated lifecycle lock.
serve.switchModel held the write lock across Snapshot, boot.Build, and the
old controller's Close (up to 15s of jobs teardown + SessionEnd hook), wedging
every HTTP handler on s.ctl()'s RLock. Mirror the acp rebuild fix: switchMu
serializes the switch, the swap revalidates s.ctrl under a short lock, teardown
runs after unlock. In acp, route the remaining bare sess.ctrl reads through a
locked accessor and make closeAll wait for in-flight maintenance.
cfg.Channels/ConnectionChannels/ToolApprovalMode were written under gw.mu but
read unlocked in sessionOptionsForMessage and the project/session index scans:
concurrent map read+write is a fatal panic. Snapshot them under gw.mu. Copy
state.cancel inside the lock before invoking it. Make pairing.json RMW atomic
under a package lock. In boot, connect_tool_source spawned the MCP subprocess
under the connector lock, blocking parallel connects; run the connect callback
off-lock.
withStderr reaped the child with an unbudgeted cmd.Wait while holding callMu:
a surviving grandchild that keeps the stderr pipe open wedged every future
call on the transport (close() already budgets exactly this wait). Share its
5s budget via waitWithBudget. cachedShellPATH ran up to ~6s of login-shell
probes under its lock and re-probed empty results on every spawn; probe
off-lock with an in-flight channel and cache completed empty results.
pluginpkg state RMW gets a process lock and atomic writes.
Rewind truncated the live message slice in place, racing History/Save readers
on other goroutines; use Session.Snapshot/Replace and a lock-guarded Len().
Compact lacked the running-turn guard Rewind/Branch have, letting a manual
compact rewrite the log under the run loop. Run's turn++ was unlocked. The
branch-meta sidecar RMW helpers (UpdateSessionMeta, in-flight markers,
model/touch) now hold the per-path save lock so concurrent writers can't drop
each other's fields. Document the promptMu/sink.Emit contract.
LoadForEdit+SaveTo is not atomic: concurrent editors (bot auto-session
persistence vs settings saves) each load, mutate, and save their own copy,
silently dropping the other writer's fields. Add a process-wide edit lock
and move botruntime's rememberPersistMu onto it so every editor shares one
serialization point.
Resolves the desktop/settings_app.go conflict against the already-
merged #5931 (39270df55, 'Fix provider-key rebuild lease handling'),
which independently refactored rebuild()/rebuildSetting() around the
same resumeWithFreshSystemPrompt(ctrl, carried, path) call this PR
introduces.
Keeps origin/main-v2's structure for rebuildSetting(): it defers
oldCtrl.Close() until after the new controller's resume succeeds
(lease-safety fix), which this branch's older pre-refactor hunk did
not have anywhere else to fall back to. The resumeWithFreshSystemPrompt
call itself is equivalent on both sides — this PR's fix to that
function's implementation (desktop/session_prompt.go) is untouched by
the merge and carries forward unchanged.
checkSnapshotWrite now reports when the on-disk transcript already
matches the pending write byte-for-byte. save() skips the rewrite and
revision bump in that case instead of unconditionally recording a new
revision.
Without this, a routine no-op autosave (e.g. an idle tab's shutdown
snapshot) advanced the on-disk revision under any other runtime that
had cloned this file's persistence baseline via CloneWithMessages(If
Compatible). That runtime's next legitimate append then read as a
stale-runtime conflict and forked a spurious recovery branch — the
same symptom this PR fixes, reintroduced through a different trigger.
A load-time repair (LoadSession normalizing dangling tool calls, empty
names, truncated args) still forces a real write even when digests
match, so the repair actually reaches disk instead of being skipped as
a no-op forever.
Store pasted image data URLs in the browser dev mock so composer thumbnails and image previews render the same bytes that were pasted. Keep a valid fallback preview for mock attachment paths.
Co-authored-by: SivanCola <[email protected]>
Ensure edit-mode image thumbnails open the shared image viewer and limit pointer/hover styling to thumbnails with an image click handler. Extend the desktop image capability test to cover composer, sent-message, and edit-mode previews.
Co-authored-by: SivanCola <[email protected]>
- add per-bot access and runtime overrides for QQ and IM connections
- replace the bot overview entry with a direct four-channel manager
- keep primary bot controls between connection summary and access settings
Co-authored-by: SivanCola <[email protected]>
Add ImageViewer component that shows a full-size image preview in a
portal overlay (following MermaidDiagram fullscreen pattern). Both the
composer thumbnail chips and sent-message image attachments are now
clickable — opens a lightbox-style overlay with the full image.
- New ImageViewer component with createPortal to .chat-pane
- Message.tsx: image attachments get onClick + keyboard accessibility
- ComposerContextCard: new onImageClick prop for thumbnails
- Composer.tsx: integrated ImageViewer with tooltip hint
- CSS: hover scale(1.08) on thumbnails, ImageViewer overlay styles
- i18n: imageViewer.clickToPreview/closePreview/title in en/zh/zh-TW
Closes#5832
Store queued reaction cleanup callbacks on the gateway instead of controller state so reactions added before controller creation still get removed.
Carry queued cleanup callbacks into the merged follow-up turn so the pending reaction is removed when that turn finishes.
Co-authored-by: SivanCola <[email protected]>
AddPendingReaction now returns a cleanup function that the gateway
defers in runTurn. When the turn finishes (or errors), the OnIt
emoji is automatically removed via MessageReaction.Delete API.
Minimal interface change: return type becomes (func(), error) instead
of error. QQ/WeChat are unaffected — they don't implement this
interface.
Use a single expand/collapse control for pasted message blocks and remove the separate preview button to avoid overlapping interaction semantics.
Co-authored-by: SivanCola <[email protected]>
Keep pasted message cards mounted after expansion so users can collapse the full text back to the compact card state.
Co-authored-by: SivanCola <[email protected]>
Ensure message pasted block parsing recognizes Traditional Chinese labels and add a focused regression test for Simplified Chinese, Traditional Chinese, and English labels.
Co-authored-by: SivanCola <[email protected]>
Pasted long text in the desktop input is folded into a collapsible
label (e.g. '[已粘贴文本 #2 · 31 行]'). When the message is sent
the bubble previously showed only that label, hiding the actual
content from the user even though the model received it.
This change adds per-block preview/expand controls inside the
user-message bubble, modelled after the composer's existing paste
block UI:
- A bordered card with the label on the left and Eye (preview) +
Expand buttons on the right
- Preview toggles a <pre> showing the raw pasted content
- Expand irreversibly replaces the label with the actual text
inline at its original position in the message
Closes#5863
Refs #5662, #5649, #4543.
This empty follow-up commit records commit-level GitHub attribution for contributors whose source PR work was kept or adapted by the #5662 math rendering consolidation.
Co-authored-by: drafish <[email protected]>
Co-authored-by: lightfront <[email protected]>
Co-authored-by: light-front-theory <[email protected]>
Update the OpenAI empty-parameters regression expectation after #5883 so it matches the stricter canonical schema shape.
Co-authored-by: SivanCola <[email protected]>
Adapted from #5048 by @SEPURI-SAI-KRISHNA. Keep the explicit-zero temperature serialization fix while preserving caller-controlled DeepSeek temperature behavior.
Co-authored-by: SEPURI-SAI-KRISHNA <[email protected]>
When switching projects in the desktop app, the AI previously had no explicit provider-visible cue for the active workspace when project memory was absent or similar across roots. Add a Current workspace line to the cache-stable boot prompt after the language policy so each controller carries its resolved workspace root.
The existing no-memory prompt invariant now strips the boot-time workspace line, and a new regression test verifies two different WorkspaceRoot values produce different system prompt workspace lines.
Cache-impact: low; adds one stable per-session workspace line to the provider-visible system prompt. Different project roots get distinct prompt prefixes, which is expected for project-scoped sessions.
Cache-guard: go test ./internal/boot
System-prompt-review: required; adds "Current workspace: <root>" after LanguagePolicy and before token-economy/environment sections.
Refs #5792
The protection for remember/forget tools already exists on main-v2
(requiresFreshApprovalTool + policy.Ask rules in newInteractiveGate),
but only Auto mode had a dedicated test. This adds an equivalent test
for YOLO/full-access mode to prevent regression.
Cache-impact: none — test-only change.
Cache-guard: go test ./internal/control/ -run "TestToolApprovalModeYoloForcesMemoryAskRules"
Refs: #5753
- Window controls white box hidden when workspace panel closed
- Window controls dim (opacity 0.35) when command palette open
- Creation mode window controls height matches dock tools (40px)
- Topicbar buttons no longer pushed off-screen when workspace opens
- Creation dock tabs use flex layout to prevent overlap
- ContextPanel prompt/completion/reasoning tokens now real-time
- Cache hit/miss rate now real-time (usage precedence over info)
- Throttled background refresh (1s) during streaming for telemetry data
Documents the tokenizer's scope explicitly and hardens against three shapes
that would otherwise decompose incorrectly:
- heredocs (`cat <<EOF … EOF`): body isn't shell syntax but the tokenizer
can't tell where it ends without tracking the delimiter. Bail on any
unquoted `<<` and fall back to exact match.
- leading control operators (`&& ls`, `; ls`, `| grep`, `|| echo`):
malformed shell; refuse to decompose.
- process substitution (`<(cmd)` / `>(cmd)`): tracked as opaque via the
same paren-depth counter used for `$(cmd)`, so operators inside don't
split the outer command.
Also expands the package doc to enumerate known out-of-scope shapes and to
note that the hand-rolled tokenizer exists to avoid a full `mvdan.cc/sh`
dependency; the contract is `[]string` or `nil` for exact-match fallback,
so swapping in a real parser later is a local change.
golangci-lint (staticcheck QF1003) flagged the if/else if on the same
variable as a tagged-switch candidate. Same behavior, just the idiomatic
form the project's lint config expects.
Compound bash commands (`&&` / `||` / `|` / `;`) currently fall back to
whole-string matching in the rule table, so permanent approvals for atomic
commands never carry over to a compound of those same commands. #3882 reported
this and was closed as by-design because synthesizing a prefix from a compound
(`cd /path && …` → `Bash(cd /path:*)`) would silently authorize whatever
follows the operator.
This change takes a different route with the same safety posture: instead of
synthesizing new rules, decompose a compound at match time and check each
simple-command segment against the existing rule table independently. The
storage path is unchanged — `BashCommandPrefix` still returns nil for compound
subjects, so "always allow" still stores the exact literal on the persist
side. What changes is only the matching algorithm.
- Add `DecomposeBashCommand` (`internal/permission/bash_decompose.go`): a
hand-rolled tokenizer that splits on `;`, `|`, `||`, `&`, `&&`, and
newlines while treating single/double quotes, `$(…)`, backticks, and
backslash-escapes as opaque. `2>&1` / `>&2` are recognized as fd
duplication (not splitters). Unbalanced quotes/parens return nil so the
caller falls back to exact-string matching.
- Route `Policy.DecideSubject` bash path through `decideBashSegments` when
decomposition yields ≥2 segments. Precedence stays deny > ask > allow >
fallback: any segment hitting deny denies the whole call, any missing rule
turns the whole call into Ask, whole-call Allow requires every segment to
be covered by an allow rule or classified read-only by `shellsafe`.
- Effect: a user who has approved `Bash(git add:*)` + `Bash(git commit:*)` +
`Bash(git push:*)` (via the existing atomic prefix save path or manual
config) now passes `git add . && git commit -m … && git push` silently.
Out of scope for this pass:
- Redirect stripping (`2>/dev/null`, `> file`) inside individual segments —
`bashPrefixMatches` still rejects subjects with any shell syntax, so a
segment carrying a redirect misses even a matching prefix rule. Follow-up.
- Per-segment approval UI in the TUI. This change is match-side only.
Cache-impact: none — permission is called before any LLM request; nothing
about system prompt, memory prefix, tool schema, or provider serialization
changes.
Cache-guard: `go test ./internal/permission/... -count=1` (15 tokenizer
cases + 6 policy cases, all pass). `go build ./cmd/reasonix` and `go vet ./...`
clean.
Refs: #5702, #3882, #3815
registry.reasonix.io was never provisioned; the folded registry API now
serves from the crash worker. Default the skills page at crash.reasonix.io
so discovery/publish work; PUBLIC_REGISTRY_ORIGIN still overrides.
Resolve docs/GUIDE.md by keeping both the extra_body configuration docs and the latest Desktop hooks guide section.
Co-authored-by: SivanCola <[email protected]>
Give the owner one admin console instead of a standalone, undeployed
registry worker. The registry source moves under the crash worker as a
second data plane (REGISTRY_DB); its public + publish API is served by
delegating to the folded Hono app, and a server-rendered Community tab
moderates user-published skills/MCP (approve/reject/hide/verify) gated on
the unified dashboard admin role, writing the shared audit log.
Requires a reasonix-registry D1 (schema in registry-schema.sql) before
the auto-deploy runs; the id in wrangler.toml is a placeholder.
Dashboard loads blocked on a fresh id.reasonix.io /me round-trip and ran
all nine aggregation queries regardless of the module shown. Cache the
resolved identity per token hash (60s), fetch only the active module's
data (metric_users COUNT(DISTINCT) now hits only /stats/preferences), and
index the groups/pings/metrics columns the filters and facets scan.
Releasing a left-drag selection in the TUI transcript now copies it to
the clipboard automatically (native terminal convention), with a
transient status-line "copied to clipboard" notice shared by the
mouse, right-click, and Ctrl+C copy paths.
Add "/mouse" to release mouse capture back to the terminal on demand,
restoring its native click-drag selection and right-click context
menu at the cost of in-app drag-select, the scrollbar, and
wheel-scroll while it's off. REASONIX_DISABLE_MOUSE=1 starts every
session with it off.
Reuse the site account client (scripts/auth.js) to show sign-in state in the nav, gate publishing behind a signed-in session, and reveal a moderator review queue (approve/reject) to admins. Adds currentAccount()/accountLogout() helpers to auth.js for account-aware pages.
Publishing now requires a verified email; non-admin submissions land as 'pending' and stay out of the listing, detail, and activity feed until an admin approves them. Adds /v1/admin review endpoints (approve / reject / hide / verify) gated on the admin role. Versions of an already-approved package stay live; admins publish straight to active.
Browse, search, sort, and publish community skills and MCP servers at /skills, with a live activity feed. Client-fetches the registry (PUBLIC_REGISTRY_ORIGIN, defaults to registry.reasonix.io) and links it from the home nav.
Discovery layer over the existing install_source rail. Hono + D1: list/search published skills and MCP servers, record installs, and serve a live activity feed. Identity is delegated to id.reasonix.io; the worker stores only metadata and a source pointer, and never fetches or executes package content.
The /community pages shipped English-only; the rest of the site is
bilingual. Add the same EN/中文 toggle: a lang switch in the community
nav, .l-en/.l-zh spans for all static chrome, and the shared reasonix-lang
choice so the language carries over from the main site. The client
renders dynamic labels (categories, times, counts, badges) as toggled
spans too, and localizes the anti-spam/gate errors by code — so the whole
forum reads in either language.
Categories with a high min_trust_to_post (e.g. Announcements at 4) were
unreachable: admins/moderators seed at trust 2, so the min_trust and
new-member link checks blocked them. Staff now bypass the trust gates
(and the rate limiter) while still needing a verified email.
The human-facing forum, built on the approved design and unified into the
main site: Astro pages at reasonix.io/community (discussions home, thread,
new topic, guidelines). They're client-rendered against the
forum.reasonix.io API and gated on the shared id.reasonix.io session (the
rxid cookie is sent cross-subdomain), so there's no second login and no
separate app to deploy — it ships with the site via pages.yml.
Anti-spam surfaces here too: the composer only appears when signed in, and
the API's gate errors (verify email, links-restricted, rate limits) are
shown inline. Reuses the site's design tokens with self-hosted fonts.
API base is build-configurable via PUBLIC_FORUM_API (defaults to
https://forum.reasonix.io).
* feat(forum): community forum API with shared identity + anti-spam
First slice of the Reasonix Community forum: a new reasonix-forum worker
(forum.reasonix.io) backed by its own D1. Identity is the shared
id.reasonix.io session — it reads the .reasonix.io rxid cookie and
resolves it via /me (Bearer), the same pattern the crash dashboard uses —
so there's no second login.
Anti-abuse is built in at the identity/trust gate rather than per-post
content scanning, keeping it cheap: posting requires a verified email;
new (trust 0) members can't post links; per-trust daily caps + a per-IP
limiter throttle flooding; and N distinct flags auto-hide a post pending
moderation. Endpoints: categories/topics/posts read, create topic, reply,
and flag.
Bootstrap (out of band): wrangler d1 create reasonix-forum, apply
schema.sql + seed.sql, fill database_id. Frontend, deploy CI, and the
moderation queue land in follow-ups.
* ci(forum): provision D1 + auto-deploy forum.reasonix.io on merge
Fill the reasonix-forum database_id (D1 created) and add the deploy
workflow so merging to main-v2 ships forum.reasonix.io, matching the
accounts/crash worker pattern.
The crash dashboard ran its own account system — a separate D1 users/
sessions table with its own password login — which fragmented identity
from id.reasonix.io. Make it consume the shared identity instead: it
resolves the .reasonix.io rxid cookie via id.reasonix.io/me (Bearer) and
keeps only a per-dashboard role in a new `access` table. Existing roles
migrate by email (migrate-access.sql); ADMIN_EMAILS force-promotes the
owner so a missed migration can't lock them out. Sign-in redirects to the
shared login; sign-out revokes the shared session.
Also: accounts /auth/logout accepts a Bearer token (so crash can revoke
server-side), and the site login honours a next to any *.reasonix.io.
The accounts API (id.reasonix.io) shipped without a web frontend, so no
one could register or sign in. Add Astro pages that consume the JSON API:
login (handles the post-verify ?verified banner), register, forgot/reset,
a gated account page (profile edit, password change, sign out, delete),
and a device-approval page — the human half of the CLI/desktop device
flow (enter the terminal's code, review, approve/reject). Reuses the
site's existing design tokens; all copy is bilingual like the rest of the
site. The API base is build-configurable via PUBLIC_ACCOUNTS_API and
defaults to https://id.reasonix.io.
The worker only did cookie-based web login, so the CLI/desktop had no way
to authenticate without a browser redirect. Add an RFC 8628-style device
flow: /device/start issues a device_code (polled) plus a short user_code
the human approves at APP_ORIGIN/device while signed in; /device/poll
returns the minted cli session on the winning atomic claim. Only the
device code's peppered hash is stored, and the session token is created at
claim time, so it never lands in the DB. Session middleware now resolves a
Bearer token in addition to the cookie, so the one sessions table serves
both web and cli kinds.
The worker reads env.RESEND_API_KEY at runtime from Cloudflare, but the
key was only stored as a GitHub Actions secret — where wrangler deploy
never pushes it — so mail silently fell back to the console stub. Add a
deploy step that pipes the RESEND_API_KEY repo secret into
wrangler secret put, giving the mail key a single source of truth and
removing the need for local wrangler auth. Guarded so a missing secret
just skips (stays in stub mode) instead of setting an empty key.
* ci(accounts): auto-deploy id.reasonix.io on merge to main-v2
The accounts worker shipped without a deploy pipeline — only the crash
worker had one, so id.reasonix.io could never go live from CI. Mirror
deploy-crash-worker with the repo's standard pnpm setup (action-setup@v6
+ setup-node cache), path-filtered to workers/accounts/**. Migrations
and secrets stay manual and out of band, same as the crash worker.
* fix(accounts): send from the verified send.reasonix.io domain
MAIL_FROM was [email protected], but only send.reasonix.io is a
verified Resend domain (the apex isn't). Resend rejects sends from an
unverified from-domain, so every verification/reset email would 403.
Send from the verified subdomain instead.
Restore saved topic titles from session metadata or desktop session title sidecars when the topic title index is missing, while preserving manually defaulted topic titles. Also removes the unrelated OpenAI stream change from this PR diff.
Co-authored-by: SivanCola <[email protected]>
Move the fresh-system-prompt resume helper into shared desktop code and apply it when model, effort, or token mode rebuilds carry existing history.
Refresh existing system messages when restoring pinned/topic sessions, while preserving legacy user-only transcripts as-is.
Add regression coverage for global AGENTS.md being picked up after a desktop model switch.
When a new desktop tab is created, createEmptySessionFile writes an
empty placeholder .jsonl file. Later, buildTabControllerWithContext
loads it via LoadSession — which returns a non-nil &Session{} (zero
messages) — and Resume replaces the executor's real session (built by
boot.Build with the full system prompt) with the empty one. The
subsequent snapshot writes only user/assistant/tool messages,
dropping the agent identity contract permanently.
Root cause: desktop/tabs.go loadPinnedTabSessionWithPreload treated
a 0-message session as a valid resume target.
Fix: treat an empty session the same as a missing file — return nil
so the caller keeps the live session built by boot.Build.
Adds a defensive guard in controller snapshot(): refuse to persist
a session that has content but no system message, logging a warning
so the identity-loss invariant is enforced regardless of code path.
Adds Session.HasSystemMessage() and focused tests.
Add the new ui.cursor_shape option to the user guide, configuration paths docs, and configuration spec so the formal docs match the PR behavior.
Co-authored-by: SivanCola <[email protected]>
Builds on the in-flight hydration coalescing approach from #5580 while scoping ready events to the tab that finished building and keeping visible transcript hydration independent from slower ancillary calls.
Co-authored-by: GTC2080 <[email protected]>
Reset transcript-owned shell output indexes when /cls or /clear clears the visible TUI buffer, so Ctrl+B cannot rewrite or panic on stale transcript slots. Document the clear-screen shortcut in the CLI guide.
Co-authored-by: SivanCola <[email protected]>
Render ui.cursor_shape through config save paths and guard the textarea cursor-shape mapping with focused tests.
Co-authored-by: SivanCola <[email protected]>
QQ Bot (as of v1.13.1) does not support QR code scanning for
connection setup. It must be manually configured with App ID and
App Secret. This corrects the documentation to match the actual
implementation.
Fixes#5540
The snip strategy was a name-keyed switch in prune.go: a tool rename
silently dropped its result to the generic default, with nothing to
catch the desync. Move the geometry onto the tools themselves.
- Add tool.SnipHint value + optional tool.SnipHinter interface; the
large-output built-ins (read_file, web_fetch, bash, grep, glob, ls,
lsp_*) implement it with the same geometry the switch used.
- prune.go resolves the strategy through the registry (SnipHinter),
falling back to a ReadOnly-tiered default: read-only output keeps a
long head/short tail, side-effecting output keeps both ends evenly.
- TestEveryBuiltinDeclaresSnipStance forces every registered built-in to
either implement SnipHinter or opt into the default explicitly, so a
new or renamed tool fails CI instead of silently desyncing.
- Cover registry-resolved hints and the ReadOnly-tiered fallback.
go test ./internal/agent/... ./internal/tool/... ./internal/lsp/... ./internal/boot/...
go vet ./internal/agent/... ./internal/tool/... ./internal/lsp/...
ACP spec ships HTTP/SSE MCP `headers` as an array of {name,value} objects
(see https://agentclientprotocol.com). Reasonix's `MCPServerSpec.Headers`
declared the legacy `map[string]string` shape, so spec-compliant clients
like Paseo — which always emits `headers` as an array, even when empty —
got rejected at the very first `session/new`:
json: cannot unmarshal array into Go struct field
MCPServerSpec.mcpServers.headers of type map[string]string
The sibling `Env` field already solves this exact problem with a custom
`MCPEnv` type that tries the official array shape first and falls back to
the legacy map. Mirror that for headers via a new `MCPHeaders` type, and
extract the shared parse into `unmarshalNameValueMap` so both fields share
one implementation. Existing wire formats keep working unchanged.
Refs: #5553
Add [ui] cursor_shape option (block|underline|bar) to let users choose
the textarea cursor shape. Defaults to underline to avoid CJK wide-char
rendering artifacts caused by Bubble Tea's real terminal block cursor
overlapping double-width characters during edit operations.
- UIConfig.CursorShape field + UICursorShape() normalizer
- cliCursorShape package var set from config at startup
- applyTextareaTheme() maps config value to tea.CursorShape
Add /cls slash command and Ctrl+L keyboard shortcut that clears the TUI
transcript display while preserving the LLM conversation context. Unlike
/clear and /new (which both reset the agent session), /cls only clears
the visual buffer.
- /cls command handler in runSlashCommand
- Ctrl+L key binding (idle-only, gated by m.state != tuiRunning)
- Tab completion and /help registration
- i18n messages for en/zh/zh-TW
The stream-reconnect loop calls sendChunk(ctx, out, error) with the
caller's context after readStream returns context.Canceled. Since
the context is already cancelled, sendChunk's second select races
<-ctx.Done() against out<-chunk (both ready), dropping the error
~50% of the time — the caller sees a nil error instead of
context.Canceled.
Fix: use context.Background() for the final non-reconnectable error
send, so the error chunk is always delivered to the reader.
Fixes TestStreamCancelDoesNotReconnect flaky failure under -race.
Preserve the same run_skill skill-name subject when completed tool calls are restored from archived history, where the frontend no longer has full args available to recompute it.\n\nRefs #4740.
Update Agent to use TaskClassifier:
- Add classifier field and UseMemoryCompilerLLMClassification option
- Initialize classifier in New() (LLM or heuristic based on flag)
- Completely skip Memory v5 for chat inputs (not just injection)
- Clear cache on session boundary
Add REASONIX_MEMORY_COMPILER_LLM_CLASSIFICATION env var (default: false).
When disabled (default), uses heuristic classifier. When enabled, uses LLM
classifier with heuristic fallback on error/timeout.
Add TaskClassifier interface with two implementations:
- llmClassifier: uses provider with 2s timeout, fallback to heuristic
- heuristicClassifier: improved keyword matching + file reference detection
Add classificationCache with LRU eviction (100 entries max) and 5min TTL.
Includes comprehensive unit tests for both classifiers and cache behavior.
Tool result memory nodes (Influence == "evidence") represent transient
internal graph state like "bash succeeded" or "read_file succeeded".
These are noise when shown as "Memory v5 compiler" citations every turn.
The memory graph still records tool results for model-side learning and
constraint generation; only the user-facing citation display is filtered.
Fixes#5468
In single-surface (creation/workbench) mode, switching between projects
removes the previous tab via keepOnlyVisibleTab. When the user switches
back, a new tab is created and the topic title is loaded from the
persisted topic-titles file. However, if the topic-title file has no
entry for this topic ID (e.g., due to path normalization differences
on Windows), the title would incorrectly show the default '新的会话'.
This fix adds a fallback: when the topic-title file has no entry and a
valid session path is known, extract the title from the session and
persist it back to the topic-titles file for consistency.
Address Codex review feedback:
- Reset prompt history navigation state in the short-paste branch
so context-menu pastes don't corrupt prompt-history editing
- Normalize \r\n to \n for caret position calculation (Windows)
The paste handler previously relied on the browser's default paste behavior
for short multi-line text (<20 lines), only preventing default for long
pastes that get folded into collapsible blocks. This creates a race condition
in React's controlled-input reconciliation: a concurrent re-render can
overwrite the textarea DOM value with stale state before onChange fires,
losing the pasted content entirely (WebView2 / Windows).
Fix: always preventDefault() in the paste handler and insert text through
React setText() — both for folded long pastes and raw short pastes — so
the value is always driven by React state, not the DOM.
Also switch submit() to read from textRef.current / attachmentsRef.current
/ workspaceRefsRef.current / sessionRefsRef.current instead of state
variables, providing a second defense layer against stale closures.
Track active bot sessions by connection so live approval mode changes reach hashed session keys, propagate inherit selections to the running gateway, and keep the settings panel state in sync after the lightweight save path.
Co-authored-by: SivanCola <[email protected]>
Treat a tool result whose name does not match its paired tool call as malformed so SanitizeToolPairing takes the slow path and backfills the name before provider serialization.
Add regression coverage for saved sessions that carried a tool result with an empty name.
Co-authored-by: SivanCola <[email protected]>
Parse EDITOR/VISUAL values into argv with shell-style quoting and backslash escaping while still avoiding shell execution. This preserves common editor commands such as emacsclient -a '' and quoted editor paths without reintroducing command injection risk.
Co-authored-by: SivanCola <[email protected]>
Remove the now-unused private wrapper left behind by the MCP timeout options path so golangci-lint passes on the PR merge ref.
Co-authored-by: SivanCola <[email protected]>
When the goal FSM transitions to completed (via proper completion or the
second [goal:complete] override in non-strict mode), force-complete any
remaining incomplete canonical todos and emit a synthetic todo_write
event so the frontend panel reflects the final state.
Bug: the todo panel stayed stuck showing the initial todo_write state
('0/5 进行中') even after the model text said '全部完成 ✅', because
no synthetic todo_write event was pushed to the frontend.
- Add completeRemainingGoalTodos() to turn_orchestrator.go
- Add goalCompleteNotice constant to goal.go
- Tests: end-to-end override, empty list, all-completed, mixed, empty-string status
When multi-line text is pasted during model reasoning (tuiRunning), it
is folded to a "[Pasted text #N · M lines]" placeholder. Pressing Enter
queues this placeholder text directly into pendingInterject, and the
pastedBlocks are immediately cleared. When TurnDone fires, the raw
placeholder is sent to the model instead of the actual pasted content.
Expand paste blocks with expandPastedBlocks() before storing in the
interject queue, matching the normal send path at line 1172.
Fixes#5423.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
A new Cloudflare Worker (Hono + zod + D1) served at id.reasonix.io that
provides the self-owned account system for reasonix.io: email/password
registration with verification, login/logout, password reset, sessions,
and public profiles. Backend API only — no frontend pages yet.
- Layered structure: routes / db repositories / auth primitives / pluggable
email / middleware / lib, kept separate from the internal crash dashboard.
- Sessions store sha256(pepper:token); the raw token lives only in the cookie.
- Registration is enumeration-safe; login and password reset return generic
messages. Email tokens are single-use via UPDATE ... RETURNING.
- Schema reserves future growth: nullable password_hash for OAuth identities,
and sessions.kind ('web'|'cli') for a later desktop/CLI device login.
- Email is pluggable: a console stub for local dev, Resend for production.
- PBKDF2-HMAC-SHA256 at 100k iterations (the Cloudflare Workers maximum).
Keep topic-backed session results titled by topic while surfacing the saved session title or preview as the visible hint.
Co-authored-by: SivanCola <[email protected]>
Keep topic-backed Cmd+K entries displayed by topic title while preserving session titles and previews as searchable keywords.
Prefer explicit session renames in history before falling back to topic titles, and cover both contracts with a focused frontend test.
Co-authored-by: SivanCola <[email protected]>
Plan mode let the model run only a short hard-coded bash allowlist (git
status/diff/log/show, ls, cat, …), so read-only git commands the user needs to
plan with — git rev-parse, describe, reflog, for-each-ref, cat-file, ls-tree,
plus go env, npm view, docker ps, kubectl get — were blocked even though the
permission auto-approve path already trusts them. Two independent read-only
command tables (planmode.safeBashCommands vs permission's readOnlyBash tables)
had drifted.
Unify them on one source of truth: a new internal/shellsafe package holds the
canonical read-only command/prefix tables + the shell-syntax guard. Both the
permission auto-approve path and the plan-mode gate classify command membership
against it, so they can't drift again.
Each consumer keeps its own ARGUMENT rigor on top — plan mode stays
intentionally stricter than auto-approve (it still refuses git --output, git
grep --open-files-in-pager, find -exec, go list -mod=mod, …, and any command
with shell operators / background execution). So this widens the read-only
command set without weakening either gate's write-capable-arg checks.
Tests: shellsafe classifier table (read-only vs write/shell-syntax);
TestPlanModeAllowsSharedReadOnlyBashSet (the #5341 git/tooling set is allowed,
write-capable + shell-syntax + write-args still blocked). go build/vet/test
./internal/... green (57 packages); existing permission/planmode tests
unchanged-pass.
P0-2 of the multi-session switch race: when a tab's runtime is detached for
replacement (single-surface session switch / controller replace / session
remove), the code wrote sink.ctx = nil DIRECTLY. That:
1. Data-races s.ctx — emitRuntimeEvent reads it through the locked s.context(),
but these teardown sites wrote it without the mutex.
2. Leaves the asyncRuntimeEmitter's ALREADY-QUEUED events to flush. The
detached/backgrounded controller's in-flight stream events then reach the
frontend and land on the tab that has since been rebound to another session
— the 'AI 不断输出,永不结束' on the now-visible session in #5352.
Route every sink context mutation through the locked accessors: teardown sites
use clearContext() (locked nil + runtimeEvents.Clear() to drain the queue),
re-attach sites use setContext(). emitRuntimeEvent already no-ops on a nil ctx,
so once cleared no new events enqueue either. This unifies all six bare ctx
writes onto the two accessors that were already used elsewhere (tabs.go:1839/
1976/2059), so there is one consistent teardown path.
Test: TestTabEventSinkClearContextStopsEmission — after clearContext the sink
reports a nil context and emitRuntimeEvent does not reach the runtime bridge.
Pairs with #5404 (serialize tab switches): #5404 stops concurrent switches from
racing the activation; this stops a superseded binding's queued events from
corrupting the new session. Desktop Go can't build locally (cgo); relies on the
CI desktop job.
Topic / blank / sidebar-IM / resume-session navigation already runs through
the last-click-wins coalescing scheduler (openTopicCoalescing), but the tab-bar
click path (handleTabChange / handleTabsClose) called switchTab() directly,
fire-and-forget. Rapidly switching between two running sessions therefore ran
two switchTab() calls concurrently, racing the backend SetActiveTab /
confirmBackendActiveTab ordering — a primary trigger for #5352 (events and
hydration landing on the wrong session: '用户输入变了', 'AI 不断输出').
Route tab switches through the same enqueueNavigationRequest primitive (its own
refs) so they serialize and last-click-wins, exactly like the other navigation
entry points. switchTab's loadSessionDataForTab is already seq-guarded; this
serializes the backend activation around it. Unifies every navigation entry
point on one scheduler.
Test: rapid A→B→C tab switches run serially (A,C — middle coalesced) with no two
run()s overlapping (no concurrent SetActiveTab). tsc + the tab test suites green.
Follow-up (not in this PR, needs backend verification): wire events are routed
by tabId only (useController.ts:1156) with no session-generation guard, so a
detached/rebound controller's already-queued events can still land on the tab
(desktop/tabs.go detach sets sink.ctx=nil but does not clear the queued
emitter). A session-identity/epoch stamp on wire events + a frontend drop of
mismatched events would close the remaining stale-event window.
Confirm untrusted MCP read-only hints at plan-mode execution time and persist approved raw tool names back to the owning MCP config source.
Keep provider-visible MCP tool names and schemas stable while updating desktop, CLI, and docs language around pre-trusting read-only tools.
Wait for the controller TurnDone event before reading the recording runner output. This gives the async send path a real happens-before edge under go test -race instead of relying on a fixed sleep.
Verify that NewConversationEachRun execution returns the newly created topic ID, submits the prompt, and records the in-flight pending topic so later task merges can persist the latest conversation.
Co-authored-by: SivanCola <[email protected]>
The executeTask method now checks t.NewConversationEachRun before deciding
whether to create a new conversation or reuse the existing topicId.
Previously the field existed in the type and frontend but was never read
by the backend, making the toggle ineffective — it always reused the
same topicId even when 'new conversation each run' was selected.
New behavior:
- NewConversationEachRun=true → always creates a new topic every run,
and stores the new topicId back so the task list always points to
the latest session.
- NewConversationEachRun=false → reuses the existing topicId (same
as before).
- TopicID always stores the most recent conversation's ID, so if the
user switches modes, the last conversation is still the target.
Also adds the missing struct fields ApprovalMode, TimeWindowStart, and
TimeWindowEnd that already exist in the frontend type but were never
persisted to disk because the Go struct didn't have fields for them.
When multiple goal/long tasks run and the user switches between conversations,
already-sent user messages showed a wall of raw <memory-compiler-execution>
JSON ('乱码') instead of the prompt text.
Root cause: the pre-fix goal loop (#5342, fixed in #5387) re-compiled the
model's echoed contract every turn, so a user message's source_event nested
another full <memory-compiler-execution> block, many levels deep on long
runs. The display unwrap (agent.unwrapMemoryCompilerExecution) only peeled a
fixed one/two levels and its non-greedy regex couldn't complete a
dangling/truncated block, so the reload-on-switch path surfaced raw contract
JSON. (#5387 stops NEW accretion; existing sessions still carry it.)
Backend: unwrap to a fixpoint (peel nested layers up to a bound) and, as a
final guard, drop any residual/dangling block from its opening tag onward so
raw contract JSON is never shown — the user's text precedes it.
Frontend: extract the inline Message.tsx strip into a tested
lib/memoryCompilerDisplay helper and harden it the same way (remove complete
blocks + cut dangling) as a display-boundary safety net.
Tests: backend reproduction with 6-level nesting + a truncated block (was
leaking, now clean); frontend tsx unit test for the strip helper.
go build/vet/test ./internal/... green (56); frontend tsc + tsx test green.
Two independent causes made the goal loop re-inject the same
memory_v5_execution_contract every turn and never terminate:
1. The synthetic goal-continuation turn ('Continue pursuing the active
goal. …') was Memory v5-compiled like a real user turn. The model
echoes the compiled contract, and the goal loop re-injects it, so the
contract accretes and the loop never converges.
2. classifyStrategy / strategy preconditions matched 'ui' as a bare
substring, so 'pursuing' (and 'build', 'quiet', …) misrouted unrelated
turns to frontend-visual-verify.
Fix 1 is done at the layer that knows a turn is synthetic. The turn
orchestrator already routes every controller-injected message and owns the
canonical synthetic-message registry (IsSyntheticUserMessage /
syntheticPrefixes). It now marks synthetic turns with a new
agent.WithMemoryCompilerSkip(ctx) instead of supplying a compiler source,
and Agent.Run honours it. This covers ALL synthetic turns uniformly
(goal-loop continuation, plan-approved execution, future additions) rather
than special-casing one English prefix inside the agent.
Fix 2 adds strategyPreconditionMatches: preconditions of <=2 runes (e.g.
'ui') match only on whole-token boundaries; longer ones keep substring
semantics.
Tests: agent skips compilation when the turn is marked synthetic; the
orchestrator marks goal-continuation (not genuine user) turns; 'ui'
token-matches real frontend goals but not 'pursuing'; goal-continuation no
longer classifies as frontend. go build/vet/test ./internal/... green (56).
The Memory v5 control subsystem — internal/controlplane, internal/equilibrium,
internal/controlsemantics (~1600 LOC of oscillation/consensus/convergence/
entropy/anti-centralization math) — reached the model-facing contract through
exactly one value: the per-turn exploration rate (clamped to [3,12]) fed to a
deterministic hash threshold in selectStrategy. Measured over a varied 30-turn
session, it changed the selected strategy on ~1/30 turns versus a constant
rate; it was an elaborate 'explore less when unstable' switch with no other
contract-visible effect (its rich telemetry only fed a closed observer loop).
Replace controlPolicyForState with a legible heuristic that derives the policy
from the same signals the control-theory consumed as inputs — oscillating
strategy history, recent failures/drift (unstable), sustained clean successes
(stable), and accumulated semantic shift — mapping them directly to
exploration rate, gain, and mode. The signal helpers (equilibriumStable/
Unstable/Oscillating, semanticShiftSignals, hasDrift) are kept; only the math
tower between them and the rate is removed.
Also drops the now-dead ControlReports history machinery (its only reader was
the control-plane decision call) and decouples the compression report's two
len(ControlReports) telemetry counts.
Behavior change (unlike the prior dead-code removals): the exploration rate is
now derived by the heuristic. The documented control-policy semantics are
preserved and asserted by the existing tests — stable widens exploration,
unstable/oscillating/semantic-shift damp it, gain raises/lowers the mutation
cooldown. go build ./..., go vet, go test ./internal/... green (56 packages).
Net -2220 lines.
cloneNoisyRefs (used only by the removed production snapshot/restore path)
and the assertCausalEdge test helper (used only by a removed production
hardening test) are now unused. Caught by golangci-lint's unused linter,
which go vet/gofmt do not flag.
The Memory v5 production-hardening apparatus — internal/runtime/{canary,
resource,sandbox,snapshot,rollback} plus ~24 glue funcs in runtime.go — ran
every turn to build a ProductionHardeningTrace: a budget 2-phase-commit
ledger, a canary behavior diff, atomic snapshots written to r.dir, and a
rollback evaluator. None of it was consumed. No UI, metric, event, or the
compiled contract reads ProductionHardening, and st.Production is never read
by buildIRWithPolicy / the model-facing contract path. The one thread out of
the loop (canary attribution -> PrimaryCauses on the causal-compression
report) was only stored in st.CompressionReports, which only the observer
loop reads — never the contract. A closed telemetry loop: a goroutine + CPU +
periodic disk writes every turn for data nothing acted on.
Remove the runtime-OS packages, the production-hardening glue, the
ProductionHardeningTrace / ProductionState / RollbackRecord types and their
trace/state fields, and decouple compressCausalEdges from hardening (dropping
the now-dead PrimaryCauses field it fed).
No model-behavior change: buildIRWithPolicy never read st.Production, so the
compiled contract is byte-identical. Verified with go build ./..., go vet,
and go test ./internal/... (65 packages green). Net -2445 lines.
The Memory v5 compiler replaces each user turn with a compiled
<memory-compiler-execution> contract. It re-serialized the entire
PlannerIR every turn: a prose ir_explanation that merely restated the
constraints and memory references, the ranked available_strategies
candidate table, and each strategy's rejected/score/exploration-rate
control-loop state. On short user turns that inflated the user message
~20-50x and grew unbounded as the candidate table and explanation
accreted, with no token-saving counterpart anywhere in the code.
Project the model-facing IR onto a bounded contractIR that drops only
the non-actionable fields. The kept fields (source_event, constraints,
memory_references, execution_steps, risk_notes, selected strategy +
reason/mode) are passed through verbatim from the canonical IR, so the
guidance the model receives is byte-identical to before — verified by an
offline A/B over a warmed-up multi-turn session: same strategy, same
constraints, same 5 memory references; only ir_explanation,
available_strategies and rejected drop out. Per-turn contract ~737->390
tokens and a 12-turn session ~8170->4312 (-47%), with the unbounded
growth removed.
The full IR is still recorded on the execution trace for learning;
only the prompt copy is slimmed. Display unwrapping is unaffected
(planner_ir.source_event is preserved).
internal/system (architecture_lock.go, contract.go, v6_isolation.go,
release_guard.go) had zero importers anywhere in the module, confirmed by
`go build ./...` passing unchanged after removal. It declared a frozen
SystemContract and an ArchitectureLock that forbade control-plane and
equilibrium changes, yet nothing ever consulted it — pure dead weight that
also actively discouraged the very architecture work it claimed to guard.
No behavior change: nothing referenced the package.
Commit edacf1a8 (fix(tui): preserve native terminal selection) removed
v.MouseMode = tea.MouseModeCellMotion from the View() method. Without
mouse mode, terminals do not send MouseWheelMsg events; many terminals
translate wheel events to Up/Down arrow keys, which trigger input
history navigation (recallSubmittedInput) instead of viewport scrolling.
Restore MouseModeCellMotion on the alt-screen path only. The Termux
native-scrollback path (nativeScrollback=true) is unchanged because
mouse mode breaks soft-keyboard focus on Android.
The TUI already implements in-app text selection (left-drag to select,
right-click to copy, Ctrl+C to copy selection with visual highlight),
so the loss of native terminal selection is a conscious trade-off for
restoring essential wheel scrolling behavior.
pairToolResults already set Name on backfill (new) tool results but
left it empty when reusing an existing result. If a saved session
carried a tool message without Name, SanitizeToolPairing would not
repair it, and DeepSeek's API rejects tool-role messages with a
missing 'name' field (HTTP 400). GLM's endpoint does not validate
this, so the bug only surfaced after switching.
Now set r.Name = tc.Name on both the id-matched and positional
paths, matching the backfill behaviour.
- `/copy` opens a modal picker (↑/↓ Enter Esc) to select an assistant
response from the current turn and copy it to the clipboard.
- `/export` saves the full session as a Markdown file, merging
consecutive same-role messages and stripping thinking/tool calls.
- Both commands read `Content` only; `ReasoningContent`, `ToolCalls`,
and system/tool messages are excluded.
- Add i18n strings for en, zh-Simplified, zh-Traditional.
Previously, apiKeyEnvFromProviderName() returned 'CUSTOM_API_KEY' for any
provider name consisting entirely of non-ASCII characters (e.g. Chinese).
All such custom providers shared the same credential slot, so adding a new
one silently overwrote the previous provider's API key.
Now an FNV-1a hash suffix ensures each provider gets a unique slot like
CUSTOM_d39b9067_API_KEY.
Fixes#4708
A folded multi-line paste ([Pasted text #N · M lines]) was never seen by
the model — only the placeholder label was. The CLI passed the folded
label (the un-expanded `line`) as the controller's `raw` value. The Memory
v5 compiler (enabled by default) takes `raw` as the source_event of its
execution contract, and that contract replaces the whole user turn — so
the model's only view of the paste became the label, with the actual
content lost.
The Desktop path already passes the expanded text as `raw`; only the CLI
passed the label. Pass the expanded content (sentLine / msg.display) as
`raw` in both the non-refs and @-refs submit paths, matching Desktop.
`raw` still excludes resolved @-reference payloads (per the existing
TestRunCompilesMemorySourceFromUnexpandedContext intent) — it now carries
the expanded paste block but not inline file contents.
Add TestPasteFoldExpandOnSubmit, which asserts the memory-compiler
source_event holds the expanded paste (it fails before the fix:
source_event is only "[Pasted text #1 · 11 lines]").
Co-Authored-By: Claude <[email protected]>
The MCP trust controls must change a tool's execution/approval flags
(ReadOnly / PlanModeUntrustedReadOnly) without altering the model-visible
tool name or input schema — otherwise every trust toggle would break the
stable prompt prefix and drop the prompt-cache hit rate.
TestReadOnlyTrustDoesNotChangeModelVisibleSchema starts the same mock MCP
tool with and without ReadOnlyModelToolNames trust and asserts the tool name
and Schema() bytes are byte-identical across both, while ReadOnly() flips from
false to true and the trusted tool is no longer plan-mode-untrusted.
The Memory v5 compiler replaces a user turn with a <memory-compiler-execution>
contract rather than prefixing it; the original prompt survives only in the
contract's source_event. Adding the tag to the prefix-stripper made
UserPreviewText / SessionPreviewFromMessages collapse such turns to an empty
string, so a session whose first prompt was compiled showed a blank
history/sidebar preview (#5307 review finding).
Unwrap the contract to its source_event instead of dropping it like a prepended
transient block. source_event under planner_ir is the user's unexpanded prompt
(no @reference file blocks), so it is the right preview text. Falls back to a
top-level source_event for older shapes, and to empty only when no source_event
is recoverable.
Tests: cover unwrap (block-only, language-blocks-first, top-level fallback,
unrecoverable, non-contract) and the end-to-end SessionPreviewFromMessages path
with a compiled first turn. Verified they fail against the drop-the-block
behavior and pass with the unwrap.
Co-authored-by: SivanCola <[email protected]>
StripTransientUserBlocks and UserPreviewText had no direct test coverage,
so the memory-compiler-execution stripping this PR adds was an implicit,
fragile string contract. Add focused tests using the real block format
(<memory-compiler-execution>{json}</memory-compiler-execution>) the Memory
v5 compiler emits, covering the leading-block, language-blocks-first, and
block-only (compiled contract replaced the whole turn) cases, plus the
UserPreviewText path used for titles/previews.
Verified the tests fail against the pre-fix regex and pass with the fix.
Co-authored-by: SivanCola <[email protected]>
After stripTurnMessagesAfter truncates the session transcript, two states
remained inconsistent:
1. Agent.todoState was not updated. Session.Replace only swaps the message
slice; it does not call rebuildTodoState. So a cancelled turn that had a
successful todo_write left the in_progress item in the canonical task list,
visible via Controller.Todos(), goal readiness, and the task panel on the
next turn. Fix: expose Agent.RebuildTodoState() and call it after Replace.
2. A mid-turn autosave may have already written the partial transcript to
disk. snapshotActivityIfChanged skips the write when messageCount() returns
to startMessages (which it does after the strip), so the stale file was
never overwritten. A restart or session resume would reload the partial
turn. Fix: call Session.Save(path) directly after the strip to force a
flush, bypassing the HasContent() guard that snapshot() uses (so the clean
state is written even when the strip leaves only a system message).
Tests: extend TestTurnOrchestratorCancelStripsIncompleteTurn to assert
Todos() is empty after cancel, and add
TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk to verify the
on-disk transcript matches the pre-cancel message count.
Co-authored-by: SivanCola <[email protected]>
A turn that blocks inside RunTurn waiting for interactive approval must not
wedge the per-adapter dispatch loop. handleSlashCommand is the only caller of
ctrl.Approve, and it runs on the same dispatch goroutine that drives runTurn.
With runTurn inline, the loop could never deliver the /approve reply that
unblocks the turn (#4402, #4701, #4863).
The test drives the real dispatchLoop with a fake controller whose RunTurn
parks until Approve is called, then sends a normal turn followed by /approve.
It passes only when the turn runs off the dispatch goroutine; against the
inline implementation it fails because the approval reply is never delivered.
Replace the 🧠 emoji badge inside msg__body with a BrainCircuit lucide
icon in the msg-meta bar (between time and copy button). The icon uses
disabled styling (opacity 0.45, cursor: default, no hover effects, no
pointer events) so it looks purely decorative — not clickable.
Remove the clickable BrainCircuit button from msg-meta (which was
confusingly similar to copy/edit buttons). Instead show a subtle 🧠
emoji badge inside the message body area when Memory v5 compiler was
applied. The badge is purely visual — no interaction.
Clarify that OSC 52 clipboard copy depends on terminal support while avoiding remote-host platform clipboard writes in SSH/tmux flows.
Co-authored-by: SivanCola <[email protected]>
The BrainCircuit icon in the user message meta bar is now a clickable
button. Clicking it toggles between the cleaned display text and the
original raw message text (with the <memory-compiler-execution> block
visible). The icon changes to accent color when raw view is active.
Updated locales (en/zh/zh-TW) with ShowRaw/HideRaw labels.
When a user message had a <memory-compiler-execution> block that was
stripped from display, show a BrainCircuit icon in the message meta bar
(left of the copy button) to indicate that Memory v5 compiler was applied.
Added translation key msg.memoryCompilerApplied in all three locales.
The Memory v5 compiler injects a <memory-compiler-execution> block into
user turn text for model-internal planning. This block is not user-facing
content and should be hidden from the transcript display.
Backend change: add memory-compiler-execution to the transient XML blocks
regex in StripTransientUserBlocks so it is stripped when resolving display
text from persisted session messages.
Frontend change: add stripMemoryCompilerExecution() in Message.tsx to
strip the block as a safety net if it reaches the component via any path.
When the user presses Ctrl+C to cancel a turn, the agent's
incomplete assistant messages and tool results are already saved
to the session. On the next turn, the model sees leftover
in-progress todo items and partial tool calls and may re-execute
the interrupted work.
Fix: in turn_orchestrator.runTurnWithRawDisplay, when Run()
returns context.Canceled AND the cancel was user-initiated
(CancelRequested() == true), strip the session back to the
pre-turn message count so the next prompt starts clean.
The guard (CancelRequested) ensures internal context cancellations
(stream recovery, timeouts) are unaffected — only explicit user
cancels trigger the strip.
Remove the atotto/clipboard dependency from copyToClipboard and the
two-tier strategy that tried the platform clipboard (xclip/wl-copy/pbcopy)
first and fell back to OSC 52 on failure.
Over SSH, the platform tool writes to the remote server's clipboard — the
fallback never triggered locally because clipboardWriteAll returned nil
success. OSC 52 works in all environments (local, SSH, tmux, modern
terminals) and is the correct single path.
- transcript.go: remove clipboardWriteAll var, simplify copyToClipboard
- transcript_test.go: replace two-tier tests with direct OSC 52 test
- chat_tui_test.go: remove clipboard stubs from Ctrl+C tests
The rand.Read failure comment overstated the risk as making the cookie
'trivially forgeable and bypass authentication'. verifySession still
requires the PBKDF2-derived sessKey to produce a valid HMAC, so an
all-zero nonce is not an auth bypass; it weakens session token
uniqueness/unpredictability. Reword to match the accurate PR description
and avoid misleading future security review.
Two fixes for the circular gate that freezes the task list when a
complete_step is rejected on a technicality like command quoting:
1. Better error messages in verifyCommandFromSession: when a command
citation doesn't match, the error now lists ALL commands from both
the per-turn ledger and session history, formatted so the model can
self-correct with the exact string.
2. Allow attempted (failed) complete_step to authorize todo_write
completions: hasAttemptedCompleteStepForTodo accepts any
complete_step receipt (not just successful), recognizing the model
acted in good faith. A technical mismatch won't deadlock the list.
Tests updated: TestLedgerRequiresCompleteStepForNewCompletedTodos,
TestTodoWriteAllowsCompletionAfterAttemptedCompleteStep,
TestEvidenceFlowRejectsUncitedCommand (error message format).
(cherry picked from commit 7577ad0cd9)
mcpEditConfigLaunchCommand concatenated the raw $VISUAL/$EDITOR value
into a `sh -lc "<editor> <path>"` command string. Only the path was
shell-quoted, so an editor value like `vim; rm -rf ~` (or any
environment pollution) would execute arbitrary shell commands.
Replace the sh -lc construction with editorLaunchCmd, which expands
$VAR references and leading ~/~ prefixes without a shell, splits the
result into argv via strings.Fields, and invokes the binary directly
with exec.Command. This matches the safe pattern already used by the
terminal-editor fallback (exec.Command(bin, path)) in the same function.
Multi-token values such as `code --wait` are still honored.
Add tests covering: direct invocation (no shell), argument splitting,
$VAR expansion, tilde expansion, rejection of shell metacharacters,
and proof that tilde expansion cannot be abused by an injection payload.
Add TestTurnOrchestratorStopHookCancelledContext to prove the Stop hook
spawner receives a usable (non-cancelled) context even when the turn
context is cancelled before runTurnWithRawDisplay returns.
This guards the fix in turn_orchestrator.go:47 and controller.go:1118.
signSession() silently discarded the error from rand.Read, leaving the
session nonce all-zero on failure. While this does not directly bypass
authentication (forging a cookie still requires the PBKDF2-derived
sessKey), it weakens the unpredictability of password-mode session
cookies and is an explicit anti-pattern inconsistent with the same
file's generateToken() and sessionKeyForPasswordHash(), both of which
panic on rand.Read failure.
Now panics with a descriptive message, matching the existing convention.
Reviewer SivanCola pointed out that the synchronous/headless
Controller.Run(ctx, ...) path had the same issue: a cancelled caller
context would prevent the Stop hook command from executing.
Apply the same context.Background() fix to controller.go:1118.
When the user cancels a turn (Stop/Esc), Controller.Cancel() cancels the
turn context before the deferred Stop hook runs, causing
exec.CommandContext(cancelledCtx, ...) to immediately fail with
context.Canceled. Since Stop is a non-blocking cleanup hook, use
context.Background() instead so the user-configured command executes
regardless of cancellation.
Closes#5281
Honor shell quoting when screening plan-mode bash commands, allow explicitly declared MCP servers to connect in token economy plan mode, and strip the legacy plan marker from restored display text.
Co-authored-by: SivanCola <[email protected]>
Addresses a self-review of #5217; all four are reproduced by focused tests.
1. outcomeFor: a turn that finished with no error and no tool calls returned
partial_success, which updateStrategy then learned as a strategy *failure*,
poisoning scores once Memory v5 is on by default. A no-tool successful turn is
now "success".
2. StartTurn: the goal was classified from the controller's injected "Referenced
context:" preamble plus <file>/<dir>/<resource>/<image> blocks, so
summarizeGoal and strategy matching keyed off file contents. Goal
classification now strips that prefix via stripReferencedContext. SourceEvent
intentionally keeps the full input — when the compiled contract replaces the
user turn, source_event is the model's only view of the referenced files.
3. hardeningTraceForStartWithCoordinator: an empty / un-useful IR reserved the
entire MaxToolCalls, so concurrent tabs on one workspace starved each other's
reservations through the shared coordinator. It now reserves only the IR's
steps (zero for an empty IR).
4. consensus.capDominantWeights: the cap used the pre-cap total, so [100,1]
capped to 0.55*101 still left the node at ~98% of the post-cap total. It now
bounds each node to maxNodeShare of the post-cap total, iterating since
capping one node lowers the total for the rest.
Adds regression tests for all four (including the no-tool turn path the earlier
hardening-fix tests didn't cover) and the first unit tests for capDominantWeights.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The moderate fail-closed gate trusted any ReadOnly()==true tool, but an MCP
remoteTool's ReadOnly() can be true purely from a server-reported readOnlyHint —
an untrusted signal. Such a tool could enter plan mode (and read-only research
sub-agents) without a plan_mode_allowed_tools declaration.
- Add tool.PlanModeUntrustedReadOnly; plugin remoteTool/lazyTool implement it,
returning true only when ReadOnly() came from a server readOnlyHint, not from a
first-party Spec.ReadOnlyToolNames override (cache format unchanged).
- planmode.Call gains Untrusted; Decide no longer trusts an untrusted ReadOnly()
on its fast path — such tools are gated like writers and must be declared in
plan_mode_allowed_tools. Built-ins and first-party overrides stay trusted.
- ReadOnlySubagentToolRegistry / FilterReadOnlyRegistry exclude untrusted
read-only tools from read-only research sub-agents and the planner registry.
- Tests across planmode/plugin/agent; docs corrected (the earlier "plugin/MCP
contractually non-read-only" claim was wrong).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The test spawned a real login shell to probe its PATH, but the probe
(defaultBashShellPATH) runs up to three interactive-login shells with a 2s
timeout each. Under the CPU load of `go test ./...` the probe timed out and
returned an empty PATH, so the test failed with command-not-found — only in
the full suite, never in isolation or in the builtin package alone.
Inject a deterministic login-shell PATH via the bashShellPATH function var
(which exists for exactly this kind of seam) so the test no longer spawns a
real shell. Add TestParseShellPATH and TestMergePathLists to cover the probe's
parsing/merging directly — these pure functions had no unit test, so coverage
increases rather than drops.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The cli test binary had no deterministic UI language. Production code
(cli.Run) calls i18n.DetectLanguage("") which resolves the host locale from
the environment and installs it as the global i18n.M. Tests that exercise the
CLI entry point (acp_test.go's Run(["--acp"]), cli_test.go's Run(["version"])
etc.) leave M set to the host language without restoring it. On a zh_CN dev
machine this flips M to Chinese, so every later test that asserts English UI
strings fails — but only when the whole package runs, not in isolation:
TestIngestSeparatesReasoningFromAnswer
TestVerboseReasoningInsertsTextUnderSummary
TestToolWorkingLineThenClears
TestClearCommandRequiresConfirmationAndDiscardsSession
TestIngestEventRoutesByKind
CI never caught it because CI runs in an English locale.
Pin a deterministic English environment in cli's TestMain so the suite is
independent of the host locale. Tests that need another language still set it
explicitly via i18n.DetectLanguage(lang) with their own cleanup.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Plan mode previously trusted any ReadOnly()==true tool while planning, so
a pseudo-read-only tool (e.g. complete_step) could slip through unless it
was manually denylisted, with no test pinning the built-in roster.
- Add tri-state PlanSafety + tool.PlanModeClassifier so tools self-report
their plan-mode stance; complete_step reports unsafe, read_only_task /
read_only_skill report safe.
- Make Decide fail-closed for non-read-only tools (plugin/MCP, writers)
while still trusting in-process ReadOnly() tools; enforce PlanSafe =>
ReadOnly. plan_mode_allowed_tools is the escape valve for read-only
MCP/plugin tools.
- Add reconcile_test.go: walks tool.Builtins() and fails if any built-in
lacks an explicit plan-mode stance (forcing function against drift).
- Add marker round-trip test pinning the model-facing Marker to the gate.
- Document the boundary in SPEC/GUIDE/GUIDE.zh-CN/MIGRATING.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The production-hardening layer used hardcoded budgets to both gate contract
injection and rewrite execution outcomes after the fact, which broke the
compiler in normal multi-turn use:
- Bug 1: StartTurn stopped injecting the compiled contract once learned
memory nodes reached the GC cap (which equalled the hardening memory-node
budget) and never recovered — about 10 turns in practice.
- Bug 2: a genuinely successful turn with more tool calls than the budget was
rewritten to partial_success in the persisted trace.
Fixes:
- StartTurn no longer gates the cache-safe contract on hardening.Allowed. The
contract is plain input text and the real execution that follows is still
bounded by tool permissions.
- applyProductionHardening no longer rewrites tr.Outcome; the budget/canary
verdict is recorded as an advisory observation only.
- Remove the per-turn sandbox replay + RunIsolated goroutine from the hot path
(StartTurn and writeTraceAndLearn) — they produced trace metadata that never
reached the model yet cost a goroutine + CPU on every turn.
- Decouple the memory-node budget (2000) from the graph GC cap (300) so a
fully-populated graph no longer permanently trips the verdict.
- Unify canary.DefaultPolicy() (was a 10% canary) with the runtime's
full-production seed, removing the mismatch.
Add TestMemoryV5SurvivesLongRun (80 turns, 25 tool calls each) as a regression
guard and rewrite two tests that asserted the old buggy behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Exclude connect_tool_source from read_only_task child registries so token economy source loading cannot reopen writer-capable or installer tool surfaces. Extend the economy plan-mode test to execute read_only_task and verify the child request stays within the read-only boundary.
Co-authored-by: SivanCola <[email protected]>
Add read_only_task as an ephemeral subagent that exposes only read-only research tools plus plan-mode safe foreground bash. Wire it into token economy source loading and document the plan-mode boundary.
Co-authored-by: SivanCola <[email protected]>
Drain already-finished child results before marking remaining parallel_tasks work cancelled so partial aggregates do not lose completed output during cancellation races.
Make OpenAI-compatible and Anthropic stream producers stop blocking when the agent abandons chunk reads while still delivering cancellation errors to active readers.
Return a cancellation-aware aggregate for parallel_tasks so completed children remain visible while unfinished work is marked cancelled or skipped instead of reported as fully completed.
Keep fresh-conversation heartbeat topics as in-flight guards until their controllers are idle, restore editor scrolling, and cover pending-topic cleanup.
Co-authored-by: SivanCola <[email protected]>
Combine the workspace tree reveal fix from PR #5219 with the useful desktop and config fixes from PR #5220, while keeping plan restore intent scoped per tab.
Co-authored-by: lifu963 <[email protected]>
Co-authored-by: yzz521 <[email protected]>
config.go mixed the config schema with the entire load pipeline, the DeepSeek/Mimo pricing tables, and all OS path resolution. Move three cohesive, self-contained concerns into their own files in the same package (pure code motion, no API or behaviour change):
- pricing.go — DeepSeek/Mimo pricing tables + official-pricing reset/compare helpers
- load.go — Load/LoadForRoot + the TOML merge and legacy-normalisation pipeline
- paths.go — user-config / home / session / cache / convention dir resolution
config.go drops from ~3142 to ~1455 lines, leaving the config schema, Default(), and the core resolvers/Validate. The remaining sub-config types stay together as the cohesive config schema — deliberately not split further to avoid fragmenting their cohesion.
Verified: gofmt clean, go vet clean, go build ./..., and go test ./internal/config (English locale). config.go's diff is pure deletion (0 insertions).
Two snippets of session auto-save path logic were copy-pasted across every surface that builds a controller: the 'fresh session' path-stamp branch (CLI chat, CLI serve, bot) and the model/effort-switch carry (CLI, serve, ACP) — the latter carrying the #2807 orphaned-duplicate fix in three separate places.
Add EnsureSessionPath() and AdoptHistory(msgs, path) to Controller as the single home for both, and migrate the six call sites. The bot's local ensureControllerSessionPath helper and its now-unused agent import are removed.
No behaviour change; verified with gofmt, go build ./..., and go test for the affected packages (control, cli, serve, acp, bot).
Third slice of the frontend state-layer modernization. Moves the 12 transient
overlay/modal atoms out of App.tsx useState into a new src/store/overlays.ts,
read via selectors: settingsTarget/settingsFocus, paletteOpen/paletteSessions,
shortcutsOpen, heartbeatOpen, topicExportOpen, sidebarSearchOpen, the
sidebar-search-focus and dismiss-all coordination signals, startupSplashVisible,
and needsOnboarding.
Setters mirror Dispatch<SetStateAction<T>>, so call sites are drop-in —
including functional updaters like setPaletteOpen(c => ...) and the signal bumps
setSidebarSearchFocusSignal(s => s + 1). Behavior is unchanged: nothing here is
persisted, and every atom keeps its prior initial value (the splash still probes
sessionStorage via shouldShowStartupSplash, now from the store initializer).
Also extracts the SetStateAction resolver shared by the layout and overlays
stores into src/store/setState.ts (applySetState), and points layout.ts at it
to drop the duplicate.
App.tsx is down to 33 useState (from 54 at the start of this track).
Verified: tsc (main + test configs), pnpm build, full frontend test suite.
Continues the layout-store slice. Moves the four non-persisted layout
configuration flags — workspacePanelOpen, workspacePanelMaximized,
workspacePreviewActive, and rightDockMode (plus its RightDockMode type) —
out of App.tsx useState into src/store/layout.ts, read via selectors.
These describe the right dock's layout and are read across the render tree
(rightDockMode alone has 17 sites, one flag is already prop-drilled to
AppChrome), so a shared store removes real coupling. Their setters mirror
React's Dispatch<SetStateAction<T>>, so existing call sites — including the
functional toggle setWorkspacePanelMaximized(v => !v) — are drop-in.
The purely view-local interaction ephemera (resize-drag flags, button-press
animation flags, measured footerHeight, viewportWidth) deliberately STAY as
App-local useState: they have no cross-component readers, so promoting them
to shared state would be a regression, not a cleanup.
Behavior is unchanged: the flags are not persisted and initialize to the
same defaults the useState calls used.
Verified: tsc (main + test configs), pnpm build, full frontend test suite.
App.tsx held the desktop shell's sidebar/right-dock geometry (the collapse
flag plus three widths) as local useState, alongside the geometry constants,
clamps, and localStorage load/save helpers — part of why App.tsx is a
3.3k-line component with 50+ useState and no state layer at all.
Move the layout-geometry domain into a focused src/store/layout.ts: a zustand
store for the four atoms plus the constants/clamps/persistence helpers that
belong with it. App.tsx now reads each atom via a selector (only that slice
re-renders) and imports the shared geometry helpers.
Behavior is byte-identical: the store's setters are pure (state only) and
callers keep invoking the same save* helpers where they did before, so the
localStorage schema and write timing are unchanged.
First slice of the frontend state-layer modernization; introduces zustand as
the chosen state library.
Verified: tsc (main + test configs), pnpm build, full frontend test suite.
Serve mode always reads default_model from the user's global config,
ignoring any project-level override. This keeps the model choice
consistent across projects. CLI/TUI/desktop are unaffected.
- Add Todos() to Status interface and Controller implementation
- Add GET /todos endpoint returning canonical task list
- Add todo panel CSS, HTML, JS to index.html
- Track todo_write events from SSE, fetch /todos on reconnect
- Auto-collapse when all done, dismiss on click, reset on new turn
- Add hamburger menu button for narrow screens (<768px): sidebar slides in with overlay, closes on Esc/backdrop click
- Fix balance always showing --: Balance.Display() is a Go method not a JSON field; now explicitly includes display in /status response
- Add balance diagnostic log at serve startup
getOrCreateSession released gw.mu between the "does this key exist?" check and
the registration of the freshly built controller, with boot.Build (a slow,
MCP-connecting operation) in between. Two near-simultaneous opening messages
for the same chat could both miss the cache, both build a controller, and the
second registration would overwrite the first in gw.controllers — orphaning
(leaking) the first controller's jobs and plugin host.
Re-check under the second lock: if another goroutine already registered a
session for the key, close the controller we just built (the same teardown the
eviction path uses) and reuse the existing one. The first writer wins.
Build + vet clean; -race bot suites and full go test ./... stay green. The race
is structural (TOCTOU around an un-injectable boot.Build), so it's fixed by the
standard double-check rather than a new unit test.
Replace feishu's hand-rolled reconnect loop with RunWithRetry, completing the
connloop adoption across all three adapters (#5136, #5137).
The attempt keeps the SDK's blocking client.Start in an off-loop goroutine so
a ctx cancellation closes the client immediately rather than waiting for Start
to notice — behavior identical to before. What changes is the outer reconnect
wait: the manual `time.NewTimer(5s)` becomes RunWithRetry's cancellation-aware
exponential backoff (1s→30s). The SDK's own WithAutoReconnect still handles
in-connection blips; RunWithRetry is the outer "SDK fully exited" fallback.
-race bot suites and full go test ./... stay green.
Replace the two adapters' hand-rolled connection loops with the shared
primitives from #5136.
qq: gatewayLoop's `for { check ctx; getToken; connectGateway; sleep 5s on
error }` becomes a RunWithRetry call whose attempt is getToken+connectGateway
(already a blocking, connection-lifetime call). This swaps the fixed,
ctx-ignoring `time.Sleep(5s)` for a cancellation-aware exponential backoff
(1s→30s) — so Stop() now takes effect promptly instead of blocking out the
remaining delay, and a flapping gateway no longer hard-reconnects every 5s.
weixin: pollLoop is a polling adapter, not persistent-connection, so it keeps
its loop but swaps its three time-based waits (2s startup, 5s error, 500ms
idle) for SleepCtx — gaining prompt cancellation without changing the delays
or the poll semantics.
Behavior-preserving except for the intended backoff/cancellation improvements.
-race bot suites and full go test ./... stay green.
The three IM adapters (qq/feishu/weixin) each hand-roll the same "loop
forever, attempt a connection, wait a fixed 5s on failure" shape. Two of
them (qq, weixin) wait with time.Sleep, which ignores ctx — so a Stop()
stays blocked for the remaining delay and the backoff is uncancellable.
None of the three backs off exponentially.
This adds the shared primitives the adapters will delegate to (no existing
adapter is changed yet — that's the follow-up PRs):
- SleepCtx(ctx, d): a cancellation-aware sleep; returns false if ctx is
cancelled mid-wait. Replaces the raw time.Sleep in the adapter loops so
Stop() takes effect promptly.
- RunWithRetry(ctx, log, name, cfg, attempt): drives a persistent-connection
adapter (qq/feishu) — runs attempt (one connection lifetime, blocking),
then reconnects after a cancellation-aware exponential backoff (1s→30s,
reset after 60s healthy), until ctx is cancelled.
Fully unit-tested (cancellation, retry count, backoff cap, defaults) and
race-clean. Pure addition; the full suite stays green.
chat_tui.go is the cli's largest god-file (3920 lines). Move its
self-contained paste & image-attachment concern — folding long pasted text
into a deletable [Pasted text #N] token, turning dragged/pasted images and
file paths into @references, and the clipboard commands behind them — into a
new chat_tui_paste.go.
This is a file-level separation of concerns, not a struct change: the
composer state (pastedBlocks / nextPasteID / pendingPastes) stays on chatTUI;
the functions (mostly free, with minimal model coupling) move verbatim. The
two non-paste methods that were interleaved in the region (chooserTyping,
growInputToFit) stay put.
chat_tui.go: 3920 -> 3659 lines; new chat_tui_paste.go is 280. Behavior is
unchanged — same package, same identifiers, cross-file calls resolve as
before; the now-unused net/url, path/filepath and clipboard imports drop from
chat_tui.go. The cli suite and full go test ./... stay green.
Group the session's discovered-skill fields out of the Controller god object
into a skillSet value type — the skills slice of the Capabilities concern,
alongside the mcpManager extracted in the prior step.
skillSet owns the enabled subset, the full set (including config-disabled
ones), and the optional reloadable stores that supersede the
construction-time snapshots. It is read-only (no lock): skills are discovered
at boot and never mutated in place, so unlike the stateful managers it just
consolidates four related fields and their lookup logic behind list /
listAll / byName / discovered.
- New internal/control/skill.go holds the type.
- Controller drops skills/allSkills/skillStore/allSkillStore for a single
`skills skillSet`; Skills/AllSkills delegate, RunSkill resolves via
byName (the redundant skillByName method is removed), and the /skills
listing keeps reading the construction-time snapshot via discovered() so
its output is unchanged. The public SessionAPI surface is unchanged.
controller.go: 3358 -> 3346 lines, 41 -> 38 fields. Behavior-preserving;
the -race suite and full go test ./... stay green.
Carve the live tool/plugin surface out of the Controller god object into an
mcpManager collaborator, following the goalMachine/approvalManager pattern.
mcpManager owns the MCP plugin Host (live server connections), the tool
registry the executor reads each turn, and the session-scoped context a
hot-added stdio server binds its subprocess to — behind its own lock, off
c.mu. Following the approvalManager precedent, only the live plumbing moves;
the Controller keeps the config-facing orchestration (persisting
reasonix.toml on add/remove, building specs from entries).
This also closes a latent race: the lazy `host = plugin.NewHost()` creation
was previously an unsynchronized field write; it now happens under the
manager's lock (the host pointer, once set, never reverts to nil).
- New internal/control/mcp.go holds the manager (connectSpec / disconnect /
removeToolPrefix / suspendToolPrefix / registerTool / serverNames /
hasServer / prompts / failures / readResource / hostRef).
- Controller drops the host/reg/pluginCtx fields for a single `mcp
mcpManager`; AddMCPServer/RemoveMCPServer/Disconnect/Import/ReloadCommands/
Host and the input/refs/slash readers delegate. The dead containsString
helper goes with hasServer. The public SessionAPI surface is unchanged.
controller.go: 3417 -> 3358 lines, 43 -> 41 fields. Behavior-preserving;
the -race MCP tests and the full suite stay green.
The upgrade success format was only printing the latest version
instead of both current and latest versions. Fixed by passing the
correct arguments to the format string.
Avoid coupling the orchestrator test to Controller checkpoint internals so it survives the checkpoint manager extraction.
Co-authored-by: Cursor <[email protected]>
Add a focused test proving the orchestrator records checkpoint boundaries before the user message and still snapshots activity.
Co-authored-by: Cursor <[email protected]>
Add a focused regression test proving resolved @file context reaches the model while the display recorder keeps the visible prompt.
Co-authored-by: Cursor <[email protected]>
Lock down the existing sync and async already-running behavior so orchestrator extraction cannot interleave session writes.
Co-authored-by: Cursor <[email protected]>
Delegate controller turn execution to the orchestrator so plan approval and goal continuation share one focused lifecycle boundary.
Co-authored-by: Cursor <[email protected]>
Add a minimal foreground-turn orchestrator seam with a tracer test so follow-up lifecycle extraction has a focused target.
Co-authored-by: Cursor <[email protected]>
Carve the snapshot-based rewind bookkeeping out of the Controller god
object into a checkpointManager collaborator, following the
goalMachine/approvalManager pattern, and disentangle the overloaded cpRoot
field.
checkpointManager owns the per-session checkpoint store, the monotonic turn
counter, and the conversation-rewind boundary map behind its own lock —
moving that state off c.mu, so a boundary read for a rewind/fork no longer
contends on the run-state lock. Every store call does its disk I/O off the
lock. Following the approvalManager precedent, only the bookkeeping moves;
the Controller keeps the rewind/fork/summarize orchestration (truncating the
session, restoring code, emitting events) that needs its collaborators.
cpRoot was never checkpoint-specific — it is the workspace root used for
@-ref resolution, "!" shell cwd, custom-command discovery and the restore
guard. Rename it to workspaceRoot so the remaining field reads true.
- New internal/control/checkpoint.go holds the manager.
- Controller drops cp/cpRoot/cpTurn/cpBound for workspaceRoot +
checkpoints; rebind/begin/Rewind/Fork/summarize/CheckpointHasBoundary
delegate the state ops. The public SessionAPI surface is unchanged.
controller.go: 3455 -> 3417 lines, 45 -> 43 fields. Behavior-preserving;
the -race rewind/fork/concurrency tests and the full suite stay green.
Carve the memory subsystem out of the Controller god object into a strict
leaf collaborator, following the goalMachine/approvalManager pattern.
memoryManager owns the loaded snapshot, the pending turn-tail notes queue,
and write serialization behind its own locks (mu for the snapshot pointer
and pending queue, writeMu to serialize the write+reload off mu) — entirely
off c.mu, so a memory-panel save never stalls an approval or status poll.
- New internal/control/memory.go holds the manager and all memory logic.
- Controller drops the mem/memMu/pendingMemory fields for a single
`memory memoryManager`; QuickAdd/SaveDoc/SaveMemory/ForgetMemory/
QueueMemory/Memory become one-line delegations. The public SessionAPI
surface is unchanged, so no frontend is touched.
- Compose drains pending notes via the manager; the /memory listing reads
the snapshot through it.
controller.go: 3561 -> 3455 lines, 47 -> 45 fields. Behavior-preserving;
the -race memory concurrency test and the full suite stay green.
Describe the shared wire fields for MCP surface readiness and cache diagnostics so frontend types match backend event JSON.
Co-authored-by: Cursor <[email protected]>
Keep desktop tab routing local while reusing the shared event wire contract for the core JSON payload sent to Wails.
Co-authored-by: Cursor <[email protected]>
Route SSE event broadcasting through the shared wire contract so retrying and future event kinds use the same JSON mapping as other frontends.
Co-authored-by: Cursor <[email protected]>
Introduce the shared event wire package with retrying coverage and a mechanical kind boundary so future event kinds cannot silently miss a wire name.
Co-authored-by: Cursor <[email protected]>
Document which OpenAI-compatible backend uses which reasoning protocol
(thinking.type vs reasoning_effort), which params each ignores, and how to
drive thinking on a non-auto-detected backend via the `thinking` field.
Includes a troubleshooting section for telling provider behaviour apart
from a Reasonix bug. Suggested by @eghrhegpe (#5063).
Treat any already-rendered canonical skill root as active when deciding whether a configured user path needs an inactive placeholder. This prevents configured project convention roots from being shown twice after duplicate root merging.
Tested: cd desktop && go test . -run 'TestSkillRootsView(DedupesConfiguredConventionRoot|DedupesConfiguredProjectConventionRoot|CountsProjectSkills|MarksEnvConfiguredCustomRoot|OmitsExcludedConventionRoot)|Test(AddSkillPathRestoresConventionRootWithoutCustomPath|RemoveSkillPathPseudoDeletesConventionRoot)' -count=1
Tested: git diff --check
Co-authored-by: SivanCola <[email protected]>
Ensure incremental project saves can remove stale plugin entries when the runtime config no longer contains project plugins. This prevents lazy MCP uninstall/remove flows from leaving old [[plugins]] tables behind.
Co-authored-by: SivanCola <[email protected]>
Preserve top-level project config deltas, replace array-table sub-sections as part of their owning entries, and avoid writing merged user settings back into project configs.
Co-authored-by: SivanCola <[email protected]>
Invalidate the topic migration marker when session files change so CLI-created no-topic sessions added after the first migration pass still surface in the sidebar.
Co-authored-by: SivanCola <[email protected]>
Configured convention skill roots can be discovered twice: once as a custom path and once as a built-in convention root. Collapse duplicate canonical paths in the desktop settings view while preserving configured metadata and skill counts.
Constraint: Keep skill discovery priority unchanged; fix only desktop source rendering.
Rejected: Deduping internal skill Store roots | would alter discovery behavior beyond the UI bug.
Confidence: high
Scope-risk: narrow
Directive: Keep duplicate-root handling in the view layer unless product behavior requires changing discovery semantics.
Tested: cd desktop && go test ./...
Tested: go test ./...
Tested: gofmt -l .
Tested: git diff --check
Not-tested: manual Wails desktop UI smoke
Replace message substring matching with a private typed error marker while preserving the underlying JSON-RPC error code and message.
Co-authored-by: SivanCola <[email protected]>
Lock the observable retry path so a rejected config switch can succeed after the session's background jobs finish.
Co-authored-by: Cursor <[email protected]>
Avoid queueing model or config rebuilds while an ACP session is blocked on user interaction, and cover the legacy set_model path.
Co-authored-by: Cursor <[email protected]>
When a queued switch cannot rebuild after a turn, report the failure, restore the visible config state, and require a new client request instead of keeping it armed.
Co-authored-by: Cursor <[email protected]>
Broaden the thinking-mode support beyond Zhipu auto-detection (credit
@eghrhegpe, #5063):
- Add a vendor-agnostic `thinking` config field (enabled|disabled) read
from cfg.Extra, so OpenAI-compatible providers we don't auto-detect
(e.g. opencode.ai) can drive thinking.type explicitly. An explicit
field also overrides the Zhipu effort default.
- Let DeepSeek turn thinking off via /effort disabled (off now maps to
disabled instead of erroring), routed through thinking.type=disabled
with no reasoning_effort.
Covered by buildRequest/parse tests plus updated DeepSeek effort tests.
On KDE Plasma Wayland with NVIDIA GPUs, the WebKitGTK webview crashes at
startup (Error 71: Protocol error) due to an upstream WebKit explicit-sync
bug (WebKit #280210, #317089). ANGLE advertises wp_linux_drm_syncobj but
fails to set an acquire point before committing the buffer, violating the
Wayland protocol.
Set __NV_DISABLE_EXPLICIT_SYNC=1 automatically when all three conditions
are true: Wayland session detected, NVIDIA GPU present (/sys/module/nvidia),
and the user hasn't already set the variable. This preserves GPU acceleration
(unlike WEBKIT_DISABLE_DMABUF_RENDERER=1) and keeps the native Wayland
session (unlike GDK_BACKEND=x11).
Closes#4700.
Zhipu's OpenAI-compatible endpoint (open.bigmodel.cn / api.z.ai) gates
chain-of-thought through `thinking.type` (enabled|disabled) and silently
ignores `reasoning_effort`, so GLM users could not turn thinking off and
paid for reasoning tokens they didn't want.
Detect Zhipu hosts via IsZhipu() and route reasoning control to the
thinking knob, mirroring the existing MiniMax handling: emit thinking.type
and omit reasoning_effort. /effort exposes enabled|disabled and maps stale
depth levels onto them.
Manual wrangler deploy was the only path to ship crash.reasonix.io
changes; gate deploys on workers/crash-report changes so merging a PR
publishes the worker without a maintainer holding the Cloudflare token.
Needs the CLOUDFLARE_API_TOKEN repo secret.
Adds a /reload-cmd command that rescans .md command files on disk and
hot-swaps the slash_command tool in the Registry plus the Controller's
internal commands slice — no MCP restart, no hook rerun, no Controller rebuild.
Implementation approach (lightweight hot-reload):
- Controller.commands changed from []command.Command to atomic.Pointer
for lock-free thread safety
- Controller.ReloadCommands() rescans disk via command.Load(), rebuilds
the slash_command tool with current skills preserved, and atomically
swaps both the Registry entry and the commands slice
- Three dispatch paths: CLI TUI (runSlashCommand), Desktop/HTTP
(managementNotice), and Desktop App bridge
- i18n support for zh/zh_tw/en locales
- All test suites pass
Closes#4840
Co-Authored-By: Claude <[email protected]>
# Conflicts:
# internal/control/controller.go
Co-authored-by: db <[email protected]>
When the user @-references files or resources, the controller injects a
"Referenced context:" preamble followed by XML reference blocks
(<file>, <dir>, <resource>, <image>) before the user's actual input.
This caused the session title to display as "Referenced context..."
instead of the user's actual message.
Add StripReferencedContextPrefix() to remove the preamble and reference
blocks, then apply it in topicTitleFromSession() so the title matches
what the user typed.
Fixes: #4954
Co-authored-by: liaoyl830 <[email protected]>
Preserve rename semantics in the copy+remove fallback by using lstat-based dispatch and recreating symlinks instead of dereferencing them.
Add a regression test for symlinks inside copied session artifact directories.
Co-authored-by: SivanCola <[email protected]>
SaveTo now writes only delta sections/fields for project-level reasonix.toml,
preserving existing file content. User configs (full template) unchanged.
- RenderTOMLProjectDelta: generates only non-default sections/fields
- replaceTOMLSection: handles both [section] and [[section]] formats
- saveProjectIncremental merges delta into existing file
On Windows, os.Rename can fail when moving session directories to trash
if file handles are briefly held open (antivirus, indexing, etc.). This
caused 'move to trash' to fail for sessions with conversation history.
Add a fallback path: when os.Rename fails with a cross-device or
file-busy error, fall back to copy-then-remove. This handles:
- Cross-device moves (EXDEV)
- Windows sharing violations (ERROR_SHARING_VIOLATION)
The fallback recursively copies the directory tree, then removes the
source. A brief sleep (10ms) after close ensures Windows has time to
release file handles before removal.
Fixes: #4956
Combine the TUI escape hatch from PR #4900 with the agent tool-batch cancellation path from PR #4924. The TUI now surfaces cancel-pending state and lets a second Ctrl+C quit, while the agent stops scheduling remaining tools once cancellation is observed.
Co-authored-by: GTC2080 <[email protected]>
Co-authored-by: liaoyl830 <[email protected]>
Final frontend on the driving port (after bot #4931, serve+acp #4932, cli #4935).
With this, all five frontends depend on control.SessionAPI instead of the
concrete *control.Controller — the inbound boundary is complete.
The desktop App held the controller as *control.Controller across ~35 sites
(WorkspaceTab.Ctrl, activeCtrl/ctrlByTabID, and many per-ctrl helpers). All now
take control.SessionAPI. cli already forced the port to completeness, so desktop
needed no new methods — every one of its 69 controller calls is already in the
port. The controller is still BUILT concrete (boot.Build) and stored through the
interface; the dedupe maps become map[control.SessionAPI]bool (interface keys are
comparable). Test helpers that accept a controller widen to the interface too;
the constructor helpers keep returning the concrete type.
desktop can't be built locally (cgo -arch toolchain issue), so this was verified
by manual audit — no concrete type assertions/comparisons, no port-bypassing
consumers (serve/acp), all 69 methods in SessionAPI — backed by control's
compile-time SessionAPI assertion. CI's desktop job is the gate.
Next: with all frontends on the port, generate the desktop bridge.ts from
SessionAPI instead of hand-mirroring it.
Fourth and largest frontend on the driving port (after bot #4931, serve+acp
#4932). The TUI used ~70 controller methods across every domain, so it validated
the port's completeness — it only surfaced two missing surfaces, now added:
- Input sub-port: Compose, ComposeSynthetic, ResolveRefs, HasRefs
- Settings sub-port: SetReasoningLanguage, SetDisplayRecorder
SessionAPI now composes all eleven sub-ports (asserted against *Controller).
chatTUI.ctrl, modelSwitchMsg.ctrl/oldCtrl, and oldControllers[] are now
control.SessionAPI; the leaf helpers narrow further (fetchBalance → Status,
mcpConnected/recordMCPModePluginFailure → Capabilities). The model-switch
plumbing that builds controllers (setup/setupQuiet, the buildController closure,
acpFactory) stays concrete — construction returns *Controller, which is stored
through the interface. cli never calls InheritLifecycleFrom, so no concrete
escape hatch was needed. No behavior change.
Only desktop remains; once it's on the port the bridge.ts hand-mirror can be
generated from SessionAPI.
Verified: gofmt, go build/vet ./..., go test ./internal/cli + -race ./internal/control.
Continues the driving-port migration (after bot, #4931). Adds the remaining
sub-ports and the full composition, then moves the next two frontends off the
concrete *control.Controller.
port.go: add Goals, SessionHistory, MemoryControl, Capabilities, Status, and
SessionPersistence sub-ports, plus SessionAPI = the composition of all nine.
Compile-time assertions prove *Controller satisfies each.
- serve uses ~37 controller methods spanning every domain, so it depends on the
full control.SessionAPI (Server.ctrl field, New param, ctl() return). switchModel
still builds a concrete controller via boot.Build and stores it through the
interface.
- acp uses ~17 methods across five domains; its acpSession.ctrl and
availableCommandsFor now take a narrow acpController interface (Lifecycle +
TurnControl + Approvals + Capabilities + SessionPersistence). The Factory still
returns the concrete type at construction. InheritLifecycleFrom reads another
controller's private state, so it stays a concrete construction-wiring call
(not part of the port) behind a guarded type assertion at its one call site.
Two frontends remain (desktop, cli); once migrated the desktop bridge.ts mirror
can be generated from the port. No behavior change.
Verified: gofmt, go build/vet ./..., go test -race ./internal/control + serve/acp/bot.
Five frontends (cli/desktop/bot/acp/serve) each couple to the concrete
*control.Controller and its ~99 exported methods — there is no inbound boundary,
which is why everything piles into the Controller god object: it's the only thing
a frontend can reach.
Start a segregated driving port in internal/control/port.go: Lifecycle,
TurnControl, and Approvals sub-interfaces, with compile-time assertions that
*Controller satisfies each. A frontend depends only on the sub-ports it uses
(interface segregation) rather than the whole surface. The sub-ports double as
the spec for Controller's own future decomposition — the port leads, the splits
follow.
First adapter migrated: the bot gateway. It used 12 controller methods spanning
exactly those three sub-ports; its renderSink/sessionState fields, locals, and
helpers now take a narrow botController interface (Lifecycle+TurnControl+
Approvals) instead of *control.Controller, so the bot can no longer reach goals,
checkpoints, or memory. boot.Build still returns the concrete type at
construction; it's stored through the interface. No behavior change.
Remaining frontends (serve/acp/desktop/cli) migrate in follow-ups; the full
SessionAPI composition accretes as their sub-ports are added.
Verified: gofmt, go build/vet ./..., go test ./internal/control + ./internal/bot/...
Start of a persistence layer. Five packages each independently derived a
session's sidecar paths off the .jsonl path — agent (.meta, .cleanup-pending.json),
control (.ckpt, .goal-state.json), jobs (.jobs) — and acp re-spelled .meta and
.ckpt inline a second time. A layout change meant hunting across packages.
internal/store is now the single authority for that layout: SessionMeta /
SessionGoalState / SessionCheckpointDir / SessionJobsDir / SessionCleanupPending.
It is a strict leaf (stdlib only — verified it depends on no other internal
package), so anything can import it without an import cycle.
The seven derivation sites now delegate to store; paths resolve byte-identically
(locked by a layout test), so this is pure structure — no behavior change, no
migration. The exported names (agent.BranchMetaPath, jobs.ArtifactDir, …) stay as
thin wrappers so their many callers are untouched.
Next slices (not here): store takes over root/dir resolution with config
delegating (P2), then desktop drops its divergent ~/.config/reasonix root and
adopts the store root with a one-time migration (P3).
Verified: gofmt, go build/vet ./..., go test across store/agent/jobs/control/acp
plus downstream serve/cli/boot/history/checkpoint.
The approval subsystem was ~9 fields plus two mutexes (the catch-all c.mu and
promptMu) interleaved into Controller. Unlike the goal FSM, approval blocks on
user input and has side effects (events, hooks, the executor gate), so it can't
be a pure leaf — extract the bookkeeping, keep the orchestration.
approvalManager (internal/control/approval.go) owns the prompt maps, session
grants, the id counter, the runtime posture (ask/auto/yolo), the approval
timeout, and the just-approved-plan window, behind its own mu + promptMu — all
off c.mu. It exposes the decision helpers and map primitives (preApproved /
register / resolve / grantSession / drain / snapshot / setMode / clearAll …).
The Controller keeps the thin I/O orchestration: requestApproval/Ask still emit
the events, fire the hooks, and rebuild the executor gate.
Net effect: all approval bookkeeping moves off c.mu, Controller sheds 9 fields
and a mutex, and the manager is a strict leaf (no callbacks into Controller, no
lock inversion). Public API (Approve/AnswerQuestion/SetMode/SetToolApprovalMode/
SetAutoApproveTools/Bypass/AutoApproveTools/EnableInteractiveApproval/
ReplayPendingPrompts) is unchanged, so bot/cli/serve/desktop callers are
unaffected. Also drops the write-only autoApproveTools field and the now-redundant
bypass pre-check in gateApprover (requestApproval already short-circuits it).
controller.go: 3712 -> 3506 lines. Verified with go build/vet ./... and
go test ./internal/control/ -race plus downstream bot/serve/cli tests.
The goal state machine was ~13 fields plus its FSM logic interleaved
directly into Controller and protected by the catch-all c.mu. Move it into
a dedicated goalMachine collaborator (internal/control/goal.go) with its own
mutex, kept as a strict leaf: its methods never call back into Controller, so
c.mu and the goal lock can't invert.
The FSM is now pure — advance() takes already-gathered inputs (the parsed
marker, the executor's todo snapshot and readiness, whether a tool ran) and
returns what to persist plus a notice. This also moves the executor calls
(CanonicalTodoState/GoalReadinessFailure) out of the lock critical section,
removing the disk/executor work that previously ran under c.mu.
Controller drops 13 goal fields and the goal write mutex; the goal accessors
become thin delegations. Public API (Goal/GoalStatus/SetGoal/
SetGoalWithResearchMode/ClearGoal/GoalStrict/ShortGoalForNotice) is unchanged,
so cli/desktop/acp callers are unaffected. Behavior-preserving; plan mode is a
separate concern and stays on Controller.
controller.go: 4054 -> 3712 lines. Verified with go build/vet ./... and
go test ./internal/control/ -race (incl. the goal concurrency test).
internal/inspect was a read-only capability-projection layer that nothing
ever imported — zero non-test references across the main module and the desktop
module since it was first added. Its own doc comment claims the CLI /mcp listing
and a desktop settings panel consume it, but neither does. Drop the orphan.
desktop/frontend used pnpm (wails.json frontend:install runs pnpm install) yet
also tracked a stale package-lock.json. Remove it and gitignore it so the npm
lockfile can't drift back in alongside pnpm-lock.yaml.
migrateLegacySessionsIntoGlobalTopics ran on EVERY ListProjectTree (every
sidebar render) and every buildTabController, taking a global lock and doing a
full ListSessionOrder (ReadDir + N x LoadBranchMeta) only to find — in steady
state — that every session already has a TopicID and there is nothing to
migrate. Each render re-paid the discovery cost under a global lock to do
nothing.
Gate it behind a per-dir one-shot marker (.topics-migrated), matching the
marker-gated passes in internal/agent/migrate.go. Once a dir's pass completes
with nothing deferred, the marker short-circuits future calls before the scan.
New sessions are born with a TopicID, so no fresh legacy files appear after the
pass.
Zero-regression safety: the marker is stamped only when nothing was deferred
— an empty legacy session (turns==0, could gain content later) or a transient
read/write error leaves the dir unmarked, so the gate never permanently hides a
session that should still be migrated. Callers are unaffected: in steady state
the function already returned nil, so returning nil early preserves behavior.
Tests: the marker is written after a complete pass and gates a later re-scan;
an empty legacy session defers marking. Existing migration + idempotency tests
still pass. Desktop can't build locally (cgo) — verified by the CI desktop job.
saveGoalState built the goal-state JSON (including the executor's todo
snapshot) AND wrote it to disk (MkdirAll + WriteFile) all under c.mu. It runs
after every goal turn (advanceGoalAfterTurn) and on SetGoal/ClearGoal/GoalStrict/
stopGoal, so the same c.mu that requestApproval / RuntimeStatus / Running take
was held across a filesystem write between every goal-loop turn.
Split it: buildGoalStateLocked reads state + marshals under c.mu (cheap, no
disk) and returns the path + bytes; writeGoalState does the MkdirAll + WriteFile
OFF c.mu, serialized by a new goalWriteMu. Each caller now captures the snapshot
under the lock and writes after releasing it. Writes stay synchronous in the
calling goroutine, so persistence still completes before the method returns. The
persisted result is unchanged — advanceGoalAfterTurn's redundant pre-clear save
in the complete path (always overwritten by the final save) is dropped.
First stage of the goal-FSM extraction; moving the goal* fields into a
dedicated collaborator is a separate follow-up.
Tests: goal_test.go (11) + the persistence test pass under -race; added a
concurrency test (8 writers + concurrent c.mu readers) proving no data race and
that goalWriteMu keeps the on-disk file from being torn.
if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then
jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null
echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))"
exit 0
fi
echo "attempt $attempt failed; retrying in 10s"
sleep 10
done
echo "::error::public compatibility manifest unreachable at $url"
exit 1
# A stable CLI release must never leave the npm line behind: v1.17.5
# shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2
# (#5822) — every `npm update -g` user was silently downgraded to a
# months-old version, and nothing noticed because the npm line
# (release-npm.yml, `npm-vX.Y.Z` tags) is triggered independently and the
# stable npm tag was simply never pushed. release-npm.yml's own verify
# step only guards runs that happen; this guard catches the run that
# DIDN'T.
#
# Two distinct states, two responses (the runs race: both workflows wait
# on the release environment independently, and npm dist-tags propagate
# asynchronously, so "tag pushed but latest not moved yet" is a NORMAL
# mid-release state, not a failure):
# - npm-v<version> tag missing -> hard fail. This is the #5822 gap:
# nobody pushed the npm release at all.
# - tag pushed, latest lagging -> poll briefly, then WARN and pass.
# The npm run may still be awaiting its own environment approval;
# release-npm.yml's verify step owns asserting the dist-tag lands.
- name:Check npm latest dist-tag freshness
env:
TAG:${{ github.ref_name }}
run:|
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — npm latest does not move on prereleases; skipping"
exit 0
;;
esac
version="${TAG#v}"
if ! git ls-remote --exit-code origin "refs/tags/npm-v$version" >/dev/null; then
echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)."
echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-<unreadable>} — the npm publish run is likely awaiting release-environment approval or still propagating. Approve/monitor the release-npm run; its verify step asserts the dist-tag lands."
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/9e931d80-2050-4b10-902e-44970cc133ad.svg" alt="oosmetrics — Top 2 in Agents by velocity"/></a>
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/556d94b3-61b7-486b-baf2-888b9327deab.svg" alt="oosmetrics — Top 3 in LLMs by velocity"/></a>
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/0f457d4c-efca-4d15-ad2b-139691ff342c.svg" alt="oosmetrics — Top 3 in CLI by velocity"/></a>
</p>
<br/>
<h3 align="center">A DeepSeek-native AI coding agent for your terminal.</h3>
@@ -55,11 +49,14 @@
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
declared in `reasonix.toml`. No hardcoded models.
- **Multi-model & composable.** DeepSeek (flash/pro) and MiMo ship as presets;
any OpenAI-compatible endpoint is a config entry, not new code. Optionally run
- **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.** External tools run as subprocesses over stdio JSON-RPC
(MCP-compatible). Built-in tools self-register at compile time.
- **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 only dependency is a TOML parser.
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/9e931d80-2050-4b10-902e-44970cc133ad.svg" alt="oosmetrics — Top 2 in Agents by velocity"/></a>
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/556d94b3-61b7-486b-baf2-888b9327deab.svg" alt="oosmetrics — Top 3 in LLMs by velocity"/></a>
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/0f457d4c-efca-4d15-ad2b-139691ff342c.svg" alt="oosmetrics — Top 3 in CLI by velocity"/></a>
</p>
<br/>
<h3 align="center">面向终端的 DeepSeek 原生 AI coding agent。</h3>
MessageBoxMB_RETRYCANCEL|MB_ICONEXCLAMATION"Reasonix is still running. Close Reasonix, then click Retry to continue the installation."IDRETRYretryIDCANCELabort
Gotoretry
silent:
SetErrorLevel1618
abort:
Abort"Reasonix is still running. Close Reasonix and run the installer again."
if(!sendButton)thrownewError("send revision button did not render");
awaitact(async()=>{
sendButton.click();
awaitflushTimers(220);
});
eq(revisions.join(","),"please inspect @README.md","submitted plan revision keeps the selected file reference");
awaitact(async()=>{
root.unmount();
});
dom.window.close();
}
{
constdom=installDom("zh-CN");
mockApp({
ListDir: async()=>[],
SearchFileRefs: async()=>[],
});
const{root}=awaitrenderApproval({
approval:{
id:"sandbox-escape-approval-zh",
tool:"sandbox_escape",
subject:"run unconfined once: go test ./...",
reason:"Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
},
});
consttext=document.body.textContent??"";
ok(text.includes("仅本次不进沙箱运行:go test ./..."),"sandbox escape approval localizes subject in Chinese UI");
ok(text.includes("Windows 沙箱启动这条命令时失败"),"sandbox escape approval localizes English backend reason in Chinese UI");
ok(text.includes("允许一次"),"sandbox escape Chinese approval shows allow once");
ok(text.includes("本会话使用真实环境"),"sandbox escape Chinese approval shows session grant");
ok(text.includes("拒绝"),"sandbox escape Chinese approval shows deny");
ok(!text.includes("总是允许"),"sandbox escape Chinese approval hides persistent grant");
awaitact(async()=>{
root.unmount();
});
dom.window.close();
}
{
constdom=installDom("zh-CN");
mockApp({
ListDir: async()=>[],
SearchFileRefs: async()=>[],
});
const{root}=awaitrenderApproval({
approval:{
id:"memory-approval-zh",
tool:"remember",
subject:"Save/update memory \"prefers-vitest\" [user]: Preferred test framework | body: Use Vitest for frontend tests.",
},
});
consttext=document.body.textContent??"";
ok(text.includes("保存记忆"),"remember approval localizes tool label in Chinese UI");
ok(text.includes("保存/更新记忆 \"prefers-vitest\" [user]"),"remember approval localizes subject prefix in Chinese UI");
ok(text.includes("正文: Use Vitest for frontend tests."),"remember approval localizes body label in Chinese UI");
awaitact(async()=>{
root.unmount();
});
dom.window.close();
}
{
constdom=installDom("zh-CN");
mockApp({
ListDir: async()=>[],
SearchFileRefs: async()=>[],
});
const{root}=awaitrenderApproval({
approval:{
id:"plan-mode-read-only-command-zh",
tool:"plan_mode_read_only_command",
subject:"Trust \"gh issue view\" as a read-only command prefix while planning\nCommand: gh issue view 5867 --json title",
reason:"This bash command is not in Reasonix's built-in read-only set. Confirm only if this exact prefix is read-only for planning and research. Auto/YOLO approval cannot answer this trust prompt.",
},
});
consttext=document.body.textContent??"";
ok(text.includes("计划模式只读命令"),"plan-mode read-only command approval localizes tool label in Chinese UI");
ok(text.includes("在计划模式中信任 \"gh issue view\" 为只读命令前缀"),"plan-mode read-only command approval localizes subject in Chinese UI");
ok(text.includes("不在 Reasonix 内置只读集合中"),"plan-mode read-only command approval localizes reason in Chinese UI");
ok(failed.showRetryInRow,"failed server row should expose retry");
ok(mcpServerRetryableFromAvailableList(server("initializing")),"connecting server should be included in available-list retry all");
ok(mcpServerRetryableFromAvailableList({...server("deferred"),startIntent:"automatic"}),"automatic idle server should be included in available-list retry all");
ok(!mcpServerRetryableFromAvailableList(server("connected")),"connected server should be excluded from available-list retry all");
ok(!mcpServerRetryableFromAvailableList({...server("disabled"),startIntent:"off"}),"disabled server should be excluded from available-list retry all");
ok(!mcpServerRetryableFromAvailableList({...server("failed"),runtimeState:"issue"}),"failed server is handled by the failure banner retry all");
eq(formatShortcutCombo(defaultShortcutCombo("settings.open","windows"),"windows"),"Ctrl+,","formats Windows settings shortcut");
eq(JSON.stringify(formatShortcutComboParts(defaultShortcutCombo("settings.open","windows"),"windows")),JSON.stringify(["Ctrl",","]),"splits Windows settings shortcut for display");
eq(providerBaseURLFromChatURL("https://proxy.example.com/v1/chat/completions"),"https://proxy.example.com/v1","chat URL derives base URL for model discovery");
ok(!document.querySelector(".bot-overview-grid"),"bots tab does not render the removed entry overview");
ok(!document.getElementById("bot-mobile-remote"),"bots tab no longer renders the mobile remote entry card");
ok(!document.querySelector(".bot-channel-entry"),"bots tab no longer renders the Bot Channel entry panel");
ok(!document.getElementById("bot-step-access"),"bots tab omits the old global access step card");
ok(!document.getElementById("bot-step-behavior"),"bots tab omits global default behavior card");
eq(document.querySelectorAll(".bot-step-chip").length,0,"hero no longer shows the old two-step chips");
eq(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]").length,4,"bot manager uses four fixed channel tabs on the left");
ok(document.querySelector(".bot-channel-setup-card")?.textContent?.includes("Configure QQ")===true,"unconfigured QQ tab shows key setup on the right");
ok(document.body.textContent?.includes("Back to entry")===false,"bot manager does not show a return-to-entry action");
"selected bot detail places enable, approval, and model controls between summary and access control",
);
ok(document.body.textContent?.includes("ou_mock_user_001")===true,"selected bot detail shows its trusted user");
ok(document.body.textContent?.includes("Legacy global allowlist")===true,"advanced area keeps the legacy global allowlist");
ok(document.querySelector(".bot-simple-advanced")?.textContent?.includes("local control API")===false,"advanced area no longer owns mobile/control API setup");
eq(historyCalls.length,historyCallsBeforeReturnToA,"cached idle tab skips history hydration when reselected");
awaitact(async()=>{
historyB.resolve([userMessage("late B")]);
awaithistoryB.promise;
awaitflushPromises();
});
eq(controller?.activeTabId,"tab-a","late history for another tab does not change the active tab");
ok(controller?.state.items.some((item)=>item.kind==="user"&&item.text==="cached A")??false,"late history for another tab does not overwrite the active transcript");
ok(!(controller?.state.items.some((item)=>item.kind==="user"&&item.text==="late B")??false),"late history stays scoped to its tab state");
eq(controller?.activeTabId,"tab-d","openProjectTab activates the opened tab");
eq(controller?.state.items.length,0,"open topic keeps the new tab transcript empty while hydrating");
ok(controller?.state.hydratePlaceholderItems?.some((item)=>item.kind==="user"&&item.text==="streaming C")??false,"open topic stores previous transcript only as a hydration placeholder");
awaitact(async()=>{
historyD.resolve([userMessage("history D")]);
awaithistoryD.promise;
awaitflushPromises();
});
eq(controller?.state.hydrating,false,"topic history clears visible hydration before ancillary phase 2 settles");
@@ -54,11 +82,173 @@ function usage(source: string): WireUsage {
console.log("\nuse controller meta");
{
eq(
modelSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing model"),
"The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"model busy guard is localized",
);
eq(
modelSwitchNoticeText("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing model"),
"This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing models.",
"model lease conflict explains the safe path",
);
eq(
modelSwitchNoticeText("workspace is still starting"),
"This session is still starting. Try changing models again in a moment.",
"model startup race asks the user to retry later",
);
eq(
modelSwitchNoticeText('tab "tab-a" changed while switching model; retry'),
"The current session changed while switching models. Try once more.",
"model tab race asks the user to retry",
);
eq(
modelSwitchNoticeText('unknown model "missing"'),
'Unknown model "missing".',
"model unknown error is localized",
);
eq(
modelSwitchNoticeText('model "other/other-model" is not available because provider "other" is not added'),
'Model "other/other-model" is unavailable because provider "other" is not added.',
"model provider access error is localized",
);
}
{
eq(
effortSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort"),
"Reasoning effort cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"effort busy guard is worded as temporary",
);
eq(
effortSwitchNoticeText("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing effort"),
"This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing effort.",
"effort lease conflict explains the safe path",
);
eq(
effortSwitchNoticeText("workspace is still starting"),
"This session is still starting. Try changing reasoning effort again in a moment.",
"effort startup race asks the user to retry later",
);
eq(
effortSwitchNoticeText('tab "tab-a" changed while switching effort; retry'),
"The current session changed while switching reasoning effort. Try once more.",
"effort tab race asks the user to retry",
);
eq(
effortSwitchNoticeText("unknown model \"missing\""),
"Reasoning effort switch failed: unknown model \"missing\"",
"effort true failure keeps the underlying error",
);
}
{
eq(
tokenModeSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing token mode"),
"Token saver cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
"token mode busy guard is localized",
);
eq(
tokenModeSwitchNoticeText('tab "tab-a" changed while switching token mode; retry'),
"The current session changed while switching token saver. Try once more.",
"token mode tab race asks the user to retry",
);
}
{
eq(
localizedBackendNoticeText("Session autosave failed: disk full"),
"Session autosave failed: disk full",
"backend autosave notice is localized through the active dictionary",
);
eq(
localizedBackendNoticeText("Session save failed before changing model: disk full"),
"Session save failed before changing models: disk full",
"backend save-before-action notice localizes the action",
);
eq(
localizedBackendNoticeText('model "old/model" is no longer available; switched to new/model'),
'Model "old/model" is no longer available; switched to new/model.',
"backend model fallback notice is localized",
);
eq(
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as recovery branch 20260706-152144.863947300-longcat-openai-LongCat-2.0-119b7259f151-recovery-693ce51bcbcbaa9"),
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
Loaded 100 of 851 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.