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.
ListSessions gated its decode+backfill fallback on Turns <= 0, which conflated
two cases: a legacy session whose counts were never recorded, and a genuinely
empty session (0 user turns). The empty one could never backfill a non-zero
count, so it was re-decoded on EVERY listing — wasted work each sidebar refresh
for every abandoned/empty session.
Add BranchMeta.SchemaVersion, stamped to BranchMetaCountsVersion only by the
writers that derive Turns/Preview from content (UpdateSessionMeta and
Fork/Branch) — never by EnsureBranchMeta/Touch/rename/set-model, which don't
know the count. ListSessions now gates on SchemaVersion: a counts-authoritative
meta is trusted even when Turns == 0 (skipped without decoding); a legacy meta
is decoded once, then backfilled AND stamped, so an empty session is recorded
once instead of forever. Bumping the version forces a re-derive when the counts'
meaning changes (forward-compat the format previously lacked).
Tests: a recorded-empty session is skipped without decoding (planted content
the meta deliberately contradicts); an empty legacy session is stamped
authoritative-empty after a single listing.
QuickAdd/SaveDoc/SaveMemory/ForgetMemory/QueueMemory held c.mu across the
doc/store write AND the memory.Load re-discovery (a filesystem walk). Because
requestApproval / RuntimeStatus / Running also take c.mu, a memory-panel save
could stall an approval decision or a UI status poll for the duration of that
I/O.
Add a dedicated memMu that serializes memory mutations, and do the disk I/O
off-lock: c.mu is now taken only briefly to read the snapshot pointer
(memSnapshot) and to swap the reloaded snapshot in (applyMemoryWrite), never
around the write or the re-discovery. Write semantics are unchanged — still
serialized with respect to each other, still queue a turn-tail note, still
reflected by the next Memory().
Tests: the snapshot reflects a write (and is a fresh reload); a concurrency
test (8 writers x 5 plus concurrent c.mu readers) passes under -race, proving no
data race or deadlock in the new memMu/c.mu split.
First slice of the broader 'disk I/O under c.mu' finding; the goal-state path
(saveGoalState, 6 call sites in the goal FSM) is left for a separate change.
Since #4882 cached turn count + preview in the .meta sidecar, ListSessions
reads only the small sidecars — a few milliseconds even for thousands of
sessions. The project-session-cache.json layer (sessionDiskCache) was built to
skip re-reading session files on restart, but that win has evaporated: it now
saves single-digit milliseconds while carrying a whole consistency surface whose
per-directory signature was invalidated by every new message (the active
session's size/mtime change busts the dir), forcing a full re-list of the
directory on each sidebar refresh during active use.
Remove the disk cache entirely: the sessionDiskCache type, its
load/getDir/putDir/invalidate methods, the JSON model + version, and the
emitProjectTreeChanged invalidation. ListProjectTree lists directly through
ListSessions, still fronted by the in-memory projectSessionCache. The shared
topicSessionDirSnapshot / signature helpers stay — topicSessionIndexCache uses
them. A leftover project-session-cache.json on disk is simply ignored.
Cold-start benchmark (added): 15 workspaces x 200 sessions (3000 total,
1.2 GB) lists the whole tree in 27ms concurrent / 98ms sequential with
sidecar-only reads — confirming the disk layer is no longer worth its cost.
Three low-risk cleanups from an architecture pass:
- Fork and Branch wrote the new session's .jsonl with content but a
BranchMeta without Turns/Preview, so the first sidebar listing had to decode the
whole transcript and backfill. Populate both from the in-memory messages at
creation (via SessionPreviewFromMessages), making the sidecar authoritative from
the start — the listing fast path now holds for forked/branched sessions too.
- saveGoalState silently discarded json.Marshal / MkdirAll / WriteFile
errors, so goal-state persistence could fail invisibly. Log each failure via
slog.Warn for parity with the other persistence paths.
- copyFileIfExists had a tautological 'err != nil' in an else-if where err is
already known non-nil; drop the dead clause.
ListSessions decoded every .jsonl in full just to count user turns and build
the preview line, so the desktop sidebar's project list was O(total session
bytes). On large histories — or slow / cloud-synced storage where each os.Open
triggers a fetch — this ran into minutes, and the per-directory disk cache was
busted by every new message, re-decoding the whole directory on each refresh.
Persist Turns and Preview in the BranchMeta (.jsonl.meta) sidecar, written
from the in-memory conversation on each autosave snapshot. Session.Save writes
exactly those messages, so the counts are identical to decoding the file.
ListSessions now reads them from the sidecar and only falls back to a one-time
full decode (then backfills) for legacy sessions recorded before this change.
Steady-state listing drops from O(file size) to O(number of sessions) small
reads: ~100x faster and flat regardless of session size in the added benchmark.
Includes sidecar correctness tests and a REASONIX_BENCH-gated scaling benchmark.
Wait for the specific background task job by id so fast terminal failures are still observed after the job leaves the running set.
Co-authored-by: SivanCola <[email protected]>
Preserve stored tool messages during LoadSession normalization while keeping wire-only orphan cleanup in provider sanitization. Tighten the fast path so healthy tool-call histories return the original slice.
Co-authored-by: SivanCola <[email protected]>
Extract NormalizeMessages as the single source of truth for all conversation history repairs (tool-call name backfill, truncated-arg closing, orphan-result dropping, unanswered-call placeholder). Both the agent load path (NormalizeSession) and the provider send path (SanitizeToolPairing) now route through it, preventing the drift that caused #4727 → #4775 → revert churn.
A fast path (tryNormalizeFastPath) returns the input slice unchanged for well-formed histories, keeping prefix-cache keys stable and making repeated normalization a zero-allocation no-op.
LoadSession marks repaired sessions dirty so the next Save persists the fix automatically.
Keep startup capability flags immutable during runtime HTTP session refreshes so expired-session recovery does not race with concurrent capability readers.
Co-authored-by: SivanCola <[email protected]>
Two code paths were splitting MCP server commands on whitespace
without the shouldSplitPluginCommand guard that NormalizePluginCommandLine
has, which truncated any command path with spaces.
1. Frontend AddServerForm / EditServerForm (CapabilitiesPanel.tsx):
split(/\s+/) on the command field before sending to the Go backend.
A path like "C:\Program Files\MyApp\server.exe" became command
"C:\Program" with args ["Files\MyApp\server.exe"].
2. Legacy config parser (mcpjson.go parseLegacyMCPSpec):
Always split the body on spaces. The v0.x config.json format uses
"name=command args..." lines; for paths with spaces like
"ida=C:\PersonalData\Software\Learn\IDA Pro\ida_bridge.bat", this
truncated the command to "C:\PersonalData\Software\Learn\IDA".
Fix: in the frontend, pass the full command string through and let
NormalizePluginCommandLine handle the split on the Go side. In
parseLegacyMCPSpec, only split when shouldSplitPluginCommand returns
true (known runner like npx/node, or quoted command), otherwise keep
the full body as the command.
Fixes e.g. IDA Pro MCP server configuration where the binary path
contains spaces.
Keep startup capability flags immutable during runtime HTTP session refreshes so expired-session recovery does not race with concurrent capability readers.
Co-authored-by: SivanCola <[email protected]>
Route Creation-specific labels through locale strings and preserve the narrower Creation sidebar width without leaking it into classic/workbench layouts.
Co-authored-by: SivanCola <[email protected]>
Fix parallel_tasks foreground worker shutdown, reject dependency cycles, preserve /goal research flags, and persist goal state next to the session.
Co-authored-by: SivanCola <[email protected]>
planApprovedMessage no longer mandates serial execution — it neutrally
instructs step-by-step execution with parallel_tasks for independent
steps. Removed separate planApprovedHint const (merged into message).
Render Markdown transcript content with the same content width used by the transcript viewport so horizontal rules do not spill into the reserved scrollbar column. Reuse that width for startup, resize, branch replay, and clear-context banners.
Co-authored-by: taibai233 <[email protected]>
Co-authored-by: CnsMaple <[email protected]>
Prometheus prompt now suggests parallel_tasks for independent research
directions before planning. /plan-exec routing rules also emphasize
parallel research/exploration across modules.
- Wisdom: write task results to temp files instead of inlining in prompt
- Module preview: /plan-exec shows detected modules as a notice
- Deep scan: walks 2 levels deep (e.g. internal/agent, internal/control)
Before dispatching, scan the project root for source directories
(excluding .git, node_modules, vendor, dist, etc.) and inject the
module list into the conductor prompt. The agent can then route
each step to the appropriate module's context.
- /plan-exec prompt now instructs agent to group steps by module
- Different modules run in parallel batches, same module runs serially
- Prometheus prompt asks for module tags in each plan step
- Goal: clean context per sub-agent, no cross-module pollution
Wisdom: completed task summaries are prepended to dependent tasks'
prompts, so later tasks learn from earlier results.
Goal state: progress is saved to sessionDir on each change; on restart
the controller can detect and suggest resume.
StripGoalMarkers replaces [goal:complete]/[goal:continue] with empty
text and [goal:blocked:reason] with a friendly warning. Applied at the
Message event emission layer so frontends show clean text. Markers
remain in session history for controller parsing.
- Call GoalStrict after SetGoal so strict mode takes effect
- Validate all tasks in runAsBackgroundJobs before dispatching
- Add parallel_tasks to subagent exclusion list
Reads the canonical todo list, starts a goal that analyzes steps for
independence, and dispatches them concurrently via parallel_tasks.
Inspired by OMO's /start-work + Atlas orchestration flow.
Tasks can declare dependencies on other tasks by 0-based index.
The dispatcher runs tasks in topological order: independent tasks
run concurrently, dependent tasks start only after all their
dependencies complete. Validates out-of-range and self-references.
Track consecutive turns without tool calls; after 2 idle turns inject
a reminder to make progress or use [goal:blocked]. Resets on any tool
call, goal completion, or goal reset.
- Evidence audit gating: GoalReadinessFailure() exposed on Agent,
incompleteGoalTodos() now checks both canonical todos and project checks
- Todo enforcement: intercept [goal:complete] when todos remain;
/goal --strict mode disables override escape hatch
- Quality self-check: strict mode injects verification prompt before
final completion (compile, test, requirements check)
- Parallel sub-agent dispatch: new parallel_tasks tool fans out
multiple background sub-agents and aggregates results
- Comprehensive test coverage for all new behaviours
Move the active tab assignment until after the reusable blank title reset succeeds so a failed title-index write does not desynchronize backend and frontend active tab state.
Co-authored-by: SivanCola <[email protected]>
Reset reused blank tab titles only when the topic title source is auto, and keep the stored topic title/source consistent with the returned tab metadata. Preserve manually renamed blank topics when EnsureBlankTab reuses an open blank tab.
Co-authored-by: SivanCola <[email protected]>
Use the ACP delete cleanup path when boot resumes cleanup-pending sessions so ACP metadata sidecars are removed with the transcript artifacts.
Co-authored-by: Cursor <[email protected]>
Continue moving any remaining desktop session sidecars when startup resumes a cleanup-pending delete after a partial trash move.
Co-authored-by: Cursor <[email protected]>
Problem: each desktop tab calls boot.Build() independently, creating its
own plugin.Host and spawning separate MCP subprocesses. With 85 tabs this
meant 85 CodeGraph (node) processes, causing severe system slowdown.
Solution: three-layer change across plugin, boot, and desktop.
### plugin layer (internal/plugin/plugin.go + internal/plugin/lazy.go)
- Host.HasClient / Host.ToolsFor: expose existing clients on a Host so
sharing callers can detect and reuse running servers
- lazySpawn.run: when host.Add returns serverAlreadyConnected, fetch tools
from the existing client and transition to spawnReady instead of failed
- lazyTool.Execute: same handle-already-connected logic for the cache-hit
synchronous path
### boot layer (internal/boot/boot.go)
- Options.SharedHost: optional *plugin.Host to reuse across controllers
- Build: when SharedHost is set, reuse its running clients; register lazy
tools without kick (no immediate process start); pass a no-op cleanup
so Controller.Close doesn't kill shared subprocesses
### desktop layer (desktop/shared_host.go + tabs.go + app.go)
- sharedPluginHost: refcounted wrapper around *plugin.Host, keyed by
workspace root
- buildTabController: acquire shared host at tab build time
- CloseTab: release shared host; last release closes the underlying Host
- lookupSharedHost: non-refcounting lookup for controller rebuild paths
(model switch, effort change, settings reload)
- shutdown: closeAllSharedHosts via defer so hosts are cleaned up even
on panic
- reapOrphanCodeGraph: on startup, kill any codegraph MCP processes not
belonging to the current Reasonix instance, preventing orphan
accumulation from crashes or older versions
This reverts commit f99684a7a2.
A live DeepSeek A/B disproved the premise of #4773/#4775. The "missing
field name" 400 comes from an omitted assistant tool_calls[].function.name,
not from the tool result name:
- tool result with no name -> 200 (accepted)
- function.name omitted -> 400 messages[i]: missing field name
- function.name present but "" -> 200 (accepted)
#4727 already fixes this by dropping omitempty on the function name (it now
always sends the key, empty string when unknown). Backfilling the tool
result name changes nothing for DeepSeek and shipped a comment that the API
contradicts, so it goes.
Retry delayed session artifact cleanup when a new controller starts so crash-left cleanup-pending markers do not become permanent invisible orphans.
Co-authored-by: Cursor <[email protected]>
#4727 dropped omitempty on the assistant tool_call function name and
backfilled empty call names from their results, but left the reverse
direction. An old session (pre-adde2d3e) can carry a tool result with no
name; the wire still omits it, so DeepSeek 400s "missing field name".
Cross-fill the result name from its paired call in pairToolResults,
mirroring backfillToolCallNames. Healthy sessions serialize
byte-identically, so the prefix cache is unaffected.
Closes#4773
Avoid a race in non-cooperative teardown tests and satisfy staticcheck in the ACP delayed cleanup assertion.
Co-authored-by: Cursor <[email protected]>
Mark sessions with unfinished background jobs as cleanup-pending before ClearSession returns so delayed cleanup cannot expose logically cleared transcripts.
Co-authored-by: Cursor <[email protected]>
Follow-up to #4738: regression tests for backfillToolCallNames (id-keyed/positional/unpaired/no-empty fast path) and the #4727 replay through SanitizeToolPairing; return the input slice unchanged when no name is empty; drop the redundant message copy.
Reject explicit CLI resume paths that are pending delayed cleanup so run and serve cannot rebind logically deleted sessions.
Co-authored-by: Cursor <[email protected]>
Old sessions (pre-adde2d3e) saved assistant tool_calls with empty name/arguments, tripping HTTP 400 "missing field name" on replay. Backfill empty names from the paired tool result, and drop omitempty on the function name/arguments keys so the wire always carries them. Healthy sessions serialize byte-identically — cache-safe.
Fixes#4727
Route stable/rc signing through SignPath only once SIGNPATH_RELEASE_SIGNING_READY is set; until the Foundation release certificate clears CSR-pending, stable ships unsigned instead of failing the whole release. Canary keeps signing with the test certificate.
Reject direct HTTP, ACP, and desktop resume paths for cleanup-pending sessions so delayed-delete transcripts cannot be rebound as ordinary active sessions.
Co-authored-by: Cursor <[email protected]>
Filter durable cleanup-pending markers from HTTP and ACP session list surfaces so delayed-delete transcripts cannot be resumed as ordinary sessions.
Co-authored-by: Cursor <[email protected]>
Close controllers asynchronously after session-specific teardown takes ownership, so delayed cleanup paths return after a single grace window while still reaping job temp artifacts.
Co-authored-by: Cursor <[email protected]>
Sign the NSIS installer through SignPath between packaging and minisign, so the updater's minisign signature and the manifest SHA cover the signed bytes. Canary uses the test certificate, stable/rc the Foundation release certificate. Org id and token come from secrets; the signing contract lives in .signpath/artifact-configurations/windows-installer.xml.
Ensure desktop Clear Session deletes the cleared session's background job sidecar, including the active-runtime path used while jobs are still running.
Co-authored-by: Cursor <[email protected]>
withBuiltinFamiliesForLanguage previously deduplicated by family key,
so a user who had only deepseek-flash (with model deepseek-v4-flash)
in their config caused the entire deepseek family to be considered
'already present'. The sibling deepseek-pro entry was never appended,
making deepseek-v4-pro invisible in the model picker on re-run.
Change deduplication from family-key level to entry-name level so that
all built-in entries within an existing family are appended when missing,
while user-customized entries (same exact name) are preserved as-is.
Add regression test TestWithBuiltinFamiliesRestoresSiblingEntries.
The one-time routing pass is gated by marker files in the global session
dir. Once stamped, a downgrade to a pre-routing build (which writes every
session to the flat ~/.reasonix/sessions dir regardless of workspace)
followed by an upgrade leaves project sessions stranded in the flat dir:
the marker makes the routing pass a no-op, so the desktop sidebar never
sees them and the conversations appear lost (#4666).
Add a bounded incremental re-home pass that runs when the routing markers
are present. It uses the routing marker mtime as a watermark and only
considers flat-dir sessions written after it, re-homing only those that
explicitly name a still-existing workspace (v1+ branch meta scope=project
or a v0.x .meta.json). Global/CLI sessions carry no workspace and are left
untouched, sessions older than the watermark are not revived (so a deleted
import does not reappear), the branch-meta sidecar is copied so the sidebar
keeps title/topic, and source files are never modified. The watermark is
advanced after each scan to keep subsequent boots cheap and idempotent.
655c72ad removed the auto-fetch installer (internal/codegraph) and the
Makefile CODEGRAPH_VERSION it read, but left this job behind. With no
version source it now fails ("CODEGRAPH_VERSION not found"), and nothing
consumes the R2 codegraph/<version>/ mirror anymore — CodeGraph is an
optional user-installed MCP server with a built-in code_index fallback.
- Add 'reasonix review' and 'reasonix init' to usage (en/zh/zh-tw)
- Expose --yolo and --dir flags on the interactive session line
- Add '|import' to the mcp subcommand summary
- Note 'reasonix update' as alias for 'reasonix upgrade'
- Replace 'Codex-enabled' wording with neutral 'MCP servers from cc-switch' in mcpUsage() and /mcp import completion hint
- Add missing 'bot' and 'upgrade' entries to Traditional Chinese usage
Route the bare reasonix command and its interactive flags to the CLI session while keeping chat and code as compatibility aliases.
Update README, docs, website examples, and CLI help to present reasonix as the primary interactive entrypoint.
Persist the canonical provider/model ref in session metadata and prefer it before rebuilding controllers for resumed CLI and desktop sessions.
Co-authored-by: Juan-LukeKlopper <[email protected]>
Keep folder disclosure semantics gated on child presence so empty projects do not toggle expansion, expose aria-expanded, or show an open-folder state.
Add focused runtime coverage for expandable and non-expandable project folder states.
Co-authored-by: SivanCola <[email protected]>
The Transcript component is not keyed by tabId in App.tsx, so React reuses
the same component instance across tab switches. The useScrollManager hook
stores the auto-scroll stick state in a useRef, which also persists.
When the user scrolls up in tab A (stick.current = false) then switches to
tab B, the stale stick ref prevents auto-scroll from pinning to the bottom
of tab B. The same issue occurs when switching back.
Fix: add a useEffect on tabId that resets stick.current = true, so the
existing auto-scroll mechanism (driven by contentVersion changes) correctly
pins new content to the bottom after a tab switch.
When switching sessions via /resume, /switch, /branch, /rewind or /new,
the transcript was never cleared before replaying the new session history.
This caused two bugs:
1. Transcript accumulation — every switch appended the new session to
the old, bloating memory with duplicate content and making re-wrap
progressively more expensive.
2. Scroll position not reliably at bottom — the viewport YOffset was
preserved across SetContent (bubbletea viewport behaviour), and the
outer Update only called GotoBottom when wasAtBottom was true before
the switch. A single line of scroll before switching was enough to
break this, leaving the viewport at a stale offset in the middle of
the merged content.
Fix:
- replayActiveBranch and resetFreshContextView now clear m.transcript
before replaying (or simply setting transcriptDirty for /new).
- A forceGotoBottom flag forces GotoBottom after any session/branch
switch regardless of the previous scroll state, and is cleared in the
same Update cycle.
Route Desktop navigation through session-scoped runtimes so switching or hiding views no longer cancels active work, and remove the user-facing top tab strip in favor of the sidebar.
Co-authored-by: Cursor <[email protected]>
* feat(desktop): add workspace status bar items
* Fix DeepSeek billing source breakdown
* Address billing source review feedback
* fix(desktop): reset telemetry and context panel after /clear
Fixes#4186
Sub-bug (a): /clear sometimes fails to clear context (stale events
re-populate state after reset). Fix: guard usage handler with
turnActive check so stale events from the previous session are dropped
after the reset action zeros the state.
Sub-bug (b): token info and workspace context not refreshed after
/clear. Three causes:
1. Tab telemetry not reset — ClearSession() resets the executor session
but tab.usageTelemetry/readTelemetry accumulate forever. Fix: add
resetTelemetry() and call it from App.ClearSession().
2. ContextPanel not refreshed — dockRefreshKey only bumps on turn_done,
so the panel keeps showing stale data. Fix: bump dockRefreshKey in
confirmClearContext.
3. Stale info fallback — ContextPanel line 212 falls back to
info.usedTokens when context.used is 0, which is stale. Fix: clear
info state when context.used and sessionTokens are both 0.
* Fix clear-session context refresh races
Discard stale ContextPanel responses after clear-session refreshes and let ClearSession failures surface to the caller before showing success.
Co-authored-by: ashishexee <[email protected]>
Co-authored-by: lizhengwu <[email protected]>
* Address clear-context telemetry review
* fix(config): localize DeepSeek official pricing defaults
* fix: default built-in pricing to CNY
* Reset official pricing defaults once on desktop upgrade
* Update CLI migration test for config version 3
---------
Co-authored-by: lizhengwu <[email protected]>
Co-authored-by: ashishexee <[email protected]>
Co-authored-by: Codex <[email protected]>
PruneStaleToolResults runs before compact() in maybeCompact, so a stale
tool result larger than minPruneBytes starting with "error:" or "blocked:"
was rewritten to an [elided ...] marker before the KeepErrors predicate
ever saw it (and pruning alone could skip compaction entirely). With the
new default agent.keep = ["errors"], long build/test failures beyond the
recent tail were therefore lost despite the keep policy.
Skip error/blocked tool results in PruneStaleToolResults when KeepErrors
is set, so the verbatim content survives to compaction's keep pass.
Addresses codex review discussion_r3409675037 on PR #4392.
Transfer the finalized compaction retention policy work from PR #2407 onto the latest main-v2 base.
Keep retained tool-call groups intact, scope keep-policy retention to the latest fold window, expose keep/recent_keep config, and wire the options through agent, planner, skill, and task sessions.
Mark stale running subagent metadata as interrupted on startup and ensure background task panics persist failed metadata instead of leaving ghost running refs.
Co-authored-by: Cursor <[email protected]>
Recover panics inside managed job goroutines and surface stalled jobs through existing notices and background-job notes.
Co-authored-by: Cursor <[email protected]>
Define the threshold in config so stalled-job behavior is explicit, testable, and renderable in generated TOML.
Co-authored-by: Cursor <[email protected]>
A steady slowdown re-tripped the lag/long-task threshold every cooldown
window, so the report prompt kept reappearing even after the user already
reported it. Suppress a pressure label once it is reported (persisted per
build so a new build can re-surface it once) or dismissed (session only),
keeping the existing cooldown and hidden-window guards as fallbacks.
#4323 pinned esbuild to 0.28.1 (npm overrides + pnpm-workspace overrides)
to pick up the patched release. But vite 6.4.2 depends on esbuild
^0.25.0, and 0.28.1 breaks its dev dependency pre-bundling: every dep
using destructuring fails with "Transforming destructuring to the
configured target environment is not supported yet", so `wails dev`
never starts. The advisory the pin targeted was already fixed in 0.25.0,
which vite enforces anyway.
Cap the override at ^0.25.0 (>=0.25.0 <0.26.0): still patched, matches
vite's own range, and blocks Dependabot from bumping back to 0.28.
Resolves to 0.25.12. site/ and workers/ keep 0.28.1 (Astro/Wrangler are
unaffected by the vite dev path).
package-lock.json is regenerated by npm 11 — reformatting only, no
dependency drift beyond esbuild.
CloseTab released a.mu before Snapshot() wrote the session file, so a
concurrent DeleteSession could trash the file and the deferred Snapshot
rewrote it — the deleted session 'resurrected.' Two vectors:
1. The explicit Snapshot() after delete(a.tabs).
2. The background tabSnapshotLoop holding a raw *WorkspaceTab pointer.
Fix: after the final snapshot (and skipping the detach path), clear the
controller's session path so future snapshots no-op, and drain any in-flight
autosave loop via a closing flag + sync.Cond before CloseTab returns.
Disk I/O stays outside a.mu. Adds regression tests proving no resurrection
and that survivor tabs keep autosaving.
Fixes#4384.
Fixes#4317
renderTranscript dumps full tool-call arguments (including sub-agent
task prompts) into the text sent to the summarizer. The summarizer may
reproduce these arguments verbatim in its output, which then gets
inserted as a role:user message in the session. This leaked text looks
like user input and can corrupt the message structure sent to the API.
Fix: replace raw arguments with a short summary (key names + count) so
the summarizer knows what tools were called but can't reproduce the
full argument text.
Two distinct bugs in latexNormalize.ts | handling that surfaced as
broken math rendering in the chat:
1. Array column specs corrupted
\begin{array}{c|c} was rewritten to \begin{array}{c\vert c},
causing KaTeX "Unknown column alignment: \vert" parse errors. In
LaTeX, the | inside an array/tabular preamble means "draw a
vertical rule" between columns and must not become \vert.
Fix: COLUMN_SPEC_ENVS lists environments whose first {...} arg is
a column spec. When \begin{<env>} is found, the spec brace group
is copied verbatim (no | or % rewriting). Pipes outside the spec
still convert to \vert normally.
2. Ket delimiters in GFM tables render as double bars
In Markdown tables, | is the column delimiter, so an LLM writes
kets as \|uud\rangle to avoid breaking the table. But \| in KaTeX
is the "parallel-to" symbol (U+2225) the heavy double bar used for
norms, not the single bar (U+2223) kets use. The result: kets
rendered with double bars instead of single bars.
Fix: fixKetPipes() converts \| to \vert when it is a ket opener
(\|...\rangle) or bra closer (\langle...\|), while preserving
matched \|...\| norm pairs. Disambiguation is by forward scan to
the next \| or \rangle, with a backward scan for unmatched
\langle to catch bra closers.
Tests: 121 passed, 0 failed (was 109; +12 new golden tests covering
column-spec preservation, ket/bra conversion, norm preservation, and
end-to-end KaTeX render of all three cases through the full pipeline).
Co-authored-by: Xingbo Zhao <[email protected]>
Fixes#4166
When planner_max_steps=0 is set in global config (e.g. via Desktop Settings),
the project-level reasonix.toml generated by RenderTOML always wrote
planner_max_steps=12 as an explicit value. Since project config has higher
priority than global config in LoadForRoot(), the project's 12 silently
overrides the user's 0.
Comment out max_steps and planner_max_steps when they equal Default() values,
same pattern already used for system_prompt, language, etc.
Resolve desktop/runtime conflicts while preserving the async Wails event emitter and topic-session index cache. Keep detached runtime telemetry and snapshot ownership on detached runtimes, and restore ClearSession cleanup error propagation.
Co-authored-by: SivanCola <[email protected]>
Avoid repeated full session scans when initializing new tabs via a signature-validated topic-session directory index (self-correcting on file changes, with explicit invalidation on metadata updates), and speed up legacy session migration with session-order metadata and lighter pre-load filtering.
Closes#4385
Co-authored-by: SivanCola <[email protected]>
Add image-capable MiMo models (mimo-v2.5, mimo-v2-omni) to the official MiMo API and Token Plan presets, and infer vision support only for those models on official MiMo endpoints (api.xiaomimimo.com / token-plan-cn.xiaomimimo.com) — mimo-v2.5-pro stays text-only and explicit provider vision still wins for custom gateways.
Co-authored-by: SivanCola <[email protected]>
Render full and inline diff rows against a shared scroll width so add/delete backgrounds fill long horizontally-scrolled lines, add old/new line-number gutters (sticky during horizontal scroll), and fix tool-card diff summaries for failed writer results and multi_edit replace_all.
Co-authored-by: SivanCola <[email protected]>
Route per-tab Wails runtime.EventsEmit through an async, in-order emitter so a backed-up webview event channel can't block while the agent's event.Sync lock is held. Tab sink context is now mutex-guarded.
Co-authored-by: SivanCola <[email protected]>
Add a General settings layout-style option (classic default + Codex-inspired workbench sidebar with compact project grouping, project/session quick actions, pinned grouping, archive behavior). Persist project/topic pinned state across desktop config (backward-compatible, dedupes, drops stale pins). layout_style is UI-only and defaults to classic. Integrates #4253 by @lizhengwu.
Co-authored-by: SivanCola <[email protected]>
Render streamed bot replies at semantic boundaries / length limits with a final flush on turn completion, persist concrete bot session IDs so linked sessions open from the desktop UI, accept QQ expires_in as number or string, and keep Feishu/Lark WebSocket intake alive after SDK Start exits (treating topic_group as group messages).
Closes#4372Closes#4293Closes#4283
Co-authored-by: SivanCola <[email protected]>
Add a CodeGraph built-in MCP update path (reasonix codegraph update) with desktop background checks in notify (default) / download / auto_next_session modes. Downloads verify the asset against SHA256SUMS and sanitize the version before any path/URL use; default notify only surfaces a notice and running MCP sessions are never hot-swapped, so provider-visible tool schemas don't change mid-session. Reconnect adopts the shared codegraph.MCPSpec helper (#4363).
Co-authored-by: SivanCola <[email protected]>
Seed the agent's canonical todo state from approved plans so later complete_step calls advance the visible panel, and rebuild restored desktop history by replaying todo_write/complete_step only on explicitly successful tool results (missing or errored results no longer count as completed). Hide the panel once all todos are done, keyed to stable content rather than array identity. Integrates #4159, #4230, #4271, and #4309.
Closes#4259Closes#4105Closes#4170Closes#4241
Co-authored-by: JesonChou <[email protected]>
Co-authored-by: ashishexee <[email protected]>
Co-authored-by: GTC2080 <[email protected]>
Add a header EN/中文 language switcher (localStorage), chart axis ticks/gridlines/hover targets, grouped status+facet filter chips, row-level clickable report cards, and a compact group-detail page with collapsible samples and copy actions. All report-derived fields stay esc()-escaped. Adopts the Report/诊断 naming from #4322.
Add a user-triggered bot connection diagnostic report on top of the #4322 crash/performance pipeline. Per-connection metadata is reduced to booleans/counts/enums (no remote IDs, session IDs, workspace paths, or secret env names), and free-text summaries run through scrubSensitiveText (now also redacting bare *_SECRET/*_TOKEN env identifiers) plus the Go-side report scrubber. The crash Worker accepts the new `bot` kind.
Worker must be deployed before the desktop release that sends bot reports (old kind enum rejects them).
Add English and Chinese Bot Guide sections for running the bot as a headless gateway via reasonix bot start: channel selection (--channels qq,feishu,lark,weixin), per-connection config, credentials kept in env/credential store, session mappings, and the mandatory allowlist requirement. Refs #4371.
Add a Chinese desktop Hooks guide (settings scopes, project trust, JSON shapes, event keys, stdin payloads, exit-code behavior, examples) and link it from the Chinese usage guide. Correct the docs, desktop settings copy, and /hooks trust notices so they no longer claim /new reloads saved hook config — /new reuses the in-memory hook runner, so a restart is required.
Move elapsed time and request count into the compact runtime metrics row and give session tokens a full-width metric card (reusable context-panel__metric--wide) so large token totals stay readable.
Extract a shared codegraph.MCPSpec helper so the built-in CodeGraph MCP spec (daemon idle-timeout env, codegraph_ prefix strip, low-priority, read-only tool names) stays identical across normal boot, token-economy on-demand enablement, and manual MCP reconnect. The on-demand and reconnect paths previously drifted and omitted the idle-timeout env (and prefix/priority), which this centralizes. Follow-up to #4332.
Advertise ACP session config options and legacy model metadata, implement session/set_config_option (model + effort) and legacy session/set_model, and rebuild the session controller with session-local model/effort while carrying over transcript, history, session path, and MCP servers. Config changes during an active turn are queued and applied after it finishes; rebuilds reuse lifecycle state so SessionStart/SessionEnd hooks do not fire spuriously.
Co-authored-by: SivanCola <[email protected]>
Resolve CodeQL alerts with real fixes: reject zip-slip and unsafe/dangling symlink targets in archive extraction (allowing only in-bundle relative links that resolve inside the root), reject the !shell shortcut over the localhost HTTP frontend, contain session-resume paths to the session dir via EvalSymlinks, scope frontend @file references to the workspace root through os.OpenRoot, and tighten checkpoint path containment with filepath.IsLocal.
Migrate the v0.5.3 QQ bot path into the current bot config (legacy import keeps the App Secret in the credentials store, never the TOML, and enables the allowlist fail-closed). Harden the QQ gateway: official token/expires handling, discovered-gateway host allowlist, C2C openid identity, heartbeat bounds, sandbox/formal endpoints, passive-reply chunk cap, and path-escaped REST sends. Simplify the desktop QQ settings flow and gate enable on a configured allowlist.
Pin the transitive esbuild resolution to the patched 0.28.1 via npm and pnpm overrides for site, workers/crash-report, and desktop/frontend, and refresh both lockfiles so Dependabot resolves the patched release. Astro, Wrangler, and Vite stay unchanged to keep the blast radius minimal.
Add a user-initiated performance-pressure report prompt when the desktop observes long tasks, event-loop lag, or high JS heap pressure. Captures coarse diagnostic context (timing, heap, visibility, network, breadcrumbs, version, OS), scrubs it through the existing crash-report pipeline as kind: performance, and lets the Worker/dashboard ingest it with no DB migration.
Follow-up to #4285. Worker must be deployed before the desktop release that sends performance reports (old kind enum rejects them).
Replace the desktop app icon with a blue-background single-mark design (R + whale), add the SVG source under the build-asset ignore exceptions, and regenerate the Windows ICO so macOS, Linux, and Windows share consistent artwork.
Closes#4321
Add a PermissionRequest hook that fires only when a real tool approval prompt is
emitted (policy allow, session grants, auto/yolo, and plan approval do not
trigger it). The remember/forget payload is fully redacted so private memory
content never reaches the hook.
Closes#3661
Co-authored-by: AresNing <[email protected]>
Co-authored-by: SivanCola <[email protected]>
Refactor desktop typography into platform-aware UI/content/mono font tokens, add an xxlarge text-size step with status-bar overflow safeguards, and add UI/mono font-family controls with local-font availability filtering.
Closes#4315
Add a workspace-confined move_file(source_path, destination_path) built-in so
file moves/renames go through the Edit permission family instead of shell mv /
Move-Item, where prefix-scoped approvals would keep re-prompting. Both endpoints
are confined and permission-checked (deny > ask > allow across source and
destination); cross-device and case-only renames are handled.
Closes#4310
- Add content-visibility: auto on .msg / .tool / .turn-collapse / .process-card
so off-screen elements are skipped during paint — reduces layer texture memory
from ~97 MB down to only the visible viewport
- Remove the blinking <span class="cursor"> injected by Markdown.tsx into
streaming markdown; each blink triggered a full document paint (paint count
grew linearly with output), which was the root cause of frequent flicker and
high layer memory
Add a General settings option for desktop layout style, keeping the classic layout as the default while adding a Codex-inspired workbench sidebar.
Implement workbench-only navigation density, project/session quick actions, pinned project and session grouping, and desktop layout persistence.
Co-authored-by: SivanCola <[email protected]>
Keep the common ListTabs path under an RLock with a read-only ordering snapshot, then reacquire the write lock only when stale tab order needs repair. Add a race regression for concurrent ListTabs calls.
Co-authored-by: SivanCola <[email protected]>
Use a session snapshot when resolving archived tool data so the desktop query does not race live session writes. Also make archived tool cards expandable when only lazy-loaded output is available, restore shell previews from fetched output, and keep the new frontend tests typecheck-safe and deterministic.
Co-authored-by: SivanCola <[email protected]>
Use CodeGraph’s idle-timeout knob instead of killing the root-scoped daemon from controller cleanup, so active clients on the same project keep their shared daemon alive.
Harden explicit daemon cleanup by reading the structured daemon.pid lockfile and requiring a matching socket hello before sending a direct kill, preventing stale log/PID reuse from killing unrelated processes.
Route Desktop navigation through session-scoped runtimes so switching or hiding views no longer cancels active work, and remove the user-facing top tab strip in favor of the sidebar.
Co-authored-by: Cursor <[email protected]>
Combined test suites from both branches: reasoning-display.test.ts,
crash-reporting.test.ts (upstream), tool-data-archive.test.ts,
render-optimization.test.ts (ours).
- E2: normalizeDisplayMode default changed from 'compact' to 'standard'
to match getDisplayMode (fixes inconsistent fallback)
- A4: history archive skips items with empty output string (avoids
unnecessary object allocation for already-small data)
- A5: ToolCard resets fullData cache when item reference changes
(prevents stale tool data after rewind)
- D3: confirmed MAX=30 cap already exists in breadcrumbs.ts (no fix
needed — review was a false alarm)
useDeferredValue caused the rendered markdown to jump from the stale
(shorter) deferred text to the current (longer) value, creating a
visible flash when streaming long code blocks. Using text directly
ensures incremental rendering with no jumps.
The rAF batch already provides sufficient coalescing (~16ms per flush)
for smooth streaming without useDeferredValue.
historyMessagesToItems creates items with full args/output, bypassing the
tool_result reducer. Added archiving in the 'history' action handler so
history-loaded tools also get args truncated and output dropped.
Root cause: v1.7.0 introduced multiple compounding factors that froze the UI
for seconds when switching chat sessions on Windows 11.
Changes:
1. ListTabs: a.mu.Lock() → a.mu.RLock()
Read-only operation was using the exclusive write lock, blocking all
concurrent tab operations including the 2-second polling timer.
2. SetActiveTab: save tabs outside the write lock
Split saveTabsLocked into saveTabsCollectLocked (gathers data under mu)
+ saveTabsWrite (disk I/O outside mu). Added tabsSaveMu + version
stamping to prevent stale snapshots from overwriting newer ones.
3. handleTabChange: deduplicate ListTabs call
reconcileTabRuntime already fetched TabMeta[] via ListTabs; return it
through switchTab and reuse in handleTabChange instead of calling
refreshTabMetas (another ListTabs).
4. ReplaceFile: reduce max retries from 8 to 3
Worst-case backoff drops from ~720ms to ~120ms on Windows transient
locks.
5. Added TestSaveTabsSkipsOlderSnapshot to verify version-stamping.
Three root-cause fixes for the 600MB→2GB→600MB memory spike during
streaming model output:
1. LiveAssistantMessage (Transcript.tsx): wrap object in useMemo
so React.memo on AssistantMessage is not defeated by new object
identity on every render → stops the cascade through Markdown when
text hasn't changed.
2. Markdown (Markdown.tsx): wrap normalizeMath(deferred) in useMemo so
the full-text math normalization is skipped when deferred value is
stable.
3. HljsCode (editors/HljsCode.tsx): add React.memo + useMemo for
highlightToHtml so code blocks are not re-highlighted when value
and language are unchanged.
Tool cards now only keep tool name + command (200-char args preview) in
items[] after tool_result. Full output is dropped immediately; full data
is loaded on demand via app.ToolResultForTab when the card is expanded.
Running tools still keep full args for subject/UI display; archiving only
triggers on tool_result (completion or error).
Test: 18 tests confirm every completed tool is archived, running tools
keep full data, undefined output doesn't crash, and a 500-tool session
sees 99% reduction in tool string data (7.5M chars → 100K chars).
12 tests verify: archiving triggers at threshold, truncates args to ≤200
chars, drops output to undefined, sets dataArchived flag, does not crash
on undefined output, does not archive running tools, and reduces total
string size by 33% in a 150-tool session.
Add backend ToolResult endpoint (Controller + Wails binding App.ToolResultForTab)
to retrieve full tool args/output from Session.Messages by tool ID.
Frontend truncates oldest tool items (>100) to 200-char previews on turn_done,
loading full data via the backend when the card is expanded.
Prefix cache invariance: untouched — session messages, system prompt, and tool
schemas are never modified by this change.
- Add reasoningComplete flag to LiveStream to track when reasoning finishes
- Auto-close reasoning panel and update label when reasoningComplete becomes true
- Suppress current-turn action buttons (copy/fork/summarize/rewind) while model is running
- Keep previous completed turns' action buttons visible with copy enabled
useController.ts: reasoningComplete logic in reducer (turn_started/reasoning/text)
Message.tsx: reasoningComplete-driven auto-close, label toggle, data-running control
Transcript.tsx: running-guarded final pushTurnActions for hot zone
App.tsx: pass running={state.running} to Transcript
CodeGraph serve --mcp starts a daemon that calls setsid(2) to detach
into its own process group. On Unix, KillTree sends SIGKILL to the
launcher's process group (syscall.Kill(-pid, SIGKILL)) — but the
daemon has already moved to a new group, so it survives and runs until
its 5-minute idle timeout fires. (Windows is unaffected: the Job
Object with KILL_ON_JOB_CLOSE captures all descendants regardless of
reparenting.)
Fix:
- codegraph.DaemonPID(root) parses the last daemon PID from
.codegraph/daemon.log
- codegraph.KillDaemon(root) sends a direct SIGKILL to that PID
- boot.Build chains KillDaemon into the cleanup closure so every
ctrl.Close() path — normal exit, model switch, tab rebuild,
desktop shutdown — reaps the escaped daemon
- Both E2E tests now clean up their daemons; a new E2E test
(TestE2ECodegraphKillDaemon) proves the daemon survives
process-group kill and dies to the explicit KillDaemon call
The previous -0.4em left a small but visible gap between rows. The
exact value for zero gap is derived from katex's glyph metrics:
the \\square strut height is 0.675em (the visible glyph height),
and katex's default display-math baseline spacing is 1.2em. The
gap is 1.2 - 0.675 = 0.525em, so \\[-0.525em] subtracts exactly
that amount, making adjacent rows touch with zero visible space.
Measured empirically: at -0.525em the baseline gap is exactly
0.675em (= glyph height), so bottom of row 1 aligns with top of
row 2. Tests: 145 passed, 0 failed.
The previous translator wrapped each cell in \\raisebox{-0.35em}
to shift glyphs down by the math-axis offset. That doesn't close
the gap because uniform translation is invariant — the relative
distance between row baselines stays at 1.2em regardless of how
much you shift the content within each row.
The right fix is per-row spacing, not per-cell shift. KaTeX display
math uses 1.2em baseline-to-baseline spacing, but a \\square glyph
is only ~0.85em tall and sits centred on the math axis (so its
bottom is ~0.15em below the baseline). Default row spacing leaves
0.35em of white space between rows.
\\[-0.4em] between rows pulls each subsequent row up by ~the
math-axis offset, so consecutive rows touch. The diagram becomes
a single connected shape — the standard Young-diagram layout.
Earlier commits added the raisebox and removed it as we narrowed
in on the actual root cause; this commit replaces both with the
correct \\[-0.4em] approach and reverts cells to plain \\square.
Tests: 145 passed, 0 failed in math-golden.test.ts.
Without this, a multi-row Young diagram has a visible gap between
every pair of adjacent rows: \square sits centred on the math axis
(roughly 0.4em above the baseline), so the bottom of one row's
square is ~0.15em below the baseline, and the top of the next row's
square is ~0.65em below the next baseline — leaving 0.35em of
white space between rows. Young diagrams are conventionally drawn as
a single connected shape, not as disconnected boxes.
Wrapping each cell in \\raisebox{-0.35em}{...} shifts the square
down by half the math-axis offset so consecutive rows touch. The
raise argument uses \\square (or the cell content) directly
without a \\$…\\$ wrapper, because the whole macro is already
inside math mode (whether the model wrote \\$…\\$ or the prose
wrapper added it); nesting \\$ inside \\$…\\$ would break the
katex parser.
Tests: 145 passed, 0 failed in math-golden.test.ts (+1 new
vertical-flush regression test). The translator unit tests now
expect \\raisebox{-0.35em}{...} instead of plain \\square.
The previous translator used \\,\\, between cells, which leaves
a visible 0.1667em gap between every pair of \\square boxes. A
Young diagram should look like a row of *flush* boxes (the
youngtableau package renders them as a connected shape, not a row
of spaced-out squares). Switching to \\! (negative thin space,
-0.1667em) exactly cancels the thin-space margin, so adjacent
cells touch with zero visible gap.
Same width as before (the glyph is the same), just no inter-cell
gap. The translator unit test now expects \\! and the new
regression test pins the spacing behaviour so it can't regress.
Tests: 144 passed, 0 failed in math-golden.test.ts (+1 new).
The previous translator used \\begin{array}{c} (centered), which
causes the shorter rows of a Young diagram to be centred relative
to the longest row — so a (3,2,1) diagram looks like:
[ ][ ][ ]
[ ][ ]
[ ]
instead of:
[ ][ ][ ]
[ ][ ]
[ ]
Switch to {l} so every row's first cell is at the same horizontal
position. This is the standard Young-diagram layout: short rows just
have fewer cells, but they all start at the left edge.
Tests: 143 passed, 0 failed in math-golden.test.ts (+1 new alignment
regression test). The translator unit tests now expect {l} instead
of {c}.
The earlier translator used \\hphantom{x} as the empty-cell
placeholder so that empty cells would have the same width as
filled ones. \\hphantom renders as 'invisible x' — the cell takes
up space but produces no visible glyph. The result is correct
typesetting but useless for the user: the chat shows what looks
like an empty paragraph where the diagram should be.
Switching to \\square (Unicode U+25A1, 'WHITE SQUARE', katex class
mord amsrm) gives an actually-visible glyph per cell, which is
what a Young diagram is supposed to look like. \\square is in
amssymb, which KaTeX bundles by default. Width behaviour is the
same as \\hphantom{x} (the glyph is the same width as a lowercase
x in KaTeX's AMS font).
Verified with the user's exact reproduction: the
"12 invisible boxes" string now produces 24 visible \\square
glyphs (12 cells, each rendered as both MathML and HTML), no
katex-error, no transparent text — the diagram is now visible.
Tests: 142 passed, 0 failed in math-golden.test.ts. The unit
test that checks the substituted array form now expects
\\square instead of \\hphantom{x}.
User-reported after PR #4216 landed: \yng(2,1) inside prose
without surrounding $$…$$ still showed up as raw text in the
chat. The previous design only fired expandYoungDiagrams inside math
blocks, so a bare \yng in prose was never translated — and remark-math
left it as literal text because there's no $ delimiter for it to
match against.
This change moves expandYoungDiagrams to a stateful pre-pass that
runs once at the top of normalizeMathText, before any delimiter
handling. The translator tracks the math-depth counter (0 = prose,
1 = inline math $…$, 2 = display math $$…$$) so it can decide
per-macro whether to wrap the result in $…$ (when outside math)
or just substitute the inner form (when already inside math, where
the surrounding delimiters are preserved).
The implementation now uses src.startsWith + a small parser for the
parenthesised shape and optional {…} content, instead of a single
regex. The previous non-greedy regex couldn't handle shape with
spaces inside (\yng( 2, 1 )) or a content block containing nested
braces, both of which are valid ytableau syntax.
Tests (142 passed, 0 failed in math-golden.test.ts; +10 new):
• \yng(2,1) in prose (no $ delimiters) gets wrapped in $…$
and rendered as an inline Young diagram — the user-reported case
• \yng(3,2,1), \yng(4,3,2,1), \young(2 1) (various shapes)
• \yng(2,1){a&b\\c\\d&e} renders as a filled Young tableau
• Unit tests on the translator (with / without content;
pass-through for non-Young macros; bare-in-prose wraps in $…$)
• Step-3 brace repair regression test from the previous commit is
preserved
When switching back to a plan-mode tab, the 3-button modal
(revise/execute/exit) was lost because ReplayPendingPrompts() was
only called on initial mount, not on tab change. The backend still
held the pending exit_plan_mode approval event but the frontend
never re-emitted it.
Root cause: ReplayPendingPrompts() was only in the mount-effect,
never triggered when activeTabId changes.
Fix: add a useEffect that calls app.ReplayPendingPrompts() on every
activeTabId change, so switching back to a plan-mode session
rebuilds the approval modal.
Follow-up to #4250: flush stale MEMORY.md entries when the active file is absent, archive the old active copy + stale index line on cross-scope save, wait on already-killed-but-unwinding jobs in DestroySession, and route memory suggestion generation/acceptance through the selected Memory panel tab. Each fix has a regression test.
Co-authored-by: lifu963 <[email protected]>
Co-authored-by: ashishexee <[email protected]>
Co-authored-by: AresNing <[email protected]>
* feat: rewind undo banner + scroll to rewound message
- UndoRewindBanner: shows above composer after non-fork rewind, displaying
turns rolled back and files changed, with two-click confirm undo button
- Silent fork before rewind: creates a backup tab that undo switches to,
fully restoring the pre-rewind session
- rewindSignal prop: Transcript scrolls to the last user message after
rewind so the user sees exactly where they rewound to
- CSS: .undo-rewind styles matching the composer area
- Locales: undoRewind keys in en/zh/zh-TW
* fix: disable rewind button for compacted turns
CheckpointHasBoundary previously only checked whether the cpBound[turn]
key existed. After compaction the key remains but its value is stale
(boundary > len(Messages)), so the UI showed the rewind button as
enabled despite the backend refusing the operation with 'conversation
rewind unavailable for turn N: the conversation was compacted past
this point'.
Now CheckpointHasBoundary also validates boundary <= len(Messages),
so compacted turns get CanConversation=false and the frontend button
is correctly disabled.
* fix: optimistic rewind + checkpoint boundary corrections
Frontend:
- Optimistic rewind: immediate UI truncation, undo via restore,
deferred Go Rewind on actual send. Composer refill with
rewound-to prompt. Code-only rewind skips truncation.
- displayItems memo: switches between full and truncated items
based on rewindState.
- send wrapper: commits any pending rewind before sending.
Go backend:
- CheckpointHasBoundary: also validate boundary <= len(Messages),
so compacted turns correctly show CanConversation=false.
- List(): nil cur.Paths so in-progress file snapshots don't
pollute CanCode backward-propagation.
- RestoreCode: only emit notice when files actually changed
(skip confusing '0 file(s) restored, 0 removed').
* fix: propagate cumulative file count alongside CanCode
actionMeta('code') showed checkpoint.files.length (the current turn's
own files), but RestoreCode(fromTurn) restores all files from turn
fromTurn onward. Now CheckpointsForTab propagates the cumulative
unique file set backwards so the UI shows the true number of files
that would be affected.
* fix(desktop): tighten optimistic-rewind rough edges
Follow-on cleanup to the optimistic rewind in this PR:
- On a deferred-rewind failure, leave the optimistic state cleared (full
transcript restored) instead of re-truncating to a view the Go side no
longer matches; still skip the send.
- Defer the dock/project refresh for scope=both until the rewind actually
commits, so the file dock no longer flashes the un-rewound state.
- Clear the refilled composer when the user undoes the rewind.
- Sort CheckpointsForTab's cumulative Files (Go map order is nondeterministic).
- Drop the unused onDismiss prop from UndoRewindBanner.
---------
Co-authored-by: wufengfan <[email protected]>
* fix: unconditionally enrich shell PATH for MCP stdio subprocesses
MCP plugin 使用绝对路径命令(如 /path/to/wrapper.sh)时,
resolveStdioExecutable 识别到路径中含分隔符后直接返回,
跳过了 defaultStdioShellPATH 对子进程环境变量 PATH 的富化。
这导致 wrapper 脚本中 exec 的子工具(如 uvx、npx)在
macOS GUI 启动环境下找不到,报类似这样的错误:
plugin "MiniMax": read: EOF: stderr: uvx: not found
将 shell PATH 探测从"命令查找失败的回退路径"提升为
resolveStdioExecutable 中独立的、无条件的初始化步骤。
新辅助函数 enrichStdioShellPATH 在 hasPathSeparator 检查
和 lookPathInEnv 之前执行,确保每个 MCP stdio 子进程
都继承用户交互式 shell 的完整 PATH。
影响范围:
- 使用绝对/相对路径命令的 MCP stdio plugins 获得完整 shell PATH
- 裸命令名路径不受影响(之前已能正确查找)
- Windows 行为不变(由独立的 windowsStdioFallbackPATH 处理)
- shell 探测有 2 秒超时保护,无 shell 环境无副作用
- 向后兼容:现有工作配置不受影响
* perf(plugin): memoize the stdio shell-PATH probe
resolveStdioExecutable now probes the login shell for every stdio plugin, not
just on a not-found fallback. The interactive PATH is stable for the process,
so cache the first non-empty probe behind the injectable stdioShellPATH seam —
one login shell per process instead of one per server. Empty probes are not
cached, so a shell that becomes resolvable later is still picked up.
---------
Co-authored-by: weikejia <[email protected]>
#4250 carried an approved plan's writer auto-approval across a user pause
when the next turn matched a fixed phrase whitelist (继续 / continue / …).
That is the brittle intent-detection the project removed elsewhere, and a
false positive silently widens auto-approval. Auto-approval now covers the
execution turn only; any later turn — including "continue" — returns to the
normal per-tool approval posture (ask/auto/yolo still govern it). The plan
still seeds todos and avoids clobbering the model's own todo_write.
Removes approvedPlanActive/approvedPlanContinuationTurn, the execution
marker, and isApprovedPlanContinuation.
The desktop module is a separate Go module the root lint job never
covered, so unused funcs and unchecked errors accumulated unseen. Wire
golangci-lint (v2.12.2, root config) into the desktop CI job and clear
the existing findings: drop 12 unused funcs, make the best-effort
json.Unmarshal/MkdirAll/WriteFile error-ignores explicit, check MkdirAll
in saveProjectsFile, switch a tab.Scope if-chain to a tagged switch, and
replace the deprecated strings.Title with cases.Title.
emitReady returns before touching ctx when a readyHook is set, so the
test behaves identically — this only silences staticcheck SA1012 (do not
pass a nil Context). Follow-up to #4250, which added the test.
Integrates the session ownership foundation from PR #4215 and compatible state-routing fixes from PR #4073 and PR #3935. Contributor attribution and verification details are documented in the pull request body.
* fix(migrate): import legacy default model from v0.x config.json
The v0.x config.json stored the user's model preference in a "model"
key, but the migration struct had no field for it — so every v0.x
migration silently reset the default to "deepseek-flash" regardless
of what the user had configured.
* fix(desktop): run legacy config migration before first config load
On first launch the desktop's restoreOrBuildTabs called config.Load
before MigrateLegacyIfNeeded had run — so when the config file didn't
exist yet, Load fell back to Default() (deepseek-flash). The tab's
model was resolved to Flash and saved to the tabs file before boot.Build
ran the migration and wrote the correct default_model.
Move MigrateLegacyIfNeeded to the top of restoreOrBuildTabs so it runs
once at startup, before any config loading. boot.Build's own migration
call becomes a harmless no-op (destination already exists).
* fix(migrate): resolve legacy model to provider/model ref during migration
Instead of writing the bare model name (e.g. "deepseek-v4-pro") to
default_model, resolve it through cfg.ResolveModel to produce a proper
"provider/model" ref (e.g. "deepseek-pro/deepseek-v4-pro"). This
makes the stored ref explicit and avoids the frontend's toRef having
to scan providers — which could pick the wrong provider if names
overlap.
* fix(migrate): import legacy .jsonl sessions alongside .events.jsonl
The v0.x import only processed *.events.jsonl files, silently skipping
.jsonl sessions that the TS version later wrote in native message format
(ACP, desktop, subagent, and later-version chat sessions).
- Pass 1 (events): prefer .jsonl sidecar when it's newer than the event log
- Pass 2 (.jsonl-only): import message-format .jsonl files without events
counterpart, with .jsonl.bak recovery. Subagent sessions are skipped.
- Pass 3 (subdirectories): recurse into project-scoped subdirs
- transformAndCopyJsonl: flatten legacy nested-function tool calls to the
flat name/arguments format the Go version expects
- Desktop: topic migration now handles both global and project session dirs,
assigning correct scope. Runs for all tabs.
- CLI: resolveCLISessionDir uses the project session dir when cwd maps to
a project, so /resume shows project history.
9 new tests; all 13 original migration tests unchanged.
* fix(migrate): reach .jsonl-only pass for already-routed upgraders
The early return bailed on the routed marker before the v3-jsonl pass
marker was ever checked, so users whose events pass already completed
never imported their .jsonl-only (ACP/desktop) sessions. Gate the return
on both markers; Pass 1 stays idempotent via its dest checks. Also drop
the unused copyFile that golangci-lint's unused linter would reject.
---------
Co-authored-by: Tommy Li <[email protected]>
The model often appends Windows cmd idioms like `2>nul` / `>nul` to discard
output. reasonix runs commands under bash or PowerShell (never cmd.exe), where
`nul` is an ordinary filename, not the null device — so the redirect created an
undeletable file named `nul` (a Windows reserved name) in the workspace root,
which breaks builds and git operations.
Rewrite those redirects to `/dev/null` (bash) / `$null` (PowerShell) in argv,
the single point every shell invocation funnels through. Non-redirect uses of
"nul" (echo nul, grep nul, nul.txt) are left untouched.
Closes#4252
Reopening a session idle past the provider cache window (~24h) auto-elides
stale tool results to cheapen the cold restart. Some users would rather keep
full history at the higher cold-start cost, so expose a toggle.
Adds [agent] cold_resume_prune (default on; the CLI honors it too) gated in
the controller's maybeColdResumePrune via Options.DisableColdResumePrune (zero
value preserves the prune, so existing callers and tests are unaffected).
Desktop surfaces it in the Agent runtime settings section, 3 locales included.
All four collapsible sections (ReadOnlyBatch, TurnCollapse, ToolCard,
Reasoning) now share the same expanded left-padding of 12px, matching
the collapsed state and keeping the visual border indent consistent.
* fix(desktop): map legacy "minimal" display mode to compact
#4225 removed the minimal mode (it was identical to compact) but left
existing users stranded: DesktopDisplayMode() still returned "minimal"
for a persisted toml, which the frontend now rejects, so those users
fell back to standard (verbose) instead of the equivalent compact.
Normalize the legacy value to compact on both the Go read path and the
localStorage hydrate path.
* fix(desktop): drop scroll-behavior:smooth fighting instant streaming scroll
The transcript pins to the bottom during streaming via a direct
scrollTop = scrollHeight write, which #4225 documents as instant to
avoid the perpetual tween-chase jitter. A global scroll-behavior:smooth
on .transcript turns that write into an animated scroll in Chromium,
reintroducing the lag it set out to remove. The two call sites that want
smooth (jump-bar, warm-turn expand) already pass behavior explicitly.
* refactor(desktop): drop dead code left by the GSAP animation merge
- remove the no-op footer-height effect (empty if body, unused ref)
- drop the constant `false` from the hot-zone useMemo deps
- delete unused DUR_SLOWER/EASE_IN_OUT/EASE_SCROLL exports
- correct stale "minimal mode" comments and the entrance-hook usage doc
* refactor(desktop): GSAP-driven animation system for transcript rendering
- Install gsap + @gsap/react with Flip and ScrollToPlugin
- Create useGSAPCollapse hook (dynamic height tween, replaces max-height hacks)
- Create useScrollManager (GSAP ScrollToPlugin, replaces rAF scrollTop thrash)
- Create useEntranceAnimation (fadeIn+slideUp stagger for new items)
- Unify tool__body / reasoning__body / readonly-batch__body / turn-collapse__body
collapse: all use GSAP height animation instead of max-height:5000px
- Fix AssistantMessage reasoning auto-close race condition (userOverridden ref)
- WarmTurnCard: GSAP collapse animation for expand/collapse
- Markdown: O(depth) deepestLastInlineElement replaces O(n) TreeWalker,
skip cursor inject when already positioned correctly
- CSS: remove all max-height transition blocks, add overflow:hidden where needed
- All animations respect prefers-reduced-motion
* perf: fix long-session slowness — entrance animation batching + unified fold borders
- useEntranceAnimation: pre-seed seen set on first mount so history items
never animate; add deps parameter so querySelectorAll only runs when
items.length changes (not on every streaming token)
- useGSAPCollapse: skip gsap.set on initial mount for elements that default
to collapsed (already handled — no change needed)
- Unify border-left + padding across reasoning__body, tool__body,
readonly-batch__body, turn-collapse__body for consistent visual hierarchy
- tool--open class drives expanded padding for tool__body matching the
readonly-batch / turn-collapse pattern
* perf: progressive render — split long-session mount into 3 frames
Before: hot zone (30 turns) + warm zone cards ALL render synchronously
in one frame. For sessions with 200+ turns this blocks the UI for
hundreds of milliseconds.
Now:
Phase 0 (frame 1): only latest 5 turns — instant paint
Phase 1 (rAF): full hot zone (up to 30 turns)
Phase 2 (+80ms): warm zone collapsed cards
- effectiveHotStart replaces hotStartIdx during phase 0
- showWarmZone gates WarmZone rendering until phase 2
- useEntranceAnimation: pre-seed seen set on mount, add deps parameter
so querySelectorAll only runs when items.length changes
* perf(TurnCollapse): skip fold for sub-second step batches (< 500 ms)
When intermediate steps (tools, phases) complete in under 500 ms,
rendering them as a collapsible 'processed' card wastes space and
adds unnecessary UI complexity. Now they render inline as individual
ToolCard / PhaseCard elements instead.
* Revert "perf: progressive render — split long-session mount into 3 frames"
This reverts commit 2b84236118.
* perf: eliminate mount-time layout churn — CSS initial height + single-pass entrance scan
GSAP Performance skill analysis found two bottlenecks in the initial render
path for long sessions (500+ items):
1. useGSAPCollapse called gsap.set(el, { height: 0/auto }) on mount for
every collapsed element (~400 calls). While each is sub-ms, the
cumulative GSAP property-resolution and style-invalidation cost delays
first paint. Replaced with el.style.height = '0px' — the element's
initial collapsed state is already guaranteed by overflow:hidden in CSS.
2. useEntranceAnimation used two separate useEffect calls (mount-only
pre-seed + deps-driven scan), each running querySelectorAll over the
entire DOM subtree. Merged into a single effect with a firstRun flag:
first call pre-seeds the seen set (no animation), subsequent calls only
scan for genuinely new elements.
* Revert "perf(TurnCollapse): skip fold for sub-second step batches (< 500 ms)"
This reverts commit 796f604593.
* fix: useLayoutEffect in useGSAPCollapse to prevent flash of expanded content
useEffect fires AFTER paint, so every collapsible card rendered its full
content for one frame before height=0 took effect — visible as a 'flash
open then collapse' on initial load and session switch.
useLayoutEffect fires synchronously after React commit, BEFORE the
browser paints. The initial style.height = '0px' is applied before the
first frame, so content starts collapsed — no flash.
For toggle animations, useLayoutEffect also means the first frame of the
GSAP fromTo tween is painted immediately (no 1-frame delay), making the
animation feel more responsive.
* perf: streaming scroll + entrance reset + approval exit animation
- Replace gsap.to(scrollTo) during streaming with direct scrollTop=scrollHeight,
eliminating 120ms tween kill/restart jitter on every token
- useEntranceAnimation: add resetKey param to clear seen set + firstRun on
session switch, preventing 500-history-item entrance animations
- Transcript: pass sessionKey to useEntranceAnimation; remove unused
scrollToBottom from useScrollManager destructuring
- ApprovalModal: key={approval.id} fixes React instance reuse bug that
blocked subsequent approval cards; GSAP exit animation (opacity+y, 120ms)
replaces jarring instant-removal pop
* refactor: remove minimal display mode and expandThinking setting
- displayMode: removed 'minimal' option (was identical to compact), now
only standard | compact. Default changed from minimal to compact.
- expandThinking: removed the setting entirely. Thinking process now
always auto-opens while streaming and auto-closes on completion.
Manual toggle is preserved — user expansion is never force-closed.
- Cleaned up associated UI, types, bridge bindings, and locale strings.
* fix: WarmTurnCard close animation starts from correct height
When WarmTurnCard closes, React swaps expanded children → preview
BEFORE useGSAPCollapse measures scrollHeight, so the collapse tween
started from ~40px (preview) instead of the full expanded height.
Fix: render both preview and body always (display:none toggle), capture
scrollHeight before the swap in the click handler, pass it to
useGSAPCollapse via a new prevHeight option so the close tween starts
from the correct (expanded) height.
* fix: update pnpm-lock.yaml to include gsap + @gsap/react
---------
Co-authored-by: wufengfan <[email protected]>
Physics / chemistry / representation-theory answers routinely use
the ytableau (\yng) and youngtab (\young) packages to draw Young
diagrams and Young tableaux:
\yng(2,1) → empty (2,1) diagram
\yng(2,1){a&b\\c\\d&e} → filled (2,1) tableau
\young(2 1) → youngtab syntax (whitespace separator)
Neither macro is bundled with KaTeX, so without this pass the macros
fail with "Undefined control sequence: \yng" and the chat surfaces
the raw LaTeX source as a red error block — same shape as the
comma-fix and step-6 bugs.
This change adds a small pre-pass (src/components/youngDiagrams.ts)
that translates \yng/\young into KaTeX-compatible \boxed{array}
forms. Empty cells become \hphantom{x} so the row width is uniform
and the diagram doesn't look ragged. Content cells are split on \\ and
& at brace-depth 0 (so \frac{a}{b}-style entries survive). The
pass is invoked inside the math body, alongside latexNormalizeForKatex,
in mathNormalize.ts steps 4/5/6 so display, text-mode, and inline math
are all covered.
The repair-regex character class in mathNormalize.ts step 3 also
gains '{' and '}'. A model that writes \end{array}919 or \frac{a}{b}919
on one line has the same micromark-fence problem as the comma case;
including the closing brace lets the closing 919 find a clean
start-of-line and the rest of the document doesn't get consumed as
math.
Tests (141 passed, 0 failed in math-golden.test.ts; +9 new):
• \yng(2,1), \yng(3,2,1), \yng(4,3,2,1) render as the
corresponding Young diagrams via react-markdown + KaTeX
• \yng(2,1){a&b\\c\\d&e} renders as filled tableau
• \young(2 1) (youngtab syntax) renders
• Unit tests on the translator (with / without content;
pass-through for non-Young macros)
• New step-3 regression test: \end{...}919 repaired to
\end{...}\n\n$$
Cherry-picks the maintainer's review patch from PR #3666 onto
dev-new-features so the local Reasonix app renders currency and
env-var tokens as literal dollars instead of triggering katex
errors.
Per maintainer feedback: a literal $…$ pair in normalizeMath
output is not enough to keep a non-math token out of KaTeX, because
remark-math parses any $…$ it sees in the source. The classifier's
reject verdict only holds when the $ is hidden as a $ entity.
Step 6: non-math pairs (currency $5, env vars $PATH) now wrap in
$ entities. Decoded entity still renders as a literal dollar.
Test updates:
- Two eq assertions flipped to expect the entity form.
- Drop the stale '$5$ is filtered to entities' comment.
- New render-boundary section runs the real react-markdown +
remark-math + rehype-katex path; the previous golden cases never
crossed the prose→parser boundary, so the regression was
undetectable at the normalizeMath layer.
132 passed, 0 failed (was 129).
Companion fix to PR #3666 (fix/inline-math-rendering). The character
class in mathNormalize.ts step 3 missed the comma case: a model that
emits a display block whose closing $$ is on the same line as the
trailing comma of the equation content (…D(q^2),$$) leaves the
closing fence glued to the content line, which micromark-extension-math
does not recognise as a closing fence (it only checks for $$ at
the start of a new line). The rest of the document is then consumed
as math and katex fails on the stray $ in the next paragraph.
Add ',' to the character class so the closing $$ is forced onto its
own line, matching the existing 'inline $$ after closing bracket'
behaviour. A regression test pins the comma case.
This commit was previously 45482b20 on fix/inline-math-rendering
(the PR's tip), but cherry-picking the older PR commits onto
dev-new-features (which had this fix from an earlier cherry-pick
overwritten by the older base state) reverted the regex change.
Re-applying it here.
Expressions starting with a unary + or - (e.g. +2, -x, +\alpha)
were rejected by isLikelyInlineMath because none of the existing
patterns matched them — the operator-pattern on line 10 requires
a character before the operator, and the pure-number pattern
requires the first character to be a digit.
This caused \( +2 \) to be treated as non-math text, rendering
as literal '$+2$' instead of rendering the KaTeX unary plus.
Add a dedicated pattern: /^[+\-]\s*(?:\d+(?:\.\d+)?|[A-Za-z\])/
that matches unary operator + digit/variable/backslash-command.
Rebase onto current main-v2 plus five targeted cleanups called out in
the review:
1. Drop the $ escape/unescape dance. Protected segments are stored
out-of-band and swapped back wholesale, so the round-trip is a no-op
— except for code that legitimately contains the literal text $
(which got silently rewritten to $ on restore, corrupting the source).
The header comment is also stale: the description claims restore does
not unescape, but the code did.
2. Revert Step 5's greedy→non-greedy change. The char class
[^$\n]+ already excludes $, so changing + to +? has no effect
on match extent; the comment claiming it prevents cross-pair
matching is wrong. Drop the change and the misleading comment.
The "leave non-math pairs unchanged" behaviour is kept.
3. Restrict fenced-code detection to line-start. Allowing ``` anywhere
in the line would swallow prose like "wrap code in ```blocks``` here"
into a code region and break the math for the rest of the message —
the CommonMark spec requires fences at line start. Single-line docs
are still handled (the next matching fence is the closer).
4. Escape top-level % in math. KaTeX treats unescaped % as a LaTeX
comment char and silently truncates the formula at end-of-line —
"$x = 50%$" rendered as "x = 50" with no error. Add a top-level
case in latexNormalizeForKatex that emits \% (already-escaped \%
is handled above as a 2-char command, so no double-escape).
5. Trim oversized comments. Drop the // was: ... history notes in tests
and the 8-12 line essays in mathNormalize / mathClassify that
describe code that no longer exists or that the reader can see
from the regex. The header still lists the pipeline as a map.
128 tests pass; typecheck clean.
Previously, the Step 5 regex would greedily match '$5 and $' as a single
math expression with content '5 and ', then convert it to '$5 and $'
because the classifier correctly identified it as non-math. This was visually
correct but had two problems:
1. The greedy match would consume the closing dollar that belonged to the
next currency token, causing cascade replacements.
2. Prose currency like 'These two apples cost $5 and $6' would have its
dollar signs converted to HTML entities, which works but is unnecessary
noise in the rendered output.
Changes:
- Step 5 regex now uses non-greedy matching (+\?) so '$5 and $' doesn't
match '$5 and $' as a single pair
- When the classifier rejects a match, the original text is preserved
unchanged (return _m) instead of being wrapped in HTML entities
- This keeps dollar signs visible in prose while still preventing them from
being parsed as math
All 122 tests pass.
Enhancements to inline math detection:
- Reject pure numbers (1, 2.5, 10) as currency/percentages
- Accept numbers with variables (2.5x, 3y^2) as math
- Accept numbers with LaTeX escapes (10\%) as math
- Fix single-line code block detection to protect $ in malformed markdown
This better matches real-world usage where 'costs $5' is currency
but '$2.5x + 3$' is clearly a mathematical expression.
All 122 tests pass (108 math-golden + 8 text-size + 6 provider-model-refresh).
Remove the requirement that ``` must appear after a newline. This
handles cases where documentation is pasted on a single line with
embedded code blocks containing $ symbols.
Previously: ``` markers were only recognized after \n
Now: ``` markers are recognized anywhere
This prevents KaTeX errors (red text) when processing malformed code
blocks that contain $ in regex patterns, template literals, or other
code examples.
All 120 tests pass.
Add defensive escaping for code blocks containing $ characters.
When protecting code (inline `...` or fenced ```...```), replace
$ with $ (HTML entity). On restoration, unescape back to $.
This prevents KaTeX from attempting to parse math delimiters that
appear in code examples, regex patterns, or template literals.
Fixes: Pasted documentation about the math pipeline itself no longer
shows red KaTeX error text.
Tests: 3 new cases added, 106/106 passing
The classifier rules are language-agnostic, not specific to CJK text.
Updated test section name and descriptions to reflect that patterns
like single digits, comma-separated tokens, and one-sided operators
apply universally across languages. Chinese text in test cases remains
as real user examples, but the rules themselves are not CJK-specific.
Three targeted fixes to the math-pipeline pre-pass that resolve cases
where the rendered chat output showed LaTeX source as raw text:
1. mathNormalize.ts (Step 2.5): when the model writes block math with
the opening $$ glued to prose on the same line ('…decomposes
as$$\n\mathbf{6}…'), CommonMark requires a blank line before
the $$. remark-math otherwise creates an empty math node and the
formula leaks out as literal text. Insert \n\n before any $$
preceded by a letter or end-of-sentence punctuation. The
freshly-rewritten \] → $$ from step 2 is not affected.
2. mathClassify.ts: classify single digits ($1$, $2$) as math —
commonly used as set / sequence indices. Multi-digit numbers,
decimals, and percentages stay literal (still currency / percentage).
This is a deliberate behavior change documented in the comment.
3. mathClassify.ts: allow comma-separated tokens ('A, B', '1, 2, 3',
'\\alpha, \\beta', '(A, B)') as math. These are typical of
ordered-pair / tuple / enumeration notation. Currency and env-var
usage never looks like this.
4. mathClassify.ts: allow single uppercase letters as math. In
non-English math prose (Chinese / Japanese / Korean textbooks)
single capital letters are extremely common as set / algebra /
group / vector-space names, and the closing-dollar form $X$ is
essentially never written for English words like I/A/V by hand.
Test changes: 4 existing currency/acronym assertions updated to
reflect the new behavior, 13 new regression tests covering all four
fixes including the user's specific cases ('$1$ 和 $2$' and
'$S$ 非空 / $S$ 有上界'). 98 math-golden tests pass, 112/112 across
all suites, typecheck clean.
Orphan $$ (model wrote display math but forgot the closing $$) is
documented as not-fixed-from-the-renderer: every attempt to rescue
the orphan from the renderer side made the output worse, so the fix
for that case is on the LLM side (post-generation lint or stricter
system prompt).
Follow-up to #4204. Sent images had no size control — an attached photo went out
at full resolution (up to the 10 MB cap), wasting request bytes and image tokens
since vision models downscale server-side anyway.
- internal/control: a vision-only send path (visionImageDataURL) downscales an
oversized image to 1568px on its longest side and re-encodes it — PNG/GIF stay
lossless (screenshots, text, transparency), JPEG/WebP go to JPEG q85 — guarded
against decompression bombs. Best-effort: an undecodable format passes through
untouched. The desktop preview path (ImageDataURL) is unchanged, full res.
- A per-model `vision_detail` (low|high) config flag sets the openai image_url
detail hint; empty = auto/omit. "low" pins an image to ~85 tokens.
- Deliberately no request-body gzip: it only helps the wire (~25%, and
provider-support-dependent) and nothing for tokens, so downscaling is the lever.
Also corrects the #4204 comment that claimed images "break prefix-cache
stability" — they don't (vision-gated, append-only, byte-stable); the real
concern was always cost.
tool__label-group was capped at max-width:70% with overflow:hidden, so a
non-shrinking tool name was hard-cut (e.g. a sub-agent "explore" rendered as
"explor") once the ⊞count + status icon + name reached the cap with no subject
to absorb the overflow. Let the group shrink as a flex unit instead — the
subject keeps ellipsizing, the name stays whole — and pin the duration so a
long subject can't squeeze it out.
Compact/minimal modes fold middle steps into a "processed Xs" line and
batch completed read-only tools (including inside explore/research/task
sub-agent cards); standard mode stays flat and is now the default. Adds
tool status icons, uniform shimmer, and reasoning auto-collapse.
Co-authored-by: wufengfan <[email protected]>
Refs #4158
Attached images were saved to .reasonix/attachments and referenced as @-paths, so
a vision model only ever saw the path text — the message content was a plain
string with no way to carry image data. This embeds them for models that declare
vision support.
- provider.Message gains Images []string (data URLs). Agent history is already
[]provider.Message, so images ride through history, persistence, and both
providers with one field.
- openai: chatMessage.Content becomes `any` — a []chatContentPart array (text +
image_url) for a vision user turn, the string/null shape otherwise.
- anthropic: a base64 image block alongside the text block.
- A model's `vision` config flag (forwarded via Extra) gates emission: text-only
models never receive images (they 400) and their prompt cache stays untouched.
- The controller resolves image @refs to data URLs and carries them via
agent.WithUserImages(ctx, …); the agent must not import control.
Images store inline in history (session bloat is the known tradeoff). Video and
per-frame decoding are out of scope.
Companion fix to the inline-math-rendering PR. The character class in
mathNormalize.ts step 3 missed the comma case: a model that emits a
display block whose closing $$ is on the same line as the trailing
comma of the equation content (…D(q^2),$$) leaves the closing
fence glued to the content line, which micromark-extension-math does
not recognise as a closing fence (it only checks for $$ at the
start of a new line). The rest of the document is then consumed as
math and katex fails on the stray $ in the next paragraph.
Add ',' to the character class so the closing $$ is forced onto its
own line, matching the existing 'inline $$ after closing bracket'
behaviour. A regression test pins the comma case.
* fix(tui): pad completion menu lines to fixed width to prevent ghost cells
bubbletea's delta renderer uses \033[K (erase-to-end-of-line) when a
changed line is shorter than the previous frame. The completion menu
(slash commands, @-references) wrote lines of varying width — one
candidate might be "/review" while the next is "/clear · start fresh",
so delta updates routinely changed widths and \033[K had work to do.
On mintty (MSYS2/MinGW), \033[K is unreliable and can leave trailing
characters from the previous frame — ghost cells.
Fix: pad every completion line (items and footer) to m.width with
padRight(), matching the transcript viewport and status line which
already enforce fixed-width lines. delta renderer now never sees a
width change for completion lines, and \033[K has nothing to clear.
No terminal detection needed — this is strictly a layout correctness fix.
* fix(tui): avoid erase-based completion padding
---------
Co-authored-by: taibai <[email protected]>
Follow-up to #4150. The dropdown is marked up as role=menu/menuitem but was
mouse-only. Add Escape-to-close (focus returns to the trigger), move focus into
the menu on open (the selected option, else the first), and ArrowUp/Down/Home/End
roving between options. The trigger gains aria-haspopup / aria-expanded.
Expose the IM bot entry in production builds, simplify the bot settings
page, and run saved Feishu/Lark/WeChat connections as independent runtime
adapters that stay up when one fails. Adds per-connection access status,
allowlist deep-linking, Feishu/Lark registration QR alignment, inbound
remote remembering, and user-level bot settings shared by desktop + CLI.
Review fixes folded in before merge:
- Authenticate Feishu/Lark approval and Ask card clicks against the real
operator, gating the allowlist on the button presser instead of the
requester id baked into the card; value["user_id"] is routing-only.
- Isolate the requested Feishu/Lark domain so `--channels feishu` / `lark`
start only the matching tenant's connections.
Follow-up to #4150. The dropdown was positioned `left: 0` inside the
`overflow: hidden` sidebar; the trigger sits in the right-aligned header, so on
a narrow sidebar — and with the longer zh labels — the menu ran past the right
edge and got clipped. Right-align it so it opens leftward into the sidebar.
Also fold the duplicated Latest-10 / Latest-20 cutoff IIFEs into one helper;
behavior is identical (same Set-dedup, sort, and short-list fallback).
* feat(desktop): add time filter to sidebar project tree
Add a clock button in the project tree header that opens a dropdown with time-based filters:
- All / Latest 10 / Last 1h / 3h / 5h / 24h
- Pure frontend filtering using topicActivityTime()
- Persistent filter state via localStorage
- Shows empty-state message + clear filter button when no results
* fix(address review): reorder time-filter before collapse button, fix default styling and adaptive width
- Move time filter clock button to the left of collapse button
- Remove fixed width:22px, use min-width:22px so button expands when label shown
- Share combined selectors with add-project/collapse-all for consistent default styling
* feat: add 'Latest 20' option to sidebar time filter
* feat: group time-filter dropdown into three sections (all / count / time) with separators
* fix(review): remove redundant topicMatchesTime branch, add missing focus-visible style
Lets power users type an arbitrary CSS font-family stack (persisted in
localStorage, injected as --font-family-custom). A blank value removes the
property and the CSS falls back via var(), so the stack never begins with a
leading comma that would void the whole font-family.
Co-authored-by: myNinety <[email protected]>
Four users crashed the same day on `rename ...desktop-topic-title-sources.json.tmp: Access is denied` — antivirus / the search indexer / a second instance briefly locks the destination JSON, MoveFileEx fails, and the error reached the frontend as an uncaught [unhandledrejection] that paints the crash overlay.
The desktop's cosmetic-state writers (tabs, projects, topic titles/sources/created-ats, telemetry) did a raw os.Rename(tmp, path) with no retry, bypassing fileutil.ReplaceFile. Harden ReplaceFile to retry with a short backoff while the tmp source survives — a missing tmp means the write itself failed, so no retry can help — keep the EXDEV copy fallback, and route the six sites through it. Branch-meta / session / config writers already on ReplaceFile get the same robustness for free.
Opt-in, default off; preset picker in Settings -> General. Maps the
generation token stream to a pentatonic Web Audio voice (osc -> ADSR ->
low-pass -> reverb) so a long silent wait has audible "still working"
feedback. Throttled (every 4th token, 100ms floor) for DeepSeek's rate.
Co-authored-by: ttmouse <[email protected]>
The desktop.telemetry toggle now also gates resending a crash captured on a
prior run (the Go-side panic path), and the dashboard lists those alongside
user-sent reports. Update the settings hint (en/zh) and the /stats line so the
copy matches what's actually collected — "user-sent ... only" is no longer true.
The crash dashboard only ever saw frontend WebView errors, every group was
an opaque count=1 fingerprint, and minified stacks carried no symbols. This
widens what a report captures and makes the dashboard triageable.
- desktop: capture Go-side panics on the goroutines we own (restore/build/
snapshot), persist a scrubbed stack to crash-pending.json, and resend on
next launch under the desktop.telemetry opt-out. recoverToPending re-raises
so the process still crashes — the stack is recorded, not swallowed.
- worker: store a one-line title per group and surface it in the dashboard
list so a crash is readable without opening each fingerprint.
- frontend: a breadcrumb ring buffer (console.error/warn, tab navigation)
folded into the report. Keep console.warn/error in production — terser
drop_console was deleting the very trail the breadcrumbs collect.
- frontend: preserve function/class names through terser and stamp the build
commit, normalized in the worker fingerprint so grouping stays build-stable.
When the user dismisses an ask (the "just chat" / 先不选择 option, or esc)
the chooser sends empty answers, and formatAnswers rendered that as
"- <q>: (no answer)". The model reads that as "no constraint" and acts
unasked — e.g. it writes a config the user only wanted to discuss.
Treat a fully-declined ask as an explicit stop signal: tell the model the
user chose not to decide and to wait for their next message rather than
take action. Partial answers now mark the skipped questions as
"don't assume a choice" instead of a bare "(no answer)". Shared by CLI and
desktop since both resolve the ask through AskTool.
Opt-in economy token profile: boots with only the core coding tools, moving skills/MCP/CodeGraph/LSP/web_fetch/install_source/task behind an on-demand connect_tool_source. Cuts the first-request prefix by ~56%+ (scales with configured surface). The default/full profile stays byte-identical (provider-visible system prompt + complete tool schemas), guarded by regression tests; economy first-request tool list is pinned too. Per-tab, inherited by new sessions, rejected mid-turn.
os.UserConfigDir() resolves to ~/Library/Application Support on macOS and
%AppData% on Windows, but the rendered config.toml header and every doc
hardcoded ~/.config/reasonix/config.toml — correct only on Linux, so
macOS/Windows users put config where it is never read. Render the resolved
path (home-collapsed) in the config.toml header and document all three OS
locations across README/GUIDE/MIGRATING/SPEC and the package comments.
* fix(desktop): retry transient failures in the updater download
A single mid-stream connection reset — common on CN IPv6 routes to Cloudflare — failed the whole self-update with a raw "forcibly closed" error. Retry the manifest, asset, and signature fetches up to 3 times with a short backoff so a transient RST self-recovers instead of surfacing.
* feat(desktop): resume interrupted updates and fall back to IPv4
Build on the download retry: a retry now resumes from the bytes already received via an HTTP Range request instead of restarting the tens-of-MB installer from zero, and switches to an IPv4-pinned client — a first-try reset usually means the IPv6 route (CN to Cloudflare) is the problem. netclient gains a ForceIPv4 transport option for the fallback.
A manual /compact (or auto-compaction) could show "compacting…" then hang
forever: the call passed context.Background() with no deadline and summarize
ranged over the provider channel without watching ctx, so a stalled stream
pinned the placeholder with no card and no error. Fast failures already
surfaced; an unbounded wait did not.
Bound each summary call to 90s, select on ctx.Done so a stalled stream
unblocks, retry one transient failure, and — when the summarizer is genuinely
unreachable — fold mechanically (the region is already archived) to a
deterministic marker so /compact always frees context instead of aborting on a
still-full window. Verbatim user turns stay untouched and the fold/keep
algorithm is unchanged, so cache behavior matches a normal compaction.
- Simplified ModelSwitcher to match upstream (removed closing animation)
- Added search input with auto-focus for filtering models by name/provider
- Grouped models by provider with accent-colored group labels and Brain icon
- Adjusted width to adapt to content (min triggerWidth, max min(90vw, 480px))
- Added max-height: 50vh with scroll for large model lists
"Reveal in file manager" ran `exec.Command("explorer", ...)`, relying on
explorer being on PATH. On Windows installs whose launch environment doesn't
include %SystemRoot% on PATH, that fails with `exec: "explorer": executable
file not found in %PATH%`, and because the two frontend callers fire the
promise with a bare `void` (no catch), it surfaced as an [unhandledrejection]
in crash telemetry.
Resolve explorer.exe directly from %SystemRoot% (then %windir%), and catch the
reveal promise at both call sites so a failed reveal can never become an
unhandled rejection again.
Surfaces the [tools.shell] preference (added in #4131) in the desktop Settings
panel so users don't have to hand-edit reasonix.toml. The Sandbox tab gains a
"Shell interpreter" select — auto / bash / PowerShell / pwsh — wired through the
existing SetSandbox apply path (snapshot → reload → resume), with en/zh strings.
Closes#4114
emitTodoState emitted every synthetic todo_write with the static ID
host-advance, so multiple complete_step calls within a turn produced
events that the frontend merged into a single item (matching by ID).
The todo panel only rendered the first sign-off update and dropped
subsequent advances.
Add a monotonic atomic.Int64 counter (hostAdvanceSeq) to the Agent and
pass the matched todo item index from advanceCanonicalTodo into
emitTodoState. The tool ID is now host-advance-{seq}-{idx} — the
counter guarantees uniqueness across turns (no cross-turn ID reuse),
while the index maps directly to the task panel position for
debuggability (e.g. host-advance-3-2 means third advance, item 2).
The per-turn ledger, canonical todo state, and all guard mechanisms
are unchanged. Update the hostAdvances test helper to accept any
host-advance-* prefix.
The shell tool auto-detected its interpreter — real bash first, Git-for-Windows
bash next, PowerShell only as a last resort — with no way to override it. A
Windows user who prefers PowerShell but has Git installed was stuck on Git bash.
Add a [tools.shell] config block mirroring [tools.search]: prefer = auto
(default, unchanged behaviour) | bash | powershell | pwsh, with an optional
explicit path. A forced shell that isn't installed warns at startup and falls
back to auto-detection, so a typo can't leave the tool broken. The resolved
shell rides on sandbox.Spec, so it reaches both the agent's bash tool and
user-invoked "!" commands (the controller stores it too). The "fell back to
PowerShell" hint is suppressed once a shell is chosen explicitly.
Cache-neutral by default: prefer=auto resolves exactly as before, so the bash
tool's description (the only shell-dependent part of the prompt) is byte
identical. Forcing a shell changes that description to match — intrinsic to
switching shells, and stable per config.
Part of #4114 (desktop Settings UI follows in a separate PR).
Plan mode produced a full plan even when key decisions were underspecified —
which tech stack, an ambiguous requirement, scope — because the PlanModeMarker
told the model to "explore, then present a layered plan and stop" and never
listed the ask tool among the available ones. The ask tool was registered and
read-only (so the plan gate already allowed it); the prompt just steered past
it.
Add ask to the plan-mode tool list and a high-bar clarify-first step: if a
decision that is genuinely the user's would materially shape the plan and can't
be settled from the codebase or a sensible default, ask first; otherwise pick
the obvious default and state the assumption in the plan rather than asking —
matching the project's prefer-defaults stance and the ask tool's own contract.
The marker rides in the user message, not the system prompt or tools, so the
cache-stable prefix is untouched and the change costs nothing in cache hits.
Add a windows/arm64 target to the desktop release matrix on a native
windows-11-arm runner — Wails can't cross-compile its CGO+WebView binary,
so each arch builds on its own runner. Teach the manifest generator the
windows-arm64 key and skip the portable .zip for any windows- arch, not
just amd64. The build script is already arch-parameterized and the
updater's CurrentPlatform already resolves windows-arm64, so nothing else
changes.
The arm64 build only runs on a desktop-v* tag or a manual dispatch, so PR
CI does not exercise it; the next desktop release validates the runner
toolchain. fail-fast is already off, so an arm64 failure won't sink the
other platforms.
The system-prompt steering text documented the codegraph tools by short name
(`codegraph_context`), but they register under the MCP namespace as
`mcp__codegraph__context` (the daemon's raw `codegraph_` prefix is stripped via
Spec.StripRawPrefix, then namespaced by plugin.ToolPrefix). A model that
followed the steering verbatim called a name that doesn't exist and got
"unknown tool"; the list was also missing `explore`, `node`, and `status`.
Use the real registered names and cover all ten tools. The text stays a static
constant so the cached system-prompt prefix is unchanged byte-for-byte across
sessions with the same codegraph; a new TestSteerTextNamesMatchRegisteredTools
fails if the names drift from ReadOnlyToolNames in either direction, so this
can't silently rot again.
Closes#4111
* feat(cli): add /rename command and show topic title in /resume list
- Add /rename <title> to rename the current session
- Add /rename <n> <title> to rename session #n from the /resume list
- sessionPickerLabel() now prefers TopicTitle over raw preview
- sessionSummary() now prefers TopicTitle over raw preview
- RenameSession() writes topic_title to .jsonl.meta sidecar file
- Add /rename to slash-command autocomplete and /help menu
- Add i18n strings for English and Chinese
- Add unit tests for RenameSession and sessionPickerLabel
Closes #XXXX
* fix(cli): clean up encoding/format in the /rename change
Restore the mojibake-corrupted "subagent · " hint in help_view.go, drop
the UTF-8 BOM and reformat rename_test.go, and gofmt the i18n field
alignment so the lint check passes. No behavior change.
---------
Co-authored-by: yhh <[email protected]>
Ship two MCP servers without requiring user configuration. time uses a bundled, dependency-free Reasonix stdio server (builtin-mcp time) — no uvx/Python/Node needed — and is enabled by default on the lazy tier (no startup cost). context7 runs via npx/pnpm dlx/bunx and stays opt-in. Built-ins are suppressed when a same-name server exists in config or session ExtraPlugins, so explicit user/host config stays authoritative.
* feat(desktop): add hooks settings UI
* test(control): isolate hook trust home on Windows
* fix(desktop): save project hooks to the displayed workspace root
SaveHooksSettings recomputed the active project root, so switching the
active tab while the hooks settings modal stayed open could write the
edited JSON into a different workspace's settings.json than the one
shown. Add a root-aware SaveHooksSettingsForRoot mirroring the trust
flow, and pass the displayed projectRoot from the settings UI.
---------
Co-authored-by: yhh <[email protected]>
On session restore the hot zone renders the recent turns at once, each with the graphite-message-in animation (fill-mode both), so they all fade in simultaneously and flash. Tag .msg elements whose id starts with "h" (history items) with data-history-restore and disable the animation via CSS; live messages (ids a/u/n/p/c/s/e) keep their entrance animation.
* Fix cost currency display
* fix(provider): keep multi-char currency symbols off the ¥ default
The normalizer only passed through single-rune currency signs, so compact
symbols like A$, CA$, HK$, R$ fell back to ¥ and mislabeled non-USD/CNY
spend. Match on any embedded currency sign instead, on both the Go
(provider) and frontend (money.ts) sides.
---------
Co-authored-by: yhh <[email protected]>
Add a session-scoped /diff-fold command to toggle whether writer diffs fold at the line limit (40) or show all lines. Default stays folded — diffMaxLines initializes to diffFoldLimit, so /diff-fold is the expand opt-out. Renames the diffScrollbackMaxLines constant to diffFoldLimit; en/zh i18n, help, and completion are wired up.
sessionCount was declared with let below loadSessions(), which uses it; an early loadSessions() call hit the temporal dead zone and threw a ReferenceError. Hoist the declaration above loadSessions() and remove the now-duplicate one.
Pasting a Markdown-heading prompt (e.g. COSTAR `# Context ...`) into the desktop GUI matched the `# ` quick-add shortcut in submit(), so the whole multi-line input was saved as a memory note and the agent never ran. Reject any newline-containing input in MemoryQuickAddNote: a `#`-leading multi-line block is Markdown, not a note. Single-line `# note` is unchanged; the TUI was already unaffected (paste folds to [P1] before the check).
Fixes#4008
Steer (mid-turn guidance) messages were classified as synthetic and skipped during desktop history replay, so they vanished after a tab switch. Drop the steer prefix from syntheticPrefixes (its only consumer is desktop history replay) and render steer messages as a notice (↪ text) across desktop, CLI TUI, and HTTP/SSE replay via a new agent.SteerText helper. Display-only: the model still sees the raw prefixed message for context.
Closes#4044
Replace the single shared STATS_PASSWORD with email/password accounts,
sessions, and role-based access. New registrations land as `pending`
with no access until an admin grants `viewer` or `admin`; the
ADMIN_EMAILS secret bootstraps the first admin on register. Admins get
user management, crash-group triage (status / note / delete), and an
audit log. The /v1 ingest endpoints stay public and unchanged.
`reasonix upgrade` deliberately tracks the newest 1.x, including rc: only 1.x
carries this command and the 1.x line ships as rc on npm @next, so there is no
stable user to hold back — the original #4005 already picked the newest v*
release regardless of the prerelease flag, and that stays.
Extract the selection into a pure pickCLIRelease (foreign desktop-v/npm-v tags
skipped) and unit-test it without a network round-trip, including a case that
locks in "a newer rc wins". Remove normalizeOS, a no-op returning runtime.GOOS
unchanged behind a multi-line comment; real assets are reasonix-<goos>-<goarch>.
No behavior change.
Adds `reasonix upgrade` / `update`: queries the GitHub releases API, filters to the CLI tag namespace (v*, skipping desktop-v*/npm-v*), compares with x/mod/semver, downloads the platform archive, fail-closed SHA256 verification (mismatch or missing entry aborts), extracts the binary in memory, and replaces the running executable (atomic rename on Unix; two-phase .old/.new rename with rollback on Windows). Includes --check and --force flags and full en/zh i18n.
Closes#3859
A relative [agent] system_prompt_file was read against the process cwd, so Desktop tabs whose cwd differs from the project root loaded the wrong file (or none). Resolve it against the boot-resolved workspace root via a new ResolveSystemPromptForRoot, matching how skills/memory/write-roots already resolve. ResolveSystemPrompt() delegates to ForRoot(".") so the prior cwd-relative behavior is unchanged.
The interactive login-shell PATH probe (zsh -l -i -c) could enable job control and seize the TTY foreground process group, leaving it pointing at a dead group on exit and stopping the TUI with SIGTTIN. Run the probe in a new session (setsid) on Unix so it cannot touch the TUI's controlling terminal; hide the helper window on Windows as before. Covers both the bash tool and the stdio plugin resolver.
Compact/minimal mode folded each completed step into "Processed", which hid
what the user most wants to see: the model's reasoning (stripped entirely in
minimal), its answer text between tool calls, and writer output (bash was
stripped in minimal). Only read-only research should fold away.
Render the hot zone the way the warm zone already does — flat, batching only
completed read-only tools into ReadOnlyBatch. Thinking now shows live while it
streams; answer text and writer diff/output are visible by default; read-only
research stays in the slim batch fold. Drops the now-unused TurnCollapse +
stepGroups machinery and the expandWhileStreaming flag that only existed to
mask the fold flicker.
The native search engine (the fallback when ripgrep is absent) walked
serially and paid two avoidable costs on every run, which a profile of a
6k-file no-match walk pinned down:
- it recompiled the full cumulative .gitignore pattern set into regexes at
every directory, so inherited ancestor rules were compiled over and over
(~36% of the walk);
- it allocated ~72 KiB per file (8 KiB peek + 64 KiB scanner buffer),
churning the GC across the whole tree.
Memoizing the compiled matcher by its pattern set and reusing the per-file
buffers across the serial walk roughly halves the warm full-walk time
(6k-file tree: 1.31s -> 0.62s with ignores, 0.61s -> 0.30s without). The
decoder path is handed a copy of the peek bytes so its goroutine can't
alias the reused buffer after an early return.
Also add a model-supplied timeout_seconds (default 30s, max 300s): a
pathological search now returns the matches found so far with a clear note
pointing at timeout_seconds, instead of hanging for minutes. Both the
native and ripgrep paths honor it through the request context.
MiMo's token-plan gateway returns a transient 401 under load/quota. We
surfaced every 401 as "API key missing, wrong, or expired" and never
retried it, so a working key looked like it had "expired" — re-entering
the same key only helped because saving it rebuilt the connection.
- SendWithRetry backs off and retries a 401/403 up to maxAuthRetries when
the key has authenticated before (SendOptions.RetryAuth); a key that has
never worked still fails fast, so a genuinely bad key isn't hammered.
- AuthError gains HasKey so the message separates "no key configured" from
"server rejected your key (possibly transient)".
Closes#3146
* fix: new session inherits settings from active tab (#4019)
EnsureBlankTab now inherits model, effort, mode, toolApprovalMode,
disabledMCP, and mcpOrder from the currently active tab when creating
a blank session. Previously model and effort were left empty (falling
back to config defaults), mode was hardcoded to normal, and
toolApprovalMode was hardcoded to ask.
The indexed-topic fallback path (which used to delegate to
OpenProjectTab/OpenGlobalTab and therefore bypass inheritance) now
builds the tab inline so it receives the same inherited settings.
Fork also now carries toolApprovalMode (the only setting missing from
its carry-over set), so forked sessions preserve the same approval
posture as the source.
Result: clicking + on the tab bar, or forking a session, keeps the
same model, effort, collaboration mode, tool-approval posture, and
MCP state that the user already configured.
* test(desktop): cover new-session settings inheritance
Regression for #4019/#4088: a blank tab opened from an active session
must inherit effort, mode, tool-approval, and MCP state instead of
falling back to hardcoded defaults. Mutation-checked — disabling the
inherit block fails this test.
---------
Co-authored-by: JesonChou <[email protected]>
Co-authored-by: reasonix <[email protected]>
* fix(ui): batch completed read-only tools, unify body styles, add subagent nesting
- ReadOnlyBatch: extract to standalone component, fix JS reference bug
(items={[...roBatch]} instead of items={roBatch})
- Skip partial tool_dispatch events so tool name + args appear at once
- WarmTurnItems + TurnCollapse: use isReadOnlyTool(name) for read-only
batching instead of it.readOnly boolean (which is false for history)
- Running tools render directly (not folded) in compact/minimal mode
- TurnCollapse body: group completed read-only tools into ReadOnlyBatch
- Add .tool--subagent border-left on body for task/explore/research cards
- Add ⊞N nested count indicator on sub-agent tool heads
- Remove dead .tool__summary CSS and related selectors
- Unify reasoning__body, notice__body, warm-turn__body padding/font
- Clean up .tool__nested styling with recursive nesting border
- Add isReadOnlyTool() for session restore (useController.ts)
- i18n: add tool.otherReadCount key
* fix(ui): move code block copy button inside <pre>, replace Tooltip with inline label
- Move CopyButton from CodeViewer wrapper into HljsCode's <pre> so it
renders inside the code block's grey background, not outside it
- Remove Tooltip/portal (broken by <pre> overflow:auto) and native title
(1s browser delay), replace with .copybtn__label-inline: zero-delay
CSS hover that slides the Copy text inside the button via max-width
- Remove unused showLabel prop from CopyButton type
- Remove unused CopyButton import from CodeViewer.tsx
---------
Co-authored-by: wufengfan <[email protected]>
A compaction digest was eligible to be re-summarized by the next fold, so a
fact it had captured could be dropped to summary-of-summary drift. Keep prior
digests verbatim alongside the kept user turns and summarize only the new
foldable work; digests accumulate (small) instead of collapsing into one lossy
rolling summary.
Document the guarantee in SPEC 3.6: a fact stated in a normal turn, and a fact a
digest captured, survive any number of folds; a fact buried inside a single
oversized message stays best-effort — there is no reliable way to auto-detect an
arbitrary fact in bulk, so durable facts belong in their own turn.
Co-authored-by: reasonix <[email protected]>
A turn that ends with a final answer (no trailing tool batch) skipped
compaction entirely — maybeCompact ran only after tool batches and in the
retry paths. So a large context carried into the next turn un-folded, and
across a multi-turn session it accumulated until the next request exceeded the
model's hard context limit and the provider returned 400, breaking the session.
Compact at the end of the final-answer path too. It is a no-op below the
trigger, so normal turns keep their warm cache; it folds only when the context
is already over the threshold — exactly when the next turn would risk overflow.
Co-authored-by: reasonix <[email protected]>
Builds on the first-turn pin (#4048): the deterministic floor now covers a fact
the user states at ANY point, not just the opening turn. Compaction keeps every
small user turn verbatim and folds only the assistant/tool work, so a mid-session
"always deploy to eu-west-3" survives regardless of how the summarizer behaves.
On top of that floor, the digest now leads with a structured "Standing facts &
constraints" section consolidating what the user stated into one tidy view —
redundant with the verbatim turns by design, so a weak summarizer dropping a fact
there loses nothing.
Co-authored-by: reasonix <[email protected]>
Expressions starting with a unary + or - (e.g. +2, -x, +\alpha)
were rejected by isLikelyInlineMath because none of the existing
patterns matched them — the operator-pattern on line 10 requires
a character before the operator, and the pure-number pattern
requires the first character to be a digit.
This caused \( +2 \) to be treated as non-math text, rendering
as literal '$+2$' instead of rendering the KaTeX unary plus.
Add a dedicated pattern: /^[+\-]\s*(?:\d+(?:\.\d+)?|[A-Za-z\])/
that matches unary operator + digit/variable/backslash-command.
Compaction folded the first user turn — the task brief and the user's stated
facts and constraints — into a model-written summary, and a later fold then
re-summarized that summary, degrading it to nothing and dropping the user's
facts from context permanently. A real-provider run reproduced it: a token
stated up front vanished from every request after the second fold, and the
agent could no longer recall it.
planCompaction now pins a small first user turn and any prior summaries in the
verbatim prefix, so a fold never summarizes the task away and never re-folds an
earlier summary. A large first turn (pasted content) stays foldable, capped by
an absolute token ceiling and a window fraction, so pinning never starves the
context.
Co-authored-by: reasonix <[email protected]>
The marketing site bakes the version at build time from R2 latest.json,
and pages.yml only rebuilds on site/** pushes — so a release left the
static value (and JSON-LD softwareVersion) lagging until the next site
edit. site.js's runtime .rxv refresh fixes the live page but not first
paint / SEO. Dispatch pages.yml from the mirror job's stable path, once
R2 latest/ has actually moved.
Co-authored-by: reasonix <[email protected]>
Surfaces the desktop.metrics opt-in in Settings, mirroring the telemetry
ping wiring end to end: SettingsView field, the SetDesktopMetrics bound
method, the bridge AppBindings + dev mock, and the panel toggle with a
disclosure hint (en + zh). The hint is the disclosure — it spells out that
only enumerated counters ship, never conversations, prompts, keys, or paths.
SetDesktopMetrics starts/stops the aggregator live so the toggle takes
effect immediately, and a.metrics becomes an atomic.Pointer so the event
sink reads it race-free while the setting flips mid-session.
Co-authored-by: reasonix <[email protected]>
Desktop side of the opt-in metrics telemetry (worker /v1/metrics already
live). A metricsAggregator taps the existing tabEventSink event stream —
internal/agent is untouched, so the CLI stays zero-egress — and counts
enumerated facts only: finish_reason, empty_final, provider error class,
cache-hit bucket, tool error class, compaction, and turns. Never message
text, keys, prompts, or paths; error classes are derived from a status-code
regex, not the message body.
Gated on a new desktop.metrics flag (default off, separate from the
default-on launch ping) and skipped in dev. Counts persist per turn and
flush once at the next launch, mirroring the ping; a failed POST folds back
to retry. The settings toggle and first-run disclosure land in a follow-up;
until then the flag is set via config.toml.
Co-authored-by: reasonix <[email protected]>
WarmTurnItems batched read-only tools without the status guard the hot-zone and TurnCollapse paths use. Add it for consistency so an interrupted running tool lingering in history renders individually instead of folding into the batch. Drop a stray blank line. Follow-up to #4031.
Co-authored-by: reasonix <[email protected]>
Three render paths were rendering read-only tools (read_file, grep, glob,
web_fetch, ls) as individual tool cards even after they completed:
1. Hot zone final step (compact/minimal) — running tools render
individually, completed tools batch into ReadOnlyBatch.
2. TurnCollapse body (compact/minimal folded steps) — same batching
logic added to the pre-computed body array.
3. WarmTurnItems (expanded history turns) — new roBatch/flushRO loop
added so warm read-only tools are also grouped.
This keeps running read-only tools visible (with shimmer animation) and
hides completed ones behind a compact "Read N files · Search N files"
fold line, consistent across all compact/minimal display modes.
Co-authored-by: wufengfan <[email protected]>
Adds the worker side of the desktop opt-in metrics flush: a generic
(date, version, os, signal, bucket) -> count table, a zod-validated
/v1/metrics POST that upserts per-launch aggregate snapshots, a dedicated
rate-limit bucket, and a stats-page section that breaks each signal down
by bucket.
The signal set is an enum and the bucket is length+charset capped, so the
table can never be polluted with arbitrary keys. No install id and no
content reach this endpoint — only enumerated counters.
Deploy is owner-run: apply schema.sql to D1, then wrangler deploy.
Co-authored-by: reasonix <[email protected]>
The rule :root[data-theme-style] .msg--user .msg__body (with no specific
style value) leaked graphite's inverted-color bubble (--fg as background,
--bg as text) onto all other themes like carbon, aurora, and slate.
Fix: narrow both selectors to [data-theme-style="graphite"].
Co-authored-by: wufengfan <[email protected]>
Two fixes for issue #4001:
1. App.tsx: subscribe to onEvent turn_done and bump dockRefreshKey
so WorkspacePanel/ContextPanel re-fetch after AI tool writes.
2. WorkspacePanel.tsx:
- Load app.WorkspaceChanges() alongside git history on refresh
- Filter to sources: ['session'] — only files the agent actually wrote
- Render session changes list above git history in 'changed' tab
- Only show 'no changes' when both sources are empty
textOverlaps duplicated stepTextContains verbatim — fold it into a normalize-then-contains one-liner. Hoist the index bounds check so both current[index-1] accesses share one guard instead of one inline and one bare. Follow-up to #4013; behavior unchanged (evidence tests pass).
Co-authored-by: reasonix <[email protected]>
In `hasSuccessfulCompleteStepForTodo`, when a `complete_step` receipt carries
a `TodoStep` snapshot whose content no longer exactly matches the current todo
text (the model rephrases between successive `todo_write` calls), the bare
`continue` unconditionally blocked the index-based fallback
(`matchTodoStep`). This caused legitimate rephrased todos to be flagged as
missing a completion, rejecting the `todo_write` call, and preventing the
frontend from showing checkmarks (closes#3992).
Replace the `continue` with a content-overlap guard
(`todoContentRelates`/`textOverlaps`): when old and new text share a substring
(case-insensitive), the receipt falls through to the index/text fallback;
otherwise the original block is preserved for genuinely different tasks.
Add `TestLedgerNumericCompleteStepAuthorizesRephrasedTodo` to cover the
rephrased-but-same-task happy path.
All 11 evidence tests and 33 builtin tests pass with zero regressions.
* fix(chat): clear accumulated shell output on re-run of !commands
When a !command (e.g. !pwd) is run multiple times, the tool ID is
fixed (e.g. "shell-pwd") so streamToolOutput appends each run's
output to the same shellOutputs key. Without clearing, collapseShellSlot
renders all accumulated lines from every run.
Fix by deleting shellOutputs[id] in beginToolRunning, so each run
starts with a clean slate.
Before (3x !pwd):
⎿ /d/about_fish/ProgramProtable/DeepSeek-Reasonix
/d/about_fish/ProgramProtable/DeepSeek-Reasonix
/d/about_fish/ProgramProtable/DeepSeek-Reasonix
After (3x !pwd):
⎿ /d/about_fish/ProgramProtable/DeepSeek-Reasonix
⎿ /d/about_fish/ProgramProtable/DeepSeek-Reasonix
⎿ /d/about_fish/ProgramProtable/DeepSeek-Reasonix
* test(chat): cover shell output accumulation across !command re-runs
A re-run of the same "!" command reuses its command-derived id, so without
the clearing fix streamToolOutput appended each run's output to the last.
Pin the regression: three identical runs must leave one run's output, not three.
---------
Co-authored-by: CnsMaple <[email protected]>
Co-authored-by: reasonix <[email protected]>
pnpm honors npm_config_*, not PNPM_CONFIG_*, so dev's guards never took effect.
After an upstream sync, pnpm 11's verify-deps-before-run reinstalls before
`pnpm build`, wants to remove node_modules, and aborts with no TTY under
wails dev (ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY). Pin confirm-modules-purge
+ verify-deps-before-run in a committed desktop/frontend/.npmrc and fix the
env-var form in dev.
Co-authored-by: reasonix <[email protected]>
* fix(openai): round-trip reasoning_content on DeepSeek tool_calls turns
DeepSeek's thinking mode now rejects an assistant tool_calls turn whose
reasoning_content was dropped on replay (400 "reasoning_content … must be
passed back to the API"). The provider stripped it unconditionally, so any
cache-miss replay of a tool-calling history — session resume, compaction,
or a turn after the prompt cache expires — 400s, while warm consecutive
turns are tolerated because DeepSeek still holds the reasoning server-side.
Round reasoning_content back, but only on the assistant turn that carries
tool calls and only for the DeepSeek protocol: a plain assistant text turn
does not require it, and other backends still bill it as input for nothing.
Reasoning enters the cached prefix and is reused on later turns, so the
cost is one miss per chain, not a cache collapse.
* feat(agent): add finish_reason diagnostics to the empty-final notice
The empty-final warning carried no context, so reports couldn't tell its
three causes apart: a length-truncated turn, a reasoning-only stop, or a
provider that swallowed the answer. Include the provider, the finish
reason, and the reasoning length so each occurrence is self-diagnosing.
---------
Co-authored-by: reasonix <[email protected]>
Add Feishu/Lark + WeChat connection flows with status management, diagnostics, and scoped connection persistence (env-name credentials, no raw secrets). Surface IM-origin conversations in the desktop UI with sidebar management and connection details; per-platform session model/workspace routing in the gateway.
Per-turn evidence ledger reset made complete_step reject cross-turn citations and let the final gate miss an unfinished plan. diff/files evidence now falls back to the full session (like commands, #3587); the host keeps a canonical todo list (survives turns + compaction) the gate consults; a successful complete_step advances that list so the model no longer batches todo_write (#3909). Real-API A/B confirmed base rejects/blocks where the PR accepts/advances.
Closes#2917
Parallel call_N-style bash tools lost their per-call line count: streamToolOutput reset the active id's count on every switch and collapseShellSlot's late path only recovered it from shellOutputs (shell- ids only), so call_N ids rendered '-1 lines'. Stash the per-id count before reset and accept the ToolResult output as a last-resort source.
Closes#4003
Stale or empty model refs fell back to the first configured provider, ignoring the user's default_model. Try default_model (when keyed) before iterating providers.
Closes#3801
* fix(evidence): tolerate citation drift when matching complete_step to todos
The todo-step matcher demanded byte-exact (case-folded) equality between
complete_step.step and a todo's text, so a fullwidth/halfwidth colon or
whitespace drift ("Phase 5:…" cited as "Phase 5: …") could never match
and the model looped on "no matching todo_write item" retries, burning
tokens (discussion #3970). Same disease #3982 cured for command
citations, different limb.
Normalize both sides (fullwidth ASCII → halfwidth, whitespace dropped,
case-folded) before comparing, fall back to unique substring containment
(≥6 runes; ambiguous citations stay unmatched), and list this turn's
todos in the rejection so the model can self-correct by verbatim content
or index instead of guessing.
* style: gofmt evidence_test (CJK-width map alignment)
---------
Co-authored-by: reasonix <[email protected]>
* fix(desktop): keep step folds in the centered transcript column
.turn-collapse and .readonly-batch set margin: Npx 0, which outranks
the .transcript > * auto centering by source order, so compact-mode
folds rendered flush against the pane's left edge.
* fix(desktop): make compact-mode step folds honest and calm
Processed folds counted reasoning-only assistants as content, so
minimal mode produced expandable folds over an empty body, and
sub-second batches labeled themselves 0s. Filter to items the body
actually renders (hiding the fold when nothing survives), drop the
seconds suffix below 1s, and thread subcalls so nested tool calls
show inside the fold. Streaming reasoning no longer auto-expands in
compact/minimal — steps fold away on completion, so auto-open read
as open/close flicker.
---------
Co-authored-by: reasonix <[email protected]>
Rebase onto current main-v2 plus five targeted cleanups called out in
the review:
1. Drop the $ escape/unescape dance. Protected segments are stored
out-of-band and swapped back wholesale, so the round-trip is a no-op
— except for code that legitimately contains the literal text $
(which got silently rewritten to $ on restore, corrupting the source).
The header comment is also stale: the description claims restore does
not unescape, but the code did.
2. Revert Step 5's greedy→non-greedy change. The char class
[^$\n]+ already excludes $, so changing + to +? has no effect
on match extent; the comment claiming it prevents cross-pair
matching is wrong. Drop the change and the misleading comment.
The "leave non-math pairs unchanged" behaviour is kept.
3. Restrict fenced-code detection to line-start. Allowing ``` anywhere
in the line would swallow prose like "wrap code in ```blocks``` here"
into a code region and break the math for the rest of the message —
the CommonMark spec requires fences at line start. Single-line docs
are still handled (the next matching fence is the closer).
4. Escape top-level % in math. KaTeX treats unescaped % as a LaTeX
comment char and silently truncates the formula at end-of-line —
"$x = 50%$" rendered as "x = 50" with no error. Add a top-level
case in latexNormalizeForKatex that emits \% (already-escaped \%
is handled above as a 2-char command, so no double-escape).
5. Trim oversized comments. Drop the // was: ... history notes in tests
and the 8-12 line essays in mathNormalize / mathClassify that
describe code that no longer exists or that the reader can see
from the regex. The header still lists the pipeline as a map.
128 tests pass; typecheck clean.
De-card tool/reasoning/step rows so the transcript reads as one quiet
column: shared reasoning__head fold line, ChevronRight everywhere, long
commands truncate instead of wrapping, and running rows get a text-clip
shimmer sweep instead of a block background. Adds a transcript display
mode setting (standard/compact/minimal) persisted in reasonix.toml and
hydrated into the frontend at boot so config stays the source of truth.
Co-authored-by: wufengfan <[email protected]>
Crash reports alone answer "what broke" but not "for whom" or "how many
are even running this". Three additions, all on the existing worker:
- Launch ping: one POST per start with a random install id + version +
OS facts. Gated on new desktop.telemetry config (default on, toggle in
Settings > Updates, disclosed in desktop/README.md); dev builds never
ping. Carries no conversation, key, or file data.
- Crash reports now attach coarse device facts (OS version, CPU model,
cores, RAM) so "only crashes on X" patterns are visible.
- Worker grows /v1/ping (per-day install dedup with opens counter) and a
Basic-auth /stats page (daily actives, version/platform breakdown,
recent crash groups) so day-to-day reading needs no SQL.
Co-authored-by: reasonix <[email protected]>
Previously, the Step 5 regex would greedily match '$5 and $' as a single
math expression with content '5 and ', then convert it to '$5 and $'
because the classifier correctly identified it as non-math. This was visually
correct but had two problems:
1. The greedy match would consume the closing dollar that belonged to the
next currency token, causing cascade replacements.
2. Prose currency like 'These two apples cost $5 and $6' would have its
dollar signs converted to HTML entities, which works but is unnecessary
noise in the rendered output.
Changes:
- Step 5 regex now uses non-greedy matching (+\?) so '$5 and $' doesn't
match '$5 and $' as a single pair
- When the classifier rejects a match, the original text is preserved
unchanged (return _m) instead of being wrapped in HTML entities
- This keeps dollar signs visible in prose while still preventing them from
being parsed as math
All 122 tests pass.
Enhancements to inline math detection:
- Reject pure numbers (1, 2.5, 10) as currency/percentages
- Accept numbers with variables (2.5x, 3y^2) as math
- Accept numbers with LaTeX escapes (10\%) as math
- Fix single-line code block detection to protect $ in malformed markdown
This better matches real-world usage where 'costs $5' is currency
but '$2.5x + 3$' is clearly a mathematical expression.
All 122 tests pass (108 math-golden + 8 text-size + 6 provider-model-refresh).
Remove the requirement that ``` must appear after a newline. This
handles cases where documentation is pasted on a single line with
embedded code blocks containing $ symbols.
Previously: ``` markers were only recognized after \n
Now: ``` markers are recognized anywhere
This prevents KaTeX errors (red text) when processing malformed code
blocks that contain $ in regex patterns, template literals, or other
code examples.
All 120 tests pass.
Add defensive escaping for code blocks containing $ characters.
When protecting code (inline `...` or fenced ```...```), replace
$ with $ (HTML entity). On restoration, unescape back to $.
This prevents KaTeX from attempting to parse math delimiters that
appear in code examples, regex patterns, or template literals.
Fixes: Pasted documentation about the math pipeline itself no longer
shows red KaTeX error text.
Tests: 3 new cases added, 106/106 passing
The classifier rules are language-agnostic, not specific to CJK text.
Updated test section name and descriptions to reflect that patterns
like single digits, comma-separated tokens, and one-sided operators
apply universally across languages. Chinese text in test cases remains
as real user examples, but the rules themselves are not CJK-specific.
Three targeted fixes to the math-pipeline pre-pass that resolve cases
where the rendered chat output showed LaTeX source as raw text:
1. mathNormalize.ts (Step 2.5): when the model writes block math with
the opening $$ glued to prose on the same line ('…decomposes
as$$\n\mathbf{6}…'), CommonMark requires a blank line before
the $$. remark-math otherwise creates an empty math node and the
formula leaks out as literal text. Insert \n\n before any $$
preceded by a letter or end-of-sentence punctuation. The
freshly-rewritten \] → $$ from step 2 is not affected.
2. mathClassify.ts: classify single digits ($1$, $2$) as math —
commonly used as set / sequence indices. Multi-digit numbers,
decimals, and percentages stay literal (still currency / percentage).
This is a deliberate behavior change documented in the comment.
3. mathClassify.ts: allow comma-separated tokens ('A, B', '1, 2, 3',
'\\alpha, \\beta', '(A, B)') as math. These are typical of
ordered-pair / tuple / enumeration notation. Currency and env-var
usage never looks like this.
4. mathClassify.ts: allow single uppercase letters as math. In
non-English math prose (Chinese / Japanese / Korean textbooks)
single capital letters are extremely common as set / algebra /
group / vector-space names, and the closing-dollar form $X$ is
essentially never written for English words like I/A/V by hand.
Test changes: 4 existing currency/acronym assertions updated to
reflect the new behavior, 13 new regression tests covering all four
fixes including the user's specific cases ('$1$ 和 $2$' and
'$S$ 非空 / $S$ 有上界'). 98 math-golden tests pass, 112/112 across
all suites, typecheck clean.
Orphan $$ (model wrote display math but forgot the closing $$) is
documented as not-fixed-from-the-renderer: every attempt to rescue
the orphan from the renderer side made the output worse, so the fix
for that case is on the LLM side (post-generation lint or stricter
system prompt).
* fix(evidence): match paraphrased verification commands and guide complete_step self-correction
complete_step rejected real verifications whenever the cited command string
was not byte-identical to the bash receipt: a dropped cd prefix (#2917), a
flag or quote-style drift, or a piped tail all failed both the ledger match
and the #3587 session fallback's prefix matching. Local session forensics
show 5 of 18 real complete_step calls rejected this way, each cascading into
todo_write failures and final answers that overclaim.
Match commands by shell segment instead: split cited and ran commands on
&&/||/;/|/newlines, quote-strip and whitespace-normalize tokens, and accept a
cited segment when a ran segment equals it or supersets its tokens under the
same head token. One-token citations still require exact equality, and an
aggregated citation that no single command covers is still rejected. The
session fallback now uses the same matcher and skips calls whose recorded
result is an error or block, closing the false positive where any attempted
command counted as proof.
Rejections now carry recovery context: ran-but-nonzero commands are
distinguished from never-ran (with a '|| true' hint for negative
verification, e.g. proving a file is gone), never-ran rejections list the
turn's actual receipts, and the schema marks command/paths as required for
their kinds instead of advertising them optional.
* fix(evidence): count bash commands naming a path as files receipts
Files created or edited through shell redirection (seq … > file, sed -i)
leave no reader/writer receipt, so files evidence for them was always
rejected and the model had to re-write the file with write_file just to
mint a receipt. A successful bash command whose text names the path now
counts as having touched it.
---------
Co-authored-by: reasonix <[email protected]>
* feat(desktop): user-initiated crash reporting via crash.reasonix.io
The crash overlay could only ask users to screenshot or copy the error,
so most reports never reached us. Add an opt-in-per-click "Send report"
button wired to a new ReportCrash binding that scrubs user names from
paths, attaches version/GOOS/GOARCH, and POSTs to a Cloudflare Worker
(workers/crash-report): D1-backed, fingerprint-deduped (5 raw samples
per group), per-IP rate limited. Nothing is ever sent automatically.
* chore(workers): pin crash-report D1 database_id from initial deploy
---------
Co-authored-by: reasonix <[email protected]>
Follow-up to #3951: the late-result branch of collapseToolOutput
mutated the transcript without setting transcriptDirty, so the
rewritten slot waited for the next unrelated event to paint. Set the
flag inside collapseShellSlot, covering both callers.
Co-authored-by: reasonix <[email protected]>
* fix(chat): keep each tool's marker under its own card in back-to-back dispatches
When the model dispatched two Bash tools in quick succession, late
ToolProgress chunks for the first tool no longer matched the current
toolStreamID, so streamToolOutput fell through to the generic
collapse-then-append path. The fresh live block landed at the tail of
the transcript under the second tool's card, and the first tool's
collapsed ⎿ marker stacked beneath the second card as well — making
the two runs visually indistinguishable.
The fix threads the slot beginToolRunning recorded for the first
dispatch through shellTranscriptIdx. When a late progress (or late
result) for that id arrives, streamToolOutput and collapseToolOutput
now reuse the recorded slot instead of appending, so each tool's live
block and final summary stay directly under its own card regardless
of dispatch/progress arrival order.
Adds TestConsecutiveToolCallsKeepMarkersUnderOwnCard to lock the
behaviour in: it verifies both markers are present and that the first
card's marker previews the full output, not just the chunk visible
before the second tool took over.
* fix(chat): clear the slot for back-to-back non-shell tools
The back-to-back dispatch fix in 8b1dcd77 records every dispatched
id in shellTranscriptIdx, so a late ToolResult for an earlier tool
can land in the correct slot. But for non-shell-prefixed tools
(e.g. read_file dispatched in parallel) the streaming state belongs
to the current id and the shellOutputs accumulator is never
populated, so the late path in collapseShellSlot computed n = -1
and rendered the final else branch as '⎿ -1 lines'. The visual
result was two negative-count markers stacked at the end of the
transcript, one per parallel tool.
Guard n < 0 in collapseShellSlot by treating the unknown as zero
output: clear the slot rather than fabricate a count. The id stays
recorded in shellTranscriptIdx (set by beginToolRunning) so a late
ToolProgress that finally arrives for that id can still extend the
slot in place.
Add TestConsecutiveNonShellToolsDoNotRenderNegativeLineCount, a
minimal regression covering the read_file/read_file case the
reviewer flagged: two back-to-back dispatches, late ToolResult
for the first one. Verified by stashing the chat_tui.go change
and rerunning — the test fails with the exact '-1 lines' transcript
the user reported.
Follow-up to #3962: the removed legacy block was the only carrier of the
.palette__item:focus-visible outline, and the detached chrome search
button (rendered on Windows/Linux only) was hardcoded to the mac glyph.
Closes#3841
Co-authored-by: reasonix <[email protected]>
* feat(agent): prune stale tool results before folding and on cold resume
Stale tool results are re-derivable (files re-read, commands re-run), so
eliding them is a free, lossless alternative to the paid summarize fold.
Prune runs only where a cache reset is already being paid: at the compact
trigger, where it skips the fold entirely when eliding alone clears the
threshold, and on resume after the provider prefix cache has expired
(cacheColdAfter), where rewriting history costs no extra misses and
directly shrinks the full-price first request. No message is ever removed,
so tool_call/result pairing and signed reasoning stay intact by
construction; originals are archived like fold drops.
* bench(e2e): context-maintenance driver — cold-resume A/B + placeholder comprehension
Three real-API scenarios for the prune work: seed two identical-shape fat
sessions, A/B the cold-restart miss tokens with and without pruning after
the provider cache has expired, and verify the model re-reads a file behind
a prune placeholder instead of answering from nothing.
* fix(control): widen cold-resume prune threshold to a risk-asymmetric default
Pruning a still-cached session costs ~4x the miss tokens of leaving it
alone (measured warm-cache A/B), while a threshold that is too large only
forgoes a free prune. Default to 24h until the cache-ttl probe pins the
real retention; never set below it.
* bench(e2e): check write/unmarshal errors in the maintenance driver (errcheck)
* fix(control): derive cold-resume idle from branch meta only (CodeQL go/path-injection)
The os.Stat mtime fallback fed a user-influenced path straight into a
filesystem call. Branch meta is guaranteed for every session the controller
has snapshotted, so the fallback only ever covered never-saved imports —
those now skip one prune until their first snapshot creates the meta.
---------
Co-authored-by: reasonix <[email protected]>
The darwin chrome blanket-marks the whole tabbar no-drag and leaves only
a 10px rail at the very top as the drag region, so dragging the visible
titlebar area selects text instead of moving the window. Re-enable drag
on the spacer between the last tab and the search button and stretch it
to the strip height -- the area every user actually grabs.
Closes#3853Closes#3852
Co-authored-by: reasonix <[email protected]>
will-change: transform plus a fill-mode that keeps the final transform
applied left the settings/history/trash modal permanently composited,
rasterized at the entrance-animation scale. On Windows fractional DPI
the whole panel rendered blurry until a tab switch or resize forced a
repaint. Let the entrance animation end with no retained style so the
modal returns to the normal raster path.
Closes#3838Closes#3902Closes#3899
Co-authored-by: reasonix <[email protected]>
v0.x stored MCP servers in two shapes: the canonical mcpServers map and
the older `mcp` list of --mcp-format strings (name=cmd args, name=URL
for SSE, name=streamable+URL), with mcpEnv/mcpDisabled keyed by name.
Both the one-time migration and the live lowest-priority legacy source
only read mcpServers, so servers configured the old way -- including the
default memory server -- silently vanished on upgrade.
Parse the string list in both paths; mcpServers wins a name collision,
matching the v0.x merge order. Anonymous specs get a synthesized name
since v1+ plugins require one.
Closes#3949
Co-authored-by: reasonix <[email protected]>
In planner+executor mode the executor session opens with the handoff
boilerplate, so every session preview and auto-generated title collapsed
to 'Reasonix executor'. HandoffTask extracts the embedded original task;
the session picker, serve preview, and desktop topic titles all use it.
Closes#3860
Co-authored-by: reasonix <[email protected]>
A proxy that idle-closes the SSE connection with a clean FIN ends the
scan loop with no error, so the turn was committed as complete -- with
whatever fraction of the tool-call arguments had streamed. DeepSeek then
rejects every subsequent request that replays the truncated JSON with
HTTP 400, bricking the session.
Two layers:
- readStream now requires [DONE] or a finish_reason; a clean EOF
without either is a connection cut, which the existing replay /
StreamInterrupted recovery handles (partial calls are never emitted).
- SanitizeToolPairing closes truncated argument JSON before requests
are built, so sessions already poisoned by this bug resume working.
Co-authored-by: reasonix <[email protected]>
The explicit tsx file list in the test scripts never picked up the
test added by #3786, so CI was not running it.
Co-authored-by: reasonix <[email protected]>
The bottom status/data line (model · git · effort · context · cache · jobs ·
balance, or custom statusline) was truncated with an ellipsis when its content
exceeded the terminal width. This lost information with no way to reveal it.
Replace clampStatusLine (ansi.Truncate) with wrapStatusLine (ansi.Hardwrap)
so the data line wraps to additional rows instead of being silently cut.
Add statusLineCount field + computeStatusLineCount() method so bottomRows()
reserves the correct dynamic height, including the working/spinner line when
running and the custom statusline output.
computeStatusLineCount mirrors View()'s construction exactly: same width,
both the mode/state line and the data line are wrapped for counting, and the
mode tag width includes the Padding(0,1) that View() applies — without this
the count drifts from the rendered height by 1 row at certain widths, hiding
the bottom transcript line.
Also fix gitTag() to never truncate itself — it previously took a maxWidth
argument and silently truncated with ansi.Truncate, causing the git tag to
be cut off ('项...字@项目分支') or disappear entirely before wrapStatusLine
had a chance to wrap the whole line. Now gitTag() renders the full identity
and lets wrapStatusLine handle any needed wrapping at word boundaries.
The CodeGraph daemon walks and indexes the whole project tree in the
background; on large repos it saturates CPU/IO alongside the system
indexer (Spotlight on macOS) and has frozen M1 machines (#3797), and
its idle file-watch load is the prime suspect for #3771. Spawn it
below normal scheduling priority: BELOW_NORMAL_PRIORITY_CLASS at
creation on Windows, renice +10 after start elsewhere, via a new
LowPriority flag on plugin.Spec.
Co-authored-by: reasonix <[email protected]>
The session only persisted at turn end, so killing the process during
a long agent turn lost the whole turn, and the chat TUI caught no
signal at all - an SSH disconnect (SIGHUP) died without saving, which
is why resume offered a stale conversation (#3772).
- snapshot every 30s while a turn runs (atomic tmp+rename write, reads
copy under the session lock, so racing the turn is safe)
- chat TUI: SIGHUP/SIGTERM persist the conversation, then quit through
the normal close path
- run/serve contexts also cancel on SIGTERM (and SIGHUP for run)
Closes#3772
Co-authored-by: reasonix <[email protected]>
NewSession/ClearSession returned nil when the active tab's controller
didn't exist yet (lazy per-tab construction includes MCP startup, so the
window is real), and the frontend reset the transcript regardless — the
click was swallowed with no feedback, and with a second workspace open
"new session" appeared simply broken (#3938).
The backend now names the reason (still starting, or the tab's startup
failure), and the frontend only resets the transcript when the backend
actually rotated the session.
Closes#3938
Co-authored-by: reasonix <[email protected]>
promptMissingKeys walked every preset provider, so a fresh DeepSeek-only
install asked for MIMO_API_KEY on each launch — confusing enough that
users assumed they had installed the wrong tool (#3939).
Restrict the prompt to providers named by default_model, planner_model,
and the subagent model settings. Merely-available presets stay silent;
the chat banner still warns when a user switches to a model whose key is
missing, and setup's explicit provider selection is unchanged.
Closes#3939
Co-authored-by: reasonix <[email protected]>
The flat import (#3247) reconstructed v0.x sessions into the global
config session dir, but the desktop sidebar only lists the per-workspace
projects/<slug>/sessions dirs — so for desktop users the import
"succeeded" into a directory nothing reads, and upgraders kept reporting
their history gone (#3937).
- the per-workspace session-dir layout moves to config.ProjectSessionDir
(the desktop helper now delegates), so the importer can route to it
- the importer reads each session's v0.x sidecar (<name>.meta.json):
a live workspace routes the session into that workspace's dir, the
summary becomes its display title; dead/missing workspaces fall back
to the global dir as before
- a new one-time routed pass deliberately ignores the flat-import
markers so existing upgraders get their stranded sessions re-homed;
a global-dir file is only moved when its mtime matches the legacy
event log (the flat import stamped it), so a same-named native v1+
session is never touched
Trade-off: sessions a user deleted after the flat import reappear once
(in their workspace) during the routed pass; there is no record that
distinguishes "deleted by user" from "never imported", and recovering
every stranded upgrade is worth one resurrection.
Closes#3937
Co-authored-by: reasonix <[email protected]>
Rewind and fork map a clicked bubble to a backend turn by counting
user items in the transcript. A mid-turn steer added a user bubble
live and replayed its persisted wrapper message as one more, so every
steer shifted all later rewind/fork targets by one - the "rewound
several turns at once" class of report (#3660). Steers now surface
only through the Steer event notice, live and on replay.
Co-authored-by: reasonix <[email protected]>
* fix(desktop): use DesktopLanguage for i18n slash command descriptions
When the user selects a language in desktop settings, it saves to
DesktopLanguage field. However, the i18n system for slash command
descriptions only reads from cfg.Language (CLI setting), causing
the / commands to show English descriptions even when Chinese is
selected in desktop settings.
Fix: Fall back to cfg.DesktopLanguage() when cfg.Language is empty,
so the desktop language preference is respected.
Fixes#3850 (upstream)
Signed-off-by: Bernardxu123 <[email protected]>
* style: gofmt desktop/app.go
* fix(desktop): use DesktopLanguage for i18n slash command descriptions
When the user selects a language in desktop settings, it saves to
DesktopLanguage field. However, the i18n system for slash command
descriptions only reads from cfg.Language (CLI setting), causing
the / commands to show English descriptions even when Chinese is
selected in desktop settings.
Fix: Fall back to cfg.DesktopLanguage() when cfg.Language is empty,
so the desktop language preference is respected.
Fixes#3875 (upstream)
Signed-off-by: Bernardxu123 <[email protected]>
* fix(desktop): DesktopLanguage should take priority over cfg.Language
Per maintainer feedback: in the desktop app, the desktop-specific
setting is the user's most direct intent (it's also what the rest
of the desktop UI follows), so the precedence should be:
lang := cfg.DesktopLanguage()
if lang == {
lang = cfg.Language
}
This matches the comment and fully fixes the issue for users with
both settings configured.
Signed-off-by: Bernardxu123 <[email protected]>
---------
Signed-off-by: Bernardxu123 <[email protected]>
Co-authored-by: Bernardxu123 <[email protected]>
* fix(control): never render compaction fold summaries as user bubbles
Compaction stores its summary as a user-role message so the model
treats it as context, but IsSyntheticUserMessage did not recognize the
three fold prefixes. A history reload that races a compaction pass
(switch tabs mid-/compact, switch back -> missedTurnDone reload) then
rendered the entire fold - tool calls, commands, console digests - as
a giant user bubble.
Closes#3653
* fix(desktop): wait for the forked tab before loading its history
Fork created the new tab, switched to it, and immediately read
HistoryForTab - but the tab controller builds in a background
goroutine, so the read raced the session resume and returned empty.
The ready-event fallback reloads activeTabIdRef, which can still point
at the source tab when the build wins the race, so the forked
conversation stayed blank. Reuse the waitForTabReady gate the resume
path already relies on.
Closes#3742
---------
Co-authored-by: reasonix <[email protected]>
Compaction stores its summary as a user-role message so the model
treats it as context, but IsSyntheticUserMessage did not recognize the
three fold prefixes. A history reload that races a compaction pass
(switch tabs mid-/compact, switch back -> missedTurnDone reload) then
rendered the entire fold - tool calls, commands, console digests - as
a giant user bubble.
Closes#3653
Co-authored-by: reasonix <[email protected]>
DETACHED_PROCESS gave the probe git no console at all, so any console
child git spawned (fsmonitor daemon, credential helper) allocated a
new visible one - the repeated black windows in #3906. Route all
workspace probes through workspaceGit: CREATE_NO_WINDOW so children
inherit the invisible console, with fsmonitor and auto-maintenance
disabled so probes never spawn background daemons. HideWindowDetached
has no callers left and is removed.
Co-authored-by: reasonix <[email protected]>
The desktop updater, the manifest URLs cmd/sign rewrites at release
time, and the codegraph mirror all pointed at the rate-limited
pub-*.r2.dev dev subdomain. dl.reasonix.io is the same R2 bucket behind
its custom domain (Cloudflare-cached, mainland-reachable), verified to
serve the stable + canary pointers and the codegraph bundles with
byte-for-byte parity before switching.
Already-installed clients keep polling the pub-*.r2.dev endpoint baked
into their binary; that URL stays live (same bucket, public dev access
stays enabled), and the artifact URLs inside newly published manifests
work for old and new clients alike.
Co-authored-by: reasonix <[email protected]>
Mainland-China visitors hit two walls on the new site: the contributor
wall loads 40+ images from avatars.githubusercontent.com (blocked
there), and downloads went through the rate-limited pub-*.r2.dev dev
subdomain.
- a prebuild script now fetches community stats and downloads every
contributor avatar into public/av/, so the wall is served same-origin
with zero third-party requests; any avatar that fails to fetch falls
back to its remote URL, and the whole script degrades to the
committed snapshot rather than failing the build
- download links and the runtime version check now use the R2 bucket's
custom domain dl.reasonix.io (Cloudflare-cached, range requests
verified) instead of pub-*.r2.dev
Co-authored-by: reasonix <[email protected]>
Regroup the bottom status bar into model/cache/token/context/account
clusters with shared tooltips, cache-rate coloring, and compaction
warning states. Refresh the context overview cards, route dependency
and session-change rows to scoped workspace views, and carry session
total-token accounting through tab telemetry into the UI.
Co-authored-by: Sivan <[email protected]>
The v2 @-menu search matched only on the basename of each file, so an
input like "@planind" never surfaced a deeply-nested match such as
"src/planind/index.tsx" whose basename is "index.tsx".
* internal/fileref/search.go: split matches into a basename tier and a
path-segment tier; basename hits still win, but the segment tier now
fills the remaining capacity using pathSegmentContains(rel, query).
Noise directories (node_modules, build, dist, .git, ...) are still
skipped before the matching switch.
* internal/fileref/search_test.go: cover path-segment match, basename
match, sort order between tiers, and noise-directory skip list.
* desktop/frontend/src/lib/atMatches.ts: extract the Composer @-menu
filter into a pure helper so it can be unit-tested without React.
* desktop/frontend/src/__tests__/at-matches.test.ts: regression tests
for nested path surfacing, basename match, and stable de-duplication.
* desktop/frontend/src/components/Composer.tsx: delegate the filter
to the helper. The atDir !== "" guard for sub-path search is
intentionally left unchanged for this fix.
Replace the strict prefix filter in updateCompletion with a case-insensitive
subsequence matcher, so typing a partial slash token like /mdl surfaces
/compact, /model, /memory and other commands whose letters appear in order,
not just commands that start with the typed prefix.
The new matcher keeps the previous menu semantics for every input the old
filter already handled: an empty query (bare /) lists every command, and a
query that matches nothing closes the menu. Prefix hits rank first within
the result list, so /mo still puts /model on top and the rest of the
subsequence matches follow in their original slashItems order.
Scope is intentionally limited to the in-TUI completion popup; typed-input
resolution (/name + Enter) and the model's slash_command tool still use
exact-name lookup to keep the change reviewable.
Tested with new unit tests in complete_test.go covering subsequence
matching, prefix-first ranking, case insensitivity, empty query, no-match
menu close, custom command coverage, and end-to-end accept.
Submit rejections were swallowed, leaving an optimistic user bubble
that looked delivered while the backend never saw the turn. Mark the
bubble failed, append a warning notice, and stop the running
indicator when the bridge call rejects before the first backend
event; confirmed turns are unaffected.
Closes#3056
Co-authored-by: reasonix <[email protected]>
Co-authored-by: luffy <[email protected]>
Decouple popover width from trigger width: keep the 160px floor as
minWidth and cap growth at 400px so long model refs stay readable.
Add title attributes on model/provider labels as a hover fallback.
Fixes#3628
Co-authored-by: zqy <[email protected]>
The old page buried the install path and still presented v0.53 as the
default line. Rework both pages so the site sells what we actually ship:
- every install surface now says `npm i -g reasonix@next`; the npm
`latest` tag is pinned at the deprecated 0.53 line and the old copy
was routing new users into it
- desktop downloads now serve the 1.x artifacts from the R2 `latest/`
manifest (version resolved at build time, refreshed at runtime);
the 0.53 desktop links are gone
- community wall renders the real contributor avatars and live
stars / merged-PR counts at build time, with a baked fallback so an
unauthenticated or rate-limited build still shows real numbers
- SEO: per-page canonical/og:url on the reasonix.io domain, og:image
card, sitemap via @astrojs/sitemap, robots.txt, SoftwareApplication
JSON-LD, branded 404 with noindex
- fonts self-hosted (Outfit + JetBrains Mono via fontsource), no
external font requests
- pages workflow passes GITHUB_TOKEN to the build so the GitHub API
fetches are not anonymously rate-limited
Co-authored-by: reasonix <[email protected]>
* fix(permission): prefer bash command prefix for approval grants
Fixes#3759 — the approval system remembered rules with full command
arguments (e.g. "Bash(grep -r specificTerm .)"), causing every
slightly different invocation to re-prompt within the same session.
Root cause: SessionGrantRuleForScope and RememberRuleForScope only
applied BashCommandPrefix when scope==ApprovalScopePrefix, but the
default "allow this session" path used ApprovalScopeExact — so the
prefix was never used. Every command was stored literally.
Fix:
- Both functions now always prefer BashCommandPrefix for bash when a
safe prefix can be extracted, regardless of the scope parameter.
Safe prefix means: >=2 words, no shell metacharacters, not dangerous.
When no prefix is available (single word, shell syntax, dangerous
command), the exact command is still stored as the fallback.
- File mutation tools remain tool-wide ("Edit") — matching existing
session-grant behaviour.
- The now-unused scope parameter is removed from both function
signatures; all callers updated.
Concrete example:
Before: "grep -r TODO ." approves -> stored "Bash(grep -r TODO .)"
"grep -r FIXME ." -> prompts again (different subject)
After: "grep -r TODO ." approves -> stored "Bash(grep -r:*)"
"grep -r FIXME ." -> auto-allowed (matches prefix)
"git log" -> still prompts (different command family)
* fix(permission): fully remove approval scope plumbing; complete across backend + frontend
Address reviewer feedback on #3815:
1. Remove the inert scope parameter from approvalReply, Controller.ApproveWithScope,
ApprovalScopeExact/ApprovalScopePrefix constants, and all callers.
2. Collapse TUI approval handler into single session/persist pair (no more
hasBashPrefix branching). Restored numeric key "4" for deny.
3. Add TestPersistedEditRuleIsToolWide for the deliberate tool-wide Edit grant.
4. Clean up desktop Go (ApproveWithScope/ApproveTabWithScope), TypeScript bridge
interface+mock, serve.go Scope field, and React ApprovalModal "prefix" arg.
5. Restore nil guard in bindApprove.
* fix: add viewport scrolling and search to select menus; add /provider command
- Fix selectOne/selectMany overflow: when the model list exceeds the
terminal height, the menu now shows a viewport-sized window with scroll
indicators (↑ N more above / ↓ N more below) instead of rendering all
items off-screen.
- Add keyword search: pressing '/' in any selectOne/selectMany menu
enters a search mode that filters items by name/desc substring match
(case-insensitive). Esc exits search and restores the full list.
- Add /provider slash command: lists configured providers and their
models; '/provider <name>' switches to that provider's default model
(or lists models for selection when multiple are configured).
- Add provider name autocomplete for /provider <Tab>.
- Add /provider to /help listing.
- Add i18n messages for all new UI strings (en + zh).
- Add test coverage for /provider arg completion in control/slash_test.go.
Closes#3765
* fix: address review feedback — redraw, panic, viewport, desktop integration
1. Search redraw stale rows: track prevLines (lines actually printed in
the last frame), move cursor up by prevLines (not the new height),
and emit \033[J (clear-to-end-of-screen) after each redraw to wipe
stale rows left by a taller previous frame.
2. Panic in showProviders(): use the unified fallback model list
(ChatModelList → ModelList) for both the count and the single-model
display label. Previously models[0] could panic on an empty slice
when only non-chat models were configured.
3. Viewport height: fixedLines() now returns the exact non-item line
count (4 normal, 5 with search bar) instead of a hardcoded 3.
maxViewport() accepts a searching flag and subtracts the right
overhead. Frame rows are padded to a constant height so cursor
positioning stays stable.
4. Remove unused i18n fields: ProviderSwitchingFmt, ProviderSwitchedFmt.
5. Desktop integration:
- Commands(): add /provider to the built-in command list.
- SlashArgs(): populate ArgData.ProviderNames and CurrentProvider
from the Models() result.
- managementNotice(): handle /provider (list) and /provider <name>
(switch) for the desktop/HTTP Submit path.
- Add providerListText() and providerSwitchText() controller methods.
6. Tests: add select_test.go with FrameLines, MaxViewportBounds, and
FilterMenuItems unit tests. Add provider name completion test in
control/slash_test.go.
* fix: use fmt.Fprintf instead of WriteString(fmt.Sprintf(...))
Add min-width: min-content to .diff__row and .inline-diff__row so
background colors cover the full line width when horizontal scrolling
is active.
Fixes#3599
Co-authored-by: zqy <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
Add max-height (50vh) and overflow-y: auto to the portal model menu
so it scrolls when there are too many models instead of overflowing
the screen.
Fixes#3629
Co-authored-by: zqy <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
KaTeX's aligned/gathered environments treat the entire block as one
equation and only permit a single \tag per block (parse error "Multiple
\tag"), while align/gather support \tag on every row natively.
Instead of stripping \tag commands (which loses equation numbers),
convert aligned→align, gathered→gather, and alignedat→alignat when
\tag is detected in the math source. Plain aligned blocks without
\tag are left unchanged so they keep their un-numbered behaviour.
6 new unit tests + 1 updated e2e test (99 total, all pass).
Co-authored-by: Xingbo Zhao <[email protected]>
The ContextPanel gadget shows a donut chart in the workspace overview with a
token breakdown: prompt, completion, reasoning, other. The donut fill represents
context-window usage (snapshot from the latest turn via ContextSnapshot()), but
the breakdown segments (PromptTokens, CompletionTokens, ReasoningTokens) were
sourced from sessionUsageStats — a cumulative counter that grows across every
turn. After the first turn, cumulative > snapshot, so the segments inflate to
nonsense values: "other" is always zero (capped), and segment percentages
overshoot the donut ring.
Fix: read PromptTokens/CompletionTokens/ReasoningTokens/CacheHitTokens/
CacheMissTokens from LastUsage() (the same per-turn snapshot already used for
UsedTokens). Cumulative fields (RequestCount, ElapsedMs, SessionCost) stay
on telemetry.Usage — they were never mixed into the donut math.
The frontend fallback path (WireUsage events) already used per-turn snapshots,
so no TypeScript changes are needed.
The site is now served from the apex domain reasonix.io at the root,
not from the GitHub Pages repo subpath. Drop the Astro base
'/DeepSeek-Reasonix' (which prefixed every asset URL and broke CSS),
point site at https://reasonix.io, and derive canonical/og:url from
Astro.site instead of hardcoding the old esengine.github.io URL.
Replace the stale floating-mode comment with the viewport-clamped dock strategy implemented for the Workspace panel.
Co-authored-by: Cursor <[email protected]>
Align the Workspace resize separator's ARIA minimum with the viewport-clamped width so constrained dock states report a valid range.
Co-authored-by: Cursor <[email protected]>
Clamp the right Workspace dock to available window width and remove the docked layout's summed min-width so the app shell reflows instead of clipping at the right edge.
Co-authored-by: Cursor <[email protected]>
The left-click handler set via systray.SetOnTapped runs synchronously
inside the tray's Win32 wndProc. When showFromTray blocks — runtime.WindowShow
can stall while the webview/main thread is wedged after sleep + a dropped
network connection — wndProc never returns and the tray's message pump
freezes: the icon stays in the tray (selectable via Win+B) but no click does
anything. The menu items were already decoupled via goroutines; the tap
handler was the last callback running on the message loop.
Dispatch showFromTray on a goroutine so the pump is never blocked.
Closes#3834
Co-authored-by: reasonix <[email protected]>
* fix(desktop): replay pending approval/ask prompts on (re)connect
A session blocked awaiting a tool approval or ask question keeps its
"waiting" status server-side (tabs.go derives it from the event stream),
but the frontend approval modal is driven only by the live
ApprovalRequest/AskRequest event with no restore path. A frontend that
reloaded, reconnected, or raced the original event was left showing the
"waiting" badge with no modal and no working stop — a deadlock only a new
message could break (#3844).
Add Controller.ReplayPendingPrompts to re-emit the event for any prompt
still blocking the run loop, expose it on App, and call it from the
frontend once its event subscription is live. asks now retains its
questions so an AskRequest can be replayed faithfully.
Closes#3844
* fix(desktop): declare ReplayPendingPrompts in AppBindings + dev mock
The hand-written AppBindings contract and the dev-mock bridge must carry
every bound Go method. ReplayPendingPrompts was missing from both, so the
generated-binding drift check and tsc failed the frontend build.
---------
Co-authored-by: reasonix <[email protected]>
README.md / README.zh-CN.md are now a lean landing page (intro, install,
quick start, a minimal config, and a documentation index). The detailed
configuration, permissions/sandbox, plugins (MCP), slash commands,
@-references, and two-model setup move into docs/GUIDE.md (+ zh-CN), so
end users get an approachable guide and the README stays scannable.
Also fix stale MiMo preset facts in the guide and SPEC §5 to match
config.Default(): base_url is token-plan-cn.xiaomimimo.com/v1, and the
mimo-flash model is mimo-v2.5 (not mimo-v2-flash). Align the guide's
built-in slash-command list with what /help prints.
Co-authored-by: reasonix <[email protected]>
Scope @-reference detection and TUI subdirectory completion to the active workspace root when one is configured. Use absolute paths for scoped PDF extraction so external tools work outside the process CWD.
Co-authored-by: SuMuxi66 <[email protected]>
Multi-tab desktop sessions were resolving @file references against the process working directory (os.Getwd()), causing cross-workspace contamination when tabs share the same process.
Changes:
- controller.go: add WorkspaceRoot() accessor for cpRoot
- refs.go: detectRefs/resolveBareNames/readFileRef use cpRoot as base directory
- app.go: workspaceRelative renamed to workspaceRelativeIn with explicit root; AttachDropped uses activeWorkspaceRoot()
- complete.go: fileItems/searchFileRefs use ctrl.WorkspaceRoot()
All paths fall back to os.Getwd() when workspaceRoot is empty, preserving CLI compatibility.
Closes: esengine/DeepSeek-Reasonix#3776
* fix(desktop): prevent EnsureBlankTab from creating duplicate blank tabs
Add EnsureBlankTab with a three-stage pipeline that reuses existing
blank tabs, falls back to indexed blank topics on disk, and only
creates a new topic as a last resort. Also short-circuit NewSession
when the current tab is already blank.
- EnsureBlankTab: reuse open blank tab → reuse indexed blank topic → create new
- blankTabMatchesTargetLocked: helper to identify reusable blank tabs
- indexedBlankTopicIDLocked: helper to find blank topics on disk not in any tab
- NewSession: skip snapshot when the tab has no real conversation content
- messagesHaveConversationContent: utility to check for non-system messages
- Comprehensive tests covering all three stages for global and project scopes
* feat(desktop): fix merge error
* feat(desktop): fix merge ccode
* style(desktop): trim restating/banner comments in EnsureBlankTab
The code is self-explanatory here; drop the section-banner and line-end
restating comments to match the repo comment policy.
---------
Co-authored-by: reasonix <[email protected]>
* fix(desktop): harden MCP batch ops and CTA layout guard
Bulk retry/remove of failed MCP servers used Promise.all, so a single
rejection abandoned the rest; switch to allSettled so every server is
attempted. Replace the CTA guard's fixed-distance regex window with
order-independent lookaheads scoped to the .sidebar__new block.
* chore(desktop): remove dead browser-preview guard
check-browser-preview-stability.mjs was never wired into any CI workflow
and had already rotted — its assertions match exact JSX literals down to
indentation, so any harmless reformat misfires. Drop the script and its
npm entry; UI invariants belong in component tests, not source snapshots.
---------
Co-authored-by: reasonix <[email protected]>
The goal-draft decision was inlined at seven App.tsx call sites (active
tab, tab list, controller sync, model switch). Move it into
src/lib/goalDraftMode.ts so the rule lives in one place and is unit
tested; no behavior change from the inline form merged in #3795.
Co-authored-by: reasonix <[email protected]>
Co-authored-by: SivanCola <[email protected]>
- Add 'Show in file manager' option to file tree right-click menu
- Call app.RevealWorkspacePath to open file in system file manager
- Split context menu height constants to avoid selection menu offset issue
- Add locale strings for en/zh
Co-authored-by: Li-Charles-One <[email protected]>
- Add max-height and overflow-y: auto to model dropdown menu
- Add title attribute to model name and provider for full text tooltip
- Fixes long model names being truncated without way to see full name
- Fixes model list not being scrollable when there are many models
Co-authored-by: Li-Charles-One <[email protected]>
* chore(deps-dev): bump @types/react-dom to 19 to match @types/react
#3690 bumped @types/react to 19 but left @types/react-dom at 18, whose types
pin the @types/react peer to ^18 — an ERESOLVE conflict that npm ci tolerated
only on older npm. Bump @types/react-dom to 19.2.3 so the two react type
packages agree: npm ci now resolves without --legacy-peer-deps and the
frontend typechecks unchanged.
* chore(deps-dev): sync pnpm-lock.yaml for the @types/react-dom 19 bump
---------
Co-authored-by: reasonix <[email protected]>
@types/react 19 (bumped in #3690) tightened ref nullability and dropped the
global JSX namespace, which TypeScript 6 enforces. Type the shared ref props
as RefObject<T | null> (matching what useRef(null) returns), import the JSX
namespace from react, and give the debounce useRef an explicit initial value.
No behavior change; unblocks the typescript@6 bump (#3691).
Co-authored-by: reasonix <[email protected]>
* fix(proc): reap codegraph's process tree off Windows via process groups
KillTree off Windows only signalled the direct child, so a launcher whose
sub-daemon outlives it (codegraph's bundled node runtime) left orphan
grandchildren after exit — the Unix counterpart of the Windows Job Object
reaping. StartTracked now starts the child in its own process group
(Setpgid) and KillTree kills the whole group with a negative-pid signal;
SetProcessGroupKill exposes the same for one-shot children (codegraph init).
No-op on Windows, where the Job Object already covers the tree.
Co-authored-by: ttmouse <[email protected]>
* test(proc): lift the reap-poll break into the loop condition (QF1006)
---------
Co-authored-by: reasonix <[email protected]>
Co-authored-by: ttmouse <[email protected]>
Background auto-refresh calls FetchProviderModels for every configured
provider and merges the result via mergedFetchedProviderModels().
When the user left the models list empty (common for custom providers),
preserveCurated: true had no effect because saved.length === 0,
causing every model from the API to be written into the config.
Now the background refresh skips providers whose models list is empty,
preventing silent pollution of the model dropdown. Users can still
manually fetch models via the 'Refresh Models' button.
Fixes#3705
Co-authored-by: wufengfan <[email protected]>
Follow-up to #3763.
- Feishu webhook mode is the default, and verificationTokenValid accepts
any caller when no token is configured, so an empty verification_token
exposed an unauthenticated event endpoint. Refuse to start webhook mode
without a token (fail closed).
- Move the weixin account-persistence and QR-login block to
weixin_login.go, bringing weixin.go under the 800-line ceiling (811->587).
Co-authored-by: reasonix <[email protected]>
Lands the bot gateway as its own feature, extracted from #3752: gateway/session plumbing plus Feishu, Weixin, and QQ adapters, CLI/config support, and the desktop Settings UI for bot channel configuration and guided setup.
Co-authored-by trailers preserved from the source branch.
Follow-up to #3773: that cleared the n+m addition alert, but CodeQL still
flagged the 2*maxD allocation (go/allocation-size-overflow) because it
doesn't propagate maxD's bound through the guarded sum. Clamp each line
count to maxDiffEdits before summing, so the sum and the 2*maxD allocation
both have constant-bounded operands. maxD stays min(n+m, maxDiffEdits).
Co-authored-by: reasonix <[email protected]>
CodeQL go/allocation-size-overflow (high) flagged `n + m` flowing into
`make([]int, 2*maxD+1)`: a pathological line count could overflow the
addition before the downstream clamp. The result was already safe (the
clamp caught a wrapped-negative maxD), but the addition itself still
tripped the query. Restructure to maxD = min(n+m, maxDiffEdits) with the
sum taken only when n and m are within the cap — no overflow at the
operation, identical bound.
Co-authored-by: reasonix <[email protected]>
Lands three changes from #3752:
- Desktop: graphite UI redesign with slate/carbon/nocturne/amber theme
directions (light+dark), Cmd-K command palette, and a status bar /
composer / sidebar refresh. Default desktop appearance is now light.
- Controller: separate tool approvals from business asks and plan
approval. Posture is ask/auto/yolo; deny rules still block upstream,
auto keeps explicit ask rules while auto-approving the writer fallback,
and plan approval plus the ask tool stay user decisions in every mode.
- Controller: opt-in /goal autonomous loop that self-continues up to 50
turns, driven by [goal:complete|blocked|continue] markers, bounded and
cancellable, with a repeated-block stop.
The \slashed -> \not cases (#3750) wrote their braced inputs with a
double backslash (\slashed{p}); real model output is a single one. The
regex matched either way so the tests passed, but they exercised a form
the model never emits. Switch them to single-backslash inputs and the
eq() helper the surrounding golden tests already use.
Co-authored-by: reasonix <[email protected]>
* fix(desktop): convert \\slashed to \\not for KaTeX compatibility
KaTeX doesn't support the \\slashed command from the LaTeX 'slashed'
package, which is commonly used in physics for Feynman slash notation
(\\slashed{p}, \\slashed{\\partial}).
Convert \\slashed{X} to \\not{X} before passing to KaTeX. The \\not
command provides a similar visual effect (a slash through the character)
and is supported by KaTeX.
This prevents KaTeX parse errors when the model emits physics notation
using \\slashed.
Tests: 3 new cases added, 111/111 passing
* fix(desktop): handle unbraced \\\\slashed forms like \\\\slashed\\\\epsilon(0)
The previous fix only handled \\\\slashed{X} (braced form). User-reported
issue: \\\\slashed\\\\epsilon(0) (Greek letter + function call, no braces)
was not being converted and still showed as red KaTeX error.
Extended the regex to handle two additional forms:
- \\\\slashed X → \\\\not X (single letter, no braces)
- \\\\slashed\\\\epsilon → \\\\not{\\\\epsilon} (backslash command)
- \\\\slashed\\\\epsilon(0) → \\\\not{\\\\epsilon(0)} (backslash command + function)
- \\\\slashed a → \\\\not a (single ASCII letter)
This covers the common physics notation \\\\slashed{p} where the model
sometimes forgets the braces around the argument.
Tests: 2 new cases added, 113/113 math-golden passing.
---------
Co-authored-by: Xingbo Zhao <[email protected]>
* fix(proc): assign the job object before the launcher runs so detached MCP children are reaped
A Windows stdio MCP launcher (cmd.exe -> node.exe, as the CodeGraph
daemon re-parents itself off its shim) raced the job-object assignment:
the job was assigned only after Start returned, so a fast shim could exec
its grandchild and exit before the assignment, leaving node.exe orphaned
in no job. KillTracked and an abrupt reasonix exit then both missed it,
and dozens of codegraph daemons leaked past a session (#3747).
Create the child suspended, assign it to the job, then resume — so every
descendant is captured before the launcher can spawn anything.
* fix(codegraph): refuse to index a filesystem root
When the workspace root resolved to a drive root (C:\), CodeGraph's
cwd-aware serve --mcp walked the entire volume — C:\Windows, Program
Files, everything — pinning ~1GB of RAM (#3747). Reject filesystem roots
(and an empty root) at both spawn sites before launching serve.
---------
Co-authored-by: reasonix <[email protected]>
The previous fix only handled \\\\slashed{X} (braced form). User-reported
issue: \\\\slashed\\\\epsilon(0) (Greek letter + function call, no braces)
was not being converted and still showed as red KaTeX error.
Extended the regex to handle two additional forms:
- \\\\slashed X → \\\\not X (single letter, no braces)
- \\\\slashed\\\\epsilon → \\\\not{\\\\epsilon} (backslash command)
- \\\\slashed\\\\epsilon(0) → \\\\not{\\\\epsilon(0)} (backslash command + function)
- \\\\slashed a → \\\\not a (single ASCII letter)
This covers the common physics notation \\\\slashed{p} where the model
sometimes forgets the braces around the argument.
Tests: 2 new cases added, 113/113 math-golden passing.
KaTeX doesn't support the \\slashed command from the LaTeX 'slashed'
package, which is commonly used in physics for Feynman slash notation
(\\slashed{p}, \\slashed{\\partial}).
Convert \\slashed{X} to \\not{X} before passing to KaTeX. The \\not
command provides a similar visual effect (a slash through the character)
and is supported by KaTeX.
This prevents KaTeX parse errors when the model emits physics notation
using \\slashed.
Tests: 3 new cases added, 111/111 passing
* fix(bash): reap the process group after a command completes
A foreground bash command that forked a lingering child (e.g. bazel run, which starts a persistent server) left it alive: Setpgid put it in the command process group, but cmd.Wait only reaps the shell leader and setKillTree only kills the group on cancel/timeout. Normal completion left the strays running, accumulating into an OOM (#3702). Kill the group after Run() on both the foreground and background paths. POSIX-only; Windows is a documented no-op (after-exit tree cleanup there needs a Job Object).
* test(bash): fix reap test — use CommandContext and pass pid via file
The first cut used exec.Command with setKillTree, but Go rejects a non-nil Cancel without CommandContext; it also read the bg pid from the inherited stdout, which the backgrounded child holds open. Create with context.Background(), redirect the child fds, and pass the pid through a temp file.
---------
Co-authored-by: reasonix <[email protected]>
* @
fix(provider): time out a stalled SSE stream instead of hanging
A half-open connection (a proxy switched mid-stream, no RST) left scanner.Scan()
blocked forever: the UI sat on "thinking", Ctrl+C/Esc were unresponsive, and the
only way out was kill -9. Add an idle watchdog to both the openai-compatible and
anthropic stream readers — if a started stream goes silent past streamIdleTimeout
the body is closed and a clear "stream stalled" error surfaces, returning the
session to the input prompt. The watchdog owns the timer; the read loop pings a
buffered channel, so there is no Timer.Reset race, and the openai replay path
(#3148) is untouched. Closes#3374.
@
* fix(provider): make the SSE idle timeout per-client, not a global var
The first cut used a package-level var so tests could shorten it, but a test writing it raced another test stream watchdog reading it under go test -race. Store the window on the client (defaultStreamIdleTimeout via New); tests set the field on their own client before Stream, so the go statement orders the write before the watchdog read. A zero-value client falls back to the default.
---------
Co-authored-by: reasonix <[email protected]>
Strip controller-injected prefixes ([Plan mode] marker, <memory-update>, <background-jobs>) and synthetic Role:user messages from the chat display so agent-mode internals no longer leak into the desktop/TUI transcript. Closes#3738, Closes#3720.
* @
fix(codegraph): resolve symlinks when extracting third-party bundle
The lexical tar/zip-slip guard missed the symlink-redirect escape: an
in-bounds symlink extracted as a parent component lets a later entry be
written through it to land outside the cache dir. Resolve the real parent
with EvalSymlinks before validating, and judge a symlink target from its
resolved location. Closes the go/unsafe-unzip-symlink + go/zipslip alerts.
@
* @
fix(scripts): run gh without a shell in backfill-issue-labels
execSync built a shell command string from interpolated values; switch to
execFileSync with an argv array so label and issue arguments can never be
parsed as shell. Closes the js/command-line-injection alert.
@
* @
ci(e2e-bot): gate untrusted PR-head run behind an environment
Running PR-head code with the provider secret was guarded only by the
author_association check. Add an `e2e-bot` deployment environment (configure
required reviewers to force per-run approval) and pin the checkout to the
head commit resolved at trigger time, detached, so a mid-run force-push
cannot swap in different code. Addresses actions/untrusted-checkout-toctou.
@
* @
fix: bound integer conversions flagged by code scanning
parseHexColor parses single bytes; parse them as 8-bit unsigned and return
int so the per-channel value is provably in range, dropping the
int64->int conversions at the call sites. Clamp the Myers maxD against a
negative n+m overflow too, so make() can never see a wrapped size.
@
---------
Co-authored-by: reasonix <[email protected]>
* feat(agent): add subagent transcript store
Co-authored-by: Cursor <[email protected]>
* feat(agent): continue task subagent transcripts
Co-authored-by: Cursor <[email protected]>
* feat(skill): continue subagent skill transcripts
Co-authored-by: Cursor <[email protected]>
* refactor(agent): require subagent transcript store
Co-authored-by: Cursor <[email protected]>
* fix(boot): record effective subagent identity
Co-authored-by: Cursor <[email protected]>
* fix(agent): release source lock after fork copy
Co-authored-by: Cursor <[email protected]>
* fix(cli): run review with explicit subagent session
Co-authored-by: Cursor <[email protected]>
* fix(agent): persist failed subagent transcripts
Co-authored-by: Cursor <[email protected]>
* fix(agent): mark failed foreground continuations
Co-authored-by: Cursor <[email protected]>
* fix(agent): bind subagents to parent sessions
Ensure persisted subagent transcripts record the active parent session and prevent in-place continuation from a different parent, keeping fork_from as the explicit copy path.
Co-authored-by: Cursor <[email protected]>
* fix(session): cascade subagent lifecycle cleanup
Move owned subagent artifacts with desktop trash/restore and delete them from hard-delete paths so parent session cleanup no longer leaves reusable child transcripts behind.
Co-authored-by: Cursor <[email protected]>
* fix(agent): run sub-agents ephemerally without a parent session
Persisted sub-agent transcripts made a parent session mandatory, so
`reasonix run` (headless, which never mints a session path) failed every
task/subagent-skill call with "subagent transcript parent session is
required". Fall back to a non-persisted run when no parent session is
active — restoring pre-transcript behaviour — and reserve continue_from/
fork_from for sessions that actually have a persisted owner.
Adds a headless-run regression guard plus task-level ephemeral coverage.
---------
Co-authored-by: lifu963 <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: esengine <[email protected]>
The e2e-bot pinned go-version: '1.22' with setup-go's GOTOOLCHAIN=local, but
go.mod now requires go >= 1.25.0, so the very first step ("Build harness +
fallback agent from the default branch") fails with:
go: go.mod requires go >= 1.25.0 (running go 1.22.12; GOTOOLCHAIN=local)
That breaks every /e2e invocation before it can build anything. Every other
workflow (ci.yml, release-*.yml) already uses go-version-file: go.mod; mirror
that here so the bot tracks the repo's Go version and never goes stale again.
The committed e2e suite has no task that reliably makes the model delegate,
so it never exercises the sub-agent path end-to-end. This task seeds three
files with arbitrary numbers and instructs the agent to read them via a
single `task` sub-agent (not inline), then write their sum to result.txt.
Because e2ebench drives the headless `reasonix run` path, this covers a fresh
`task` delegation there — the exact path that regressed when persisted
sub-agent transcripts made a parent session mandatory. continue_from/fork_from
need a persisted session and are out of scope for the headless harness.
* test(plugin): de-flake TestStartPhaseAReturnsBeforePhaseB
The test asserted StartAvailable returns in <150ms while the helper stalls
prompts/list by 200ms — a wall-clock proxy for "phase A doesn't block on the
prompt fetch". But StartAvailable spawns a subprocess and runs the MCP
initialize handshake, and that cost alone can approach or exceed 150ms on a
slow CI runner (observed 196ms on windows-latest), so the threshold flaked
with no real regression.
Drop the timing assertion and keep the deterministic deferral checks that
already encode the contract: after phase A the prompts surface is empty, and
prompts only materialise after StartPhaseB drains them. A realistic
"phase A surfaces prompts inline" regression still trips the empty-surface
check; the removed threshold only proxied an implausible fetch-but-not-surface
case at the cost of flakiness.
* test(control): de-flake TestRunShell_CancelStopsCommand
The test waited 5s for TurnDone after Cancel, but cmd.Wait honours
shellWaitDelay (also 5s) — and on Windows cmd.Cancel spawns taskkill /F /T —
so when the kill rides the full grace, TurnDone arrives at ~shellWaitDelay and
the 5s budget loses its own race (observed 5.18s on windows-latest).
Wait shellWaitDelay + 10s instead, via a new waitForDoneWithin helper that the
existing waitForDone now delegates to. The command is still killed promptly in
practice; the larger budget only removes the dead heat with the grace period.
Complete #3051: drop the plan-mode arm from the Esc handler so mode switches are exclusively Shift+Tab driven (Esc must not silently drop to a less-permissive mode). Also hoist Ctrl+C selection-copy above clear-input so copying a selection no longer wipes a half-typed draft.
Map Ctrl+Z to Bubble Tea's tea.Suspend so an explicit user suspend releases terminal state before stopping. First mitigation for #3655; the Unix SIGTTIN job-control guard is tracked separately.
Wrap the Windows systray loop in runtime.LockOSThread() so fyne.io/systray's Win32 hidden-window message loop stays pinned to one OS thread; the scheduler migrating the goroutine left the tray unresponsive while the app was in the tray. Fixes#3516.
C:/Program Files/Git/model now writes the chosen provider/model to default_model in the user config so the next session starts on it instead of the global default. SetDefaultModel accepts the provider/model form (validated via ResolveModel).
Wizard now probes the same candidate URLs (root + /v1 + compat suffixes) the chat client resolves, and the anthropic provider strips a trailing /v1 from base_url so a pasted OpenAI-shape URL no longer produces /v1/v1/messages.
* fix(dev): suppress pnpm TTY prompts and lockfile age checks in dev script
pnpm v11 aborts node_modules removal without a TTY and rejects lockfiles
with packages published within the minimum release age window. Both checks
are meant for CI safety and are irrelevant during local development.
Set PNPM_CONFIG_CONFIRM_MODULES_PURGE=false and
PNPM_CONFIG_MINIMUM_RELEASE_AGE=0 before launching wails dev.
* ci: trigger re-run
---------
Co-authored-by: wufengfan <[email protected]>
* fix: always allow (persist) now grants all tools for the session
When user clicks 'Always allow' (Allow Persistently) on a tool approval
prompt, the current code only remembers the grant for that specific tool
(e.g. write_file). Later in the same session, other tools like bash still
trigger permission prompts, confusing the user.
This fix makes 'Always allow' set a wildcard session grant (c.granted['*'])
so all writer tools are auto-allowed for the rest of the session without
further prompting. The actual on-disk config rule is still written as before
via OnRemember for cross-session persistence.
The normal 'Allow for this session' remains tool-specific as before.
* Revert "fix: always allow (persist) now grants all tools for the session"
This reverts commit f90ba6f587.
* fix: reload persist rule into in-memory Policy immediately
After OnRemember writes an 'always allow' rule to the on-disk config,
also append the parsed rule to the Gate's in-memory Policy.Allow slice
so it takes effect in the current session without requiring a restart.
Previously, clicking 'Always allow' on a tool (e.g. write_file) would:
1. Write the rule to reasonix.toml ✅
2. Set c.granted['write_file'] = true for the Approver path ✅
3. BUT: the Gate's Policy was not updated in memory ❌
→ Any code path consulting Policy.Decide() directly would still see
the old policy and not match the new allow rule
This fix adds the parsed rule to g.Policy.Allow after writing to disk,
so the in-memory Policy stays consistent with the persisted config.
Ref: #3607
---------
Co-authored-by: HorusJiang <[email protected]>
The pinned golangci-lint binary's staticcheck reports SA5011 (possible nil
pointer dereference) on `if x == nil { t.Fatal(...) }`-guarded derefs in tests
— it doesn't model t.Fatal as terminating. The same code is clean under a
locally-built golangci-lint, and the finding is masked by the action cache
until a go.sum change cold-busts it, so it surfaces per-package and has failed
lint on otherwise-correct PRs (handled ad hoc in #3706, #3714).
Scope a test-only SA5011 exclusion so it stops blocking PRs while SA5011 keeps
guarding production code.
Co-authored-by: reasonix <[email protected]>
* fix(config): honor an explicit proxy for no_proxy providers (mimo)
The built-in mimo presets carry no_proxy=true so their domestic endpoint
stays off an auto-detected (GFW-circumvention) system proxy. But that bypass
was applied in every mode, so behind a mandatory corporate proxy
(proxy_mode = "custom") mimo tried a direct connection the firewall blocks —
making mimo unusable on enterprise networks (#3635).
Apply the provider-level no_proxy bypass only for auto/env proxies. An explicit
custom proxy means "route everything through this", so honor it for every
provider; a custom-proxy user who still wants a host direct uses
network.no_proxy.
Closes#3635
* test(config): structurally guard nil-deref (staticcheck SA5011)
The release golangci-lint binary CI runs flags SA5011 on guarded t.Fatal
derefs in backfill_test.go and migrate_test.go (same cache-masked false
positive as #3706); this PR touches the config package so a cold lint run
surfaces them. Guard with else-if; no behavior change.
---------
Co-authored-by: reasonix <[email protected]>
run_skill is ReadOnly()=false because subagent skills can spawn writer tool
calls, so plan mode (read-only) blocks ALL skill use — including inline skills,
which only render a body and have no side effects. That made it impossible to
consult a playbook while planning (#3491).
Add read_skill: a read-only counterpart that renders an inline skill body and
rejects subagent skills (pointing the model at run_skill). Being ReadOnly it
stays available in plan mode. Added to SubagentMetaTools so spawned agents
don't inherit it.
Closes#3491
Co-authored-by: reasonix <[email protected]>
* fix(control): don't auto-answer the ask tool in YOLO mode
Controller.Ask checked bypassEnabled() and silently returned the first option
of every question, so in YOLO mode the `ask` tool got a fake answer and the
user never saw the prompt. Bypass is meant to skip tool-approval gates, not to
answer the user's genuine questions. Remove both bypass checks (and the now
unused recommendedAskAnswers helper); Ask always emits an AskRequest and waits
for AnswerQuestion, even under bypass.
Combines the minimal fix from #3709 (CVEngineer66) with the behavior tests
from #3638 (warvyvr), dropping that PR's unrelated files.
Closes#3624
Co-authored-by: CVEngineer66 <[email protected]>
Co-authored-by: warvyvr <[email protected]>
* fix(control): drop now-unused bypassEnabled helper
Removing the bypass checks from Ask left bypassEnabled with no callers
(staticcheck unused).
---------
Co-authored-by: reasonix <[email protected]>
Co-authored-by: CVEngineer66 <[email protected]>
Co-authored-by: warvyvr <[email protected]>
The 'Allow for session' and 'Always allow' buttons in the approval modal
displayed the full bash command as inline code (e.g. Bash(cd ...)), which
stretched the button elements and broke the card layout.
The command is already visible in the card header meta area and the
expandable Details section, so showing it again inside the action buttons
is redundant. Removed the RuleActionLabel component, the rule-formatting
helpers (approvalSessionRule, approvalPersistentRule, truncateSubject),
and the unused CSS classes.
Fixes https://github.com/esengine/DeepSeek-Reasonix/issues/3677
Co-authored-by: wufengfan <[email protected]>
The release golangci-lint v2.12.2 binary CI runs flags SA5011 (possible nil
pointer dereference) on two `if x == nil { t.Fatal(...) }`-guarded derefs,
even though they are correct (Fatal stops the test). The finding was masked by
the golangci-lint-action cache until #3678 changed go.sum and invalidated it,
then surfaced on main-v2 and every open PR. A locally-built golangci-lint does
not reproduce it, so this is a staticcheck quirk in the pinned binary, not a
real defect.
Restructure both sites with flow-based guards (else-if / switch) the analyzer
always respects — no behaviour change, no unreachable `return` (which a
Fatal-aware analyzer would flag locally).
Co-authored-by: reasonix <[email protected]>
Previously, the Step 5 regex would greedily match '$5 and $' as a single
math expression with content '5 and ', then convert it to '$5 and $'
because the classifier correctly identified it as non-math. This was visually
correct but had two problems:
1. The greedy match would consume the closing dollar that belonged to the
next currency token, causing cascade replacements.
2. Prose currency like 'These two apples cost $5 and $6' would have its
dollar signs converted to HTML entities, which works but is unnecessary
noise in the rendered output.
Changes:
- Step 5 regex now uses non-greedy matching (+\?) so '$5 and $' doesn't
match '$5 and $' as a single pair
- When the classifier rejects a match, the original text is preserved
unchanged (return _m) instead of being wrapped in HTML entities
- This keeps dollar signs visible in prose while still preventing them from
being parsed as math
All 122 tests pass.
Enhancements to inline math detection:
- Reject pure numbers (1, 2.5, 10) as currency/percentages
- Accept numbers with variables (2.5x, 3y^2) as math
- Accept numbers with LaTeX escapes (10\%) as math
- Fix single-line code block detection to protect $ in malformed markdown
This better matches real-world usage where 'costs $5' is currency
but '$2.5x + 3$' is clearly a mathematical expression.
All 122 tests pass (108 math-golden + 8 text-size + 6 provider-model-refresh).
Remove the requirement that ``` must appear after a newline. This
handles cases where documentation is pasted on a single line with
embedded code blocks containing $ symbols.
Previously: ``` markers were only recognized after \n
Now: ``` markers are recognized anywhere
This prevents KaTeX errors (red text) when processing malformed code
blocks that contain $ in regex patterns, template literals, or other
code examples.
All 120 tests pass.
Add defensive escaping for code blocks containing $ characters.
When protecting code (inline `...` or fenced ```...```), replace
$ with $ (HTML entity). On restoration, unescape back to $.
This prevents KaTeX from attempting to parse math delimiters that
appear in code examples, regex patterns, or template literals.
Fixes: Pasted documentation about the math pipeline itself no longer
shows red KaTeX error text.
Tests: 3 new cases added, 106/106 passing
/npm/reasonix only declares this project's own @reasonix/cli-* platform
binaries as optionalDependencies — versioned by the release pipeline, not
third-party deps. Dependabot was opening useless 0.0.0->1.0.0 self-bump PRs
for them; remove the ecosystem entry.
Co-authored-by: reasonix <[email protected]>
The classifier rules are language-agnostic, not specific to CJK text.
Updated test section name and descriptions to reflect that patterns
like single digits, comma-separated tokens, and one-sided operators
apply universally across languages. Chinese text in test cases remains
as real user examples, but the rules themselves are not CJK-specific.
* chore(ci): add Dependabot config and CodeQL code scanning
Enable Dependabot version updates (gomod for the root and desktop
modules; npm for desktop/frontend, site, and npm/reasonix; github-actions)
with grouped minor/patch PRs to keep noise down, and add a CodeQL workflow
(go, javascript-typescript, actions; build-mode none so the multi-module
repo needs no build step) on push/PR to main-v2 plus a weekly scan.
* chore(ci): use codeql-action v4 (v3 deprecates Dec 2026)
* fix(ci): CodeQL Go requires a build — use autobuild, not none
---------
Co-authored-by: reasonix <[email protected]>
Three targeted fixes to the math-pipeline pre-pass that resolve cases
where the rendered chat output showed LaTeX source as raw text:
1. mathNormalize.ts (Step 2.5): when the model writes block math with
the opening $$ glued to prose on the same line ('…decomposes
as$$\n\mathbf{6}…'), CommonMark requires a blank line before
the $$. remark-math otherwise creates an empty math node and the
formula leaks out as literal text. Insert \n\n before any $$
preceded by a letter or end-of-sentence punctuation. The
freshly-rewritten \] → $$ from step 2 is not affected.
2. mathClassify.ts: classify single digits ($1$, $2$) as math —
commonly used as set / sequence indices. Multi-digit numbers,
decimals, and percentages stay literal (still currency / percentage).
This is a deliberate behavior change documented in the comment.
3. mathClassify.ts: allow comma-separated tokens ('A, B', '1, 2, 3',
'\\alpha, \\beta', '(A, B)') as math. These are typical of
ordered-pair / tuple / enumeration notation. Currency and env-var
usage never looks like this.
4. mathClassify.ts: allow single uppercase letters as math. In
non-English math prose (Chinese / Japanese / Korean textbooks)
single capital letters are extremely common as set / algebra /
group / vector-space names, and the closing-dollar form $X$ is
essentially never written for English words like I/A/V by hand.
Test changes: 4 existing currency/acronym assertions updated to
reflect the new behavior, 13 new regression tests covering all four
fixes including the user's specific cases ('$1$ 和 $2$' and
'$S$ 非空 / $S$ 有上界'). 98 math-golden tests pass, 112/112 across
all suites, typecheck clean.
Orphan $$ (model wrote display math but forgot the closing $$) is
documented as not-fixed-from-the-renderer: every attempt to rescue
the orphan from the renderer side made the output worse, so the fix
for that case is on the LLM side (post-generation lint or stricter
system prompt).
* fix(control): fail conversation rewind loudly past a compacted boundary
A turn's conversation-rewind boundary is the message-log index at turn
start. Compaction shrinks the log without rewriting boundaries, so for a
turn compacted away the boundary now exceeds len(Messages). Rewind tested
`boundary <= len` and, when false, skipped the truncation but still emitted
a "rewound conversation" success — code restored, conversation silently not.
Return a clear failure instead, and only report success when the log was
actually truncated.
Closes#3598
* fix(desktop): mark a turn code-rewindable when a later turn changed files
RestoreCode(turn) reverts every file touched in that turn or any later one,
so a turn that changed no files of its own can still rewind code when a
later turn did. CanCode only checked the turn's own paths, disabling the
code/both rewind buttons for such turns. Propagate CanCode backwards over
the oldest-first checkpoint list so the UI matches the backend's capability.
Refs #3438
---------
Co-authored-by: reasonix <[email protected]>
The desktop release dropped Debian/Ubuntu packaging in the v2 rewrite —
Linux only shipped a bare-binary tar.gz (discussion #3627). Add a .deb
built with goreleaser/nfpm in desktop-build.sh's linux branch.
- desktop/build/linux/nfpm.yaml + reasonix.desktop describe the package:
binary to /usr/bin, .desktop entry, icon to /usr/share/pixmaps, and
libgtk-3-0 + libwebkit2gtk-4.1-0 runtime deps apt resolves on install.
- The .deb is a human-download artifact only (like the macOS .dmg); the
Linux updater channel stays the tar.gz, so cmd/sign's manifest skips
.deb files. A test locks the linux-amd64 key to the tarball.
- release-desktop.yml installs nfpm (pinned v2.46.3) on the Linux runner.
The existing sign / upload / release / R2-mirror steps already glob dist/*,
so the .deb is signed, attached to the GitHub release, and mirrored with no
further workflow changes.
Co-authored-by: reasonix <[email protected]>
TestACPFactoryLoadsSessionCwdProjectConfig builds a full controller via
boot.Build with the default config, which starts the codegraph serve
daemon when the binary is installed. The daemon opens
<workspaceRoot>/.codegraph/codegraph.db and on Windows holds the handle
past Close, so t.TempDir cleanup fails with "being used by another
process". The test only exercises ACP project-command loading, so turn
codegraph off — matching the boot tests' convention.
Co-authored-by: reasonix <[email protected]>
Replace the ad-hoc macOS codesign with Developer ID signing (hardened runtime + entitlements + timestamp), notarize and staple the .app and .dmg via notarytool. Gated on HAS_APPLE_CERT from the APPLE_* secrets; ad-hoc fallback when unset.
Three fixes for resuming sessions from the history panel:
1. OpenProjectTab now calls addProject() and emitProjectTreeChanged(), so
opening a session from a previously removed project automatically restores
it to the sidebar. No more silent switch to the wrong workspace tab.
2. openProjectTab/openGlobalTab no longer swallow exceptions (catch { return
undefined }). Errors propagate to onResumeSession which shows a user-facing
toast instead of silently falling back to the current tab.
3. A reusable ToastProvider + useToast() hook (info/warn/error levels, 2.5s
auto-dismiss, stackable, click-to-dismiss) is introduced. Used here for
session-open failures and available to other contributors for notifications.
Co-authored-by: wufengfan <[email protected]>
When switching between projects, the @ mention picker retained stale
candidates from the previous project because dirCache/searchCache keys
did not include workspace context and the ListDir/SearchFileRefs effects
did not depend on cwd.
Changes:
- Add cwdRef snapshot pattern to detect workspace switches in async
openPastChats callback, discarding stale ListSessions responses
- Add useEffect([cwd]) to invalidate all @ mention state (caches,
entries, past chats, loading, active index) when cwd changes
- Use prevCwdRef to skip the effect on initial mount, avoiding
unnecessary re-renders with empty-array references
- Add cwd to ListDir and SearchFileRefs effect dependencies so the
picker re-fetches when the workspace changes while the menu is open
- Conditionally reset loadingPastChats in openPastChats finally block
to prevent stale async responses from overwriting new workspace state
Closes#3601
Root cause: AnchoredPopover and ContextMenu both listen for scroll events
on window to close themselves. During AI streaming generation, the transcript
area auto-scrolls on every incoming token (Transcript.tsx:218-226), which
triggers window scroll events and instantly closes any open menu/popover
before the user can interact with it.
Fix: remove the close-on-scroll listener from both components. Menus still
close on Escape key, window resize, click-outside (ContextMenu via pointerdown,
AnchoredPopover via backdrop), which are the standard and predictable ways
to dismiss a menu.
Also fixes AnchoredPopover's useLayoutEffect missing dependency array
[open, align, offset, placement] to reduce unnecessary position recalculations.
Wails 的 EnableFileDrop 全局开启后,拖动窗口左上角图标
(非磁盘文件)会触发 ResolveFilePaths 抛出未捕获错误,
导致整个应用崩溃。
在 onFilesDropped 中注册全局 error/unhandledrejection 拦截器,
吞掉 'additional File object is not a file on the disk' 错误,
仅允许真实的文件拖拽到达回调。
Co-authored-by: Li-Charles-One <[email protected]>
Three changes that all reduce false signals in the evidence/complete_step/todo
verification system:
1. Remove duplicate complete_step ordering check from finalReadinessCheck.
The todo_write tool's own guard (verifyTodoCompletionTransitions) already
rejects marking items completed without a matching complete_step receipt,
and is order-independent. The finalReadinessCheck duplicate was stricter
(required complete_step after the latest writer), causing false positives
when the model correctly completes steps in a different order.
2. Add session-fallback command verification in complete_step.
The per-turn Ledger is reset each turn, so cross-turn references, non-bash
tool calls (e.g. the `ls` tool cited as command="ls ."), and truncated
command strings all fail the strict receipt match. verifyCommandFromSession
scans the full conversation transcript with normalized matching (strip …,
collapse whitespace, prefix match with 8-char minimum) and tool-name
fallback, eliminating three common false negatives.
3. Remove stale "Progress may be stale" indicator from desktop TodoPanel.
The heuristic (≥2 tools after last todo_write, final assistant message,
readiness notices) triggered false alerts in normal completion flows.
The todo count and transcript itself provide better progress visibility.
Fixes#3469
Co-authored-by: wufengfan <[email protected]>
MiniMax-M3 exposes a single binary thinking knob (adaptive|disabled)
on its OpenAI-compatible endpoint at api.minimaxi.com, but our
provider unconditionally emits reasoning_effort — which M3 rejects.
Detect api.minimaxi.com as a separate wire shape alongside
api.deepseek.com and:
- emit thinking.type=adaptive|disabled in buildRequest
- omit reasoning_effort entirely (M3 has no level scale)
- translate /effort auto to 'adaptive' (the M3 default, since M3
ships with thinking on out of the box and 'auto' semantically
means 'don't override the model default')
- map legacy level names from other vendors so a stale /effort
value still resolves to a valid M3 level:
off → disabled (retired DeepSeek 'no thinking' — M3
actually supports 'thinking off')
low/medium/high → adaptive
xhigh/max → disabled
- in NormalizeEffort, surface a MiniMax-specific usage hint
(auto|adaptive|disabled) when an unknown level is supplied
internal/config/effort.go mirrors the change: M3 entries now expose
/levels=[auto,adaptive,disabled] with default=adaptive. Tests cover
the wire shape (TestBuildRequestMiniMaxThinking) and the
config-layer remap (TestNormalizeEffortMiniMax,
TestNormalizeEffortMiniMaxRejectsGarbage,
TestEffectiveEffortMiniMax).
Refactor: the host-matching logic was duplicated across
internal/provider/openai/openai.go (private *BaseURL helpers) and
internal/config/effort.go (private *Entry wrappers). Extract the
shared primitive into internal/provider/openai/host.go as
matchesVendorHost, plus thin exported wrappers IsDeepSeek and
IsMiniMax. The *Entry wrappers in effort.go now just gate on the
openai kind and delegate. Adding a new gateway is now a one-line
change in host.go rather than a four-line change across two files.
Add internal/provider/openai/host_test.go pinning the matching
rule (canonical hostname exact match, plus any subdomain of the
apex) directly at the source.
Co-authored-by: xbzhao <xbzhao@local>
* feat(web_fetch): proxy support with SSRF-safe HTTP CONNECT / SOCKS5 tunnelling
web_fetch intentionally bypasses netclient proxy settings (source comment
in netclient.go). This means users behind corporate firewalls or GFW
cannot use web_fetch to reach external sites (GitHub, npm, etc.), even
when they have a working proxy configured in [network].
Wire web_fetch to the existing [network] proxy configuration while
preserving full SSRF protection:
1. netclient.ResolveProxyURL — new exported function that resolves
ProxySpec to URL string (reuses existing customProxyURL logic)
2. webFetch.proxyURL — new field set during tool registration
3. ssrfGuardedClient(proxyURL) — enhanced to tunnel through proxy:
- HTTP/HTTPS proxy: dial proxy -> CONNECT with ORIGINAL hostname
(proxy resolves DNS remotely, essential for GFW users) -> tunnel
- IP literal targets still get SSRF-checked via net.ParseIP
- SOCKS5/SOCKS5h: Transport.Proxy (Go standard library handles it)
- No proxy: existing direct SSRF-safe behavior unchanged
4. ConfineWebFetch + boot.go plumbing for both workspace and fallback
paths
| File | Change |
|------|--------|
| internal/netclient/netclient.go | +ResolveProxyURL() |
| internal/tool/builtin/webfetch.go | webFetch.proxyURL, ssrfGuardedClient proxy tunnel |
| internal/tool/builtin/workspace.go | Workspace.ProxyURL |
| internal/tool/builtin/confine.go | +ConfineWebFetch() |
| internal/boot/boot.go | addBuiltins proxyURL param + fallback fix |
| internal/boot/boot_test.go | test signature update |
| internal/tool/builtin/web_fetch_proxy_test.go | new proxy tests |
- go build ./... compiles
- go test ./internal/tool/builtin/ PASS
- go test ./internal/boot/ PASS
- SSRF still blocks 169.254.169.254, 10.0.0.1, 192.168.1.1 through proxy
* fix(web_fetch): keep SSRF guard usable with a SOCKS5 proxy
The SOCKS branch only set Transport.Proxy, so the proxy itself was dialed
through the SSRF-checking directDialContext — a SOCKS proxy on a private/LAN
address (anything but loopback) was refused by the guard. Route SOCKS through
golang.org/x/net/proxy with a plain dialer to the trusted proxy, and keep the
IP-literal target SSRF check in a wrapping DialContext, mirroring the HTTP
CONNECT path. Adds tests for both the preserved block and the proxy-not-blocked
case.
---------
Co-authored-by: ningqipeng <[email protected]>
Co-authored-by: reasonix <[email protected]>
* fix:Fix the issue where the phone keyboard cannot be used in the Termux environment.
* fix:测试环境加上termux环境判断
---------
Co-authored-by: Your Name <[email protected]>
The workspace-panel-resizer ::after line was positioned at left: 0,
leaving 7px of empty space on the right side of the 8px resizer area.
This created a visible gap between the resizer line and the workbench dock.
Move the line to left: 50% with translateX(-50%) to center it within
the resizer, matching the sidebar-resizer behavior.
Co-authored-by: Li-Charles-One <[email protected]>
Add onAuxClick and onMouseDown handlers to <a> in Markdown component
to intercept middle-click events and open links via openExternal()
instead of allowing default browser navigation behavior.
Co-authored-by: Li-Charles-One <[email protected]>
* fix(desktop): virtualize composer / and @ menus, drop item caps
The slash menu was sliced to 8 and the @ file menu to 10, so commands and
files past those were unreachable even by scrolling — Commands() alone
returns 10+ builtins before skills/custom/mcp are added. Remove both caps
and render the menus through a shared @tanstack/react-virtual list, so they
scale to any directory size while only mounting the visible rows.
* build(desktop): update pnpm-lock for @tanstack/react-virtual
The build uses pnpm (wails.json frontend:install); the new dep must be in
pnpm-lock.yaml or pnpm install --frozen-lockfile fails in CI.
---------
Co-authored-by: reasonix <[email protected]>
* fix(provider): emit a valid schema for no-param tools
An empty tool schema (common for no-argument MCP tools) is an empty
json.RawMessage, which makes json.Marshal of the whole chat request fail
with "unexpected end of JSON input" — bricking the entire provider for
every request with a cryptic error. Canonicalize empty to {"type":"object"}.
* fix(openai): send null content for pure tool-call assistant turns
Strict OpenAI-compatible backends can reject an assistant tool_calls
turn whose content is the empty string. null is the OpenAI-spec form and
is accepted by DeepSeek (verified against a live multi-tool session), so
emit null for that one case and keep the string form (empty included)
for every other role. Refs #3526.
---------
Co-authored-by: reasonix <[email protected]>
* fix(agent): keep executor from answering as planner
* fix(agent): scope handoff-confusion match to role-identity phrases
Bare permission claims (no write access / 没有写入权限) are the exact
vocabulary a correctly-executing model uses to report a real blocker;
matching them mis-fired the planner-confusion guard and could hard-error
a legitimately-blocked task. Drop those phrases, keep role-identity ones,
and lock it with a regression test.
* fix(agent): detect executor punt by behavior, not keywords
The keyword list matched on vocabulary, so it both missed paraphrases
and false-flagged legitimate blocker reports ("no write access" is the
exact phrasing the handoff prompt asks the executor to use). Replace it
with a structural signal: if the executor reaches a final answer in
handoff mode having called zero tools, nudge once with the executor
instructions, then trust it — no keyword table, no hard error. Any tool
use (including read-only) exempts it, so only a true punt triggers the
nudge. Language-independent.
Tests cover both the nudge and the no-nudge-when-acting path; verified
end-to-end with a live deepseek-pro planner / deepseek-flash executor
run that wrote and ran the file without tripping the guard.
---------
Co-authored-by: reasonix <[email protected]>
* feat(cli): add reasonix review subcommand for code review
Add a new `reasonix review` subcommand that runs the built-in review skill as
a subagent against git changes and outputs the result. This gives users a
one-shot, non-interactive way to review code without opening a chat session.
Usage:
reasonix review # review uncommitted working-tree changes
reasonix review --base main # review current branch vs main
reasonix review --commit abc123 # review a specific commit
reasonix review --model deepseek # use a specific model
reasonix review --instructions ... # add extra review instructions
Implementation: 3 files, +154 lines
- cli/review.go: reviewCommand + getReviewDiff + buildReviewTask
- cli/review_test.go: TestBuildReviewTask (3 sub-cases)
- cli/cli.go: register review case in Run()
* fix(cli): use fmt.Fprint instead of WriteString(fmt.Sprint) in review
staticcheck QF1012 — keeps the lint job green.
---------
Co-authored-by: reasonix <[email protected]>
Multi-select mode now uses Space only for toggling individual options,
while Enter (and number keys) confirms current selections and advances
to the next question or submits, matching the single-select flow.
Previously both keys toggled, making it impossible to commit without
toggling the highlighted option.
The desktop AskCard already has this correct via explicit confirm
buttons and was unaffected.
The .history-content grid used minmax(340px, 0.38fr) for the list column,
which forced a 340px minimum even in narrow viewports and squeezed the
preview pane on the right. Lower the floor to 0 and cap the list at
40vh inside the <=760px breakpoint so the preview gets room to breathe.
Fixes#3538
Co-authored-by: Li-Charles-One <[email protected]>
Required for the SignPath Foundation open-source program: the download
reference must mention that the project uses SignPath for code signing.
Added to both README.md and README.zh-CN.md.
Co-authored-by: reasonix <[email protected]>
* fix(desktop): persist yolo composer mode across reload (#3517)
persistedTabMode dropped yolo (only plan was saved), so selecting yolo and
relaunching reverted to normal — the tab persist/restore machinery already
round-trips the mode, yolo was just filtered out. Persist plan and yolo
(a restored yolo tab keeps its status-bar indicator); normal stays the
un-persisted default.
Closes#3517
* test: flip the yolo-persist tab test to the new behavior
TestSaveTabsDoesNotPersistYoloMode encoded the old drop-yolo behavior;
renamed to TestSaveTabsPersistsYoloMode and flipped to assert yolo survives
the real saveTabsLocked/loadTabsFile round-trip. Drops the redundant unit
test added alongside the fix.
---------
Co-authored-by: reasonix <[email protected]>
* fix(permission): make session/persist approval grants tool-wide
"Allow for this session" and "Allow persistently" pinned the exact
subject (file path / command), so writing a different file or running a
different command re-prompted every time — users read the labels as
"trust this tool" and reasonably expected no further prompts. Both now
grant the whole tool: the session grant keys on the tool name, and the
persisted rule is the bare tool name. Deny rules still take precedence on
every call. Adds a full agent-turn e2e proving a second file write under
the same tool runs without a second prompt.
Closes#3498Closes#3520
* test: gofmt the e2e approval test
---------
Co-authored-by: reasonix <[email protected]>
Canary desktop builds no longer publish a GitHub release — the version
showed up on the public releases page looking like a 1.4 stable. Canary
now uploads its signed dist as a workflow artifact that the mirror job
pushes to R2 (canary/ + desktop-canary/); the updater's canary channel is
R2-only. Stable is unchanged. Docs updated.
Co-authored-by: reasonix <[email protected]>
Stable publish jobs (CLI goreleaser, npm tag push, desktop tag/stable
dispatch) now run in the `release` environment, which requires esengine's
approval before anything goes public. Canary dispatches use the open
`canary` environment so any maintainer can self-serve a pre-release. Adds
docs/RELEASING.md documenting the trunk+tags model, channels, and who can
ship what.
Co-authored-by: reasonix <[email protected]>
* feat(desktop): add opt-in canary update channel
A canary build (-X main.channel=canary) polls R2's canary/ pointer instead of
latest/, so a stable install can never auto-update onto it. Folded into the
existing desktop release workflow as a dispatch-only channel input: build/sign/
manifest are shared, only version/publish/mirror branch on channel. Tag/version/
channel resolution is centralized in scripts/resolve-desktop-release.sh. CLI is
unchanged (stays on the npm next tag).
* feat(cli): add opt-in canary npm dist-tag
A version containing -canary. publishes to the canary dist-tag instead of
next/latest, so npm i reasonix@canary opts in and 1.x (next) / 0.x (latest)
users are untouched. release-npm.yml gains a workflow_dispatch canary mode
that builds the current ref as <base>-canary.<run_number>.
---------
Co-authored-by: reasonix <[email protected]>
* perf(desktop): coalesce streaming text/reasoning deltas per animation frame
The agent stream pushes a text/reasoning delta per Webview event; at 200
tok/s that is one state update every ~5ms, while rAF is 16ms — so the
reducer was running 3-4 times per visible frame and the React tree was
re-rendering the entire Message list each time.
Add lib/rafBatch.ts: a tiny rAF-coalescing queue with a synchronous
drain() so non-text events (tool_dispatch, usage, notice, turn_started/
done, message) flush any pending deltas first to keep causal ordering
intact. Drain is also called on unmount so a turn-end that races the
teardown doesn't strand the last few tokens in the buffer.
tsc --noEmit passes; Go build ./desktop/... is clean.
* perf(desktop): coalesce streaming text/reasoning deltas per animation frame
The agent stream pushes a text/reasoning delta per Webview event; at 200
tok/s that is one state update every ~5ms, while rAF is 16ms — so the
reducer was running 3-4 times per visible frame and the React tree was
re-rendering the entire Message list each time.
Add lib/rafBatch.ts: a tiny rAF-coalescing queue with a synchronous
drain() so non-text events (tool_dispatch, usage, notice, turn_started/
done, message) flush any pending deltas first to keep causal ordering
intact. Drain is also called on unmount so a turn-end that races the
teardown doesn't strand the last few tokens in the buffer.
tsc --noEmit passes; Go build ./desktop/... is clean.
---------
Co-authored-by: HUQIANTAO <[email protected]>
Co-authored-by: reasonix <[email protected]>
The send button uses the default flex alignment (center), so it floats
vertically-centred in the composer. As the textarea grows to 2, 3, 4+
lines the button drifts downward with each new line — the muscle memory
target keeps moving.
Add align-self: flex-end to .composer__btn so the button hugs the
bottom-right corner regardless of how tall the input is. The caret (›)
stays vertically centred (the default align-items: center) which keeps
it visually anchored to the first line of text.
Co-authored-by: HUQIANTAO <[email protected]>
The parent task tool card chevron only toggled args/output visibility;
nested sub-agent calls (read_file / grep / ls from an /explore run)
always rendered underneath. A 50-step /explore flooded the transcript
with its full step list, with no way to fold it back.
Now the open state also controls nested items. A single chevron click
collapses or expands the whole subagent tree. Behavior:
- Open by default while the sub-agent is still "running" so the user
can watch progress.
- Closed by default once it settles, so a finished explore folds to
a single summary line ("12 steps") on first paint.
- Individual sub-calls can still be expanded/collapsed independently.
This is the ToolCard half of the original #3140. The streaming-markdown
half has been merged via #3444.
Co-authored-by: HUQIANTAO <[email protected]>
* feat(desktop): add branch switcher dropdown in changes panel
Show current git branch name in the Changed-files view with a dropdown
to switch branches and a refresh button to reload changes.
Backend:
- Add GitBranch field to WorkspaceChangesView
- Add workspaceGitBranch() helper
- Add GitBranches() and GitCheckout() bindings
Frontend:
- Add branch indicator bar with dropdown menu
- Add refresh button with spinning animation
- Add workspace-branch CSS styles
- Add mock implementations for GitBranches/GitCheckout
- Update WorkspaceChangesView type with gitBranch field
Tests:
- Add GitBranch assertion to existing git status test
- Add TestWorkspaceChangesGitBranchDetachedHead
- Add gitOutput test helper
* fix(desktop): branch menu add background and increase height
---------
Co-authored-by: ttmouse <[email protected]>
Co-authored-by: reasonix <[email protected]>
The CommandPalette component existed in the codebase since its original
PR but was never imported or rendered in App.tsx. Wire it up:
- Import CommandPalette and PaletteItem type
- Add paletteOpen state with ⌘K/Ctrl+K toggle listener
- Add buildPaletteItems() generating session-switch + action items
- Render <CommandPalette> below existing overlays
- Add palette CSS styles (backdrop, panel, input, list, footer)
Treat .reasonix/attachments refs as images only when the attachment is an actual image extension, so yaml/zip attachments resolve as file refs and no longer render as misleading image placeholders.
Co-authored-by: Eason Sun <[email protected]>
Co-authored-by: Cursor <[email protected]>
* fix(desktop): clear active workspace pointer when removing current project
RemoveWorkspace() was cleaning up desktop-workspaces.json and
desktop-projects.json but never cleared the desktop-workspace pointer
file. When the removed project was the active workspace, this left a
stale reference that caused subsequent sessions to use the wrong
workspace root — attachments and memory would land in a deleted
project's directory.
Now RemoveWorkspace checks whether the removed dir matches the active
pointer and either falls back to the first remaining project or clears
the pointer entirely when no projects remain.
Fixes#1623
* fix(desktop): update active workspace pointer in OpenProjectTab
SwitchWorkspace correctly saves the active pointer, but clicking an
existing project in the sidebar goes through OpenProjectTab instead,
which never called saveWorkspace. This meant that after switching
projects via the sidebar, desktop-workspace still pointed to whichever
project was active at app startup — causing attachments and memory to
land in the wrong project directory.
Now OpenProjectTab also calls saveWorkspace so the pointer stays in
sync regardless of how the user navigates between projects.
Also adds a test verifying OpenProjectTab updates the pointer.
The CLI release was driven by a single `v*` tag that ran goreleaser
(archives + GitHub release + Homebrew cask) AND published npm together.
goreleaser auto-skips the cask on prereleases (brew has no prerelease
channel), so a `v*-rc*` tag could publish an npm rc but never a stable
cask, and a stable `v*` forced npm to leave its rc — the two could not
move independently.
Give npm its own tag namespace, mirroring desktop:
- `v*` -> release.yml: archives + checksums + GitHub release + cask
- `npm-v*` -> release-npm.yml (new): npm publish only, own cache-guard
- `desktop-v*`-> release-desktop.yml (unchanged)
build.mjs now strips `^(npm-)?v`, so `npm-v1.3.0-rc.1` -> 1.3.0-rc.1
(dist-tag stays `next` for the 1.x line and every prerelease). A normal
stable release now needs both `v1.3.0` and `npm-v1.3.0` tags.
When the user clicks into the textarea, the resize handle pill above the
composer card can appear (on hover, which co-occurs with focus). Also,
the textarea's native scrollbar track can show in some webview configs.
Fix:
- Hide the resize handle pseudo-element while the composer card has
:focus-within (the user is typing, not resizing).
- Add scrollbar-width:none + ::-webkit-scrollbar { display:none } to
.composer__input to suppress any native scrollbar.
- Restore a thin scrollbar for .composer-card--resized where the user
has explicitly made the input taller and might need to scroll.
Co-authored-by: HUQIANTAO <[email protected]>
The tab bar applies a semi-transparent background on hover to all tabs,
including the one that is already selected. The active tab's own
background (--chat-bg) gets overridden by the hover mixin, making the
currently-selected tab flicker to a different shade on mouse-over — a
visual cue that says 'this tab is not selected' when it already is.
Add :not(.tabbar__tab--active) to the hover rule so only inactive tabs
respond to hover. The close button inside the active tab is unaffected
(its visibility is controlled by a separate rule).
Co-authored-by: HUQIANTAO <[email protected]>
The assistant bubble previously rendered raw text while streaming and
only parsed markdown once the turn completed. This created a jarring
visual jump: plain text for seconds, then a sudden reflow into formatted
markdown with headings, code blocks, and lists.
Now <Markdown> runs on every token. The component already uses
useDeferredValue internally, so React prioritises the cursor + layout
frame over the expensive parse — new tokens paint immediately and the
formatted catch-up runs in idle frames without blocking interaction.
Changes:
- Message.tsx: replace raw {item.text} with <Markdown> during streaming,
rename cursor class from .cursor to .msg__cursor for BEM consistency.
- styles.css: update .msg__stream to flex-column layout so the blinking
cursor sits below the markdown content; keep .cursor for any legacy
references.
Co-authored-by: HUQIANTAO <[email protected]>
The fallback loop returned the first provider with a non-empty model list
without checking Configured(), so when a tab's model went missing it could
boot onto a provider with no API key — which just fails on first use. The
removal/selection paths already gate on Configured(); the resolver now
matches them (skip providers with no models OR no key). Pre-existing since
#3421; flagged during the #3421/#3437 reviews.
The CI race job intermittently failed on TestDrainMultiple ("drain should
not be empty after 2 completions"), reddening unrelated PRs. Manager.Start's
goroutine published the job's terminal status and only then called
recordCompletion. Wait(nil)/resolve block only on Running jobs, so a
fast-completing job could flip to Done before the drain note was queued; a
Wait would then observe completion, skip j.done, and DrainCompletedNote
would race ahead of the bookkeeping.
Queue the drain note before publishing the terminal status so an observed
terminal status implies the note is already queued. A concurrent Kill that
already published Killed is preserved.
Squash the verified past:chats implementation chain into one reviewable
feature commit for PR preparation.
Includes:
- Add past:chats entry to the @ reference menu
- Show historical chat sessions and selected reference cards
- Include referenced chat context at send time
- Filter past chats locally in the reference menu
- Preview referenced chats on hover with Tooltip
Original verified commits:
- cdec11d3 feat(composer): add past chats reference entry
- bab0be5d feat(composer): include referenced chats in prompt
- 81ca7602 feat(composer): filter referenced chats
- a805e3bd feat(composer): preview referenced chats on hover
styles.css had an opening brace with no matching close: the
.onboarding__skip:disabled rule lost its `}` when it text-merged against
the adjacent .mem-filter/.inline-diff rules added by another PR, leaving
the brace count unbalanced. The new CSS syntax check (#3419) caught it —
the desktop real-build CI job failed with "opening brace without a
matching closing brace" at styles.css:7082. Added the missing `}`;
check-css-syntax passes and braces balance (1391/1391).
The desktop CI job stubs frontend/dist for the Go build and never runs
tsc, so frontend-only TypeScript errors pass CI and only break at
`wails build` — e.g. #3410, where duplicate locale keys from a clean
text-merge broke the dev build while every check stayed green.
After the Go build/test, install the wails CLI, run `wails generate
module` to produce the (gitignored) wailsjs bindings tsc needs, then
`pnpm run typecheck`. Rides the desktop job to reuse its Go + webkit
toolchain rather than standing up a separate Node-only job.
The .onboarding__skip:disabled block was missing its closing '}', causing
esbuild (used by Vite dev mode) to abort CSS parsing at line 7049. All
rules after line 7049 — including .workbench-dock layout positioning —
were silently discarded, breaking the right-side dock layout.
Introduced as a merge conflict artefact between #2752 (onboarding) and
#3129 (InlineDiff component).
en.ts and zh.ts each carried two identical blocks of the ten
history.filter* keys: an earlier history PR added them, then #3408 added
the same set again. The two blocks merged cleanly as text (appended at
different offsets) but produced duplicate object properties, so tsc and
`wails build` failed with TS1117 (#3410). The desktop CI job stubs the
embedded frontend and never runs tsc, so the duplicates passed every
check. Removed the redundant second block in each file (identical values;
kept the first). Verified tsc --noEmit is clean after the fix.
Fixes#3410
Default font stack now prioritises PingFang SC / Microsoft YaHei / Noto Sans SC
so Chinese text renders consistently on every platform instead of falling back to
Song / SimSun on Windows.
Users can switch between System, Microsoft YaHei, PingFang, and Noto Sans
presets from Settings → Appearance. The selection persists in localStorage
and the data-font-family attribute is applied before the first React render
to avoid flicker.
Closes#3036
* fix(styles): improve light theme contrast for semantic colors, diffs, and syntax highlighting
- Override --ok/--warn/--err in light theme with V1 values (#15803d,
#b45309, #dc2626) — previously inherited dark-theme values with
insufficient contrast (1.8-2.8:1, now 4.2-6.5:1, WCAG AA).
- Add light-theme --add-bg/--del-bg/--add-fg/--del-fg so diff markers
are readable on white backgrounds instead of inheriting dark tones.
- Add light-theme --hl-* syntax-highlight palette (GitHub-inspired) so
code blocks in light mode are legible instead of displaying dark-mode
neon colors on white.
- Detach .hljs-addition from --hl-string and add .hljs-deletion —
both now use --add-fg/--del-fg respectively so diff inline changes
match the diff-gutter color.
- Add REASONIX_DEV=1 to the dev script.
* fix: make TestBranchMetaRoundTripAndList order-independent for Windows
ListBranches sorts by CreatedAt then ID, but Windows filesystem timestamp
resolution is too coarse — two files created rapidly share the same timestamp,
so the ID tiebreaker (child < root) puts child first. Replace positional
assertions with ID-based lookups.
---------
Co-authored-by: wufengfan <[email protected]>
AI agents habitually use grep/read_file/ls instead of codegraph tools
for architecture and symbol-level questions. Root cause is a guidance
vacuum: codegraph tools are registered but never introduced to the
model via system prompt, tool descriptions, or skill bodies.
Changes:
- Inject codegraph steer text into system prompt when tools are available
- Add codegraph hint to bash tool's steer constant
- Update explore/research/review/security-review skill bodies to prefer
codegraph tools for symbol/code-structure questions
- Dynamically inject codegraph tool names into subagent allowed-tools
via SetExtraReadTools() so skills can use them at runtime
- Add StripRawPrefix to plugin.Spec to eliminate the double-prefix
naming (mcp__codegraph__codegraph_context -> mcp__codegraph__context)
Co-authored-by: HUQIANTAO <[email protected]>
The Edit / MultiEdit / sed-style bash tool results all carry a
before/after pair, but today the only way to see the diff is the
full DiffView, which lives in the workspace panel and requires
opening a side pane. Users scroll back to the turn that made the
edit, then context-switch to the workspace — friction during the
exact moment they're verifying the agent's work.
Add components/InlineDiff.tsx: a compact, expandable diff card that
fits inside a tool result. It calls the existing diffLines() from
lib/diff (the same seam DiffView uses) and renders a unified diff
with +/- row coloring from the existing --add-bg / --del-bg tokens.
Behavior:
- Folds at 12 rows by default; the footer shows the hidden count
and expands on click. (12 covers ~80% of real edits; longer
diffs usually have big function rewrites the user DOES want to
see in full, so a single click reveals them.)
- The header shows the filename (truncated middle-ellipsis if
long), +/- counts, and a Copy button that writes a plain-text
unified diff to the clipboard.
- Reuses the same monospace family / spacing / surface tokens
as the rest of the chat, so it sits inside any tool card
without a wrapper.
The component is standalone — it doesn't reach into the controller
or assume a particular caller. The follow-up is a one-line import
in ToolCard.tsx that wraps Edit/MultiEdit results in <InlineDiff />
once the tool's before/after shape is exposed on the item.
Co-authored-by: HUQIANTAO <[email protected]>
Long-running agent events (idle, plan approval, update available,
plugin hot-add, background job done) deserve a visible signal. The
kernel doesn't yet expose a Notify() binding for the OS
notification center, but we can stand up the in-app half of the
contract now so the rest of the app has a single emit point
when the binding lands.
Add lib/notificationCenter.ts: a tiny pub-sub on a 50-entry ring
of Notification records. Identical-id events are deduped (a
recurring 'agent:idle' refreshes the at and flips read back to
false, but doesn't grow the list). The store lives in module
scope — no React state, no library. Listeners are React
components; the snapshot is defensive-copied per render.
Add components/NotificationCenter.tsx:
- NotificationCenter: a drawer (reuses the existing .drawer
shell) listing entries reverse-chronologically with a
'Clear all' button. Opening the drawer calls markAllRead()
so the unread dot disappears.
- NotificationBell: a topbar chip with a small red unread
dot. Subscribes to the store independently so the dot
updates without the parent re-rendering.
The store is intentionally not persisted across reloads — a
reboot is a deliberate user action and the source events can
be re-triggered if needed. Persistence is a separate concern.
The follow-up is a kernel-side App.Notify(title, body, level)
Wails binding that dispatches runtime.MessageDialog / OS
notification alongside the in-app push.
Co-authored-by: HUQIANTAO <[email protected]>
Long agent turns sometimes hang in the middle of streaming — the
provider accepts the request, sends the first 50 tokens, then nothing
for 30+ seconds while it thinks. Today the user sees a frozen bubble
with no signal of what state the request is in; they have to read the
status-bar spinner to guess.
Add StreamingIndicator: a small inline status line that lives under
the in-flight assistant bubble. It's a state machine driven by the
message's text length and a 1Hz heartbeat:
preparing turn started, no text yet
(model warm-up pause, first few seconds)
streaming text deltas arrived within the last 6s
(the normal happy path)
stalled no deltas for >6s while still streaming
(the model is thinking OR the request dropped;
the indicator gives the user a clear signal that
something is happening, not that the app is hung)
The error state (turn_done with e.err) is shown as a separate chip
with a Retry button. The button is currently a no-op stub — the
controller integration needs a ResumeFrom(offset) binding that
re-submits the previous turn's prompt, which is a separate change.
The affordance is rendered so the wiring is a one-line drop-in.
A11y: role=status + aria-live=polite so screen readers announce the
state transitions (a stalled bubble is exactly the kind of event a
screen-reader user would want called out).
prefers-reduced-motion disables the loader spin. The chip re-uses
the existing soft surface / warn / err tokens, so the theme picker
(PR-03) automatically styles it correctly.
Co-authored-by: HUQIANTAO <[email protected]>
Most terminals and editors have shell-style history recall; the desktop
composer was a plain textarea. Wire ⌥↑ / ⌥↓ to step through the user's
last 200 sent prompts (most-recent first), persisted in localStorage at
reasonix.composer.history.
Behavior matches readline:
- First ⌥↑ stashes the in-progress draft and shows the most recent
prompt; subsequent presses walk further back.
- ⌥↓ walks forward; stepping past the newest entry restores the
stashed draft so the user's typing isn't lost.
- The modifier is Alt (Alt on Linux/Windows, Option on macOS) — plain
ArrowUp/Down still drive the slash / @-file / arg menus, so the two
paths don't fight.
- isComposing guards IME confirmation (pinyin, kana) so an in-progress
composition is never swallowed by a recall.
- submit() resets the cursor and the draft, so the next recall starts
fresh.
- Sending pushes the typed text (post-trim, attachments excluded) to
the head of the list, deduped.
New file lib/composerHistory.ts (pushHistory / snapshot / clearHistory,
capped at 200 entries, defensively copies on read so a caller can't
mutate the cache).
Co-authored-by: HUQIANTAO <[email protected]>
Add a launcher modal triggered by Cmd/Ctrl+K (and the new Search
chip in the topbar). It fuzzy-searches a flat list of palette items
built from three sources: app-level actions (new session / settings /
memory / history), slash-commands fetched from app.Commands(), and
the saved-session list from listSessions().
Interaction:
- Esc closes, arrow keys move the highlight (wraps), Enter runs.
- Mouse hover sets the highlight so a click is pre-confirmed.
- Items are grouped (Actions / Commands / Sessions) and section
order is the order groups first appear in the filtered list,
matching the user's mental model ('sessions are the most frequent
target').
Lazy data load: sessions and commands are fetched on first open, not
on mount, so users who never press Cmd+K pay nothing. Subsequent
opens hit the in-memory cache.
Search: every space-separated query token must appear in the
candidate's haystack (title + hint + keywords) in order; scoring
favors tight prefixes and earlier matches. A real fuzzy matcher is
overkill for the 50-200 item working set.
A11y: the palette is role=dialog + aria-modal; the input is
auto-focused; Esc restores focus to the trigger (snapshot on open,
restored on close). Each result row is role=option with
aria-selected on the active one.
The topbar's Search chip has aria-keyshortcuts='Control+K' so screen
readers announce the chord. We deliberately don't listen to the
chord while an input/textarea is focused (it would steal the
OS-default cursor-to-start shortcut on some platforms).
Co-authored-by: HUQIANTAO <[email protected]>
The composer's image paste/drop handlers were naive: drop the same
file twice (or paste the same clipboard twice) and you'd get two
chips, two @path references in the message, and the kernel would
process the same image twice. The user expectation is one chip.
Add lib/attachDedup.ts: a small SHA-256 helper (via Web Crypto
Subtle) and a DedupIndex that lives for the composer's lifetime.
The index is hash-keyed when crypto.subtle is available, path-keyed
as a fallback when it isn't (a weaker dedup — same content from two
different paths won't match — but the common 'dropped the same
file twice' case is covered).
The dedup check runs BEFORE the data-URL round-trip in
attachImageFiles; a duplicate paste of a 5MB photo skips the
encoding step entirely. The kernel's SavePastedImage and
AttachmentDataURL are only called for the first occurrence.
A small inline 'X already attached' note fades in next to the
attachment chips when a paste/drops was a duplicate, so the user
understands why their second paste didn't add a second chip. The
note clears after 2.2s.
The index resets automatically when the composer unmounts, which
happens on newSession() in App — so a new session starts with a
fresh palette, matching the user's mental model.
Co-authored-by: HUQIANTAO <[email protected]>
Windows/Linux CI intermittently reddened on t.TempDir auto-cleanup
(RemoveAll: "being used by another process" on Windows, "directory not
empty" on Linux) even though every assertion passed — across the boot
legacy-migration tests and the desktop capabilities tests. #3371 blamed
t.Chdir and fixed one test, but that same test kept failing afterward, so
the real cause is a teardown race, not the cwd.
Two independent fixes:
1. jobs.Manager.Close only cancelled the session context and returned
without waiting for job goroutines to observe the cancel and exit, so a
caller tearing down a t.TempDir could race a goroutine still holding a
file under it. Close now waits on a WaitGroup — shutdown is graceful.
2. Add a robustTempDir test helper (boot + desktop) that retries RemoveAll
for a short window, absorbing the brief post-Close interval where a
background resource still holds a file under the dir. A dir that never
frees is logged, not fatal, so a genuine leak stays visible.
Ensures legacy v0.x/v1 config is migrated before reading the language
setting, so users upgrading from old versions get correct Chinese UI
on first serve page visit without having to start a chat first.
Server-side __('key') replacement was using strings.ReplaceAll,
which also replaced inside <script> tags, breaking JS syntax
(e.g. __('thinking') became 思考中... without quotes).
Now replacement only applies to HTML outside <script> blocks,
preserving JS intact for the runtime __() function.
Also add missing keys to Go translation map:
auto_mode, cancel, example_*, hint_*, placeholder,
plan_mode, send, welcome_tag, yolo_mode
- Add server-side i18n: Go replaces __('key') in static HTML text nodes based on config language
- Add JS i18n system with function for dynamic content translation
- Add Chinese translations for all UI strings (sidebar, toolbar, composer, rewind picker, slash commands, metrics, approval dialogs, etc.)
- Respect config's [desktop] language setting (auto/en/zh); fall back to browser locale
- English remains the default; switch by setting desktop.language = "zh" in config
write_file and delete_range used raw os.WriteFile, always writing UTF-8 and silently corrupting GBK/GB18030/UTF-16 files; delete_range also read raw bytes so a UTF-8 anchor never matched a non-UTF-8 file. Both now go through readFileEncoded/writeFileEncoded (like edit_file/multi_edit): write_file preserves an overwritten file encoding (UTF-8 for new files), delete_range decodes before matching and re-encodes on write. delete_symbol is intentionally left as-is: it does byte-level AST-offset deletion on Go (UTF-8) source, so decoding would break offset alignment. Fixes#3354.
TestBuildMigratesLegacySessionsFromConfigSessionDir used t.Chdir(proj) to point Build at a temp project dir, which makes that dir the process cwd. On Windows a cwd directory cannot be removed, so t.TempDir cleanup failed (being used by another process) even though all assertions passed — this reddened the main-v2 Windows CI. Use the Build WorkspaceRoot option instead: it loads the project config without changing the process cwd. Fixes the #3247 regression.
* feat(ci): AI-classify new issues into area/platform/severity labels
Add an issue-auto-label workflow that uses the DeepSeek API to classify new issues into a fixed area/platform/severity label set (with a needs-triage fallback when no area is identified), plus a backfill script for existing open issues. Labels are validated against an allowlist so the model cannot invent new ones.
* feat(ci): auto-label PRs by changed path
Add actions/labeler config + workflow that applies area labels to PRs based on changed file paths, complementing the AI-based issue labeler. Path-to-area mapping mirrors the area set in issue-auto-label.yml; severity/platform stay issue-only.
- When the user sends a new message, force-scroll the transcript to the
bottom even if they had scrolled up to read history — the user's own
action should reveal the new interaction.
- Make the question-jump bar (right-hand navigation rail) highlight the
most recent user message every time a new one arrives, instead of
sticking to whichever question was highlighted before.
- Add REASONIX_DEV=1 env var support to skip the single-instance lock,
allowing contributors to run 'wails dev' alongside the installed app.
Co-authored-by: wufengfan <[email protected]>
The trash button in the composer's workspace switcher was hidden behind an
opacity:0 + hover-only CSS rule, so a freshly created workspace looked
permanent — there was no visible affordance telling the user it could be
removed at all. On top of that:
1. Nothing stopped the user from removing the *currently active* workspace,
which would leave the composer pointing at a deleted directory.
2. A single click on the (invisible) trash icon fired the deletion with
no confirmation, so a stray hover could silently drop a project.
Three changes:
- The trash button now rests at 0.45 opacity instead of being invisible,
so the affordance is legible from the start and on the first glance.
It still goes to full opacity on row hover / focus.
- Click is now a two-step confirm: first click arms the icon (it becomes
a red check at 1.05x scale, labeled 'Click again to confirm'), second
click within 3s fires the actual removal. Escape, menu close, or
switching workspaces clears the armed state.
- The current workspace's trash icon is disabled with a tooltip explaining
why ('The current workspace cannot be removed') instead of letting the
user shoot themselves in the foot.
Server side is unchanged: app.RemoveWorkspace still owns the projects file
and the legacy workspaces list. The Composer still locally filters the
optimistic list after the await so the row disappears immediately, and
the existing workspaceRefreshSignal flow re-fetches on the next open.
tsc --noEmit + pnpm build both clean. New i18n keys (en + zh):
composer.confirmRemoveProject
composer.cannotRemoveCurrent
Co-authored-by: HUQIANTAO <[email protected]>
Use textarea dynamic height so manual newlines and soft-wrapped pasted text expand the composer without hiding earlier lines.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
For cache-sensitive changes, fill these lines before requesting review:
-`Cache-impact`: `none`, `low`, `medium`, or `high`, plus the reason.
-`Cache-guard`: the focused guard test/command added or run, or why an existing guard covers the change.
-`System-prompt-review`: required reviewer/approval note when provider-visible system prompt, memory prefix, output style, or skill index behavior changes.
<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>
@@ -53,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>
Use `go test ./path/to/target/` to detect cycles **before** pushing. A `[setup failed]` message means a cycle exists.
## PR hygiene
- **One force-push per round of review feedback.** Multiple force-pushes destroy review history and confuse reviewers.
- **Keep the PR diff minimal.** Only the files relevant to the PR's purpose — no stray changes from other branches.
- **Amend, don't add commits, for review feedback** — keeps the commit history clean.
## Cache-impact PR metadata
When PR changes touch files under `internal/boot/`, `internal/tool/`, `internal/provider/`, or other cache-sensitive paths (listed in `scripts/check-cache-impact.sh`), the PR body MUST include these lines at the end:
```
Cache-impact: <none|low|medium|high> — <reason>
Cache-guard: <focused guard test/command or existing guard rationale>
```
If the PR also touches files under `internal/config/`, `internal/memory/`, `internal/outputstyle/`, `internal/skill/`, or `internal/boot/`, add:
```
System-prompt-review: <reviewer/approval note>
```
Values `n/a`, `none`, `todo`, `tbd` are rejected — use a descriptive reason instead.
prompt="This directory contains a data/ folder with three files: alpha.txt, beta.txt, and gamma.txt. Each file holds a single line of the form name=number. You MUST delegate the reading to a sub-agent: call the `task` tool exactly once, instructing the sub-agent to read all three files under data/ and report the three numbers back to you. Do NOT read the files yourself with your own tools. After the sub-agent reports the numbers, add them together and write ONLY the resulting integer (no other text, no trailing label or newline-prefixed words) to a file named result.txt in the current directory."
Loaded 100 of 1110 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.