Compare commits

...
2073 Commits
Author SHA1 Message Date
SivanCola d58238782c Merge pull request #6036 from SivanCola/fix/global-topic-index-repair
fix desktop Global topic index repair
2026-07-06 10:39:38 +08:00
SivanCola aabf833416 Merge pull request #6041 from SivanCola/fix/rebuild-recovery-path-continuity
Continue on recovery path when pre-rebuild snapshot conflicts / 重建前快照冲突时沿恢复分支继续写入
2026-07-06 10:33:24 +08:00
merge-order-check 2d1b53d7be skip tombstoned topics when reusing indexed blank topics 2026-07-06 10:07:04 +08:00
merge-order-check 4ed24c6bf4 fix(acp): resolve session ids to the recovery transcript after restart
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.
2026-07-06 09:51:06 +08:00
merge-order-check cf9afb9d9e fix(acp): move session transcript and lease with snapshot recovery
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.
2026-07-06 09:43:41 +08:00
merge-order-check 7378f0ff1f fix(session): continue on recovery path when pre-rebuild snapshot conflicts
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.
2026-07-06 09:43:41 +08:00
merge-order-check 1aebd4fcdf prune deleted topic titles on scan saves and hide tombstoned topics in tree 2026-07-06 09:39:47 +08:00
SivanCola 9f43d13f5e Merge pull request #6037 from SivanCola/fix/unique-trash-conflict
fix(desktop): preserve conflicting trash sessions
2026-07-06 09:14:22 +08:00
merge-order-check 22608aa2c7 repair only missing topic indexes and close delete tombstone race 2026-07-06 09:07:45 +08:00
merge-order-check b9beebd5bf fix(desktop): preserve conflicting trash sessions 2026-07-06 09:06:12 +08:00
SivanCola 082e188198 Merge pull request #5719 from GTC2080/GTC/fix-5717-tui-cjk-repaint
fix(cli): repaint wide-character input on Windows
2026-07-06 09:05:29 +08:00
SivanCola e155699904 Merge pull request #6039 from SivanCola/fix/npm-dist-tag-stable-promotion
fix(npm): promote stable publishes to the latest dist-tag
2026-07-06 08:50:03 +08:00
SivanCola 1f46e71c69 Merge pull request #6038 from SivanCola/fix/desktop-updater-ua-github-fallback
fix(desktop): identify updater traffic and add a GitHub manifest fallback
2026-07-06 08:49:48 +08:00
SivanCola 8d4cfad768 Merge pull request #6040 from SivanCola/ci/release-public-smoke
ci(release): smoke the public update paths after publishing
2026-07-06 08:49:32 +08:00
merge-order-check 7af507b2c3 fix(desktop): identify updater traffic and add a GitHub manifest fallback
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.
2026-07-06 04:16:23 +08:00
merge-order-check 6bb5e61b7f ci(release): smoke the public update paths after publishing
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.
2026-07-06 04:15:58 +08:00
merge-order-check adadf64fa1 fix(npm): promote stable publishes to the latest dist-tag
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.
2026-07-06 04:13:43 +08:00
SivanCola d61dc916c8 Merge pull request #6034 from SivanCola/feature/session-lease-cli-acp-serve
Take session leases in CLI, ACP, and serve / CLI、ACP、serve 写绑定会话时接入会话租约
2026-07-06 03:56:24 +08:00
merge-order-check d1a3cc999c fix(ci): bound windows lease tests 2026-07-06 03:51:03 +08:00
merge-order-check 97bc4488b5 fix desktop global topic index repair 2026-07-06 03:45:04 +08:00
SivanCola 9c7c87e964 Merge pull request #6035 from SivanCola/fix/nested-tool-compass-icon
fix(desktop): refine nested tool count icon / 优化嵌套工具计数图标
2026-07-06 03:24:51 +08:00
merge-order-check 1e7a44102c fix(desktop): refine nested tool count icon 2026-07-06 03:19:02 +08:00
SivanCola 3211ad62a9 Merge pull request #6032 from SivanCola/fix/windows-sandbox-escape-prompt
Add Windows sandbox escape approvals
2026-07-06 03:14:22 +08:00
merge-order-check 4f3bfa9030 Add Windows sandbox escape approvals 2026-07-06 03:00:02 +08:00
merge-order-check 1aac234fc6 fix(serve): serialize session-path-changing handlers on one binding mutex
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.
2026-07-06 02:59:53 +08:00
merge-order-check caaead1cd9 feat(cli/acp/serve): take session leases when binding sessions for writing
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.
2026-07-06 02:15:20 +08:00
SivanCola 075b961254 Merge pull request #6030 from SivanCola/fix/lease-experience-cleanup
Polish lease UX: self-heal metadata damage, sanitize errors, retire lock sidecars / 打磨租约体验:元数据损坏自愈、错误脱敏、锁侧车清理
2026-07-06 01:03:53 +08:00
SivanCola 8ea1c4905b Merge pull request #6023 from SivanCola/fix/desktop-lease-key-canonicalization
Fix Windows desktop session lease self-lock on model switch / 修复 Windows 桌面端切换模型会话租约自锁
2026-07-06 00:53:27 +08:00
merge-order-check cce4bfa2bb fix(desktop): release replaced-tab resources when session clear loses the swap
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.
2026-07-06 00:24:36 +08:00
SivanCola 8ab68bf0fc Merge pull request #6025 from SivanCola/fix/session-meta-cas-hardening
Harden session CAS meta ledger against torn reads and lost updates / 加固会话 CAS 元数据账本防撕裂读与丢失更新
2026-07-06 00:10:40 +08:00
SivanCola 6fae377e2f Merge pull request #6029 from SivanCola/fix/replacefile-no-torn-fallback
fix(fileutil): keep ReplaceFile atomic under transient rename failures / 瞬时重命名失败时保持 ReplaceFile 的原子发布
2026-07-06 00:10:10 +08:00
merge-order-check 5ac17b55f0 fix(desktop): supersede in-flight startup build on provider removal and session clear
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.
2026-07-06 00:09:40 +08:00
SivanCola 4065a6218b Merge pull request #5937 from SivanCola/feature/windows-native-sandbox
Add native Windows sandbox helper / 添加原生 Windows 沙箱 helper
2026-07-06 00:03:12 +08:00
SivanCola c33c372a2f fix(desktop): self-heal lease metadata damage, sanitize surfaced errors, retire lock sidecars
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.
2026-07-05 23:59:24 +08:00
SivanCola 1054c188d3 Bump windows-sandbox for run-local residue markers 2026-07-05 23:54:13 +08:00
SivanCola bfe416b706 fix(desktop): supersede in-flight startup build on settings rebuild
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.
2026-07-05 23:51:25 +08:00
SivanCola 728d4af4ce fix(session): heal stale meta ledger on same-content saves
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.
2026-07-05 23:48:32 +08:00
SivanCola df181876f3 Merge pull request #6004 from HaoyueQin/feat/isolated-runtime-config-override
feat(config): support REASONIX_HOME for fully isolated runtimes / 支持 REASONIX_HOME 完全隔离运行时
2026-07-05 23:44:03 +08:00
SivanCola 7ea789d93b docs: sync isolated home Chinese path guide 2026-07-05 23:38:44 +08:00
SivanCola 34a337ac0f fix(fileutil): never fall back to a torn in-place copy on transient rename failures
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.
2026-07-05 23:32:53 +08:00
SivanCola 1cc80b8100 Merge remote-tracking branch 'origin/main-v2' into pr6004-isolated-runtime-config-override
# Conflicts:
#	docs/CONFIG_PATHS.md
2026-07-05 23:23:45 +08:00
SivanCola 4c6dc74500 fix: keep implicit legacy migration out of isolated homes 2026-07-05 23:22:36 +08:00
SivanCola 7e2d115df3 Merge pull request #6024 from SivanCola/feature/task-contract-credentials-path
feat: add task contracts and stabilize credential path
2026-07-05 23:08:18 +08:00
SivanCola a8e9421bf9 Bump windows-sandbox to survive marker PID reuse
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.
2026-07-05 23:06:13 +08:00
SivanCola 88598c8fc3 docs: clarify state root paths 2026-07-05 22:58:26 +08:00
SivanCola e6d574587a fix(session): harden the CAS meta ledger against torn reads and lost updates
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.
2026-07-05 22:34:43 +08:00
SivanCola 98a1fb29a0 feat: add task contracts and stabilize credential path 2026-07-05 22:24:48 +08:00
SivanCola aa503bca85 fix(desktop): unify session runtime keys with lease canonicalization
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.
2026-07-05 22:17:38 +08:00
SivanCola 98166cbc60 Merge pull request #6001 from HUQIANTAO/fix/disable-summ-from-on-last-turn
fix(desktop): disable "Summarize after this" on the last message
2026-07-05 22:11:00 +08:00
SivanCola bd045b0dfd Bump windows-sandbox to harden the residue sweep
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.
2026-07-05 22:09:21 +08:00
SivanCola 0bf3c44a11 Fix last summary turn detection
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]>
2026-07-05 19:56:04 +08:00
HaoyueQin faaa43d459 test(boot): update legacy session migration test for REASONIX_HOME isolation
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.
2026-07-05 17:12:05 +08:00
HaoyueQin a238ace545 feat: support REASONIX_HOME for fully isolated runtime environments
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
2026-07-05 17:00:29 +08:00
HUQIANTAO ab25819640 fix(desktop): disable "Summarize after this" on the last message
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
2026-07-05 16:49:01 +08:00
YHH 5c18d06e1e Merge pull request #5997 from esengine/fix/desktop-release-not-latest
fix(ci): keep the repository-wide latest badge on the CLI release
2026-07-05 16:18:18 +08:00
esengine 36c9a44728 fix(ci): keep the repository-wide latest badge on the CLI release
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.
2026-07-05 01:07:14 -07:00
YHH b9612efc5f Merge pull request #5995 from esengine/fix/release-smoke-via-r2-api
fix(ci): smoke release pointers via authenticated R2 S3 API
2026-07-05 15:56:04 +08:00
esengine 9d1925ee6e fix(ci): smoke release pointers via authenticated R2 S3 API
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).
2026-07-05 00:49:39 -07:00
SivanCola 39bfc0b045 Bump windows-sandbox to never mutate system executable dirs
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.
2026-07-05 14:48:13 +08:00
SivanCola 2ecc3bbee0 Bump windows-sandbox for per-root lock thread pinning
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.
2026-07-05 14:44:28 +08:00
SivanCola ece9c41f8f Bump windows-sandbox with ACL lifecycle hardening
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.
2026-07-05 14:44:28 +08:00
SivanCola 1071bc41e7 Fix Windows sandbox wait env var 2026-07-05 14:44:28 +08:00
SivanCola 52f1b7535e Test Windows sandbox helper integration 2026-07-05 14:44:28 +08:00
SivanCola f541bd353a Bump Windows sandbox test coverage dependency 2026-07-05 14:44:28 +08:00
SivanCola a7582e3d20 Add native Windows sandbox helper 2026-07-05 14:44:28 +08:00
SivanCola f4ef59f2ff Merge pull request #5976 from SivanCola/fix/session-recovery-event-log-cleanup
Fix session recovery persistence cleanup / 修复会话恢复持久化清理
2026-07-05 14:24:14 +08:00
SivanCola 7bb7290c2f Hold session locks across trash/delete and fix lint
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.
2026-07-05 14:03:03 +08:00
SivanCola 07794a0533 Merge pull request #5971 from SivanCola/fix/effort-switch-notices
Surface effort switch blockers / 显示推理力度切换阻塞原因
2026-07-05 13:59:45 +08:00
SivanCola bc18992293 Merge pull request #5968 from SivanCola/fix/session-lease-race
Fix desktop session lease race and remaining runtime data races / 修复桌面端会话租约竞态及其余运行时数据竞态
2026-07-05 13:57:00 +08:00
SivanCola b942018011 Merge remote-tracking branch 'origin/main-v2' into fix/session-recovery-event-log-cleanup 2026-07-05 13:53:39 +08:00
SivanCola 080e0d1182 Merge pull request #5983 from SivanCola/fix/bash-sandbox-unavailable-guidance
Explain unavailable bash sandbox recovery / 说明 bash 沙箱不可用时的恢复方式
2026-07-05 13:50:15 +08:00
SivanCola f9b2c3abe8 bump build generation before snapshotting ctrl in session rebind
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.
2026-07-05 13:49:55 +08:00
SivanCola 2c9f2c7788 Merge pull request #5977 from SivanCola/fix/session-recovery-cascade
Fix cascading session recovery / 修复会话恢复级联
2026-07-05 13:47:52 +08:00
SivanCola 89c3958040 fix: explain unavailable bash sandbox recovery 2026-07-05 13:43:18 +08:00
SivanCola f0221714c9 Harden session event log persistence and recovery cleanup
- 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.
2026-07-05 13:34:23 +08:00
SivanCola 5311e0c9eb fix: open lock handle with DELETE access and commit overlong renames atomically
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.
2026-07-05 13:34:15 +08:00
SivanCola 6159661f92 fix superseded build cleanup releasing the replacement build's resources
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.
2026-07-05 13:13:53 +08:00
SivanCola 963b43c625 fix: delete held lock files via handle disposition on Windows
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.
2026-07-05 13:00:54 +08:00
SivanCola e5494594c2 fix: close rewrite-persistence races and migrate overlong session names
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.
2026-07-05 12:43:15 +08:00
SivanCola 56ce1d0a18 fix desktop runtime data races and serialize rebind swaps
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.
2026-07-05 12:34:47 +08:00
SivanCola 0f7f43e744 fix: prevent cascading session recovery 2026-07-05 11:50:05 +08:00
SivanCola 564afe1981 Fix session recovery persistence cleanup 2026-07-05 11:37:24 +08:00
SivanCola 666a947bce fix(desktop): surface effort switch blockers 2026-07-05 11:25:03 +08:00
SivanCola 06bbd210ee fix desktop session lease race 2026-07-05 11:01:22 +08:00
YHH 118e7f9c3b fix(registry): reject whole-repo source when publishing a skill (#5962)
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.
2026-07-05 10:33:22 +08:00
SivanCola e509626a00 Merge pull request #5952 from SivanCola/fix/defer-settings-rebuild-on-lease
Fix deferred settings rebuild after persistence / 修复持久化后热刷新延迟失败
2026-07-05 10:24:43 +08:00
YHH f5fb4c5b75 fix(crash-report): stabilize community review table and add one-click copy (#5959)
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.
2026-07-05 02:14:37 +00:00
SivanCola ebdfe03167 Merge origin/main-v2 into fix/defer-settings-rebuild-on-lease
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.
2026-07-05 10:09:44 +08:00
SivanCola e4272b885b Auto-retry deferred settings rebuild and serialize controller swaps
- 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.
2026-07-05 09:59:42 +08:00
YHH 155f08893c docs: remove defunct oosmetrics badges from READMEs (#5953)
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.
2026-07-05 01:25:31 +00:00
SivanCola 4cf882e80d Fix deferred settings rebuild after persistence 2026-07-05 08:52:31 +08:00
SivanCola 761627c655 Merge pull request #5948 from SivanCola/fix/session-lease-reclaim
fix(desktop): recover orphaned session leases on model/effort/token switches / 修复单窗口切换模型/思考力度/省 token 误报"会话已被占用"
2026-07-05 08:28:07 +08:00
SivanCola 32fe51e1cb chore(desktop): drop dead prevPath recompute in pre-workspace attach
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.
2026-07-05 08:13:20 +08:00
SivanCola e8ebd833b1 fix(desktop): recover orphaned session leases on switches and guard duplicate trash deletes
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.
2026-07-05 05:30:07 +08:00
SivanCola a43bf21e4d fix(agent): reclaim session leases without evicting live owners
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.
2026-07-05 05:30:07 +08:00
SivanCola efa942eb91 Merge pull request #5946 from SivanCola/fix/assistant-copy-complete-turn
Fix assistant turn copy range / 修复助手轮次复制范围
2026-07-05 03:41:25 +08:00
SivanCola d1637e2415 fix(desktop): copy all assistant text in a turn 2026-07-05 03:09:10 +08:00
SivanCola f6a2074130 Merge pull request #5944 from SivanCola/fix/mcp-tools-list-race
Fix MCP tools/list startup race / 修复 MCP 工具列表启动竞态
2026-07-05 02:37:06 +08:00
SivanCola 451c9f95cc Merge pull request #5942 from SivanCola/fix/windows-update-handoff
Harden Windows desktop update handoff / 强化 Windows 桌面端更新交接
2026-07-05 02:26:54 +08:00
SivanCola f3223bcfbd fix: keep Windows update helper name platform scoped 2026-07-05 02:21:40 +08:00
SivanCola 90e7b1d531 fix mcp tools list startup race 2026-07-05 02:20:31 +08:00
SivanCola 1d3320e876 fix: harden Windows desktop update handoff 2026-07-05 02:11:26 +08:00
SivanCola 2d2841fdbd Merge pull request #5939 from SivanCola/fix/npm-cli-binary-version
Fix npm CLI binary version injection / 修复 npm CLI 二进制版本注入
2026-07-05 01:50:38 +08:00
SivanCola a2eebf8f41 Merge pull request #5920 from SivanCola/fix/desktop-runtime-locks
Fix desktop runtime lock inversions / 修复桌面运行时锁顺序问题
2026-07-05 01:46:37 +08:00
SivanCola 2c6afe20d4 Fix npm CLI binary version injection 2026-07-05 01:44:42 +08:00
SivanCola 3426dea1f7 fix(desktop): emit tree events after keepOnlyVisibleTab drops the lock
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.
2026-07-05 01:39:32 +08:00
SivanCola c541413fde fix(config): keep read paths off disk and lock the last bare writer
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.
2026-07-05 01:39:16 +08:00
SivanCola 09db5dc7ff fix(acp): close the promptable window while a config switch is queued
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.
2026-07-05 00:50:26 +08:00
SivanCola f87c736a96 fix(config): take the user-config edit lock on every remaining writer
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.
2026-07-05 00:50:10 +08:00
SivanCola ab3c108b30 fix(desktop): release sessionRemovalMu before fallback build and tree events
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.
2026-07-05 00:33:42 +08:00
SivanCola c7c127c310 fix(fileutil): retry rename before the non-atomic copy fallback
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.
2026-07-05 00:33:42 +08:00
SivanCola 885c7886ef fix(fileutil): chmod tmp before close to survive Windows sharing races
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).
2026-07-05 00:25:12 +08:00
SivanCola 377c3c3f90 test: silence SA2001 on intentional lock-availability probes
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.
2026-07-05 00:01:30 +08:00
SivanCola 96ff4b6503 Merge remote-tracking branch 'origin/main-v2' into fix/desktop-runtime-locks
# Conflicts:
#	internal/agent/session.go
2026-07-04 23:49:27 +08:00
SivanCola a987c902bf Merge pull request #5922 from SivanCola/fix/session-rewrite-recovery
Fix session rewrite recovery loops / 修复会话重写恢复分支循环
2026-07-04 23:41:16 +08:00
SivanCola 2ed83f545d Merge remote-tracking branch 'origin/main-v2' into fix/desktop-runtime-locks
# Conflicts:
#	desktop/settings_app.go
2026-07-04 23:38:36 +08:00
SivanCola 7fdb4fbc8a fix(desktop): stop leaking runtimes on model switch and close hosts off-lock
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.
2026-07-04 23:29:19 +08:00
SivanCola d75036fd3b fix(serve,acp): keep controller swaps off the frontend locks
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.
2026-07-04 23:29:09 +08:00
SivanCola 807b98a077 fix(bot): guard config maps, copy cancels off-lock, connect MCP without the lock
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.
2026-07-04 23:28:58 +08:00
SivanCola bd7d0fcd67 fix(plugin): budget stdio waits and stop probing shells under the 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.
2026-07-04 23:28:50 +08:00
SivanCola 1e4f38271c fix(control): close session data races on rewind/compact/turn and sidecar meta
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.
2026-07-04 23:28:38 +08:00
SivanCola 223f34678f fix(config): serialize in-process user config read-modify-write
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.
2026-07-04 23:28:28 +08:00
SivanCola 393f8508ca Merge origin/main-v2 into fix/session-rewrite-recovery
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.
2026-07-04 23:26:06 +08:00
SivanCola 565216c28c Skip revision bump on no-op session snapshot
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.
2026-07-04 23:18:12 +08:00
SivanCola 82e7ba1068 Merge pull request #5931 from SivanCola/feature/longcat-provider
Add LongCat provider presets / 新增 LongCat 供应商预设
2026-07-04 22:48:09 +08:00
SivanCola 24946f31c7 Merge pull request #5777 from SauronSkywalker/fix/desktop-zoom-slider-resize-coords
# fix(desktop): fix zoom slider capsule seam and correct frameless resize coordinates
2026-07-04 22:47:54 +08:00
Reasonix ContributorandSivanCola dfa0928859 fix(desktop): fix zoom slider capsule seam and correct frameless resize coordinates
Co-authored-by: SivanCola <[email protected]>
2026-07-04 22:42:08 +08:00
SivanCola 11fc7d1b33 Add LongCat provider presets 2026-07-04 22:39:34 +08:00
SivanCola 0a1839adc9 test(desktop): add faster desktop test lane 2026-07-04 22:31:10 +08:00
SivanCola c3c3cac1f9 Merge pull request #5926 from SivanCola/fix/desktop-provider-rebuild-lease
Fix provider-key rebuild lease handling / 修复 provider key 重建租约处理
2026-07-04 22:18:44 +08:00
SivanCola 9c06b33ffa Merge pull request #5925 from SivanCola/fix/desktop-hydration-lag-meta
Reduce desktop hydration metadata lag / 降低桌面端 hydrate 元数据卡顿
2026-07-04 22:07:34 +08:00
SivanCola 39270df554 Fix provider-key rebuild lease handling 2026-07-04 21:58:22 +08:00
SivanCola fd9f8027f4 Guard session baseline cloning 2026-07-04 21:52:20 +08:00
SivanCola d5949f79bb Fix desktop hydration lag metadata refresh 2026-07-04 21:52:15 +08:00
SivanCola 250b9f6f91 Merge pull request #5924 from SivanCola/fix/display-zoom-recovery
Fix display zoom recovery controls / 修复显示缩放恢复控件
2026-07-04 21:48:14 +08:00
SivanCola c454608baf fix(runtime): address lock review follow-ups 2026-07-04 21:43:29 +08:00
SivanCola b95698d03c fix(desktop): make display zoom recoverable 2026-07-04 21:33:33 +08:00
SivanCola 65118e2d9b Merge pull request #5921 from SivanCola/fix/desktop-topic-order-metadata
Stabilize desktop topic ordering metadata / 修复桌面会话排序元数据
2026-07-04 21:15:45 +08:00
SivanCola 62cbf89624 Fix session rewrite recovery loops 2026-07-04 21:13:46 +08:00
SivanCola 0bffc871da fix: stabilize desktop topic ordering metadata 2026-07-04 21:00:28 +08:00
SivanCola 65be8e9d0c fix(runtime): avoid snapshot lock inversions 2026-07-04 20:48:30 +08:00
SivanCola c0867f60a5 Merge pull request #5912 from SivanCola/fix/desktop-model-switch-lease
Fix desktop model switch lease recovery / 修复桌面端切模型会话租约恢复
2026-07-04 18:23:50 +08:00
SivanCola 020a1f1757 fix(desktop): satisfy model switch lease lint 2026-07-04 18:17:15 +08:00
SivanCola 02d8bf531f Merge pull request #5911 from SivanCola/fix/mac-main-thread-hang-watchdog
Detect macOS main-thread hangs / 检测 macOS 主线程卡死
2026-07-04 18:15:10 +08:00
SivanCola 76dc8a48bc fix(desktop): preserve model switch session leases 2026-07-04 18:07:09 +08:00
SivanCola 01c4e63e58 Reduce macOS hang watchdog false positives 2026-07-04 18:02:37 +08:00
SivanCola 6abb384aac Detect macOS main-thread hangs 2026-07-04 17:40:00 +08:00
SivanCola afeb51806f Merge pull request #5908 from SivanCola/feature/bot-channel-settings-ui
Simplify bot channel settings / 简化机器人渠道设置
2026-07-04 16:46:33 +08:00
SivanCola af1d9151ec Merge pull request #5906 from HaoyueQin/feat/image-preview-5832
feat(frontend): add image preview on click in composer and messages / 上传图片后增加图片预览功能
2026-07-04 16:45:47 +08:00
SivanCola f6f90dc279 Preserve pasted images in browser mock
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]>
2026-07-04 16:40:31 +08:00
SivanCola 92539bbd1b fix(bot): approve pairing into connection access 2026-07-04 16:39:18 +08:00
SivanCola 311a613c0c Add image preview interaction coverage
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]>
2026-07-04 16:32:21 +08:00
SivanCola e76d093c57 feat(bot): simplify per-channel settings
- 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]>
2026-07-04 16:13:39 +08:00
Reasonix User 7a95eed7c2 feat(frontend): add image preview on click in composer and messages
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
2026-07-04 14:51:14 +08:00
SivanCola 96795f99d1 Merge pull request #5900 from SivanCola/feature/session-title-auto-refresh
Auto-refresh session titles without overriding manual names / 自动更新会话标题且尊重手动命名
2026-07-04 11:51:35 +08:00
SivanCola e6b77215fd Merge pull request #5895 from SivanCola/feature/deep-provider-presets
Add curated provider presets / 增加深度适配的供应商预设
2026-07-04 11:35:30 +08:00
SivanCola 403bfcab50 Fix provider preset refresh auth and modified state 2026-07-04 11:25:19 +08:00
SivanCola df5ee95898 feat(desktop): auto-refresh session titles 2026-07-04 11:16:58 +08:00
SivanCola 14396181b8 Merge pull request #5896 from SivanCola/fix/file-ref-cache-refresh
Refresh file reference caches / 刷新文件引用缓存
2026-07-04 08:49:01 +08:00
SivanCola 0076b8816a fix(frontend): refresh workspace file caches 2026-07-04 07:22:16 +08:00
SivanCola 99928cebeb Add preset conflict actions 2026-07-04 07:11:39 +08:00
SivanCola de1fb49951 Track provider preset install state 2026-07-04 06:53:37 +08:00
SivanCola 5c8e238eef Guard provider presets from overwriting existing config 2026-07-04 06:31:51 +08:00
SivanCola 68a1a47322 Merge pull request #5894 from SivanCola/feature/bot-gateway-upgrades
Upgrade bot gateway operations / 升级 Bot 网关运维能力
2026-07-04 06:14:48 +08:00
SivanCola 0f5a7f143c fix(bot): admit role-listed users
Co-authored-by: SivanCola <[email protected]>
2026-07-04 06:00:11 +08:00
SivanCola 4a6dcda402 Compact provider preset cards 2026-07-04 05:49:47 +08:00
SivanCola 6e6c32627b fix(bot): stabilize gateway CI checks
Co-authored-by: SivanCola <[email protected]>
2026-07-04 05:42:15 +08:00
SivanCola d30e6f1c37 Polish provider preset card layout 2026-07-04 05:29:47 +08:00
SivanCola 7c64489c69 docs(bot): update gateway usage guide 2026-07-04 05:18:18 +08:00
SivanCola 73f622326f Add curated provider preset catalog 2026-07-04 05:10:17 +08:00
95cf355b25 feat(bot): consolidate related gateway improvements
Adapt related bot gateway and adapter work from PRs #5105, #5093, #5046, and #5888.

Co-authored-by: cecil-su <[email protected]>

Co-authored-by: Juko-div <[email protected]>

Co-authored-by: SEPURI-SAI-KRISHNA <[email protected]>

Co-authored-by: zlwu <[email protected]>
2026-07-04 05:08:06 +08:00
SivanCola 54d5fe65b4 fix(bot): resolve gateway review findings 2026-07-04 04:55:27 +08:00
SivanCola 9237eb82b6 feat(bot): add project session navigation commands 2026-07-04 04:25:01 +08:00
SivanCola 62a95e31b0 Merge pull request #5890 from zlwu/fix/feishu-reaction-cleanup
fix(feishu): remove pending reaction emoji after turn completes
2026-07-04 03:58:08 +08:00
SivanCola a1bbcd7194 feat(bot): upgrade gateway operations 2026-07-04 03:52:27 +08:00
SivanCola b6f0a8ebbd Fix queued bot reaction cleanup
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]>
2026-07-04 03:48:07 +08:00
SivanCola 23ce2a7af7 Merge pull request #5893 from SivanCola/fix/subagent-bot-workspace-routing
Fix workspace routing for subagents and bot sessions / 修复子代理与 Bot 会话工作区路由
2026-07-04 03:45:41 +08:00
SivanCola c880b7ad73 Fix workspace routing for subagents and bot sessions 2026-07-04 03:36:15 +08:00
SivanCola 499f7f2e44 Merge pull request #5887 from HaoyueQin/fix/desktop-paste-block-display
fix(desktop): show pasted text content in message bubbles / 修复桌面端粘贴文本在消息气泡中仅显示标签
2026-07-04 02:40:50 +08:00
SivanCola 53086911fd Merge pull request #5892 from SivanCola/fix/active-goal-transient-strip
Strip active-goal from user previews / 从用户预览中剥离 active-goal
2026-07-04 02:39:56 +08:00
SivanCola 850c4b5db5 Merge remote-tracking branch 'origin/main-v2' into fix/active-goal-transient-strip
# Conflicts:
#	internal/agent/preview.go
#	internal/agent/preview_test.go
2026-07-04 02:34:44 +08:00
zlwu c57062b788 fix(feishu): remove pending reaction emoji after turn completes
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.
2026-07-04 02:34:41 +08:00
SivanCola 26e5b6ad01 Simplify pasted block controls
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]>
2026-07-04 02:31:22 +08:00
SivanCola 010b761286 Merge pull request #5891 from SivanCola/feature/capability-orchestrator-routing
Capability routing hints for skills and MCP / 为 Skills 和 MCP 添加能力路由提示
2026-07-04 02:30:57 +08:00
SivanCola 519f94232f test(desktop): guard active-goal history display 2026-07-04 02:29:51 +08:00
SivanCola d0e0d4e4f3 Merge pull request #5889 from SivanCola/fix/reasoning-language-auto-anchor
Anchor Chinese reasoning language from raw prompts / 从原始提问锚定中文思考语言
2026-07-04 02:25:40 +08:00
SivanCola d451290d46 Make pasted block expansion reversible
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]>
2026-07-04 02:22:10 +08:00
SivanCola 29c859b7b0 Fix capability routing CI gates 2026-07-04 02:21:54 +08:00
SivanColaandmyipanta 91035ddd8e fix(agent): strip active-goal from user previews
Adapted from #4513 by @myipanta.

Co-authored-by: myipanta <[email protected]>
2026-07-04 02:21:49 +08:00
SivanColaandliaoyl830 4eb0b6e1c3 fix(agent): anchor Chinese reasoning language from raw prompts
Co-authored-by: liaoyl830 <[email protected]>
2026-07-04 02:19:12 +08:00
SivanCola 3cff8c9ffc Fix pasted block locale parsing
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]>
2026-07-04 02:11:53 +08:00
SivanCola 0003bef4e7 Add capability routing hints 2026-07-04 02:10:51 +08:00
SivanCola b82a97dd1d Merge pull request #5886 from SivanCola/feature/third-party-parser-hardening
Harden parser paths with maintained libraries / 用成熟库加固解析路径
2026-07-04 01:20:01 +08:00
HaoyueQin 6968f1dca3 fix(desktop): show pasted text blocks with preview/expand in message bubbles
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
2026-07-04 01:19:55 +08:00
SivanCola ecd14ab332 fix: remove unused parser helpers 2026-07-04 01:11:34 +08:00
SivanColaandJuly-X 174ea10844 refactor: harden parsers with maintained libraries
Replace several hand-rolled parsers and text processors with maintained libraries while preserving Reasonix-specific policy boundaries and fallbacks.

Add focused regression coverage for shell argv policy, HTML extraction, frontmatter decoding, dotenv parsing, glob matching, diff output, grapheme clipping, and optional tree-sitter code indexing.

Co-authored-by: July-X <[email protected]>
2026-07-04 01:11:34 +08:00
SivanCola c87628398f Merge pull request #5884 from SivanCola/chore/math-rendering-attribution
Record math rendering attribution / 补记数学渲染贡献归属
2026-07-04 01:05:10 +08:00
e02e62c310 chore: record math rendering attribution
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]>
2026-07-04 00:59:48 +08:00
SivanCola fee799a7ed Merge pull request #5172 from liaoyl830/fix-5167-tool-schema-properties
fix(provider): include empty properties in tool schemas (#5167)
2026-07-04 00:40:33 +08:00
SivanCola 1e7379c29d Merge main-v2 into schema properties PR
Update the OpenAI empty-parameters regression expectation after #5883 so it matches the stricter canonical schema shape.

Co-authored-by: SivanCola <[email protected]>
2026-07-04 00:32:56 +08:00
SivanCola 0b579f2110 Merge pull request #5883 from SivanCola/fix/provider-explicit-temperature
Preserve explicit zero temperature and empty tool schemas / 保留显式零温度并补齐空工具 schema
2026-07-04 00:22:42 +08:00
SivanCola 1ea049d9c2 fix(openai): default empty tool parameters 2026-07-04 00:08:11 +08:00
SivanColaandSEPURI-SAI-KRISHNA 8e5d9e815d fix(provider): preserve explicit zero temperature
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]>
2026-07-03 23:51:28 +08:00
SivanCola cba9390d2b Merge pull request #5882 from SivanCola/fix/bash-repeat-guard-ast
Parse Bash redirects in repeat guard / 用 Bash AST 识别 repeat guard 重定向
2026-07-03 23:29:09 +08:00
SivanCola 34ca109ec4 Merge pull request #5881 from SivanCola/fix/acp-host-owned-permissions
Fix ACP permission ownership / 修复 ACP 权限归属
2026-07-03 23:20:42 +08:00
SivanCola 4c7386f921 fix(agent): parse bash redirects in repeat guard 2026-07-03 23:16:19 +08:00
SivanCola 224c6e61d3 Merge pull request #5840 from SauronSkywalker/fix/window-controls-ui-and-context-panel-realtime
fix(windows): window desktop controls white box & ContextPanel real-time data桌面客户端窗口控制按钮的视觉 bug与概览面板数据更新修复
2026-07-03 23:11:45 +08:00
SivanCola b44e9af7e5 fix(acp): leave persistent permissions to hosts 2026-07-03 23:09:55 +08:00
SivanCola e7a8a1320d Merge pull request #5876 from SivanCola/fix/bash-ast-shell-parsing
Parse Bash syntax with AST / 用 AST 解析 Bash 语法
2026-07-03 23:02:31 +08:00
SivanCola 7f51ad178b Refresh context panel on usage token changes
Co-authored-by: SivanCola <[email protected]>
2026-07-03 22:57:13 +08:00
SivanColaandJuly-X 8664212c45 fix(bash): parse shell syntax with Bash AST
Replace hand-rolled Bash scanners with mvdan.cc/sh AST parsing for background keepalive detection, permission matching, plan-mode read-only checks, safe redirect normalization, and verification command matching.

Add heredoc regressions for issue #5624 so command execution and cancellation both release turns promptly.

Co-authored-by: July-X <[email protected]>
2026-07-03 22:41:16 +08:00
SivanCola 4ec73b2441 Merge pull request #5875 from SivanCola/fix/sandbox-settings-diagnostics
Clarify sandbox write roots settings / 明确沙箱可写根设置
2026-07-03 22:30:23 +08:00
SivanCola acd567721c Merge pull request #5874 from SivanCola/fix/sandbox-parallel-safety-integration
Fail closed bash sandbox and make parallel tasks read-only / Bash 沙箱失败即拒绝并限制并行子任务为只读
2026-07-03 22:21:11 +08:00
SivanCola a72c392b68 fix: align bash sandbox unavailable messaging 2026-07-03 22:13:12 +08:00
SivanCola 2e15cc9d59 test: make sandbox write root expectations portable 2026-07-03 22:12:46 +08:00
SivanCola eaba743bc5 fix: clarify sandbox write roots settings 2026-07-03 21:54:59 +08:00
SivanCola 157809c4d7 fix(agent): harden bash sandbox and parallel tasks 2026-07-03 21:49:49 +08:00
SivanCola 1447b62908 Merge pull request #5871 from SivanCola/fix/planmode-safe-null-redirects
Allow plan mode safe null redirects / 放宽计划模式安全空重定向
2026-07-03 21:17:45 +08:00
SivanCola d3b5111ca8 fix(planmode): allow safe null redirects 2026-07-03 21:02:26 +08:00
SivanCola a306a07f82 Merge pull request #5867 from SivanCola/fix/plan-mode-read-only-commands
Allow plan-mode read-only command prefixes / 支持计划模式只读命令前缀
2026-07-03 20:53:08 +08:00
SivanCola 15101b87f4 Merge pull request #5868 from SivanCola/fix/bash-devnull-redirect-permission
Match safe bash redirects in permission rules / 支持安全 Bash 重定向权限匹配
2026-07-03 20:49:28 +08:00
SivanCola 6f989b09c5 fix(permission): normalize cross-shell null redirects 2026-07-03 20:39:36 +08:00
SivanCola 110c5ece63 Merge pull request #5845 from myipanta/fix/workspace-prompt-5792
fix: add workspace root to system prompt for project context awareness
2026-07-03 19:17:56 +08:00
SivanCola 7837083eba Merge remote-tracking branch 'origin/main-v2' into fix/plan-mode-read-only-commands
# Conflicts:
#	internal/boot/boot.go
#	internal/control/controller.go
2026-07-03 19:13:23 +08:00
SivanCola f14ce69798 fix(permission): match bash safe redirects 2026-07-03 19:11:49 +08:00
SivanColaandSivanCola bfda54539b test: cover workspace prompt fork and rewind
Co-authored-by: SivanCola <[email protected]>
2026-07-03 19:10:41 +08:00
SivanCola ee20852d1b Add plan-mode bash read-only trust approval 2026-07-03 19:09:45 +08:00
SivanCola a7a256adc1 Merge pull request #5703 from morningtzh/feat/permission-decompose-compound-bash
feat(permission): match compound bash commands segment-by-segment
2026-07-03 18:56:55 +08:00
SivanColaandSivanCola 0466fef9d6 Make workspace prompt escaping test portable
Co-authored-by: SivanCola <[email protected]>
2026-07-03 18:52:48 +08:00
SivanCola dc68694cdb Merge pull request #5865 from esengine/fix/desktop-release-manifest-gateway
fix: route desktop updater via release gateway
2026-07-03 18:51:11 +08:00
SivanColaandSivanCola a1029786ad Escape workspace root in prompt line
Co-authored-by: SivanCola <[email protected]>
2026-07-03 18:46:00 +08:00
SivanCola 50a658e434 Merge pull request #5857 from SivanCola/fix/windows-shell-cancel-hang
Fix Windows shell cancellation for Git Bash descendants / 修复 Windows Shell 取消残留进程
2026-07-03 18:45:38 +08:00
SivanColaandSivanCola 9a4f56e4cf fix(permission): preserve compound bash exact rules
Keep whole-command bash allow/ask/deny rules authoritative before segment-level decomposition, so existing exact compound approvals and denials keep their previous precedence.

Co-authored-by: SivanCola <[email protected]>
2026-07-03 18:44:39 +08:00
SivanColaandmyipanta 06634777c1 fix: route desktop updater via release gateway
Co-authored-by: myipanta <[email protected]>
2026-07-03 18:41:09 +08:00
SivanCola 7bd9c13f01 Merge pull request #5856 from esengine/fix/session-conflict-recovery
fix(session): recover stale controller transcripts
2026-07-03 18:37:38 +08:00
SivanCola 107456d605 Cover shell routing during rapid tab switches 2026-07-03 18:36:58 +08:00
SivanCola f19c00f141 fix(desktop): surface recovery lease failures 2026-07-03 18:29:12 +08:00
SivanCola 2ba719ebab fix(planmode): allow declared read-only bash prefixes 2026-07-03 18:26:40 +08:00
SivanCola 7cb64f55e0 Preserve direct shell session isolation 2026-07-03 18:19:10 +08:00
SivanCola 201423138e Merge pull request #5864 from SivanCola/codex/fix-5753-fresh-human-memory-approval
Restrict remember/forget and plan approvals to fresh human-only flow
2026-07-03 18:17:07 +08:00
SivanCola 787fe8a0bf fix(session): require lease before recovery switch 2026-07-03 18:16:21 +08:00
SivanCola 9d0890f92a Merge pull request #5844 from myipanta/fix/memory-yolo-test-5753
test: add YOLO regression test for remember/forget forced approval (#…
2026-07-03 18:12:32 +08:00
SivanCola 79efac68a3 Unify tracked shell cancellation paths 2026-07-03 18:10:43 +08:00
SivanCola 608527502a Restrict remember/forget and plan approvals to fresh human-only flow 2026-07-03 17:45:09 +08:00
SivanCola 61baf002e9 test: cover project blank session workspace binding 2026-07-03 17:37:18 +08:00
SivanCola 0f89cec5ea Merge pull request #5861 from SivanCola/fix/serve-http-auth-cookie
Fix serve HTTP auth cookies / 修复 serve HTTP 登录 Cookie
2026-07-03 17:33:40 +08:00
SivanCola 6482375b72 fix(session): preserve malformed metadata errors 2026-07-03 17:26:19 +08:00
SivanCola 5f2eac7053 fix(serve): keep HTTP auth cookies usable 2026-07-03 17:25:48 +08:00
SivanCola f61118b8fe Stabilize Windows Git Bash cancel regression 2026-07-03 17:23:56 +08:00
SivanCola 8ecdc894a8 fix(session): add transcript CAS and runtime leases 2026-07-03 17:22:14 +08:00
jiangrenhang faab493b02 fix: add workspace root to system prompt for project context awareness
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
2026-07-03 17:20:05 +08:00
SivanCola d7bd736a85 Use real Python for Windows Git Bash cancel test 2026-07-03 17:19:26 +08:00
SivanCola 4e3a1aea57 Fix Windows Git Bash regression test path 2026-07-03 17:13:42 +08:00
SivanCola afa8b710f4 Merge pull request #5773 from GTC2080/GTC/fix-5769-stop-button
fix(desktop): keep stop state when switching to running tabs
2026-07-03 17:08:36 +08:00
SivanCola 02702c8378 Fix Windows shell cancellation for tracked process trees 2026-07-03 17:07:43 +08:00
SivanColaandSivanCola 863d07be20 Merge main-v2 into PR 5773
Preserve both project-switch send fallback and running-tab rapid switch behavior.

Co-authored-by: SivanCola <[email protected]>
2026-07-03 17:02:25 +08:00
SivanCola 2b105dd575 Merge pull request #5854 from GTC2080/GTC/fix-yolo-project-switch
fix: reconcile opened topic runtime
2026-07-03 16:57:11 +08:00
SivanCola 7902c9321d fix(session): recover stale controller transcripts 2026-07-03 16:50:52 +08:00
SivanColaandSivanCola 7e2a14ef91 fix: preserve last-click tab activation
Co-authored-by: SivanCola <[email protected]>
2026-07-03 16:47:17 +08:00
SivanCola 7135b32e14 Merge pull request #5720 from GTC2080/GTC/fix-5716-creation-tool-collapse
fix(desktop): hide collapsed creation tool bodies
2026-07-03 16:46:37 +08:00
SivanColaandSivanCola acc954a506 fix: reconcile runtime on active tab fallback
Co-authored-by: SivanCola <[email protected]>
2026-07-03 16:29:51 +08:00
SivanCola 4e440421af Merge pull request #5852 from GTC2080/GTC/fix-block-comment-prompt
fix: allow block comment prompts
2026-07-03 16:17:23 +08:00
SivanCola a9928d3ef5 Merge pull request #5847 from SivanCola/feature/desktop-system-notifications
Add desktop system notifications for turns / 为桌面端任务完成增加系统通知
2026-07-03 16:11:13 +08:00
SivanCola 74b9dd77b6 Merge pull request #5851 from SivanCola/fix/session-snapshot-conflict
Guard session snapshots against stale overwrites / 防止旧快照覆盖会话
2026-07-03 16:05:59 +08:00
SivanColaandSivanCola 1e8fc36577 fix: share slash comment prompt dispatch
Co-authored-by: SivanCola <[email protected]>
2026-07-03 16:05:15 +08:00
SivanColaandBarsit ec814165dd chore: credit related notification contribution
Credits the Windows Toast direction from PR #5365 in the repository history.

Co-authored-by: Barsit <[email protected]>
2026-07-03 16:04:06 +08:00
SivanCola 4b4b976fcd fix(desktop): avoid duplicate Windows notification fallback 2026-07-03 15:52:33 +08:00
SivanCola aad432d693 fix(session): protect explicit rewrites from stale controllers 2026-07-03 15:49:46 +08:00
SivanCola a6807da7f8 Merge pull request #5059 from maoxin1234/feat/zhipu-thinking
feat(provider): add Zhipu GLM thinking-mode support
2026-07-03 15:49:31 +08:00
GTC2080 bf24e28377 fix: reconcile opened topic runtime 2026-07-03 17:47:18 +10:00
SivanCola 31d3b7c0e7 docs(desktop): clarify provider compatibility settings 2026-07-03 15:40:01 +08:00
GTC2080 63fbad01d3 fix: allow block comment prompts 2026-07-03 17:39:39 +10:00
SivanCola d2e91a8a11 fix(session): guard snapshots against stale overwrites 2026-07-03 15:39:25 +08:00
SivanCola aeb6431f73 chore: avoid touching cache-sensitive config 2026-07-03 14:59:53 +08:00
SivanCola 98ee87101b feat(desktop): add system notifications for turns 2026-07-03 14:58:09 +08:00
SivanCola 27b86732c2 Merge remote-tracking branch 'origin/main-v2' into HEAD 2026-07-03 14:42:31 +08:00
SivanCola 912bbd86ae Merge pull request #5833 from SivanCola/codex/claude-plugin-compat
Support Claude-style plugin hooks and CLAUDE.md compatibility
2026-07-03 14:37:51 +08:00
jiangrenhang 939975881e test: add YOLO regression test for remember/forget forced approval (#5753)
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
2026-07-03 13:46:11 +08:00
Reasonix Dev 746d06800a fix: window controls white box & ContextPanel real-time data
- 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
2026-07-03 13:11:33 +08:00
MorningTZH bd31c9e10e test(permission): cover heredoc, leading operator, process substitution
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.
2026-07-03 13:00:25 +08:00
MorningTZH f55083fa62 fix(permission): use tagged switch for parenDepth branch (QF1003)
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.
2026-07-03 12:53:22 +08:00
MorningTZH 23b429d3d5 feat(permission): match compound bash commands segment-by-segment
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
2026-07-03 12:53:22 +08:00
YHH a87adced3c Merge pull request #5838 from esengine/site-registry-origin
Point skills page at the live registry API (crash worker)
2026-07-03 12:29:26 +08:00
SivanCola 573f58a1c2 Merge remote-tracking branch 'origin/main-v2' into HEAD
# Conflicts:
#	desktop/frontend/src/components/SettingsPanel.tsx
#	desktop/frontend/src/locales/en.ts
#	desktop/frontend/src/locales/zh-TW.ts
#	desktop/frontend/src/locales/zh.ts
#	desktop/settings_app_test.go
2026-07-03 12:26:39 +08:00
yhh b7d1e95c50 fix(site): point skills registry API at the live worker
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.
2026-07-02 21:24:18 -07:00
SivanCola a8fcd8c1c8 Merge pull request #5808 from ying-hua/fix/provider-extra-body
fix(provider): support provider extra_body
2026-07-03 12:22:10 +08:00
SivanColaandSivanCola 3307be74d9 feat(desktop): expose provider thinking override
Co-authored-by: SivanCola <[email protected]>
2026-07-03 12:21:25 +08:00
SivanCola 639736f5fa fix: return failure for plugin install JSON errors 2026-07-03 12:19:57 +08:00
YHH 2c7ad4973e Merge pull request #5834 from esengine/admin-unify
Unify community registry into the admin backend + speed up the dashboard
2026-07-03 12:11:28 +08:00
SivanColaandSivanCola eefcb09038 Add provider extra body settings UI
Co-authored-by: SivanCola <[email protected]>
2026-07-03 12:10:16 +08:00
SivanColaandSivanCola 02a1741a3d fix(provider): honor DeepSeek thinking disable config
Co-authored-by: SivanCola <[email protected]>
2026-07-03 12:07:11 +08:00
yhh 0f2ce6529f chore(crash-dashboard): set reasonix-registry database_id 2026-07-02 21:05:31 -07:00
SivanCola 9bc6344fa1 feat: add session plugin usage command 2026-07-03 11:58:21 +08:00
SivanColaandSivanCola dbced6638a Merge main-v2 into provider extra_body
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]>
2026-07-03 11:53:58 +08:00
SivanCola 253faa132f feat: surface plugin package usage details 2026-07-03 11:39:17 +08:00
yhh 606d782379 feat(crash-dashboard): fold skill/MCP registry into the admin backend
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.
2026-07-02 20:13:14 -07:00
SivanCola ad3d06343d feat: expand Claude-style plugin compatibility 2026-07-03 11:01:47 +08:00
yhh ac5f37f40b perf(crash-dashboard): cache identity + per-module queries + indexes
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.
2026-07-02 19:59:07 -07:00
SivanCola 9b5c870bd3 Merge pull request #5791 from SivanCola/feature/session-hook-subagent-depth
Support hook bootstrap, nested subagents, and plugin packages / 支持 Hook 启动上下文、二级子代理与插件包
2026-07-03 10:43:38 +08:00
SivanCola 2b29e2d92f docs: expand plugin CLI usage 2026-07-02 19:08:30 +08:00
SivanCola bafd9db24d docs: describe desktop plugin settings 2026-07-02 19:04:33 +08:00
SivanCola 8ff0b64884 fix(desktop): refine local plugin install layout 2026-07-02 19:00:48 +08:00
SivanCola 8de3dba0ff fix(desktop): swap plugin install actions and options 2026-07-02 18:56:52 +08:00
SivanCola d2ec3a1d68 fix(desktop): compact plugin install name field 2026-07-02 18:54:15 +08:00
SivanCola 9b70f121c0 fix(desktop): left align plugin replace option 2026-07-02 18:52:16 +08:00
SivanCola a71a850213 fix(desktop): extend plugin local path field 2026-07-02 18:50:14 +08:00
SivanCola 95d06df682 fix(desktop): align plugin install mode layouts 2026-07-02 18:48:25 +08:00
SivanCola addd1125d9 fix(desktop): unify plugin install form layout 2026-07-02 18:43:56 +08:00
SivanCola 08cf2b4585 fix(desktop): unify plugin install action label 2026-07-02 18:34:12 +08:00
ying-hua 052708f6f4 Support provider extra_body 2026-07-02 18:33:27 +08:00
SivanCola 554e881a69 fix(desktop): clarify plugin link mode 2026-07-02 18:29:22 +08:00
SivanCola 4e6db19690 fix(desktop): clarify plugin replacement option 2026-07-02 18:25:55 +08:00
SivanCola 044936cd5b refactor(desktop): simplify plugin install settings 2026-07-02 18:19:23 +08:00
SivanCola 48f9b9418a feat(desktop): add plugin folder picker 2026-07-02 18:10:09 +08:00
SivanCola 8ac66a4b18 fix(history): satisfy around clamp lint 2026-07-02 17:51:19 +08:00
SivanCola b99c08a52c feat(desktop): add plugin settings page 2026-07-02 17:48:39 +08:00
SivanCola 61e943beb3 fix(history): clamp around window without overflow 2026-07-02 17:48:34 +08:00
SivanCola 9e9d9e6e44 Merge pull request #5801 from esengine/docs/website-docs-refresh
Refresh website docs page / 刷新官网文档页
2026-07-02 17:38:20 +08:00
SivanCola f7c6048fa4 Refresh website documentation page 2026-07-02 17:32:57 +08:00
SivanCola 42ee48f214 feat(plugin): add unified plugin packages 2026-07-02 17:23:56 +08:00
SivanCola 6acdf3edb3 test(control): wait for edited prompt autosave 2026-07-02 16:45:42 +08:00
SivanCola 5d63d8e29a fix(agent): satisfy hook context lint 2026-07-02 16:41:51 +08:00
SivanCola 26ef771c7a feat(agent): support hook context and nested subagents 2026-07-02 15:58:51 +08:00
SivanCola bf53799e8a Merge pull request #5786 from SivanCola/feature/mouse-capture-toggle
Auto-copy mouse selection + mouse-capture toggle / 拖拽选中自动复制 + 鼠标接管开关
2026-07-02 15:37:35 +08:00
SivanCola ee9d976622 feat(cli): auto-copy mouse selection and add mouse-capture toggle
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.
2026-07-02 15:02:17 +08:00
SivanCola d5ba463964 Merge pull request #5774 from SivanCola/fix/windows-classic-drag-rail
Fix Windows classic titlebar dragging / 修复 Windows 经典标题栏拖动
2026-07-02 12:00:44 +08:00
SivanCola 4762cd33d6 fix(desktop): add Windows classic drag rail 2026-07-02 11:53:20 +08:00
GTC2080 9838a27280 fix: keep stop state when switching to running tabs 2026-07-02 13:49:44 +10:00
SivanCola 484bca854f Merge pull request #5757 from SivanCola/feature/custom-provider-setup
Simplify custom provider setup / 简化自定义供应商接入配置
2026-07-02 10:05:33 +08:00
SivanCola 9ff0b20589 feat(desktop): simplify custom provider setup 2026-07-02 09:45:56 +08:00
SivanCola a2eaf80601 Merge pull request #5755 from ttmouse/feat/heartbeat-bot-push
feat: Heartbeat 定时任务输出实时推送到 Bot 渠道(飞书/QQ)
2026-07-02 09:44:31 +08:00
SivanCola cf2a8b061b Localize heartbeat bot push labels
Co-authored-by: SivanCola <[email protected]>
2026-07-02 03:06:32 +08:00
SivanCola fcd6180976 Fix heartbeat bot forwarding isolation
Co-authored-by: SivanCola <[email protected]>
2026-07-02 02:18:24 +08:00
SivanCola 6cbffaa78b Merge pull request #5754 from SivanCola/fix/anchor-edit-safety
Guard anchor edits after same-turn writes / 防止同回合锚点编辑失效
2026-07-02 01:58:50 +08:00
SivanCola 9ee254b2ec Fix anchor edit guard follow-ups 2026-07-02 01:44:07 +08:00
ttmouse cae565c158 feat: Heartbeat → Bot 渠道事件推送(双 Sink 分流) 2026-07-02 01:27:02 +08:00
SivanCola 3255803636 Guard anchor edits after same-turn writes 2026-07-02 01:05:53 +08:00
SivanCola c8ac697010 Merge pull request #5751 from SivanCola/fix/desktop-trash-ancillary-lag
Reduce desktop trash fallback hydration lag / 降低桌面端删除话题后的恢复卡顿
2026-07-02 00:39:24 +08:00
SivanCola 8d8bccd334 Merge pull request #5752 from SivanCola/fix/windows-classic-titlebar-tools
Fix Windows classic titlebar tool positioning / 修复 Windows 经典标题栏工具定位
2026-07-02 00:38:20 +08:00
SivanCola 3b0b9c0546 fix(desktop): keep checkpoint file arrays non-null 2026-07-02 00:32:52 +08:00
SivanCola 3608146829 Fix Windows classic titlebar tool positioning 2026-07-02 00:13:27 +08:00
SivanCola 3b3c9dba57 fix(desktop): reduce trash fallback hydration lag 2026-07-02 00:03:20 +08:00
SivanCola e1c2f46e8a Merge pull request #5743 from SivanCola/fix/desktop-code-preview-scroll
Fix workspace code preview scrolling / 修复工作区代码预览滚动
2026-07-01 23:27:44 +08:00
SivanCola 798c7ce85a fix(desktop): restore code preview scrolling 2026-07-01 23:06:04 +08:00
SivanCola 2ade3cd50d Merge pull request #5737 from esengine/fix/windows-frameless-titlebar
Fix Windows frameless titlebar controls / 修复 Windows 无边框标题栏控制
2026-07-01 23:01:58 +08:00
SivanCola da348d552e Merge pull request #5738 from SivanCola/fix/desktop-session-save-failures
Fix silent desktop session save failures / 修复桌面会话保存失败静默丢失
2026-07-01 22:46:02 +08:00
SivanCola 96431bc31f fix(desktop): surface session snapshot failures 2026-07-01 22:31:45 +08:00
SivanCola 2b56585f8f fix(desktop): add Windows frameless titlebar controls 2026-07-01 22:25:25 +08:00
SivanCola 4ea38a7a22 Merge pull request #5733 from SivanCola/fix/bash-cancel-wait-stall
Fix stalled bash cancel waits / 修复 bash 取消后等待卡死
2026-07-01 22:06:01 +08:00
SivanCola 5b8a9c0cd5 fix(bash): preserve wait error after cancel 2026-07-01 21:49:53 +08:00
SivanCola 1f4d811ac3 Merge pull request #5729 from SivanCola/feature/desktop-unread-topic-dot
Show unread topic attention dots / 显示未读会话提醒点
2026-07-01 21:39:05 +08:00
YHH 7cab46ac3a Merge pull request #5731 from esengine/feat/account-aware-skills
feat(skills): account-aware registry page with admin review
2026-07-01 21:38:13 +08:00
SivanCola 0a38be6852 fix(bash): release cancelled tracked waits 2026-07-01 21:38:00 +08:00
SivanCola 0ae11ebdef fix(desktop): omit hidden unread topic actions 2026-07-01 21:33:18 +08:00
yhh fdb99115d8 feat(skills): make the registry page account-aware with admin review
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.
2026-07-01 06:31:40 -07:00
SivanCola 2bcac618d7 feat(desktop): show unread topic attention dots 2026-07-01 21:13:10 +08:00
YHH 25184026bc Merge pull request #5728 from esengine/feat/registry-moderation
feat(registry): moderate submissions before they go live
2026-07-01 20:54:20 +08:00
yhh 3a1d04ae23 feat(registry): moderate submissions before they go live
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.
2026-07-01 05:47:41 -07:00
SivanCola e73221c8b7 Merge pull request #5726 from esengine/feature/composer-guidance-queue
Add composer guidance queue controls / 新增 Composer 引导队列控制
2026-07-01 20:44:54 +08:00
YHH bd9923d644 Merge pull request #5725 from esengine/feat/skills-mcp-registry
feat: skill/MCP registry — worker + web discovery page
2026-07-01 20:28:37 +08:00
yhh 6892b5a035 feat(site): add skills registry page
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.
2026-07-01 05:21:13 -07:00
SivanCola 885ad5627f feat(desktop): add composer guidance queue controls 2026-07-01 20:21:08 +08:00
yhh e702eb082e feat(registry): add skill/MCP registry worker
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.
2026-07-01 05:21:07 -07:00
GTC2080 ef1d287fc0 fix(desktop): hide collapsed creation tool bodies 2026-07-01 20:51:18 +10:00
GTC2080 c146456d8a fix(cli): repaint wide-character input on Windows 2026-07-01 20:15:05 +10:00
SivanCola ebd73457da Merge pull request #5711 from SivanCola/fix/compact-prompt-approvals
Compact approval prompts
2026-07-01 17:55:11 +08:00
YHH 3f037fa745 feat(site): bilingual community forum (EN / 中文) (#5715)
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.
2026-07-01 17:53:13 +08:00
SivanCola f83b84b896 fix(desktop): show approval details by default 2026-07-01 17:47:51 +08:00
SivanCola be59e66976 Merge pull request #5714 from SivanCola/feature/context-panel-usage-visuals
Refine context panel usage overview / 优化上下文面板用量概览
2026-07-01 17:42:36 +08:00
SivanCola d924d998a0 feat: refine context panel usage overview 2026-07-01 17:32:31 +08:00
YHH 8b04b164dc fix(forum): let staff bypass the trust-based post gates (#5713)
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.
2026-07-01 17:32:09 +08:00
SivanCola 118fe34378 Merge pull request #5704 from SivanCola/fix/session-profile-restore
fix(desktop): Restore session profile on switch / 修复切会话恢复权限与思考模式
2026-07-01 17:25:18 +08:00
SivanCola 170771fc3a fix(desktop): compact approval prompts 2026-07-01 17:19:20 +08:00
YHH 06fc964bc9 feat(site): community forum frontend at /community (#5708)
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).
2026-07-01 17:16:45 +08:00
YHH 6643299111 feat(forum): community forum API with shared identity + anti-spam (#5706)
* 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.
2026-07-01 17:15:03 +08:00
SivanCola 72ce3d8d93 fix(desktop): persist closed session profile 2026-07-01 17:07:07 +08:00
SivanCola 0aad3bd186 fix(desktop): restore session profile on switch 2026-07-01 16:44:08 +08:00
YHH 1858974dc7 feat(crash): unify dashboard auth onto id.reasonix.io identity (#5701)
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.
2026-07-01 16:26:24 +08:00
SivanCola ac2f6695c5 Merge pull request #5700 from SivanCola/docs/attribute-todo-panel-contributors
Attribute TodoPanel contributor credit / 补充 TodoPanel 贡献署名
2026-07-01 16:19:41 +08:00
69d0185ff6 docs: leave attribution in PR history
Co-authored-by: dengbushi <[email protected]>
Co-authored-by: HHHEEEWWW <[email protected]>
2026-07-01 16:13:34 +08:00
4d8caaa944 docs: attribute todo panel contributors
Co-authored-by: dengbushi <[email protected]>
Co-authored-by: HHHEEEWWW <[email protected]>
2026-07-01 16:10:02 +08:00
SivanCola 5e40ed682b Merge pull request #5698 from SivanCola/fix/todo-panel-batch-collapse
fix(desktop): scope todo panel completion state / 修复待办面板跨会话完成态
2026-07-01 15:56:29 +08:00
SivanCola 1284d1c6c1 fix(desktop): scope todo panel state by session 2026-07-01 15:46:04 +08:00
YHH 3093f03585 feat(site): account UI — sign-in, register, profile, device approval (#5682)
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.
2026-07-01 15:42:22 +08:00
YHH f39f06896f feat(accounts): device-authorization sign-in for CLI/desktop (#5681)
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.
2026-07-01 15:42:11 +08:00
YHH cb1a0bfd18 ci(accounts): sync RESEND_API_KEY from repo secret on deploy (#5695)
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.
2026-07-01 15:26:57 +08:00
YHH c757d24483 ci(accounts): auto-deploy id.reasonix.io on merge to main-v2 (#5679)
* 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.
2026-07-01 15:13:54 +08:00
SivanCola c38b0801cc Merge pull request #5691 from SivanCola/fix/transient-blank-session-delete
Fix delete fallback blank sessions / 修复删除后的空白会话残留
2026-07-01 15:05:16 +08:00
SivanCola 8d42210d7c fix(desktop): discard unused transient blank sessions 2026-07-01 14:55:19 +08:00
SivanCola 191772931f fix(desktop): keep delete fallback blanks out of project tree 2026-07-01 14:27:47 +08:00
SivanCola 56a53237f4 Merge pull request #5675 from SivanCola/fix/legacy-sidebar-project-recovery
Recover legacy sidebar projects on upgrade / 升级时恢复旧版侧栏项目
2026-07-01 11:49:53 +08:00
SivanCola c045230307 fix(desktop): serialize project index updates 2026-07-01 11:41:06 +08:00
SivanCola 946255ce89 fix(desktop): recover legacy sidebar projects on upgrade 2026-07-01 11:27:46 +08:00
SivanCola 397605ab13 Merge pull request #5674 from SivanCola/fix/mcp-remove-clears-failure
Clear stale MCP failure after removal / 删除 MCP 后清理残留失败状态
2026-07-01 11:13:59 +08:00
SivanColaandcyq1017 a753893797 fix(desktop): clear failed MCP status after removal
Co-authored-by: cyq1017 <[email protected]>
2026-07-01 11:08:30 +08:00
SivanCola 23bf5dd464 Merge pull request #5657 from CVEngineer66/feature/autoresearch-runtime
Add host-managed AutoResearch runtime
2026-07-01 04:53:00 +08:00
SivanCola c600b12693 Merge pull request #5643 from SauronSkywalker/dpi-zoom-restart
feat(desktop): add custom DPI zoom via WebView2 ZoomFactor
2026-07-01 04:51:57 +08:00
SivanCola d2cf3addc7 Merge pull request #5662 from SivanCola/fix/math-rendering-pr-integration
Consolidate math rendering fixes / 整合数学渲染修复
2026-07-01 04:51:17 +08:00
SivanCola f1951c5d1f Harden AutoResearch task store paths
Co-authored-by: SivanCola <[email protected]>
2026-07-01 01:01:35 +08:00
SivanCola 82a9d6829e Consolidate math rendering fixes 2026-07-01 00:54:52 +08:00
SivanCola 7981c96d70 fix(desktop): keep zoom preference authoritative
Co-authored-by: SivanCola <[email protected]>
2026-07-01 00:50:51 +08:00
SivanCola ac1b2ad45d Merge pull request #5661 from SivanCola/fix/hide-windows-probe-consoles
Hide Windows startup probe consoles / 隐藏 Windows 启动探测终端弹窗
2026-07-01 00:49:18 +08:00
SivanCola f00bb1ea85 Fix AutoResearch readiness evidence loop 2026-07-01 00:22:09 +08:00
SivanCola 68b7ea6c07 fix(desktop): hide zoom setting outside Windows
Co-authored-by: SivanCola <[email protected]>
2026-07-01 00:12:19 +08:00
SivanCola d239e346f6 fix(environment): hide startup probe consoles on Windows 2026-07-01 00:09:17 +08:00
wufengfan 5527e8e805 Keep AutoResearch out of default tool surface 2026-06-30 23:51:13 +08:00
wufengfan cf7ee9e400 Remove AutoResearch frontend UI surfaces 2026-06-30 23:35:21 +08:00
wufengfan c580472167 Reuse AutoResearch task for repeated goal updates 2026-06-30 23:07:03 +08:00
wufengfan 462fd5e281 Refresh status metadata for AutoResearch notices 2026-06-30 23:01:39 +08:00
wufengfan 8ddad32b4b Allow AutoResearch status bar item in config 2026-06-30 22:53:31 +08:00
wufengfan 2bc0d2f010 Add host-managed AutoResearch runtime 2026-06-30 22:40:43 +08:00
SivanCola 97a6d617f2 Merge pull request #5648 from GTC2080/GTC/fix-5596-memory-compiler-self-feedback
fix(memorycompiler): suppress compiler feedback noise
2026-06-30 21:16:41 +08:00
SivanCola 13a86545d3 Complete memory compiler issue controls
Co-authored-by: SivanCola <[email protected]>
2026-06-30 21:06:36 +08:00
SivanCola 8d67305f88 Merge pull request #5646 from SivanCola/fix/desktop-session-workspace-binding
Fix desktop session workspace binding / 修复桌面会话工作区绑定
2026-06-30 20:54:29 +08:00
SivanCola 052fdc25ca fix(desktop): reconcile effort workspace binding 2026-06-30 20:48:04 +08:00
SivanCola 754c95c038 Merge pull request #5647 from Li-Charles-One/feat/creation-composer-polish
style(creation): 打磨创作输入框发送按钮与模式切换条
2026-06-30 20:25:31 +08:00
SivanCola 78686f68a3 Filter memory compiler feedback noisy refs
Co-authored-by: SivanCola <[email protected]>
2026-06-30 20:24:32 +08:00
Sivan 6d43964c5b fix(desktop): serialize workspace reconcile quick clicks 2026-06-30 20:23:46 +08:00
Sivan 4df899342b fix(desktop): guard steer and compact workspace binding 2026-06-30 20:14:10 +08:00
GTC2080 0a227840d1 fix(memorycompiler): suppress compiler feedback noise 2026-06-30 21:56:53 +10:00
Li-Charles-One 9c55693279 Polish creation composer controls 2026-06-30 19:33:55 +08:00
Sivan 82e6002d1d fix(desktop): reconcile session workspace bindings 2026-06-30 19:26:58 +08:00
SivanCola 8b1e5c8700 Merge pull request #5644 from SivanCola/fix/custom-provider-full-url
Support full custom provider request URLs / 支持自定义供应商完整请求 URL
2026-06-30 18:04:47 +08:00
SivanCola 21e1965d5a Merge pull request #5635 from SivanCola/fix/planner-executor-cache-breakdown-5488
Fix planner/executor cache telemetry breakdown / 修复规划器与执行器缓存明细
2026-06-30 18:01:43 +08:00
Sivan e7d6b0f5c6 docs: document custom provider full URLs 2026-06-30 17:55:25 +08:00
SivanCola f55a0c6f0d test(desktop): cover executor-only usage sources
Co-authored-by: SivanCola <[email protected]>
2026-06-30 17:51:34 +08:00
SivanCola 69305cbf5c ui(desktop): compact runtime metrics panel
Co-authored-by: SivanCola <[email protected]>
2026-06-30 17:44:18 +08:00
SivanCola 0b3f501db0 fix(provider): support full custom chat URLs 2026-06-30 17:41:52 +08:00
SivanCola 61e10c95fc ui(desktop): refine model usage breakdown
Co-authored-by: SivanCola <[email protected]>
2026-06-30 17:38:38 +08:00
SivanCola 40b32c421e Merge pull request #5641 from SivanCola/fix/custom-provider-openai-kind
Default custom providers to OpenAI / 自定义供应商默认使用 OpenAI 协议
2026-06-30 17:34:01 +08:00
SivanCola 5d6d8c8504 fix(desktop): default custom providers to OpenAI 2026-06-30 17:22:44 +08:00
SauronSkywalker af13bc76be feat(desktop): add custom DPI zoom via WebView2 ZoomFactor
- Add ZoomFactor support in windows.Options (main.go)
- Add zoom_factor.go for JSON persistence of zoom setting
- Add dpiScale.ts for frontend zoom state management
- Add slider UI in Settings → Appearance with capsule thumb
- Add SetDesktopZoomFactor / GetDesktopZoomFactor / RestartApplication bindings
- Add locale keys for zoom setting (en/zh/zh-TW)

Setting changes require app restart to take effect (WebView2 engine-level).
2026-06-30 17:17:27 +08:00
SivanCola e0bdcf70df Fix planner executor cache telemetry breakdown
Co-authored-by: SivanCola <[email protected]>
2026-06-30 16:31:16 +08:00
SivanCola ff379b4244 Merge pull request #5478 from ttmouse/fix/session-title-reset-5438
fix(desktop): restore topic title from session fallback when switching projects in creation mode (#5438)
2026-06-30 16:23:21 +08:00
SivanCola d60d0b0ae8 Merge pull request #5455 from ttmouse/fix/paste-multiline-5446
fix(composer): ensure pasted text is always captured via React state (#5446)
2026-06-30 16:18:59 +08:00
SivanCola e7334831e9 Merge pull request #5477 from ttmouse/fix/session-switch-flicker-5441
fix(tui): suppress ClearScreen flicker on session switch (#5441)
2026-06-30 16:11:24 +08:00
SivanCola d25edd2541 test(composer): cover short multiline text paste submit
Co-authored-by: SivanCola <[email protected]>
2026-06-30 15:51:36 +08:00
SivanandSivanCola 1f23350262 fix(desktop): preserve stored topic titles on project switch
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]>
2026-06-30 15:50:40 +08:00
SivanandSivanCola 59fa0357fd Merge origin/main-v2 into session switch flicker fix
Resolve the clear transcript helper conflict and add focused regression coverage for session-switch ClearScreen suppression.

Co-authored-by: SivanCola <[email protected]>
2026-06-30 15:47:25 +08:00
SivanCola 6f15baefac Merge pull request #5535 from GTC2080/GTC/fix-5068-image-drop-navigation
fix(desktop): prevent file drops opening images
2026-06-30 15:36:01 +08:00
SivanCola 60c787e02e Merge pull request #5536 from GTC2080/GTC/fix-5533-transcript-final-fold
fix(desktop): keep final replies visible in compact transcript
2026-06-30 15:33:40 +08:00
SivanCola 185731e586 Merge pull request #5284 from qiukeren/addUpdateMessage
feat(i18n): add MB unit to download progress display
2026-06-30 15:30:39 +08:00
SivanCola dd896f9ea1 Merge pull request #5630 from SivanCola/fix/desktop-system-prompt-memory
Fix desktop system prompt refresh / 修复桌面 system prompt 刷新
2026-06-30 15:21:30 +08:00
SivanCola bddb8000d2 Merge pull request #5631 from SivanCola/fix/composer-responsive-controls
Improve composer responsive controls / 优化输入栏响应式控件
2026-06-30 15:19:59 +08:00
Sivan dad8195b3a fix(desktop): improve composer responsive controls 2026-06-30 15:11:49 +08:00
SivanCola 224321fbd0 fix(desktop): refresh system prompt across session rebuilds
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.
2026-06-30 15:07:06 +08:00
drafish 46e50ee5a5 fix(desktop): prevent empty placeholder session from overwriting real session
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.
2026-06-30 15:07:06 +08:00
SivanCola d98610825a Merge pull request #5628 from SivanCola/codex/fix-5627-tui-copy-docs
docs: correct TUI transcript selection guidance
2026-06-30 15:04:27 +08:00
SivanandVu Minh Sang d52fec85a3 docs: correct TUI transcript selection guidance
Co-authored-by: Vu Minh Sang <[email protected]>
2026-06-30 14:53:31 +08:00
SivanCola 046f575e2d Merge pull request #5620 from SivanCola/fix/desktop-runtime-safety-maintenance
Harden desktop topic IDs and tray probing / 加固桌面 topic ID 与托盘探测
2026-06-30 11:52:39 +08:00
SivanCola 06e6075d88 Merge pull request #5555 from yekern/fix/tui-cjk-cursor-shape
feat(cli): add configurable cursor_shape to fix CJK text input rendering
2026-06-30 11:35:37 +08:00
Sivan c675d754cc fix(desktop): harden topic ids and tray probing 2026-06-30 11:34:17 +08:00
SivanCola c8ccc01740 Merge pull request #5619 from SivanCola/fix/desktop-hydration-final
Fix desktop hydration storms / 修复桌面端会话同步风暴
2026-06-30 11:31:56 +08:00
SivanandSivanCola 5f6315c079 Document TUI cursor shape config
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]>
2026-06-30 11:26:05 +08:00
SivanCola b2fa320188 Merge pull request #5543 from yekern/fix/tui-transcript-perf
feat(cli): add /cls to clear screen without losing LLM context
2026-06-30 11:17:07 +08:00
SivanandGTC2080 7e4f600b03 fix(desktop): coalesce tab hydration storms
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]>
2026-06-30 11:13:09 +08:00
SivanCola be6df9e2f5 Merge pull request #5572 from GTC2080/GTC/fix-5570-edit-original-jsonl
fix: preserve original prompt on message edit
2026-06-30 11:08:04 +08:00
SivanandSivanCola 5a6a93eced fix(cli): reset display state when clearing transcript
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]>
2026-06-30 11:06:10 +08:00
SivanandSivanCola 041585203b Preserve configured TUI cursor shape
Render ui.cursor_shape through config save paths and guard the textarea cursor-shape mapping with focused tests.

Co-authored-by: SivanCola <[email protected]>
2026-06-30 11:03:34 +08:00
SivanCola f3c536d2ff Merge pull request #5579 from GTC2080/GTC/fix-5575-preserve-waitdelay
fix(bash): preserve session launchers after WaitDelay
2026-06-30 10:46:09 +08:00
SivanCola e8f7c150b4 Merge pull request #5568 from nanami7777777/docs/add-qq-bot-guide
docs: add QQ Bot to bot guide
2026-06-30 10:45:13 +08:00
Sivan 2250de1ab9 docs: fix QQ bot guide accuracy 2026-06-30 10:34:27 +08:00
wufengfan 4d767f3db4 Add AutoResearch runtime design spec 2026-06-29 22:21:11 +08:00
GTC2080 192c53357a fix(bash): preserve session launchers after WaitDelay 2026-06-29 22:09:55 +10:00
nanami7777777 ab9951af9e docs: fix QQ Bot description - change from QR scan to manual config
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
2026-06-29 18:56:35 +08:00
GTC2080 615d5a9981 fix: preserve original prompt on edits 2026-06-29 20:35:10 +10:00
SivanCola adf228b344 Merge pull request #5566 from esengine/feature/context-contract-maintenance
Add context maintenance and tool contract guards / 增加上下文维护与工具合约保护
2026-06-29 18:21:47 +08:00
Sivan d308d0de1b Move snip geometry onto tools and guard the contract
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/...
2026-06-29 18:14:54 +08:00
SivanCola 9a69481e8d Harden startup environment probes 2026-06-29 17:18:59 +08:00
nanami7777777 40259bea22 docs: add QQ Bot to bot guide (fixes #5540) 2026-06-29 17:05:43 +08:00
SivanCola 2abd87e646 Cache startup environment probes 2026-06-29 16:56:40 +08:00
SivanCola 5401c3538b Add context maintenance and tool contract guards 2026-06-29 16:41:36 +08:00
SivanCola 0eb641c960 Merge pull request #5530 from cyq1017/codex/reasonix-5176-step-index
fix(agent): allow complete_step by todo index
2026-06-29 16:14:41 +08:00
SivanCola 9656efb5fe Merge pull request #5556 from morningtzh/fix/acp-mcp-headers-array
fix(acp): MCPServerSpec.Headers 兼容 ACP 官方 [{name,value}] 数组形状
2026-06-29 15:46:38 +08:00
SivanCola c3593566b3 Merge pull request #5554 from SivanCola/feature/mermaid-rendering-integration
Integrate Mermaid diagram rendering / 整合 Mermaid 图表渲染
2026-06-29 15:25:39 +08:00
MorningTZH 43cc7a315c fix(acp): accept official HTTPHeader[] shape for MCP server headers
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
2026-06-29 15:15:40 +08:00
Sivan 3556ffe771 Integrate Mermaid diagram rendering 2026-06-29 15:15:27 +08:00
maple 673996e9fc feat(cli): add configurable cursor_shape for TUI input
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
2026-06-29 14:38:21 +08:00
maple 5ea0ee26fc cli: add /cls command and Ctrl+L to clear TUI screen only
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
2026-06-29 11:16:04 +08:00
GTC2080 cb494af294 fix(desktop): keep final text out of processed folds 2026-06-29 09:41:03 +10:00
GTC2080 b09d045d14 fix(desktop): prevent file drops opening images 2026-06-29 08:17:27 +10:00
ttmouse 01414ef99b fix(openai): deliver final stream error chunk reliably when ctx is cancelled
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.
2026-06-29 03:07:14 +08:00
SivanCola aaaf863c11 Merge pull request #5532 from SivanCola/fix/theme-user-bubble-accent
Soften themed user message bubbles / 柔化主题用户消息气泡
2026-06-29 02:16:00 +08:00
SivanCola a0ce934d1e Soften themed user message bubbles 2026-06-29 02:02:07 +08:00
SivanCola 284e65d912 Merge pull request #5529 from SivanCola/fix/edit-file-fuzzy-old-string
Fix edit_file old_string drift matching / 修复 edit_file 匹配漂移
2026-06-29 01:38:07 +08:00
SivanCola 2a9c3f5ede fix(edit): tolerate whitespace drift in old_string matches 2026-06-29 01:26:50 +08:00
SivanCola d7f49d1f54 Merge pull request #5528 from cyq1017/codex/reasonix-5269-run-skill-subject
fix(desktop): show run_skill subject
2026-06-29 01:25:32 +08:00
cyq 555af11724 fix(agent): allow complete_step by todo index 2026-06-29 01:12:26 +08:00
SivanCola 633d602718 fix(desktop): keep run_skill subject in history
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.
2026-06-29 01:04:18 +08:00
SivanCola 02bd3d95b4 Merge pull request #5512 from SivanCola/fix/desktop-performance-5306
Fix desktop long-history lag and cancellation state / 修复桌面端长历史卡顿与取消状态
2026-06-29 00:44:09 +08:00
SivanCola 0da7fe8326 fix(desktop): align paged checkpoint refresh 2026-06-29 00:38:13 +08:00
SivanCola c9a358c779 fix(desktop): page history and preserve cancel context 2026-06-29 00:16:01 +08:00
cyq 0256e836ec fix(desktop): show run_skill subject 2026-06-29 00:12:19 +08:00
SivanCola 1c19a04833 Merge pull request #5500 from SauronSkywalker/feat/metrics-session-cumulative-tokens
feat(metrics): add session-cumulative input/在运行指标栏新增输入tokens(命中/未命中缓存)和输出tokens统计
2026-06-29 00:09:24 +08:00
SivanCola 1d30640be6 Show exact token metric values
Co-authored-by: SivanCola <[email protected]>
2026-06-28 23:56:22 +08:00
SivanCola 176e41c0ad Use wide cards for token metrics
Co-authored-by: SivanCola <[email protected]>
2026-06-28 23:52:05 +08:00
SivanCola cb52340db8 Merge pull request #5522 from SivanCola/fix/statusbar-configured-items-only
Restrict status bar to configured items / 限制状态栏仅显示配置项
2026-06-28 23:39:51 +08:00
SivanCola cf66ff24fa Fix status bar to honor configured items 2026-06-28 23:25:26 +08:00
SivanCola bca450eff0 Compact large token metric labels
Co-authored-by: SivanCola <[email protected]>
2026-06-28 23:25:23 +08:00
SivanCola 4bea752508 Merge pull request #5504 from SivanCola/fix/bot-session-delete-mapping
Clean up bot session mappings on delete / 删除 Bot 会话时清理映射
2026-06-28 23:14:27 +08:00
SivanCola 758a9e1fe1 Fix bot session deletion mapping cleanup 2026-06-28 23:06:25 +08:00
SivanCola 94166c3c08 Format context panel metrics fields
Co-authored-by: SivanCola <[email protected]>
2026-06-28 23:05:49 +08:00
SivanCola 68460194c4 Merge pull request #5498 from GTC2080/GTC/fix-5492-linux-tray-restore-path
fix(desktop): avoid background close without tray restore
2026-06-28 22:49:45 +08:00
SivanCola 832fe95906 Merge pull request #5501 from SivanCola/docs/memory-v5-llm-classification-env
Document Memory v5 classifier env / 补充 Memory v5 分类环境变量文档
2026-06-28 22:48:11 +08:00
SivanCola f7bfa62b7f fix(desktop): require ready tray before background close
Co-authored-by: SivanCola <[email protected]>
2026-06-28 22:41:49 +08:00
SivanCola fd9338fd61 docs: document Memory v5 LLM classifier env 2026-06-28 22:41:03 +08:00
SivanCola 0f24a334f6 Merge pull request #5493 from XTLine/fix/memory-v5-citation-noise
feat: improve Memory v5 task/chat classification with LLM classifier
2026-06-28 22:29:37 +08:00
SauronSkywalker d6d36a9c05 feat(metrics): add session-cumulative input/output token cards to overview panel
Add three new metric cards to the runtime metrics section:
- Input tokens (cache hit) -- session-cumulative cache hit prompt tokens
- Input tokens (cache miss) -- session-cumulative cache miss prompt tokens
- Output tokens -- session-cumulative completion tokens

Go backend changes:
- ContextPanelInfo struct: add SessionCacheHitTokens,
  SessionCacheMissTokens, SessionCompletionTokens fields
- ContextPanel() method: populate new fields from telemetry snapshot

Frontend changes:
- types.ts: add matching fields to ContextPanelInfo interface
- bridge.ts: update mock return values
- ContextPanel.tsx: use new info fields directly; add wide layout
- en.ts/zh.ts/zh-TW.ts: add three i18n translation keys
2026-06-28 22:23:58 +08:00
GTC2080 aae563c0b3 fix(desktop): avoid background close without tray restore 2026-06-29 00:19:39 +10:00
SivanCola 72e6e360bb test(agent): expand Memory v5 heuristic classifier coverage 2026-06-28 22:17:13 +08:00
SivanCola 715630d366 Merge pull request #5467 from ttmouse/fix/context-token-total-5283
fix(desktop): include completion tokens in context gauge total (#5283)
2026-06-28 22:07:16 +08:00
SivanCola 9d5d2a3c1c Merge pull request #5487 from ttmouse/fix/memory-v5-filter-evidence-5468
fix(memorycompiler): filter tool_result evidence from user-facing citations (#5468)
2026-06-28 22:01:14 +08:00
SivanCola 356037ae62 Add context token total regression coverage
Co-authored-by: SivanCola <[email protected]>
2026-06-28 21:58:41 +08:00
SivanCola fae1bf2c74 fix(agent): honor Memory v5 task classification 2026-06-28 21:50:19 +08:00
SivanCola ba611da664 test(memorycompiler): guard evidence citation filtering 2026-06-28 21:50:19 +08:00
XTLine 097a49d2aa test: update and add E2E tests for classifier behavior
Update TestRunDoesNotInjectMemoryCompilerContractForGreeting to expect
zero stats events (chat inputs now completely skip Memory v5).

Add two new E2E tests:
- TestClassifierSkipsMemoryV5ForChat: verifies chat skips Memory v5
- TestClassifierUsesMemoryV5ForTask: verifies tasks use Memory v5
2026-06-28 20:09:11 +08:00
XTLine bbc2d87404 feat: integrate classifier to gate Memory v5 for chat vs task
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.
2026-06-28 20:08:56 +08:00
XTLine d916d0c78d feat: add task/chat classifier infrastructure for Memory v5
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.
2026-06-28 20:08:14 +08:00
XTLine 76852fe134 fix: gate memory compiler prompt injection for chat 2026-06-28 17:57:26 +08:00
ttmouse 53dea11006 fix(memorycompiler): filter tool_result evidence from user-facing citations (#5468)
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
2026-06-28 17:51:58 +08:00
ttmouse 808e24ff2e fix: also reset controller planMode; only suppress ClearScreen on actual transcript rebuild 2026-06-28 17:42:40 +08:00
XTLine bc9b7f2abb fix: hide memory compiler citations in transcript 2026-06-28 16:45:07 +08:00
XTLine 59dc039d33 fix: throttle memory compiler prompt injection 2026-06-28 16:24:40 +08:00
ttmouse 0dd1923ab1 fix(desktop): restore topic title from session fallback when switching projects in creation mode (#5438)
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.
2026-06-28 13:55:47 +08:00
ttmouse df11ddd1b7 fix(composer): reset history nav + normalize CRLF in short paste path (#5446)
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)
2026-06-28 13:30:40 +08:00
ttmouse 1e3ef27a31 fix(composer): ensure pasted text is always captured via React state (#5446)
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.
2026-06-28 13:26:37 +08:00
SivanCola b9d1ffc606 Merge pull request #5470 from esengine/fix/plan-revision-file-refs
Fix file references in plan revisions / 修复修改计划中的文件引用
2026-06-28 13:11:05 +08:00
SivanCola e4a72782c0 Fix file references in plan revisions 2026-06-28 12:33:03 +08:00
ttmouse c545e6ae26 fix(desktop): include completion tokens in context gauge total (#5283) 2026-06-28 12:10:46 +08:00
SivanCola 5efbb7384f Merge pull request #5454 from ttmouse/fix/bot-tool-approval-live-update
fix(bot): 修改连接工具审批时避免全量重启 BotGateway
2026-06-28 11:25:19 +08:00
SivanCola f54c763538 Fix live bot approval mode updates
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]>
2026-06-28 11:08:17 +08:00
SivanCola 427658bc86 Merge pull request #5453 from SivanCola/feature/external-folder-session-refs
Support session-scoped external folder refs / 支持会话级外部文件夹引用
2026-06-28 09:10:38 +08:00
SivanCola 8a8daad590 Merge main-v2 into external folder refs
# Conflicts:
#	internal/boot/boot.go
#	internal/tool/builtin/glob.go
#	internal/tool/builtin/grep.go
#	internal/tool/builtin/ls.go
#	internal/tool/builtin/readfile.go
#	internal/tool/builtin/workspace.go
2026-06-28 04:26:06 +08:00
SivanCola 1976a217eb feat: let read tools resolve external folder refs 2026-06-28 04:15:32 +08:00
SivanCola a81a46583f feat: clarify directory ref context 2026-06-28 04:01:47 +08:00
SivanCola af3d3805e5 feat: support external folder subpath refs 2026-06-28 03:52:57 +08:00
SivanCola 20e99f6913 Merge pull request #5452 from SivanCola/fix/memory-security-hardening
Fix memory and permission hardening regressions / 修复记忆与权限加固回归
2026-06-28 03:31:02 +08:00
SivanCola 35d2476974 Fix memory and permission hardening regressions 2026-06-28 03:22:28 +08:00
ttmouse 20ccf46d2b fix(bot): 修改连接工具审批时避免全量重启 BotGateway
## 问题

在 Settings → Bot → 连接详情中修改「工具审批」(ask/auto/yolo) 时,
SetBotSettings 会调用 refreshBotRuntimeAsync() 停掉整个 BotGateway
再重建,导致飞书 WebSocket 断连、已有 session 销毁。
此期间任何卡片动作回调都会失败,显示「目标回调服务当前未在线」。

## 修复

新增轻量路径 SetBotConnectionToolApprovalMode:

1. internal/bot/gateway.go — 新增 UpdateConnectionToolApprovalMode,
   直接更新内 ConnectionChannels 的 ToolApprovalMode,
   并通知该连接下所有活跃 session 的 controller 即时生效

2. desktop/bot_runtime_app.go — 新增 updateConnectionToolApprovalMode,
   委托给网关的原地更新方法

3. desktop/settings_app.go — 新增 SetBotConnectionToolApprovalMode API,
   只保存该字段到磁盘 + 原地更新运行中网关,不触发全量重启

4. desktop/frontend — 桥接声明、调用处改为新 API

附带修复:
- desktop/frontend/src/lib/types.ts: normalizeToolApprovalMode
  补充 fallbackMode === 'yolo' 分支(之前只有 auto)

关联 issue: #5424
2026-06-28 03:21:23 +08:00
SivanCola 6a9d1acb8c feat: support session-scoped external folder refs 2026-06-28 03:13:50 +08:00
SivanCola 3ee319d763 Merge pull request #5348 from myipanta/fix/4708-custom-provider-key-collision
fix: generate unique apiKeyEnv for non-ASCII custom provider names
2026-06-28 03:11:42 +08:00
SivanCola 4f1769a05f Unify custom provider key env derivation
Co-authored-by: SivanCola <[email protected]>
2026-06-28 03:04:16 +08:00
SivanCola c9df209e92 Merge pull request #5353 from liugh-dev/fix/backfill-tool-result-name
fix(provider): backfill Name in pairToolResults for existing tool res…
2026-06-28 02:59:10 +08:00
SivanCola aa5ff7bd4b Merge pull request #5359 from yizeyi18/sandbox-nonreading
feat(sandbox): add a `forbid_read` key
2026-06-28 02:56:13 +08:00
SivanCola a11719a643 Improve custom provider key env defaults
Co-authored-by: SivanCola <[email protected]>
2026-06-28 02:52:22 +08:00
SivanCola 759986a138 Merge pull request #5345 from ashishexee/fix/session-stats-telemetry-resume
fix(desktop): load session stats from telemetry on resume (#3450)
2026-06-28 02:51:57 +08:00
SivanCola 009017ed51 fix(provider): route missing tool result names through repair
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]>
2026-06-28 02:51:05 +08:00
SivanCola 995bcf432e Enforce forbid_read across reader tools
Co-authored-by: SivanCola <[email protected]>
2026-06-28 02:40:35 +08:00
SivanCola e8a8a4e120 Fix restored telemetry reset state 2026-06-28 02:37:22 +08:00
SivanCola 5e7cdac33e Merge pull request #5304 from cyq1017/codex/reasonix-5250-config-fallback
fix(config): add Windows home fallback for config discovery
2026-06-28 02:27:57 +08:00
SivanCola 534dcd85d6 Document forbid_read sandbox usage
Co-authored-by: SivanCola <[email protected]>
2026-06-28 02:25:08 +08:00
SivanCola e2b8f08a9c Merge pull request #5288 from YoungDan-hero/dev-6
fix(cli/mcp): prevent command injection via $EDITOR/$VISUAL
2026-06-28 02:22:11 +08:00
SivanCola 2f0cf143bc Merge pull request #5382 from cyq1017/codex/reasonix-5374-collab-mode
fix(desktop): preserve collaboration mode during session rebuild
2026-06-28 02:20:14 +08:00
SivanCola fb320db1f3 Support quoted MCP editor commands
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]>
2026-06-28 02:13:42 +08:00
SivanCola 59ff9af911 Merge pull request #5428 from ttmouse/fix/todo-auto-update-5339
fix(goal): 目标模式完成时待办列表未自动更新
2026-06-28 02:05:00 +08:00
SivanCola 0b12cf418a Merge pull request #5426 from 183965983/fix/paste-expand-interject-queue
fix(tui): expand paste placeholders before queuing interject during r…
2026-06-28 01:54:28 +08:00
SivanCola fde0c93879 test(tui): cover queued folded paste expansion 2026-06-28 01:46:53 +08:00
SivanCola d6a3cb7c38 fix(goal): restore completed todos on resume
Co-authored-by: SivanCola <[email protected]>
2026-06-28 01:46:18 +08:00
SivanCola ad05ecc9b7 Merge pull request #5442 from GTC2080/GTC/fix-topic-trash-placeholder
fix(desktop): include placeholder sessions in topic trash
2026-06-28 01:37:39 +08:00
SivanCola 76bc768da5 Merge pull request #5392 from cyq1017/codex/reasonix-5371-stdio-timeout
fix(plugin): make stdio call timeout configurable
2026-06-28 01:36:32 +08:00
SivanCola e5b6e08471 fix(boot): remove unused plugin spec helper
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]>
2026-06-28 01:30:57 +08:00
SivanCola ed818c1b05 feat(plugin): unify MCP call timeouts
Co-authored-by: SivanCola <[email protected]>
2026-06-28 01:09:47 +08:00
GTC2080 2eb11f8c70 fix(desktop): include placeholder sessions in topic trash 2026-06-28 02:34:42 +10:00
ttmouse 0993db1078 fix(goal): auto-complete remaining todos when goal finishes (#5339)
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
2026-06-27 19:11:35 +08:00
jitzandClaude Opus 4.6 7c8d679c7c fix(tui): expand paste placeholders before queuing interject during reasoning
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]>
2026-06-27 17:58:25 +08:00
YHH 3e824fc319 Merge pull request #5420 from esengine/feat/accounts
feat(accounts): email/password account service worker (id.reasonix.io)
2026-06-27 16:58:37 +08:00
esengine eb1500ef8f feat(accounts): add email/password account service worker
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).
2026-06-27 01:52:51 -07:00
SivanCola 8ceb23fb6e Merge pull request #5388 from ttmouse/pr/unify-topic-title
fix(desktop): 统一历史记录/搜索面板的对话标题与左侧边栏一致
2026-06-27 14:37:39 +08:00
cyq 23ea7aa2e5 Merge remote-tracking branch 'origin/main-v2' into codex/reasonix-5371-stdio-timeout
# Conflicts:
#	docs/SPEC.md
#	internal/config/mcpjson_test.go
#	internal/config/render.go
#	reasonix.example.toml
2026-06-27 14:14:08 +08:00
ttmouseandSivanCola fe0680a3ce fix(desktop): show session context in Cmd+K
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]>
2026-06-27 12:52:31 +08:00
SivanCola aa1f951797 Merge origin/main-v2 into pr/unify-topic-title 2026-06-27 11:37:12 +08:00
ttmouseandSivanCola 63d12145bc fix(desktop): lock session title search contracts
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]>
2026-06-27 11:32:39 +08:00
SivanCola 57e80a942f Merge pull request #5409 from SivanCola/fix/desktop-session-hydration-mcp-cache
Fix desktop session hydration latency / 修复桌面会话加载延迟
2026-06-27 11:22:45 +08:00
SivanCola 80130b0aed Fix desktop session hydration latency 2026-06-27 11:10:20 +08:00
SivanCola 33b3a4a6bf Merge pull request #5401 from SivanCola/fix/mcp-read-only-trust-approval
Confirm MCP read-only trust on first use / 首次使用时确认 MCP 只读信任
2026-06-27 11:08:38 +08:00
YHH 0b6f5c9d0f Merge pull request #5407 from esengine/fix/plan-mode-readonly-bash-unify
fix(planmode): allow the full read-only bash command set while planning (#5341)
2026-06-27 09:58:04 +08:00
esengine 0b80deec92 fix(planmode): allow the full read-only bash command set while planning (#5341)
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.
2026-06-26 18:51:42 -07:00
YHH 70da2df7fc Merge pull request #5406 from esengine/fix/desktop-sink-context-stale-events
fix(desktop): clear queued events + lock ctx on sink teardown (#5352)
2026-06-27 09:23:58 +08:00
esengine 4fda67d03c fix(desktop): clear queued events + lock ctx on sink teardown (#5352)
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.
2026-06-26 18:18:52 -07:00
YHH 950bba82a9 Merge pull request #5404 from esengine/fix/desktop-tab-switch-coalescing
fix(desktop): coalesce tab-bar switches through the navigation scheduler (#5352)
2026-06-27 09:05:05 +08:00
esengine 3fe39f7c47 fix(desktop): coalesce tab-bar switches through the navigation scheduler (#5352)
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.
2026-06-26 17:59:12 -07:00
SivanCola 4204d61212 Keep MCP trust approvals fresh 2026-06-27 04:46:37 +08:00
SivanCola 45d8859075 Fix MCP read-only trust approvals
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.
2026-06-27 04:24:08 +08:00
SivanCola 615544e2b4 Merge pull request #5272 from onenewcode/fix/5175-image-input-text-only-model
fix : image input gating by model capability
2026-06-27 03:58:37 +08:00
SivanCola 86aa67279e Merge pull request #5400 from SivanCola/fix/tui-scroll-interactions
Fix TUI transcript scroll interactions / 修复 TUI 对话滚动交互
2026-06-27 03:56:08 +08:00
SivanCola 6b788b43fb Merge latest main-v2 into image input gating PR 2026-06-27 03:53:46 +08:00
SivanCola 9008ab1a03 Merge pull request #5349 from liugh-dev/feat/copy-export-commands
feat(tui): add /copy and /export commands
2026-06-27 03:53:29 +08:00
SivanCola 5d7651430f Merge pull request #5347 from liugh-dev/fix/cli-paste-memory-compiler-source-event
fix(cli): folded paste content was invisible to the model
2026-06-27 03:49:40 +08:00
SivanCola 6641a92f11 fix(tui): harden transcript scroll interactions 2026-06-27 03:49:37 +08:00
SivanCola 7da9b0acd0 Allow text models to keep tool-readable images 2026-06-27 03:43:10 +08:00
SivanCola dde60a4246 fix(tui): skip empty session exports
Avoid writing markdown files when /export has no visible user or assistant content after filtering.
2026-06-27 03:43:03 +08:00
SivanCola f6b7b429d9 test(cli): wait for folded paste turn completion
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.
2026-06-27 03:42:28 +08:00
SivanCola 63575c9e06 fix(tui): keep exported markdown display-safe
Strip referenced-context payloads from exported user messages and cover /copy and /export slash command behavior.
2026-06-27 03:33:07 +08:00
SivanCola d9dd4ab55c Merge pull request #5399 from SivanCola/fix/desktop-rewind-checkpoint-turns
Fix desktop rewind checkpoint targeting / 修复桌面端回溯 checkpoint 定位
2026-06-27 03:31:54 +08:00
SivanCola b03f7f6ebf Merge latest main-v2 into image input gating PR 2026-06-27 03:27:47 +08:00
SivanCola fef4c09ef7 fix(desktop): align rewind targets with checkpoints 2026-06-27 03:26:34 +08:00
SivanCola f6500d8782 Merge main-v2 into image input gating PR 2026-06-27 03:24:18 +08:00
SivanCola 5c9209b1b2 Merge pull request #5398 from SivanCola/fix/code-copy-button-stable
Fix code block copy button positioning / 修复代码块复制按钮定位
2026-06-27 03:22:44 +08:00
SivanCola 04abf2469d Merge pull request #5378 from yekern/fix/tui-restore-mouse-wheel
fix(tui): restore mouse wheel scrolling in transcript viewport
2026-06-27 03:22:37 +08:00
SivanCola 61ae3b77b9 Fix code block copy button positioning 2026-06-27 03:12:38 +08:00
SivanCola 85fd0a2417 Merge pull request #5396 from ttmouse/fix/heartbeat-new-conversation-btn
fix(heartbeat): 每次新建对话后更新 topicId 到最新
2026-06-27 02:59:21 +08:00
SivanCola 2ae7d211ab test(heartbeat): cover fresh topic persistence
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]>
2026-06-27 02:54:50 +08:00
SivanCola 6e4e2ed348 Merge pull request #5397 from SivanCola/fix/desktop-navigation-coalescing
Fix desktop navigation coalescing / 修复桌面导航合并竞态
2026-06-27 02:42:51 +08:00
SivanCola 7754911ac1 fix(desktop): coalesce session navigation 2026-06-27 02:37:48 +08:00
SivanCola a015f7fea4 Merge pull request #5395 from SivanCola/fix/memory-v5-boundary-guardrails
Guard Memory v5 synthetic boundaries / 加固 Memory v5 合成边界守卫
2026-06-27 02:30:47 +08:00
SivanCola 1b6868ab92 fix(memory-v5): guard synthetic and replay boundaries 2026-06-27 02:20:26 +08:00
SivanCola 8fcf663b3a Merge pull request #5393 from SivanCola/fix/memory-v5-synthetic-turn-contract
Harden synthetic Memory v5 turns / 加固 Memory v5 合成轮次标记
2026-06-27 02:07:14 +08:00
SivanCola 96518c298c Merge pull request #5394 from SivanCola/feature/explicit-legacy-session-import
Import legacy sessions from explicit paths / 支持从指定旧目录导入历史会话
2026-06-27 02:05:05 +08:00
ttmouse 217e01126a fix(heartbeat): honor NewConversationEachRun flag when creating topics
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.
2026-06-27 02:04:58 +08:00
SivanCola 08177734e3 Merge pull request #5355 from GTC2080/GTC/fix-5306-desktop-lag
fix(desktop): paginate restored transcript warm turns
2026-06-27 02:03:25 +08:00
SivanCola 6f059b850a feat(migration): import legacy sessions from explicit roots 2026-06-27 01:59:16 +08:00
SivanCola cef150d8fe fix(control): mark synthetic turns before Memory v5 2026-06-27 01:54:53 +08:00
GTC2080andSivanCola 5ccacbc5ee fix(desktop): reveal warm turns before jump navigation
Co-authored-by: SivanCola <[email protected]>
2026-06-27 01:52:35 +08:00
GTC2080andSivanCola 667451bcb2 fix(desktop): reset warm transcript pagination per session
Co-authored-by: SivanCola <[email protected]>
2026-06-27 01:37:20 +08:00
SivanCola 51a152d7cc Merge pull request #5391 from SivanCola/fix/approved-plan-execution-after-approval
Fix approved-plan and Memory v5 visible noise / 修复计划批准与 Memory v5 可见噪音
2026-06-27 01:35:49 +08:00
SivanCola 93451f819d fix(desktop): clear approved-plan and memory citation noise 2026-06-27 01:27:18 +08:00
cyq 4ecd3080f8 fix(plugin): make stdio call timeout configurable 2026-06-27 01:13:18 +08:00
YHH 84e2daf4f4 Merge pull request #5390 from esengine/fix/memory-v5-display-unwrap-nested
fix(memory-v5): render corrupted/accreted contracts cleanly on session switch (#5361)
2026-06-27 00:37:58 +08:00
esengine 4411b5d486 fix(memory-v5): render corrupted/accreted contracts cleanly on session switch (#5361)
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.
2026-06-26 09:33:06 -07:00
ttmouse 9ae6bf8ba3 fix(desktop): 回应 Codex review — 添加 Cmd+K keywords + 优先显示会话重命名标题 2026-06-27 00:28:12 +08:00
YHH 74b33fbfda Merge pull request #5387 from esengine/fix/memory-v5-synthetic-turn-loop
fix(memory-v5): stop synthetic turns from spinning the goal loop (#5342, #5329)
2026-06-26 23:59:17 +08:00
ttmouse ed9d9b91e1 fix(desktop): 统一历史记录/搜索面板的对话标题与左侧边栏一致
问题:HistoryPanel 中 sessionDisplayTitle 使用 s.title || s.preview
作为主标题(用户重命名或首条消息预览),而左侧边栏展示的是
node.label(即 topicTitle),两者完全不同。

修复:
- sessionDisplayTitle 优先使用 s.topicTitle,与左侧边栏统一
- sessionLocation 中去掉冗余的 s.topicTitle 显示(已作为主标题展示)
2026-06-26 23:55:22 +08:00
esengine 02e05b6d76 fix(memory-v5): stop synthetic turns from spinning the goal loop (#5342, #5329)
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).
2026-06-26 08:52:09 -07:00
YHH 369bfda9af Merge pull request #5386 from esengine/refactor/memory-v5-p4-control-heuristic
refactor(memorycompiler): replace control-theory packages with a heuristic
2026-06-26 23:38:49 +08:00
esengine 34b5da781e refactor(memorycompiler): replace control-theory packages with a heuristic
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.
2026-06-26 08:33:18 -07:00
YHH 8c7a32a361 Merge pull request #5383 from esengine/refactor/memory-v5-p2-drop-runtime-os
refactor(memorycompiler): drop the unused production-hardening runtime-OS
2026-06-26 23:12:23 +08:00
esengine 7f1c94372e refactor(memorycompiler): drop functions orphaned by runtime-OS removal
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.
2026-06-26 08:08:09 -07:00
YHH f0a57255fc Merge pull request #5381 from esengine/refactor/memory-v5-p3-compact-contract
perf(memorycompiler): compact the per-turn execution contract
2026-06-26 23:06:09 +08:00
YHH 23cdda5e0b Merge pull request #5380 from esengine/refactor/memory-v5-p1-dead-code
refactor(system): remove dead internal/system architecture-lock package
2026-06-26 23:05:57 +08:00
esengine 2af3af95a3 refactor(memorycompiler): drop the unused production-hardening runtime-OS
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.
2026-06-26 07:54:10 -07:00
cyq 28ff9a4887 Preserve collaboration mode during session rebuild 2026-06-26 22:29:31 +08:00
esengine 12c3aac637 perf(memorycompiler): compact the per-turn execution contract
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).
2026-06-26 07:24:58 -07:00
esengine 0536f049ab refactor(system): remove dead internal/system architecture-lock package
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.
2026-06-26 06:55:06 -07:00
maple 8a34a55cfc fix(tui): restore mouse wheel scrolling in transcript viewport
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.
2026-06-26 20:37:24 +08:00
yizeyi18 cb81cb5a6d Adjust darwin test from detecting /etc/ssh to /home/user/.ssh. 2026-06-26 15:41:25 +08:00
yizeyi18 ee975ed517 run go fmt 2026-06-26 15:35:47 +08:00
YI Zeping 55f259646f Merge branch 'main-v2' into sandbox-nonreading 2026-06-26 15:32:42 +08:00
yizeyi18 41f3780bd0 modify tests 2026-06-26 15:12:24 +08:00
GTC2080 bf432d905b fix(desktop): paginate restored transcript warm turns 2026-06-26 17:03:45 +10:00
liuguohua 07502b7f78 fix(provider): backfill Name in pairToolResults for existing tool results
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.
2026-06-26 06:24:16 +00:00
liuguohua 10309ab4c4 feat(tui): add /copy and /export commands
- `/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.
2026-06-26 06:02:11 +00:00
jiangrenhang cb8c6b63b0 fix: generate unique apiKeyEnv for non-ASCII custom provider names
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
2026-06-26 13:58:06 +08:00
liuguohuaandClaude 7aa41f537b fix(cli): folded paste content was invisible to the model
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]>
2026-06-26 05:47:41 +00:00
ashishexee baa8cbaa05 fix(desktop): load session stats from telemetry on resume (#3450) 2026-06-26 10:19:56 +05:30
SivanCola 5dac4f6d67 Merge pull request #5321 from SivanCola/fix/mcp-trust-controls
feat(desktop): add explicit MCP trust controls / feat(desktop): 添加显式 MCP 信任控制
2026-06-26 03:47:19 +08:00
SivanCola 0526a88dad Add cache-invariant regression for read-only trust
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.
2026-06-26 00:43:53 +08:00
SivanCola cc2019b5cc Merge pull request #5285 from YoungDan-hero/dev-5
fix(serve/auth): handle crypto/rand.Read error in signSession
2026-06-26 00:37:54 +08:00
SivanCola f2ea581a47 feat(desktop): add explicit MCP trust controls 2026-06-26 00:29:42 +08:00
SivanCola 0d54d0f374 Merge pull request #5307 from ttmouse/pr/strip-memory-compiler-upstream
fix: strip memory-compiler-execution block from user message display
2026-06-26 00:24:46 +08:00
SivanCola d363773727 Preserve preview text for compiled memory turns
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]>
2026-06-26 00:19:51 +08:00
SivanCola 3370155d13 Add regression for stripping memory-compiler-execution block
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]>
2026-06-26 00:12:06 +08:00
SivanCola 7056884afc Merge pull request #5303 from myipanta/fix/5286-interrupted-task-re-execution
fix(control): strip incomplete turn messages after user cancel (#5286)
2026-06-26 00:04:29 +08:00
SivanCola 65f18ea656 fix(control): reset todoState and flush transcript after cancel strip
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]>
2026-06-25 23:58:22 +08:00
SivanCola 91395b769d Merge pull request #5313 from SivanCola/fix/desktop-topic-navigation-coalescing
Fix desktop topic navigation coalescing / 修复桌面话题导航合并竞态
2026-06-25 23:34:51 +08:00
SivanCola 2f606fad14 fix(desktop): coalesce topic navigation 2026-06-25 23:11:45 +08:00
SivanCola 5afceb9356 Merge pull request #5308 from leslie3747/main-v2
fix(bot): run turn off dispatch goroutine so approval replies arent deadlocked
2026-06-25 22:44:41 +08:00
SivanCola ca421bca5f Add regression guarding approval reply unblocks blocked turn
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.
2026-06-25 22:35:29 +08:00
TUF-User 216da47c47 fix(bot): run turn off dispatch goroutine so approval replies arent deadlocked 2026-06-25 14:08:15 +00:00
ttmouse 326cfc6ce4 refactor: move memory compiler indicator to msg-meta as disabled-style icon
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.
2026-06-25 21:56:53 +08:00
SivanCola ec82916e33 Merge pull request #5301 from meteor614/fix/direct-osc52-clipboard
fix: use OSC 52 directly for clipboard instead of platform-tool-then-…
2026-06-25 21:42:18 +08:00
ttmouse 029b717ba3 refactor: replace interactive memory icon with pure display badge
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.
2026-06-25 21:35:10 +08:00
SivanCola 54922b9760 docs: clarify OSC 52 clipboard behavior
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]>
2026-06-25 21:27:53 +08:00
ttmouse 22317b8cdf feat: make memory compiler icon interactive — click to toggle raw text
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.
2026-06-25 21:26:46 +08:00
cyq d8c4f2f07a Fix Windows config discovery fallback 2026-06-25 21:14:20 +08:00
ttmouse f60ebbe231 feat: add Memory v5 compiler indicator icon to user message meta
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.
2026-06-25 21:07:21 +08:00
ttmouse 28c6ae72f3 fix: strip memory-compiler-execution block from user message display
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.
2026-06-25 21:00:06 +08:00
jiangrenhang 66dadfc3a0 fix(control): strip incomplete turn messages after user cancel (#5286)
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.
2026-06-25 20:08:46 +08:00
meteorchen 256a62fd40 fix: use OSC 52 directly for clipboard instead of platform-tool-then-fallback
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
2026-06-25 19:26:00 +08:00
Sivan d4aadbbda5 docs(serve/auth): correct signSession nonce-panic comment
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.
2026-06-25 18:34:51 +08:00
SivanCola fb364bef87 Merge pull request #5115 from GTC2080/GTC/fix-desktop-command-wire-contract
Fix desktop command failure feedback / 修复桌面命令失败反馈
2026-06-25 18:23:21 +08:00
Sivan 9af091209e fix: surface desktop command failures 2026-06-25 18:10:39 +08:00
SivanCola dbdc2482b4 Merge pull request #5297 from SivanCola/fix/5287-planner-history-integration
fix(desktop): preserve Planner transcript across settings reload (integrates #5293 + #5295) / 修复设置重载丢失 Planner 历史(集成 #5293 + #5295)
2026-06-25 18:05:10 +08:00
SivanCola ba8ccc0085 Merge pull request #5119 from GTC2080/GTC/fix-desktop-visible-errors
Fix desktop visible error states / 修复桌面可见错误状态
2026-06-25 18:02:42 +08:00
Sivan e3a7ad9a73 Merge main-v2 into desktop visible error states 2026-06-25 17:57:23 +08:00
Deng Bushi 邓步实 1908 135abaaa9f fix(desktop): persist planner display history across reload 2026-06-25 17:56:38 +08:00
GTC2080 d7783747e7 fix(desktop): preserve transcript on settings reload 2026-06-25 17:56:38 +08:00
SivanCola 5a60b74c2a Merge pull request #5291 from SivanCola/fix/desktop-language-replies
fix(desktop): honor desktop language for replies / 修复桌面语言回复偏好
2026-06-25 17:45:53 +08:00
Sivan 4debe25ef4 fix(desktop): honor desktop language for replies 2026-06-25 16:36:20 +08:00
SivanCola e11741c13a Merge pull request #5124 from GTC2080/GTC/fix-desktop-p0-stability
Fix desktop session identity and cancel recovery
2026-06-25 16:18:38 +08:00
SivanCola e3a7d38f1e Merge pull request #5289 from SivanCola/fix/5128-signoff-recovery-integration
Integrate #5128 signoff recovery fixes / 整合 #5128 签退恢复修复
2026-06-25 16:18:12 +08:00
SivanCola 43f42a17eb Merge pull request #5290 from SivanCola/fix/creation-overview-integration
fix(desktop): stabilize creation overview dock / 修复创作概览右侧面板布局与重开渲染
2026-06-25 16:17:36 +08:00
Sivan c1f8fe5e17 Merge remote-tracking branch 'origin/main-v2' into GTC/fix-desktop-p0-stability
# Conflicts:
#	desktop/frontend/package.json
2026-06-25 16:13:53 +08:00
Sivan a5300f997b fix(desktop): settle cancel recovery and session rotation 2026-06-25 16:09:57 +08:00
Sivan 845f883d89 fix(evidence): drop stale attempted signoff helper 2026-06-25 16:08:55 +08:00
Sivan 5da004b09c fix(desktop): stabilize creation overview dock 2026-06-25 16:08:53 +08:00
Sivan a29db659e8 fix(agent): reconcile signoff recovery integration 2026-06-25 16:00:09 +08:00
Sivan 33f50d4bba fix(evidence): tighten failed signoff recovery
(cherry picked from commit e1c72dbe31)
2026-06-25 15:55:46 +08:00
GTC2080 1be1b579e1 fix(agent): recover todo completion after failed signoff
(cherry picked from commit 0d95872aba)
2026-06-25 15:55:36 +08:00
jiangrenhang 153f0d0cdb fix: prevent deadlock between complete_step and todo_write (#5128)
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)
2026-06-25 15:54:42 +08:00
SivanCola 6ddb595366 Merge pull request #5275 from GTC2080/GTC/fix-5229-linux-webkit-software-rendering
fix(desktop): disable Linux webview GPU without DRI
2026-06-25 15:28:35 +08:00
luffy 8596273b24 fix(cli/mcp): prevent command injection via $EDITOR/$VISUAL
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.
2026-06-25 15:26:31 +08:00
SivanCola d9374a4015 Merge pull request #3695 from taibai233/feature/guardian-subagent
新增 Guardian Subagent
2026-06-25 15:21:47 +08:00
SivanCola 736ba3ad75 Merge pull request #5282 from manza7014/fix/stop-hook-cancelled-ctx
fix: Stop hook 使用 context.Background() 替代已取消的 ctx
2026-06-25 15:08:00 +08:00
Sivan 80d033fa80 guardian: port safety reviewer to current main 2026-06-25 15:00:15 +08:00
yongfeng.li c4a11bf379 fix: gofmt turn_orchestrator_test.go 2026-06-25 14:58:35 +08:00
yongfeng.li 7abf3e72fb test: verify Stop hook receives non-cancelled context on cancellation
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.
2026-06-25 14:54:39 +08:00
Sivan e50755589b test: cover Stop hook cancellation contexts 2026-06-25 14:53:07 +08:00
luffy 2495efc23e fix(serve/auth): handle crypto/rand.Read error in signSession
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.
2026-06-25 14:48:27 +08:00
yongfeng.li 89d4c20a0a fix: also use context.Background() for Stop hook in Controller.Run path
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.
2026-06-25 14:46:42 +08:00
qiukeren 8d5c0cbc53 feat(i18n): add MB unit to download progress display
Show 'MB' after the numeric values in the updater's downloading status text across all three locales (en, zh, zh-TW).
2026-06-25 14:43:29 +08:00
SivanCola 3167eaa54f Merge pull request #5273 from SivanCola/fix/mode-toggle-flap-pr
Preserve auto approval mode across partial tab snapshots / 保持自动审批模式不被快照降级
2026-06-25 14:39:48 +08:00
Sivan 8eeb379910 fix(frontend): tolerate missing generated wails bindings 2026-06-25 14:33:15 +08:00
yongfeng.li 506a5f48db fix: use context.Background() for Stop hook on cancellation
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
2026-06-25 14:32:57 +08:00
Sivan 15552a3c1d fix(desktop): use global defaults for new sessions 2026-06-25 14:27:59 +08:00
GTC2080 6a0b5a94a6 fix(desktop): disable Linux webview GPU without DRI 2026-06-25 14:39:49 +10:00
Sivan 5d7f726096 fix(frontend): preserve auto mode in browser mock 2026-06-25 12:22:18 +08:00
Sivan ec81fd702e fix(frontend): preserve auto approval mode across partial tab snapshots 2026-06-25 12:10:14 +08:00
SivanCola 6bbb66efe7 Merge pull request #5262 from lifu963/pr/subagent-cancel-contract
Fix subagent cancellation contract
2026-06-25 11:55:17 +08:00
SivanCola 105ff37131 Fix canceled closed stream handling 2026-06-25 11:41:51 +08:00
SivanCola a430ea8900 Merge pull request #5248 from ttmouse/pr/fix-chat-switch-flash
fix: 消除侧边栏切换对话时 Welcome 闪现
2026-06-25 11:19:28 +08:00
onenewcode 1b2a465923 gate image input by model capability 2026-06-25 10:59:54 +08:00
Sivan 29577e091e Merge remote-tracking branch 'origin/main-v2' into HEAD
# Conflicts:
#	desktop/frontend/src/App.tsx
2026-06-25 10:59:29 +08:00
Sivan c103c2cd05 Address hydration bot review findings 2026-06-25 10:57:32 +08:00
Sivan 06a440de75 Separate hydration placeholders from transcript state 2026-06-25 10:49:55 +08:00
SivanCola 0416e312ff Merge pull request #5244 from ttmouse/fix/cmd-shortcuts-allow-input-content
输入框有内容时 Cmd+1..9 快捷键仍可切换对话
2026-06-25 10:45:59 +08:00
lifu963 3818c6cafc Merge origin/main-v2 into subagent cancellation PR 2026-06-25 10:43:26 +08:00
Sivan 2ba4ce8821 test: cover topic shortcuts inside editable fields
Add regression coverage for Cmd/Ctrl+1..9 while focus stays in input and textarea elements.
2026-06-25 10:40:46 +08:00
SivanCola b193d426b3 Merge pull request #5217 from esengine/feature/memory-v5-compiler
Add Memory v5 execution compiler / 新增 Memory v5 执行记忆编译器
2026-06-25 04:06:40 +08:00
SivanCola 5b78ad1889 Merge main-v2 into Memory v5 compiler PR 2026-06-25 04:01:51 +08:00
SivanCola 75a32c5331 Add CLI Memory v5 toggle commands 2026-06-25 03:50:22 +08:00
SivanCola 1b04e2ff72 Report Memory v5 in CLI run metrics 2026-06-25 03:35:08 +08:00
SivanCola a93440ff3e Document Memory v5 usage and metrics 2026-06-25 03:27:43 +08:00
SivanCola 54215902fd Adapt Memory v5 metrics for frontend event flow 2026-06-25 03:16:53 +08:00
SivanCola 2f2d0194c6 Report Memory v5 aggregate usage metrics 2026-06-25 03:08:54 +08:00
SivanCola 5dd5efce96 Fix Memory v5 bot review regressions 2026-06-25 02:40:35 +08:00
SivanCola ce3c5103fd Merge pull request #5246 from lifu963/pr/plan-mode-policy
Centralize plan mode policy
2026-06-25 02:30:20 +08:00
SivanCola b1326ef41e Fix plan mode review regressions
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]>
2026-06-25 02:20:21 +08:00
SivanColaandClaude Opus 4.8 5ae787efd2 Fix four Memory v5 review issues (no-tool outcome, goal context, reservation, weight cap)
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]>
2026-06-25 02:01:22 +08:00
SivanCola 948cd3e176 Merge pull request #5265 from esengine/fix/cli-i18n-test-isolation
Fix two locale/load-dependent test-isolation bugs (cli i18n, bash login-shell probe)
2026-06-25 01:57:35 +08:00
SivanColaandClaude Opus 4.8 e3b4d2a1a9 Distrust MCP readOnlyHint in plan mode (review fix)
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]>
2026-06-25 01:56:45 +08:00
SivanColaandClaude Opus 4.8 a8c91c4c11 Fix TestBashMergesLoginShellPath flaking under full-suite load
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]>
2026-06-25 01:14:58 +08:00
SivanColaandClaude Opus 4.8 0456f97f64 Fix cli tests leaking host locale into global i18n state
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]>
2026-06-25 01:14:57 +08:00
SivanCola 5873ebee69 Merge pull request #5064 from Zhuocheng-Lang/fix/nvidia-wayland-explicit-sync
fix(desktop): apply NVIDIA Wayland explicit-sync workaround for WebKitGTK
2026-06-25 01:12:59 +08:00
SivanColaandClaude Opus 4.8 905977386e Harden plan-mode gate with moderate fail-closed boundary
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]>
2026-06-25 01:04:18 +08:00
SivanColaandClaude Opus 4.8 237bdbddfb Fix Memory v5 hardening gate that disabled compiler and demoted successes
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]>
2026-06-25 00:38:44 +08:00
SivanColaandSivanCola a0dcd1f90e add read-only skill delegation
Co-authored-by: SivanCola <[email protected]>
2026-06-24 23:38:57 +08:00
SivanCola d4499008a7 Merge pull request #5257 from SivanCola/fix/session-trash-consolidation
Fix session trash fallback conflicts / 修复会话回收站与回退会话冲突
2026-06-24 23:35:07 +08:00
SivanCola d75a9d8d52 block source connector in read-only subagents
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]>
2026-06-24 23:24:24 +08:00
SivanCola b3130ec1fd Remove unused topic index helper 2026-06-24 23:23:13 +08:00
lifu963 ec0cbda3a9 Merge subagent cancellation contract implementation 2026-06-24 23:20:41 +08:00
Sivan be7c7efb4b Freeze Memory v5 stable release boundary 2026-06-24 23:16:05 +08:00
SivanCola f3cdb9d3e1 Fix blank session trash lifecycle 2026-06-24 23:14:52 +08:00
SivanCola d87ddb54b7 add read-only plan-mode subagent
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]>
2026-06-24 23:11:43 +08:00
Sivan 89b9a296a1 Add Memory v6 layer collapse analyzer 2026-06-24 23:00:40 +08:00
lifu963 dcc82866c7 Preserve completed parallel results on cancel
Drain already-finished child results before marking remaining parallel_tasks work cancelled so partial aggregates do not lose completed output during cancellation races.
2026-06-24 22:59:29 +08:00
lifu963 1ba4dbd26a Fix provider chunk sends on cancellation
Make OpenAI-compatible and Anthropic stream producers stop blocking when the agent abandons chunk reads while still delivering cancellation errors to active readers.
2026-06-24 22:58:38 +08:00
lifu963 9d3be13f83 Fix parallel subagent cancellation aggregation
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.
2026-06-24 22:56:01 +08:00
lifu963 0c96d3c863 Test subagent entrypoint cancellation
Cover task and runAs=subagent skill cancellation wiring so future changes keep parent cancellation observable through both entrypoints.
2026-06-24 22:52:39 +08:00
lifu963 cda3954506 Fix stream cancellation on stuck providers
Return promptly when the provider stream stops producing chunks so parent turns and subagents are not held by a non-cooperative backend.
2026-06-24 22:51:31 +08:00
Sivan 1349b9d8ab Guard Memory v5 predictive bias 2026-06-24 22:45:59 +08:00
SivanCola 9dc723c9f6 Fix session trash fallback conflicts 2026-06-24 22:41:37 +08:00
Sivan 4b54f5e414 Bridge Memory v5 predictive advisories 2026-06-24 22:35:40 +08:00
SivanCola a135b0ca81 Merge pull request #5160 from GTC2080/GTC/fix-tui-mouse-cell-motion
fix(tui): preserve native terminal selection
2026-06-24 22:31:05 +08:00
SivanCola 234e624e18 Fix collapsed shell hint mouse copy
Co-authored-by: SivanCola <[email protected]>
2026-06-24 22:22:51 +08:00
Sivan d4b97d543f Add Memory v5 early warning observer 2026-06-24 22:10:41 +08:00
SivanCola bd6ec239c9 Merge pull request #5243 from ttmouse/feat/heartbeat-new-session
feat: 心跳任务新增「每次新建对话」选项
2026-06-24 22:07:46 +08:00
SivanCola 1b910f84a3 Fix heartbeat fresh conversation overlap
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]>
2026-06-24 22:04:01 +08:00
Sivan 6d79fe9086 Decouple Memory v5 observer loop 2026-06-24 21:50:07 +08:00
Sivan dde3588065 Monitor Memory v5 causal signal dynamics 2026-06-24 21:32:17 +08:00
Sivan 77cf4c83b8 Correct Memory v5 compression bias 2026-06-24 21:21:34 +08:00
Sivan f6aca44764 Compress Memory v5 causal traces 2026-06-24 20:58:27 +08:00
ttmouse b469f9e707 fix: 修复 Codex review 第2轮反馈
1. resumeSession 路径也去掉 if (messages.length) 守卫
2. 新增 3 条原则沉淀到 pr-review 技能:
   - clear-placeholder-after-reset
   - always-dispatch-completing-action
   - fast-path-before-slow-path

同步更新 SKILL.md 原则计数 56→59
2026-06-24 20:45:54 +08:00
ttmouse 8b1b01e351 fix: 修复 Codex review 指出的 3 个问题
1. P1: clearSession 后 reset 保留旧 items,需 dispatch 空 history
   清除占位(dispatchTo(tabId, {type: 'history', messages: []}))
2. P1: 空 HistoryForTab 跳过 dispatch 导致旧 items 残留,
   去掉 if (messages.length) 守卫,始终 dispatch
3. P2: BalanceForTab 有网络调用,同一批 Promise.all 阻塞了
   history 渲染。拆为两阶段:先 dispatch meta+history(快),
   再 batch dispatch 其余(checkpoints/context/balance/effort/jobs)
2026-06-24 20:42:26 +08:00
ttmouse 8c2512cc1d fix: 经典模式下 openTopicSession/openProjectTab/openGlobalTab 也缺 prevItems 迁移
这三个函数在单画布模式(classic)下用于打开新标签页,
但缺少 prevItems 迁移逻辑。当 reset=true 时,新标签页的
items 为[],reset 保留不了占位内容,导致 Welcome 闪现。
修复:与 activateTopic 同样的 prevItems 保存+迁移逻辑。
2026-06-24 20:24:16 +08:00
Sivan b9f6442085 Harden Memory v5.9 causal safety 2026-06-24 20:17:43 +08:00
ttmouse 2940ad690e perf: onResumeSession 同样改为 fire-and-forget refreshTabMetas 2026-06-24 19:49:16 +08:00
ttmouse 3967a7160e perf: refreshTabMetas 改为 fire-and-forget,Transcript 不阻塞于侧边栏刷新
handleOpenTopic 和 onResumeSession 中 await refreshTabMetas()
阻塞了 Transcript 的 revealSignal 触发,导致用户感知延迟。
改为 void refreshTabMetas() 后,setTranscriptRevealSignal
立即执行,Transcript 可以在数据加载完成后就刷新,不必等待
侧边栏/顶部栏的 IPC 完成。
2026-06-24 19:49:10 +08:00
ttmouse b8637e133b Revert "perf: 合并 9 次 IPC 为单次 HydrateTopic 调用,切换会话快 3x"
This reverts commit 528c997cd2.
2026-06-24 19:48:19 +08:00
ttmouse 528c997cd2 perf: 合并 9 次 IPC 为单次 HydrateTopic 调用,切换会话快 3x
之前每次切换会话需要 9 次 IPC 往返:
  ActivateTopic → MetaForTab → HistoryForTab → CheckpointsForTab
  → ContextUsageForTab → BalanceForTab → EffortForTab → JobsForTab
  → ListTabs (refreshTabMetas)

每次 IPC 有 ~2-5ms 的序列化+传输开销,累积延迟明显。

现在 Go 后端新增 HydrateTopic 方法,一次返回全部数据:
1. tabs.go: 新增 HydrateTopicData 结构体 + HydrateTopic 方法
2. useController.ts: activateTopic 改用 app.HydrateTopic,
   一次性 dispatch 所有数据(无 reset、无 loadSessionDataForTab)
3. App.tsx: handleOpenTopic/onResumeSession 中 refreshTabMetas
   改为 fire-and-forget(不再 await)
4. types.ts + bridge.ts: 新增 HydrateTopicData 类型定义和绑定
2026-06-24 19:46:02 +08:00
ttmouse 0ec3d67eeb 允许输入框有内容时 Cmd+1..9 快捷键仍可切换对话
移除 topicShortcutIndexFromEvent 中的 isEditableTarget 守卫,
使 Cmd/Ctrl+1..9 在焦点位于输入框/文本区时也能触发对话切换。
这些是应用级导航快捷键,不是编辑器标准键(如 Cmd+C/V/Z),
移除守卫不会影响文本编辑能力。行为与 CodeX 一致。
2026-06-24 19:28:00 +08:00
lifu963 48f06c4276 assert economy source blocking behavior directly 2026-06-24 19:20:13 +08:00
ttmouse 0d884e0b79 fix: CR 反馈 — executeTask 重试保护 + 移除 min-height
根据 Codex Review (P2) 修复:

1. 【P2】executeTask: NewConversationEachRun 模式下用 pendingTopics
   内存 map 追踪临时 topicID,失败重试时复用同一 topic 而非
   反复新建;成功提交后清理,下次执行新建
   (评论: 'Reuse the pending topic when a run is retried')

2. 【P2】executeTask: 对于 NewConversationEachRun,现有 topic
   的 controller busy check 通过 openTab + 等待 controller 完成,
   防止重叠执行
   (评论: 'Prevent overlapping fresh-conversation runs')

3. 【P2】移除 .heartbeat-modal 的 min-height:600px,让模态框
   在小窗口(480px)下也能正常显示,由 max-height: min(780px,92vh)
   控制最大高度
   (评论: 'Avoid forcing a 600px modal in 480px windows')

未采纳:
- 【P2】'Keep the edit form scrollable' — TaskEditor 直接渲染在
  .heartbeat-modal 下,但 textarea 已有 max-height:180px 限制,
  总内容高度在模态框范围内,不会溢出裁剪
2026-06-24 19:20:11 +08:00
ttmouse a40aaa7614 fix: 切换标签页时迁移旧 items 作占位,消除 Welcome 闪现
activateTopic 返回新 tab ID 时,旧状态被全部清除,新状态
从 initialState 创建(items=[])。之前的 reset 保留 items
策略对空数组无效。

现在在 loadSessionDataForTab 的 reset 发生之前,将当前激活
标签页的 items 显式写入新标签页的状态。reset 保留 items 时
就能保留这些占位内容,用户看到的仍是旧对话,直到新历史到达。
2026-06-24 19:15:17 +08:00
lifu963 6f25d1ecb2 cover plan mode economy source blocking 2026-06-24 19:09:56 +08:00
lifu963 605c481fad block subagent skill tools explicitly in plan mode 2026-06-24 19:09:28 +08:00
lifu963 f4f9965855 document plan mode allowed tools migration 2026-06-24 19:05:39 +08:00
SivanCola 10bdacf625 Merge pull request #5241 from esengine/fix/opaque-script-error-reporting
Improve opaque script error reporting / 改进不透明脚本错误上报
2026-06-24 19:03:26 +08:00
ttmouse 24a2d9f47c feat: 心跳任务新增「每次新建对话」选项
在心跳任务编辑器中增加「对话模式」选项,可选择:
- 复用同一对话(默认,原有行为)
- 每次执行新建对话(每次触发时创建新 topic)

Go 后端:
- HeartbeatTask 新增 NewConversationEachRun 字段
- executeTask 中当该字段为 true 时清除 topicID 强制创建新对话
- mergeRunUpdatesLocked 不为 newConversationEachRun 任务持久化 topicId

前端:
- 编辑面板新增 toggle 选择器(复用 set-seg 模式)
- 提示词 textarea 自动增高(min-height 80px → max-height 180px,超出内部滚动)
- 底部操作栏移除分隔线,与表单融为一体
- 移除列表页底部提示条(heartbeat-hint)
- 弹窗高度自适应,任务卡片间距缩小至 4px
- 项目选择器选中态取消蓝色高亮

i18n:中/英/繁三语文案同步

修复审查发现:
- autoGrowPrompt 改为 useLayoutEffect 避免高度闪烁
- handleAdd/startNew 初始化补上 newConversationEachRun: false
2026-06-24 19:01:49 +08:00
Sivan c9e7a3659b Harden Memory v5.9 production runtime 2026-06-24 19:00:00 +08:00
SivanCola 74868106f0 Preserve manual crash severity overrides 2026-06-24 18:58:11 +08:00
SivanCola a1cd445a78 Merge pull request #5238 from ttmouse/pr/doubleclick-rename-topic
左侧边栏双击对话名称可重命名
2026-06-24 18:56:49 +08:00
Sivan 56a334f44d Fix project tree double-click routing 2026-06-24 18:50:11 +08:00
lifu963 fa010d90ce block plan mode bash process controls 2026-06-24 18:47:18 +08:00
SivanCola 3401643586 Improve opaque script error crash reporting 2026-06-24 18:42:53 +08:00
lifu963 7cfd4238c5 cover actual plan mode writer tool names 2026-06-24 18:39:23 +08:00
lifu963 bf54e8c45c document plan mode override tightening 2026-06-24 18:38:27 +08:00
Sivan ac95619904 Add Memory v5.9 production hardening 2026-06-24 18:35:57 +08:00
lifu963 8cef3524a0 apply plan mode policy to economy sources 2026-06-24 18:35:41 +08:00
lifu963 d18cab405a align plan mode marker with policy 2026-06-24 18:34:41 +08:00
lifu963 fd38efb48d centralize agent plan mode gate 2026-06-24 18:33:59 +08:00
lifu963 f3470b2e4f add plan mode policy tracer 2026-06-24 18:32:13 +08:00
SivanCola 671dd22232 Merge pull request #5153 from GTC2080/GTC/fix-5097-new-session-history
fix(desktop): keep project history topics out of blank reuse
2026-06-24 18:21:16 +08:00
Sivan e4a7f4399a Add Memory v5.8 typed control semantics 2026-06-24 18:19:25 +08:00
ttmouse 29715aeeff fix: 批量分发 loadSessionDataForTab 结果,消除级联重渲染
loadSessionDataForTab 中有 7 个并行 IPC 调用(MetaForTab,
HistoryForTab, CheckpointsForTab, ContextUsageForTab, BalanceForTab,
EffortForTab, JobsForTab),之前每个通过独立的 .then() 链 dispatch,
导致每次 IPC 完成都触发一次 bump() → React 重渲染,共产生 7+ 次
视觉刷新,用户感受到两次以上闪烁。

改为 Promise.all 收集所有结果后,在同一个微任务中批量 dispatch 所有
数据(history、meta、checkpoints、context、balance、effort、jobs
再加 hydrate_done),React 18 自动合并到一次渲染中完成。
2026-06-24 17:55:14 +08:00
SivanCola 112618ce41 Merge pull request #5230 from ttmouse/pr/fix-new-conversation-modes
fix: 工作台/创作模式下新建对话显示旧对话的问题
2026-06-24 17:44:40 +08:00
ttmouse df9f4d932d fix: 消除切换会话时 Welcome 闪现
问题根因:activateTopic 调用 loadSessionDataForTab(reset=true) 时,
reset 先清空 items(设为 []),Transcript 检测到 empty 后立即渲染
Welcome,随后异步 history 到达才填充真实会话内容,导致一闪而过。

修复方案(两处改动):
1. useController.ts reducer: reset 时保留旧 items 不立即清空,
   切换期间旧对话内容留在界面上作为占位,新 history 到达后替换。
2. Transcript.tsx + App.tsx: 添加 hydrating prop,加载期间不显示
   Welcome;仅在真正空且非加载态时才显示新建会话界面。
2026-06-24 17:44:27 +08:00
Sivan eb067be473 Add startup active-tab race regression 2026-06-24 17:38:13 +08:00
Sivan 03d9638d89 Add Memory v5.7 global equilibrium 2026-06-24 17:38:07 +08:00
SivanCola ed9b285bcb Merge pull request #5233 from SivanCola/fix/desktop-workspace-plan-integration
Integrate desktop workspace and plan persistence fixes / 整合工作区与计划模式修复
2026-06-24 17:31:40 +08:00
ttmouse 025c095484 fix: 组件卸载时清理 clickTimerRef 避免空回调 2026-06-24 17:29:18 +08:00
SivanCola 8026a8c685 Address desktop integration review findings 2026-06-24 17:23:47 +08:00
SivanCola b99ffc8e01 Merge pull request #5213 from ttmouse/pr/fix/composer-max-width-4465
fix: 关闭侧栏后输入框宽度撑满窗口 + jump-bar/滚动条不影响历史消息对齐
2026-06-24 17:21:32 +08:00
ttmouse 49034f6ab8 fix: 双击改名时拦截第一下点击的导航跳转
用 clickTimerRef 延迟 200ms 执行 onOpenTopic:
双击时第二下点击清除定时器并提前返回,彻底避免双击过程中
的导航/滚动到顶部问题;单击延迟 200ms 后正常打开话题。
2026-06-24 17:14:41 +08:00
Sivan 4b2eb60264 Add Memory v5.6 distributed control plane 2026-06-24 17:13:31 +08:00
Sivan 81bde42d3e Fix compact composer controls at capped width 2026-06-24 17:12:06 +08:00
ttmouse 40a2ee3bdf fix: 经典模式编辑态保留行高,创作模式双击不改位置
1. 编辑态 div 添加 project-tree__topic--has-meta 类名,
   匹配正常行高(min-height: 34px vs 48px),避免行高突变
2. 创作模式下 onClick 检查 event.detail > 1 时跳过导航,
   双击改名不会触发位置切换
2026-06-24 17:11:57 +08:00
e6115f6743 Integrate desktop workspace and plan persistence fixes
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]>
2026-06-24 17:09:06 +08:00
ttmouse c32c421644 feat: 双击重命名时自动选中输入框全部文本 2026-06-24 17:08:13 +08:00
SivanCola 65feb79e0c Merge pull request #5199 from lifu963/pr/subagent-continue-autofork
Simplify subagent continuation reuse
2026-06-24 17:06:01 +08:00
ttmouse af0ca59cc3 style: 精简重命名输入框样式,用 align-self: stretch 撑满行高
将 .project-tree__topic-input 的固定 height: 26px 改为
align-self: stretch,让输入框自然撑满 flex 容器的高度(匹配
选中态的 min-height: 34px),消除白色输入框与蓝灰选中背景之间的
视觉重叠和文字位移。
2026-06-24 17:04:37 +08:00
Sivan 604b73c057 fix(agent): handle legacy subagent fork refs 2026-06-24 16:59:42 +08:00
Sivan c8140dce10 Add Memory v5.5 control hierarchy 2026-06-24 16:56:33 +08:00
SivanCola f450f61113 Merge pull request #5223 from SivanCola/fix/default-collapsed-workspace-panel
Default workspace panel to collapsed / 默认收起工作区面板
2026-06-24 16:45:18 +08:00
SivanCola 2b5f68bd17 Merge pull request #5125 from qiukeren/main-v2
fix(cli): show current version in upgrade success message
2026-06-24 16:44:23 +08:00
Sivan d106710082 Add Memory v5.4 control stability 2026-06-24 16:42:12 +08:00
ttmouse 9606bebe3e fix: 移除输入框激活时的 1px 上移,避免焦点切换轻微位移
:root[data-theme-style] .composer-card:focus-within 的
transform: translateY(-1px) 导致焦点切换时输入框位移 1px。
2026-06-24 16:40:13 +08:00
Sivan f3a638cd8a Add upgrade success message regression 2026-06-24 16:37:43 +08:00
Sivan 1167d6ef15 Add Memory v5.3 equilibrium controls 2026-06-24 16:26:26 +08:00
Sivan 14fb425e94 Add Memory v5 turn citations 2026-06-24 16:15:37 +08:00
lifu963andCursor 6e4a756531 test(agent): cover background ancestor continuation
Co-authored-by: Cursor <[email protected]>
2026-06-24 16:14:19 +08:00
lifu963andCursor d3a697f9e4 test(agent): cover background subagent references
Co-authored-by: Cursor <[email protected]>
2026-06-24 16:14:19 +08:00
lifu963andCursor d138d8bcc7 feat(agent): simplify subagent continuation interface
Co-authored-by: Cursor <[email protected]>
2026-06-24 16:14:19 +08:00
lifu963andCursor 1be16fc92c feat(agent): resolve subagent continuations through lineage
Co-authored-by: Cursor <[email protected]>
2026-06-24 16:14:19 +08:00
lifu963andCursor 238cdebcb3 test(agent): trace ancestor subagent continuation
Co-authored-by: Cursor <[email protected]>
2026-06-24 16:14:19 +08:00
Sivan ab33bad6bd Default workspace panel to collapsed 2026-06-24 16:01:43 +08:00
Sivan fa292b0f34 Add Memory v5.2 stability layer 2026-06-24 15:51:18 +08:00
Sivan 59bb5e8f81 Stabilize Memory v5 learning loop 2026-06-24 15:39:58 +08:00
ttmouse b2b0b99f03 fix: scrollbar-gutter: stable both-edges 防止滚动条导致内容偏移
overflow-y: overlay 在 WebKit 新版已被移除,回退到 auto 时滚动条
出现会导致历史消息内容偏移。使用 scrollbar-gutter: stable both-edges
在两侧对称预留空间,滚动条出现/消失不影响内容居中对齐。
2026-06-24 15:13:08 +08:00
ttmouse 8882478ade fix: 将 jump-bar 移出 transcript,使用绝对定位避免布局偏移
QuestionJumpBar 原为 .transcript 的子元素,其 sticky + margin-right: -48px
导致历史消息内容偏移。改为放置在 .transcript-shell 下、.transcript 外部,
使用 position: absolute 定位在右侧中部,完全不参与 transcript 子元素布局,
与 codex/upstream-sync-20260613 分支结构一致。
2026-06-24 15:10:22 +08:00
SivanCola 5968a44f96 Satisfy Memory v5 lint check 2026-06-24 15:09:41 +08:00
SivanCola 76c56778a6 Fix Memory v5 runtime review issues 2026-06-24 15:06:09 +08:00
SivanCola 199442e40f Bind Memory v5 compiler to execution runtime 2026-06-24 15:00:00 +08:00
ttmouse eebc3ee919 fix: 工作台/创作模式下新建对话显示旧对话的问题
工作台和创作模式使用 ensureBlankSurface(单 surface 布局)创建新对话时,
与 mount/onReady 时 fire-and-forget 调用的 syncActiveTabFromBackend
存在竞态条件:后者异步返回后会用旧的 backend tab 覆盖新创建的空白 tab。

修复:给 syncActiveTabFromBackend 增加 guard 参数,mount/onReady 的
fire-and-forget 调用传入 guard=true,在异步获取 backend 结果后检查
activeTabIdRef.current 是否已被用户操作改变,若是则跳过覆盖。
其他同步调用(closeTab、switchTab 等)不传 guard,行为不变。
2026-06-24 14:49:18 +08:00
ttmouse bc89a87cba fix: jump-bar 改用绝对定位,不占用 transcript 布局空间
.transcript > .jump-bar 原使用 margin-left: auto + margin-right: -48px
强制右移,导致 transcript 子元素偏移。改用 position: absolute 定位
在右下角,与 codex/upstream-sync-20260613 分支方案一致,不影响
其他子元素的居中对齐。
2026-06-24 14:46:29 +08:00
SivanCola 0acc680a2c Add Memory v5 execution compiler 2026-06-24 14:46:20 +08:00
ttmouse c987c8cc74 fix: 关闭侧栏后输入框宽度撑满窗口,设置固定最大宽度居中布局
1. :root[data-theme-style] .composer-wrap: max-width: none → var(--maxw)
   工作台模式下输入框不再铺满窗口,与消息历史共享 --maxw: 760px 对齐

2. .transcript: overflow-y: auto + overlay fallback + scrollbar-gutter: auto
   滚动条悬浮不占位,避免历史消息因滚动条出现而偏左移位

参考 codex/upstream-sync-20260613 分支的对齐方案。

Fixes #4465
2026-06-24 14:32:25 +08:00
ttmouse 4f23e75c2b feat: 左侧边栏双击对话名称可重命名
在 ProjectTree 的 topic 主按钮上添加 onDoubleClick 处理,双击对话
主题名称时调用已有的 startRenameTopic 逻辑,触发内联重命名输入框。
对 session 节点(运行时会话)跳过,保持与右键菜单重命名行为一致。
2026-06-24 14:09:38 +08:00
SivanCola 483d536214 Merge pull request #5209 from SivanCola/fix/session-composer-draft
Fix session-scoped composer drafts / 修复会话级输入草稿隔离
2026-06-24 13:35:36 +08:00
Sivan aaa65796e1 fix(desktop): keep composer draft async work scoped 2026-06-24 13:29:10 +08:00
SivanCola 60a203456c Merge pull request #5208 from ttmouse/fix/progress-panel-collapse-state
fix: 进度面板折叠状态持久化到 localStorage,移除自动弹窗
2026-06-24 13:21:31 +08:00
ttmouse 584492567d fix: TestSessionWrongSignature flaky test — 签名首 nibble 可能已是 0,翻转成空操作
随机签名 hex 串的首个 nibble 有 1/16 概率天然是 0,
此时 0 + sig[1:] 与原 sig 相同,verifySession 返回 true。
修复:若首字符已是 0,翻转为 f 保证不同。
2026-06-24 12:40:45 +08:00
Sivan 030e780fe9 fix(desktop): isolate composer drafts by session 2026-06-24 12:33:47 +08:00
SivanCola 8f489b5022 Merge pull request #4950 from ttmouse/pr/heartbeat-approval-mode
feat(heartbeat): 自动化任务新增权限等级选择
2026-06-24 12:30:38 +08:00
ttmouse da7b4bd128 fix: 进度面板折叠状态持久化到 localStorage,移除自动弹窗
Issue: #4920

- 添加 loadOpenState/saveOpenState 读写 localStorage('todoPanel:open')
- 移除 todoKey/allDone 触发的自动展开 useEffect
- 用户折叠/展开状态跨会话保持,不再被新任务覆盖
2026-06-24 12:27:49 +08:00
Sivan 009c4af0c9 Fix heartbeat schedule edge cases 2026-06-24 12:26:57 +08:00
SivanCola 2d254a08e4 Merge pull request #5205 from SivanCola/fix/desktop-workspace-resize-integration
Integrate desktop workspace split and resize drag fixes / 集成桌面文件区分栏与拖拽优化
2026-06-24 12:17:23 +08:00
Sivan d8ce905c16 Preserve workspace split during file browsing 2026-06-24 12:11:56 +08:00
Sivan ce79c6d354 Merge remote-tracking branch 'origin/main-v2' into HEAD
# Conflicts:
#	desktop/frontend/package.json
2026-06-24 12:02:01 +08:00
Sivan 1a113589b8 Fix live workspace dock resizing 2026-06-24 12:01:20 +08:00
Sivan b31b711884 Fix heartbeat approval and window scheduling 2026-06-24 11:58:22 +08:00
Sivan 0889e925a4 Fix workspace preview split edge cases 2026-06-24 11:43:56 +08:00
Sivan cdf7aabde2 Merge latest main-v2 into desktop integration 2026-06-24 11:27:06 +08:00
YHH 90a3c2892c Merge pull request #5203 from esengine/refactor/split-config-god-file
refactor(config): split the 3120-line config.go god file by concern
2026-06-24 11:27:00 +08:00
SivanCola b79c3e4fbf Merge pull request #5109 from lifu963/pr/controller-turn-orchestration
Extract controller turn orchestration
2026-06-24 11:24:27 +08:00
Sivan ad640fd41f Merge PR #5171 resize drag performance
# Conflicts:
#	desktop/frontend/package.json
#	desktop/frontend/src/components/WorkspacePanel.tsx
2026-06-24 11:22:16 +08:00
esengine 656ff9b2ca refactor(config): split the 3120-line config.go god file by concern
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).
2026-06-23 20:22:10 -07:00
Sivan c60c33f5be Merge PR #5157 workspace split fix 2026-06-24 11:20:06 +08:00
Sivan a05af40bc4 Fix final resize drag flush 2026-06-24 10:59:09 +08:00
YHH ece0d4e073 Merge pull request #5195 from esengine/refactor/control-session-path-helpers
refactor(control): collapse per-surface session-path wiring into Controller helpers
2026-06-24 10:15:03 +08:00
esengine c87781577f refactor(control): collapse per-surface session-path wiring into Controller helpers
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).
2026-06-23 19:10:02 -07:00
liaoyl830 5b81febdab fix(provider): include empty properties in tool schemas (#5167) 2026-06-23 21:58:36 +08:00
lifu963andCursor 83144f4013 chore(desktop): fix resize test whitespace
Co-authored-by: Cursor <[email protected]>
2026-06-23 21:46:16 +08:00
lifu963andCursor ab5e0652a9 test(desktop): include resize drag coverage
Co-authored-by: Cursor <[email protected]>
2026-06-23 20:33:49 +08:00
lifu963andCursor 27356b6a73 fix(desktop): batch composer resizing
Co-authored-by: Cursor <[email protected]>
2026-06-23 20:31:49 +08:00
lifu963andCursor 9fe85032d4 fix(desktop): batch panel splitter resizing
Co-authored-by: Cursor <[email protected]>
2026-06-23 20:29:39 +08:00
lifu963andCursor 4bed5f2233 fix(desktop): batch root splitter resizing
Co-authored-by: Cursor <[email protected]>
2026-06-23 20:28:27 +08:00
lifu963andCursor a3047b38d5 test(desktop): add rAF resize updater
Co-authored-by: Cursor <[email protected]>
2026-06-23 20:26:15 +08:00
GTC2080 edacf1a8c2 fix(tui): preserve native terminal selection 2026-06-23 19:50:36 +10:00
SivanCola 9c27591edf Merge pull request #5146 from qiaone/serve-next
serve: authentication, goal mode, live todos, and mobile UX improvements
2026-06-23 17:24:42 +08:00
lifu963andCursor 9280fa1771 fix(desktop): recompute default files split after preview resize
Co-authored-by: Cursor <[email protected]>
2026-06-23 17:22:15 +08:00
lifu963andCursor ec370d75b9 fix(desktop): preserve code preview viewport padding
Co-authored-by: Cursor <[email protected]>
2026-06-23 17:09:18 +08:00
lifu963andCursor 624cb84f9e fix(desktop): account for file tree rail in split sizing
Co-authored-by: Cursor <[email protected]>
2026-06-23 17:09:13 +08:00
Sivan 95407074ef Address serve auth CodeQL findings 2026-06-23 16:53:42 +08:00
GTC2080 acb19f4eeb fix(desktop): avoid reusing project topics with history 2026-06-23 18:52:20 +10:00
YHH 13f7c041de Merge pull request #5152 from esengine/refactor/frontend-overlays-store
refactor(desktop): move overlay/modal state into a zustand store
2026-06-23 16:52:07 +08:00
lifu963andCursor 057856b3fb fix(desktop): keep file tree rail visible in split
Co-authored-by: Cursor <[email protected]>
2026-06-23 16:52:04 +08:00
lifu963andCursor 07d34a3d25 fix(desktop): restore file tree toggle rail
Co-authored-by: Cursor <[email protected]>
2026-06-23 16:48:50 +08:00
esengine 7ee1d00223 refactor(desktop): move overlay/modal state into a zustand store
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.
2026-06-23 01:47:15 -07:00
YHH 1f347727c7 Merge pull request #5151 from esengine/refactor/frontend-layout-flags
refactor(desktop): move right-dock layout config into the zustand store
2026-06-23 16:39:38 +08:00
lifu963andCursor 2f916b7e76 fix(desktop): restore flexible files dock resizing
Co-authored-by: Cursor <[email protected]>
2026-06-23 16:38:47 +08:00
esengine f7049182ec refactor(desktop): move right-dock layout config into the zustand store
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.
2026-06-23 01:34:08 -07:00
YHH b99886d001 Merge pull request #5150 from esengine/refactor/frontend-layout-store
refactor(desktop): extract layout geometry state into a zustand store
2026-06-23 16:33:12 +08:00
Sivan e8447a7a77 Document serve web frontend usage 2026-06-23 16:33:08 +08:00
esengine f0cdc2892a refactor(desktop): extract layout geometry state into a zustand store
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.
2026-06-23 01:25:56 -07:00
Sivan 1f390dd0e8 Harden serve authentication configuration 2026-06-23 16:22:39 +08:00
lifu963andCursor 9efaffd28c fix(desktop): keep code preview scrolling local
Co-authored-by: Cursor <[email protected]>
2026-06-23 16:16:25 +08:00
lifu963andCursor 34cd2b99d2 fix(desktop): stabilize files split view
Co-authored-by: Cursor <[email protected]>
2026-06-23 16:14:33 +08:00
qiaone 2129bb0569 serve: ignore project default_model, use user global config
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.
2026-06-23 15:54:37 +08:00
qiaone 7720361e39 serve: add live todo panel above composer
- 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
2026-06-23 15:54:37 +08:00
qiaone 7e92a3cc3a serve: 添加 goal 模式支持
- 新增 POST /goal 端点,支持设置/清除目标(设置时自动关闭 plan 模式)
- 工具栏新增 Goal 按钮(靶心图标),三态切换:进入草稿→退出草稿→清除活跃目标
- 活跃目标时在输入框上方显示 goal-chip,点击可清除
- goal 草稿模式下 placeholder 切换为描述你的目标,发送时自动拼 /goal 前缀
- 手动输入 /goal 时自动退出草稿模式,避免重复拼前缀
- Esc / Shift+Tab 可退出 goal 草稿模式;goal 运行时状态栏显示 Active goal
- 添加 /goal 到斜杠命令列表,中英文 i18n 完整覆盖
- status 轮询同步 goal/goalStatus 状态,SSE 重连时刷新
2026-06-23 15:52:14 +08:00
qiaone ced56ef72d feat(serve): mobile sidebar toggle + fix balance display
- 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
2026-06-23 15:52:14 +08:00
qiaone 535993a271 serve: add token and password authentication for HTTP frontend
Add authentication to 'reasonix serve' so it can safely bind to
non-localhost addresses. Two modes:

- Token mode (--auth token): a pre-shared token in the URL query
  string sets a HttpOnly cookie; share the link with collaborators.
- Password mode (--auth password): a login page (bcrypt) with
  HMAC-signed session cookies, no username needed, persistent login.

Design priorities: simplicity, security, backward compatibility.
Default is still --auth none (no authentication).

Security highlights:
- Constant-time token comparison (crypto/subtle)
- bcrypt cost 12 for password hashing
- HMAC-SHA256 session tokens with expiry + cryptographic nonce
- HttpOnly / Secure / SameSite=Lax cookies
- Per-IP rate limiting on /login (5 attempts/minute)
- Periodic cleanup of rate-limit state
- X-Forwarded-* headers only trusted when behind_proxy is set
- /login path bypasses CSRF guard (form POST, not JSON)
- Error messages are intentionally generic (no oracle)

Files:
- internal/serve/auth.go (new): authGate middleware, session
  management, login handler, rate limiter
- internal/serve/auth_test.go (new): 28 tests covering token,
  password, session HMAC, rate limiting, IP extraction
- internal/serve/login.html (new): dark-themed login page
- internal/config/config.go: add ServeConfig struct
- internal/serve/serve.go: integrate auth into middleware chain
- internal/cli/cli.go: --auth, --token, --password, --hash-password,
  --behind-proxy flags
- i18n: updated serve help text (en, zh, zh-TW)
- reasonix.example.toml: [serve] section
2026-06-23 15:52:13 +08:00
SivanCola 0cfc3f021d Merge pull request #5141 from SivanCola/codex/fix-tab-build-context
Preserve tab plugin context after boot / 保留标签页插件上下文
2026-06-23 15:09:53 +08:00
SivanCola 6279485ad7 fix(desktop): preserve tab plugin context after boot 2026-06-23 15:01:54 +08:00
YHH 8e9d1c41fd fix(bot): close the getOrCreateSession check-create race (#5140)
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.
2026-06-23 15:00:12 +08:00
YHH 9349ea6916 refactor(bot): wire feishu onto the connloop primitives (#5139)
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.
2026-06-23 14:54:56 +08:00
YHH c13a3d1054 refactor(bot): wire qq and weixin onto the connloop primitives (#5137)
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.
2026-06-23 14:49:11 +08:00
YHH 806a3df631 feat(bot): add shared connection-lifecycle primitives (connloop) (#5136)
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.
2026-06-23 14:43:12 +08:00
SivanCola 948b2b45d2 Merge pull request #5101 from lifu963/pr/event-wire-contract
统一事件 wire 契约
2026-06-23 14:41:25 +08:00
SivanCola f3525db383 Merge pull request #5133 from SivanCola/fix/single-surface-review-followup
Harden single-surface tab pruning / 加固单界面会话剪枝
2026-06-23 14:34:25 +08:00
GTC2080 1466e44233 fix: surface desktop error states 2026-06-23 16:29:48 +10:00
SivanCola 99c09ae7e3 fix(desktop): harden single-surface tab pruning 2026-06-23 14:29:25 +08:00
Sivan 0ef88e9064 Handle retrying events in serve UI 2026-06-23 14:27:42 +08:00
GTC2080 5fcae79fa0 fix: stabilize desktop session and cancel flows 2026-06-23 16:27:20 +10:00
YHH e17f31b76e refactor(cli): split the paste/image input layer out of chat_tui.go (#5130)
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.
2026-06-23 14:21:27 +08:00
SivanCola 3e520a9b2d Merge pull request #5126 from GTC2080/GTC/fix-command-palette-esc-close
Fix command palette escape close button / 修复命令面板 Esc 关闭按钮
2026-06-23 14:15:13 +08:00
SivanCola 0987f47623 Merge pull request #5129 from SivanCola/fix/single-surface-tab-persistence
Fix single-surface tab persistence / 修复单界面布局隐藏会话持久化
2026-06-23 14:09:50 +08:00
SivanCola bf159d0b56 fix(desktop): prune hidden tabs in single-surface layouts 2026-06-23 14:05:01 +08:00
YHH b16dba2dbb refactor(control): extract skillSet from Controller (#5127)
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.
2026-06-23 13:46:18 +08:00
GTC2080 3bafeb7bbb fix: make command palette escape key clickable 2026-06-23 15:32:51 +10:00
YHH 8c6a425e78 refactor(control): extract mcpManager from Controller (#5123)
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.
2026-06-23 13:30:17 +08:00
qiukeren d519a7620e fix(cli): show current version in upgrade success message
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.
2026-06-23 13:26:02 +08:00
SivanCola edc500849d Merge pull request #5114 from esengine/fix/nonblocking-session-switch
Make desktop session switching non-blocking / 桌面会话切换改为非阻塞
2026-06-23 13:24:56 +08:00
SivanCola 158eeffcde Merge pull request #5117 from SivanCola/fix/provider-credential-followups
Fix provider credential follow-ups / 修复 provider 凭据后续问题
2026-06-23 13:24:13 +08:00
Sivan 12b64da913 Remove unused desktop credentials path 2026-06-23 13:11:33 +08:00
Sivan b53310f214 Guard session switch races 2026-06-23 13:07:36 +08:00
Sivan 1d564bd4d5 Scope project dotenv expansion 2026-06-23 13:06:08 +08:00
lifu963andCursor 0028c4883f Cover checkpoint ordering through public behavior
Avoid coupling the orchestrator test to Controller checkpoint internals so it survives the checkpoint manager extraction.

Co-authored-by: Cursor <[email protected]>
2026-06-23 12:57:02 +08:00
lifu963andCursor da5b171fc4 Cover checkpoint ordering in turn orchestration
Add a focused test proving the orchestrator records checkpoint boundaries before the user message and still snapshots activity.

Co-authored-by: Cursor <[email protected]>
2026-06-23 12:55:27 +08:00
lifu963andCursor 92b6b4ae4e Cover reference display separation in turn orchestration
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]>
2026-06-23 12:55:27 +08:00
lifu963andCursor e72e5020ed Cover foreground turn concurrency semantics
Lock down the existing sync and async already-running behavior so orchestrator extraction cannot interleave session writes.

Co-authored-by: Cursor <[email protected]>
2026-06-23 12:55:27 +08:00
lifu963andCursor ff55d5c0e3 Move foreground turn lifecycle into orchestrator
Delegate controller turn execution to the orchestrator so plan approval and goal continuation share one focused lifecycle boundary.

Co-authored-by: Cursor <[email protected]>
2026-06-23 12:55:27 +08:00
lifu963andCursor 4a46b81b9a Introduce turn orchestrator boundary
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]>
2026-06-23 12:55:27 +08:00
Sivan 2163c0889b Remove unused credential migration wrapper 2026-06-23 12:31:09 +08:00
YHH 656862d775 refactor(control): extract checkpointManager and rename cpRoot (#5116)
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.
2026-06-23 12:30:30 +08:00
Sivan 2dd6fc6ddf Fix provider credential follow-ups 2026-06-23 12:27:23 +08:00
Sivan e6fc472145 Fix nonblocking desktop session switching 2026-06-23 12:17:58 +08:00
YHH b093abac8f refactor(control): extract memoryManager from Controller (#5113)
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.
2026-06-23 12:17:43 +08:00
SivanCola a2911e81e0 Merge pull request #5110 from esengine/fix/provider-global-env
Use global .env for provider credentials / 使用全局 .env 管理 provider 密钥
2026-06-23 12:00:12 +08:00
Sivan bc4e826a2b Close legacy keyring connections on Unix 2026-06-23 11:44:36 +08:00
SivanandBrunswickTabby 7e7c7eda09 Use global env for provider credentials
Co-authored-by: BrunswickTabby <[email protected]>
2026-06-23 11:38:06 +08:00
SivanCola 5bac71fd7b Merge pull request #5106 from esengine/fix/long-stream-interruptions
Fix interrupted stream recovery / 修复长任务断流恢复
2026-06-23 11:12:02 +08:00
Sivan 6de3bcbab3 Fix recovery for interrupted model streams 2026-06-23 11:04:53 +08:00
lifu963andCursor 5eec1c8733 Update desktop event wire types
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]>
2026-06-23 10:15:13 +08:00
lifu963andCursor 126acdfa38 Use shared event wire in desktop
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]>
2026-06-23 10:12:49 +08:00
lifu963andCursor 83cf4dcc4d Use shared event wire in serve
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]>
2026-06-23 10:10:27 +08:00
lifu963andCursor f3c2695179 Cover event wire payload mapping
Move the observable payload contract into shared eventwire tests so tool, usage, interaction, and lifecycle events keep stable JSON fields.

Co-authored-by: Cursor <[email protected]>
2026-06-23 10:08:39 +08:00
lifu963andCursor 46b78a7dfe Add shared event wire kind coverage
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]>
2026-06-23 10:06:39 +08:00
maoxin1234 c58805afea docs: add reasoning-controls-by-provider reference
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).
2026-06-23 09:46:05 +08:00
SivanCola 7aa97d31ad Merge pull request #5084 from SivanCola/fix/post-merge-bot-findings
Fix post-merge bot review findings / 修复合并后 bot review 问题
2026-06-23 01:49:11 +08:00
SivanCola cb66b4895c Merge pull request #5076 from GTC2080/GTC/fix-short-choice-planner-context
Fix short choice replies in dual-model context / 修复双模型短选项回复上下文
2026-06-23 01:48:09 +08:00
SivanCola 71068eab91 Fix post-merge bot review findings 2026-06-23 01:27:40 +08:00
SivanCola 6ad1df07ff fix: preserve planning for short work requests
Co-authored-by: SivanCola <[email protected]>
2026-06-23 01:23:06 +08:00
SivanCola d26572c27c Merge pull request #5077 from Ninot1Quyi/fix/skill-root-dedupe
fix(desktop): dedupe skill roots in settings
2026-06-23 01:09:45 +08:00
SivanCola a64a6a8aa3 Merge pull request #5000 from jbts6/main-v2
feat: incremental project config writes (surgical section replacement)
2026-06-23 01:09:00 +08:00
SivanCola 76c8a07e57 fix(desktop): keep deduped skill roots active
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]>
2026-06-23 01:04:41 +08:00
SivanCola 0173f3a913 Handle plugin removal in project config saves
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]>
2026-06-23 01:04:08 +08:00
SivanCola f10a456613 Merge pull request #5082 from SivanCola/fix/integrate-ask-user-decisions
Integrate ask user-decision fixes / 整合用户选择 ask 修复
2026-06-23 01:02:17 +08:00
SivanColaandGTC2080 658bc7ce46 Integrate ask user decision fixes
Co-authored-by: GTC2080 <[email protected]>
2026-06-23 00:55:15 +08:00
SivanCola dcf6e19126 Merge pull request #4983 from paradoxSCH/fix/desktop-cli-session-topic-migration
fix(desktop): surface CLI sessions in sidebar
2026-06-23 00:44:56 +08:00
SivanCola b61e4a77bd Fix project config incremental writes
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]>
2026-06-23 00:43:44 +08:00
SivanCola 2a7852f86a fix(desktop): rescan migrated CLI session dirs
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]>
2026-06-23 00:33:59 +08:00
SivanCola 05cf7a8b53 Merge pull request #4981 from ttmouse/pr/session-deletion-perf
perf: 优化对话删除流程 — 前端本地移除 + 锁外写磁盘
2026-06-23 00:33:52 +08:00
quyi 51eea606e7 fix(desktop): dedupe skill roots in settings
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
2026-06-23 00:29:13 +08:00
GTC2080 07b176ac6d fix: route short choice replies through executor context 2026-06-23 02:27:25 +10:00
SivanCola ce54916648 Merge pull request #5072 from SivanCola/fix/desktop-rewind-plan-prompt-integration
Consolidate desktop rewind and plan prompt recovery / 整合桌面回溯与计划确认恢复
2026-06-23 00:08:32 +08:00
SivanCola 962396cf7e Merge pull request #5073 from SivanCola/feature/desktop-mcp-headers-integration
Support remote MCP headers in desktop / 桌面端支持远程 MCP 请求头
2026-06-23 00:06:51 +08:00
SivanCola ddbbb37d36 Merge pull request #5069 from lifu963/pr/acp-background-job-rebuild-risk
修复 ACP 配置切换取消后台任务
2026-06-23 00:05:01 +08:00
dengbushi e19fbdcc00 fix(desktop): support remote MCP headers 2026-06-23 00:01:52 +08:00
SivanCola 87b40dfe87 fix(desktop): consolidate rewind and plan prompt recovery 2026-06-23 00:00:25 +08:00
SivanCola 6a84a4d3b9 Merge pull request #5065 from Li-Charles-One/feat/creation-tool-card-scrollbar-error
style(creation): 精简工具卡片错误框与代码块滚动条
2026-06-22 23:52:47 +08:00
SivanCola 61ec6a4b75 Use typed ACP config active-work errors
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]>
2026-06-22 23:52:20 +08:00
lifu963andCursor f60c0a74da Cover ACP config retry after background jobs stop
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]>
2026-06-22 23:17:42 +08:00
lifu963andCursor 33304cbccd Reject ACP config switches during pending prompts
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]>
2026-06-22 23:04:03 +08:00
lifu963andCursor 23280359a0 Discard queued ACP config switches blocked by jobs
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]>
2026-06-22 23:02:32 +08:00
lifu963andCursor 4997a0f2e1 Reject ACP config switches during background jobs
Prevent idle ACP config rebuilds from releasing a controller that still owns running background jobs.

Co-authored-by: Cursor <[email protected]>
2026-06-22 23:00:01 +08:00
maoxin1234 44876d2607 feat(provider): generic thinking toggle + DeepSeek disable
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.
2026-06-22 22:28:46 +08:00
Li-Charles-One 917e036e65 style(creation): 精简工具卡片错误框与代码块滚动条
仅限 .app--creation 作用域,classic/workbench 不受影响:

- 工具错误信息 .tool__err 去掉红底块、清左缩进、左对齐,降低视觉干扰

- 工具体代码块 .tool__body .code 底部横滚动条隐藏滑道、只留 4px 细滑块

滚动条需先把 scrollbar-width 重置为 auto 以抵消全局 *{scrollbar-width:thin},

否则 WebView2 下 ::-webkit-scrollbar 伪元素被忽略;写法对齐已有的 .transcript 滚动条。
2026-06-22 21:51:44 +08:00
Zhuocheng-Lang 11b6c52bf8 fix(desktop): apply NVIDIA Wayland explicit-sync workaround for WebKitGTK
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.
2026-06-22 21:32:56 +08:00
maoxin1234 8ecdf392ad feat(provider): add Zhipu GLM thinking-mode support
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.
2026-06-22 21:27:00 +08:00
SivanCola f2a475a2a5 Merge pull request #5044 from SivanCola/feature/crash-stats-default-30d
Default crash stats to 30d / 默认崩溃统计为 30 天
2026-06-22 18:29:56 +08:00
Sivan 05d2d209a8 Default crash stats window to 30d 2026-06-22 18:26:17 +08:00
SivanCola b9fc0a51ea Merge pull request #5042 from SivanCola/fix/session-trash-sidecars
Fix session trash sidecars / 修复会话回收站旁路文件
2026-06-22 18:19:55 +08:00
SivanCola b11f474de1 Merge pull request #5041 from SivanCola/feature/crash-stats-navigation
Improve crash stats navigation / 改进崩溃统计导航
2026-06-22 18:18:59 +08:00
Sivan 9eff550330 Improve crash stats navigation 2026-06-22 18:12:20 +08:00
Sivan 47166a3792 fix(desktop): keep session sidecars with trash 2026-06-22 18:12:03 +08:00
SivanCola 287d0804b9 Merge pull request #5034 from SivanCola/fix/transcript-reveal-signal-i18n
Fix transcript reveal signal and jump label / 修复正文 reveal 信号与跳底部文案
2026-06-22 17:32:51 +08:00
YHH 82f89adb5a ci(crash): auto-deploy crash worker on merge to main-v2 (#5036)
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.
2026-06-22 17:29:23 +08:00
Sivan 48f7af9bbb Fix transcript reveal signal and jump label 2026-06-22 17:01:37 +08:00
SivanCola 7eb4f2e650 Merge pull request #5029 from SivanCola/fix/transcript-scroll-bottom-reliability
Fix transcript bottom pinning / 修复对话滚动到底部定位
2026-06-22 16:47:49 +08:00
Sivan f7a47d4a6d fix(scroll): keep transcripts pinned on reveal 2026-06-22 16:38:38 +08:00
SivanCola 4fbc385e2e Merge pull request #5023 from SivanCola/fix/path-ref-containment
Fix path containment for image file references / 修复图片文件引用路径边界
2026-06-22 15:58:41 +08:00
Sivan 1e7a4f10f3 fix(control): clarify unscoped image refs 2026-06-22 15:52:13 +08:00
SivanCola bf3168e012 Merge pull request #5024 from esengine/chore/consolidate-dependabot-updates
Consolidate Dependabot dependency updates / 合并 Dependabot 依赖升级
2026-06-22 15:48:56 +08:00
Sivan b71d11cf5a chore(deps): consolidate frontend and Go updates 2026-06-22 15:44:08 +08:00
Sivan d68ed3b8a6 fix(control): contain image file references 2026-06-22 15:39:56 +08:00
dependabot[bot] d3dd9189cb chore(deps): bump actions/checkout from 6 to 7 in the actions group
Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-06-22 15:38:42 +08:00
dependabot[bot] 2392e4936f chore(deps): bump astro from 6.4.6 to 6.4.8 in /site in the npm group
Bumps the npm group in /site with 1 update: [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro).


Updates `astro` from 6.4.6 to 6.4.8
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/[email protected]/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/[email protected]/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 6.4.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-06-22 15:38:42 +08:00
SivanCola b535111280 Merge pull request #5020 from eghrhegpe/pr-fix-openai-omitempty
fix(openai): add omitempty to chatFunction description/parameters
2026-06-22 15:32:02 +08:00
Sivan 559b71d772 test(openai): cover omitting empty tool fields 2026-06-22 15:22:42 +08:00
eghrhegpe e36dd55905 fix(openai): add omitempty to chatFunction description/parameters to fix strict gateway rejection 2026-06-22 15:15:48 +08:00
SivanCola 8dbb673f51 Merge pull request #5018 from SivanCola/fix/model-switcher-compact-label
Compact model switcher trigger / 精简模型切换按钮
2026-06-22 14:55:18 +08:00
Sivan d692e25ea4 fix(desktop): keep model switcher trigger compact 2026-06-22 14:49:59 +08:00
YHHanddb 4da0a184c3 feat: add /reload-cmd command for hot-reloading custom slash commands without restart (#5016)
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]>
2026-06-22 14:20:18 +08:00
SurfireandSurfire bb4dbca0e0 fix(control): attach workspace images to vision turns (#4737)
Co-authored-by: Surfire <[email protected]>
2026-06-22 14:08:03 +08:00
eghrhegpe c29a39cd9a feat(agent): add grace round when tool-call budget is exhausted (#4869)
* feat(agent): add grace round when tool-call budget is exhausted

* fix: restore HasSuccessfulTodoProgressReceipt guard in finalReadinessCheck
2026-06-22 14:07:00 +08:00
liaoyl830andliaoyl830 94817d4c9d fix(control): strip referenced context prefix for title generation (#4954) (#4998)
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]>
2026-06-22 12:08:34 +08:00
cyq eb9200f5a5 fix(plugin): persist lazy MCP schema cache (#4878) 2026-06-22 12:08:29 +08:00
SivanCola 043e618342 Merge pull request #4999 from liaoyl830/fix-4956-trash-session
fix(desktop): add copy+remove fallback for session trash move on Windows
2026-06-22 09:47:32 +08:00
SivanCola f1532d55dc Merge pull request #4974 from Li-Charles-One/feat/creation-tool-flow
style(desktop): add Creation tool flow visuals
2026-06-22 09:46:25 +08:00
SivanCola f853ac3c02 Scope Creation status block rendering 2026-06-22 09:32:16 +08:00
SivanCola d3c5f671f5 Fix session trash fallback symlink handling
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]>
2026-06-22 09:29:45 +08:00
SivanCola 051239b633 Merge pull request #5005 from SivanCola/feature/desktop-shortcuts-integration
Integrate desktop shortcut improvements / 整合桌面快捷键改进
2026-06-22 02:08:26 +08:00
SivanCola faacd02423 Document desktop shortcut updates 2026-06-22 02:04:38 +08:00
SivanCola 1b15d6a25c Merge pull request #4985 from ttmouse/pr/fix-panel-spacing-header-bg
fix(ui): 修复右侧面板分隔线间距和对话头部浅色模式背景
2026-06-22 02:01:39 +08:00
SivanCola 28325fce0e Fix integrated desktop shortcut conflicts 2026-06-22 01:55:55 +08:00
ttmouse 037cabee13 fix: Cmd+K 命令面板需按两次才能打开 + 焦点和选中状态修复
- App.tsx: setPaletteOpen 回调 return current → return !current,修复首次按键无效
- CommandPalette.tsx: requestAnimationFrame → useLayoutEffect + callback ref,修复 input 焦点丢失
- CommandPalette.tsx: active 初始值 0 → -1,修复打开面板时已有选中项
2026-06-22 01:51:17 +08:00
ttmouse 7ab2f40411 feat: 互换 ⌘B 快捷键 — ⌘B 切换侧边栏,⌘⇧B 切换 Shell
- 将 ⌘B (Ctrl+B) 从 shell.toggle 改为 sidebar.toggle
- 将 ⌘⇧B (Ctrl+Shift+B) 从 sidebar.toggle 改为 shell.toggle
- 新增 sidebar.toggle 快捷键注册到 App 组件
- 更新 en/zh/zh-TW 国际化文本
2026-06-22 01:51:17 +08:00
ttmouse ba7b19e26a fix: 移除 capture-phase 监听器,恢复 Cmd+↑/↓ 原生滚动行为 2026-06-22 01:51:17 +08:00
ttmouse 50313c8d15 fix(sidebar): PR review 修复 — 折叠子话题跳过编号 + 移除 Cmd+0 + 输入框内显示标记
1. 折叠的文件夹内子话题不再占用快捷键编号(renderNode 增加 isVisible
   参数,折叠时传 false)
2. 移除 Cmd+0 / topic.goto.0,仅保留 Cmd+1-9
3. 输入框内按 Cmd 也显示快捷键标记(移除 editable element 检查)
2026-06-22 01:51:16 +08:00
ttmouse 1b253a0ee2 fix(sidebar): 创作模式下快捷键标记显示时隐藏时间标签
CSS 仅对 .sidebar--workbench 生效,创作模式(.app--creation)下
topic-side 时间标签未被隐藏。补充 .app--creation 选择器。
2026-06-22 01:51:16 +08:00
ttmouse 13a5bfdd6c feat(sidebar): Cmd+1-10 快捷键接入设置面板
将 topic.goto.1-0 注册到 SHORTCUT_DEFINITIONS,设置面板中展示
「转到聊天 1-10」及对应键位(⌘1-⌘0)。configurable: false,
不可自定义绑定。

- keyboardShortcuts.ts: 新增 10 个 topic.goto.* action 定义
- SettingsPanel: 支持 configurable === false 时禁用录制按钮
- en/zh/zh-TW: 添加快捷键标签和描述文案
2026-06-22 01:51:16 +08:00
ttmouse 05f2c32388 fix(sidebar): 输入框内也允许 Cmd+1-10 跳转话题
移除 editable element 焦点检查,Cmd+1-10 在任何焦点状态下均可触发。
Cmd+数字在 textarea 中本身无默认行为,不会干扰输入。
2026-06-22 01:51:16 +08:00
ttmouse b977698273 fix(sidebar): Cmd+1-10 快捷键不再等待标记显示即可触发
移除 showTopicBadges 对导航逻辑的 gate,Cmd+1-10 在 Cmd 按下的
瞬间即可触发跳转,无需等待 250ms 标记出现。标记显示保持延迟,
仅作为新用户发现功能的视觉辅助。
2026-06-22 01:51:16 +08:00
ttmouse 83ceb82290 fix(sidebar): PR review 修复 — 类型复用、编辑器焦点保护、死代码清理
- 复用 TopicShortcutEntry 类型替代 App.tsx/ProjectTree.tsx 中的内联类型
- Cmd+1-10 handler 增加 editable element 检查,编辑器内不拦截
- 移除已废弃的 flattenVisibleTopics / MAX_SHORTCUT_TOPICS / 无用 import
2026-06-22 01:51:16 +08:00
ttmouse 5026ccdb67 fix(sidebar): 快捷键导航顺序与侧边栏实际渲染顺序对齐
之前用 BFS 遍历项目树来确定快捷键对应的对话,没有考虑搜索筛选、
时间筛选、排序、置顶、折叠等状态,导致快捷键和展示对不上。

改为 ProjectTree 在渲染时收集实际可见话题列表,通过
onVisibleTopicsChange 回调上报给 App.tsx,Cmd+1-10 直接从该列表
取对应项进行导航,保证快捷键顺序与视觉顺序完全一致。
2026-06-22 01:51:16 +08:00
ttmouse c9c1e2de92 feat(sidebar): Cmd+1-10 快捷键跳转侧边栏对话
长按 Cmd/Ctrl 键后,侧边栏对话列表右侧会显示对应的快捷键标记(⌘1-⌘0),
松开后消失。按住期间按下对应数字键即可快速跳转到该对话。

- 新增 useTopicShortcuts hook:检测 Cmd/Ctrl 长按并控制标记显示
- ProjectTree:接受 showShortcutBadges prop,渲染快捷键标记
- App.tsx:注册 Cmd+1-10 全局快捷键,调用 ListProjectTree 获取对话列表
- styles.css:快捷键标记样式(圆角矩形,淡色背景)
2026-06-22 01:51:16 +08:00
SivanCola 27b5880041 chore: remove style trailing whitespace 2026-06-22 01:44:48 +08:00
jbts6 2419ca27ea feat: incremental project config writes (surgical section replacement)
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
2026-06-21 22:40:46 +08:00
liaoyl830 3208664815 fix(desktop): add copy+remove fallback for session trash move (#4956)
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
2026-06-21 21:27:17 +08:00
ttmouse ff45677e29 test: 同步 topicbar background 断言到 var(--bg-elev)
PR #4985 将 topicbar 的 background 从 var(--chat-bg) 改为 var(--bg-elev),
app-chrome-tabs.test.ts 中的 contract 测试需要同步更新断言值。
2026-06-21 19:19:10 +08:00
ttmouse a2072126d8 fix(ui): 修复右侧面板分隔线间距和对话头部浅色模式背景
- grid 从 4 列改为 3 列,去掉独立的 resizer 列
- resizer 与 dock 同列(grid-column:3),justify-self:start 紧贴列左侧
  dock 的 border-left:1px solid var(--border-soft) 作为视觉分割线,
  resizer 1px 宽叠在上面,hover 变 accent 蓝色
- ::before 左右各 3px 做不可见拖拽热区,::after 隐藏
- topicbar 所有 background 规则统一为 var(--bg-elev),
  包括基础 / override / data-theme-style / chrome-hidden 四种情况,
  浅色模式下 = #ffffff 纯白
2026-06-21 18:31:13 +08:00
paradoxSCH 01613306d0 fix(desktop): surface CLI sessions in project tree 2026-06-21 18:23:25 +08:00
ttmouse a9111d1174 fix: deleteSession 失败时不从历史面板移除
codex review: 当 app.DeleteSession 因验证/snapshot/I/O 失败时,
deleteSession hook 之前的 .catch(() => {}) 吞掉了错误,
导致本地 filter 仍移除了条目,但后端未实际删除。

修复:
1. useController.ts: 去掉 deleteSession 的 .catch(() => {}),
   让错误正常传播
2. App.tsx: onDeleteSession 用 try/catch 包裹,仅在成功后
   执行本地 state 过滤,失败时保持历史面板不变
2026-06-21 17:41:31 +08:00
ttmouse 8bdc4d35b9 perf: 优化对话删除流程 — 前端本地移除 + 锁外写磁盘
优化点:

1. 前端 onDeleteSession: 删除后不调用 listSessions() 全量重拉,
   改为从当前 state 中直接 filter 掉被删除的会话,省掉一次 Wails
   IPC 调用 + 后端全量扫描所有 .jsonl 文件的开销。

2. removeSessionRuntimeBindings / removeTopicRuntimeBindings:
   将 saveTabsLocked() 拆分为锁内收集 + 锁外写入,磁盘 I/O
   (序列化 JSON + 写文件) 不再在持有 a.mu 大锁期间进行,
   减少对其他并发操作的阻塞。
2026-06-21 17:22:08 +08:00
SivanCola 9ada141766 Merge pull request #4977 from SivanCola/fix/user-level-auto-plan
Make auto-plan user-level only / 自动计划仅按用户级配置生效
2026-06-21 17:18:03 +08:00
Li-Charles-One 6e7f27a73d style(desktop): make Creation settings panel less transparent 2026-06-21 17:16:58 +08:00
SivanCola 2db7acf6c8 fix: rebuild auto-plan classifier on enable 2026-06-21 17:09:58 +08:00
SivanCola 881b2f2f26 Merge pull request #4979 from SivanCola/feature/user-global-step-limits
Make agent step limits user-global / Agent 轮数上限仅按用户全局配置生效
2026-06-21 17:01:13 +08:00
SivanCola 01d9b1730e fix: make auto-plan a user-level setting 2026-06-21 16:58:56 +08:00
SivanCola 62180a99d6 Make agent step limits user-global 2026-06-21 16:54:58 +08:00
SivanCola 79af368a51 Merge pull request #4976 from SivanCola/fix/cancel-batch-results-and-parallel-stop
Preserve cancelled batch results / 保留取消批次结果
2026-06-21 16:39:30 +08:00
SivanCola f9461704d9 fix(agent): preserve cancelled batch results
Persist tool results from cancelled batches before returning, and stop launching new read-only tools once a parallel batch observes cancellation.
2026-06-21 16:33:25 +08:00
SivanCola 803a707f1a Merge pull request #4975 from SivanCola/fix/tui-cancel-escape-integration
Make running turns escapable after cancel / 取消后保持运行中任务可退出
2026-06-21 16:25:16 +08:00
53d515bb36 fix: make running turns escapable after cancel
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]>
2026-06-21 16:16:45 +08:00
Li-Charles-One 916457fd26 style(desktop): lighten creation markdown code blocks 2026-06-21 15:55:20 +08:00
Li-Charles-One e8e87e819d style(desktop): refine creation markdown flow 2026-06-21 15:43:59 +08:00
Li-Charles-One b8bd45c9ad style(desktop): group creation tool calls 2026-06-21 15:06:58 +08:00
SivanCola bfe398cc89 Merge pull request #4957 from linze0721/fix/daf48ba5-00c6-4ab1-8150-148d9363260f
兼容ps7
2026-06-21 15:00:33 +08:00
SivanCola 50ff43d8cd merge main-v2 into pwsh compatibility PR 2026-06-21 14:53:34 +08:00
Li-Charles-One c486e3d2ee style(desktop): refine creation tool flow 2026-06-21 14:45:33 +08:00
SivanCola 32addaecfb fix(shell): describe pwsh chaining support 2026-06-21 13:12:41 +08:00
SivanCola 9e56c32768 Merge pull request #4959 from linze0721/fix/b56315f8-ca45-49e0-b42a-8cb97e40e434
会话级的 todo 列表被持久化保存了,即使清空也会被 relaod。
2026-06-21 13:02:10 +08:00
SivanCola 02b25d8bc2 fix(todo): keep archived todo args bounded after clear
Co-authored-by: SivanCola <[email protected]>
2026-06-21 12:48:03 +08:00
SivanCola 027e7dc083 Merge pull request #4961 from SivanCola/fix/mcp-background-autostart
Remove lazy MCP startup mode / 移除 MCP lazy 启动模式
2026-06-21 12:25:40 +08:00
SivanCola 315c95b366 fix(mcp): address startup retry review feedback 2026-06-21 10:33:38 +08:00
SivanCola e5a48d9c77 fix(mcp): add retry all for available servers 2026-06-21 10:20:15 +08:00
SivanCola 7ba4295565 fix(mcp): remove lazy startup mode 2026-06-21 10:09:33 +08:00
Berserk Agent dffb6973cc fix(todo): preserve cleared todo state on reload 2026-06-21 01:19:45 +00:00
Berserk Agent 3569f3a417 fix(sandbox): detect standard pwsh install path 2026-06-21 01:17:14 +00:00
ttmouse b0bc394b03 style: gofmt 格式化 heartbeat.go 和 write_heartbeat/main.go 2026-06-21 03:08:40 +08:00
ttmouse f899bdfc0c fix(heartbeat): 所有 interval 访问加 null 防御
所有 task.interval / draft.interval / prev.interval 访问改为
(task.interval || '') 形式,防止 null/undefined 时 .match()
.includes() .replace() 崩溃。
2026-06-21 02:56:45 +08:00
ttmouse cd08ab4fa8 fix(heartbeat): 防御 interval 为空时 .match() 崩溃
所有 draft.interval.match() 调用改为 (draft.interval || '').match(),
防止 interval 意外为 null/undefined 时抛出异常。
2026-06-21 02:53:36 +08:00
ttmouse c8ce403f8f fix(heartbeat): 切换周期/间隔模式不再污染 interval 值
用 useRef 保存切换前的原始 interval,切回间隔时恢复原值,
避免点一下周期再切回去后 interval 被改成 cycle 格式。
2026-06-21 02:50:00 +08:00
ttmouse eb6d6f4b32 fix(heartbeat): 任务卡片周几列表简化
每周/每两周任务卡片只显示'每周'/'每两周' + 时间,
不列出具体星期几,卡片信息保持简洁。
2026-06-21 02:46:11 +08:00
ttmouse 74c8359bae feat(heartbeat): 任务列表时间展示中文友好
- 简单间隔: '30m' → '每30分钟', '1h' → '每1小时'
- 每日: '24h|daily@22:00' → '每天 22:00'
- 每周: '168h|weekly:mon,wed,fri@09:00' → '每周 周一/三/五 09:00'
- 每两周: '336h|biweekly:mon@09:00' → '每两周 周一 09:00'
- 每月: '720h|monthly:15@09:00' → '每月 15日 09:00'
- 每年: '8760h|yearly:1-1@09:00' → '每年 1月1日 09:00'
2026-06-21 02:44:15 +08:00
ttmouse 0e72bc79dc fix(heartbeat): 解决 Codex review 提出的 P1/P2 问题
P1: 将 approvalMode 设置在 tab 层面而非直接操作 controller,
    使用 SetToolApprovalModeForTab 在打开 tab 后立即设置,
    controller 构建时自动继承,避免 drain 待审批的风险。

P2: normalizeHeartbeatApprovalMode 加 trim/lowercase 处理,
    防止人工编辑 JSON 时大小写/空格导致默认 fallback 到 yolo。
2026-06-21 02:41:50 +08:00
ttmouse b51f31b5c0 fix(heartbeat): 编辑时间隔模式初始化修复
- 给 TaskEditor 加 key={editing.id} 确保切换任务时组件重建
- 给 freqType 初始化加防御性空值检查
2026-06-21 02:35:24 +08:00
ttmouse 4707413f86 refactor(heartbeat): 简化代码
- ALL_WEEKDAYS 改为 WEEKDAYS.map(w => w.key) 派生
- 提取 hasWeekdays 变量消除 3 处重复判断
- 时间窗口内联样式搬到 heartbeat.css
- normalizeHeartbeatApprovalMode 调一次存变量复用
2026-06-21 02:21:08 +08:00
ttmouse 6da94c9498 fix(heartbeat): 新建任务默认 approvalMode=yolo,后端运行后补齐
- handleAdd / startNew 路径显式设置 approvalMode: yolo
- executeTask 中标准化后写回 t.ApprovalMode,确保首次运行后 JSON 有正确值
2026-06-21 02:16:10 +08:00
ttmouse 200cc9895a feat(heartbeat): 按间隔模式新增时间窗口限制
后端:
- HeartbeatTask 新增 TimeWindowStart/End 字段
- heartbeatWithinTimeWindow 判断当前时间是否在窗口内
- heartbeatTaskDueAt 在间隔模式下检查时间窗口

前端:
- 间隔模式下新增起止时间选择器(09:00 — 17:00)
- timeWindowStart/timeWindowEnd 类型定义
- 新增 heartbeat.timeWindow i18n 键
2026-06-21 01:53:51 +08:00
ttmouse dcf1ff80c6 fix(heartbeat): 周选择器移至时间右侧,同行显示 2026-06-21 01:46:31 +08:00
ttmouse d5b084e328 fix(heartbeat): 调整周选择器位置和样式
- 将周一到周日选择器移到时间输入框之后
- 样式改用 set-seg / set-seg__btn 以匹配权限等级选择器风格
2026-06-21 01:33:11 +08:00
ttmouse 0398bf0d13 feat(heartbeat): 周期编辑器中增加周一到周日激活日选择器
- 每日/每周/每两周模式均显示周一到周日按钮,默认全部选中
- 点击可切换选中/非选中状态
- 当每日模式取消部分日期时,自动切换为 weekly 时间格式存储
- 后端已支持 weekly 按日调度,无需改动
2026-06-21 01:23:04 +08:00
SivanCola 91fe06db61 Merge pull request #4949 from SivanCola/fix/remove-mimo-builtins
Remove MiMo built-in guidance / 移除 MiMo 内置引导
2026-06-21 01:19:51 +08:00
SivanCola 85ff986569 Merge pull request #4951 from SivanCola/fix/codebase-memory-mcp-auto-index
Support codebase-memory MCP auto-indexing / 支持 codebase-memory MCP 自动索引
2026-06-21 01:16:52 +08:00
SivanCola d9e453a1be fix(config): migrate MiMo built-ins to custom providers 2026-06-21 01:10:49 +08:00
ttmouse ad1b457b8e chore(heartbeat): write_heartbeat 工具同步新增 ApprovalMode 字段 2026-06-21 01:06:39 +08:00
ttmouse 0ef1321e83 fix(heartbeat): 收紧 approvalMode 类型精度
- heartbeart.types.ts: approvalMode 使用联合类型替代 string
- HeartbeatPanel.tsx: normalizeMode 签名同步收紧
2026-06-21 00:59:41 +08:00
ttmouse cfd92861e2 fix(heartbeat): 权限等级选择器文本走国际化
- 新增 en.ts / zh.ts / zh-TW.ts 中 approval mode 相关 i18n 键
- HeartbeatPanel 中 label、tooltip、hint 使用 t() 替换硬编码中文
2026-06-21 00:58:21 +08:00
SivanCola 45367085bf Support codebase-memory MCP auto-indexing 2026-06-21 00:20:39 +08:00
ttmouse a40b10020c feat(heartbeat): 自动化任务新增权限等级选择
自动化任务创建时支持选择权限等级(Ask/Auto/YOLO),默认 YOLO 模式以避免任务执行时因权限授权导致暂停。

- backend: HeartbeatTask 新增 ApprovalMode 字段,executeTask 时应用到 controller
- frontend: TaskEditor 新增权限等级选择器,使用 set-seg 分段按钮
- types: HeartbeatTask 接口新增 approvalMode 字段
- css: 新增 .heartbeat-editor__mode-hint 样式
2026-06-21 00:10:38 +08:00
SivanCola 49c14762b7 Merge pull request #4942 from SivanCola/fix/sidebar-resilience-integration
Integrate desktop sidebar runtime fixes / 整合桌面侧边栏运行态修复
2026-06-20 23:57:34 +08:00
SivanCola fea81e3aa5 Address desktop sidebar bot review feedback 2026-06-20 23:52:50 +08:00
SivanCola 62a8c3f79e Merge pull request #4938 from SivanCola/fix/pe-handoff-planner-routing
Fix PE handoff and planner routing / 修复 PE 交接与规划路由
2026-06-20 23:38:10 +08:00
e89419af24 Integrate desktop sidebar runtime fixes
Combine the sidebar/session resilience work from related PRs into one integration change.

- Preserve session runtime teardown before trash moves and CloseTab snapshot ordering.
- Reset planner sessions on desktop tab activation changes.
- Add bounded sidebar sidecar reads and safe ListProjectTree fan-out without caching runtime ProjectNode state.
- Keep REASONIX_DEV single-instance bypass while allowing path-scoped release/dev locks.

Co-authored-by: ttmouse <[email protected]>
Co-authored-by: GTC2080 <[email protected]>
Co-authored-by: liaoyl830 <[email protected]>
Co-authored-by: myipanta <[email protected]>
2026-06-20 23:36:03 +08:00
SivanCola b54e8dcaa3 Address PE handoff bot findings 2026-06-20 23:27:29 +08:00
YHH 5d1ad2ae8c Merge pull request #4939 from esengine/refactor/port-desktop
refactor(desktop): migrate the App to the SessionAPI port
2026-06-20 23:11:29 +08:00
esengine dbb6f83acd refactor(desktop): migrate the App to the SessionAPI port
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.
2026-06-20 08:06:35 -07:00
SivanCola 582f31fd67 Fix PE handoff and planner routing 2026-06-20 23:05:08 +08:00
YHH c202f97035 Merge pull request #4935 from esengine/refactor/port-cli
refactor(control): migrate the cli TUI to the SessionAPI port
2026-06-20 22:44:19 +08:00
esengine 9ae96d8225 refactor(control): migrate the cli TUI to the SessionAPI port
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.
2026-06-20 07:39:54 -07:00
YHH 2909ef120f Merge pull request #4932 from esengine/refactor/port-serve-acp
refactor(control): migrate serve and acp to the SessionAPI port
2026-06-20 22:30:41 +08:00
esengine 1280c0f759 refactor(control): migrate serve and acp to the SessionAPI port
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.
2026-06-20 07:25:45 -07:00
YHH 48e5b99067 Merge pull request #4931 from esengine/refactor/control-driving-port
refactor(control): introduce the SessionAPI driving port; migrate bot to it
2026-06-20 22:15:57 +08:00
esengine 3e625b91d9 refactor(control): introduce the SessionAPI driving port; migrate bot to it
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/...
2026-06-20 07:11:19 -07:00
YHH d02457ee78 Merge pull request #4928 from esengine/refactor/store-session-sidecar-authority
refactor(store): add a store package owning the session-sidecar layout
2026-06-20 22:00:46 +08:00
esengine 98a57ded7b refactor(store): add a store package owning the session-sidecar layout
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.
2026-06-20 06:56:15 -07:00
YHH bc8249c307 Merge pull request #4922 from esengine/refactor/control-approval-manager
refactor(control): extract approval/ask bookkeeping into an approvalManager
2026-06-20 21:32:26 +08:00
esengine 726036bd15 refactor(control): extract approval/ask bookkeeping into an approvalManager
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.
2026-06-20 06:28:01 -07:00
YHH c23d40f74c Merge pull request #4918 from esengine/refactor/control-goal-machine
refactor(control): extract goal FSM into a goalMachine collaborator
2026-06-20 21:10:33 +08:00
YHH 7409342b70 Merge pull request #4919 from esengine/chore/drop-dead-inspect-and-npm-lock
chore: remove unused inspect package and redundant npm lockfile
2026-06-20 21:09:12 +08:00
esengine dbaea84333 refactor(control): extract goal FSM into a goalMachine collaborator
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).
2026-06-20 06:06:21 -07:00
esengine bb06f5b4b6 chore: remove unused inspect package and redundant npm lockfile
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.
2026-06-20 05:59:16 -07:00
YHH be67a498ad Merge pull request #4897 from esengine/perf/desktop-gate-topic-migration-marker
perf(desktop): gate legacy topic migration behind a per-dir marker
2026-06-20 13:45:17 +08:00
esengine ad3d742aeb perf(desktop): gate legacy topic migration behind a per-dir marker
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.
2026-06-19 22:34:35 -07:00
YHH 6d404d8009 Merge pull request #4891 from esengine/perf/goal-state-write-off-controller-lock
perf(control): write goal state off the controller lock
2026-06-20 12:45:44 +08:00
esengine 341f7209cd perf(control): write goal state off the controller lock
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.
2026-06-19 21:40:26 -07:00
YHH 6617c81bf6 Merge pull request #4890 from esengine/refactor/branchmeta-schema-version
refactor(agent): version BranchMeta counts instead of overloading Turns==0
2026-06-20 12:02:14 +08:00
esengine 7ebb08e87e refactor(agent): version BranchMeta counts instead of overloading Turns==0
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.
2026-06-19 20:58:27 -07:00
YHH ef7bf97005 Merge pull request #4888 from esengine/perf/memory-writes-off-controller-lock
perf(control): move memory-write disk I/O off the controller lock
2026-06-20 11:50:52 +08:00
esengine f6ba7553d7 perf(control): move memory-write disk I/O off the controller lock
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.
2026-06-19 20:47:01 -07:00
YHH 33545f644c Merge pull request #4886 from esengine/perf/drop-redundant-session-disk-cache
perf(desktop): drop the now-redundant project-session disk cache
2026-06-20 11:37:06 +08:00
esengine 5db1d0b760 perf(desktop): drop the now-redundant project-session disk cache
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.
2026-06-19 20:33:09 -07:00
YHH 69ccca3f5c Merge pull request #4884 from esengine/fix/fork-sidecar-and-control-cleanups
fix(control): seed fork/branch sidecar counts; tighten two latent issues
2026-06-20 11:23:40 +08:00
esengine 73e2025074 fix(control): seed fork/branch sidecar counts; tighten two latent issues
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.
2026-06-19 20:18:49 -07:00
YHH 7b051ee033 Merge pull request #4882 from esengine/perf/session-list-sidecar-counts
perf(sessions): cache turn count + preview in the .meta sidecar
2026-06-20 11:01:29 +08:00
esengine 249a4f8b70 perf(sessions): cache turn count + preview in the .meta sidecar
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.
2026-06-19 19:57:29 -07:00
SivanCola 7032f39336 Merge pull request #4871 from eghrhegpe/pr-skill-scripts
feat(skill): list scripts/ directory in skill body for bash execution
2026-06-20 01:47:08 +08:00
SivanCola 60dfc0de58 Tighten skill script cache guard
Co-authored-by: SivanCola <[email protected]>
2026-06-20 01:29:45 +08:00
SivanCola db43de8974 Merge pull request #4866 from SivanCola/fix/mcp-plugin-integration-4788
Integrate MCP plugin fixes / 集成 MCP 插件修复
2026-06-20 01:27:46 +08:00
eghrhegpe dd753d0c96 optimize: align loadBodyWithScripts style with loadBodyWithReferences 2026-06-20 01:26:27 +08:00
SivanCola 87d9d12d88 Address MCP integration review findings 2026-06-20 01:23:24 +08:00
eghrhegpe 26f86be20b fix: filter hidden files and non-script extensions in scripts/ listing 2026-06-20 01:18:34 +08:00
SivanCola 9e17a6079a Fix lifecycle MCP spawn coalescing 2026-06-20 01:12:46 +08:00
SivanCola 379ef9f783 Merge pull request #4788 from cyq1017/codex/reasonix-4734-http-session-reinit
fix(plugin): reinitialize expired HTTP MCP sessions
2026-06-20 01:10:31 +08:00
eghrhegpe d3bfc928de feat(skill): list scripts/ directory in skill body for bash execution 2026-06-20 01:09:36 +08:00
SivanCola 02c85a8a06 Merge pull request #4862 from eghrhegpe/feat/session-tools
feat: add list_sessions and read_session tools
2026-06-20 01:04:49 +08:00
SivanCola 15eedba89b Stabilize background task panic test
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]>
2026-06-20 01:00:52 +08:00
SivanCola ee6b2a4ef0 Fix session tool schema validation 2026-06-20 00:50:47 +08:00
eghrhegpe 040043c2ea feat: add list_sessions and read_session tools for cross-session AI context sharing 2026-06-20 00:49:30 +08:00
SivanCola 48eaeffef1 Merge pull request #4865 from SivanCola/feature/crash-stats-long-tail
Improve crash stats dashboard navigation / 优化崩溃统计看板导航
2026-06-20 00:46:18 +08:00
SivanCola 193ab2ff2c Merge main-v2 into HTTP MCP session PR 2026-06-20 00:44:44 +08:00
SivanCola 21fbec6845 Fix stats window metric counts 2026-06-20 00:41:05 +08:00
SivanCola 54d60e64df Merge pull request #4811 from wangwangcodecode/fix/unify-history-normalization
fix(agent,provider): unify history normalization into single NormalizeMessages entry point
2026-06-20 00:40:54 +08:00
SivanCola ca10c06989 Stabilize ACP delete teardown test 2026-06-20 00:31:35 +08:00
SivanCola 5082b12a32 Merge remote-tracking branch 'origin/main-v2' into fix/mcp-plugin-integration-4788 2026-06-20 00:29:46 +08:00
SivanCola 27a9ea7c60 Fix atomic write permission test on Windows
Skip Unix permission-bit assertions on Windows, where os.FileMode only reflects limited permission state.

Co-authored-by: SivanCola <[email protected]>
2026-06-20 00:27:57 +08:00
SivanCola 607cb9b95a Fix session-safe history normalization
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]>
2026-06-20 00:25:30 +08:00
wangwangcodecode 6486cec5f6 fix(agent,provider): unify history normalization into single NormalizeMessages entry point
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.
2026-06-20 00:25:30 +08:00
SivanCola e4f157c3bb Format integrated Go changes
Run gofmt on the integrated bot and control changes so the CI formatting gate passes.

Co-authored-by: SivanCola <[email protected]>
2026-06-20 00:21:54 +08:00
SivanCola 6e4a8e5ff2 Merge pull request #4856 from CVEngineer66/fix/todo-panel-and-prompt-cards
fix: simplify blocking prompt cards and harden todo state handling
2026-06-20 00:19:18 +08:00
SivanCola 3791f7a37f Clean integrated config migration test formatting
Keep the integrated migration test file gofmt-clean after combining the MCP-related PRs.

Co-authored-by: SivanCola <[email protected]>
2026-06-20 00:15:23 +08:00
SivanCola 22879629b0 Improve crash stats dashboard navigation 2026-06-20 00:15:06 +08:00
SivanCola 5e4c70eb29 Fix HTTP MCP session reinit race
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]>
2026-06-20 00:10:14 +08:00
cyq 425445e73d fix(plugin): reinitialize expired HTTP MCP sessions 2026-06-20 00:10:14 +08:00
Diarica ce0b46089f fix: don't truncate MCP paths containing spaces
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.
2026-06-20 00:10:05 +08:00
Teng Yanxi 4bf191962b fix: resolve temp file leaks, HljsDiff virtualization bugs, and tighten credential dir permissions 2026-06-20 00:09:50 +08:00
Teng Yanxi 0fc4303469 feat: data directory separation and frontend virtualization 2026-06-20 00:09:50 +08:00
SivanCola 7596ac9c3b Fix HTTP MCP session reinit race
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]>
2026-06-20 00:05:38 +08:00
wufengfan ae1da05c58 fix(prompt): restore numeric option labels 2026-06-19 23:37:22 +08:00
SivanCola 6d6e1e8b70 Merge pull request #4801 from Li-Charles-One/feat/creation-desktop-style-scaffold
feat(desktop): 新增「创作 Creation」桌面风格与视觉实现
2026-06-19 23:37:16 +08:00
SivanCola 948a34a683 Fix creation layout localization polish
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]>
2026-06-19 23:30:28 +08:00
eghrhegpe 78181b9793 feat: add list_sessions and read_session tools for cross-session AI context sharing 2026-06-19 23:20:31 +08:00
SivanCola 3ba5ebdd40 Merge pull request #4857 from GTC2080/GTC/desktop-settings-lazy-secondary-pages
perf(desktop): lazy load secondary settings code / 延迟加载设置页次级代码
2026-06-19 23:11:48 +08:00
SivanCola 7106a8e236 Merge pull request #4839 from assoyuan/fix/desktop-display-mode-persistence
fix(desktop): persist transcript display mode in user config / 修复“会话展示模式”无法保存
2026-06-19 23:09:57 +08:00
SivanCola b42a362100 Merge pull request #4848 from ttmouse/pr/fix-memory-tab-label
fix(memory): 标签选择器显示项目名而非模型名
2026-06-19 23:00:15 +08:00
eghrhegpe f6d3b5a8eb feat: add list_sessions and read_session tools for cross-session AI context sharing 2026-06-19 22:56:40 +08:00
SivanCola 2c415def3e Merge pull request #4858 from ttmouse/pr/session-cache
sidebar: 会话列表加载性能优化
2026-06-19 22:54:54 +08:00
SivanCola 4cf8164ffb fix: restore project tree session cache validity
Co-authored-by: SivanCola <[email protected]>
2026-06-19 22:46:00 +08:00
ttmouse 428313f292 knownSessionDirs: 跳过不存在的项目目录,防止 os.ReadDir 挂载卡死 2026-06-19 22:06:49 +08:00
ttmouse e1b554baa7 gofmt 2026-06-19 21:45:42 +08:00
GTC2080 86935d642e perf(desktop): lazy load secondary settings code 2026-06-19 23:30:26 +10:00
ttmouse d29e591100 sidebar: 会话列表加载性能优化
- 并发读取所有工作区的会话目录(goroutine + WaitGroup)
- 内存缓存 sessionListCache:同次会话内重渲染零 I/O
- 磁盘缓存 sessionDiskCache:签名校验后跳过 LoadBranchMeta + previewSession
- 会话变更时(emitProjectTreeChanged)自动失效缓存

效果:10+ 工作区 1100+ 会话,首次 0.87s vs 1.6s,二次启动 8ms
2026-06-19 21:17:04 +08:00
wufengfan fb2d6b37a8 fix(todo): tighten readiness gating and canonical snapshots 2026-06-19 19:25:21 +08:00
ttmouse 988eb855c5 fix(memory): 自定义工作区下拉框 + 去重 + 刷新修复
- 工作区选择器改为 AnchoredPopover 自定义下拉框,30px 高度对齐 tab
- 多个工作区:显示下拉框可切换;单个工作区:显示静态名称
- 下拉列表按 workspaceRoot 去重,同一项目多个对话只显示一个
- 新增 effectiveTabId 确保去重后仍能正确匹配选中项
- 切换工作区时立即清空旧数据,避免残留
- 所有回调统一迁移到 effectiveTabId(含 acceptMemorySuggestion、
  acceptSkillSuggestion、refreshSuggestions 依赖数组)
2026-06-19 17:55:27 +08:00
Li-Charles-One e5f431ea38 feat(desktop): tune creation theme accents 2026-06-19 16:47:38 +08:00
Li-Charles-One a9788eb4bc feat(desktop): polish creation light settings 2026-06-19 16:01:06 +08:00
ttmouse ae8570cd11 fix(memory): 工作区去重 + 切换时清空旧数据
- 下拉列表按 workspaceRoot 去重,同一项目多个对话只显示一个
- 切换工作区时立即清空 view,避免旧数据残留
- wsSelector 提取为公共变量,不可用状态也显示工作区选择器
2026-06-19 15:48:02 +08:00
Li-Charles-One 6ee3c64dae feat(desktop): refine creation settings panel 2026-06-19 15:41:38 +08:00
Li-Charles-One fa66389227 fix(desktop): clear creation light footer 2026-06-19 15:10:20 +08:00
wufengfan 41e0322c4e fix(todo): restore task panel and simplify prompt cards 2026-06-19 15:07:08 +08:00
Li-Charles-One cf1d129f3b feat(desktop): tint creation light canvas 2026-06-19 15:04:52 +08:00
Li-Charles-One 56aa58f438 fix(desktop): neutralize creation theme tinting 2026-06-19 14:53:22 +08:00
Li-Charles-One 6875fd9102 feat(desktop): unify creation dark surfaces 2026-06-19 14:03:12 +08:00
Li-Charles-One 955c4139ee fix(desktop): label creation tool folds 2026-06-19 13:41:33 +08:00
Li-Charles-One 277f7b8479 feat(desktop): polish creation sidebar and welcome 2026-06-19 13:17:45 +08:00
Li-Charles-One 395baf980e feat(desktop): refine creation message chrome 2026-06-19 12:16:44 +08:00
Li-Charles-One a1c24c682d feat(desktop): move creation automation entry 2026-06-19 11:34:02 +08:00
ttmouse 3c3ffac05b fix(memory): 标签选择器显示项目名而非模型名
记忆设置面板的标签选择器(mem-tab-selector)之前显示的是模型名
(如 deepseek-v4-flash),导致多标签场景下所有选项完全相同,
无法区分不同工作区。

修复方案:将下拉列表的显示优先级从 label(模型名)改为
workspaceName(项目目录名),利用 TabMeta 已有的 workspaceName
字段,不改动 tab.Label(它同时被 composer 和状态栏使用)。
2026-06-19 11:26:02 +08:00
Li-Charles-One 26668a5be5 feat(desktop): refine creation sidebar 2026-06-19 11:22:20 +08:00
Li-Charles-One ef12be0ed7 feat(desktop): refine creation topicbar editing 2026-06-19 11:22:20 +08:00
Li-Charles-One 96161aaf8b feat(desktop): lighten creation run status 2026-06-19 11:22:20 +08:00
Li-Charles-One b756e924a2 fix(desktop): use z-index token for creation composer 2026-06-19 11:22:20 +08:00
Li-Charles-One f3190edf46 feat(desktop): polish creation composer 2026-06-19 11:22:20 +08:00
Li-Charles-One 70a9ce94c9 feat(desktop): refine creation welcome screen 2026-06-19 11:22:20 +08:00
Li-Charles-One 60bb6182a0 fix(desktop): align creation context metrics 2026-06-19 11:22:20 +08:00
Li-Charles-One 8107cb3eea feat(desktop): polish creation context panel 2026-06-19 11:22:20 +08:00
Li-Charles-One 10c8d9e68f feat(desktop): 优化 creation 会话界面 2026-06-19 11:22:20 +08:00
Li-Charles-One b02a1f65ab fix(config): 补全 creation 文档注释与测试,修复 styles.css EOF 空行
- styles.css: 删除文件末尾多余空行 (git diff --check)
- config.go / render.go / reasonix.example.toml: 注释补全 creation
- edit_test.go: 归一化测试表新增 creation case
- render_test.go: 新增 creation 布局风格 TOML round-trip 测试
2026-06-19 11:22:20 +08:00
Li-Charles-One 09f29d33cf fix(config): 放行 creation 桌面风格的后端校验
脚手架只改了前端,后端 SetDesktopLayoutStyle 和 normalizeDesktopLayoutStyle 仍只认 classic|workbench,选中创作即报 'must be classic|workbench'。两处同步放行 creation。
2026-06-19 11:22:20 +08:00
Li-Charles-One f9d17de88a feat(desktop): 新增「创作」桌面风格脚手架
在经典 (classic) 和工作台 (workbench) 之后新增第三种桌面风格「创作」(creation),通过 .app--creation CSS 作用域完全隔离,可独立重新设计。

- App.tsx:扩展 DesktopLayoutStyle 联合类型 + normalize
- 新增独立的 sidebarCreation 标志位和 app--creation 根 class(刻意不设置 sidebarWorkbench;当前复用经典 JSX 结构)
- SettingsPanel.tsx:风格选择器加入 creation 选项
- bridge.ts dev mock 接受 creation,types.ts 注释加宽
- en/zh/zh-TW 新增 Creation / 创作 / 創作 标签
- styles.css 新增空的 .app--creation 作用域块(遵守 AGENTS.md,未触碰全局 base tokens)

后端已接受任意字符串风格名,无需改动 Go。
pnpm typecheck 通过;前端测试全部通过。
2026-06-19 11:22:20 +08:00
Yuan DongjieandCursor ee36325bfc fix(desktop): persist transcript display mode in user config / 修复会话展示模式无法保存
Co-authored-by: Cursor <[email protected]>
2026-06-19 07:17:21 +08:00
SivanCola ba7a50b2d1 Merge pull request #4833 from SivanCola/fix/cache-impact-guard-hardening
ci: harden cache impact guard / 加固缓存影响门禁
2026-06-19 03:27:31 +08:00
SivanCola 734f93d1ff Merge pull request #4832 from SivanCola/fix/codeql-bare-ref-path
Fix bare ref path probing / 修复 bare ref 路径探测
2026-06-19 03:25:31 +08:00
SivanCola 44072a80fb ci: harden cache impact guard 2026-06-19 03:18:15 +08:00
SivanCola 0d044521c3 Fix unsafe bare ref path probing 2026-06-19 03:16:22 +08:00
SivanCola 987db664a7 Merge pull request #4829 from SivanCola/fix/cache-impact-review-gate
ci: require cache impact notes for sensitive PRs / 要求缓存敏感 PR 填写影响说明
2026-06-19 03:13:41 +08:00
SivanCola 6e6f1854a0 ci: require cache impact notes for sensitive PRs 2026-06-19 03:06:26 +08:00
SivanCola 65bd190a76 Merge pull request #4757 from GTC2080/GTC/fix-codegraph-daemon-idle
fix(plugin): bound CodeGraph daemon idle timeout
2026-06-19 03:02:07 +08:00
SivanCola 0b3c12013c Merge pull request #4760 from GTC2080/GTC/fix-4751-desktop-mcp-persistence
fix(desktop): persist .mcp.json server edits
2026-06-19 03:01:48 +08:00
SivanCola 02f787396b Merge pull request #4761 from GTC2080/GTC/fix-4759-session-project-restore
fix(desktop): restore session workspace from pinned metadata
2026-06-19 03:01:32 +08:00
SivanCola d8d3f5fbfb Merge pull request #4762 from GTC2080/GTC/issue-4758-empty-new-session
fix(desktop): prevent stale history after new session
2026-06-19 03:01:17 +08:00
SivanCola 8afe6f074b Merge pull request #4795 from GTC2080/GTC/fix-official-provider-context-window
fix(config): backfill official context windows
2026-06-19 03:00:58 +08:00
SivanCola cf28aebc97 Merge pull request #4827 from eghrhegpe/main-v2
feat: goal enforcement enhancements — idle detection, plan-exec, prometheus, wisdom, module routing
2026-06-19 02:52:43 +08:00
SivanCola 2bed4482c8 fix: keep parallel orchestration cache conservative
Avoid changing the default parallel_tasks schema, built-in explore/research tool lists, and default plan approval prompt while retaining explicit orchestration runtime support.

Co-authored-by: SivanCola <[email protected]>
2026-06-19 02:40:50 +08:00
SivanCola 4a59ba2eb0 Merge pull request #4828 from SivanCola/fix/tui-transcript-divider-width
Fix TUI transcript divider width / 修复 TUI transcript 分割线宽度
2026-06-19 02:40:36 +08:00
SivanCola 264f600345 fix: stabilize goal orchestration changes
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]>
2026-06-19 02:29:58 +08:00
SivanCola e2acf5afc1 Merge main-v2 into new-session history fix 2026-06-19 02:27:12 +08:00
eghrhegpe 51e30f228e fix: remove conflicting planApprovedMessage; consolidate parallel hint
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).
2026-06-19 02:06:59 +08:00
036e337ca9 Fix TUI transcript divider width
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]>
2026-06-19 02:05:44 +08:00
eghrhegpe 1e51cde2f8 optimize: reduce token overhead per SivanCola feedback
- Trimmed explore/research skill body additions to single lines
- Halved /plan-exec routing rules (8→5 terse lines)
- Halved Prometheus prompt (was 10 lines, now 3)
- Shortened intercept & idle messages
- Shortened planApprovedHint
2026-06-19 02:02:49 +08:00
eghrhegpe 4f03bd9238 feat(skill): enable parallel_tasks in explore/research subagents
- Add parallel_tasks to readCodeTools so explore/research subagents can
  dispatch independent investigations concurrently
- Review tools explicitly removes parallel_tasks (reviews are serial)
- Update explore/research skill bodies to mention parallel_tasks usage
- Single-layer recursion: sub-sub-agents still cannot call parallel_tasks
2026-06-19 01:59:33 +08:00
eghrhegpe a79acae95f enhance(prometheus): add parallel research hint to prompts
Prometheus prompt now suggests parallel_tasks for independent research
directions before planning. /plan-exec routing rules also emphasize
parallel research/exploration across modules.
2026-06-19 01:54:45 +08:00
eghrhegpe 31e30df61b style: gofmt fixes for CI compliance 2026-06-19 01:48:20 +08:00
SivanCola 0034cc8baf Merge pull request #4825 from esengine/dependabot/npm_and_yarn/workers/crash-report/multi-6269ed2cd4
chore(deps): bump undici and wrangler in /workers/crash-report
2026-06-19 01:47:47 +08:00
SivanCola f08fd17df9 Merge pull request #4793 from ttmouse/pr/shared-mcp
修复(桌面端): 按 workspace root 共享 plugin.Host,解决每个标签页启动一个 codegraph 进程的问题
2026-06-19 01:47:24 +08:00
eghrhegpe a38f9354a5 enhance: file-based wisdom, module preview, deep module scan
- 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)
2026-06-19 01:43:58 +08:00
eghrhegpe 842ba015c3 feat(plan-exec): auto-detect project modules for task routing
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.
2026-06-19 01:43:58 +08:00
eghrhegpe 0d82184ad4 enhance(plan-exec): module-aware task routing
- /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
2026-06-19 01:43:58 +08:00
eghrhegpe 89c2d814c0 feat: wisdom accumulation in parallel_tasks + goal state persistence
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.
2026-06-19 01:43:58 +08:00
eghrhegpe 61aa616bc1 feat(ui): strip goal markers from display text
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.
2026-06-19 01:43:58 +08:00
eghrhegpe df6b4db13b docs: add Prometheus interview section 2026-06-19 01:43:58 +08:00
eghrhegpe d397380d84 feat: add /prometheus interactive planning interview
Prometheus interviews the user one question at a time before creating a
plan. Reuses the goal loop for the interview cycle. Supports --strict.
2026-06-19 01:43:58 +08:00
eghrhegpe 8bc676a65f enhance(plan-exec): add structured batch execution with --strict support
-  now shows a structured execution prompt with clear rules
- Supports  for strict goal mode
- Shows completion progress (X/N steps done)
- Improved instructions for parallel batching, verification, recovery
2026-06-19 01:43:58 +08:00
eghrhegpe c7f7370026 fix: address P2 review feedback
- Call GoalStrict after SetGoal so strict mode takes effect
- Validate all tasks in runAsBackgroundJobs before dispatching
- Add parallel_tasks to subagent exclusion list
2026-06-19 01:43:58 +08:00
eghrhegpe 50a17187ce feat(control): add /plan-exec command for parallel plan execution
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.
2026-06-19 01:43:57 +08:00
eghrhegpe 0bc446bd72 feat(plan): suggest parallel_tasks for independent plan steps
Append a hint to planApprovedMessage suggesting parallel execution
via parallel_tasks when steps have no overlapping files or deps.
2026-06-19 01:43:57 +08:00
eghrhegpe 72b34ee2e4 feat(parallel_tasks): add depends_on for task dependency ordering
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.
2026-06-19 01:43:57 +08:00
eghrhegpe b74987193f docs: add Goal enforcement guide in Chinese 2026-06-19 01:43:57 +08:00
eghrhegpe 6c873e564e feat(goal): add idle detection in goal loop
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.
2026-06-19 01:43:57 +08:00
SivanCola 58b815f3bc Fix per-tab shared MCP disable
Co-authored-by: SivanCola <[email protected]>
2026-06-19 01:42:33 +08:00
dependabot[bot] 6ef7588f04 chore(deps): bump undici and wrangler in /workers/crash-report
Bumps [undici](https://github.com/nodejs/undici) to 7.28.0 and updates ancestor dependency [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler). These dependencies need to be updated together.


Updates `undici` from 7.24.8 to 7.28.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.24.8...v7.28.0)

Updates `wrangler` from 4.99.0 to 4.102.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/[email protected]/packages/wrangler)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.28.0
  dependency-type: indirect
- dependency-name: wrangler
  dependency-version: 4.102.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-06-18 17:32:10 +00:00
SivanCola 8688ce1ff1 Merge pull request #4824 from esengine/feature/integrate-goal-contributor-prs
Integrate Goal enforcement and draft preservation / 集成 Goal 执行增强与草稿保留
2026-06-19 01:29:50 +08:00
SivanCola 57a95b5bfc merge: sync goal integration with main-v2 2026-06-19 01:25:26 +08:00
SivanCola 951588bf29 fix: address goal enforcement review findings 2026-06-19 01:18:37 +08:00
SivanCola e34cb1ca1d Merge pull request #4823 from esengine/feature/goal-autoresearch-upgrade
Auto-start AutoResearch for strong goals / 强信号目标自动启动 AutoResearch
2026-06-19 01:13:50 +08:00
SivanCola a2bd224437 Fix shared MCP spawn race
Co-authored-by: SivanCola <[email protected]>
2026-06-19 01:13:24 +08:00
SivanCola bc9961751d fix(goal): preserve AutoResearch startup context 2026-06-19 01:05:54 +08:00
SivanCola 96e9a53d4f fix: satisfy CI formatting and lint 2026-06-19 00:58:35 +08:00
SivanCola b4b9f4ae7f Merge main-v2 into shared MCP host PR 2026-06-19 00:47:56 +08:00
GTC2080andSivanCola 424e924641 fix: preserve draft when enabling goal mode
Co-authored-by: SivanCola <[email protected]>
2026-06-19 00:41:58 +08:00
SivanCola f005031621 Fix shared MCP host concurrency and lifecycle
Co-authored-by: SivanCola <[email protected]>
2026-06-19 00:41:49 +08:00
eghrhegpe 6e5f1b84c4 feat(goal): add OMO-inspired enforcement features
- 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
2026-06-19 00:30:09 +08:00
ttmouse 6ffb765071 fix: 根据 Codex review 修复 10 个问题 + 追加子代理审核的 3 个 nit
修复了 PR #4793 review 中 Codex Bot 指出的全部问题:

### 正确性问题
1. lazy.go: 消除 cache-hit 路径双重解锁 panic
2. boot.go: eager 竞态下 ErrServerAlreadyConnected → ToolsFor fallback
3. shared_host.go: pgrep -P 无子进程时不跳过后续收割
4. shared_host.go: 增加 ppid 检查,仅杀 init 收养的孤儿
5. tabs.go: boot.Build 失败时 releaseSharedHost
6. boot.go: eager Add 增加 5s per-plugin 超时

### 并发问题
7. plugin.go: Host 级 spawningMu 守卫防止并发启动风暴
8. tabs.go: attachExistingSessionRuntime 路径 releaseSharedHost

### 覆盖完整性
9. boot.go: 经济模式适配 ErrServerAlreadyConnected + ToolsFor

### 稳定性
10. plugin.go: ToolsFor 签名改为 ToolsFor(ctx, name),消除 context.Background()

### 追加修复(子代理审核)
11. lazy.go: cache-hit Execute 前 unlock,匹配 spawnReady 模式
12. lazy.go: 注释行号修正
13. shared_host.go: pgrep -f 中 . 转义为 \.
2026-06-19 00:26:23 +08:00
SivanCola 95b8b590eb feat(goal): auto-start AutoResearch for strong goals 2026-06-19 00:21:06 +08:00
SivanCola ebea82bdae Merge pull request #4813 from ttmouse/pr/heartbeat-tasks
feat: 心跳任务系统 — 定时 AI 提示词注入
2026-06-19 00:18:08 +08:00
SivanCola a7b7e8f294 Respect Reasonix state dir for heartbeat tasks
Co-authored-by: SivanCola <[email protected]>
2026-06-18 23:56:32 +08:00
SivanCola a3e63f5503 Merge pull request #4819 from SivanCola/fix/reusable-blank-tab-title
Fix reusable blank tab title reset / 修复复用空白标签页标题重置
2026-06-18 23:36:51 +08:00
ttmouseandSivanCola 259b3c8c69 Keep active tab stable on title reset failure
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]>
2026-06-18 23:30:16 +08:00
SivanCola 40437e0496 Fix heartbeat scheduling and safe submission
Co-authored-by: SivanCola <[email protected]>
2026-06-18 23:27:05 +08:00
SivanCola 2cc29c5ef9 Merge pull request #4815 from assoyuan/feature/workbench-folder-ux
fix(desktop): refine project tree folder hover and startup expansion / 优化项目树文件夹悬停icon和启动时的文件夹展开逻辑
2026-06-18 23:23:16 +08:00
ttmouseandSivanCola 965ab31eaa Fix reusable blank tab title reset
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]>
2026-06-18 23:12:43 +08:00
SivanCola fb4c0c56b0 Merge pull request #4817 from esengine/feature/auto-research-skill
Add built-in Auto Research skill / 新增内置 Auto Research 技能
2026-06-18 23:11:29 +08:00
SivanCola 14029f2148 Merge pull request #4816 from SivanCola/perf/desktop-startup-settings-integration
Integrate desktop startup and Settings performance fixes / 整合桌面启动与设置性能优化
2026-06-18 23:09:34 +08:00
SivanCola 35e774f894 feat(skill): add built-in auto research protocol 2026-06-18 22:41:38 +08:00
CVEngineer66 f4d17cad6a fix: reduce Settings CPU spike from redundant re-renders and forced layout
Root cause (#4459): configuring custom models on Windows triggered 40%
CPU usage from three compounding issues:
  - ModelPicker re-filtered all refs on every keystroke (no debounce)
  - ProviderEditor reparsed modelNames on every input change (no memo)
  - ModelSwitcher called getBoundingClientRect() in render path

Fixes:
- allRefs(s) → useMemo([s.providers]) in ModelsSection
- providerAccessGroups call → useMemo([s.providers, t]) in ProvidersSection
- modelNames split/trim/filter → useMemo([models])
- ModelChips extracted as standalone memo component
- reload/apply/backgroundApply → useCallback
- uniqueStrings O(n²) → Set-based O(n)
- ModelPicker + ProviderModelDraftPicker search → 150ms debounce
- ModelSwitcher getBoundingClientRect → useEffect + ResizeObserver

(cherry picked from commit ffd8ea1988)
Co-authored-by: SivanCola <[email protected]>
2026-06-18 22:29:39 +08:00
GTC2080 78180424b8 perf(desktop): split startup bundle
(cherry picked from commit 54660abbbb)
Co-authored-by: SivanCola <[email protected]>
2026-06-18 22:29:39 +08:00
GTC2080 d97eafc6ea perf(desktop): avoid full settings load on startup
(cherry picked from commit 060b1fe4b4)
Co-authored-by: SivanCola <[email protected]>
2026-06-18 22:29:39 +08:00
GTC2080andSivanCola 013ecb350f fix: cache settings credential lookups
(cherry picked from commit fb614c59b5)
Co-authored-by: SivanCola <[email protected]>
2026-06-18 22:29:39 +08:00
Yuan Dongjie be2a076b86 fix(desktop): refine project tree folder hover and startup expansion / 优化项目树文件夹悬停icon和启动时的文件夹展开逻辑 2026-06-18 22:17:24 +08:00
SivanCola 863498f9a6 Merge pull request #4710 from lifu963/pr/desktop-clear-session-jobs-artifacts
修复后台任务 teardown 阻塞与延迟清理
2026-06-18 22:01:35 +08:00
SivanCola 01be75333f fix(desktop): guard live settings during active work 2026-06-18 21:39:45 +08:00
ttmouse 25ab916ac0 fix: 修复 Codex 审核提出的 8 个问题
1. saveTasks 前创建 ~/.reasonix 目录 (MkdirAll)
2. 控制器等待超时后不更新 LastRunAt,下次 tick 重试
3. tick 合并 topicId/LastRunAt 而非全量替换,保留并发编辑
4. 年周期值按月、日分开传参,修复 2-3-1 拼接错误
5. 数字输入框清空时回退到 '1' + 单位,不保存裸单位
6. 切换到周期 tab 时初始化 interval 为 24h|daily@09:00
7. 年/月 onChange 传 [month, day] 而非 [-]
2026-06-18 21:17:15 +08:00
ttmouse 21dafbf65d fix: gofmt 格式修正 + lint errcheck 修复 2026-06-18 21:04:55 +08:00
ttmouse dd44f3a879 feat: 心跳任务系统 — 定时 AI 提示词注入
- HeartbeatEngine: 后台 goroutine 调度器, 30s tick
- parseInterval 支持 | 后缀剥离
- Wails bindings: ListTasks/ReloadTasks/SaveTasks/TriggerNow/GenerateID
- App 启动/关闭 hook
- 前端面板: 任务列表 + 编辑/删除/立即执行
- CycleEditor: 每天/每周/双周/每月/每年 子模式
- 周天数选择按钮 + 时间选择器
- 搜索/状态筛选/项目筛选 工具栏
- 单行卡片: 频率+下次执行时间 + 打开对话按钮
- 三语言 i18n (zh/en/zh-TW)
2026-06-18 20:44:06 +08:00
lifu963andCursor 726e32dedd fix(acp): reconcile pending metadata cleanup
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]>
2026-06-18 15:44:56 +08:00
lifu963andCursor 96a1db21fe fix(desktop): make pending trash reconciliation idempotent
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]>
2026-06-18 15:37:44 +08:00
GTC2080 fc5fe53e2d fix(config): backfill official context windows 2026-06-18 15:44:18 +10:00
ttmouse ef953ee2f8 fix(desktop): share plugin.Host across tabs per workspace root
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
2026-06-18 13:25:51 +08:00
cyq 89c771591b fix(plugin): reinitialize expired HTTP MCP sessions 2026-06-18 12:44:14 +08:00
YHH 00a3d29d66 Revert "fix(provider): backfill tool-result name on old-session replay (#4775)" (#4778)
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.
2026-06-18 11:36:36 +08:00
lifu963andCursor d8e0e738bb chore(teardown): clarify cleanup pending edge cases
Document why desktop keeps cleanup running after a timed-out clear rebuild fails, and fix the HasTimedOut doc comment.

Co-authored-by: Cursor <[email protected]>
2026-06-18 11:31:16 +08:00
lifu963andCursor cffc5dca40 fix(teardown): reconcile pending cleanup on boot
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]>
2026-06-18 11:30:22 +08:00
YHH f99684a7a2 fix(provider): backfill tool-result name on old-session replay (#4775)
#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
2026-06-18 11:06:00 +08:00
GTC2080 b772895d05 fix(desktop): ignore stale history loads after new session 2026-06-18 02:50:36 +10:00
GTC2080 924b48fc2a fix(desktop): restore session workspace from pinned metadata 2026-06-18 02:38:11 +10:00
GTC2080 07e688bbaa fix(desktop): persist MCP JSON server edits 2026-06-18 02:25:02 +10:00
lifu963andCursor af84b03811 test(teardown): relax stuck-job timing on Windows
Co-authored-by: Cursor <[email protected]>
2026-06-18 00:24:37 +08:00
lifu963andCursor 7b282ef713 Merge origin/main-v2 into cleanup-pending visibility fixes
Co-authored-by: Cursor <[email protected]>
2026-06-18 00:10:28 +08:00
GTC2080 bf2abf64c4 fix(plugin): bound codegraph daemon idle timeout 2026-06-18 02:05:14 +10:00
lifu963andCursor cd380f2945 test(history): cover pending cleanup tool retrieval
Co-authored-by: Cursor <[email protected]>
2026-06-18 00:02:23 +08:00
lifu963andCursor af57f01f95 fix(history): skip cleanup-pending transcripts
Co-authored-by: Cursor <[email protected]>
2026-06-18 00:01:52 +08:00
lifu963andCursor 6aa8215e36 fix(teardown): hide pending sessions from branches
Co-authored-by: Cursor <[email protected]>
2026-06-18 00:00:20 +08:00
SivanCola ef1f38c898 Merge pull request #4721 from GTC2080/GTC/desktop-performance-audit
Improve desktop session restore performance / 优化桌面会话恢复性能
2026-06-17 22:54:12 +08:00
lifu963andCursor 884c4acf4c fix(test): clean up teardown CI failures
Avoid a race in non-cooperative teardown tests and satisfy staticcheck in the ACP delayed cleanup assertion.

Co-authored-by: Cursor <[email protected]>
2026-06-17 22:27:51 +08:00
lifu963 ade40e0877 Merge bounded background teardown fixes 2026-06-17 22:13:52 +08:00
lifu963andCursor 310c321f7d fix(teardown): hide cleared sessions before async cleanup
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]>
2026-06-17 22:00:27 +08:00
YHH 1e5d796c61 test(provider): cover tool-call name backfill; skip copy when unneeded
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.
2026-06-17 21:48:33 +08:00
lifu963andCursor bd19a28c42 fix(teardown): block cleanup-pending CLI resumes
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]>
2026-06-17 21:42:52 +08:00
CVEngineer abb0310402 fix(provider): backfill empty tool-call name/arguments on old-session replay (#4727)
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
2026-06-17 21:39:00 +08:00
lifu963andCursor 423f07165d fix(teardown): skip cleanup-pending topic sessions
Filter cleanup-pending transcripts from desktop topic lookup so sidebar topic opens and startup fallbacks cannot restore delayed-cleanup sessions.

Co-authored-by: Cursor <[email protected]>
2026-06-17 21:32:30 +08:00
YHH e7fbb07219 ci(desktop): gate stable Windows signing until the release cert is ready
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.
2026-06-17 21:32:30 +08:00
lifu963andCursor 94aefd32ab fix(teardown): block cleanup-pending resumes
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]>
2026-06-17 21:24:46 +08:00
lifu963andCursor c34508df33 fix(teardown): hide cleanup-pending API sessions
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]>
2026-06-17 20:47:42 +08:00
lifu963andCursor 998750f133 fix(teardown): avoid duplicate destroy grace waits
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]>
2026-06-17 20:46:49 +08:00
YHH 6a6e665a75 ci(desktop): Authenticode-sign the Windows installer via SignPath
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.
2026-06-17 20:42:38 +08:00
lifu963andCursor 6350c29b93 fix(teardown): delay delete and trash cleanup
Co-authored-by: Cursor <[email protected]>
2026-06-17 20:16:33 +08:00
lifu963andCursor 49fb97dd6f fix(teardown): defer cleanup for stuck jobs
Co-authored-by: Cursor <[email protected]>
2026-06-17 20:13:47 +08:00
lifu963andCursor 7a7962d688 fix(agent): hide cleanup-pending sessions
Co-authored-by: Cursor <[email protected]>
2026-06-17 20:11:12 +08:00
lifu963andCursor a2cd780dfb fix(control): delay cleanup after teardown timeout
Co-authored-by: Cursor <[email protected]>
2026-06-17 20:09:46 +08:00
lifu963andCursor f77fe10531 fix(jobs): bound background teardown waits
Co-authored-by: Cursor <[email protected]>
2026-06-17 20:08:28 +08:00
GTC2080 dcc434e562 perf: avoid redundant desktop session reloads 2026-06-17 18:38:32 +10:00
lifu963andCursor 7e7c47d7e2 fix(desktop): remove job artifacts on clear
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]>
2026-06-17 15:45:49 +08:00
SivanCola f944dfb73d Merge pull request #4673 from opengis-agent/fix/setup-model-selection-regression
fix(setup): restore sibling built-in entries when re-running setup wizard
2026-06-17 15:15:22 +08:00
SivanCola c6e08424b0 Merge pull request #4705 from SivanCola/codex/fix-bash-detached-reap
fix(bash): preserve explicit detached background jobs
2026-06-17 15:10:55 +08:00
Sivan e1ce8fbdb8 fix(bash): preserve explicit detached background jobs 2026-06-17 15:06:26 +08:00
SivanCola 934b1aa5ca Merge pull request #4698 from SivanCola/codex/fix-no-auth-settings-pr4685
fix: align no-auth provider settings state
2026-06-17 15:02:33 +08:00
Sivan 089c7a9a32 fix: align no-auth provider settings state 2026-06-17 14:59:07 +08:00
SivanCola 6dd9db13bb Merge pull request #4695 from esengine/codex/fix-settings-refresh-test-runtime
fix: keep settings refresh test off generated runtime imports
2026-06-17 14:54:46 +08:00
Sivan 7090bb33b6 fix: avoid static wails runtime imports 2026-06-17 14:36:35 +08:00
SivanCola 5dc86b4000 Merge pull request #4680 from GTC2080/GTC/fix-4663-settings-refresh-lag
Fix settings refresh lag / 优化设置保存响应
2026-06-17 14:23:31 +08:00
SivanCola 314918d55c Merge pull request #4685 from GTC2080/GTC/fix-4612-custom-model-metadata
Fix custom no-auth model providers / 修复自定义免密模型接入
2026-06-17 14:22:07 +08:00
GTC2080 2453051eb6 fix: support no-auth custom model providers 2026-06-17 15:49:24 +10:00
SivanCola 8dbc35195b Merge pull request #4602 from JesonChou/fix/4584-scroll-position-session-switch
fix(cli&desktop): Fix stale scroll and transcript accumulation on session switch / 修复会话切换时滚动偏移与对话累积
2026-06-17 13:46:17 +08:00
SivanCola 3c0d491ab9 Merge pull request #4675 from GTC2080/GTC/fix-4667-session-plan-isolation
Fix planner context isolation across sessions / 修复跨会话规划上下文隔离
2026-06-17 13:44:31 +08:00
Sivan e1210804e4 test(cli): cover resume scroll pin regression 2026-06-17 13:42:15 +08:00
Sivan e580cfe2f7 fix(control): reset planner context on branch switches 2026-06-17 13:40:29 +08:00
SivanCola ba4ceaa4cd Merge pull request #4677 from GTC2080/GTC/fix-4661-status-bar-items
Fix status bar workspace items / 修复信息栏工作区项勾选
2026-06-17 13:38:58 +08:00
GTC2080 8a81062bfb fix: reduce settings refresh lag 2026-06-17 15:27:30 +10:00
SivanCola 114a12adc2 Merge pull request #4678 from esengine/fix/workbench-dock-chrome-safety
Fix workbench dock chrome safety / 修复工作台 Dock 顶部安全区
2026-06-17 13:20:56 +08:00
Sivan ac370f4ff1 Fix workbench dock chrome safety 2026-06-17 13:14:54 +08:00
GTC2080 8690c5e566 fix(config): allow workspace status bar items 2026-06-17 15:14:32 +10:00
SivanCola e3924bc692 Merge pull request #4671 from SivanCola/fix/global-provider-config-priority
Prefer global provider credentials / 优先使用全局模型服务凭据
2026-06-17 13:13:51 +08:00
Sivan e35ec31557 fix(config): address global provider review gaps 2026-06-17 13:06:15 +08:00
SivanCola 5e748f8ed9 Merge pull request #4670 from SivanCola/fix/4666-rehome-stranded-sessions
fix(session): re-home project sessions stranded by version downgrade / 修复版本降级导致工程会话丢失
2026-06-17 12:59:50 +08:00
Sivan e2a8e5d6d8 fix(session): carry subagent artifacts during re-home 2026-06-17 12:55:17 +08:00
GTC2080 f051c34b90 fix(agent): reset planner context across sessions 2026-06-17 14:49:37 +10:00
wangwangcodecode b9c7af3373 fix(setup): restore sibling built-in entries when re-running setup wizard
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.
2026-06-17 12:42:24 +08:00
Sivan b664e3c3b7 fix(session): harden stranded session re-home 2026-06-17 12:31:25 +08:00
Sivan ca0ee6e93e fix(config): prefer global provider credentials 2026-06-17 12:27:03 +08:00
SivanCola bd143053b1 Merge pull request #4668 from SivanCola/fix/global-mcp-migration
Backfill MCP servers into global config / 将 MCP 服务器补齐到全局配置
2026-06-17 12:25:24 +08:00
Sivan ad1bee0170 fix(config): address MCP migration review gaps 2026-06-17 12:22:03 +08:00
Sivan 9cd5e1a4f3 fix(session): re-home project sessions stranded by version downgrade
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.
2026-06-17 12:21:48 +08:00
SivanCola 81886bc46c Merge pull request #4669 from esengine/fix/workbench-titlebar-shadow
Fix workbench titlebar shadow / 修复工作台标题栏残留阴影
2026-06-17 12:20:43 +08:00
Sivan 77b743ca3c Fix workbench titlebar shadow 2026-06-17 12:11:18 +08:00
Sivan 27ced7b8e3 fix(config): stabilize MCP migration CI 2026-06-17 12:10:50 +08:00
SivanCola 1c22475046 Merge pull request #4639 from opengis-agent/fix/cli-usage-hidden-commands
docs(cli): surface hidden commands and flags in usage text
2026-06-17 12:03:08 +08:00
Sivan 605aeed4a3 fix(config): backfill MCP servers into global config 2026-06-17 12:00:55 +08:00
SivanCola 5aac554466 Merge pull request #4638 from ashishexee/fix/4572-plan-mode-readonly
fix(agent): smart plan-mode gate with bash read-only validation (#4572)
2026-06-17 11:42:33 +08:00
Sivan cb00cdc2ed fix(agent): block write-capable plan-mode flags 2026-06-17 11:37:45 +08:00
Sivan 235c550ce2 fix(agent): harden plan-mode bash whitelist 2026-06-17 11:31:30 +08:00
SivanCola c93a69c664 Merge pull request #4644 from lifu963/pr/job-artifact-sidecar
持久化后台 job artifacts
2026-06-17 11:23:01 +08:00
SivanCola 17427dd8dd Merge pull request #4662 from SivanCola/fix/anchored-popover-scroll
Fix anchored popover scroll positioning / 修复锚定弹层滚动定位
2026-06-17 11:22:30 +08:00
SivanCola 79c553f297 Merge pull request #4658 from SivanCola/docs/site-download-guidance
Improve site download guidance / 优化官网下载指引
2026-06-17 11:19:29 +08:00
SivanCola b56f7cd7e8 Validate site download manifest values 2026-06-17 11:16:23 +08:00
Sivan 4cc7c89b70 Fix anchored popover scroll positioning 2026-06-17 11:15:32 +08:00
Sivan 70f2a780df fix(jobs): decouple artifact failures from job status 2026-06-17 11:14:37 +08:00
SivanCola a5b0494652 Improve site download guidance 2026-06-17 11:07:28 +08:00
SivanCola 859b4f46e5 Merge pull request #4654 from SivanCola/fix/blue-desktop-app-icon
Fix desktop app icon background / 修复桌面端图标底色
2026-06-17 11:06:01 +08:00
Sivan 584c0c5e36 Fix desktop app icon background 2026-06-17 10:55:47 +08:00
Sivan eaadbdda1a fix(jobs): migrate active artifacts safely 2026-06-17 10:45:13 +08:00
lifu963andCursor 5ac41c2a32 Remove unused artifact rebase helper
Co-authored-by: Cursor <[email protected]>
2026-06-17 10:42:52 +08:00
YHH 76088e6949 ci(release): drop orphaned CodeGraph→R2 mirror job (#4649)
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.
2026-06-17 10:42:30 +08:00
lifu963andCursor 9da75a12c8 Fix Windows job artifact migration
Co-authored-by: Cursor <[email protected]>
2026-06-17 10:35:08 +08:00
lifu963andCursor 21e4bc7713 Remove job artifacts from ACP sessions
Co-authored-by: Cursor <[email protected]>
2026-06-17 10:12:40 +08:00
lifu963andCursor 1544e5c54a Adopt temporary jobs when artifact migration fails
Co-authored-by: Cursor <[email protected]>
2026-06-17 10:12:40 +08:00
lifu963andCursor ecfdcdcd91 Adopt temporary background jobs on session bind
Co-authored-by: Cursor <[email protected]>
2026-06-17 10:12:40 +08:00
lifu963andCursor ed0516e741 Scope restored job artifacts by session
Co-authored-by: Cursor <[email protected]>
2026-06-17 10:12:40 +08:00
lifu963andCursor 015b2ba99a Read job artifact output incrementally
Co-authored-by: Cursor <[email protected]>
2026-06-17 10:12:40 +08:00
lifu963andCursor ef8619131e Handle temporary job artifact migration
Co-authored-by: Cursor <[email protected]>
2026-06-17 10:12:39 +08:00
lifu963andCursor 169e44c39a Remove job artifacts from serve sessions
Co-authored-by: Cursor <[email protected]>
2026-06-17 10:12:39 +08:00
lifu963andCursor b3a64f81f7 Clean up job artifacts with sessions
Co-authored-by: Cursor <[email protected]>
2026-06-17 10:12:39 +08:00
lifu963andCursor 27ef96cf0b Persist background job artifacts
Co-authored-by: Cursor <[email protected]>
2026-06-17 10:12:39 +08:00
wangwangcodecode 6f39a76858 docs(cli): surface hidden commands and flags in usage text
- 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
2026-06-17 09:21:21 +08:00
ashishexee 2f9406db2a fix(agent): smart plan-mode gate with bash read-only validation (#4572) 2026-06-17 02:10:31 +05:30
SivanCola bc83374d6b Merge pull request #4637 from esengine/feature/desktop-update-settings
Add desktop updater and settings refinements / 增加桌面更新与设置优化
2026-06-17 04:28:45 +08:00
SivanCola 2a28335dec Fix desktop updater cached install state
Co-authored-by: SivanCola <[email protected]>
2026-06-17 04:05:18 +08:00
SivanCola bc75807e05 Add desktop update flow and settings refinements
Co-authored-by: SivanCola <[email protected]>
2026-06-17 03:29:09 +08:00
SivanCola 325e4a7f6f Merge pull request #4635 from esengine/fix/settings-capabilities-loading
Fix settings capability loading / 修复设置页能力加载延迟
2026-06-17 03:24:16 +08:00
SivanCola fd62097a46 Merge pull request #4636 from esengine/docs/site-docs-refresh
Refresh docs site install and desktop guidance / 刷新文档站点安装与桌面端说明
2026-06-17 03:23:19 +08:00
SivanCola 3d09c12b96 Scope settings snapshots to active tab 2026-06-17 03:19:33 +08:00
SivanCola e6e42ac390 docs: refresh site documentation
Co-authored-by: SivanCola <[email protected]>
2026-06-17 03:14:24 +08:00
SivanCola 94c0fc69d9 Merge pull request #4634 from SivanCola/fix/default-interactive-non-tty
Fix default interactive CLI routing / 修复默认交互 CLI 路由
2026-06-17 03:03:25 +08:00
SivanCola b6f8e4038d Fix settings capability loading 2026-06-17 02:58:35 +08:00
SivanCola 0cfc05cbe3 Fix default interactive CLI routing 2026-06-17 02:56:18 +08:00
SivanCola bc3f0afc1c Merge pull request #4632 from esengine/feature/default-interactive-entry
Make bare reasonix start sessions / 让 reasonix 直接启动会话
2026-06-17 02:28:20 +08:00
SivanCola 29ffd315f7 Merge pull request #4630 from SivanCola/fix/credential-source-visibility
Surface credential source diagnostics / 显示凭据来源诊断
2026-06-17 02:20:30 +08:00
SivanCola a4037ebdb0 Handle credential shadow warnings without failing saves 2026-06-17 01:01:42 +08:00
SivanCola 74d0ab6d93 Make bare reasonix start interactive sessions
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.
2026-06-17 00:59:36 +08:00
SivanCola 8ddb46d844 Merge pull request #4631 from esengine/fix/resume-session-model-meta
Restore saved session model on resume / 恢复会话时保留原模型
2026-06-17 00:56:19 +08:00
SivanCola c1953f23fb fix: harden resumed session model metadata 2026-06-17 00:48:05 +08:00
SivanColaandJuan-LukeKlopper 1a02582538 fix: restore saved model when resuming sessions
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]>
2026-06-17 00:37:38 +08:00
SivanCola 66ed0b0e98 Merge pull request #4613 from SivanCola/fix/full-canvas-app-icon
Fix app icon full-canvas background / 修复应用图标全画布背景
2026-06-17 00:37:28 +08:00
SivanCola 976af56284 Merge pull request #4628 from SivanCola/fix/mcp-reconnect-timeout
Fix MCP reconnect and stdio call timeout / 修复 MCP 重连和 stdio 调用超时
2026-06-17 00:32:47 +08:00
SivanCola df67fdad39 Regenerate Windows app icon 2026-06-17 00:30:40 +08:00
SivanCola 21e2171861 Remove unused dotenv loader helper 2026-06-17 00:30:06 +08:00
SivanCola 008da591a4 Surface credential source diagnostics 2026-06-17 00:25:10 +08:00
6216228cb7 Fix MCP reconnect and stdio call timeout
Co-authored-by: GTC2080 <[email protected]>

Co-authored-by: ashishexee <[email protected]>
2026-06-17 00:05:23 +08:00
SivanCola fe36f05a75 Merge pull request #4625 from SivanCola/fix/manual-npm-stable-release
Allow manual stable npm releases / 支持手动稳定 npm 发布
2026-06-16 23:34:07 +08:00
SivanCola 981e05f547 Allow manual stable npm releases 2026-06-16 23:28:59 +08:00
SivanCola 8886dcbee5 Merge pull request #4624 from SivanCola/fix/acp-zed-integration
Fix ACP Zed integration gaps / 修复 ACP 的 Zed 集成缺口
2026-06-16 23:14:26 +08:00
SivanCola 77d58bcbfb Fix ACP Zed integration gaps 2026-06-16 23:07:26 +08:00
SivanCola 4865da6b3e Merge pull request #4623 from SivanCola/fix/resizeobserver-error-message-fallback
Check nested ResizeObserver errors / 检查嵌套 ResizeObserver 错误
2026-06-16 23:01:27 +08:00
SivanCola fed420d63c fix(desktop): check nested ResizeObserver errors 2026-06-16 22:56:39 +08:00
SivanCola 0fb84e6f2d Merge pull request #4622 from SivanCola/fix/suppress-resizeobserver-crash-report
Ignore ResizeObserver loop notices in crash reporting / 忽略 ResizeObserver 循环通知的崩溃上报
2026-06-16 22:50:58 +08:00
SivanCola 337b826c34 Merge pull request #4619 from SivanCola/feature/settings-preference-metrics
Add settings preference metrics / 新增设置偏好埋点
2026-06-16 22:50:42 +08:00
SivanCola ffcbac9bf5 Add settings preference metrics 2026-06-16 22:45:45 +08:00
SivanCola 2a77fd9339 fix(desktop): ignore ResizeObserver loop notices 2026-06-16 22:41:47 +08:00
SivanCola 8f3ae3647b Merge pull request #4589 from ttmouse/pr/new-feature
feat(ui): 优化项目树左侧栏视觉样式
2026-06-16 22:24:19 +08:00
SivanCola ca1ac33985 Merge pull request #4604 from SivanCola/fix/config-paths-reasonix-home
Fix global config path migration / 修复全局配置路径迁移
2026-06-16 22:22:18 +08:00
lab1 1cb651046d Address config path migration review 2026-06-16 22:16:33 +08:00
ttmouseandSivanCola 323715a133 Remove context preview lists from right dock
Co-authored-by: SivanCola <[email protected]>
2026-06-16 22:09:29 +08:00
SivanCola e786f400b9 Document migration rescue command 2026-06-16 21:52:48 +08:00
SivanCola 5946f6c983 Add migration rescue slash command 2026-06-16 21:45:41 +08:00
ttmouseandSivanCola 85fcffda6e Fix project tree empty folder disclosure
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]>
2026-06-16 21:38:41 +08:00
SivanCola c889d6eb9c Fix CI for credential path migration 2026-06-16 21:28:43 +08:00
Sivan 31f4847496 Fix app icon full-canvas background 2026-06-16 21:21:28 +08:00
Sivan d6d18e1c55 Clarify API key credential storage 2026-06-16 19:44:13 +08:00
Sivan 13b585c827 Fix rebase against latest main 2026-06-16 19:37:54 +08:00
Sivan a99503d3ed Cover Reasonix home asset migrations 2026-06-16 19:27:08 +08:00
Sivan a148c43d2c Add credential store backends 2026-06-16 19:27:08 +08:00
Sivan bb0769b08f Fix global config path migration 2026-06-16 19:25:34 +08:00
SivanCola a2709fc9b2 Merge pull request #4599 from esengine/feature/remove-builtin-mcps
Remove bundled MCP servers / 移除内置 MCP 服务
2026-06-16 19:10:25 +08:00
ttmouse 0acbe0b5d5 fix(ui): 修复键盘可达性和间距重复问题 2026-06-16 19:09:11 +08:00
ttmouse a4157de466 fix(ui): 修复键盘可达性和间距重复问题
- 添加 :focus-within 状态显示文件夹操作图标,键盘用户可访问
- 移除 margin-top 重复规则,间距统一由 margin-bottom 控制
2026-06-16 19:07:39 +08:00
Sivan 67c5256a71 Address bot review feedback for MCP install paths 2026-06-16 18:31:26 +08:00
Sivan 4029418e24 Fix root lint after removing bundled MCPs 2026-06-16 17:52:04 +08:00
JesonChou f062313728 fix(desktop): reset auto-scroll pin when switching tabs (#4584)
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.
2026-06-16 17:50:39 +08:00
JesonChou 03f2b93144 fix(cli): clear transcript and force scroll-to-bottom on session switch (#4584)
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.
2026-06-16 17:47:37 +08:00
Sivan 655c72ad9b Remove bundled MCP servers and add code index fallback 2026-06-16 17:44:27 +08:00
SivanCola 046ccec992 Merge pull request #4586 from lifu963/pr/reapply-dc11dc-valid-email-safe
Reapply desktop session runtime commit with valid author email
2026-06-16 16:14:50 +08:00
SivanCola f7f8e9f522 Merge pull request #4577 from SivanCola/feature/user-message-actions
Add user message actions / 添加用户消息操作
2026-06-16 16:14:14 +08:00
ttmouse 67b49bbfc4 fix(ui): 修复项目树滚动、高度、图标按钮等细节
- 菜单右侧操作图标默认隐藏,悬停显示
- 新建会话图标改为 + 号
- 二级菜单固定高度,有无时间信息一致
- 时间筛选器和搜索栏固定不滚动
- 展开间距精确匹配有子节点的项目
2026-06-16 16:01:24 +08:00
lifu963andCursor abf5265645 chore: refresh contributor attribution
Co-authored-by: Cursor <[email protected]>
2026-06-16 15:55:47 +08:00
ttmouse e74ee8119c feat(ui): 优化项目树左侧栏视觉样式
- 图标栈:折叠箭头与文件夹图标同一位置互斥切换(hover 显示箭头)
- 文件夹图标:展开时用 FolderOpen,折叠时用 Folder
- 项目名称:有/无子节点分层次弱化(fg-dim 混合 / fg-faint)
- 二级菜单:整行宽、无选中文字变化、时间展示缩小
- 间距:展开的项目与下一项之间增加 6px 间距
- 悬停:一级菜单弱背景 60%,二级菜单正常强度
- 动画:移除了 children grid-template-rows 过渡避免抖动
- 操作图标:置顶/归档按钮无背景无边框
- 右侧位移动画移除
2026-06-16 15:48:58 +08:00
Sivan b1953ee205 Merge remote-tracking branch 'origin/main-v2' into fix/4577-bot-comments
# Conflicts:
#	desktop/frontend/package.json
2026-06-16 15:43:28 +08:00
Sivan 0f6c39638f Fix edit replay gating and submit context 2026-06-16 15:39:38 +08:00
lifu963andCursor 57e534e8f5 fix(desktop): make sidebar sessions own runtimes
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]>
2026-06-16 15:34:39 +08:00
lifu963 2fd2ea4239 Revert "fix(desktop): make sidebar sessions own runtimes"
This reverts commit dc11dc67e4.
2026-06-16 15:34:39 +08:00
SivanCola c644bf7b34 Merge pull request #4581 from SivanCola/fix/settings-shortcuts-panel
Fix settings shortcuts navigation and key display / 修复设置快捷键入口与按键显示
2026-06-16 15:13:24 +08:00
Sivan fc6c9e3419 Stabilize cancel runtime status race test 2026-06-16 15:08:30 +08:00
Sivan 9480ded8e3 Share composer attachment cards in message edits 2026-06-16 15:08:21 +08:00
Sivan 32ef380b29 Scope shortcut cheatsheet description styles 2026-06-16 14:52:37 +08:00
Sivan 07f1385022 Fix settings shortcut entry and key display 2026-06-16 14:29:46 +08:00
SivanCola 828cb036eb Merge pull request #4578 from SivanCola/feature/default-auto-graphite-appearance
Default desktop appearance to automatic Graphite / 默认桌面外观为自动石墨
2026-06-16 14:14:41 +08:00
Sivan f89c4404d2 fix(desktop): harden user message edit replay 2026-06-16 14:11:53 +08:00
Sivan 180b5e530a Sync auto theme native background changes 2026-06-16 14:09:37 +08:00
Sivan dc301261a1 Default desktop appearance to automatic Graphite 2026-06-16 13:45:10 +08:00
Sivan d84b277c39 feat(desktop): add user message actions 2026-06-16 13:42:58 +08:00
SivanCola 6fe72fddc0 Merge pull request #4576 from SivanCola/chore/desktop-app-icons
Add desktop app icons / 添加桌面应用图标
2026-06-16 13:40:27 +08:00
SivanCola 9bf9c36be8 Merge pull request #4574 from SivanCola/fix/runtime-cancel-recovery
Fix runtime cancellation state and stop controls / 修复运行取消状态与停止控制
2026-06-16 13:38:20 +08:00
SivanCola 8013d05849 Add desktop app icon assets 2026-06-16 13:32:47 +08:00
SivanCola 0a01ccde99 fix(desktop): preserve non-cancellable background state 2026-06-16 13:21:51 +08:00
SivanCola a4cea9142b Merge pull request #4571 from SivanCola/fix/vision-model-capabilities
Support per-model vision model capabilities / 支持按模型声明识图能力
2026-06-16 12:30:05 +08:00
SivanCola dbec1cc306 fix(runtime): make cancellation state authoritative 2026-06-16 12:21:37 +08:00
Sivan e4b6e38e90 fix(config): preserve explicit vision model selections 2026-06-16 12:19:54 +08:00
SivanCola 0706284d1c Merge pull request #4478 from SivanCola/chore/fix-esbuild-audit
chore(deps): address esbuild audit advisory / 修复 esbuild 审计告警
2026-06-16 12:07:34 +08:00
SivanCola 288c899349 Merge pull request #4392 from SivanCola/feature/compaction-retention-policy
feat(agent): preserve failure context with configurable compaction retention / 保留失败上下文并支持可配置压缩保留策略
2026-06-16 12:07:18 +08:00
SivanCola ca66d37c6f Merge pull request #4450 from esengine/fix/suppress-wails-drag-rejection
Suppress Wails non-file drag rejection / 抑制 Wails 非文件拖拽误报
2026-06-16 12:06:43 +08:00
SivanCola dbd15a85e7 Merge pull request #4538 from whale-fall-ouo/feat/approval-keyboard-nav
feat(desktop): 工具审批卡片添加左右键切换和 Enter 确认
2026-06-16 11:56:39 +08:00
Sivan 92ad79a7ce feat(config): support per-model vision capabilities 2026-06-16 11:54:29 +08:00
Sivan 0cdb2b5aa0 docs: document desktop approval keyboard shortcuts 2026-06-16 11:51:58 +08:00
Sivan 073f6dd9e9 fix(desktop): preserve approval keyboard defaults 2026-06-16 11:45:46 +08:00
SivanCola bcd310ddf6 Merge pull request #4533 from whale-fall-ouo/fix/model-selection-persist
fix(config): 修复模型选择保存后被官方默认模型覆盖的问题
2026-06-16 11:30:55 +08:00
Sivan e50ba887d6 fix(desktop): persist single enabled provider model lists 2026-06-16 11:19:36 +08:00
Sivan fd9cb35e89 Merge main-v2 into fix/suppress-wails-drag-rejection 2026-06-16 11:08:37 +08:00
Sivan 77fa33c32b test(agent): update task tool constructor after rebase 2026-06-16 11:00:28 +08:00
1a87cd4cfd Integrate clear context telemetry fixes / 集成清空上下文与统计面板修复 (#4329)
* 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]>
2026-06-16 11:00:11 +08:00
lab1 78ee4005e6 fix(agent): honor keep policy before pruning error tool results
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.
2026-06-16 10:58:08 +08:00
SivanCola f151ab3319 Add configurable compaction retention policy
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.
2026-06-16 10:58:08 +08:00
SivanCola 211a9a85da Merge pull request #4514 from lifu963/pr/subagent-tool-surface
agent: align subagent tool surface with runtime
2026-06-16 10:57:37 +08:00
SivanCola 582dec2919 Merge pull request #4562 from lifu963/pr/background-job-robustness
增强后台 job 健壮性与可观测性
2026-06-16 10:54:47 +08:00
SivanCola e79f4c1c54 agent: document subagent tool boundary 2026-06-16 10:49:02 +08:00
Sivan 87699ccd31 Avoid false stalled warnings after job return 2026-06-16 10:47:46 +08:00
lifu963andCursor d7cade9355 Finalize interrupted subagent runs
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]>
2026-06-16 10:03:30 +08:00
lifu963andCursor 22d176b5aa Harden background job lifecycle
Recover panics inside managed job goroutines and surface stalled jobs through existing notices and background-job notes.

Co-authored-by: Cursor <[email protected]>
2026-06-16 10:00:38 +08:00
lifu963andCursor 22b7c8dd13 Add background job stalled warning config
Define the threshold in config so stalled-job behavior is explicit, testable, and renderable in generated TOML.

Co-authored-by: Cursor <[email protected]>
2026-06-16 09:58:51 +08:00
SivanCola 2b6b130284 feat(desktop): unify keyboard shortcuts settings and help (#4515) 2026-06-16 09:21:05 +08:00
SivanCola 9f7d52461d Fix workspace changes refresh per tab / 修复改动列表按会话刷新 (#4501)
* fix(desktop): scope workspace changes to active tab

* fix(desktop): guard workspace dock tab races
2026-06-16 09:21:00 +08:00
SivanCola 4d193f2521 Fix desktop welcome clipping on fullscreen resize / 修复桌面端全屏后欢迎页裁剪 (#4503)
* fix(desktop): prevent welcome clipping on fullscreen resize

* fix(desktop): scope empty welcome margins
2026-06-16 09:20:56 +08:00
SivanCola 72844ee967 fix(desktop): keep new tab button beside tabs (#4493) 2026-06-16 09:20:52 +08:00
SivanCola b6f306f5c5 Ignore background performance pressure / 忽略后台性能压力误报 (#4444)
* fix(desktop): ignore hidden window performance lag

* fix(desktop): ignore unfocused performance pressure
2026-06-16 09:20:47 +08:00
SivanCola 5e4f460a65 fix(desktop): show precise context cache hit rate (#4502) 2026-06-16 09:20:42 +08:00
SivanCola e96ba2a4dc fix(desktop): restore transparent app icon safe area (#4438) 2026-06-16 09:20:38 +08:00
SivanCola d45e5c3331 Require approval for awk and sed bash commands (#4452) 2026-06-16 09:20:33 +08:00
SivanCola 43eb3ba790 Sanitize malformed MCP required schemas / 清洗异常 MCP required schema (#4469)
* fix(provider): sanitize malformed MCP required schemas

* fix(provider): preserve properties named required

* fix(provider): preserve dependentRequired constraints
2026-06-16 09:20:28 +08:00
SivanCola 4b7d984139 Fix desktop model switcher provider scope merge / 修复桌面模型切换器配置作用域合并 (#4492)
* fix(config): merge provider lists across config scopes

* fix(config): keep merged providers scoped

* fix(config): keep official provider aliases distinct
2026-06-16 09:20:24 +08:00
SivanCola 2f974efdbb Integrate skill discovery fixes / 集成技能发现修复 (#4510)
* Integrate skill discovery fixes

* Treat blank Claude skill markers as skills
2026-06-16 09:20:19 +08:00
zhangsan 237e703ef3 feat(desktop): 工具审批卡片添加左右键切换和 Enter 确认
ApprovalModal 弹出时支持键盘导航:
- ← → 循环切换高亮选项
- Enter 确认当前高亮选项
- 1/2/3/4 数字键直接选择(保留)
- Escape 拒绝(保留)

每次弹出新审批卡片时 selectedIndex 重置为 0。
2026-06-15 23:07:17 +08:00
zhangsan 74be1e51c2 fix(config): 修复模型选择保存后被官方默认模型覆盖的问题
用户在设置中选择部分模型保存后,重新加载时 normalization 函数
(ensureProviderModels、mergeCuratedModelsIntoProvider、
backfillDeepSeekPro)会把官方全部模型合并回来,覆盖用户选择。

修复:当 provider 有显式模型列表(Models 非空)时跳过合并,
尊重用户通过 Settings 明确选择的模型列表。

Fixes #4512
2026-06-15 22:42:11 +08:00
lifu963andCursor f1d3493a10 agent: align subagent tool surface with runtime
Co-authored-by: Cursor <[email protected]>
2026-06-15 19:03:20 +08:00
SivanCola 8ab6d3b547 Merge pull request #4139 from ttmouse/pr/model-list
Enhance model switcher search and provider labels / 增强模型切换器搜索与服务商标签
2026-06-15 18:42:30 +08:00
SivanCola 4776af2549 Merge pull request #4511 from nostalgia296/main-v2
feat(cli):termux启动cli前清除终端历史信息
2026-06-15 18:40:49 +08:00
Sivan c30e0e78c0 test(cli): cover native scrollback startup clearing 2026-06-15 18:36:35 +08:00
SivanCola b02c2bf7c5 Merge model switcher provider label 2026-06-15 18:35:33 +08:00
SivanCola 4ea78896a4 Merge pull request #4216 from lightfront/fix/young-diagrams
feat(desktop): expand \yng/\young to KaTeX-compatible Young diagrams
2026-06-15 18:30:11 +08:00
SivanCola 76bae5652f Merge remote-tracking branch 'origin/pr/4139' into feature/model-switcher-combined 2026-06-15 18:25:50 +08:00
Sivan 29dff5000e fix(desktop): preserve Young diagram math boundaries 2026-06-15 18:20:58 +08:00
SivanCola 94678c6ece Merge pull request #4298 from ttmouse/pr/cmd-w-close-tab
feat(desktop): add ⌘+W / Ctrl+W shortcut to close the active tab
2026-06-15 18:08:36 +08:00
Sivan 92b2e0d9d5 docs: document desktop and CLI shortcuts 2026-06-15 18:03:52 +08:00
nostalgia296 ef58f5d4c2 feat(cli):termux启动cli前清除终端历史信息 2026-06-15 18:02:47 +08:00
Sivan 06d1c55ca0 Merge remote-tracking branch 'origin/main-v2' into fix/4298-platform-shortcut
# Conflicts:
#	desktop/frontend/package.json
2026-06-15 17:56:43 +08:00
Sivan 28083cffdb fix(desktop): gate close-tab shortcut by platform 2026-06-15 17:54:00 +08:00
Sivan 82d52e0a95 fix(desktop): suppress Wails drag errors before crash handling 2026-06-15 17:15:45 +08:00
SivanCola 4f703dc08e Merge pull request #4479 from GTC2080/GTC/fix-tool-diff-line-numbers
Fix desktop tool diff previews / 修复桌面端工具 diff 预览
2026-06-15 16:36:06 +08:00
Sivan 2c683ce0dc fix(desktop): ignore trailing unified diff newline 2026-06-15 16:26:10 +08:00
SivanCola 7bde8d2f9b Merge pull request #4446 from ferstar/codex/channel-sessions-readonly
fix(desktop): open channel sessions read-only
2026-06-15 15:49:21 +08:00
GTC2080 d340a3ab11 fix(desktop): preserve tool diff previews 2026-06-15 17:48:06 +10:00
Sivan 40810787b6 fix(desktop): prevent read-only channel session writes 2026-06-15 15:37:15 +08:00
Sivan 65e55f0cea chore(deps): address esbuild audit advisory 2026-06-15 15:34:06 +08:00
SivanCola 2faf91374c Merge pull request #4379 from esengine/dependabot/npm_and_yarn/desktop/frontend/npm-4c5b514731
chore(deps): bump the npm group across 1 directory with 2 updates
2026-06-15 15:23:20 +08:00
SivanCola a783d1fc12 Merge pull request #4378 from esengine/dependabot/go_modules/go-03d31bfb34
build(deps): bump the go group with 3 updates
2026-06-15 15:06:14 +08:00
Sivan 452c062ed8 build(deps): tidy desktop module 2026-06-15 15:01:25 +08:00
SivanCola 71ab47efa3 Merge pull request #4443 from GTC2080/GTC/issue-4439-history-payload-perf
fix(desktop): reduce restored history and transcript lag
2026-06-15 14:37:32 +08:00
Sivan cc2a2fbb76 fix(desktop): preserve restored todo history args 2026-06-15 14:31:48 +08:00
Sivan 1d6ac504a6 fix(desktop): suppress Wails non-file drag rejection 2026-06-15 14:05:52 +08:00
GTC2080 0385bdc51d fix(desktop): make transcript turn grouping linear 2026-06-15 15:44:27 +10:00
zhangjianfei 48b4d3d992 fix(desktop): open channel sessions read-only 2026-06-15 13:23:42 +08:00
GTC2080 d562e3732e fix(desktop): slim restored tool history payloads 2026-06-15 15:16:01 +10:00
SivanCola a029618da9 Fix DeepSeek billing source breakdown / 修复 DeepSeek 费用来源拆分 (#4280)
* Fix DeepSeek billing source breakdown

* Address billing source review feedback
2026-06-15 12:06:16 +08:00
SivanCola 2bd8b5dcc9 fix(desktop): reserve mac tabbar new button slot (#4433) 2026-06-15 12:03:46 +08:00
YHH a15c0b9b7d fix(desktop): make the performance-pressure prompt idempotent per label (#4434)
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.
2026-06-15 11:58:59 +08:00
SivanCola 0407ce48c7 Bound chrome tab strip width / 限制顶部标签栏最大宽度 (#4427)
* fix(desktop): bound chrome tab strip overflow

* fix(desktop): pin mac chrome command button
2026-06-15 11:41:59 +08:00
SivanCola 7f2cea282b fix(desktop): reserve right dock space for chrome tabs (#4422) 2026-06-15 11:20:22 +08:00
dependabot[bot] 2c0e21848a chore(deps): bump the npm group across 1 directory with 2 updates
Bumps the npm group with 2 updates in the /desktop/frontend directory: [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `lucide-react` from 1.17.0 to 1.18.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.18.0/packages/lucide-react)

Updates `@types/node` from 25.9.2 to 25.9.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.9.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: lucide-react
  dependency-version: 1.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-06-15 02:20:47 +00:00
YHH b225dd7d05 fix(desktop): cap esbuild at ^0.25.0 so wails dev builds again (#4410)
#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.
2026-06-15 10:18:00 +08:00
ashish df35995eae fix(desktop): close CloseTab-Snapshot/DeleteSession resurrection race (#4384) (#4406)
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.
2026-06-15 09:37:04 +08:00
ashish 613ce9f8d5 fix(agent): redact tool-call args in compaction transcript (#4350)
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.
2026-06-15 09:37:00 +08:00
lightfrontandXingbo Zhao 4ee69cb698 fix(desktop): correct pipe handling for array column specs and kets (#4320)
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]>
2026-06-15 09:36:56 +08:00
ashish 4dd6796539 fix(config): comment out default max_steps/planner_max_steps in rendered project config (#4228)
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.
2026-06-15 09:36:51 +08:00
Maitreyee Deshmukh e7fc067710 fix(desktop): restore min-width floor and overflow-x to prevent titlebar/bottom overlap on Windows 11 (#4065) (#4067) 2026-06-15 09:36:47 +08:00
SivanCola fb839d791e Fix stale DeepSeek desktop model fallback (#4278) 2026-06-15 09:36:43 +08:00
SivanCola 21d77d232a Merge pull request #4360 from lifu963/fix/desktop-session-sidebar-runtime
Scope Desktop session runtimes and preserve classic tabs / 会话运行态隔离并保留 classic 顶部标签页
2026-06-14 23:42:30 +08:00
lab1andSivanCola acb0842f1b Merge main-v2 into PR 4360
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]>
2026-06-14 23:35:41 +08:00
SivanColaandSivanCola d22a852319 perf(desktop): cache topic-session index for new-tab lookup (#4395)
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]>
2026-06-14 23:02:43 +08:00
SivanColaandSivanCola aad377b4c0 feat(provider): enable MiMo vision model inputs (#4388)
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]>
2026-06-14 22:58:03 +08:00
SivanColaandSivanCola 86b3c79f76 fix(desktop): diff row backgrounds, line gutters, and accurate summaries (#4387)
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]>
2026-06-14 22:57:20 +08:00
SivanColaandSivanCola f44777062d fix(desktop): decouple tab runtime events from agent sink to avoid deadlock (#4389)
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]>
2026-06-14 22:56:34 +08:00
SivanColaandSivanCola a6aae98f82 feat(desktop): switchable workbench layout style (#4369)
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]>
2026-06-14 22:22:12 +08:00
SivanColaandSivanCola 375a0fcf2a fix(bot): channel reply chunking and session persistence (#4374)
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 #4372
Closes #4293
Closes #4283

Co-authored-by: SivanCola <[email protected]>
2026-06-14 22:21:18 +08:00
SivanColaandSivanCola f07e9084ef feat(mcp): built-in MCP update flow for CodeGraph (#4370)
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]>
2026-06-14 22:20:51 +08:00
95d64f704b fix(desktop): restore todo state across plan seeds and history replay (#4313)
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 #4259
Closes #4105
Closes #4170
Closes #4241

Co-authored-by: JesonChou <[email protected]>
Co-authored-by: ashishexee <[email protected]>
Co-authored-by: GTC2080 <[email protected]>
2026-06-14 22:19:57 +08:00
SivanCola 424f33f2ed feat(stats): redesign crash-report dashboard UI with language switcher (#4325)
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.
2026-06-14 22:18:54 +08:00
lifu963andCursor c98b6600b1 Merge origin/main-v2 into fix/desktop-session-sidebar-runtime
Co-authored-by: Cursor <[email protected]>
2026-06-14 22:00:39 +08:00
SivanCola fb0cec2c0a feat(desktop): user-triggered bot connection diagnostic reporting (#4381)
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).
2026-06-14 21:44:25 +08:00
SivanCola 40aedfb0b7 docs: document headless reasonix bot start usage (#4375)
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.
2026-06-14 21:38:11 +08:00
SivanCola 6e798bc093 docs: add Chinese desktop hooks guide; fix hook-reload guidance (#4373)
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.
2026-06-14 21:29:07 +08:00
SivanCola 66b4925cce fix(desktop): full-width session tokens card in context panel (#4367)
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.
2026-06-14 21:14:16 +08:00
SivanCola f5698cda8c fix(codegraph): set daemon idle-timeout across all MCP launch paths (#4363)
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.
2026-06-14 21:12:25 +08:00
SivanColaandSivanCola 8c558ffef2 feat(acp): support model and effort switching via session config (#4335)
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]>
2026-06-14 21:10:30 +08:00
SivanCola 3096cb999c fix(security): harden archive extraction, session resume, and frontend file refs (#4328)
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.
2026-06-14 21:03:29 +08:00
SivanCola 64837218a0 Merge pull request #4349 from ferstar/codex/4348-prompt-history-tape
Add Up/Down replay for user input history
2026-06-14 21:03:17 +08:00
bc76beab07 feat(desktop): add prompt history replay
Closes #4348

Co-authored-by: PsjJourney <[email protected]>

Co-authored-by: SivanCola <[email protected]>
2026-06-14 20:57:50 +08:00
SivanCola a58532f021 feat(bot): migrate v0.5.3 QQ bot support with gateway hardening (#4324)
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.
2026-06-14 20:56:48 +08:00
SivanCola 76ae52e872 chore(deps): pin esbuild to 0.28.1 across site, worker, and desktop (#4323)
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.
2026-06-14 20:45:35 +08:00
SivanCola 72d54e3cb9 feat(desktop): performance-pressure diagnostics reporting (#4322)
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).
2026-06-14 20:41:57 +08:00
SivanCola 11963ffa3b chore(desktop): refresh app icon assets (#4321)
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
2026-06-14 20:39:00 +08:00
c376ec76e2 feat(hooks): add PermissionRequest hook for tool approvals (#4319)
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]>
2026-06-14 20:31:02 +08:00
SivanCola bea8db23b1 feat(desktop): platform-aware typography system with font controls (#4315)
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
2026-06-14 20:18:48 +08:00
SivanCola a9760911fc feat(tools): add workspace-confined move_file built-in (#4316)
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
2026-06-14 20:08:28 +08:00
SivanCola a549f2f8a7 Merge pull request #4377 from CVEngineer66/perf/reduce-layer-paints-and-remove-streaming-cursor
perf: reduce layer paints and remove streaming cursor
2026-06-14 20:08:24 +08:00
dependabot[bot] 71cea5adad build(deps): bump the go group with 3 updates
Bumps the go group with 3 updates: [charm.land/lipgloss/v2](https://github.com/charmbracelet/lipgloss), [github.com/larksuite/oapi-sdk-go/v3](https://github.com/larksuite/oapi-sdk-go) and [golang.org/x/net](https://github.com/golang/net).


Updates `charm.land/lipgloss/v2` from 2.0.3 to 2.0.4
- [Release notes](https://github.com/charmbracelet/lipgloss/releases)
- [Commits](https://github.com/charmbracelet/lipgloss/compare/v2.0.3...v2.0.4)

Updates `github.com/larksuite/oapi-sdk-go/v3` from 3.9.4 to 3.9.5
- [Release notes](https://github.com/larksuite/oapi-sdk-go/releases)
- [Changelog](https://github.com/larksuite/oapi-sdk-go/blob/v3_main/changelog.md)
- [Commits](https://github.com/larksuite/oapi-sdk-go/compare/v3.9.4...v3.9.5)

Updates `golang.org/x/net` from 0.55.0 to 0.56.0
- [Commits](https://github.com/golang/net/compare/v0.55.0...v0.56.0)

---
updated-dependencies:
- dependency-name: charm.land/lipgloss/v2
  dependency-version: 2.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: github.com/larksuite/oapi-sdk-go/v3
  dependency-version: 3.9.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: golang.org/x/net
  dependency-version: 0.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-06-14 11:52:44 +00:00
wufengfan ff9d6503a0 perf: reduce layer paints and remove streaming cursor
- 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
2026-06-14 19:50:24 +08:00
SivanCola 61bb5c8768 Fix reasoning language propagation / 修复思考语言设置传播 (#4312)
* Fix reasoning language propagation

* Address reasoning language propagation feedback
2026-06-14 19:33:03 +08:00
SivanColaandSivan 9a37d3d0c7 Fix topicbar copy tooltip clipping (#4308)
Co-authored-by: Sivan <[email protected]>
2026-06-14 19:27:56 +08:00
lab1andSivanCola a06aae9b21 Merge workbench layout while preserving classic tabs
Co-authored-by: SivanCola <[email protected]>
2026-06-14 19:09:28 +08:00
lab1andSivanCola 97ccf1cb8e fix(desktop): expose workbench time filter
Co-authored-by: SivanCola <[email protected]>
2026-06-14 18:44:07 +08:00
lab1andSivanCola 04c2eb0694 feat(desktop): add switchable workbench layout
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]>
2026-06-14 17:45:41 +08:00
lifu963andCursor f86be86b21 Merge origin/main-v2 into fix/desktop-session-sidebar-runtime
Co-authored-by: Cursor <[email protected]>
2026-06-14 15:44:35 +08:00
SivanCola 9873ae7e66 Merge pull request #4358 from ferstar/fix/desktop-4337-session-switch-freeze
fix(desktop): resolve session-switch freeze on Windows 11 (#4337)
2026-06-14 15:35:51 +08:00
J. F. ZhangandSivanCola f2198b3d72 Fix tab list order repair locking
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]>
2026-06-14 15:29:32 +08:00
lifu963andCursor 4ce9364ce4 Merge origin/main-v2 into fix/desktop-session-sidebar-runtime
Co-authored-by: Cursor <[email protected]>
2026-06-14 15:19:32 +08:00
lifu963andCursor 6266c034dc fix(desktop): remove unused session helpers
Drop stale helper functions left behind by the session runtime refactor so desktop lint stays clean.

Co-authored-by: Cursor <[email protected]>
2026-06-14 15:19:00 +08:00
SivanCola 9a30fcc8f2 Merge pull request #4332 from heshuimu/fix/codegraph-daemon-leak
fix(codegraph): shorten shared daemon idle cleanup safely / 安全缩短共享 daemon 空闲清理
2026-06-14 15:04:51 +08:00
SivanCola a05c193e6d Merge pull request #4354 from CVEngineer66/feat/lazy-tool-data
perf: lazy-load tool data on demand, fix reasoning state & action button visibility
2026-06-14 15:04:44 +08:00
SivanandSivanCola cd4376bcea fix: harden lazy tool data loading
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]>
2026-06-14 14:57:27 +08:00
SivanCola edd4996fc3 fix(codegraph): preserve shared daemon lifecycle
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.
2026-06-14 14:56:13 +08:00
lifu963andCursor dc11dc67e4 fix(desktop): make sidebar sessions own runtimes
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]>
2026-06-14 14:51:39 +08:00
wufengfan 78e3a16ae6 Merge upstream/main-v2: resolve package.json conflict
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).
2026-06-14 14:35:45 +08:00
wufengfan 71c5d21912 fix: address code review feedback
- 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)
2026-06-14 14:18:16 +08:00
wufengfan 47d168a58b fix: remove useDeferredValue from Markdown to prevent code-block flicker
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.
2026-06-14 14:18:16 +08:00
wufengfan 6bf210ea03 feat: merge #4318 reasoning display + scroll coalescing 2026-06-14 14:18:16 +08:00
wufengfan ac4f849b7a fix: archive tool items loaded from history
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.
2026-06-14 13:34:54 +08:00
zhangjianfei d052cfb4b1 fix(desktop): resolve session-switch freeze on Windows 11 (#4337)
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.
2026-06-14 13:26:51 +08:00
wufengfan 02237a3d40 test: verify render optimization effectiveness
14 tests proving the streaming markdown rendering fixes work:

- LiveAssistantMessage shown computation: stable output for stable input,
  updates only when live.text/reasoning change (Test 1)
- normalizeMath: deterministic, fast (100 calls in 0.5ms) (Test 2)
- highlightToHtml LRU cache: cached call (0.01ms) vs first call (5.67ms),
  streaming-adjacent calls under 1ms (Test 3)
- Streaming growth simulation: pattern confirmed correct (Test 4)
2026-06-14 12:50:28 +08:00
wufengfan 933f934947 perf: prevent unnecessary re-renders during streaming markdown rendering
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.
2026-06-14 12:48:10 +08:00
wufengfan 03613c8b3b feat: archive all completed tools on tool_result — 99% memory reduction
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).
2026-06-14 12:25:15 +08:00
wufengfan db88f7d452 test: add tool-data-archive test confirming 33% JS string reduction
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.
2026-06-14 12:18:30 +08:00
wufengfan 3abea28461 feat: lazy-load tool data on demand to bound frontend memory
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.
2026-06-14 12:02:15 +08:00
wufengfan dd0ce5541b fix: reasoning complete state tracking and action button visibility during streaming
- 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
2026-06-14 11:25:39 +08:00
Tommy Li 5cf4418d11 fix(codegraph): kill daemon that escapes process-group cleanup
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
2026-06-13 16:31:03 -05:00
Sivan 743d2b39e1 Address Young diagram review feedback 2026-06-14 01:34:30 +08:00
Sivan c6f2d0350d Fix Young diagram parser regressions 2026-06-14 01:05:08 +08:00
Xingbo Zhao 92fb0fd199 fix(desktop): tighten Young row spacing to -0.525em for zero visual gap
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.
2026-06-14 00:40:24 +08:00
Xingbo Zhao cb7a0cfa5b fix(desktop): use \\[-0.4em] between Young rows (closes the gap)
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.
2026-06-14 00:23:18 +08:00
Xingbo Zhao d6217f1c59 fix(desktop): raise Young cells by -0.35em so rows are flush
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.
2026-06-14 00:15:46 +08:00
Xingbo Zhao d42b1a5ae5 fix(desktop): use negative thin space (\\!) between Young cells for flush boxes
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).
2026-06-13 23:48:58 +08:00
Xingbo Zhao 3b73d54a41 fix(desktop): use \\begin{array}{l} (left) for Young diagrams
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}.
2026-06-13 23:29:57 +08:00
SivanCola ed07684137 Merge pull request #4286 from myipanta/hotfix/4275-plan-options-disappear-on-tab-switch
fix(desktop): replay pending prompts on tab switch to preserve plan m…
2026-06-13 23:25:59 +08:00
SivanCola ea0c3a9b69 Improve crash reporting diagnostics / 增强崩溃上报诊断 (#4285)
* feat: enrich crash reporting pipeline

* fix: address crash report worker review
2026-06-13 23:25:40 +08:00
Sivan fd2967b28d Add tab prompt replay regression 2026-06-13 23:19:15 +08:00
Xingbo Zhao ab9e673ae3 fix(desktop): use \\square instead of \\hphantom{x} for empty Young cells
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}.
2026-06-13 23:11:40 +08:00
ttmouse d0b6ed9e23 feat(desktop): add ⌘+W / Ctrl+W shortcut to close the active tab (#4274)
新增桌面端快捷键 ⌘+W(macOS)/ Ctrl+W(Linux/Windows)关闭当前活动的顶部标签页。
调用已有的 handleTabClose 处理函数,复用所有关闭逻辑(状态清理、切换相邻tab、最后一张保留)。
2026-06-13 20:59:37 +08:00
yizeyi18 5584350ff0 Sandbox: add forbid_read key to regular reads to unsupposed positions. 2026-06-13 19:50:18 +08:00
Xingbo Zhao 64db8bd4ac feat(desktop): also render \yng/\young in prose without $ delimiters
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
2026-06-13 18:53:53 +08:00
jiangrenhang bd6b414390 fix(desktop): replay pending prompts on tab switch to preserve plan modal (#4275)
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.
2026-06-13 18:37:24 +08:00
SivanCola f6d8cce7eb Add reasoning language settings / 添加思考语言设置 (#4267)
* feat(settings): add reasoning language preference

* fix(control): preserve reasoning language flow

* feat(cli): add reasoning language controls

* fix(agent): strip transient blocks from previews
2026-06-13 16:36:26 +08:00
87da37b876 Fix session ownership follow-up review issues (#4273)
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]>
2026-06-13 16:05:23 +08:00
YHHandwufengfan f6aba59e4e fix(desktop): optimistic rewind + checkpoint boundary corrections (#4272)
* 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]>
2026-06-13 15:57:04 +08:00
YHHandweikejia 6b115e1b4c fix(plugin): enrich + memoize shell PATH for MCP stdio subprocesses (#4268)
* 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]>
2026-06-13 15:23:25 +08:00
YHH 2008f09c00 refactor(control): drop phrase-matched approved-plan continuation (#4266)
#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.
2026-06-13 15:09:21 +08:00
YHH 4c4c23523d ci(desktop): lint the desktop module with golangci-lint (#4264)
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.
2026-06-13 14:47:56 +08:00
YHH c31e4f3db7 test(desktop): pass context.TODO() to emitReady, not nil (#4263)
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.
2026-06-13 14:25:32 +08:00
SivanCola b99e893191 fix: integrate session ownership and state routing (#4250)
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.
2026-06-13 14:17:41 +08:00
雪竜 (Tommy Li) eb624eed2e fix(migrate): import legacy default model and run migration before desktop config load (#4224)
* 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.
2026-06-13 14:04:26 +08:00
YHHandTommy Li 6e5afa592b fix(migrate): reach .jsonl-only pass for already-routed upgraders (#4260)
* 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]>
2026-06-13 14:00:06 +08:00
YHH d40797b57b fix(sandbox): normalize cmd-style "nul" redirects to the shell's null device (#4257)
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
2026-06-13 13:52:01 +08:00
YHH 07d521aaf9 feat(desktop): toggle to disable cold-resume tool-result pruning (#4255)
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.
2026-06-13 13:41:45 +08:00
SivanCola 18cb50011a Merge pull request #4247 from CVEngineer66/refactor/gsap-animation-system
style: unify expanded left-padding for foldable sections
2026-06-13 13:10:44 +08:00
wufengfan 3279f12f09 style: unify expanded left-padding for foldable sections (30px → 12px)
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.
2026-06-13 13:05:26 +08:00
SivanCola 89bfa96250 feat(desktop): compact settings sound controls (#4244) 2026-06-13 12:56:40 +08:00
YHH 6cf4a95b81 fix(desktop): follow-up to #4225 — minimal migration, streaming scroll, dead code (#4238)
* 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
2026-06-13 10:56:32 +08:00
CVEngineerandwufengfan 679f3403a3 refactor(desktop): GSAP-driven animation — scroll, collapse, entrance, approval (#4225)
* 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]>
2026-06-13 10:45:05 +08:00
Xingbo Zhao 0fbb9086f3 feat(desktop): expand \yng/\young to KaTeX-compatible Young diagrams
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$$
2026-06-13 01:32:20 +08:00
Xingbo Zhao 9398566d31 fix(desktop): step 6 wraps non-math $…$ in HTML entities
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 &#36; entity.

Step 6: non-math pairs (currency $5, env vars $PATH) now wrap in
&#36; 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).
2026-06-13 00:17:29 +08:00
Xingbo Zhao ccbc0fcb47 Merge branch 'main-v2' into dev-new-features 2026-06-13 00:08:28 +08:00
Xingbo Zhao f63e2e5242 fix(desktop): also repair display math when closing $$ follows a comma
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.
2026-06-13 00:04:56 +08:00
Xingbo Zhao fb8ed18eaf fix(desktop): classify unary plus/minus as inline math (+2, -x)
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.
2026-06-13 00:02:44 +08:00
Reasonix bc22880e6e fix(desktop): address review feedback on inline-math PR
Rebase onto current main-v2 plus five targeted cleanups called out in
the review:

1. Drop the &#36; 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 &#36;
   (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.
2026-06-13 00:02:44 +08:00
Xingbo Zhao 9879ac29c2 Preserve dollar signs in prose currency like 'cost $5 and $6'
Previously, the Step 5 regex would greedily match '$5 and $' as a single
math expression with content '5 and ', then convert it to '&#36;5 and &#36;'
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.
2026-06-13 00:02:44 +08:00
Xingbo Zhao 664aded452 Improve math classifier and code block protection
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).
2026-06-13 00:02:44 +08:00
Xingbo Zhao 1d72bbbcef fix(desktop): handle malformed code blocks on single line
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.
2026-06-13 00:02:44 +08:00
Xingbo Zhao 2e6035a4f4 fix(desktop): escape $ in code blocks to prevent KaTeX errors
Add defensive escaping for code blocks containing $ characters.
When protecting code (inline `...` or fenced ```...```), replace
$ with &#36; (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
2026-06-13 00:02:43 +08:00
Xingbo Zhao 164f9f77e2 test(desktop): rename non-English math prose to minimal LaTeX patterns
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.
2026-06-13 00:02:43 +08:00
Reasonix 283f336b47 fix(desktop): repair inline math rendering for LLM output
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).
2026-06-13 00:02:32 +08:00
YHH 62645d1c53 feat(vision): downscale attached images before sending; add detail knob (#4210)
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.
2026-06-12 23:34:07 +08:00
YHH 500fb1d2eb fix(desktop): stop clipping the tool name in the card header (#4208)
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.
2026-06-12 23:22:48 +08:00
YHHandwufengfan 091d886bc8 fix(ui): restore TurnCollapse step rendering, batch read-only in sub-agents (#4206)
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]>
2026-06-12 23:08:26 +08:00
YHH 278984fade feat(provider): send attached images to vision-capable models (#4204)
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.
2026-06-12 23:04:48 +08:00
YHHandNkAntony777 f7a61cfd07 feat(i18n): add Traditional Chinese (zh-TW) locale support (#4199)
Adds the zh-TW desktop locale and the ChineseTraditional CLI catalogue,
wired into the locale type, DICTS map, detectLocale/normalize (zh-TW /
zh-Hant / zh-HK / zh-MO, plus POSIX zh_TW.UTF-8) and the tray/migration
bridges. Taiwan terminology throughout (設定 / 檔案 / 外掛 / 網路 / 登入).

Co-authored-by: NkAntony777 <[email protected]>
2026-06-12 22:32:34 +08:00
Xingbo Zhao 785f216832 fix(desktop): also repair display math when closing $$ follows a comma
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.
2026-06-12 22:29:27 +08:00
Xingbo Zhao ba2e99dcb6 Merge branch 'main-v2' into dev-new-features 2026-06-12 22:25:16 +08:00
taibai233andtaibai 939563ab1a fix(tui): 修复mintty刷新残留问题 (#3829)
* 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]>
2026-06-12 22:23:57 +08:00
YHH cde4f9a532 fix(desktop): make the time-filter menu keyboard-operable (#4197)
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.
2026-06-12 22:08:58 +08:00
TaoMu d042d5b24a fix(desktop): reconcile stale completed turns (#4182) 2026-06-12 22:02:27 +08:00
SivanCola 529ffce7c3 Improve IM bot setup and diagnostics (#4070)
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.
2026-06-12 22:01:34 +08:00
YHH 8b500db2d3 fix(desktop): right-align the sidebar time-filter menu so it can't clip (#4196)
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).
2026-06-12 22:00:22 +08:00
ttmouse bd30b0ac62 feat(desktop): add time filter to sidebar project tree (#4150)
* 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
2026-06-12 21:55:12 +08:00
YHHandmyNinety 9480e5a09f feat(desktop): custom font family option in Appearance settings (#4194)
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]>
2026-06-12 21:42:18 +08:00
YHH 1b57e0ca0b fix(desktop): retry atomic writes past transient Windows locks (#4193)
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.
2026-06-12 21:40:39 +08:00
Xingbo Zhao 211203dd3d Merge branch 'main-v2' into dev-new-features 2026-06-12 21:37:03 +08:00
YHHandttmouse 50294fa902 feat(desktop): token-driven generative ambient music during generation (#4192)
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]>
2026-06-12 21:27:40 +08:00
YHH 6a693eb84c docs(crash): disclose Go-panic resend in telemetry copy (#4191)
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.
2026-06-12 21:22:39 +08:00
Xingbo Zhao 9249d2ed0c Merge branch 'main-v2' into dev-new-features 2026-06-12 21:17:55 +08:00
2e1ec21fd7 fix(agent): 收紧 subagent fork 复用语义 (#4184)
* fix(agent): restrict subagent forks to session lineage

Co-authored-by: Cursor <[email protected]>

* fix(agent): clarify subagent fork schema guidance

Co-authored-by: Cursor <[email protected]>

* fix(agent): require source owner metadata for subagent forks

Co-authored-by: Cursor <[email protected]>

* fix(agent): note task fork identity compatibility

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: lifu963 <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-06-12 21:15:18 +08:00
YHHandSivan 5a1c04d873 feat(desktop): customize status bar items (#4188)
Co-authored-by: Sivan <[email protected]>
2026-06-12 21:09:14 +08:00
YHH 89bcd487c7 feat(crash): capture Go panics, breadcrumbs, and group summaries (#4187)
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.
2026-06-12 21:08:37 +08:00
SivanCola 780302d8d4 Merge pull request #4176 from ttmouse/pr/notification-sounds
feat: 通知音效系统 — 生成完成 + 等待操作提示音
2026-06-12 19:10:29 +08:00
Sivan 2eba1810fb fix(desktop): gate success chime on turn outcome 2026-06-12 19:06:45 +08:00
ttmouse e9bd6f529b feat: add notification sound system with custom dropdown selector
- Add sound.ts: notification sound core module (synth + WAV playback)
- Add 4 Mixkit WAV sound effects
- bridge.ts: add onTurnDone() for agent:turn-done event
- tabs.go: emit agent:turn-done event on TurnDone
- App.tsx: playSuccessChime on generation complete
- AskCard/ApprovalModal: playAttentionChime on user confirmation
- SettingsPanel: two sound selectors (success + attention) in General section
- SoundSelect: custom dropdown component with AnchoredPopover
- i18n: EN/ZH notification sound labels
- Default: off, can be toggled to synth or WAV sounds
2026-06-12 18:28:14 +08:00
YHH f48a2411d3 fix(agent): stop the model proceeding when the user declines an ask (#4163)
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.
2026-06-12 16:56:52 +08:00
SivanCola 8572ae50be feat(desktop): add token economy composer mode (#4151)
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.
2026-06-12 16:46:42 +08:00
YHH ffaba9bf28 fix(config): show the real per-OS user config path in docs and output (#4157)
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.
2026-06-12 16:39:42 +08:00
SivanCola 92854c6a00 feat: lightweight local history + memory retrieval (#4071)
Pure-Go BM25 retrieval (no SQLite/CGO/embeddings) behind read-only, on-demand history and memory tools; archive-instead-of-delete for forgotten memories; fresh human approval (with content preview) for agent-initiated remember/forget; desktop Memory settings overhaul with heuristic candidate suggestions. Cache-first prefix stays stable, guarded by regression tests.
2026-06-12 16:12:43 +08:00
ttmouse 508f1a899a feat(desktop): 触发按钮显示模型服务商 2026-06-12 15:35:03 +08:00
YHH 5c026dface fix(desktop): make the updater download resilient — retry, resume, IPv4 fallback (#4141)
* 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.
2026-06-12 15:21:28 +08:00
ttmouse d1f13648f5 fix(desktop): remove orphaned CSS braces that broke CSS syntax check 2026-06-12 15:14:39 +08:00
YHH dc1f28cc7e fix(agent): bound compaction summary and mechanically fold on failure (#4138)
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.
2026-06-12 14:58:39 +08:00
ttmouse edc45dee38 feat(desktop): enhanced model menu with search, grouping by provider, and adaptive width
- 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
2026-06-12 14:51:33 +08:00
YHH 0597d065d6 fix(desktop): reveal files via explorer.exe's full path, not a PATH lookup (#4137)
"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.
2026-06-12 14:48:20 +08:00
Xingbo Zhao ae179539cb Merge branch 'main-v2' into dev-new-features 2026-06-12 14:41:38 +08:00
YHH 1bc0887e6f feat(desktop): add a Shell interpreter dropdown to Settings → Sandbox (#4134)
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
2026-06-12 14:39:34 +08:00
JesonChou 09fa4c03c4 fix(agent): use unique, self-documenting IDs for host-advance todo_write events (#4132)
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.
2026-06-12 14:35:49 +08:00
YHH da3fdb7e79 feat(shell): let [tools.shell] pick the bash tool's interpreter (#4131)
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).
2026-06-12 14:24:13 +08:00
YHH cc7fe66ec8 feat(plan): clarify user-owned ambiguities before writing the plan (#4129)
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.
2026-06-12 14:00:25 +08:00
YHH 7ebc413c50 feat(desktop): build the Windows ARM64 desktop app (#4128)
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.
2026-06-12 13:57:10 +08:00
YHH 76fdd9216f fix(codegraph): name codegraph tools by their real MCP-prefixed names in steering (#4125)
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
2026-06-12 13:48:08 +08:00
Starryandyhh c6fa8aaa1a feat(cli): add /rename command and show topic title in /resume list (#4055)
* 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]>
2026-06-12 13:36:53 +08:00
Xingbo Zhao 9cab779a75 Merge branch 'main-v2' into dev-new-features 2026-06-12 13:33:07 +08:00
SivanCola d05f8075ef feat(mcp): add built-in time and Context7 MCP servers (#4115)
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.
2026-06-12 13:21:42 +08:00
SivanColaandyhh e3771b8b0e Add desktop hooks settings UI / 添加桌面端 Hooks 设置界面 (#4118)
* 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]>
2026-06-12 13:21:31 +08:00
Diarica 49ce820bdb fix(desktop): skip the fade-in animation for history-restored messages (#4120)
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.
2026-06-12 13:15:07 +08:00
SivanColaandyhh ea5f17d58b Fix cost currency display / 修复成本货币符号显示 (#4061)
* 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]>
2026-06-12 12:33:10 +08:00
Di Wu b977d87a3f feat(cli): add /diff-fold to toggle diff line folding (#3952)
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.
2026-06-12 12:28:28 +08:00
YI Zeping d579b6206f fix(serve): declare sessionCount before its first use in the web UI (#4060)
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.
2026-06-12 12:22:16 +08:00
James 668e7ca2a1 fix(control): don't treat multi-line input as a quick-add memory note (#4046)
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
2026-06-12 12:21:35 +08:00
JesonChou 7251271f76 fix(agent): preserve mid-turn steer messages across tab switches (#4074)
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
2026-06-12 12:16:53 +08:00
YHH c0aac6412f feat(crash): account system for the crash dashboard (#4119)
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.
2026-06-12 12:14:48 +08:00
YHH 2fa3a57ae1 refactor(cli): extract pickCLIRelease, drop dead normalizeOS (#4116)
`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.
2026-06-12 12:03:16 +08:00
lanshi 19f6bcdcf6 feat(cli): add self-update command (reasonix upgrade/update) (#4005)
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
2026-06-12 11:42:03 +08:00
lifu963 3d3f01899a fix(config): resolve relative system_prompt_file against the workspace root (#4108)
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.
2026-06-12 11:37:26 +08:00
55 876da2e017 fix(proc): detach login-shell PATH probe from the controlling terminal (#4098)
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.
2026-06-12 11:35:06 +08:00
YHH c12c294720 fix(desktop): keep thinking, answers, and writer output visible in compact view (#4101)
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.
2026-06-12 11:30:10 +08:00
YHH b1303b0ebb perf(grep): speed up the native engine and add a search timeout (#4113)
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.
2026-06-12 11:26:46 +08:00
YHH 6edcf5adc3 fix(provider): retry transient 401s for a known-good key; clearer auth error (#4106)
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
2026-06-12 10:49:22 +08:00
8f59fe11b9 fix(desktop): new session inherits settings from active tab (#4019, #4088) (#4097)
* 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]>
2026-06-12 10:18:41 +08:00
CVEngineerandwufengfan 8ec1130e81 fix(ui): batch completed read-only tools into ReadOnlyBatch, unify card body styles (#4059)
* 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]>
2026-06-12 10:13:42 +08:00
YHHandreasonix 1930008ade fix(agent): keep prior digests verbatim across compaction; document the limit (#4082)
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]>
2026-06-12 09:17:54 +08:00
YHHandreasonix 922f7b1024 fix(agent): compact at end of a final-answer turn to prevent context overflow (#4079)
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]>
2026-06-12 08:48:57 +08:00
SivanCola c6314eccf5 Merge pull request #4054 from lifu963/pr/continue-from-schema-guidance
fix(agent): clarify subagent continuation guidance
2026-06-12 00:25:52 +08:00
SivanCola 31d002c032 fix(agent): clarify continuation reference value 2026-06-12 00:16:13 +08:00
lifu963andCursor 287a678aa5 fix(agent): clarify subagent continuation guidance
Co-authored-by: Cursor <[email protected]>
2026-06-11 23:19:27 +08:00
Xingbo Zhao 5960dc09c6 Merge remote-tracking branch 'fork/fix/inline-math-rendering' into dev-new-features 2026-06-11 23:11:36 +08:00
YHHandreasonix d0149492ba feat(agent): keep every user turn verbatim across compaction + facts digest (#4052)
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]>
2026-06-11 22:57:53 +08:00
Xingbo Zhao c93af0ac5f fix(desktop): classify unary plus/minus as inline math (+2, -x)
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.
2026-06-11 22:52:47 +08:00
YHHandreasonix 1c6c6393c1 fix(agent): pin the user's task and prior summaries across compaction (#4048)
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]>
2026-06-11 22:27:30 +08:00
Xingbo Zhao 587abdcdd5 Merge branch 'main-v2' into dev-new-features 2026-06-11 20:41:58 +08:00
YHHandreasonix 4c5fec6136 ci(desktop): rebuild site after stable release so version follows (#4036)
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]>
2026-06-11 20:41:25 +08:00
Xingbo Zhao 9a4e9af340 Merge branch 'main-v2' into dev-new-features 2026-06-11 20:19:41 +08:00
YHHandreasonix 83f1e04890 feat(desktop): settings toggle for opt-in agent metrics (#4034)
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]>
2026-06-11 20:10:22 +08:00
YHHandreasonix 6b9d3674bf feat(desktop): opt-in aggregate agent-metrics aggregator + flush (#4033)
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]>
2026-06-11 19:58:28 +08:00
Xingbo Zhao 6d3e2a6ad3 Merge branch 'main-v2' into dev-new-features 2026-06-11 19:53:23 +08:00
YHHandreasonix 449b7a653d refactor(ui): keep running read-only tools out of the warm-turn batch (#4032)
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]>
2026-06-11 19:51:41 +08:00
Xingbo Zhao c9a70ac4fd Merge branch 'main-v2' into dev-new-features 2026-06-11 19:51:34 +08:00
CVEngineerandwufengfan 2656669b94 fix(ui): batch completed read-only tools into ReadOnlyBatch in compact/minimal mode (#4031)
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]>
2026-06-11 19:46:48 +08:00
YHHandreasonix 653ab079ce feat(crash-worker): add /v1/metrics ingest for opt-in agent metrics (#4030)
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]>
2026-06-11 19:42:55 +08:00
CVEngineerandwufengfan cd4a2bde21 fix(ui): restrict inverted user bubble style to graphite theme only (#4023)
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]>
2026-06-11 19:27:16 +08:00
SivanCola 6db97a0a6c Merge pull request #3983 from whale-fall-ouo/fix/palette-polish
fix(desktop): 优化命令面板样式与信息展示
2026-06-11 19:24:22 +08:00
James c85b4a4535 fix(desktop): show session-scoped file changes in workspace panel (#4026)
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
2026-06-11 19:18:48 +08:00
YHHandreasonix 2c6092eee2 refactor(evidence): reuse stepTextContains for todo overlap, hoist bounds guard (#4027)
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]>
2026-06-11 19:17:56 +08:00
JesonChou 0869750e8b fix(evidence): allow rephrased todos to pass complete_step verification (#4013)
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.
2026-06-11 19:12:14 +08:00
6f338e2f38 fix(chat): clear accumulated shell output on !command re-run (+ regression test) (#4025)
* 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]>
2026-06-11 19:12:02 +08:00
YHHandreasonix 83f87c07b2 fix(desktop): apply pnpm purge guard so post-sync wails dev stops aborting (#4024)
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]>
2026-06-11 19:10:07 +08:00
YHHandreasonix 0263ee9b3a fix(openai): round-trip reasoning_content on DeepSeek tool_calls turns (#4022)
* 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]>
2026-06-11 18:57:51 +08:00
Sivan 44541fc3e6 fix(desktop): polish palette session metadata 2026-06-11 18:47:34 +08:00
SivanCola 3505bd651e feat(bot): IM bot connection workflow (Feishu/WeChat) + desktop UI (#3892)
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.
2026-06-11 18:32:24 +08:00
YHH 6dee96c472 fix(agent): close complete_step cross-turn evidence + loop gaps (#4014)
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
2026-06-11 18:27:52 +08:00
YHH 4924d9fefb fix(chat): render correct line count under each parallel bash marker (#4010)
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
2026-06-11 17:20:16 +08:00
Xingbo Zhao a71d1d2b34 Merge branch 'main-v2' into dev-new-features 2026-06-11 17:11:55 +08:00
JesonChou 5a487a5b6b fix(config): honor default_model in ResolveModelWithFallback (#3994)
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
2026-06-11 17:06:18 +08:00
YHHandreasonix 0250cbc32d fix(evidence): tolerate citation drift when matching complete_step to todos (#4006)
* 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]>
2026-06-11 16:57:24 +08:00
SivanCola 40b1f94e20 fix: Improve desktop context status metrics (#3996)
* fix: improve desktop context status metrics

* fix: address context metrics review feedback
2026-06-11 16:42:30 +08:00
zqy c0fa845ad6 fix(desktop): 命令面板命令区域改为横向网格布局
- 命令项改为 4-5 列网格布局,紧凑显示图标+标题
- 会话项保持列表布局,显示路径、日期、轮数
2026-06-11 16:42:10 +08:00
YHHandreasonix f528630468 fix(desktop): compact-mode step fold follow-ups (centering, empty folds, 0s label, thinking flicker) (#4000)
* 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]>
2026-06-11 16:40:22 +08:00
YHHandreasonix 6bc71a8b76 feat(workers): restyle /stats to the site visual language + crash group detail pages (#3997)
Co-authored-by: reasonix <[email protected]>
2026-06-11 16:29:07 +08:00
Reasonix 8c477b5c6b fix(desktop): address review feedback on inline-math PR
Rebase onto current main-v2 plus five targeted cleanups called out in
the review:

1. Drop the &#36; 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 &#36;
   (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.
2026-06-11 16:14:34 +08:00
YHHandreasonix 3583c9a398 site: footer link to the stats page (#3993)
Co-authored-by: reasonix <[email protected]>
2026-06-11 16:14:07 +08:00
SivanCola 5debb08e87 Merge pull request #3976 from SivanCola/fix/active-key-prompts
Prompt only for active model keys / 仅提示活跃模型密钥
2026-06-11 16:06:58 +08:00
YHHandwufengfan 6fcb37e66e feat(ui): unify process/tool/reasoning card style with shimmer sweep animation (#3991)
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]>
2026-06-11 16:01:10 +08:00
YHHandreasonix c58bcc3a06 feat(desktop): anonymous launch ping, crash device info, /stats page (#3985)
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]>
2026-06-11 15:53:08 +08:00
Xingbo Zhao b1fb63e4d2 Preserve dollar signs in prose currency like 'cost $5 and $6'
Previously, the Step 5 regex would greedily match '$5 and $' as a single
math expression with content '5 and ', then convert it to '&#36;5 and &#36;'
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.
2026-06-11 15:52:00 +08:00
Xingbo Zhao ed6723595d Improve math classifier and code block protection
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).
2026-06-11 15:52:00 +08:00
Xingbo Zhao ccef134a44 fix(desktop): handle malformed code blocks on single line
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.
2026-06-11 15:52:00 +08:00
Xingbo Zhao c712bd647c fix(desktop): escape $ in code blocks to prevent KaTeX errors
Add defensive escaping for code blocks containing $ characters.
When protecting code (inline `...` or fenced ```...```), replace
$ with &#36; (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
2026-06-11 15:52:00 +08:00
Xingbo Zhao ae0a04ddb2 test(desktop): rename non-English math prose to minimal LaTeX patterns
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.
2026-06-11 15:52:00 +08:00
Reasonix 0b03c9486e fix(desktop): repair inline math rendering for LLM output
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).
2026-06-11 15:52:00 +08:00
YHHandreasonix d3fbcb6b19 fix(evidence): stop rejecting real complete_step verifications over command-string drift (#3982)
* 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]>
2026-06-11 15:37:56 +08:00
zqy f78fa48c26 fix(desktop): 优化命令面板样式与信息展示
- 移除重复的 palette CSS 规则集,统一使用主样式
- 为每个命令添加独立图标(SquarePen、History、Trash2 等)
- 会话项显示工作空间路径、相对日期和对话轮数
- 轮数使用 tabular-nums 等宽对齐
- hint 改为 flex 布局,路径/日期/轮数各占一位
2026-06-11 15:28:44 +08:00
YHHandreasonix 8feccbfa5b feat(desktop): user-initiated crash reporting via crash.reasonix.io (#3978)
* 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]>
2026-06-11 15:25:55 +08:00
Sivan b249410188 fix(cli): respect auto-plan classifier gate 2026-06-11 15:08:31 +08:00
Xingbo Zhao 274b545081 Merge branch 'main-v2' into dev-new-features 2026-06-11 15:01:42 +08:00
Sivan 8a8627e391 fix(cli): prompt only for active model keys 2026-06-11 15:01:02 +08:00
YHHandreasonix c5c3093516 fix(chat): repaint late-collapsed tool slots immediately (#3973)
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]>
2026-06-11 14:54:04 +08:00
CnsMaple 83fa9fcb2e fix(chat): keep each tool marker under its own card in back-to-back dispatches (#3951)
* 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.
2026-06-11 14:49:00 +08:00
YHHandreasonix 89befd23e2 fix(desktop): restore palette focus outline and platform-correct search kbd (#3964)
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]>
2026-06-11 14:39:38 +08:00
YHHandreasonix 3025f5af2a feat(agent): deterministic tool-result pruning + cache-TTL-aware cold-resume maintenance (#3968)
* 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]>
2026-06-11 14:38:27 +08:00
TaoMu 810807a83d fix(desktop): restore command palette session row layout (#3962) 2026-06-11 14:12:56 +08:00
YHHandreasonix 01774d87a7 fix(desktop): make the macOS tab-strip empty space drag the window (#3963)
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 #3853
Closes #3852

Co-authored-by: reasonix <[email protected]>
2026-06-11 14:04:38 +08:00
YHHandreasonix 5bfb10eb2b fix(desktop): stop pinning management modals to a compositor layer (#3961)
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 #3838
Closes #3902
Closes #3899

Co-authored-by: reasonix <[email protected]>
2026-06-11 14:04:31 +08:00
YHHandreasonix 7fe7167275 fix(config): read the pre-mcpServers mcp string list from v0.x config.json (#3960)
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]>
2026-06-11 14:04:25 +08:00
YHHandreasonix 734d336c67 fix(agent): derive previews and auto-titles from the original task in dual-model sessions (#3958)
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]>
2026-06-11 14:04:18 +08:00
YHHandreasonix adde2d3e23 fix(provider): stop committing half-streamed tool-call args (#3953) (#3957)
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]>
2026-06-11 14:04:12 +08:00
YHHandreasonix 5cc7b989cf test(desktop): wire attachment-display test into the frontend suite (#3954)
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]>
2026-06-11 13:21:48 +08:00
Abysstsuki 6cef84cb36 feat: improve attachment preview cards (#3786)
* feat: improve attachment preview cards

* fix: use z-index token for attachment remove button
2026-06-11 13:17:14 +08:00
ttmouse cc1023c819 feat: toggle Developer Tools (Cmd+I) (#3928) 2026-06-11 13:12:27 +08:00
Sheroy Cooper c2ff2c736e docs: use canonical repo in README badges (#3925) 2026-06-11 13:12:16 +08:00
TaoMu 6b37bd5bb5 fix(desktop): preserve selected message copy (#3942) 2026-06-11 13:11:21 +08:00
CnsMaple 4fbebb320e fix: Ctrl+C in running state copies selection instead of cancelling turn (#3947) 2026-06-11 13:09:05 +08:00
CnsMaple 31c4207627 fix(tui): wrap status line instead of truncating when too long (#3054)
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.
2026-06-11 13:08:08 +08:00
YHHandreasonix 2a7d83f465 fix(codegraph): run the indexer daemon below normal priority (#3948)
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]>
2026-06-11 12:45:49 +08:00
YHHandreasonix 80fc50da03 fix(control,cli): survive SSH drops with mid-turn autosave and SIGHUP save (#3943)
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]>
2026-06-11 12:39:45 +08:00
YHHandreasonix 4e5aabefdc fix(desktop): surface an error when session actions race controller startup (#3946)
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]>
2026-06-11 12:38:48 +08:00
YHHandreasonix dc6a1e3a86 fix(cli): only prompt for API keys the active config references (#3945)
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]>
2026-06-11 12:31:39 +08:00
YHHandreasonix 2f45fb2f66 fix(migrate): route legacy v0.x sessions into their workspace session dirs (#3944)
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]>
2026-06-11 12:29:41 +08:00
YHHandreasonix 316c9213b0 fix(control,desktop): keep steer messages out of the turn count (#3936)
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]>
2026-06-11 12:15:17 +08:00
Bernardxu123andBernardxu123 d4ef0b6405 fix(desktop): use DesktopLanguage for i18n slash command descriptions (#3876)
* 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]>
2026-06-11 12:13:13 +08:00
YHHandreasonix 74d7b642d0 fix(desktop): wait for the forked tab before loading its history (#3934)
* 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]>
2026-06-11 12:09:59 +08:00
YHHandreasonix 3370467fdb fix(control): never render compaction fold summaries as user bubbles (#3932)
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]>
2026-06-11 12:01:19 +08:00
YHHandreasonix f73f04fe8a fix(desktop): stop git.exe console flashes from workspace probes (#3930)
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]>
2026-06-11 11:55:34 +08:00
YHHandashishexee ef9eae85c9 fix(desktop): close stuck thinking window after stream interruption (#3746) (#3927)
Co-authored-by: ashishexee <[email protected]>
2026-06-11 11:47:47 +08:00
YHHandreasonix 470252babc release: move updater + codegraph downloads to dl.reasonix.io (#3926)
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]>
2026-06-11 11:41:39 +08:00
YHHandreasonix 842aff852a site: serve downloads from dl.reasonix.io, bake contributor avatars into the build (#3923)
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]>
2026-06-11 11:30:32 +08:00
YHHandSivan 9cc1d581cb feat(desktop): context overview navigation and status bar metrics (#3922)
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]>
2026-06-11 11:28:45 +08:00
SuMuxi66 406fd03327 fix(fileref): restore fuzzy @file search for nested paths (#3846)
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.
2026-06-11 11:26:27 +08:00
HHHEEEWWW 98a365dc91 fix: add explicit serial workflow instructions to executor handoff and plan-approved message (#3912)
* fix: add serial workflow instruction to executor handoff

* fix: enhance planApprovedMessage with serial workflow instructions
2026-06-11 11:25:29 +08:00
JesonChou c8f2a3b825 feat(agent): mid-turn steer injection with tab-scoped routing (#3360)
- Add Agent steerQueue + Steer/SteerConsumed/consumeSteer methods
- Consume queued steer each loop iteration, persist to session, emit Steer event
- Defer final-answer return while steer queue has items
- Add event.Steer kind with wire mappings (desktop + serve) and tests
- Add Controller.Steer/SteerConsumed delegation to executor
- Add App.Steer/SteerForTab with per-tab routing
- Add Steer/SteerForTab to frontend AppBindings, mock, and useController
- Add runningRef + steer dispatch in App.tsx handleSend
- Add MCPSurfaceReady gap-fix in wire mappings
2026-06-11 11:17:42 +08:00
CnsMaple 559b22cc88 feat(cli): add fuzzy matching to the slash-command completion menu (#3833)
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.
2026-06-11 11:16:30 +08:00
YHHandashishexee 206b4c34a0 feat(desktop): add expand_thinking config toggle (#3303) (#3919)
Also persists /verbose toggle across CLI sessions (#3312).

Closes #3303
Closes #3312

Co-authored-by: ashishexee <[email protected]>
2026-06-11 11:12:06 +08:00
YHHandSivan 654b62118e feat: add session PDF and image export (#3917)
Co-authored-by: Sivan <[email protected]>
2026-06-11 11:04:09 +08:00
f7769e47da fix(desktop): surface send failures on the user bubble (#3916)
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]>
2026-06-11 11:04:00 +08:00
YHHandzqy 9539c35378 fix(desktop): widen model switcher popover for long model names (#3915)
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]>
2026-06-11 11:03:51 +08:00
YHHandreasonix 9df1e96b06 site: redesign landing + docs on the new visual language, fix install channel (#3918)
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]>
2026-06-11 11:03:27 +08:00
SuMuxi66 4ec533fc7c fix(message): render markdown while streaming assistant output (#3337)
* fix(message): keep streaming cursor inside markdown

* fix(markdown): inject streaming cursor after rendering
2026-06-11 10:49:07 +08:00
JesonChou 6ae1b57b24 fix(permission): prefer bash command prefix for approval grants (#3815)
* 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.
2026-06-11 02:39:22 +00:00
lanshi 53eb72c9e5 fix: custom provider model list overflow and add /provider command (#3766)
* 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(...))
2026-06-11 10:37:32 +08:00
cd37d143e8 fix(desktop): extend diff row background on horizontal scroll (#3637)
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]>
2026-06-11 02:12:20 +00:00
34c760309f fix(desktop): cap model switcher list height to prevent viewport overflow (#3644)
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]>
2026-06-11 02:12:03 +00:00
lightfrontandXingbo Zhao 8f91f82a73 fix(katex): convert aligned→align when \tag is present (#3847)
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]>
2026-06-11 10:10:55 +08:00
SivanCola 040126d224 Unify mode shortcuts across desktop and CLI (#3885) 2026-06-11 09:49:25 +08:00
SivanCola b2189abbb0 fix(desktop): keep composer usable beside workspace / 保持工作区旁输入区可用 (#3886)
* fix(desktop): keep composer usable beside workspace

* fix(desktop): left align composer controls
2026-06-11 09:43:00 +08:00
SivanCola c55dec6001 Merge pull request #3888 from esengine/codex/update-check-controls
[codex] Add desktop update check controls / 增加桌面更新检查控制
2026-06-11 09:41:30 +08:00
SivanCola 413b583274 fix(control): treat slash paths as prompts (#3887) 2026-06-11 09:40:00 +08:00
SivanCola 3648d74a7f Merge pull request #3891 from SivanCola/fix/macos-top-drag-rail
Fix macOS top chrome drag rail / 修复 macOS 顶部窗口拖拽区域
2026-06-11 09:37:57 +08:00
Diarica 1c53b473ee fix: ContextPanel token breakdown uses mismatched units (snapshot vs cumulative) (#3854)
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.
2026-06-11 01:36:54 +00:00
Xingbo Zhao 392847edfd Merge branch 'main-v2' into dev-new-features 2026-06-11 09:36:37 +08:00
SivanCola c9198c3e0a Merge pull request #3894 from SivanCola/fix/anchored-popover-resize-position
Fix anchored popover resize positioning / 修复弹层尺寸变化后的定位
2026-06-11 09:35:52 +08:00
Xingbo Zhao a035b7b910 Merge branch 'main-v2' into dev-new-features 2026-06-11 09:22:01 +08:00
lab1 5d69e27dec Fix anchored popover repositioning on resize 2026-06-11 03:55:22 +08:00
lab1 c6ac6ec1b1 Fix macOS top drag rail 2026-06-11 03:24:01 +08:00
lab1 0adba93254 Add desktop update check controls 2026-06-11 02:17:28 +08:00
SivanCola 5dac1dfb0b Merge pull request #3878 from lifu963/pr/issue-3863-workspace-overflow
fix(desktop): keep workspace dock within viewport
2026-06-11 01:09:40 +08:00
lab1 f1ac264423 Add workspace dock layout regression test 2026-06-11 01:03:42 +08:00
Xingbo Zhao e64484c1b8 Merge branch 'main-v2' into dev-new-features 2026-06-11 00:50:44 +08:00
YHH 0f27608319 fix(site): serve from custom domain reasonix.io (#3880)
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.
2026-06-10 09:03:25 -07:00
lifu963andCursor ab468d2165 chore(desktop): clarify workspace dock behavior
Replace the stale floating-mode comment with the viewport-clamped dock strategy implemented for the Workspace panel.

Co-authored-by: Cursor <[email protected]>
2026-06-10 23:46:19 +08:00
lifu963andCursor 717adf678d fix(desktop): keep workspace resize range valid
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]>
2026-06-10 23:46:19 +08:00
lifu963andCursor 94c31fc429 fix(desktop): keep workspace dock within viewport
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]>
2026-06-10 23:46:19 +08:00
Xingbo Zhao 439195f960 Merge branch 'main-v2' into dev-new-features 2026-06-10 23:14:07 +08:00
YHHandreasonix 924c691dc3 fix(desktop): keep the tray responsive when the webview is wedged (#3862)
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]>
2026-06-10 07:07:41 -07:00
YHHandreasonix 761e966904 fix(desktop): replay pending approval/ask prompts on (re)connect (#3857)
* 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]>
2026-06-10 06:53:55 -07:00
Xingbo Zhao 3a08c5fe07 Merge branch 'main-v2' into dev-new-features 2026-06-10 20:34:18 +08:00
SivanCola b31898521b Merge pull request #3830 from SivanCola/codex/windows-webview2-blank-window
fix(desktop): harden Windows WebView startup
2026-06-10 20:24:04 +08:00
YHHandreasonix a3a6e80804 docs: split usage guide out of README, link from a docs index (#3839)
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]>
2026-06-10 04:14:18 -07:00
Xingbo Zhao 561d16a014 Merge branch 'main-v2' into dev-new-features 2026-06-10 18:58:11 +08:00
SivanCola e63d73884f Merge pull request #3784 from SuMuxi66/fix/at-ref-cross-workspace
fix(agent): scope @-references to workspace root instead of process CWD
2026-06-10 17:43:41 +08:00
Sivan 99e11643dd fix(desktop): harden windows webview startup 2026-06-10 17:41:38 +08:00
SivanandSuMuxi66 c23663aff9 fix(agent): tighten workspace-scoped references
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]>
2026-06-10 17:39:54 +08:00
Xingbo Zhao a7d0cfb94b Merge branch 'main-v2' into dev-new-features 2026-06-10 17:32:55 +08:00
SuMuxi66 9424cf28b1 fix(agent): scope @-references to workspace root instead of process CWD
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
2026-06-10 17:01:56 +08:00
SivanCola 31f601f3d2 fix(desktop): stabilize sidebar and settings layout (#3819) 2026-06-10 01:37:17 -07:00
SivanCola b40088654a Merge pull request #3814 from SivanCola/codex/dark-theme-logo-adaptation
fix: adapt logos for dark theme / 修复深色主题 logo 适配
2026-06-10 16:36:37 +08:00
HBLADEHandreasonix ec9ff89de5 fix(desktop): deduplicate blank tab creation (#3219) (#3741)
* 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]>
2026-06-10 01:25:47 -07:00
Sivan cdf023d197 fix(plugin): record timeout stats at budget floor 2026-06-10 16:09:23 +08:00
SivanCola d6f10b8cbe Merge pull request #3707 from SivanCola/codex/fix-desktop-image-paste
fix(desktop): restore clipboard image paste / 修复桌面端剪贴板图片粘贴
2026-06-10 15:49:40 +08:00
Sivan 9f102a6859 fix(desktop): adapt logos for dark theme 2026-06-10 15:44:56 +08:00
YHHandreasonix c3cc19ef25 fix(desktop): MCP batch retry via allSettled; drop dead browser-preview guard (#3809)
* 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]>
2026-06-10 00:38:16 -07:00
Xingbo Zhao f0414e1c22 Merge branch 'main-v2' into dev-new-features
# Conflicts:
#	desktop/frontend/src/__tests__/math-golden.test.ts
#	desktop/frontend/src/components/latexNormalize.ts
2026-06-10 15:36:46 +08:00
Sivan d034323a2e Merge remote-tracking branch 'origin/main-v2' into codex/fix-desktop-image-paste
# Conflicts:
#	desktop/frontend/src/components/Composer.tsx
2026-06-10 15:34:57 +08:00
SivanCola d25eac5d39 chore: add desktop production test build helper (#3808) 2026-06-10 00:32:28 -07:00
SivanCola 15d4a254f9 Merge pull request #3803 from esengine/codex/mcp-settings-error-summary
fix(desktop): collapse noisy MCP startup errors
2026-06-10 15:18:14 +08:00
SivanCola 46140a6757 Merge pull request #3789 from SivanCola/codex/preserve-curated-model-refresh
fix(desktop): preserve curated provider models on refresh
2026-06-10 15:16:41 +08:00
SivanCola ba8505be6e Merge pull request #3800 from SivanCola/codex/restore-sidebar-new-session-layout
Restore compact sidebar new session button / 恢复侧栏新建会话紧凑布局
2026-06-10 15:16:38 +08:00
Sivan 605dc5ca79 polish(desktop): refine MCP settings issue layout 2026-06-10 15:14:48 +08:00
Sivan cb118da388 Merge remote-tracking branch 'origin/main-v2' into codex/pr3789-resolve-conflicts
# Conflicts:
#	desktop/frontend/src/__tests__/provider-model-refresh.test.ts
#	desktop/frontend/src/components/SettingsPanel.tsx
#	desktop/frontend/src/components/WorkspacePanel.tsx
2026-06-10 14:57:11 +08:00
Sivan df7bee8e84 test(desktop): guard sidebar new session CTA layout 2026-06-10 14:53:28 +08:00
dd6e3496ca refactor(desktop): extract goal-draft collaboration-mode helpers (#3802)
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]>
2026-06-09 23:49:28 -07:00
Sivan b6ffe3a7c5 fix(desktop): collapse noisy MCP startup errors 2026-06-10 14:47:25 +08:00
Sivan 9b797b023f fix(desktop): align enabled model saves with chat picker 2026-06-10 14:37:01 +08:00
Li-Charles-OneandLi-Charles-One 345821bf84 fix(desktop): add reveal-in-file-manager to workspace context menu (#3539) (#3583)
- 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]>
2026-06-09 23:29:38 -07:00
Li-Charles-OneandLi-Charles-One 71247d7b1b fix(desktop): model selector dropdown scroll and tooltip for truncated names (#3628, #3629) (#3647)
- 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]>
2026-06-09 23:29:34 -07:00
Greg Lyu 502e038584 fix(tui): return pendingModelSwitch after skill delete to unblock Enter (#3764) (#3783) 2026-06-09 23:29:30 -07:00
SivanCola 56198d2d87 Follow up desktop UI fixes after PR #3752 merge / 补入 #3752 合并后的桌面 UI 修复 (#3795)
* fix(desktop): preserve curated provider models on refresh

* fix(desktop): clarify composer collaboration entry

* fix(desktop): keep draft goal mode visible

* fix(desktop): allow approval mode changes while running
2026-06-09 23:29:23 -07:00
YHHandreasonix de2cdc57f7 chore(deps-dev): bump @types/react-dom to 19 to match @types/react (#3793)
* 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]>
2026-06-09 23:23:06 -07:00
dependabot[bot] 509203695d chore(deps-dev): bump typescript in /desktop/frontend (#3691)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 22:53:42 -07:00
Sivan 225605bd5b feat(desktop): require explicit enabled model saves 2026-06-10 13:50:21 +08:00
YHHandreasonix 9bdff298ce fix(desktop): make the frontend typecheck under TypeScript 6 (#3791)
@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]>
2026-06-09 22:47:05 -07:00
Sivan 73cfe5f646 fix(desktop): satisfy React 19 frontend types 2026-06-10 13:43:32 +08:00
Sivan aa6e0e1cb7 fix(desktop): preserve curated provider models on refresh 2026-06-10 13:31:31 +08:00
daf60e8f6c fix(proc): reap codegraph's process tree off Windows via process groups (#3787)
* 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]>
2026-06-09 22:27:07 -07:00
TaoMu 54531f34a2 fix desktop workspace session isolation (#3029) 2026-06-09 22:07:17 -07:00
SivanCola f45708c906 Split new and clear session flows (#3780) 2026-06-09 22:07:10 -07:00
SivanCola 5f02fd7466 feat(control): extract text from PDF attachments / 提取 PDF 附件文本 (#3618)
* feat(control): extract text from PDF attachments

* fix(control): bound PDF extractor subprocess output
2026-06-09 22:04:25 -07:00
dependabot[bot] 1610eba094 chore(deps): bump react, react-dom and @types/react in /desktop/frontend (#3690)
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react), [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) and [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react). These dependencies needed to be updated together.

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

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

Updates `@types/react` from 18.3.29 to 19.2.17
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.2.17
  dependency-type: direct:development
  update-type: version-update:semver-major
- dependency-name: react
  dependency-version: 19.2.7
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: react-dom
  dependency-version: 19.2.7
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 22:03:24 -07:00
Yosef Weissmannand广东移动系统集成揭阳公司-黄韵学 655223c1cc feat(tui): add Ctrl+Home/Ctrl+End shortcuts to scroll transcript viewport to top/bottom (#3723)
Co-authored-by: 广东移动系统集成揭阳公司-黄韵学 <[email protected]>
2026-06-09 22:03:17 -07:00
CVEngineerandwufengfan a67272791f fix(desktop): stop auto-populating empty model lists from provider API (#3711)
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]>
2026-06-09 21:29:23 -07:00
SivanCola 7377f46239 fix(diff): avoid input-derived Myers allocation arithmetic (#3777) 2026-06-09 21:27:49 -07:00
YHHandreasonix a2df6abe6a fix(bot): require Feishu webhook token; split weixin account/login out (#3779)
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]>
2026-06-09 21:16:08 -07:00
SivanCola 28bedc6b7f feat(bot): add bot gateway with Feishu/Weixin/QQ adapters (#3763)
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.
2026-06-09 21:05:02 -07:00
YHHandreasonix f55aa476c5 fix(diff): clamp the Myers edit-distance operands to bound the allocation (#3775)
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]>
2026-06-09 20:54:58 -07:00
YHHandreasonix 1fe5f2c69e fix(diff): compute the Myers n+m bound only when it can't overflow (#3773)
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]>
2026-06-09 20:43:55 -07:00
SivanCola c80ba8ad76 feat(desktop): themeable workspace UI, tool-approval modes, and /goal loop (#3752)
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.
2026-06-09 20:32:18 -07:00
YHHandreasonix 7359f757b1 test(desktop): use single-backslash inputs in the slashed golden tests (#3761)
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]>
2026-06-09 20:01:54 -07:00
lightfrontandXingbo Zhao a6de668e43 fix(desktop): convert \slashed to \not for KaTeX compatibility (#3750)
* 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]>
2026-06-09 19:52:36 -07:00
YHHandreasonix 281888825b fix(codegraph): stop leaking detached MCP daemons and indexing the whole drive (#3747) (#3755)
* 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]>
2026-06-09 19:44:35 -07:00
Xingbo Zhao 4fe99058cf 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.
2026-06-10 10:04:29 +08:00
Xingbo Zhao 959ca56785 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
2026-06-10 09:30:19 +08:00
YHHandreasonix ceceff202c fix(bash): reap the process group after a command completes (#3702) (#3748)
* 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]>
2026-06-09 18:24:44 -07:00
Xingbo Zhao 13f3f357c1 Merge branch 'main-v2' into dev-new-features 2026-06-10 09:22:58 +08:00
YHHandreasonix 9976e6bae1 fix(provider): time out a stalled SSE stream instead of hanging (#3374) (#3745)
* @
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]>
2026-06-09 17:59:38 -07:00
YHH ea44f3a083 fix(desktop): strip controller-injected prefixes from chat display (#3720)
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.
2026-06-09 17:22:00 -07:00
YHHandreasonix 175d43d43d fix(security): resolve open code-scanning alerts (archive extraction, command injection, untrusted checkout, integer bounds) (#3718)
* @
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]>
2026-06-09 17:02:45 -07:00
5939500d0b Continue subagent transcripts (#3586)
* 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]>
2026-06-09 16:52:15 -07:00
YHH e634cfd835 ci(e2e-bot): build with go.mod's Go version, not pinned 1.22 (#3731)
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.
2026-06-09 09:29:35 -07:00
YHH 0234218952 test(e2e): add subagent-delegation task that forces a task sub-agent (#3725)
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.
2026-06-09 09:04:44 -07:00
Xingbo Zhao f50e866b90 Merge branch 'main-v2' into dev-new-features 2026-06-09 23:59:31 +08:00
YHH 6b0fe9bc25 test: de-flake two windows-latest timing tests (#3726)
* 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.
2026-06-09 08:58:48 -07:00
CnsMaple dfd7ff06d7 fix(tui): keep plan mode on Esc; Ctrl+C copy beats clear (#3670)
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.
2026-06-09 08:52:25 -07:00
55 5d1b106a26 fix(tui): suspend cleanly on Ctrl+Z (#3697)
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.
2026-06-09 08:49:08 -07:00
wade1999 4d83146bb2 fix(desktop): keep Windows tray loop on locked OS thread (#3717)
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.
2026-06-09 08:28:35 -07:00
CnsMaple b7786ce6bf fix(model): persist /model selection to user config.toml (#3671)
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).
2026-06-09 08:25:44 -07:00
Xingbo Zhao 55fab7e49a Merge branch 'main-v2' into dev-new-features 2026-06-09 23:25:14 +08:00
Xingbo Zhao 3deb80172a Merge branch 'main-v2' into dev-new-features 2026-06-09 23:23:43 +08:00
CnsMaple 3fe1fa09b9 fix(setup): align custom provider base URL between wizard probe and chat client (#3669)
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.
2026-06-09 08:23:11 -07:00
CVEngineerandwufengfan 7b256fc73a fix(dev): suppress pnpm TTY prompts and lockfile age checks in dev script (#3701)
* 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]>
2026-06-09 08:19:17 -07:00
paradoxSCH b0b1b17a5f fix(serve): restore query helper (#3658) 2026-06-09 08:15:09 -07:00
HorusEyesandHorusJiang 48d21d569f fix: reload persist rule into in-memory Policy immediately (#3716)
* 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]>
2026-06-09 15:12:26 +00:00
Xingbo Zhao 7bf6b22d89 Merge branch 'fix/inline-math-rendering' into dev-new-features 2026-06-09 23:06:41 +08:00
YHHandreasonix 51cfa8cc7e ci: suppress staticcheck SA5011 false positives in test files (#3715)
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]>
2026-06-09 07:30:41 -07:00
YHHandreasonix 4684e93fb2 fix(config): honor an explicit proxy for no_proxy providers like mimo (#3635) (#3714)
* 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]>
2026-06-09 06:51:08 -07:00
YHHandreasonix dea3ee1f98 feat(skill): add read_skill for loading inline skills in plan mode (#3713)
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]>
2026-06-09 06:36:34 -07:00
lab1 974f3d494a fix(desktop): read attachment previews from active workspace 2026-06-09 21:29:12 +08:00
f3d9ef3f6c fix(control): don't auto-answer the ask tool in YOLO mode (#3624) (#3712)
* 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]>
2026-06-09 06:22:01 -07:00
CVEngineerandwufengfan dae5757d35 fix(desktop): remove command text from approval rule buttons, causing layout breakage (#3700)
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]>
2026-06-09 06:04:06 -07:00
paradoxSCH 512a182672 fix(agent): allow pending todo signoff (#3673) 2026-06-09 05:59:44 -07:00
lab1 d948b8f13b Merge remote-tracking branch 'origin/main-v2' into codex/fix-desktop-image-paste
# Conflicts:
#	internal/control/shell_test.go
#	internal/provider/anthropic/anthropic_test.go
2026-06-09 20:49:40 +08:00
lab1 5f1a70b986 test: satisfy staticcheck nil guards 2026-06-09 20:47:58 +08:00
YHHandreasonix e904766a1e test: structurally guard nil-deref so staticcheck SA5011 can't misfire (#3706)
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]>
2026-06-09 05:47:19 -07:00
lab1 e0d7af713e fix(desktop): restore clipboard image paste 2026-06-09 20:39:47 +08:00
Xingbo Zhao 83dda9b617 Preserve dollar signs in prose currency like 'cost $5 and $6'
Previously, the Step 5 regex would greedily match '$5 and $' as a single
math expression with content '5 and ', then convert it to '&#36;5 and &#36;'
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.
2026-06-09 20:32:34 +08:00
Xingbo Zhao 25b24795c1 Improve math classifier and code block protection
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).
2026-06-09 20:05:44 +08:00
dependabot[bot] 6a1267bb57 chore(deps): bump the go group across 1 directory with 3 updates (#3678)
Bumps the go group with 3 updates in the / directory: [golang.org/x/sys](https://github.com/golang/sys), [golang.org/x/term](https://github.com/golang/term) and [golang.org/x/text](https://github.com/golang/text).


Updates `golang.org/x/sys` from 0.45.0 to 0.46.0
- [Commits](https://github.com/golang/sys/compare/v0.45.0...v0.46.0)

Updates `golang.org/x/term` from 0.43.0 to 0.44.0
- [Commits](https://github.com/golang/term/compare/v0.43.0...v0.44.0)

Updates `golang.org/x/text` from 0.37.0 to 0.38.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.37.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
- dependency-name: golang.org/x/term
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
- dependency-name: golang.org/x/text
  dependency-version: 0.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 04:51:15 -07:00
Xingbo Zhao b69284abc7 fix(desktop): handle malformed code blocks on single line
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.
2026-06-09 19:48:50 +08:00
dependabot[bot] b34e77c0f3 chore(deps): bump lucide-react in /desktop/frontend (#3686)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.460.0 to 1.17.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.17.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 04:34:01 -07:00
dependabot[bot] 0cd1ad6f9a chore(deps): bump react-markdown in /desktop/frontend (#3689)
Bumps [react-markdown](https://github.com/remarkjs/react-markdown) from 9.1.0 to 10.1.0.
- [Release notes](https://github.com/remarkjs/react-markdown/releases)
- [Changelog](https://github.com/remarkjs/react-markdown/blob/main/changelog.md)
- [Commits](https://github.com/remarkjs/react-markdown/compare/9.1.0...10.1.0)

---
updated-dependencies:
- dependency-name: react-markdown
  dependency-version: 10.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 04:33:55 -07:00
dependabot[bot] cdc2680298 chore(deps): bump the actions group across 1 directory with 13 updates (#3688)
Bumps the actions group with 13 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `6` |
| [actions/setup-go](https://github.com/actions/setup-go) | `5` | `6` |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `4` | `6` |
| [actions/setup-node](https://github.com/actions/setup-node) | `4` | `6` |
| [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) | `7` | `9` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` |
| [actions/github-script](https://github.com/actions/github-script) | `7` | `9` |
| [actions/setup-python](https://github.com/actions/setup-python) | `5` | `6` |
| [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) | `3` | `5` |
| [actions/deploy-pages](https://github.com/actions/deploy-pages) | `4` | `5` |
| [actions/labeler](https://github.com/actions/labeler) | `5` | `6` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `4` | `8` |
| [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) | `6` | `7` |



Updates `actions/checkout` from 4 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

Updates `actions/setup-go` from 5 to 6
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

Updates `pnpm/action-setup` from 4 to 6
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v4...v6)

Updates `actions/setup-node` from 4 to 6
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

Updates `golangci/golangci-lint-action` from 7 to 9
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v7...v9)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

Updates `actions/setup-python` from 5 to 6
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

Updates `actions/upload-pages-artifact` from 3 to 5
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5)

Updates `actions/deploy-pages` from 4 to 5
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

Updates `actions/labeler` from 5 to 6
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/v5...v6)

Updates `actions/download-artifact` from 4 to 8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

Updates `goreleaser/goreleaser-action` from 6 to 7
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](https://github.com/goreleaser/goreleaser-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/labeler
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-go
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: golangci/golangci-lint-action
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: goreleaser/goreleaser-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: pnpm/action-setup
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 04:33:49 -07:00
Xingbo Zhao 1b8303d24a fix(desktop): escape $ in code blocks to prevent KaTeX errors
Add defensive escaping for code blocks containing $ characters.
When protecting code (inline `...` or fenced ```...```), replace
$ with &#36; (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
2026-06-09 19:33:29 +08:00
YHHandreasonix be4ba0172d chore(ci): drop /npm/reasonix from Dependabot (#3692)
/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]>
2026-06-09 04:25:41 -07:00
dependabot[bot] 6c0f2235c9 chore(deps): bump astro from 5.18.2 to 6.4.4 in /site (#3684)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 5.18.2 to 6.4.4.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/[email protected]/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 6.4.4
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 04:24:25 -07:00
dependabot[bot] aa1fadac7a chore(deps-dev): bump @vitejs/plugin-react in /desktop/frontend (#3687)
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 4.7.0 to 5.2.0.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/[email protected]/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/[email protected]/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 04:24:20 -07:00
dependabot[bot] f1d420c957 chore(deps): bump the go group in /desktop with 5 updates (#3680)
Bumps the go group in /desktop with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [fyne.io/systray](https://github.com/fyne-io/systray) | `1.12.1` | `1.12.2` |
| [github.com/wailsapp/wails/v2](https://github.com/wailsapp/wails) | `2.11.0` | `2.12.0` |
| [golang.org/x/mod](https://github.com/golang/mod) | `0.36.0` | `0.37.0` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.45.0` | `0.46.0` |
| [golang.org/x/text](https://github.com/golang/text) | `0.37.0` | `0.38.0` |


Updates `fyne.io/systray` from 1.12.1 to 1.12.2
- [Changelog](https://github.com/fyne-io/systray/blob/master/CHANGELOG.md)
- [Commits](https://github.com/fyne-io/systray/compare/v1.12.1...v1.12.2)

Updates `github.com/wailsapp/wails/v2` from 2.11.0 to 2.12.0
- [Release notes](https://github.com/wailsapp/wails/releases)
- [Commits](https://github.com/wailsapp/wails/compare/v2.11.0...v2.12.0)

Updates `golang.org/x/mod` from 0.36.0 to 0.37.0
- [Commits](https://github.com/golang/mod/compare/v0.36.0...v0.37.0)

Updates `golang.org/x/sys` from 0.45.0 to 0.46.0
- [Commits](https://github.com/golang/sys/compare/v0.45.0...v0.46.0)

Updates `golang.org/x/text` from 0.37.0 to 0.38.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.37.0...v0.38.0)

---
updated-dependencies:
- dependency-name: fyne.io/systray
  dependency-version: 1.12.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go
- dependency-name: github.com/wailsapp/wails/v2
  dependency-version: 2.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
- dependency-name: golang.org/x/mod
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
- dependency-name: golang.org/x/sys
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
- dependency-name: golang.org/x/text
  dependency-version: 0.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 04:24:14 -07:00
Xingbo Zhao 820db90f78 test(desktop): rename non-English math prose to minimal LaTeX patterns
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.
2026-06-09 19:12:10 +08:00
YHHandreasonix 135470c5a4 chore(ci): add Dependabot config and CodeQL code scanning (#3676)
* 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]>
2026-06-09 04:09:28 -07:00
Reasonix 5532392ce9 fix(desktop): repair inline math rendering for LLM output
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).
2026-06-09 19:07:20 +08:00
YHHandreasonix 0f6492f6e7 fix(rewind): fail loudly past a compacted boundary (#3598) + suffix-scan CanCode (#3438) (#3672)
* 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]>
2026-06-09 03:58:26 -07:00
paradoxSCH 680945f0b6 fix(memory): handle forget failures in desktop (#3662) 2026-06-09 03:51:08 -07:00
YHHandreasonix 90c24655b0 feat(desktop): build a Linux .deb package on release (#3634)
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]>
2026-06-09 03:28:23 -07:00
YHHandreasonix 61c99ad30d fix(cli): disable codegraph in ACP session config test (#3663)
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]>
2026-06-09 03:13:13 -07:00
SivanCola ece9b77191 Merge pull request #3553 from taibai233/feature/empty-enter-scroll-bottom
feat(tui): empty enter scrolls viewport to bottom
2026-06-09 14:46:02 +08:00
YHH ccbdc79998 feat(desktop): Developer ID signing + notarization for macOS release (#3619)
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.
2026-06-08 23:10:56 -07:00
athamas huang b49b2d121e fix(desktop): preserve curated provider model lists (#3613) 2026-06-08 23:09:32 -07:00
taibai 82dc750d28 test(tui): add regression test for empty-enter scroll-to-bottom in both idle and running state 2026-06-09 13:43:43 +08:00
taibai d5dc8df1a5 fix(tui): also scroll to bottom on empty enter during running state 2026-06-09 13:43:43 +08:00
taibai bd2b29bdd5 feat(tui): empty enter scrolls viewport to bottom 2026-06-09 13:43:43 +08:00
Li-Charles-OneandLi-Charles-One 272bc9d116 fix(desktop): prevent step limit segmented buttons from wrapping on narrow screens (#3596)
Co-authored-by: Li-Charles-One <[email protected]>
2026-06-08 22:35:15 -07:00
jiaxindeyangandCodex 368dad0e47 fix(desktop): honor user font in UI chrome (#3605)
Co-authored-by: Codex <[email protected]>
2026-06-08 22:34:59 -07:00
CVEngineerandwufengfan c8007a6b6c fix(desktop): restore project to sidebar when resuming session from history, add toast notification system (#3603)
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]>
2026-06-08 22:25:06 -07:00
SivanCola 823fdce746 feat(codegraph): harden runtime bootstrap (#3623) 2026-06-08 22:24:56 -07:00
SivanCola f311a475d4 fix(permission): remember approval scopes by rule (#3549) 2026-06-08 22:24:46 -07:00
SuMuxi66 3141dddae5 fix(composer): refresh @ mention picker on workspace/project switch (#3601) (#3606)
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
2026-06-08 22:24:36 -07:00
ttmouse b30215a0d1 fix(desktop): prevent context menu and popover flash-close during streaming generation (#3608)
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.
2026-06-08 22:24:26 -07:00
Li-Charles-OneandLi-Charles-One 7c7285d4f1 fix(desktop): 拖动窗口图标时不再崩溃 (#3573) (#3616)
Wails 的 EnableFileDrop 全局开启后,拖动窗口左上角图标
(非磁盘文件)会触发 ResolveFilePaths 抛出未捕获错误,
导致整个应用崩溃。

在 onFilesDropped 中注册全局 error/unhandledrejection 拦截器,
吞掉 'additional File object is not a file on the disk' 错误,
仅允许真实的文件拖拽到达回调。

Co-authored-by: Li-Charles-One <[email protected]>
2026-06-08 22:24:15 -07:00
Laik b73ce718a8 feat(CLI):优化Termux体验,解决在termux上无法移动聊天上下文等问题 (#3611) 2026-06-08 18:56:57 -07:00
OoooorangeCloverandERroooooR 58f444a692 feat(run): add --resume flag for precise session file targeting (#3597)
Co-authored-by: ERroooooR <[email protected]>
2026-06-08 18:56:16 -07:00
ashish acc0aebda3 feat(cli): add /sandbox status command (#3316) (#3585) 2026-06-08 18:55:32 -07:00
CVEngineerandwufengfan 4441e6b5fb fix(agent): reduce evidence verification false signals — remove duplicate finalReadinessCheck, add session fallback for complete_step command matching, and drop stale todo indicator (#3587)
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]>
2026-06-08 18:53:49 -07:00
athamas huang bbf76100e6 fix(web_fetch): reuse network proxy resolution (#3614) 2026-06-08 18:53:43 -07:00
Li-Charles-OneandLi-Charles-One e2568bd07c fix(desktop): unify font family for model names and UI labels (#3468) (#3595)
Co-authored-by: Li-Charles-One <[email protected]>
2026-06-08 08:55:09 -07:00
Li-Charles-OneandLi-Charles-One d4422b651d fix(desktop): 修复粘贴附件保存到错误的工作区目录 (#3600)
Co-authored-by: Li-Charles-One <[email protected]>
2026-06-08 08:54:50 -07:00
55andlizhengwu 10fecbaa92 fix(tui): submit exact completion on enter (#3593)
Co-authored-by: lizhengwu <[email protected]>
2026-06-08 08:54:27 -07:00
YHHandxbzhao 8b2028e824 feat(openai): support adjusting thinking level for MiniMax-M3 (#3594)
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>
2026-06-08 06:07:54 -07:00
ba094b9f5e feat(web_fetch): proxy support with SSRF-safe HTTP CONNECT / SOCKS5 tunnelling (#3581)
* 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]>
2026-06-08 05:18:31 -07:00
SivanCola f5363dd741 feat(desktop): render chat attachments as cards (#3577) 2026-06-08 05:01:17 -07:00
LaikandYour Name 2a8b2446bb fix:解决Termux下无法呼出手机键盘问题 (#3563)
* fix:Fix the issue where the phone keyboard cannot be used in the Termux environment.

* fix:测试环境加上termux环境判断

---------

Co-authored-by: Your Name <[email protected]>
2026-06-08 11:54:13 +00:00
jiaxindeyangandCodex f6e6673d25 feat(desktop): add text size shortcuts (#3576)
Co-authored-by: Codex <[email protected]>
2026-06-08 04:52:33 -07:00
Li-Charles-OneandLi-Charles-One c5372f53db fix(desktop): 字体设置对加粗字符生效 (#3459) (#3569)
移除 .settings-model-picker__option strong 中强制的 font-family: var(--mono),
让模型名称等加粗文本跟随用户选择的字体设置。

Co-authored-by: Li-Charles-One <[email protected]>
2026-06-08 04:52:20 -07:00
Li-Charles-OneandLi-Charles-One a63fecaf91 fix(desktop): center workspace panel resizer line to eliminate gap (#3443) (#3566)
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]>
2026-06-08 04:52:07 -07:00
Li-Charles-OneandLi-Charles-One 91eac0a110 fix(desktop): prevent middle-click from navigating webview (#3505) (#3564)
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]>
2026-06-08 04:51:56 -07:00
wade1999andwade19990814-hue 55e683dabe fix(desktop): restore maximized window from tray (#3568)
* fix(desktop): restore maximized window from tray

* fix(desktop): avoid tray restore window flash

---------

Co-authored-by: wade19990814-hue <[email protected]>
2026-06-08 04:51:45 -07:00
SivanCola cf7e483201 Add clear alias for fresh sessions (#3575) 2026-06-08 04:51:23 -07:00
SivanCola 9328c48fb0 fix(desktop): classify proxy providers separately from official access (#3571) 2026-06-08 04:51:12 -07:00
YHHandreasonix 98280862c6 fix(desktop): virtualize composer / and @ menus, drop item caps (#3579)
* 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]>
2026-06-08 04:41:47 -07:00
YHHandreasonix 8feb6b3f17 fix(openai): harden request building for strict OpenAI-compatible backends (#3574)
* 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]>
2026-06-08 03:37:10 -07:00
binwei 0d015fcbf7 fix: modify workspace git display (#3507)
Optimize the display of the Git area in the workspace.
2026-06-08 03:10:08 -07:00
SivanColaandreasonix 5cf486f985 fix(agent): prevent executor handoff role confusion / 防止执行器交接角色混淆 (#3541)
* 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]>
2026-06-08 03:04:32 -07:00
Antoine Reichelandreasonix f9a7c08cc0 feat(cli): add reasonix review subcommand for code review (#3518)
* 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]>
2026-06-08 09:25:12 +00:00
Greg Lyu 9373980a80 fix(chooser): separate Enter (confirm) from Space (toggle) in multi-select (#3530)
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.
2026-06-08 09:10:32 +00:00
Li-Charles-OneandLi-Charles-One 7f5fa3df77 fix(desktop): allow history panel to expand in narrow windows (#3544)
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]>
2026-06-08 09:09:49 +00:00
SivanCola 6969e96f23 fix(desktop): disable native drags for internal images (#3547) 2026-06-08 01:59:40 -07:00
YHHandreasonix d5c5d3bd54 docs: note Windows code signing via SignPath Foundation (#3551)
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]>
2026-06-08 01:33:43 -07:00
YHHandreasonix 7fffa2f94a fix(desktop): persist yolo composer mode across reload (#3546)
* 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]>
2026-06-08 01:13:45 -07:00
YHHandreasonix c0bb1796c5 fix(permission): make session/persist approval grants tool-wide (#3542)
* 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 #3498
Closes #3520

* test: gofmt the e2e approval test

---------

Co-authored-by: reasonix <[email protected]>
2026-06-08 00:43:21 -07:00
SivanCola 0d248f7d21 fix(agent): make planner step limit configurable (#3532) 2026-06-08 00:20:22 -07:00
YHHandreasonix d43d0e7d44 ci(release): keep canary off the GitHub releases page (R2-only) (#3531)
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]>
2026-06-07 23:58:02 -07:00
YHHandreasonix d0ce7651e4 ci(release): gate stable releases behind the release environment (#3528)
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]>
2026-06-07 23:24:51 -07:00
YHHandreasonix 78b19bf105 feat: add opt-in canary update channel (desktop + CLI) (#3514)
* 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]>
2026-06-07 21:17:46 -07:00
c816d2dc8f perf(desktop): coalesce streaming text/reasoning deltas per animation frame (#3114) (#3501)
* 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]>
2026-06-07 20:25:40 -07:00
HUQIANTAOandHUQIANTAO f50604412a fix(desktop): anchor send button to bottom-right of composer (#3448)
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]>
2026-06-07 20:16:08 -07:00
HUQIANTAOandHUQIANTAO afb1466640 fix(tool-card): collapse sub-agent tree with parent chevron (#3140)
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]>
2026-06-07 20:11:15 -07:00
6cc7396122 feat(desktop): branch switcher dropdown in changes panel (#3482) (#3493)
* 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]>
2026-06-07 19:59:11 -07:00
ttmouse fbc80b8352 feat(desktop): wire up ⌘K command palette (#3480)
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)
2026-06-07 19:44:01 -07:00
7cef20389a fix(desktop): classify attachments by real file type (#3474)
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]>
2026-06-07 19:40:44 -07:00
Jim K. Ding f8bd98c462 fix(desktop): keep desktop-workspace pointer in sync across all navigation paths (#3436)
* 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.
2026-06-07 19:40:38 -07:00
44486ff91c fix(desktop): only highlight composer resize grip on direct hover (#3461) (#3472)
Co-authored-by: Eason Sun <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-06-07 19:40:31 -07:00
SivanCola cd313db1ff fix desktop skill detail expand control (#3462) 2026-06-07 19:13:56 -07:00
TaoMu 2532584975 fix(config): preserve LSP config when rendering TOML (#3471) 2026-06-07 19:13:49 -07:00
SuMuxi66 60f98f698e fix(config): filter non-chat models from chat candidates (#3487) 2026-06-07 19:13:33 -07:00
SivanCola 3f75e4e22c Merge pull request #3336 from SuMuxi66/feature/past-chats-reference
feat(composer): add past chats reference flow
2026-06-08 00:54:07 +08:00
reasonix-local 82a820baea fix(composer): preserve file default in at menu 2026-06-08 00:23:03 +08:00
YHH 5b336c894b ci(release): split npm onto its own tag line, decoupled from brew (#3455)
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.
2026-06-07 06:33:25 -07:00
SivanCola bacbaca9d4 fix: refresh installed skills during active sessions (#3454) 2026-06-07 06:30:45 -07:00
SivanCola 556bf77ffb feat(installsource): add the install_source meta tool for MCP/skill installs (#3292) 2026-06-07 05:44:42 -07:00
SivanCola a7877c7cf5 fix(desktop): infer legacy provider access in settings (#3451) 2026-06-07 05:17:03 -07:00
HUQIANTAOandHUQIANTAO fc7a17994a fix(desktop): remove gray bar on top of composer input when focused (#3446)
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]>
2026-06-07 04:37:52 -07:00
HUQIANTAOandHUQIANTAO da4e64bdfa fix(desktop): suppress hover highlight on the active tab (#3445)
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]>
2026-06-07 04:37:31 -07:00
HUQIANTAOandHUQIANTAO 0dd9e8a417 fix(desktop): render markdown in real time during streaming (#3444)
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]>
2026-06-07 04:37:08 -07:00
YHH 4bedc51f5f fix(config): skip keyless providers in ResolveModelWithFallback (#3449)
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.
2026-06-07 04:29:05 -07:00
CVEngineer c71650d72c fix(desktop): allow messages starting with // to reach the model (#3433) 2026-06-07 04:25:22 -07:00
CVEngineer 541de75c85 fix(desktop): eliminate send-to-screen delay (#3430) 2026-06-07 04:25:06 -07:00
CVEngineer 5a03426f1a fix(desktop): transcript hot-zone windowing with layering (#3427) 2026-06-07 04:24:51 -07:00
HUQIANTAO 04412a779e fix(desktop): eliminate perceived send delay (#3441) 2026-06-07 04:24:35 -07:00
SivanCola dd0291ec2c feat(desktop): fix provider access and complete the settings center (#3437) 2026-06-07 04:22:12 -07:00
YHH 7fba4ab9a9 fix(jobs): record completion before publishing terminal status (#3429)
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.
2026-06-07 03:30:55 -07:00
SivanCola 304e1152cd feat(desktop): restore and consolidate the settings center (#3421) 2026-06-07 03:24:25 -07:00
SuMuxi66 19f9ccfd52 fix(composer): return focus after picking past chat + fix CSS brace 2026-06-07 17:22:23 +08:00
SuMuxi66 9331bc054e fix(composer): make past chats a first-class at-menu item 2026-06-07 17:20:14 +08:00
SuMuxi66 7ca6f1b835 fix(composer): submit guard ref+state and docs trailing whitespace 2026-06-07 17:20:14 +08:00
SuMuxi66 bc0885de9d fix(composer): keyboard navigation for past:chats and submit guard 2026-06-07 17:20:14 +08:00
SuMuxi66 46af735c89 docs(composer): document past chats reference architecture 2026-06-07 17:20:14 +08:00
SuMuxi66 7efa8ad160 feat(composer): add past chats reference flow
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
2026-06-07 17:20:14 +08:00
TaoMu bf0c67db5a feat(tools): make bash foreground timeout configurable (#3018) 2026-06-07 02:10:12 -07:00
SivanCola aaacb30818 feat: import cc-switch MCP servers selectively (#2499) 2026-06-07 02:09:56 -07:00
SivanCola faed6fc7d0 feat(provider): recover interrupted model streams (#3377) 2026-06-07 02:09:40 -07:00
YHH 387ee7d237 fix(desktop): close unterminated .onboarding__skip:disabled CSS rule (#3434)
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).
2026-06-07 02:09:10 -07:00
SivanCola 7bd90becf2 fix(desktop): improve composer sizing and status text (#3400) 2026-06-07 00:54:02 -07:00
HUQIANTAO 1c96c6c0c8 feat(desktop): substring filter for MemoryPanel docs and facts (#3128) 2026-06-07 00:53:52 -07:00
SivanCola 647fb096ce fix(desktop): recover missing topic indexes (#3228) 2026-06-07 00:53:42 -07:00
SivanCola 54da657f7f fix(desktop): align Windows sidebar toggle (#3393) 2026-06-07 00:53:27 -07:00
SivanCola 38e4c5a66d feat(skills): allow excluding skill convention sources (#3425) 2026-06-06 23:46:57 -07:00
SivanCola bdfef2fe86 ci: build and check the desktop frontend (css + tsc + vite) (#3419) 2026-06-06 23:37:15 -07:00
SivanCola bfafc1c9f5 perf(desktop): reduce stream render churn (#3423) 2026-06-06 23:37:06 -07:00
SivanCola 0b7becc0b8 fix(desktop): hide generated workspace noise from file refs (#3409) 2026-06-06 23:36:56 -07:00
SivanCola e4a758b811 fix(desktop): recover running tab state across switches (#3413) 2026-06-06 23:36:41 -07:00
SivanCola 105ec7e972 fix(desktop): normalise KaTeX math in a classifier-gated pre-pass (#3376) 2026-06-06 23:03:01 -07:00
YHH e4c762c91a ci: typecheck the desktop frontend in the desktop job (#3420)
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.
2026-06-06 21:36:47 -07:00
SivanCola 761ce16ec5 Merge pull request #3418 from CVEngineer66/fix/css-missing-brace
fix(css): add missing closing brace that broke esbuild CSS compilation
2026-06-07 12:07:21 +08:00
wufengfan cffa3697f9 fix(css): add missing closing brace that broke esbuild CSS compilation
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).
2026-06-07 12:02:56 +08:00
YHH bc3fb65814 fix(desktop): drop duplicate history.filter* keys breaking wails build (#3415)
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
2026-06-06 20:19:05 -07:00
SivanCola e32d167842 feat(acp): complete v1 session lifecycle (#3383) 2026-06-06 19:21:08 -07:00
SivanCola 4b81565ccc fix(desktop): add history filter locale keys (#3408) 2026-06-06 19:09:00 -07:00
SivanCola a4710eaad2 fix(desktop): repair history management modal shell (#3402) 2026-06-06 19:06:47 -07:00
SivanCola d6dfa01dbe fix(desktop): correct display replay for composed prompts (#3398) 2026-06-06 19:06:31 -07:00
SivanCola 84e3d1d850 fix(desktop): hide CodeGraph cache from file tree (#3405) 2026-06-06 19:06:15 -07:00
SivanCola 41b7fd868e fix(desktop): prevent workspace title clipping (#3399) 2026-06-06 19:06:00 -07:00
SivanCola 31dce5f492 fix(desktop): correct workspace panel resizer hover (#3397) 2026-06-06 19:05:44 -07:00
SivanCola cfb54edcef feat(desktop): restore startup splash (#3388) 2026-06-06 19:05:14 -07:00
SivanCola b59ae21c7e feat(desktop): persist tab session paths (#3387) 2026-06-06 19:04:58 -07:00
SivanCola 9f02ff1608 fix(desktop): repair add-project flow on macOS (#3382) 2026-06-06 19:04:42 -07:00
SivanCola 7a9fe15e15 fix(desktop): handle in-memory file drops (#3381) 2026-06-06 19:04:26 -07:00
SivanCola 40be5dc1a6 feat(desktop): add font-family picker with CJK-optimized presets (#3378)
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
2026-06-06 19:04:10 -07:00
CVEngineerandwufengfan fd8e1c4bc9 fix(styles): improve light theme contrast for semantic colors, diffs, and syntax highlighting (#3372)
* 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]>
2026-06-06 19:02:57 -07:00
wade1999andwade19990814-hue c63941da22 fix(cli): prompt before reusing setup API keys (#3345)
Co-authored-by: wade19990814-hue <[email protected]>
2026-06-06 19:02:42 -07:00
HUQIANTAOandHUQIANTAO 6e7518f08e feat: improve codegraph tool visibility for AI agents (#3133)
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]>
2026-06-06 19:02:27 -07:00
HUQIANTAOandHUQIANTAO c368229b0a feat(desktop): InlineDiff component for compact before/after tool results (#3129)
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]>
2026-06-06 19:01:57 -07:00
HUQIANTAOandHUQIANTAO 80165b10fb feat(desktop): in-app notification center (OS side is a follow-up) (#3122)
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]>
2026-06-06 19:01:42 -07:00
HUQIANTAOandHUQIANTAO 0f638394f7 feat(desktop): streaming indicator with preparing/streaming/stalled states (#3118)
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]>
2026-06-06 19:01:26 -07:00
HUQIANTAOandHUQIANTAO cfba3acc19 feat(desktop): Alt+Up / Alt+Down prompt history in the composer (#3121)
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]>
2026-06-06 19:01:10 -07:00
HUQIANTAOandHUQIANTAO 63dc1499d1 feat(desktop): command palette (Cmd/Ctrl+K) for fast navigation (#3124)
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]>
2026-06-06 19:00:53 -07:00
HUQIANTAOandHUQIANTAO 6e86e57731 feat(desktop): SHA-256 dedup of image attachments + inline dup notice (#3123)
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]>
2026-06-06 19:00:37 -07:00
SivanCola 815337f98f feat(desktop): replay process events in history previews (#3370) 2026-06-06 18:51:45 -07:00
SivanCola 197477d38a feat(desktop): consolidate chat and topic actions (#3367) 2026-06-06 18:51:23 -07:00
YHH 55e131b2f8 fix(test): root-cause the recurring TempDir teardown CI flake (#3403)
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.
2026-06-06 18:44:22 -07:00
SivanCola 9beee7e85a feat(desktop): restore v0.53-style process cards (#3368) 2026-06-06 18:03:42 -07:00
SivanCola b6289ba82b feat(desktop): redesign history and trash management (#3366) 2026-06-06 17:59:25 -07:00
SivanCola 28b7060852 feat(agent): record per-tool-call execution durations (#3369) 2026-06-06 17:32:53 -07:00
SivanCola fbca49ac3b feat(desktop): refine settings center navigation (#3365) 2026-06-06 17:32:25 -07:00
SivanCola 6a0ef49b01 Merge pull request #3380 from Thatgfsj/main-v2
webweb中文web,只是简单翻译一下,老版本的web其实还蛮不错的,新版本有点难崩feat(serve): add i18n, stats dashboard, and session management
2026-06-07 03:48:03 +08:00
Sivan 1bde97c19e Fix serve i18n and session deletion 2026-06-07 03:38:00 +08:00
Thatgfsj 149f43195e fix(serve): call MigrateLegacyIfNeeded before reading language in index()
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.
2026-06-07 03:20:50 +08:00
Thatgfsj cc4e0afb3e fix(serve): prevent server-side i18n from corrupting script content
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
2026-06-07 02:57:14 +08:00
Thatgfsj 4664152693 fix(serve): fully localize remaining UI text for Chinese i18n
- Convert Stats, Auto, Plan, YOLO, Ready button labels to i18n
- Convert thinking toggle, approval title, delete confirm to i18n
- Add stats and delete_confirm translation keys to en/zh
- Fix compacted en translation (was self-referencing __('compacted'))
- Sync new keys in serve.go server-side i18n map
2026-06-07 02:46:40 +08:00
Thatgfsj c252669750 feat(serve): add i18n, stats dashboard, session delete, go toolchain setup
- Chinese i18n: server-side + JS __() for 80+ UI strings
- Stats modal: token usage, cache rate, cost, balance
- Session delete: hover to reveal X button + backend API
- Cumulative usage tracking across SSE events
- Go 1.25.0 installed at O:\go, GOROOT & PATH set
2026-06-07 02:31:01 +08:00
Thatgfsj c33d946eb0 feat(serve): add i18n, stats dashboard, and session management
- Add Chinese (zh) i18n: server-side static HTML translation + JS __() for dynamic content
- Add stats modal with token usage, cache rate, cost, balance, context usage
- Add session delete (hover to show X button + POST /delete-session API)
- Add cumulative stats tracking across SSE usage events
- Add cumulative stats tracking across SSE usage events
- Respect config desktop.language setting; fallback to browser locale
2026-06-07 02:00:12 +08:00
Thatgfsj 70d6a7eded feat(serve): add Chinese (zh) i18n support for web UI
- 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
2026-06-07 01:54:57 +08:00
YHH 6c33e52b04 fix(tools): preserve file encoding in write_file and delete_range (#3363)
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.
2026-06-06 08:12:44 -07:00
YHH 2384eb4adc test(boot): fix Windows TempDir cleanup in legacy-session migration test (#3371)
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.
2026-06-06 08:05:35 -07:00
YHH 783efe5ea5 feat(ci): AI-classify issues into area/platform/severity labels (#3358)
* 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.
2026-06-06 06:58:21 -07:00
wade1999andwade19990814-hue 9b603cf3a8 fix(cli): clarify setup overwrite prompt (#3342)
Co-authored-by: wade19990814-hue <[email protected]>
2026-06-06 05:43:02 -07:00
TaoMuandesengine c30cc39e84 feat(agent): add host readiness audit receipts (#2836)
Co-authored-by: esengine <[email protected]>
2026-06-06 05:04:48 -07:00
TaoMuandesengine fbb2101a8a fix: give two-model planner read-only context (#3294)
Co-authored-by: esengine <[email protected]>
2026-06-06 05:02:33 -07:00
SivanCola defd64ec04 feat(agent): configure subagent model and effort / 支持配置 Subagent 模型和推理强度 (#3330)
* feat: configure subagent model effort

* fix(cli): honor subagent profiles in ACP task

* fix(agent): satisfy subagent profile CI checks
2026-06-06 04:49:37 -07:00
SivanCola 121f7085e4 Simplify MCP startup behavior / 简化 MCP 启动行为 (#3333)
* Simplify MCP startup behavior

* Fix Windows MCP startup migration test

* Remove redundant MCP startup migration test
2026-06-06 04:48:24 -07:00
SivanCola 322ff581c7 Support nested skill discovery (#3331) 2026-06-06 04:45:44 -07:00
paradoxSCH 54f1293e8c Fix CLI bang shell TTY hangs (#3259) 2026-06-06 04:37:15 -07:00
SivanCola 5022a5ef8b Add desktop dev port wrapper (#3339) 2026-06-06 03:58:14 -07:00
binwei 1fa17bb022 fix(desktop): workspace panel responsive layout and floating mode (#3241) 2026-06-06 03:56:31 -07:00
CVEngineerandwufengfan 4c5a217e50 fix(transcript): scroll to bottom on new user message and highlight latest in question jump bar (#3324)
- 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]>
2026-06-06 03:55:34 -07:00
HUQIANTAOandHUQIANTAO f021303289 fix(desktop): make workspace deletion discoverable and safe (#3213)
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]>
2026-06-06 03:53:57 -07:00
SivanCola c20e64c824 fix(desktop): simplify empty project state (#3230) 2026-06-06 03:53:03 -07:00
SivanCola 971291290f fix(desktop): refresh checkpoints after resume (#3235)
* fix(desktop): refresh checkpoints after resume

* fix(desktop): invalidate checkpoint refresh on new session
2026-06-06 03:52:14 -07:00
SivanCola 8f4d64299b fix(desktop): improve Windows update install options (#3280) 2026-06-06 03:50:56 -07:00
SivanCola 4bfd7d5083 Stream workspace media previews (#3250) 2026-06-06 03:49:40 -07:00
SivanCola 78b01f04cf fix(desktop): keep Windows tray interactive after close (#3244) 2026-06-06 03:48:19 -07:00
SivanCola 3c761984ae fix(desktop): preserve tool details in history (#3286) 2026-06-06 03:46:48 -07:00
SivanCola ad3fae6db2 fix: import legacy sessions from config session dir (#3247) 2026-06-06 03:35:53 -07:00
TaoMu 42eebaf773 fix: retry empty final answers (#3293) 2026-06-06 03:33:53 -07:00
SivanCola 00916bcdd2 Merge pull request #3248 from lizhengwu/feat/config-notifications
为 CLI `reasonix chat` / `reasonix run` 增加可选系统通知。默认关闭,用户在配置中开启后,可在任务完成、等待工具批准、等待用户回答时收到系统通知。feat(cli): add desktop notifications
2026-06-06 16:25:22 +08:00
SivanCola 5b3280aa63 Merge pull request #3278 from lizhengwu/fix/tui-input-growth
fix: grow TUI composer for wrapped input
2026-06-05 23:26:05 +08:00
lizhengwuandClaude Opus 4.7 c7dc007053 fix: grow TUI composer for wrapped input
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]>
2026-06-05 21:33:18 +08:00
lizhengwu 3cec9768f4 feat(cli): add desktop notifications 2026-06-05 19:03:09 +08:00
SivanCola 02544998ef Merge pull request #3231 from paradoxSCH/fix-3218-workspace-builtins
Fix workspace builtin tool assembly
2026-06-05 18:19:29 +08:00
Sivan 590a92ec2c Add workspace schema stability regression 2026-06-05 18:01:33 +08:00
paradoxSCH d5c5d3508f Fix workspace builtin tool assembly 2026-06-05 17:21:35 +08:00
1110 changed files with 287477 additions and 23692 deletions

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
go:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "gomod"
directory: "/desktop"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
go:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "npm"
directory: "/desktop/frontend"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
npm:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "npm"
directory: "/site"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
npm:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns: ["*"]
+51
View File
@@ -0,0 +1,51 @@
# Area labels auto-applied to PRs by .github/workflows/pr-auto-label.yml
# (actions/labeler), based on the changed file paths. Keep the area set in sync
# with issue-auto-label.yml. severity/platform are issue-only — a PR is a change,
# not a bug report — and v1/v2 are handled by pr-version-label.yml.
agent:
- changed-files:
- any-glob-to-any-file:
- 'internal/agent/**'
- 'internal/control/**'
mcp:
- changed-files:
- any-glob-to-any-file:
- 'internal/plugin/**'
- 'internal/codegraph/**'
config:
- changed-files:
- any-glob-to-any-file:
- 'internal/config/**'
- 'internal/boot/**'
provider:
- changed-files:
- any-glob-to-any-file:
- 'internal/provider/**'
skills:
- changed-files:
- any-glob-to-any-file:
- 'internal/skill/**'
- 'internal/tool/**'
tui:
- changed-files:
- any-glob-to-any-file:
- 'internal/cli/**'
desktop:
- changed-files:
- any-glob-to-any-file:
- 'desktop/**'
updater:
- changed-files:
- any-glob-to-any-file:
- 'desktop/cmd/sign/**'
- 'desktop/updater*.go'
- 'scripts/desktop-build.sh'
- '.github/workflows/release*.yml'
+19
View File
@@ -0,0 +1,19 @@
## Summary
-
## Verification
-
## Cache impact
Cache-impact: TODO
Cache-guard: TODO
System-prompt-review: N/A
For cache-sensitive changes, fill these lines before requesting review:
- `Cache-impact`: `none`, `low`, `medium`, or `high`, plus the reason.
- `Cache-guard`: the focused guard test/command added or run, or why an existing guard covers the change.
- `System-prompt-review`: required reviewer/approval note when provider-visible system prompt, memory prefix, output style, or skill index behavior changes.
+37
View File
@@ -0,0 +1,37 @@
name: Cache impact guard
on:
pull_request_target:
branches: [main-v2]
types: [opened, synchronize, reopened, edited, ready_for_review]
permissions:
contents: read
pull-requests: read
concurrency:
group: cache-impact-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
cache-impact:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Collect PR changed files
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" \
--jq '.[].filename' > "$RUNNER_TEMP/cache-impact-files.txt"
- name: Check cache-sensitive PR metadata
env:
PR_BODY: ${{ github.event.pull_request.body }}
CACHE_IMPACT_CHANGED_FILES_FILE: ${{ runner.temp }}/cache-impact-files.txt
run: bash scripts/check-cache-impact.sh
+55 -19
View File
@@ -21,9 +21,9 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
@@ -48,6 +48,7 @@ jobs:
run: go build ./...
- name: test
if: runner.os != 'Windows'
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
@@ -55,12 +56,25 @@ jobs:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test ./...
- name: test
if: runner.os == 'Windows'
timeout-minutes: 10
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
# built around.
REASONIX_RELEASE_CACHE_GUARD: "1"
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=3m ./...
race:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
@@ -79,20 +93,29 @@ jobs:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
# The Go main package embeds frontend/dist; a placeholder lets the Go
# build/test resolve the embed without a full frontend build.
- name: Stub embedded frontend
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/frontend/pnpm-lock.yaml
- name: Install Wails CLI
run: |
mkdir -p frontend/dist
echo '<!doctype html><title>ci</title>' > frontend/dist/index.html
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
go install github.com/wailsapp/wails/v2/cmd/[email protected]
- name: gofmt
run: |
@@ -118,9 +141,22 @@ jobs:
sudo apt-get update
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.0-dev
- name: Build frontend
run: |
wails generate module
pnpm --dir frontend install --frozen-lockfile
pnpm --dir frontend build
- name: vet
run: go vet ./...
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
working-directory: desktop
args: --timeout=5m
- name: build
run: go build ./...
@@ -130,15 +166,15 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: golangci-lint
uses: golangci/golangci-lint-action@v7
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
args: --timeout=5m
@@ -147,9 +183,9 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true # informational — stdlib vulns need a Go patch release
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
@@ -163,9 +199,9 @@ jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
@@ -174,7 +210,7 @@ jobs:
run: go test -coverprofile=coverage.out -covermode=atomic ./...
- name: upload coverage
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: coverage.out
+40
View File
@@ -0,0 +1,40 @@
name: CodeQL
on:
push:
branches: ["main-v2"]
pull_request:
branches: ["main-v2"]
schedule:
- cron: "27 3 * * 1"
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
- language: actions
build-mode: none
steps:
- uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
@@ -0,0 +1,49 @@
name: Deploy accounts worker
on:
push:
branches: [main-v2]
paths:
- 'workers/accounts/**'
- '.github/workflows/deploy-accounts-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-accounts-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/accounts/pnpm-lock.yaml
- name: Deploy
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
npx wrangler deploy
- name: Sync RESEND_API_KEY to the worker
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
run: |
if [ -n "$RESEND_API_KEY" ]; then
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
else
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
fi
+34
View File
@@ -0,0 +1,34 @@
name: Deploy crash worker
on:
push:
branches: [main-v2]
paths:
- 'workers/crash-report/**'
- '.github/workflows/deploy-crash-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-crash-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- name: Deploy
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npm ci
npx wrangler deploy
+38
View File
@@ -0,0 +1,38 @@
name: Deploy forum worker
on:
push:
branches: [main-v2]
paths:
- 'workers/forum/**'
- '.github/workflows/deploy-forum-worker.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-forum-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 10
run_install: false
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/forum/pnpm-lock.yaml
- name: Deploy
working-directory: workers/forum
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
npx wrangler deploy
+17 -6
View File
@@ -25,9 +25,14 @@ jobs:
contains(github.event.comment.body, '/e2e') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
runs-on: ubuntu-latest
# Running PR-head code with the provider secret is gated twice: the author_
# association check above, and this deployment environment. Configure the
# `e2e-bot` environment with required reviewers in repo settings to force a
# human approval per run (actions/untrusted-checkout-toctou).
environment: e2e-bot
steps:
- name: Acknowledge
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
await github.rest.reactions.createForIssueComment({
@@ -37,16 +42,16 @@ jobs:
# Default-branch checkout: this is where the harness (cmd/e2ebench), the
# suite, and a run --metrics-capable agent live.
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version: '1.22'
go-version-file: go.mod
cache: true
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
@@ -62,7 +67,13 @@ jobs:
- name: Check out the PR head
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh pr checkout ${{ github.event.issue.number }}
# Pin to the head commit resolved now and check it out detached, not the
# mutable PR ref: a force-push mid-run can't swap in different code after
# the trusted-author gate passed.
run: |
SHA=$(gh pr view ${{ github.event.issue.number }} --json headRefOid -q .headRefOid)
git fetch -q origin "$SHA"
git checkout -q --detach "$SHA"
- name: Build the agent from the PR head
# The whole point of the bot is to drive the PR's code, not main-v2's. Fall
+99
View File
@@ -0,0 +1,99 @@
name: Auto-label issues
# Classify new issues into area / platform / severity labels using the DeepSeek
# API (the project's own model). The model is constrained to a fixed label set —
# anything it returns outside the set is dropped — so it can't invent labels.
# Issues it can't place into any area get `needs-triage` for a human to look at.
# Version (v1/v2) labels are handled separately by issue-version-label.yml.
on:
issues:
types: [opened, reopened]
permissions:
issues: write
concurrency:
group: issue-autolabel-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
classify:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
with:
script: |
const AREA = ['agent','mcp','config','updater','provider','desktop','tui','skills','rendering'];
const PLATFORM = ['windows','macos','linux'];
const SEVERITY = ['crash','data-loss','security'];
const ALLOWED = new Set([...AREA, ...PLATFORM, ...SEVERITY]);
const issue = context.payload.issue;
const title = issue.title || '';
const body = (issue.body || '').slice(0, 4000);
const system = [
'You categorize GitHub issues for Reasonix, a Go-based AI coding agent with a Wails desktop app and a terminal UI.',
'Pick labels ONLY from these fixed sets. Never invent labels.',
'area (0-2, the affected subsystem):',
' agent: core agent loop / tool-calling / reasoning',
' mcp: MCP servers, plugins, codegraph',
' config: configuration, setup wizard, .toml/.env',
' updater: auto-update, installer, release packaging',
' provider: model providers, model selection/switching',
' desktop: Wails desktop GUI',
' tui: terminal UI / CLI',
' skills: skills system',
' rendering: terminal rendering / flicker / repaint',
'platform (only if clearly specific to one OS): windows, macos, linux',
'severity (only if clearly applicable):',
' crash: app crashes, hangs, or freezes',
' data-loss: loss of sessions, config, or history',
' security: credential/secret exposure or a security flaw',
'Be conservative: omit a label when unsure. The issue may be in Chinese.',
'Reply with JSON only: {"area":[],"platform":[],"severity":[]}',
].join('\n');
let labels = [];
try {
const res = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'deepseek-chat',
temperature: 0,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: system },
{ role: 'user', content: `Title: ${title}\n\nBody:\n${body}` },
],
}),
});
if (!res.ok) {
core.warning(`DeepSeek API ${res.status}: ${await res.text()}`);
return;
}
const data = await res.json();
const parsed = JSON.parse(data.choices[0].message.content);
labels = [...(parsed.area || []), ...(parsed.platform || []), ...(parsed.severity || [])]
.filter(l => ALLOWED.has(l));
} catch (e) {
core.warning(`Classification failed: ${e.message}`);
return;
}
if (!labels.some(l => AREA.includes(l))) labels.push('needs-triage');
if (labels.length) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: issue.number,
labels,
});
core.info(`Applied: ${labels.join(', ')}`);
}
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
- uses: actions/github-script@v9
with:
script: |
const body = context.payload.issue.body || '';
+6 -4
View File
@@ -19,18 +19,20 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: site/package-lock.json
- name: Build
working-directory: site
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm ci
npm run build
- uses: actions/upload-pages-artifact@v3
- uses: actions/upload-pages-artifact@v5
with:
path: site/dist
@@ -42,4 +44,4 @@ jobs:
url: ${{ steps.deploy.outputs.page_url }}
steps:
- id: deploy
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
+28
View File
@@ -0,0 +1,28 @@
name: Auto-label PRs
# Apply area labels to PRs based on changed paths (see .github/labeler.yml).
# Complements the AI-based issue labeler (issue-auto-label.yml). Version (v1/v2)
# labels are handled separately by pr-version-label.yml.
#
# pull_request_target runs in the base repo context so it has the write token
# needed to label PRs from forks; actions/labeler only reads the diff file list
# (it never checks out or runs PR code), so this is safe.
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
concurrency:
group: pr-autolabel-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
with:
sync-labels: false
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
- uses: actions/github-script@v9
with:
script: |
const base = context.payload.pull_request.base.ref;
+292 -36
View File
@@ -8,15 +8,31 @@ name: Release desktop
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
# latest.json manifest is generated, and everything is published to a GitHub
# release and mirrored to R2 (the updater reads R2 first, GitHub as fallback).
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
# release gateway; GitHub's repository-wide "latest" is reserved for the CLI line).
#
# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag
# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/
# pointer. A manual `channel: canary` dispatch builds with -X main.channel=canary
# and uploads only to R2's canary/ pointer — no GitHub release, so canary never
# shows on the releases page and never touches latest/.
on:
push:
tags: ["desktop-v*"]
workflow_dispatch:
inputs:
channel:
description: "Release channel"
type: choice
options: [stable, canary]
default: stable
tag:
description: "Tag to publish (e.g. desktop-v1.1.0). Required when run manually."
required: true
description: "stable: tag to publish (e.g. desktop-v1.1.0)"
required: false
type: string
base_version:
description: "canary: intended next stable version, e.g. 1.5.0"
required: false
type: string
permissions:
@@ -27,9 +43,9 @@ jobs:
name: cache hit guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
@@ -40,6 +56,9 @@ jobs:
build:
name: build (${{ matrix.name }})
needs: cache-guard
permissions:
contents: read # checkout only; the publish job holds contents: write
actions: read # SignPath reads run details + downloads the unsigned artifact
strategy:
fail-fast: false
matrix:
@@ -48,24 +67,29 @@ jobs:
# runner — avoids the scarce/slow macos-13 Intel runner.
- { runner: macos-14, platform: darwin/universal, name: darwin-universal }
- { runner: windows-latest, platform: windows/amd64, name: windows-amd64 }
- { runner: windows-11-arm, platform: windows/arm64, name: windows-arm64 }
- { runner: ubuntu-22.04, platform: linux/amd64, name: linux-amd64 }
runs-on: ${{ matrix.runner }}
env:
# Windows Authenticode signing engages only when the SignPath token is set, so
# forks / token-less runs still build (unsigned), mirroring the APPLE_* gate.
HAS_SIGNPATH: ${{ secrets.SIGNPATH_API_TOKEN != '' }}
defaults:
run:
shell: bash # desktop-build.sh is bash; windows runners default to pwsh otherwise
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: "22"
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
with:
version: 10
@@ -77,6 +101,12 @@ jobs:
sudo apt-get update
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
# Linux: nfpm builds the .deb in desktop-build.sh's linux branch. go install
# drops it in ~/go/bin, already on PATH (same place the wails CLI lands below).
- name: Install nfpm
if: runner.os == 'Linux'
run: go install github.com/goreleaser/nfpm/v2/cmd/[email protected]
# Windows: NSIS provides makensis for `wails build -nsis`.
- name: Install NSIS
if: runner.os == 'Windows'
@@ -89,18 +119,95 @@ jobs:
if: runner.os == 'macOS'
run: brew install create-dmg
# macOS signing: import the Developer ID cert into a throwaway keychain and
# stage the notarization key. No-ops (and the build ad-hoc signs) when the
# APPLE_* secrets aren't set, so forks still build.
- name: Import Apple signing certificate
if: runner.os == 'macOS'
env:
APPLE_CERT_P12: ${{ secrets.APPLE_CERT_P12 }}
APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
run: |
if [ -z "$APPLE_CERT_P12" ]; then
echo "APPLE_CERT_P12 unset — desktop build will ad-hoc sign (un-notarized)"
exit 0
fi
KEYCHAIN="$RUNNER_TEMP/signing.keychain-db"
KEYCHAIN_PASS="$(uuidgen)"
security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN"
echo "$APPLE_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASS" "$KEYCHAIN" >/dev/null
# Prepend the signing keychain to the search list so codesign / find-identity see it.
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
echo "$APPLE_API_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
rm -f "$RUNNER_TEMP/cert.p12"
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/[email protected]
- name: Resolve version
id: ver
run: |
TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#desktop-}" >> "$GITHUB_OUTPUT"
run: bash scripts/resolve-desktop-release.sh
env:
EVENT_NAME: ${{ github.event_name }}
IN_CHANNEL: ${{ inputs.channel }}
IN_TAG: ${{ inputs.tag }}
IN_BASE_VERSION: ${{ inputs.base_version }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
- name: Build and package
run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ steps.ver.outputs.version }}"
env:
# macOS Developer ID + notarization path turns on only when all five
# APPLE_* secrets are present; otherwise desktop-build.sh ad-hoc signs.
# Harmless on Windows/Linux runners (only the darwin branch reads these).
HAS_APPLE_CERT: ${{ secrets.APPLE_CERT_P12 != '' && secrets.APPLE_CERT_PASSWORD != '' && secrets.APPLE_API_KEY_P8 != '' && secrets.APPLE_API_KEY_ID != '' && secrets.APPLE_API_ISSUER_ID != '' }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/notary.p8
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }}
run: scripts/desktop-build.sh "${{ matrix.platform }}" "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.channel }}"
# Authenticode-sign the Windows installer through SignPath. Runs BEFORE minisign:
# the signature rewrites the installer's bytes, so the updater's minisign sig and
# the manifest SHA must be computed over the signed build. SignPath pulls the file
# from the Actions artifact API, so it must be uploaded first. Canary signs with the
# test certificate; stable/rc sign with the Foundation release certificate, but only
# once SIGNPATH_RELEASE_SIGNING_READY is set — until that cert clears CSR-pending,
# stable ships unsigned instead of failing the whole release on an invalid policy.
- name: Upload unsigned installer for SignPath
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true')
id: unsigned-installer
uses: actions/upload-artifact@v7
with:
name: unsigned-${{ matrix.name }}
path: dist/*installer*.exe
if-no-files-found: error
retention-days: 1
- name: Submit installer for Authenticode signing
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true')
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: DeepSeek-Reasonix
signing-policy-slug: ${{ steps.ver.outputs.channel == 'canary' && 'test-signing' || 'release-signing' }}
artifact-configuration-slug: windows-installer
github-artifact-id: ${{ steps.unsigned-installer.outputs.artifact-id }}
github-token: ${{ github.token }}
wait-for-completion: true
# The policy uses an approval process — leave headroom for a human to approve
# the request (the approver gets an email on submit) before the job gives up.
wait-for-completion-timeout-in-seconds: 1800
output-artifact-directory: signed
- name: Replace installer with signed build
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true' && (steps.ver.outputs.channel == 'canary' || vars.SIGNPATH_RELEASE_SIGNING_READY == 'true')
run: cp signed/*installer*.exe dist/
- name: Sign artifacts (minisign)
working-directory: desktop
@@ -109,7 +216,7 @@ jobs:
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
run: go run ./cmd/sign sign ../dist/*
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: dist-${{ matrix.name }}
path: dist/*
@@ -119,16 +226,19 @@ jobs:
name: publish release
needs: build
runs-on: ubuntu-latest
# Stable publishes go through the `release` environment (esengine must
# approve); canary uses the open `canary` environment so maintainers self-serve.
environment: ${{ (github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') && 'canary' || 'release' }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version-file: desktop/go.mod
cache: true
cache-dependency-path: desktop/go.sum
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
path: dist
pattern: dist-*
@@ -136,13 +246,14 @@ jobs:
- name: Resolve version
id: ver
run: |
TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
VERSION="${TAG#desktop-}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# A version with a suffix (v1.0.0-rc1) is a prerelease.
case "$VERSION" in *-*) echo "prerelease=true" >> "$GITHUB_OUTPUT";; *) echo "prerelease=false" >> "$GITHUB_OUTPUT";; esac
run: bash scripts/resolve-desktop-release.sh
env:
EVENT_NAME: ${{ github.event_name }}
IN_CHANNEL: ${{ inputs.channel }}
IN_TAG: ${{ inputs.tag }}
IN_BASE_VERSION: ${{ inputs.base_version }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
# Generate latest.json with GitHub release download URLs; the mirror step
# rewrites them to R2 afterwards. GITHUB_REPOSITORY is provided by the runner.
@@ -150,32 +261,64 @@ jobs:
working-directory: desktop
run: go run ./cmd/sign manifest ../dist "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.tag }}"
# Canary never appears on the GitHub releases page; the mirror job picks up
# the signed dist via the canary-dist artifact below. Stable publishes a
# GitHub release as usual.
- name: Publish GitHub release
if: steps.ver.outputs.channel != 'canary'
env:
GH_TOKEN: ${{ github.token }}
run: |
args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --generate-notes)
# Repository-wide "latest" is reserved for the CLI line (see header);
# without this a desktop release published after the CLI one steals it.
args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --generate-notes --latest=false)
[ "${{ steps.ver.outputs.prerelease }}" = "true" ] && args+=(--prerelease)
gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}"
- name: Upload canary dist for mirror
if: steps.ver.outputs.channel == 'canary'
uses: actions/upload-artifact@v7
with:
name: canary-dist
path: dist/*
if-no-files-found: error
mirror:
name: mirror to R2
needs: publish
runs-on: ubuntu-latest
permissions:
contents: write # gh release download + compatibility manifest upload
actions: write # dispatch pages.yml to re-bake the site version
# Skip cleanly when R2 isn't configured; GitHub release still works as fallback.
if: ${{ github.repository_owner == 'esengine' }}
env:
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
steps:
- name: Resolve tag
- uses: actions/checkout@v7
- name: Resolve version
id: ver
run: |
TAG="${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
case "${TAG#desktop-}" in *-*) echo "prerelease=true" >> "$GITHUB_OUTPUT";; *) echo "prerelease=false" >> "$GITHUB_OUTPUT";; esac
run: bash scripts/resolve-desktop-release.sh
env:
EVENT_NAME: ${{ github.event_name }}
IN_CHANNEL: ${{ inputs.channel }}
IN_TAG: ${{ inputs.tag }}
IN_BASE_VERSION: ${{ inputs.base_version }}
REF_NAME: ${{ github.ref_name }}
RUN_NUMBER: ${{ github.run_number }}
# Canary has no GitHub release — pull the signed dist from the workflow
# artifact. Stable pulls from the published release.
- name: Download canary dist
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel == 'canary'
uses: actions/download-artifact@v8
with:
name: canary-dist
path: assets
- name: Download release assets
if: env.HAS_R2 == 'true'
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary'
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -187,7 +330,7 @@ jobs:
- name: Rewrite latest.json URLs to R2
if: env.HAS_R2 == 'true'
env:
R2_PUBLIC_BASE: https://pub-147fb53b9c1e4bbf891a257968619ea7.r2.dev
R2_PUBLIC_BASE: https://dl.reasonix.io
TAG: ${{ steps.ver.outputs.tag }}
run: |
f=assets/latest.json
@@ -216,10 +359,123 @@ jobs:
run: |
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
aws s3 cp assets/ "s3://${R2_BUCKET}/${TAG}/" --recursive --endpoint-url "$ENDPOINT"
# Prereleases publish to their own tag prefix but must not move the
# updater's latest/ pointer; stable releases do.
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
# The pointer a release moves is its channel's alone: canary writes
# canary/, stable writes latest/. A stable prerelease (rc) moves neither.
if [ "${{ steps.ver.outputs.channel }}" = "canary" ]; then
aws s3 cp assets/ "s3://${R2_BUCKET}/canary/" --recursive --endpoint-url "$ENDPOINT"
elif [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
echo "prerelease — leaving R2 latest/ untouched"
else
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
fi
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
# protection), so smoke the mirrored objects over the authenticated S3 API
# instead of the public edge. This verifies the mirror landed; the public
# edge itself is not reachable from CI and is covered by end users' traffic.
- name: Smoke desktop release pointers
if: env.HAS_R2 == 'true'
env:
TAG: ${{ steps.ver.outputs.tag }}
VERSION: ${{ steps.ver.outputs.version }}
CHANNEL: ${{ steps.ver.outputs.channel }}
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
f=assets/latest.json
jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" '
.version == $version and
.download_page == "https://reasonix.io/#start" and
([.platforms[] | (.url, .sig)] |
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
' "$f" >/dev/null
aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT"
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-tag-latest.json >/dev/null
if [ "$CHANNEL" = "canary" ]; then
pointer="canary"
elif [ "$PRERELEASE" = "true" ]; then
pointer=""
else
pointer="latest"
fi
if [ -n "$pointer" ]; then
aws s3 cp "s3://${R2_BUCKET}/${pointer}/latest.json" /tmp/reasonix-desktop-pointer-latest.json --endpoint-url "$ENDPOINT"
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null
fi
jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do
key="${asset#https://dl.reasonix.io/}"
aws s3api head-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$ENDPOINT" >/dev/null
done
# Best-effort probe of the release gateway — the updater's second
# manifest source — over the same public edge and Go client UA end users
# hit. A 403 here is the known Cloudflare bot-protection gap (#6005:
# datacenter/proxy egress gets blocked before the worker runs) and must
# not fail the release until a WAF skip rule for /v1/desktop/releases/*
# lands; it is surfaced as a warning so the run shows whether the edge
# is open. Anything else unexpected (404, 5xx, wrong version) means the
# gateway route or pointer regressed and fails hard.
- name: Probe public release gateway
if: env.HAS_R2 == 'true'
env:
VERSION: ${{ steps.ver.outputs.version }}
CHANNEL: ${{ steps.ver.outputs.channel }}
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
run: |
set -euo pipefail
if [ "$PRERELEASE" = "true" ]; then
echo "prerelease — no pointer moved; skipping gateway probe"
exit 0
fi
chan="stable"
[ "$CHANNEL" = "canary" ] && chan="canary"
url="https://crash.reasonix.io/v1/desktop/releases/${chan}/latest.json"
# curl already prints 000 for a transport failure; || true keeps -e
# from killing the step so the case below can route it.
code="$(curl -sS -A "Go-http-client/2.0" -o /tmp/gateway-latest.json -w '%{http_code}' "$url" || true)"
case "$code" in
200)
if jq -e --arg version "$VERSION" '.version == $version' /tmp/gateway-latest.json >/dev/null; then
echo "gateway serves $VERSION on $chan"
else
echo "::error::gateway responded 200 but serves $(jq -r '.version // "<none>"' /tmp/gateway-latest.json), want $VERSION — stale or wrong pointer"
exit 1
fi
;;
403)
echo "::warning::gateway returned 403 to CI egress — known bot-protection gap (#6005), not failing the release"
;;
000|"")
echo "::warning::gateway unreachable from CI (transport error), not failing the release"
;;
*)
echo "::error::gateway returned $code for $url — route or pointer regression"
exit 1
;;
esac
- name: Attach desktop manifest to matching CLI release
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
if gh release view "$VERSION" >/dev/null 2>&1; then
gh release upload "$VERSION" assets/latest.json --clobber
else
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
fi
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
- name: Refresh site to the new version
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2
+114
View File
@@ -0,0 +1,114 @@
name: Release npm
# npm line. Tag namespace `npm-v<semver>` triggers this — plain `v<semver>` tags
# are the CLI binary + Homebrew release (release.yml) and intentionally do NOT
# trigger here (GitHub glob `npm-v*` does not match `v*`). Splitting npm onto its
# own tag lets a prerelease ship to npm's `next` channel without touching the
# stable Homebrew cask (and lets a stable cask ship without forcing npm to leave
# its rc). Bump with:
# git tag npm-vX.Y.Z # stable -> publishes under `latest`
# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next`
# git push origin <tag>
#
# A manual workflow_dispatch can publish either an opt-in canary from the current
# ref or an approved stable/next version when tag creation is restricted. Canary
# publishes to the `canary` dist-tag (npm i reasonix@canary) and never moves
# `next`/`latest`. build.mjs decides the dist-tag: a `-canary.` build is `canary`,
# any other prerelease is `next`, and a stable version is `latest` (#5822 — the
# old "promote latest by hand" rule left it pinned at 0.53.2 and downgraded
# `npm update -g` users). The verify step below asserts the tag actually landed.
on:
push:
tags: ['npm-v*']
workflow_dispatch:
inputs:
channel:
description: "Publish channel"
required: true
default: canary
type: choice
options:
- canary
- stable
base_version:
description: "Version to publish. Canary appends -canary.<run_number>; stable publishes exactly this version."
required: true
type: string
permissions:
contents: read
jobs:
cache-guard:
name: cache hit guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- run: ./scripts/cache-guard.sh
npm:
name: publish npm packages
needs: cache-guard
runs-on: ubuntu-latest
# A tag push (npm-v*) or manual stable dispatch publishes a stable/next
# release and goes through the `release` environment (esengine approves);
# canary dispatches run free.
environment: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'canary' && 'canary' || 'release' }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
# Tag push uses the npm-v* tag. A canary dispatch synthesizes a
# <base>-canary.<run_number> version; a manual stable dispatch publishes
# the requested semver exactly. build.mjs strips the leading `npm-`/`v`.
- name: Resolve version
id: ver
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
base="${{ inputs.base_version }}"; base="${base#v}"
if [ "${{ inputs.channel }}" = "canary" ]; then
echo "arg=v${base}-canary.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
else
echo "arg=v${base}" >> "$GITHUB_OUTPUT"
fi
else
echo "arg=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
fi
- run: node npm/build.mjs "${{ steps.ver.outputs.arg }}" --publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# Assert the dist-tag landed where build.mjs aimed it. This is the guard
# that #5822 lacked: `latest` sat on 0.53.2 for months while stables went
# to `next`, and nothing noticed until users got downgraded. The registry
# applies tags asynchronously after publish, hence the poll.
- name: Verify dist-tags
run: |
set -euo pipefail
version="${{ steps.ver.outputs.arg }}"
version="${version#npm-}"; version="${version#v}"
case "$version" in
*-canary.*) tag=canary ;;
*-*) tag=next ;;
*) tag=latest ;;
esac
for attempt in 1 2 3 4 5 6; do
got="$(npm view reasonix dist-tags."$tag" 2>/dev/null || true)"
if [ "$got" = "$version" ]; then
echo "dist-tag $tag -> $got"
exit 0
fi
echo "dist-tag $tag -> ${got:-<unset>}, want $version (attempt $attempt)"
sleep 10
done
echo "::error::npm dist-tag '$tag' never landed on $version"
exit 1
+84 -21
View File
@@ -1,5 +1,12 @@
name: Release
# CLI binary line. Tag namespace `v<semver>` triggers this — it builds the
# release archives + checksums, publishes the GitHub release, and updates the
# Homebrew cask. npm is a SEPARATE line (release-npm.yml, `npm-v*`) so an npm
# prerelease can ship without forcing the stable cask, and vice versa; desktop is
# `desktop-v*` (release-desktop.yml). A prerelease `v*-rc*` still builds archives
# and a prerelease GitHub release, but goreleaser auto-skips the cask upload
# (brew has no prerelease channel — see .goreleaser.yaml).
on:
push:
tags: ['v*']
@@ -12,8 +19,8 @@ jobs:
name: cache hit guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
@@ -23,15 +30,18 @@ jobs:
name: archives + checksums + homebrew tap
needs: cache-guard
runs-on: ubuntu-latest
# CLI stable release (archives + GitHub release + Homebrew cask). Gated on the
# `release` environment so only esengine can approve it going public.
environment: release
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: goreleaser/goreleaser-action@v6
- uses: goreleaser/goreleaser-action@v7
with:
version: '~> v2'
args: release --clean
@@ -39,20 +49,73 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
npm:
name: publish npm packages
needs: cache-guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- run: node npm/build.mjs "${GITHUB_REF_NAME}" --publish
- name: Attach desktop manifest compatibility asset
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — GitHub latest does not move here; skipping desktop manifest compatibility asset"
exit 0
;;
esac
if [ "$HAS_R2" != "true" ]; then
echo "R2 secrets not configured; skipping desktop manifest compatibility asset"
exit 0
fi
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
# protection), so read the manifest over the authenticated S3 API instead
# of the public edge.
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
aws configure set region auto
aws s3 cp "s3://${R2_BUCKET}/latest/latest.json" latest.raw.json \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
jq '.download_page = "https://reasonix.io/#start"' latest.raw.json > latest.json
jq -e '
([.platforms[] | (.url, .sig)] |
all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not)))
' latest.json >/dev/null
gh release upload "$TAG" latest.json --clobber
# The compatibility asset exists for pre-v1.16 desktop updaters that
# still poll GitHub's repository-wide latest URL — so verify it exactly
# the way those clients fetch it: anonymously, over the public edge, with
# a Go client UA. Unlike dl.reasonix.io (whose bot protection 403s
# Actions egress — see the R2 note above), GitHub serves its own runners,
# so this can hard-fail. #5826/#5858 shipped a broken update check for
# weeks precisely because nothing exercised the public path. Retries
# cover the release CDN propagating the freshly uploaded asset.
- name: Smoke public compatibility manifest
env:
TAG: ${{ github.ref_name }}
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
run: |
set -euo pipefail
case "$TAG" in
*-*)
echo "prerelease $TAG — no compatibility asset uploaded; skipping"
exit 0
;;
esac
if [ "$HAS_R2" != "true" ]; then
echo "R2 secrets not configured; no compatibility asset uploaded; skipping"
exit 0
fi
url="https://github.com/${{ github.repository }}/releases/latest/download/latest.json"
for attempt in 1 2 3 4 5 6; do
if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then
jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null
echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))"
exit 0
fi
echo "attempt $attempt failed; retrying in 10s"
sleep 10
done
echo "::error::public compatibility manifest unreachable at $url"
exit 1
+4 -3
View File
@@ -22,9 +22,11 @@ mem.prof
# Wails desktop artifacts
/desktop/build/*
!/desktop/build/appicon.png
!/desktop/build/appicon.svg
/desktop/desktop
/desktop/frontend/dist/*
!/desktop/frontend/dist/.gitkeep
/desktop/frontend/sourcemaps/
/desktop/frontend/wailsjs/
/desktop/frontend/package.json.md5
@@ -54,7 +56,6 @@ node_modules/
Thumbs.db
.idea/
.vscode/
/site/public/av/
# Scratch / agent working dirs (never committed)
/tmp/
/.codex/
benchmarks/context-maintenance-e2e/run/
+9
View File
@@ -33,6 +33,15 @@ linters:
- path: _test\.go
linters:
- errcheck
# The pinned golangci-lint binary's staticcheck reports SA5011 false
# positives on `if x == nil { t.Fatal(...) }`-guarded derefs in tests (it
# doesn't treat t.Fatal as terminating); the same code is clean under a
# locally-built golangci-lint. Scope the suppression to tests so SA5011
# still guards production code.
- path: _test\.go
linters:
- staticcheck
text: SA5011
issues:
max-issues-per-linter: 0
+2
View File
@@ -36,6 +36,8 @@ homebrew_casks:
ids: [reasonix]
# Prereleases (v*-rc*) must not update the stable tap; brew has no separate
# prerelease channel, so a pushed rc cask becomes the default `brew install`.
# npm is a separate line (release-npm.yml, `npm-v*`), so an npm prerelease no
# longer drags brew along — a stable `v*` cask can ship while npm stays on rc.
skip_upload: "auto"
repository:
owner: esengine
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- GitHub Actions wraps every uploaded artifact in a zip, so the installer arrives
zipped even though it is a single file — match and sign the .exe inside. -->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file-set>
<include path="*installer*.exe" min-matches="1" max-matches="1" />
<for-each>
<authenticode-sign />
</for-each>
</pe-file-set>
</zip-file>
</artifact-configuration>
+9
View File
@@ -4,6 +4,15 @@ All notable changes to the Go line (Reasonix 1.0+) are recorded here. The legacy
`0.x` TypeScript history lives on the [`v1`](https://github.com/esengine/DeepSeek-Reasonix/tree/v1)
branch.
## Unreleased
### Changed
- Agent runtime defaults now leave both executor and dedicated planner tool-call
rounds unlimited (`max_steps = 0`, `planner_max_steps = 0`). Step limits now
come from the user/global config only; project `reasonix.toml` does not
override them.
## [1.0.0] — 2026-06-03
First stable release — a **ground-up rewrite in Go**. Not an upgrade of the `0.x`
+53
View File
@@ -63,6 +63,59 @@ make hooks # install git hooks (pre-push: go vet)
make cross # cross-compile for all 6 targets
```
### Isolated development environment
A source-built binary shares no on-disk state with a stable release when launched
with `REASONIX_HOME` set. This gives each build its own self-contained directory
tree — config, credentials, sessions, cache, skills, commands, hooks, and
desktop tab state — so the two builds never interfere:
**CLI**
```bash
REASONIX_HOME=/tmp/reasonix-dev go run ./cmd/reasonix
# or after building:
# REASONIX_HOME=/tmp/reasonix-dev ./bin/reasonix
```
**Desktop**
```bash
cd desktop && wails build
REASONIX_HOME=/tmp/reasonix-dev-isolated build/bin/reasonix-desktop
```
On Windows, use `$env:REASONIX_HOME` in PowerShell or `set REASONIX_HOME=` in
Command Prompt; the binary extension is `.exe`.
The directory is empty on first launch; the app behaves exactly like a fresh
install. Every subsequent write — config saves, credential storage, session
logs — stays under `REASONIX_HOME`. Legacy migration, OS-home convention
directory scanning, and all other fallback paths are skipped so no production
data leaks in or out.
### Cache-first review gate
Reasonix treats high prompt-cache hit rate as product behavior. Changes that
touch provider-visible system prompt construction, memory prefix, output styles,
skill index behavior, default tool surfaces, tool schemas, provider request
serialization, compaction, or MCP/tool registration need explicit cache review.
For these changes:
- Keep system prompt changes low-frequency and require explicit review.
- Fill the PR body `Cache-impact:` line with `none`, `low`, `medium`, or `high`
plus the reason.
- Fill the PR body `Cache-guard:` line with the focused guard test/command added
or run, or explain why an existing guard covers the change.
- Fill `System-prompt-review:` when system prompt, memory prefix, output style,
or skill index behavior changes.
- Prefer focused guard tests near the changed surface; `scripts/cache-guard.sh`
remains the broader release-level cache-hit check.
CI enforces this metadata for cache-sensitive paths so prompt/tool prefix churn
is called out before review.
### Running tests
```bash
+10 -20
View File
@@ -2,11 +2,7 @@ VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
GOEXE := $(shell go env GOEXE)
# CodeGraph release pinned for the bundled MCP server / e2e test. Bump together
# with any change to the integration in internal/codegraph.
CODEGRAPH_VERSION := v0.9.7
.PHONY: build vet fmt test hooks cross clean e2e-codegraph
.PHONY: build vet fmt test desktop-test desktop-test-short desktop-test-times hooks cross clean
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
@@ -21,6 +17,15 @@ fmt:
test:
go test ./...
desktop-test:
cd desktop && go test .
desktop-test-short:
cd desktop && go test -short .
desktop-test-times:
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
hooks:
@git config core.hooksPath .githooks
@echo "installed: core.hooksPath -> .githooks (pre-push runs go vet)"
@@ -35,18 +40,3 @@ cross:
clean:
rm -rf bin dist
# Fetch the matching CodeGraph bundle into bin/codegraph/ (the distribution
# layout: launcher at bin/codegraph/bin/codegraph beside bin/reasonix) and run the
# gated MCP end-to-end test against it. Requires `gh`. Windows: install via the
# upstream install.ps1 and run the test with REASONIX_CODEGRAPH_BIN set.
e2e-codegraph:
@os=$$(uname -s | tr 'A-Z' 'a-z'); arch=$$(uname -m); \
case $$arch in arm64|aarch64) arch=arm64;; x86_64|amd64) arch=x64;; *) echo "unsupported arch $$arch"; exit 1;; esac; \
asset=codegraph-$$os-$$arch.tar.gz; dest=bin/codegraph; \
echo "fetching $$asset ($(CODEGRAPH_VERSION)) -> $$dest"; \
rm -rf $$dest && mkdir -p $$dest; \
gh release download $(CODEGRAPH_VERSION) -R colbymchenry/codegraph -p $$asset -O /tmp/$$asset; \
tar -xzf /tmp/$$asset -C $$dest --strip-components=1; \
REASONIX_CODEGRAPH_E2E=1 REASONIX_CODEGRAPH_BIN=$$PWD/$$dest/bin/codegraph \
go test ./internal/codegraph/ -run E2E -v -count=1
+50 -202
View File
@@ -7,6 +7,8 @@
&nbsp;·&nbsp;
<a href="./README.zh-CN.md">简体中文</a>
&nbsp;·&nbsp;
<a href="./docs/GUIDE.md">Guide</a>
&nbsp;·&nbsp;
<a href="./docs/SPEC.md">Spec</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
@@ -21,22 +23,16 @@
<p align="center">
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
<a href="https://github.com/esengine/reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/DeepSeek-Reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=8b949e&labelColor=161b22" alt="license"/></a>
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=3fb950&labelColor=161b22&label=downloads" alt="downloads"/></a>
<a href="https://github.com/esengine/reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
<a href="https://github.com/esengine/reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<p align="center">
<a href="https://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.
@@ -71,6 +70,12 @@ brew install esengine/reasonix/reasonix # macOS
Prebuilt archives (`darwin|linux|windows × amd64|arm64`) and `SHA256SUMS` are on
every [GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases).
### Code signing
Windows builds are code-signed with a free certificate provided by the
[SignPath Foundation](https://signpath.org/), with signing through
[SignPath.io](https://signpath.io/).
### Build from source
```sh
@@ -82,29 +87,19 @@ make cross # -> dist/ (darwin|linux|windows × amd64|arm64)
```sh
reasonix setup # config wizard → ./reasonix.toml
export DEEPSEEK_API_KEY=sk-... # or put it in .env (see .env.example)
reasonix chat # then run /init to generate AGENTS.md (project memory)
export DEEPSEEK_API_KEY=sk-... # or let setup save it to Reasonix home .env
reasonix # then run /init to generate AGENTS.md (project memory)
reasonix run "implement the TODOs in main.go"
reasonix run --model mimo-pro "add unit tests for this function"
reasonix run --model deepseek-pro "add unit tests for this function"
echo "explain this code" | reasonix run
```
## Configuration
Resolution order: **flag > `./reasonix.toml` > `~/.config/reasonix/config.toml` >
built-in defaults**. Secrets come from the environment via `api_key_env` and are
never stored in config files.
A minimal `reasonix.toml` — one provider and a default model — is enough to start:
```toml
default_model = "deepseek-flash" # executor; set [agent].planner_model to add a planner
# language = "zh" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG
[agent]
# planner_model = "mimo-pro" # optional low-frequency planner
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
auto_plan = "off" # off|on; off keeps plan mode manual
# auto_plan_classifier = "deepseek-flash" # optional; only borderline tasks call it
default_model = "deepseek-flash"
[[providers]]
name = "deepseek-flash"
@@ -112,185 +107,38 @@ kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
# also preset: deepseek-pro, mimo-pro (mimo-v2.5-pro), mimo-flash (mimo-v2-flash) @ api.xiaomimimo.com/v1
[tools]
enabled = [] # omit/empty = all built-ins
[skills]
# paths = ["~/my-skills", "../shared/skills"] # extra custom skill roots
# disabled_skills = ["review"] # hide skills until /skill enable <name>
[permissions]
mode = "ask" # writer fallback when no rule matches: ask|allow|deny
deny = ["bash(rm -rf*)", "bash(git push*)"] # hard-blocked in every mode
allow = ["bash(go test*)"] # never prompted
[sandbox]
# workspace_root = "" # file-writers confined here; empty = current dir
# allow_write = ["/tmp"] # extra dirs write_file/edit_file/multi_edit may touch
[[plugins]]
name = "example"
command = "reasonix-plugin-example"
```
Permissions gate each tool call: `deny` > `ask` > `allow` > fallback (readers
always allow; writers fall back to `mode`). `reasonix chat` prompts before writers
(`y` once · `a` this session · `n` no); `reasonix run` stays autonomous but still
honours `deny`. See [`docs/SPEC.md`](docs/SPEC.md) for the full schema and contract.
Resolution order is **flag > `./reasonix.toml` > the user config file >
built-in defaults**; starting with **Reasonix v1.8.1**, the user file lives at
`~/.reasonix/config.toml` on macOS/Linux and
`%AppData%\reasonix\config.toml` on Windows. See
**[Configuration paths](./docs/CONFIG_PATHS.md)** for migration details and the
full `config.toml` / `.env` structure. Provider entries name secrets with
`api_key_env`; the secret values themselves live in Reasonix's global
`<Reasonix home>/.env`, shared by CLI and desktop. Project `.env` files are not
provider-key runtime fallbacks, but still feed workspace-scoped, non-provider
`${VAR}` expansion for MCP/plugin settings without importing Reasonix control
variables. Permissions, the sandbox, plugins (MCP), slash
commands, `@` references, and two-model setup are all in the
**[Guide](./docs/GUIDE.md)**.
Permissions are *policy* (which calls to allow / prompt). The **sandbox** is
*enforcement*: the file-writers (`write_file` / `edit_file` / `multi_edit`)
refuse any path outside `[sandbox] workspace_root` (default: the current dir, so
edits stay in the project), resolving symlinks and `..` so a link can't tunnel
out. Reads are unrestricted. `bash` is itself jailed on macOS by default
(`[sandbox] bash`, Seatbelt): commands may write only those same roots (plus
temp and toolchain caches) and reach the network only when `[sandbox] network`
is set. Other platforms fall back to running unconfined for now (see
`docs/SPEC.md` §9 for the escape-prompt and Linux support still to come).
## Documentation
### Plugins (MCP)
Reasonix is an MCP client. A `[[plugins]]` entry's `type` selects the transport:
`stdio` (default) launches a local subprocess (`command`/`args`/`env`); `http`
(Streamable HTTP) connects to a remote `url` with optional static `headers`
(`${VAR}` / `${VAR:-default}` expanded from the environment, so tokens stay out
of the file). Tools surface to the model as `mcp__<server>__<tool>`; a tool
declaring MCP's `readOnlyHint: true` joins parallel dispatch and the permission
reader-default.
A server's **prompts** surface as `/mcp__<server>__<prompt>` slash commands
(positional args after the command); its **resources** are pulled in by writing
`@<server>:<uri>` in a message; `/mcp` lists connected servers and what each
exposes. `make build` also produces `bin/reasonix-plugin-example` — a runnable
reference stdio server (`echo`, `wordcount`, a `review` prompt, a style-guide
resource) you can copy.
```toml
[[plugins]] # local stdio server
name = "example"
command = "reasonix-plugin-example"
[[plugins]] # remote server over Streamable HTTP
name = "stripe"
type = "http"
url = "https://mcp.stripe.com"
headers = { Authorization = "Bearer ${STRIPE_KEY}" }
```
**Already have an `.mcp.json`?** Drop it in the project root and Reasonix
reads it as-is — the `mcpServers` spec (`command`/`args`/`env`, `type`/`url`/
`headers`, `${VAR}` expansion) maps field-for-field onto `[[plugins]]`. Both
sources are merged; on a name collision `reasonix.toml` wins.
```json
{
"mcpServers": {
"filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"] },
"stripe": { "type": "http", "url": "https://mcp.stripe.com", "headers": { "Authorization": "Bearer ${STRIPE_KEY}" } }
}
}
```
**Upgrading from `0.x`?** Your old `~/.reasonix/config.json` is still read for its
`mcpServers` (honouring `mcpDisabled`) as a lowest-priority source, so MCP servers
keep working — move them into `reasonix.toml`'s `[[plugins]]` or a `.mcp.json` when
convenient.
### Slash commands
In `reasonix chat`, built-in commands (`/compact`, `/new`, `/rewind`, `/tree`,
`/branch`, `/switch`, `/todo`, `/model`, `/effort`, `/mcp`, `/memory`, `/help`) run locally.
`/tree` shows saved conversation branches, `/branch [name]` forks the current
conversation tip, `/branch <turn> [name]` forks from an earlier checkpointed turn,
and `/switch <id|name>` loads another branch. **Custom commands** are Markdown files under
`.reasonix/commands/` (project) or `~/.config/reasonix/commands/` (user) —
`review.md` becomes `/review`, a subdirectory namespaces it (`git/commit.md` →
`/git:commit`). The body is a prompt template; invoking the command sends it as a
turn.
```markdown
---
description: Review the staged diff
argument-hint: [focus-area]
---
Review the staged diff. Focus on $ARGUMENTS, list bugs with file:line.
```
`$ARGUMENTS` expands to all space-separated args, `$1`…`$N` to positional ones.
MCP prompts also appear here as `/mcp__<server>__<prompt>`.
### @ references
Embed `@` references in a message and Reasonix resolves them before sending, as
tagged context blocks: `@path/to/file` (or `@dir`) injects a local file's
contents (or a directory listing), and `@<server>:<uri>` injects an MCP
resource. A local path is only treated as a reference when it actually exists,
so ordinary `@mentions` stay literal. Typing `/` or `@` opens an autocomplete
menu — slash commands, or hierarchical file navigation (one directory level at a
time, descend into folders) plus MCP resources.
### Two-model collaboration (optional)
`reasonix setup` keeps first-run minimal: pick provider → keys (every SKU of a
chosen provider is enabled). Running two models together (executor + planner,
separate cache-stable sessions) is a one-line edit afterwards — set
`planner_model` to any other enabled provider:
```toml
[agent]
planner_model = "deepseek-pro" # used as the low-frequency planner
```
Subagent skills inherit the executor model by default. Set `subagent_model` to
run them on another configured model, or use `subagent_models` to override only
specific skills such as `review` or `security_review`.
For interactive frontends, plan mode is manual by default. Set
`agent.auto_plan = "on"` to make complex-looking tasks enter plan mode
automatically: Reasonix first drafts a read-only plan, then waits for approval
before editing or running side-effecting commands. `auto_plan_classifier` can
name a cheap provider such as `deepseek-flash`; it is only called for borderline
inputs and falls back to the heuristic if classification fails. Use
`/auto-plan off|on` in `reasonix chat` to change the user-level setting, or
`reasonix config auto-plan off|on` from a shell/script. Pass `--local` to the
shell command only when you intentionally want a project-local override.
## Architecture
Three tiers of extensibility, all behind registries the core resolves by name:
1. **Registry** — `Provider` and `Tool` are interfaces; the core has no
`switch model`.
2. **Compile-time built-ins** — providers (`provider/openai`) and tools
(`tool/builtin`) self-register via `init()`; `main` blank-imports them.
Adding a built-in is one file plus one import.
3. **Runtime plugins** — executables declared in config, spoken to over
newline-delimited JSON-RPC 2.0 on stdin/stdout (the MCP stdio convention).
Each remote tool is adapted to the `Tool` interface.
## Status
Done: registry-based providers/tools, OpenAI-compatible streaming with tool
calls (bounded retry on 429/5xx), built-in tools (read_file, write_file,
edit_file, multi_edit, bash, ls, glob, grep, web_fetch, task, todo_write, ask),
TOML config, an interactive `reasonix setup` wizard, two-model collaboration
(executor + planner in separate, cache-stable sessions), low-frequency context
compaction, sub-agents (`task`), a bubbletea chat TUI (markdown, plan mode with
controller-driven approval, live token/activity readout, pinned task list,
`ask` question chooser, `/compact` `/new` `/tree` `/branch` `/switch` `/todo`), session persistence + resume,
per-call **permissions** (allow/ask/deny rules; chat prompts before writers, deny
rules hard-block everywhere), a **workspace sandbox** confining file-writers to
the project (symlink/`..`-safe), an MCP client — **stdio + Streamable HTTP**
transports, tools (`mcp__server__tool`, `readOnlyHint`-aware), prompts (slash
commands), resources (`@`-references), and `/mcp`, configured via `[[plugins]]`
or a project `.mcp.json` — custom slash commands (`.reasonix/commands/*.md`),
`@file` / `@resource` references, plus a runnable reference plugin
(`cmd/reasonix-plugin-example`), the harness loop, and CLI. A Wails desktop
client (`desktop/`) drives the same kernel. Next: an OS-level sandbox for `bash`
(macOS Seatbelt / Linux bubblewrap), an Anthropic-native provider, MCP OAuth +
legacy SSE. See `docs/SPEC.md` §9.
- **[Guide](./docs/GUIDE.md)** — configuration, permissions & sandbox, plugins
(MCP), slash commands, `@` references, two-model collaboration.
- **[Bot guide](./docs/BOT_GUIDE.md)** — connect Feishu, Lark, and WeChat bots
from the desktop app, then use approvals, YOLO, and commands from IM.
- **[Spec](./docs/SPEC.md)** — engineering contract: architecture, registries,
data types, and roadmap.
- **[Task contracts & pause policy](./docs/TASK_CONTRACT.md)** — structure
complex requests with context, output boundaries, constraints, and when to ask.
- **[Tool contract](./docs/TOOL_CONTRACT.md)** — provider-visible built-in tool
names, read-only flags, and schema snapshot guard.
- **[Migrating from 0.x](./docs/MIGRATING.md)** — moving from the legacy
TypeScript releases to the 1.0 Go rewrite.
- **[Checkpoints & rewind](./docs/CHECKPOINTS.md)** — the snapshot-based edit
safety net (Esc-Esc / `/rewind`).
<br/>
+39 -176
View File
@@ -7,6 +7,8 @@
&nbsp;·&nbsp;
<strong>简体中文</strong>
&nbsp;·&nbsp;
<a href="./docs/GUIDE.zh-CN.md">指南</a>
&nbsp;·&nbsp;
<a href="./docs/SPEC.md">规格</a>
&nbsp;·&nbsp;
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
@@ -21,22 +23,16 @@
<p align="center">
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/v/reasonix.svg?style=flat-square&color=cb3837&labelColor=161b22&logo=npm&logoColor=white" alt="npm version"/></a>
<a href="https://github.com/esengine/reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/esengine/DeepSeek-Reasonix/ci.yml?style=flat-square&label=ci&labelColor=161b22&logo=githubactions&logoColor=white" alt="CI"/></a>
<a href="./LICENSE"><img src="https://img.shields.io/npm/l/reasonix.svg?style=flat-square&color=8b949e&labelColor=161b22" alt="license"/></a>
<a href="https://www.npmjs.com/package/reasonix"><img src="https://img.shields.io/npm/dm/reasonix.svg?style=flat-square&color=3fb950&labelColor=161b22&label=downloads" alt="downloads"/></a>
<a href="https://github.com/esengine/reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/stargazers"><img src="https://img.shields.io/github/stars/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=dbab09&labelColor=161b22&logo=github&logoColor=white" alt="GitHub stars"/></a>
<a href="https://atomgit.com/esengine/DeepSeek-Reasonix"><img src="https://atomgit.com/esengine/DeepSeek-Reasonix/star/badge.svg" alt="AtomGit stars"/></a>
<a href="https://github.com/esengine/reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<p align="center">
<a href="https://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>
@@ -51,10 +47,12 @@
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
内核无硬编码模型。
- **多模型 · 可组合**:DeepSeek(flash/pro)与 MiMo 作为预设内置;任何 OpenAI 兼容
- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容
端点都只是一条配置。可选让两个模型协同(执行器 + 规划器),各自独立、缓存稳定的 session。
- **插件驱动**:外部工具以子进程形式运行,通过 stdio JSON-RPC 通信(MCP 兼容);
内置工具在编译期自注册。
- **缓存友好的上下文维护**:启动时注入稳定的环境摘要;旧工具输出会先 snip/prune,
再进入摘要 compaction;内置工具 schema 合约有文档和回归测试保护。
- **零摩擦分发**:`CGO_ENABLED=0` 单二进制;一条命令交叉编译到六个目标平台。
唯一依赖是一个 TOML 解析库。
@@ -68,6 +66,11 @@ brew install esengine/reasonix/reasonix # macOS
预编译归档(`darwin|linux|windows × amd64|arm64`)和 `SHA256SUMS` 见每个
[GitHub release](https://github.com/esengine/DeepSeek-Reasonix/releases)。
### 代码签名
Windows 构建使用 [SignPath 基金会](https://signpath.org/) 提供的免费代码签名证书,
通过 [SignPath.io](https://signpath.io/) 完成签名。
### 从源码构建
```sh
@@ -79,28 +82,19 @@ make cross # -> dist/(darwin|linux|windows × amd64|arm64)
```sh
reasonix setup # 配置向导 → ./reasonix.toml
export DEEPSEEK_API_KEY=sk-... # 或写入 .env(见 .env.example)
reasonix chat # 然后在会话里运行 /init 生成 AGENTS.md(项目记忆)
export DEEPSEEK_API_KEY=sk-... # 也可以让 setup 保存到 Reasonix 全局 .env
reasonix # 然后在会话里运行 /init 生成 AGENTS.md(项目记忆)
reasonix run "把 main.go 里的 TODO 实现掉"
reasonix run --model mimo-pro "给这个函数补单元测试"
reasonix run --model deepseek-pro "给这个函数补单元测试"
echo "解释这段代码" | reasonix run
```
## 配置
优先级:**flag > `./reasonix.toml` > `~/.config/reasonix/config.toml` > 内置默认值**。
密钥经环境变量通过 `api_key_env` 注入,绝不写入配置文件。
一个最小的 `reasonix.toml`——一个 provider 加一个默认模型——就够跑起来:
```toml
default_model = "deepseek-flash" # 执行器;设 [agent].planner_model 可加规划器
# language = "zh" # 界面语言;为空则按 $LANG / $REASONIX_LANG 自动检测
[agent]
# planner_model = "mimo-pro" # 可选的低频规划器
# subagent_model = "deepseek-pro" # runAs=subagent skill 的默认模型
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
auto_plan = "off" # off|on;off 表示计划模式仅手动开启
# auto_plan_classifier = "deepseek-flash" # 可选;只在边界任务上调用
default_model = "deepseek-flash"
[[providers]]
name = "deepseek-flash"
@@ -108,161 +102,30 @@ kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
# 还有预设:deepseek-pro、mimo-pro(mimo-v2.5-pro)、mimo-flash(mimo-v2-flash) @ api.xiaomimimo.com/v1
[tools]
enabled = [] # 省略/为空 = 全部内置工具
[skills]
# paths = ["~/my-skills", "../shared/skills"] # 额外的自定义技能目录
# disabled_skills = ["review"] # 隐藏技能,直到 /skill enable <name>
[permissions]
mode = "ask" # 无规则命中时 writer 的兜底:ask|allow|deny
deny = ["bash(rm -rf*)", "bash(git push*)"] # 任何模式下都硬阻断
allow = ["bash(go test*)"] # 从不询问
[sandbox]
# workspace_root = "" # 文件写工具被限制在此目录;留空 = 当前目录
# allow_write = ["/tmp"] # write_file/edit_file/multi_edit 额外可写的目录
[[plugins]]
name = "example"
command = "reasonix-plugin-example"
```
权限逐次调用把关:`deny` > `ask` > `allow` > 兜底(只读工具永远 allow,writer 落到
`mode`)。`reasonix chat` 会在 writer 调用前征求同意(`1` 本次 · `2` 本会话 · `3` 拒绝,兼容 `y/a/n`);
`reasonix run` 保持自主运行但仍然遵守 `deny`。完整 schema 与契约见
[`docs/SPEC.md`](docs/SPEC.md)。
优先级为 **flag > `./reasonix.toml` > 用户配置文件 > 内置默认值**;从
**Reasonix v1.8.1** 开始,用户配置位于 macOS/Linux 的 `~/.reasonix/config.toml`,
Windows 为 `%AppData%\reasonix\config.toml`。迁移细节见
**[配置路径](./docs/CONFIG_PATHS.zh-CN.md)**,其中也说明了全局 `config.toml`
和 `.env` 的完整结构。Provider 通过 `api_key_env` 命名密钥,真实密钥值保存在
CLI 与桌面端共用的 Reasonix 全局 `<Reasonix home>/.env`;项目 `.env` 不再作为
provider key 的运行时 fallback,但仍会作为当前 workspace 范围内的 MCP/plugin 非 provider `${VAR}` 展开来源,不导入 Reasonix 控制变量。权限、沙盒、插件(MCP)、
斜杠命令、`@` 引用与双模型设置,全部在 **[指南](./docs/GUIDE.zh-CN.md)** 里。
权限是**策略**(哪些调用放行/询问),**沙盒**是**强制**:文件写工具
(`write_file` / `edit_file` / `multi_edit`)拒绝 `[sandbox] workspace_root`
之外的任何路径(默认当前目录,编辑不出项目),并解析符号链接与 `..`,使链接无法
打洞越界。读不受限。`bash` 本身在 macOS 默认进沙盒(`[sandbox] bash`,Seatbelt):
命令只能写这些 root(外加临时目录与工具链缓存),`[sandbox] network` 为真时才能联网;
其它平台暂回退为不沙盒运行(越界问一次与 Linux 支持见 `docs/SPEC.md` §9)。
## 文档
### 插件(MCP)
Reasonix 是一个 MCP 客户端。`[[plugins]]` 的 `type` 选择传输:`stdio`(默认)启动本地子进
程(`command`/`args`/`env`);`http`(Streamable HTTP)连接远程 `url`,可带静态
`headers`(`${VAR}` / `${VAR:-default}` 从环境展开,密钥不入文件)。工具以
`mcp__<server>__<tool>` 暴露给模型,与 Claude Code 一致;声明 MCP `readOnlyHint: true`
的工具会参与并行调度并命中权限层的只读默认放行。
服务器的 **prompts** 会暴露成 `/mcp__<server>__<prompt>` 斜杠命令(命令后空格分隔参
数);**resources** 通过在消息里写 `@<server>:<uri>` 拉入;`/mcp` 列出已连接服务器及
各自暴露的内容。`make build` 还会产出 `bin/reasonix-plugin-example`——一个可直接运行的
stdio 参考实现(`echo`、`wordcount`、一个 `review` prompt、一个 style-guide 资源),
可照抄。
```toml
[[plugins]] # 本地 stdio 服务器
name = "example"
command = "reasonix-plugin-example"
[[plugins]] # 远程 Streamable HTTP 服务器
name = "stripe"
type = "http"
url = "https://mcp.stripe.com"
headers = { Authorization = "Bearer ${STRIPE_KEY}" }
```
**已有 Claude Code 的 `.mcp.json`?** 直接放到项目根目录,Reasonix 会原样读取——其
`mcpServers` 规范(`command`/`args`/`env`、`type`/`url`/`headers`、`${VAR}` 展开)
与 `[[plugins]]` 字段一一对应。两处来源会合并加载;同名时以 `reasonix.toml` 为准。
```json
{
"mcpServers": {
"filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"] },
"stripe": { "type": "http", "url": "https://mcp.stripe.com", "headers": { "Authorization": "Bearer ${STRIPE_KEY}" } }
}
}
```
**从 `0.x` 升级?** 旧的 `~/.reasonix/config.json` 仍会被读取(读其 `mcpServers`、并遵从
`mcpDisabled`),作为最低优先级来源——所以 MCP 服务器照常可用;方便时再把它们挪进
`reasonix.toml` 的 `[[plugins]]` 或 `.mcp.json`。
### 斜杠命令
`reasonix chat` 里,内置命令(`/compact`、`/new`、`/rewind`、`/tree`、`/branch`、`/switch`、`/todo`、`/model`、`/effort`、`/mcp`、`/help`)在本地执行。
`/tree` 查看已保存的对话分支,`/branch [name]` 从当前对话末端分支,`/branch <turn> [name]`
从较早的 checkpoint 轮次分支,`/switch <id|name>` 切换到另一个分支。**自定义命令**
是放在 `.reasonix/commands/`(项目)或 `~/.config/reasonix/commands/`(用户)下的 Markdown 文件——
`review.md` 即 `/review`,子目录构成命名空间(`git/commit.md` → `/git:commit`)。文件正文
是 prompt 模板,调用即作为一轮对话发出。
```markdown
---
description: Review the staged diff
argument-hint: [focus-area]
---
Review the staged diff. Focus on $ARGUMENTS, list bugs with file:line.
```
`$ARGUMENTS` 展开为全部空格分隔参数,`$1`…`$N` 为位置参数。MCP prompts 也以
`/mcp__<server>__<prompt>` 形式出现在这里。
### @ 引用
在消息里写 `@` 引用,Reasonix 会在发送前解析成带标签的上下文块:`@path/to/file`(或
`@dir`)注入本地文件内容(或目录清单),`@<server>:<uri>` 注入 MCP 资源。本地路径**只有
真实存在**时才当作引用,普通 `@mention` 保持原文。敲 `/` 或 `@` 会弹出补全菜单——斜杠
命令,或**逐层**的文件导航(一次只列当前一层目录、可下钻进子目录)外加 MCP 资源。
### 双模型协同(可选)
`reasonix setup` 刻意保持首次体验极简:选 provider → 输入 key(所选 provider 的所有
SKU 都会启用)。若要让两个模型协同(执行器 + 规划器,各自独立、缓存稳定的
session),向导后手动在 `reasonix.toml` 加一行即可:
```toml
[agent]
planner_model = "deepseek-pro" # 作为低频规划器
```
Subagent skills 默认继承执行器模型。设置 `subagent_model` 可让它们统一走另一个已配置
模型;设置 `subagent_models` 则只覆盖 `review`、`security_review` 等指定 skill。
交互式前端中,计划模式默认手动开启。设置 `agent.auto_plan = "on"` 后,看起来复杂
的任务会自动进入 plan mode:Reasonix 先只读生成计划,待用户批准后才
编辑文件或执行有副作用的命令。`auto_plan_classifier` 可以指定便宜的 provider,例如
`deepseek-flash`;它只在边界输入上调用,分类失败会回退到启发式规则。也可以用
`reasonix chat` 里的 `/auto-plan off|on` 修改用户级设置,或在 shell/脚本里用
`reasonix config auto-plan off|on`。只有明确想写项目级覆盖时,才给 shell 命令加
`--local`。
## 架构
三层可扩展性,全部藏在内核按名解析的 registry 之后:
1. **Registry**:`Provider` 与 `Tool` 是接口;内核没有 `switch model`。
2. **编译期内置**:provider(`provider/openai`)和 tool(`tool/builtin`)通过
`init()` 自注册,`main` 用 blank import 拉入。新增内置 = 一个文件 + 一行 import。
3. **运行时插件**:配置里声明的可执行文件,通过 stdin/stdout 上的
newline-delimited JSON-RPC 2.0(MCP stdio 约定)通信,每个远程 tool 适配成
`Tool` 接口。
## 状态
已完成:基于 registry 的 provider/tool、OpenAI 兼容流式 + 工具调用(429/5xx 有界重
试)、九个内置工具(read_file、write_file、edit_file、multi_edit、bash、ls、glob、
grep、web_fetch)、TOML 配置、交互式 `reasonix setup` 向导、双模型协同(执行器 + 规划器,
各自独立、缓存稳定的 session)、低频上下文压缩、子 agent(`task`)、bubbletea 聊天
TUI(markdown、plan mode、上下文仪表盘、`/compact` `/new` `/tree` `/branch` `/switch`)、会话持久化 + 恢复、
逐次调用**权限**(allow/ask/deny 规则;chat 在 writer 前询问,deny 在各模式硬阻断)、
**工作区沙盒**(把文件写工具限制在项目内,符号链接/`..` 安全)、
MCP 客户端——**stdio + Streamable HTTP** 传输、工具(`mcp__server__tool`,支持
`readOnlyHint`)、prompts(斜杠命令)、resources(`@` 引用)、`/mcp`,可经
`[[plugins]]` 或 Claude 风格的项目 `.mcp.json` 配置——自定义斜杠命令
(`.reasonix/commands/*.md`)、`@file` / `@resource` 引用、外加可运行的参考插件
(`cmd/reasonix-plugin-example`)、harness 主循环、CLI。chat 在终端普通缓冲区运行(原生
scrollback)并带 `/` 与 `@` 输入补全。后续:给 `bash` 套 OS 级沙盒(macOS Seatbelt /
Linux bubblewrap,"盒子里放行、边界上询问")、Anthropic 原生 provider、MCP OAuth +
legacy SSE。见 `docs/SPEC.md` §9。
- **[指南](./docs/GUIDE.zh-CN.md)** —— 配置、权限与沙盒、插件(MCP)、斜杠命令、
`@` 引用、双模型协同。
- **[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md)** —— 桌面端连接飞书、Lark、微信
Bot,以及 IM 里的审批、YOLO 和命令交互。
- **[规格](./docs/SPEC.md)** —— 工程契约:架构、registry、数据类型与路线图。
- **[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md)** —— 用背景、输出边界、约束和暂停条件组织复杂请求。
- **[工具合约](./docs/TOOL_CONTRACT.zh-CN.md)** —— provider 可见的内置工具名、
read-only 标记和 schema 快照保护。
- **[从 0.x 迁移](./docs/MIGRATING.md)** —— 从 legacy TypeScript 版本迁到 1.0 Go 重写版。
- **[Checkpoints 与 rewind](./docs/CHECKPOINTS.md)** —— 基于快照的编辑安全网
(Esc-Esc / `/rewind`)。
<br/>
+45
View File
@@ -26,3 +26,48 @@ agent. It is the Reasonix analog of Claude Code's CLAUDE.md.
index), which loads into the prefix on the next session.
## Notes
## Pre-push CI simulation
Run these **before every commit** to catch the fastest CI failures locally:
```bash
gofmt -w . # catches gofmt (saves ~13s CI)
go vet ./... # catches vet warnings (saves ~52s CI/lint)
go test ./internal/tool/builtin/ ./internal/boot/ # catches tool/boot test breaks
```
CI runs `golangci-lint` (not locally available), but gofmt + vet already block ~80% of fast-fail scenarios.
## Import cycle rule
Before importing a new internal package from a non-test file, verify the target package's **test files** aren't already importing back to you:
```
# BAD: agent(_test.go) → tool/builtin(sessions.go) → agent → setup failed
```
Use `go test ./path/to/target/` to detect cycles **before** pushing. A `[setup failed]` message means a cycle exists.
## PR hygiene
- **One force-push per round of review feedback.** Multiple force-pushes destroy review history and confuse reviewers.
- **Keep the PR diff minimal.** Only the files relevant to the PR's purpose — no stray changes from other branches.
- **Amend, don't add commits, for review feedback** — keeps the commit history clean.
## 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.
+137
View File
@@ -0,0 +1,137 @@
# Security Policy
## Supported Versions
Reasonix security fixes are prioritized for the currently developed Go rewrite
and the current 1.x release line.
| Version or branch | Security support |
| --- | --- |
| `main-v2` / 1.x releases | Supported |
| `v1` / 0.x legacy branch | Critical fixes only, where practical |
| Older releases, forks, or modified builds | Not covered unless the issue is reproducible upstream |
If you are unsure whether a version is affected, report against the newest
released 1.x version and include the exact version or commit you tested.
## Reporting a Vulnerability
Please report security issues privately. Do not open a public issue with exploit
details, secrets, crash dumps, or proof-of-concept payloads.
Preferred reporting path:
1. Use GitHub private vulnerability reporting for this repository, if available.
2. If private reporting is not available to you, open a minimal public issue
asking for a private maintainer contact path. Do not include exploit details
in that issue.
Please include:
- Affected Reasonix version, commit, operating system, and installation method.
- The feature or surface involved, such as CLI, desktop app, HTTP `serve`, bot
gateway, MCP plugin, built-in tool, updater, or configuration loading.
- Clear reproduction steps using dummy credentials and non-sensitive files.
- The expected impact, such as secret disclosure, arbitrary file access,
command execution, sandbox escape, authentication bypass, or supply-chain risk.
- Any relevant logs with API keys, tokens, local paths, and personal data
redacted.
Do not send real provider API keys, bot credentials, OAuth tokens, private
workspace files, or third-party user data.
## Security Boundaries
Reasonix is a local coding agent. Many features intentionally operate on the
user's local machine and workspace, including file reads, file writes, shell
commands, MCP plugins, language servers, bot sessions, and model-provider
requests. A finding is security-relevant when it crosses a supported boundary or
bypasses an explicit guard.
Supported boundaries include:
- Workspace confinement for file operations that are documented or implemented
as workspace-scoped.
- Permission checks for tool calls, shell commands, file writes, and approvals.
- Sandbox behavior for built-in shell execution where the platform supports it.
- Secret handling for provider keys, bot credentials, OAuth tokens, plugin
headers, and credential-store fallback files.
- HTTP `serve` protections for the unauthenticated local server, including
localhost binding assumptions, JSON-only state-changing requests, and CORS
restrictions.
- Desktop and bot session isolation, including per-workspace session metadata
and configured bot allowlists.
- Updater, install, and release verification paths.
The following are normally treated as trusted local/operator-controlled inputs
unless another bug lets an untrusted actor supply them:
- CLI arguments and text typed directly by the local user.
- Project configuration files intentionally loaded from the current workspace.
- Explicit `@path` references supplied by the local user to attach local files.
- MCP servers, language servers, hooks, and slash commands installed or enabled
by the local user.
- Provider base URLs and model names configured by the local user.
The following can be security issues when reachable by an untrusted actor or
when they bypass the intended boundary:
- Reading or writing files outside the configured workspace without explicit
local-user intent.
- Following symlinks or path traversal to escape workspace confinement.
- Running shell commands or external tools without the required permission gate.
- Leaking credentials, environment variables, prompt history, local files, or bot
messages to logs, model providers, MCP servers, crash reports, or telemetry.
- Allowing a website to drive the local HTTP server through CSRF, CORS, or
content-type bypasses.
- Letting a bot user outside the configured allowlist submit prompts, approve
tools, or access a project workspace.
- Trusting unverified update artifacts, plugin definitions, or downloaded
binaries.
## `@` File References
Reasonix supports `@path` references so users can include local files and images
in a prompt. This is intentional local functionality, but implementations must
preserve these invariants:
- In workspace-scoped sessions, relative and absolute paths must resolve under
the active workspace root before file content is read or attached.
- Path traversal such as `..` must not escape the workspace root.
- Symlinks must not be usable to bypass the intended workspace boundary.
- Unscoped local CLI compatibility must not be exposed to remote, bot, or
browser-controlled inputs unless an equivalent workspace boundary is applied.
- File content should be size-limited and binary content should not be dumped as
prompt text.
Static analysis alerts about path expressions should be triaged against these
rules: user-controlled path data is expected, but the access must either stay
inside the configured workspace or be clearly limited to trusted local CLI use.
## Out of Scope
The following reports are usually out of scope unless they demonstrate a bypass
of one of the boundaries above:
- A local user intentionally asks Reasonix to read, edit, or send their own
files to a configured model provider.
- A local user installs or enables a malicious MCP server, hook, slash command,
language server, or shell command and then grants it permission.
- A configured model provider, proxy, or MCP server receives data the user
intentionally sent to it.
- Denial-of-service issues that only affect the local user's own session and do
not corrupt files, leak secrets, or bypass permissions.
- Issues requiring administrator/root access on the user's machine before
interacting with Reasonix.
- Vulnerabilities in third-party services, models, proxies, or plugins that are
not caused by Reasonix behavior.
## Coordinated Disclosure
This is a community-maintained project. Maintainers will make a best-effort
assessment, ask follow-up questions when needed, and coordinate fixes before
public disclosure for confirmed vulnerabilities.
Please give maintainers reasonable time to investigate and release a fix before
publishing exploit details. If you plan to disclose on a timeline, include that
timeline in your initial report.
+255
View File
@@ -0,0 +1,255 @@
// Drives the context-maintenance E2E scenarios against the real DeepSeek API:
// seed → (idle past cache TTL) → resume A/B-compares cold-restart miss tokens with and without pruning.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/provider"
_ "reasonix/internal/provider/openai"
"reasonix/internal/tool"
"reasonix/internal/tool/builtin"
)
const (
model = "deepseek-v4-flash"
baseURL = "https://api.deepseek.com"
fatResults = 20
fatBytes = 12_000
)
func prov() provider.Provider {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "DEEPSEEK_API_KEY not set")
os.Exit(1)
}
p, err := provider.New("openai", provider.Config{Name: "e2e", BaseURL: baseURL, Model: model, APIKey: key})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return p
}
func fakeGoFile(nonce string, i, size int) string {
var b strings.Builder
fmt.Fprintf(&b, "// module %s file%02d\npackage stress\n\n", nonce, i)
line := 0
for b.Len() < size {
fmt.Fprintf(&b, "func helper_%s_%02d_%04d(x int) int { return x*%d + %d }\n", nonce, i, line, line+3, line*7)
line++
}
return b.String()
}
func seedSession(nonce string) *agent.Session {
s := agent.NewSession("You are a terse coding agent reviewing a Go codebase.")
s.Add(provider.Message{Role: provider.RoleUser, Content: "Review every file in module " + nonce + " one by one. Keep notes short."})
for i := 0; i < fatResults; i++ {
id := fmt.Sprintf("c%02d", i)
name := fmt.Sprintf("src/file%02d.go", i)
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: fmt.Sprintf(`{"path":%q}`, name)}}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: fakeGoFile(nonce, i, fatBytes)})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf("Reviewed %s.", name)})
}
return s
}
// oneShot appends a user message and runs a single completion, returning usage.
func oneShot(p provider.Provider, msgs []provider.Message, question string) (provider.Usage, error) {
req := provider.Request{
Messages: append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleUser, Content: question}),
MaxTokens: 32,
}
ch, err := p.Stream(context.Background(), req)
if err != nil {
return provider.Usage{}, err
}
var u provider.Usage
for c := range ch {
switch c.Type {
case provider.ChunkUsage:
u = *c.Usage
case provider.ChunkError:
return u, c.Err
}
}
return u, nil
}
type meta struct {
SeededAt time.Time `json:"seeded_at"`
Nonces map[string]string `json:"nonces"`
SeedUse map[string]provider.Usage `json:"seed_usage"`
}
func seed(dir string) {
p := prov()
if err := os.MkdirAll(dir, 0o755); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m := meta{SeededAt: time.Now(), Nonces: map[string]string{}, SeedUse: map[string]provider.Usage{}}
for _, arm := range []string{"pruned", "control"} {
nonce := fmt.Sprintf("%s%d", arm, time.Now().UnixNano()%1_000_000)
s := seedSession(nonce)
u, err := oneShot(p, s.Snapshot(), "How many files have you reviewed so far? Reply with just the number.")
if err != nil {
fmt.Fprintf(os.Stderr, "seed %s: %v\n", arm, err)
os.Exit(1)
}
if err := s.Save(filepath.Join(dir, arm+".jsonl")); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m.Nonces[arm] = nonce
m.SeedUse[arm] = u
fmt.Printf("seeded %-7s prompt=%d hit=%d miss=%d\n", arm, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)
}
b, _ := json.MarshalIndent(m, "", " ")
if err := os.WriteFile(filepath.Join(dir, "meta.json"), b, 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func resume(dir string) {
p := prov()
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var m meta
if err := json.Unmarshal(raw, &m); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
idle := time.Since(m.SeededAt).Round(time.Minute)
out := map[string]provider.Usage{}
for _, arm := range []string{"pruned", "control"} {
s, err := agent.LoadSession(filepath.Join(dir, arm+".jsonl"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
prunes := 0
if arm == "pruned" {
a := agent.New(nil, tool.NewRegistry(), s, agent.Options{ContextWindow: 1_000_000, ArchiveDir: filepath.Join(dir, "archive")}, event.Discard)
st, err := a.PruneStaleToolResults()
if err != nil {
fmt.Fprintln(os.Stderr, "prune:", err)
os.Exit(1)
}
prunes = st.Results
}
u, err := oneShot(p, s.Snapshot(), "Which file did you review first? Reply with just the path.")
if err != nil {
fmt.Fprintf(os.Stderr, "resume %s: %v\n", arm, err)
os.Exit(1)
}
out[arm] = u
fmt.Printf("resume %-7s idle=%s pruned=%d prompt=%d hit=%d miss=%d\n", arm, idle, prunes, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)
}
b, _ := json.MarshalIndent(map[string]any{"idle": idle.String(), "resume_usage": out}, "", " ")
if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("resume-%d.json", time.Now().Unix())), b, 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
c, pr := out["control"], out["pruned"]
if c.CacheMissTokens > 0 {
fmt.Printf("\ncold-restart miss tokens: control=%d pruned=%d (%.0f%% reduction)\n",
c.CacheMissTokens, pr.CacheMissTokens, 100*(1-float64(pr.CacheMissTokens)/float64(c.CacheMissTokens)))
}
}
// comprehension checks that the agent re-reads a file behind a prune placeholder
// instead of hallucinating: the answer is a number that exists only in the file.
func comprehension(trials int) {
p := prov()
pass := 0
for t := 0; t < trials; t++ {
dir, err := os.MkdirTemp("", "cm-e2e-")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
secret := fmt.Sprintf("%d", 1000+time.Now().UnixNano()%9000)
content := "package cfg\n\n// retention floor, milliseconds\nconst cacheRetentionFloor = " + secret + "\n" + strings.Repeat("// padding line filler for prune eligibility\n", 400)
if err := os.WriteFile(filepath.Join(dir, "config.go"), []byte(content), 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
s := agent.NewSession("You are a terse coding agent. Use tools when you need file contents.")
s.Add(provider.Message{Role: provider.RoleUser, Content: "Read config.go and note its constants."})
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "r1", Name: "read_file", Arguments: `{"path":"config.go"}`}}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "r1", Name: "read_file", Content: content})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "Noted the constants in config.go."})
for i := 0; i < 4; i++ {
s.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("ack %d", i)})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
}
reg := tool.NewRegistry()
for _, tl := range (builtin.Workspace{Dir: dir}).Tools("read_file") {
reg.Add(tl)
}
a := agent.New(p, reg, s, agent.Options{ContextWindow: 2000, RecentKeep: 2, MaxSteps: 5}, event.Discard)
st, err := a.PruneStaleToolResults()
if err != nil || st.Results == 0 {
fmt.Fprintf(os.Stderr, "trial %d: prune did not fire (st=%+v err=%v)\n", t, st, err)
os.Exit(1)
}
err = a.Run(context.Background(), "What is the exact numeric value of cacheRetentionFloor in config.go? Reply with just the number.")
reRead, answered := false, false
for _, msg := range s.Snapshot() {
if msg.Role == provider.RoleTool && strings.Contains(msg.Content, "cacheRetentionFloor = "+secret) {
reRead = true
}
if msg.Role == provider.RoleAssistant && strings.Contains(msg.Content, secret) {
answered = true
}
}
ok := err == nil && reRead && answered
if ok {
pass++
}
fmt.Printf("trial %d: re-read=%v answered=%v err=%v\n", t, reRead, answered, err)
os.RemoveAll(dir)
}
fmt.Printf("\ncomprehension: %d/%d passed\n", pass, trials)
if pass < trials {
os.Exit(1)
}
}
func main() {
dir := flag.String("dir", "benchmarks/context-maintenance-e2e/run", "state directory for seed/resume")
trials := flag.Int("trials", 5, "comprehension trials")
flag.Parse()
switch flag.Arg(0) {
case "seed":
seed(*dir)
case "resume":
resume(*dir)
case "comprehension":
comprehension(*trials)
default:
fmt.Fprintln(os.Stderr, "usage: context-maintenance-e2e [seed|resume|comprehension]")
os.Exit(1)
}
}
@@ -0,0 +1,3 @@
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."
max_steps = 15
timeout_sec = 300
@@ -0,0 +1,12 @@
set -e
# Exercises a fresh `task` sub-agent delegation in the headless `reasonix run`
# path: 17 + 28 + 41 = 86. The numbers are arbitrary so the answer can only be
# produced by actually reading the three seed files (the prompt mandates doing
# that via the `task` tool). Before sub-agents could run without a parent
# session, the `task` call errored here with "parent session is required".
test -f result.txt
got=$(tr -d '[:space:]' < result.txt)
if [ "$got" != "86" ]; then
echo "result.txt = '$got', want 86"
exit 1
fi
@@ -0,0 +1 @@
alpha=17
@@ -0,0 +1 @@
beta=28
@@ -0,0 +1 @@
gamma=41
+10 -40
View File
@@ -10,6 +10,8 @@ import (
"strings"
"time"
"unicode/utf8"
"reasonix/internal/shellparse"
)
type diffOpts struct {
@@ -546,57 +548,25 @@ func failingTestNames(out string) []string {
}
func runTests(repo, testCmd string, pkgs []string) (bool, string) {
fields := splitShellFields(testCmd)
test, err := shellparse.ParseStaticCommand(testCmd, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true})
if err != nil {
return false, "invalid test command: " + err.Error()
}
fields := test.Argv
if len(fields) == 0 {
fields = []string{"go", "test"}
}
args := append(fields[1:], pkgs...)
cmd := exec.Command(fields[0], args...)
cmd.Dir = repo
if len(test.Env) > 0 {
cmd.Env = append(os.Environ(), test.Env...)
}
cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs
out, err := cmd.CombinedOutput()
return err == nil, string(out)
}
// splitShellFields splits on whitespace, honoring single/double-quoted spans and stripping the quotes.
func splitShellFields(s string) []string {
var out []string
var cur strings.Builder
inSingle, inDouble := false, false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case inSingle:
if c == '\'' {
inSingle = false
} else {
cur.WriteByte(c)
}
case inDouble:
if c == '"' {
inDouble = false
} else {
cur.WriteByte(c)
}
case c == '\'':
inSingle = true
case c == '"':
inDouble = true
case c == ' ' || c == '\t' || c == '\n':
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteByte(c)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go
// when includeTests is false (we want the source under test).
func changedGoFiles(repo, base string, includeTests bool) []string {
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunTestsAllowsStaticEnvAssignment(t *testing.T) {
repo := t.TempDir()
writeE2EFile(t, filepath.Join(repo, "go.mod"), "module example.com/e2ebenchtest\n\ngo 1.23\n")
writeE2EFile(t, filepath.Join(repo, "env_test.go"), `package e2ebenchtest
import (
"os"
"testing"
)
func TestEnv(t *testing.T) {
if got := os.Getenv("REASONIX_E2E_ENV"); got != "ok" {
t.Fatalf("REASONIX_E2E_ENV = %q", got)
}
}
`)
ok, out := runTests(repo, "GOWORK=off REASONIX_E2E_ENV=ok go test", []string{"./..."})
if !ok {
t.Fatalf("runTests failed:\n%s", out)
}
}
func TestRunTestsRejectsDynamicEnvAssignment(t *testing.T) {
ok, out := runTests(t.TempDir(), "REASONIX_E2E_ENV=$(echo ok) go test", []string{"./..."})
if ok {
t.Fatal("runTests accepted dynamic env assignment")
}
if !strings.Contains(out, "invalid test command: shell expansion") {
t.Fatalf("output = %q, want shell expansion rejection", out)
}
}
func writeE2EFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
+20 -1
View File
@@ -1,7 +1,8 @@
# Wails build assets/output — mostly auto-generated (plists, manifests, compiled
# binary); ignore them but keep the committed designed app icon.
# binary); ignore them but keep the committed designed app icon source and PNG.
/build/*
!/build/appicon.png
!/build/appicon.svg
# Commit only our customized per-user NSIS installer template. wails_tools.nsh and
# everything else under build/ are regenerated by `wails build`, so stay ignored.
@@ -12,6 +13,24 @@
/build/windows/installer/*
!/build/windows/installer/project.nsi
# Commit our hand-written hardened-runtime entitlements (scripts/desktop-build.sh
# passes it to codesign for Developer ID signing). Unlike Info.plist it is NOT
# regenerated by `wails build`, so it must live in git. Parent dir un-ignored first.
!/build/darwin
/build/darwin/*
!/build/darwin/entitlements.plist
!/build/darwin/icon.icns
# Commit the hand-written Linux .deb packaging inputs and app icon assets.
# `wails build` does not generate these, so they must live in git. The .deb is built
# by scripts/desktop-build.sh's linux branch via goreleaser/nfpm. Parent un-ignored first.
!/build/linux
/build/linux/*
!/build/linux/nfpm.yaml
!/build/linux/reasonix.desktop
!/build/linux/icons
!/build/linux/icons/**
# Go test binaries
*.test
+83 -10
View File
@@ -62,6 +62,36 @@ In a plain browser the native bindings are absent, so `bridge.ts` falls back to
exact same event contract — so layout, streaming, markdown, tool cards, and the
diff seam can all be built without rebuilding Go.
## Test
The desktop package is a nested Go module, so parent `go test ./...` does not run
it. Use the full lane before merging desktop changes, and the short lane for fast
local feedback:
```sh
make desktop-test # cd desktop && go test .
make desktop-test-short # skips slow desktop integration/e2e checks
```
To find the next bottleneck, rank individual test cases from the JSON stream:
```sh
make desktop-test-times
# or: cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
```
### Frontend UI review checklist
For anchored menus, dropdowns, tooltips, and other portaled UI, review both the
component code and the CSS positioning contract:
- If a component uses `createPortal` plus `getBoundingClientRect()`, it must
handle scrollable ancestors, window resize, and `visualViewport` changes.
- Add a focused regression test when changing shared positioning primitives such
as `AnchoredPopover`, not only the specific menu that exposed the bug.
- Exercise at least one scrollable container path, such as Settings content, when
manually checking dropdown or popover changes.
## Build
```sh
@@ -89,7 +119,9 @@ Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
which builds on a native runner per platform (Wails can't cross-compile a
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
`latest.json` manifest, publishes a GitHub release, and mirrors everything to R2.
`latest.json` manifest, publishes a GitHub release, mirrors everything to R2,
and attaches the current desktop manifest to the matching CLI release for old
clients that still ask GitHub's repository-wide `latest` release for it.
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
@@ -97,9 +129,12 @@ The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
git tag desktop-v1.1.0 && git push origin desktop-v1.1.0
```
The app checks `latest.json` on startup (R2 first, GitHub as fallback) and shows
an update banner when a newer version is published; **Settings → Software update**
has a manual check. Self-update behavior by platform:
The app checks `latest.json` on startup (R2 first, then the
`crash.reasonix.io` desktop release gateway) and shows an update banner when a
newer version is published; **Settings → Software update** has a manual check.
The gateway resolves only the desktop `desktop-v*` release line and never uses
GitHub's repository-wide `/releases/latest` shortcut, because plain `v*` tags are
the CLI release line. Self-update behavior by platform:
- **Linux / Windows** — download, verify the minisign signature, then update in
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
@@ -171,13 +206,23 @@ the **native** webview per OS, so the rough edges are platform-specific. What's
handled here, and what to reach for if a target misbehaves:
- **Linux / WebKitGTK** is the one real pain point — rendering varies by distro &
GPU driver. `main.go` sets `WebviewGpuPolicy: OnDemand` to avoid blank/flickering
webviews from forced compositing. If artifacts persist, launch with
`WEBKIT_DISABLE_COMPOSITING_MODE=1`. Test on at least one GTK target before
release; the CSS deliberately avoids `backdrop-filter`/blur (slow & inconsistent
there).
GPU driver. `main.go` keeps `WebviewGpuPolicy: OnDemand` when a DRI render node
is usable, and falls back to `Never` for xrdp/headless/software-rendered sessions
that cannot access `/dev/dri`. If artifacts persist, launch with
`WEBKIT_DISABLE_COMPOSITING_MODE=1`. Test on at least one GTK target before release;
the CSS deliberately avoids `backdrop-filter`/blur (slow & inconsistent there).
- **Wayland + NVIDIA**: On KDE Plasma Wayland with NVIDIA GPUs, WebKitGTK can
crash at startup (`Error 71: Protocol error`) due to an upstream WebKit
explicit-sync bug (WebKit #280210, #317089, NVIDIA/egl-wayland #179).
Reasonix automatically sets `__NV_DISABLE_EXPLICIT_SYNC=1` when it detects
Wayland + NVIDIA GPU. To opt out, set `__NV_DISABLE_EXPLICIT_SYNC=0`.
Alternative fallbacks: `WEBKIT_DISABLE_DMABUF_RENDERER=1` (poor performance)
or `GDK_BACKEND=x11` (forces XWayland).
- **Windows / WebView2** — `Theme: SystemDefault` follows the OS light/dark
setting; the runtime must be installed (bundle it for distribution).
setting; the installer embeds the WebView2 bootstrapper. Canary builds disable
WebView2 GPU acceleration by default to smoke-test blank-window reports; set
`REASONIX_DESKTOP_DISABLE_WEBVIEW2_GPU=1` or `0` to force the fallback on or
off.
- **macOS / WebKit** — inset/hidden title bar (`TitleBarHiddenInset`); the CSS
marks the top bar as an OS drag region (`--wails-draggable: drag`) and leaves
room for the traffic lights.
@@ -207,3 +252,31 @@ desktop/
Markdown, CodeViewer, DiffView
editors/ PlainCode, PlainDiff ← editor seam impls (swap targets)
```
## Telemetry
The desktop app sends one anonymous ping per launch to `crash.reasonix.io`:
a random install id (generated locally, tied to nothing), app version, OS,
arch, and OS version. It exists solely to count active installs. It never
includes conversations, API keys, file contents, or paths.
Opt out any time: Settings > Updates > "Anonymous usage ping", or set
`telemetry = false` under `[desktop]` in the global config. Dev builds
never ping. Crash and performance-pressure reports are separate and only
ever sent when the user clicks "Send report" on the diagnostic UI.
Aggregate quality metrics are also enabled by default and can be disabled from
Settings > Updates > "Share aggregate quality metrics", or by setting
`metrics = false` under `[desktop]`. These metrics are anonymous signal/bucket
counts and preference buckets; they never include conversations, prompts, keys,
paths, base URLs, or file contents.
When Memory v5 is enabled, the same aggregate metrics pipeline may include only
content-free count/size buckets such as injection on/off, compiled-token bucket,
IR-overhead bucket, memory-reference count, constraint/risk/step counts, and
memory-graph size buckets. It never uploads memory text, tool outputs, prompts,
file paths, IDs, keys, base URLs, or file contents. The Memory v5 runtime itself
is controlled from Settings > General > "Memory v5" and shares the user/global
`agent.memory_compiler.enabled` setting with the CLI/TUI and `reasonix serve`;
CLI users can also run `/memory-v5 off|observe|compact|on|status` in a session
or `reasonix config memory-v5 off|observe|compact|on|status` from a shell.
+6160 -640
View File
File diff suppressed because it is too large. Load diff
+530 -2
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
@@ -32,7 +33,7 @@ func controllerWithContent(t *testing.T, path string) *control.Controller {
sess.Add(provider.Message{Role: provider.RoleUser, Content: "remember this turn"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "acknowledged"})
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
return control.New(control.Options{Executor: ag, SessionPath: path, Sink: event.Discard})
return control.New(control.Options{Executor: ag, SessionDir: filepath.Dir(path), SessionPath: path, Sink: event.Discard})
}
func waitForFile(t *testing.T, path, want string) {
@@ -49,7 +50,12 @@ func waitForFile(t *testing.T, path, want string) {
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
waitForAutosaveIdleWithin(t, tab, 2*time.Second)
}
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
tab.saveMu.Lock()
idle := !tab.saving && !tab.saveAgain
@@ -131,3 +137,525 @@ func TestScheduleSnapshotCoalesces(t *testing.T) {
waitForFile(t, path, "acknowledged")
waitForAutosaveIdle(t, tab)
}
func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
_ = a
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForAutosaveIdleWithin(t, tab, 5*time.Second)
tab.saveMu.Lock()
failures := tab.saveFailures
tab.saveMu.Unlock()
if failures == 0 {
t.Fatal("autosave failure should be recorded and retried")
}
if info, err := os.Stat(path); err != nil || !info.IsDir() {
t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("remove blocked dir: %v", err)
}
tab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "remember this turn")
waitForAutosaveIdle(t, tab)
tab.saveMu.Lock()
failures = tab.saveFailures
tab.saveMu.Unlock()
if failures != 0 {
t.Fatalf("autosave failures after recovery = %d, want 0", failures)
}
}
func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
originalPath := filepath.Join(dir, "session.jsonl")
originalTopic := "topic_original"
if err := setTopicTitle("", originalTopic, "Original"); err != nil {
t.Fatalf("set original topic title: %v", err)
}
current := agent.NewSession("sys")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := current.Save(originalPath); err != nil {
t.Fatalf("Save current: %v", err)
}
if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
Scope: "global",
TopicID: originalTopic,
TopicTitle: "Original",
Preview: "first",
Turns: 2,
SchemaVersion: agent.BranchMetaCountsVersion,
}); err != nil {
t.Fatalf("SaveBranchMeta original: %v", err)
}
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
app := &App{
tabs: map[string]*WorkspaceTab{},
detachedSessions: map[string]*WorkspaceTab{},
activeTabID: "recovery_tab",
}
tab := &WorkspaceTab{
ID: "recovery_tab",
Scope: "global",
WorkspaceRoot: root,
TopicID: originalTopic,
TopicTitle: "Original",
SessionPath: originalPath,
Ready: true,
model: "test-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
tab.Ctrl = control.New(control.Options{
Executor: staleExec,
SessionDir: dir,
SessionPath: originalPath,
Label: "test",
Sink: tab.sink,
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
app.tabs[tab.ID] = tab
if err := tab.Ctrl.Snapshot(); err != nil {
t.Fatalf("Snapshot: %v", err)
}
recoveryPath := tab.Ctrl.SessionPath()
if recoveryPath == "" || recoveryPath == originalPath {
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
}
if tab.SessionPath != recoveryPath {
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
}
if tab.TopicID != originalTopic {
t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
}
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
}
if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
}
tabMeta := app.tabMeta(tab, true)
if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
}
nodes := app.ListProjectTree()
foundOriginal := false
var walk func([]ProjectNode)
walk = func(list []ProjectNode) {
for _, node := range list {
if node.Recovered {
t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
}
if node.TopicID == originalTopic {
foundOriginal = true
}
walk(node.Children)
}
}
walk(nodes)
if !foundOriginal {
t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
}
}
func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
isolateDesktopUserDirs(t)
root := globalTabWorkspaceRoot()
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
originalPath := filepath.Join(dir, "session.jsonl")
current := agent.NewSession("sys")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := current.Save(originalPath); err != nil {
t.Fatalf("Save current: %v", err)
}
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
OriginalPath: originalPath,
BranchMeta: agent.BranchMeta{
Name: agent.RecoveryBranchDefaultName,
Scope: "global",
TopicID: "topic_recovery",
TopicTitle: "Recovery",
},
})
if err != nil {
t.Fatalf("SaveRecoveryBranch: %v", err)
}
lease, err := agent.TryAcquireSessionLease(recovery.Path)
if err != nil {
t.Fatalf("TryAcquireSessionLease recovery: %v", err)
}
defer lease.Release()
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
runtimeEvents := make(chan runtimeEventEnvelope, 4)
app := &App{
ctx: context.Background(),
tabs: map[string]*WorkspaceTab{},
detachedSessions: map[string]*WorkspaceTab{},
activeTabID: "recovery_tab",
}
app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
runtimeEvents <- runtimeEventEnvelope{
ctx: ctx,
name: name,
payload: append([]interface{}(nil), payload...),
}
}
tab := &WorkspaceTab{
ID: "recovery_tab",
Scope: "global",
WorkspaceRoot: root,
TopicID: "topic_original",
TopicTitle: "Original",
SessionPath: originalPath,
Ready: true,
model: "test-model",
disabledMCP: map[string]ServerView{},
}
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
tab.Ctrl = control.New(control.Options{
Executor: staleExec,
SessionDir: dir,
SessionPath: originalPath,
Label: "test",
Sink: tab.sink,
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
OnSessionRecovered: app.handleTabSessionRecovered(tab),
})
app.tabs[tab.ID] = tab
err = tab.Ctrl.Snapshot()
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
}
if got := tab.Ctrl.SessionPath(); got != originalPath {
t.Fatalf("controller session path = %q, want original %q", got, originalPath)
}
if tab.SessionPath != originalPath {
t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
}
if tab.TopicID != "topic_original" {
t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
}
deadline := time.After(time.Second)
for {
select {
case emitted := <-runtimeEvents:
if emitted.name != "session:recovery-failed" {
continue
}
if len(emitted.payload) != 1 {
t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
}
failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
if !ok {
t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
}
if failed.Reason != "lease_held" {
t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
}
return
case <-deadline:
t.Fatal("session:recovery-failed event was not emitted")
}
}
}
func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, _ := appWithTab(t, path)
a.tabs["target_tab"] = &WorkspaceTab{
ID: "target_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
a.tabOrder = []string{"test_tab", "target_tab"}
err := a.SetActiveTab("target_tab")
if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
}
if a.activeTabID != "test_tab" {
t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
}
}
func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
target := filepath.Join(t.TempDir(), "target.jsonl")
sess := agent.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
if err := sess.Save(target); err != nil {
t.Fatalf("save target: %v", err)
}
loaded, err := agent.LoadSession(target)
if err != nil {
t.Fatalf("load target: %v", err)
}
err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
t.Fatalf("rebind error = %v, want persistence failure", err)
}
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
}
}
// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
// It proves that after CloseTab returns, the per-turn autosave goroutine can no
// longer write the session file — even when it is in flight at the moment the
// tab is closed. Pre-fix, the loop held a raw *WorkspaceTab pointer and a
// captured session path, so its Snapshot() call landed after DeleteSession
// trashed the file, "resurrecting" it.
func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
doomed, doomedTab := appWithTab(t, path)
// CloseTab needs >1 tab and mutates activeTabID, so add a survivor tab.
survivor := &WorkspaceTab{
ID: "survivor_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
survivor.sink = &tabEventSink{tabID: survivor.ID, app: doomed}
doomed.tabs["survivor_tab"] = survivor
doomed.activeTabID = "test_tab"
// Write the session file once via the autosave loop, then wait for idle so
// the next TurnDone reliably kicks off a fresh loop.
doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, path, "acknowledged")
waitForAutosaveIdle(t, doomedTab)
// Kick the autosave loop and close the tab in close succession. The loop
// will be in flight when CloseTab runs — exactly the #4384 window.
doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
if err := doomed.CloseTab("test_tab"); err != nil {
t.Fatalf("CloseTab: %v", err)
}
// CloseTab must have returned only after the autosave loop finished. Remove
// the file the way DeleteSession would (move to trash is just a remove here
// since we only care that nothing rewrites the original path).
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
t.Fatalf("remove session file: %v", err)
}
// Give any would-be resurrection a chance to strike. If the autosave loop
// were still alive (the bug), the file reappears here.
time.Sleep(100 * time.Millisecond)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("session file resurrected after CloseTab + delete (stat err=%v) — autosave loop not drained", err)
}
// And the controller's session path must be cleared so no future Snapshot
// can write either.
if got := doomedTab.Ctrl.SessionPath(); got != "" {
t.Fatalf("controller session path = %q after CloseTab, want empty so snapshots no-op", got)
}
}
func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "blocked.jsonl")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("mkdir blocked path: %v", err)
}
a, tab := appWithTab(t, path)
survivor := &WorkspaceTab{
ID: "survivor_tab",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
a.tabs[survivor.ID] = survivor
a.tabOrder = []string{tab.ID, survivor.ID}
err := a.CloseTab(tab.ID)
if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
t.Fatalf("CloseTab error = %v, want persistence failure", err)
}
if _, ok := a.tabs[tab.ID]; !ok {
t.Fatal("tab was removed even though its session could not be saved")
}
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
}
}
// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
// closing/drain logic is per-tab and must not leak to other tabs.
func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
doomedPath := filepath.Join(t.TempDir(), "doomed.jsonl")
survivorPath := filepath.Join(t.TempDir(), "survivor.jsonl")
a, _ := appWithTab(t, doomedPath)
survivorCtrl := controllerWithContent(t, survivorPath)
survivor := &WorkspaceTab{
ID: "survivor_tab",
Ctrl: survivorCtrl,
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
a.tabs["survivor_tab"] = survivor
a.activeTabID = "test_tab"
survivor.sink.Emit(event.Event{Kind: event.TurnDone})
waitForFile(t, survivorPath, "acknowledged")
waitForAutosaveIdle(t, survivor)
if err := a.CloseTab("test_tab"); err != nil {
t.Fatalf("CloseTab: %v", err)
}
if got := survivor.Ctrl.SessionPath(); got != survivorPath {
t.Fatalf("survivor session path = %q, want %q", got, survivorPath)
}
if survivor.closing {
t.Fatal("survivor tab was marked closing — closing flag leaked across tabs")
}
}
func TestDeleteSessionClearsRemovedRuntimeSessionPath(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
path := filepath.Join(dir, "delete-open.jsonl")
ctrl := controllerWithContent(t, path)
tab := &WorkspaceTab{
ID: "delete_open",
Scope: "global",
Ready: true,
Ctrl: ctrl,
disabledMCP: map[string]ServerView{},
}
app := &App{
tabs: map[string]*WorkspaceTab{"delete_open": tab},
activeTabID: "delete_open",
}
if err := ctrl.Snapshot(); err != nil {
t.Fatalf("snapshot: %v", err)
}
if err := app.DeleteSession(path); err != nil {
t.Fatalf("DeleteSession: %v", err)
}
if got := ctrl.SessionPath(); got != "" {
t.Fatalf("removed controller session path = %q, want empty before trash move can race Windows file locks", got)
}
trashPath := filepath.Join(dir, sessionTrashDir, "delete-open.jsonl", "delete-open.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("session should be in trash: %v", err)
}
}
func TestTrashTopicClearsRemovedRuntimeSessionPath(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_clear_removed_runtime"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Clear removed runtime"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := t.TempDir()
path := filepath.Join(dir, "trash-open-topic.jsonl")
ctrl := controllerWithContent(t, path)
if err := ctrl.Snapshot(); err != nil {
t.Fatalf("snapshot: %v", err)
}
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
CreatedAt: time.Now().Add(-time.Minute),
UpdatedAt: time.Now(),
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: "Clear removed runtime",
}); err != nil {
t.Fatalf("save branch meta: %v", err)
}
tab := &WorkspaceTab{
ID: "trash_open",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: topicID,
TopicTitle: "Clear removed runtime",
Ready: true,
Ctrl: ctrl,
disabledMCP: map[string]ServerView{},
}
survivor := &WorkspaceTab{
ID: "survivor",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
app := &App{
tabs: map[string]*WorkspaceTab{"trash_open": tab, "survivor": survivor},
tabOrder: []string{"trash_open", "survivor"},
activeTabID: "trash_open",
}
if err := app.TrashTopic(topicID); err != nil {
t.Fatalf("TrashTopic: %v", err)
}
if got := ctrl.SessionPath(); got != "" {
t.Fatalf("removed topic controller session path = %q, want empty before trash move can race Windows file locks", got)
}
trashPath := filepath.Join(dir, sessionTrashDir, "trash-open-topic.jsonl", "trash-open-topic.jsonl")
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("topic session should be in trash: %v", err)
}
}
+742
View File
@@ -1,10 +1,15 @@
package main
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
@@ -55,3 +60,740 @@ func TestCarriedRebuildsKeepOneSession(t *testing.T) {
t.Fatalf("after 5 carried rebuilds the history shows %d sessions, want 1: %v", len(infos), paths)
}
}
// EnsureBlankTab reuses an already-open blank tab rather than creating a second one.
func TestEnsureBlankTabReusesExistingBlankTab(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
first, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatal(err)
}
if first.SessionPath == "" {
t.Fatal("EnsureBlankTab should pre-create a session path for immediate deletion")
}
if _, err := os.Stat(first.SessionPath); err != nil {
t.Fatalf("pre-created blank session should exist: %v", err)
}
second, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatal(err)
}
if second.ID != first.ID {
t.Fatalf("EnsureBlankTab created duplicate blank tab: first=%q second=%q", first.ID, second.ID)
}
if tabs := app.ListTabs(); len(tabs) != 1 {
t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
}
}
func TestEnsureBlankTabReusesPrecreatedBlankBeforeControllerReady(t *testing.T) {
isolateDesktopUserDirs(t)
globalRoot := globalWorkspaceRoot()
if err := os.MkdirAll(globalRoot, 0o755); err != nil {
t.Fatal(err)
}
sessionPath := agent.NewSessionPath(desktopSessionDir(globalRoot), "")
if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
topic, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
app.tabs["blank"] = &WorkspaceTab{
ID: "blank",
Scope: "global",
WorkspaceRoot: globalRoot,
TopicID: topic.ID,
TopicTitle: defaultTopicTitle,
SessionPath: sessionPath,
disabledMCP: map[string]ServerView{},
}
app.tabOrder = []string{"blank"}
app.activeTabID = "blank"
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.ID != "blank" {
t.Fatalf("EnsureBlankTab created duplicate blank tab %q, want existing pre-created blank", meta.ID)
}
}
func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
topic, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
globalRoot := globalWorkspaceRoot()
dir := desktopSessionDir(globalRoot)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
stubPath := filepath.Join(dir, "empty-stub.jsonl")
if err := os.WriteFile(stubPath, nil, 0o644); err != nil {
t.Fatalf("write empty stub: %v", err)
}
now := time.Now()
if err := agent.SaveBranchMetaPreserveUpdated(stubPath, agent.BranchMeta{
CreatedAt: now.Add(-time.Minute),
UpdatedAt: now,
Scope: "global",
WorkspaceRoot: globalRoot,
TopicID: topic.ID,
TopicTitle: defaultTopicTitle,
}); err != nil {
t.Fatalf("save branch meta: %v", err)
}
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID != topic.ID {
t.Fatalf("EnsureBlankTab topic = %q, want reused empty topic %q", meta.TopicID, topic.ID)
}
}
func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
before := time.Now().UnixMilli()
meta, err := app.EnsureBlankTab("global", "")
after := time.Now().UnixMilli()
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
createdAt := loadTopicCreatedAt("", meta.TopicID)
if createdAt < before || createdAt > after {
t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
}
nodes := app.ListProjectTree()
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
t.Fatalf("project tree = %#v, want Global with one topic", nodes)
}
if got := nodes[0].Children[0].CreatedAt; got != createdAt {
t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
}
}
func TestEnsureBlankTabRepairsMissingCreatedAtForReusedTopic(t *testing.T) {
isolateDesktopUserDirs(t)
const topicID = "topic_20260704-104018_deadbeef"
if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
t.Fatalf("set topic title: %v", err)
}
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
t.Fatalf("prepend topic: %v", err)
}
if got := loadTopicCreatedAt("", topicID); got != 0 {
t.Fatalf("createdAt before reuse = %d, want 0", got)
}
app := NewApp()
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID != topicID {
t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
}
expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
if got := loadTopicCreatedAt("", topicID); got != expected {
t.Fatalf("repaired createdAt = %d, want %d", got, expected)
}
}
// EnsureBlankTab reuses an already-open project-scoped blank tab.
func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
first, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatal(err)
}
second, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatal(err)
}
if second.ID != first.ID {
t.Fatalf("EnsureBlankTab created duplicate project blank tab: first=%q second=%q", first.ID, second.ID)
}
if tabs := app.ListTabs(); len(tabs) != 1 {
t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
}
}
func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
tabA := waitForTabReady(t, app, first.ID)
if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
}
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
if second.ID == first.ID {
t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
}
tabB := waitForTabReady(t, app, second.ID)
if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
}
if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
}
sys := systemPromptFrom(tabB.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
if err != nil {
t.Fatalf("create project A empty session: %v", err)
}
tab := &WorkspaceTab{
ID: "blank-project-b",
Scope: "project",
WorkspaceRoot: projectB,
SessionPath: pathA,
}
if blankTabSessionPathHasNoContent(tab) {
t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
}
}
func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
waitForTabReady(t, app, first.ID)
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
tabB := waitForTabReady(t, app, second.ID)
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
forked, err := app.Fork(turn)
if err != nil {
t.Fatalf("Fork: %v", err)
}
if forked.ID == "" || forked.ID == second.ID {
t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
}
forkTab := waitForTabReady(t, app, forked.ID)
if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
sys := systemPromptFrom(forkTab.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
isolateDesktopUserDirs(t)
projectA := robustTempDir(t)
projectB := robustTempDir(t)
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
app := NewApp()
first, err := app.EnsureBlankTab("project", projectA)
if err != nil {
t.Fatalf("EnsureBlankTab(project A): %v", err)
}
waitForTabReady(t, app, first.ID)
second, err := app.EnsureBlankTab("project", projectB)
if err != nil {
t.Fatalf("EnsureBlankTab(project B): %v", err)
}
tabB := waitForTabReady(t, app, second.ID)
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
if err := app.Rewind(turn, "conversation"); err != nil {
t.Fatalf("Rewind: %v", err)
}
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
}
sys := systemPromptFrom(tabB.Ctrl.History())
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
}
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
}
}
func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
t.Helper()
if tab == nil || tab.Ctrl == nil {
t.Fatal("tab controller is required")
}
sys := systemPromptFrom(tab.Ctrl.History())
if strings.TrimSpace(sys) == "" {
t.Fatal("tab controller did not expose a system prompt")
}
sessionDir := tab.Ctrl.SessionDir()
sessionPath := tab.Ctrl.SessionPath()
workspaceRoot := tab.Ctrl.WorkspaceRoot()
label := tab.Ctrl.Label()
tab.Ctrl.Close()
sess := agent.NewSession(sys)
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{
Runner: ag,
Executor: ag,
SessionDir: sessionDir,
SessionPath: sessionPath,
WorkspaceRoot: workspaceRoot,
Label: label,
SystemPrompt: sys,
Sink: event.Discard,
})
tab.Ctrl = ctrl
app.bindControllerDisplayRecorder(ctrl)
return ctrl
}
func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
t.Helper()
ctrl.SubmitUserTurn(input, input)
waitNotRunning(t, ctrl)
deadline := time.Now().Add(time.Second)
for {
checkpoints := ctrl.Checkpoints()
if len(checkpoints) > 0 {
return checkpoints[len(checkpoints)-1].Turn
}
if time.Now().After(deadline) {
t.Fatal("controller did not record a checkpoint")
}
time.Sleep(10 * time.Millisecond)
}
}
func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
t.Fatalf("set stale auto title: %v", err)
}
tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
meta, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if got := meta.TopicTitle; got != defaultTopicTitle {
t.Fatalf("reused auto topic title = %q, want %q", got, defaultTopicTitle)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
}
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
t.Fatalf("title source = %q, want auto", got)
}
}
func TestEnsureBlankTabPreservesReusableManualTopicTitle(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "Manual title")
if err != nil {
t.Fatalf("create topic: %v", err)
}
tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
meta, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if got := meta.TopicTitle; got != "Manual title" {
t.Fatalf("reused manual topic title = %q, want Manual title", got)
}
if got := loadTopicTitle(projectRoot, topic.ID); got != "Manual title" {
t.Fatalf("stored title = %q, want Manual title", got)
}
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
t.Fatalf("title source = %q, want manual", got)
}
}
func TestEnsureBlankTabKeepsActiveTabWhenTitleResetFails(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("create topic: %v", err)
}
if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
t.Fatalf("set stale auto title: %v", err)
}
activeTab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "active-tab")
reusableTab := app.createTabEntryWithID("project", projectRoot, topic.ID, "reusable-tab")
app.tabs[activeTab.ID] = activeTab
app.tabs[reusableTab.ID] = reusableTab
app.tabOrder = []string{activeTab.ID, reusableTab.ID}
app.activeTabID = activeTab.ID
titlePath := topicTitlesPath(projectRoot)
if err := os.Remove(titlePath); err != nil {
t.Fatalf("remove title file: %v", err)
}
if err := os.Mkdir(titlePath, 0o755); err != nil {
t.Fatalf("replace title file with directory: %v", err)
}
if _, err := app.EnsureBlankTab("project", projectRoot); err == nil {
t.Fatal("EnsureBlankTab succeeded, want title reset error")
}
if got := app.activeTabID; got != activeTab.ID {
t.Fatalf("active tab after failed title reset = %q, want %q", got, activeTab.ID)
}
}
// EnsureBlankTab picks up an existing blank topic created in the sidebar
// instead of creating a fresh topic, for global scope.
func TestEnsureBlankTabOpensExistingSidebarBlankTopic(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
topic, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatal(err)
}
meta, err := app.EnsureBlankTab("global", "")
if err != nil {
t.Fatal(err)
}
if meta.TopicID != topic.ID {
t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
}
if topics := loadProjectsFile().GlobalTopics; len(topics) != 1 {
t.Fatalf("global topics length = %d, want 1: %v", len(topics), topics)
}
}
// EnsureBlankTab picks up an existing blank topic created in the sidebar
// instead of creating a fresh topic, for project scope.
func TestEnsureBlankTabOpensExistingProjectSidebarBlankTopic(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatal(err)
}
meta, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatal(err)
}
if meta.TopicID != topic.ID {
t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
}
var topics []string
for _, project := range loadProjectsFile().Projects {
if project.Root == projectRoot {
topics = project.Topics
break
}
}
if len(topics) != 1 {
t.Fatalf("project topics length = %d, want 1: %v", len(topics), topics)
}
}
func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := robustTempDir(t)
app := NewApp()
topic, err := app.CreateTopic("project", projectRoot, "")
if err != nil {
t.Fatalf("CreateTopic: %v", err)
}
dir := desktopSessionDir(projectRoot)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
existingPath := writeTopicSession(t, dir, "existing.jsonl", topic.ID, defaultTopicTitle, projectRoot)
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
t.Fatalf("precondition topic session = %q, want %q", got, existingPath)
}
meta, err := app.EnsureBlankTab("project", projectRoot)
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID == topic.ID {
t.Fatalf("EnsureBlankTab reused topic %q even though it already has session %q", topic.ID, existingPath)
}
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
t.Fatalf("existing topic session changed = %q, want %q", got, existingPath)
}
}
// EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
// into ensureTopicIndexed, whose intentional prepend clears the delete
// tombstone and resurrects the topic the user removed.
func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
isolateDesktopUserDirs(t)
// Race product on disk: deleted topic whose default title lingered in the
// global title map (title-only, absent from GlobalTopics, no sessions).
tombstonedID := "topic_tombstone_blank"
if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
t.Fatalf("set lingering title: %v", err)
}
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
return true, nil
}); err != nil {
t.Fatalf("seed tombstone: %v", err)
}
meta, err := NewApp().EnsureBlankTab("global", "")
if err != nil {
t.Fatalf("EnsureBlankTab: %v", err)
}
if meta.TopicID == tombstonedID {
t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
}
f := loadProjectsFile()
if !containsDesktopString(f.DeletedTopics, tombstonedID) {
t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
}
if containsDesktopString(f.GlobalTopics, tombstonedID) {
t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
}
}
// NewSession skips the snapshot when the current tab has no real conversation content.
func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
path := agent.NewSessionPath(dir, "model-a")
ctrl := carryingController([]provider.Message{{Role: provider.RoleSystem, Content: "sys"}}, path)
app := NewApp()
app.setTestCtrl(ctrl, "model-a")
if err := app.NewSession(); err != nil {
t.Fatal(err)
}
if got := ctrl.SessionPath(); got != path {
t.Fatalf("blank NewSession changed session path = %q, want %q", got, path)
}
}
func TestNewSessionUsesFreshTopicIdentity(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
oldTopicID := "topic_old"
oldTopicTitle := "Old topic"
oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", oldTopicID, oldTopicTitle, projectRoot, "old prompt", time.Now().Add(-time.Hour))
sess := &agent.Session{}
sess.Replace([]provider.Message{{Role: provider.RoleUser, Content: "old prompt"}})
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, SessionPath: oldPath, Sink: event.Discard})
app := NewApp()
app.setTestCtrl(ctrl, "model-a")
tab := app.tabs["test"]
tab.Scope = "project"
tab.WorkspaceRoot = projectRoot
tab.TopicID = oldTopicID
tab.TopicTitle = oldTopicTitle
tab.SessionPath = oldPath
app.projectTreeChangedHook = func() {}
if err := app.NewSession(); err != nil {
t.Fatalf("NewSession: %v", err)
}
if got := tab.TopicID; got == "" || got == oldTopicID {
t.Fatalf("new session topic ID = %q, want fresh ID distinct from %q", got, oldTopicID)
}
if got := tab.TopicTitle; got != defaultTopicTitle {
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
}
newPath := ctrl.SessionPath()
if newPath == "" || filepath.Clean(newPath) == filepath.Clean(oldPath) {
t.Fatalf("new session path = %q, want fresh path distinct from %q", newPath, oldPath)
}
if err := os.WriteFile(newPath, []byte(`{"role":"user","content":"new prompt"}`+"\n"), 0o644); err != nil {
t.Fatalf("write new session: %v", err)
}
if !app.maybeAutoTitleTopic(tab) {
t.Fatalf("new session should auto-title its fresh topic")
}
oldMeta, ok, err := agent.LoadBranchMeta(oldPath)
if err != nil || !ok {
t.Fatalf("load old meta: ok=%v err=%v", ok, err)
}
if oldMeta.TopicID != oldTopicID || oldMeta.TopicTitle != oldTopicTitle {
t.Fatalf("old session meta changed after new session auto-title: %+v", oldMeta)
}
newMeta, ok, err := agent.LoadBranchMeta(newPath)
if err != nil || !ok {
t.Fatalf("load new meta: ok=%v err=%v", ok, err)
}
if newMeta.TopicID != tab.TopicID || newMeta.TopicTitle != "new prompt" {
t.Fatalf("new session meta = %+v, want topic %q titled new prompt", newMeta, tab.TopicID)
}
}
func TestNewSessionKeepsFreshRuntimeWhenTopicRepairFails(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := agent.NewSessionPath(dir, "model-a")
ctrl := controllerWithContent(t, path)
app := NewApp()
app.projectTreeChangedHook = func() {}
app.setTestCtrl(ctrl, "model-a")
tab := app.tabs["test"]
tab.TopicID = "topic_old"
tab.TopicTitle = "Old topic"
// Block desktopConfigDir-backed topic-index writes without affecting the
// session directory, which exercises the post-NewSession repair failure path.
if err := os.MkdirAll(filepath.Dir(desktopConfigDir()), 0o755); err != nil {
t.Fatalf("mkdir desktop config parent: %v", err)
}
if err := os.WriteFile(desktopConfigDir(), []byte("not-a-directory"), 0o644); err != nil {
t.Fatalf("block desktop config dir: %v", err)
}
if err := app.NewSession(); err != nil {
t.Fatalf("NewSession should keep the fresh runtime even when topic repair fails: %v", err)
}
if got := tab.TopicID; got == "" || got == "topic_old" {
t.Fatalf("new session topic ID = %q, want fresh ID distinct from the old topic", got)
}
if got := tab.TopicTitle; got != defaultTopicTitle {
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
}
if got := ctrl.SessionPath(); got == "" || filepath.Clean(got) == filepath.Clean(path) {
t.Fatalf("new session path = %q, want a fresh path distinct from %q", got, path)
}
}
+7303 -200
View File
File diff suppressed because it is too large. Load diff
+183
View File
@@ -0,0 +1,183 @@
package main
import (
"bytes"
"encoding/binary"
"fmt"
"image"
"image/color"
"image/png"
"os"
"testing"
)
func TestAppIconPNGUsesBlueFullCanvasRoundedBackground(t *testing.T) {
f, err := os.Open("build/appicon.png")
if err != nil {
t.Fatal(err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
t.Fatal(err)
}
assertFullCanvasRoundedIcon(t, img, 1024)
}
func TestWindowsICOUsesBlueFullCanvasRoundedBackground(t *testing.T) {
for _, size := range []int{16, 24, 32, 48, 64, 256} {
t.Run(fmt.Sprintf("%dx%d", size, size), func(t *testing.T) {
img := decodeICOImage(t, "build/windows/icon.ico", size)
assertFullCanvasRoundedIcon(t, img, size)
})
}
}
func assertFullCanvasRoundedIcon(t *testing.T, img image.Image, size int) {
t.Helper()
bounds := img.Bounds()
if bounds.Dx() != size || bounds.Dy() != size {
t.Fatalf("app icon must be square, got %dx%d", bounds.Dx(), bounds.Dy())
}
corners := []struct {
name string
x int
y int
}{
{"top-left", bounds.Min.X, bounds.Min.Y},
{"top-right", bounds.Max.X - 1, bounds.Min.Y},
{"bottom-left", bounds.Min.X, bounds.Max.Y - 1},
{"bottom-right", bounds.Max.X - 1, bounds.Max.Y - 1},
}
for _, corner := range corners {
_, _, _, a := img.At(corner.x, corner.y).RGBA()
if a > 0xff {
t.Fatalf("%s corner must be transparent, alpha=%d", corner.name, a)
}
}
_, _, _, centerAlpha := img.At(bounds.Min.X+bounds.Dx()/2, bounds.Min.Y+bounds.Dy()/2).RGBA()
if centerAlpha == 0 {
t.Fatal("app icon center must contain visible artwork")
}
edgePoints := []struct {
name string
x int
y int
}{
{"top", bounds.Min.X + bounds.Dx()/2, bounds.Min.Y},
{"right", bounds.Max.X - 1, bounds.Min.Y + bounds.Dy()/2},
{"bottom", bounds.Min.X + bounds.Dx()/2, bounds.Max.Y - 1},
{"left", bounds.Min.X, bounds.Min.Y + bounds.Dy()/2},
}
for _, point := range edgePoints {
_, _, _, a := img.At(point.x, point.y).RGBA()
if a == 0 {
t.Fatalf("%s edge must contain visible rounded-rect background", point.name)
}
assertReasonixBlue(t, point.name, img.At(point.x, point.y))
}
}
func assertReasonixBlue(t *testing.T, name string, colorValue color.Color) {
t.Helper()
r16, g16, b16, _ := colorValue.RGBA()
r, g, b := uint8(r16>>8), uint8(g16>>8), uint8(b16>>8)
if !near(r, 0x01, 2) || !near(g, 0x53, 2) || !near(b, 0xe5, 2) {
t.Fatalf("%s edge must use Reasonix blue background, got #%02x%02x%02x", name, r, g, b)
}
}
func near(got, want uint8, tolerance uint8) bool {
if got > want {
return got-want <= tolerance
}
return want-got <= tolerance
}
func decodeICOImage(t *testing.T, path string, size int) image.Image {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
r := bytes.NewReader(data)
var header struct {
Reserved uint16
Type uint16
Count uint16
}
if err := binary.Read(r, binary.LittleEndian, &header); err != nil {
t.Fatal(err)
}
if header.Reserved != 0 || header.Type != 1 {
t.Fatalf("invalid ICO header: reserved=%d type=%d", header.Reserved, header.Type)
}
type iconEntry struct {
Width uint8
Height uint8
ColorCount uint8
Reserved uint8
Planes uint16
BitCount uint16
BytesInRes uint32
ImageOffset uint32
}
entries := make([]iconEntry, header.Count)
for i := range entries {
if err := binary.Read(r, binary.LittleEndian, &entries[i]); err != nil {
t.Fatal(err)
}
}
expectedSizes := map[int]bool{16: false, 24: false, 32: false, 48: false, 64: false, 256: false}
targetIndex := -1
for i, entry := range entries {
width := int(entry.Width)
height := int(entry.Height)
if width == 0 {
width = 256
}
if height == 0 {
height = 256
}
if width != height {
t.Fatalf("ICO image must be square, got %dx%d", width, height)
}
if _, ok := expectedSizes[width]; ok {
expectedSizes[width] = true
}
if width == size {
targetIndex = i
}
}
for expectedSize, found := range expectedSizes {
if !found {
t.Fatalf("ICO is missing %dx%d image", expectedSize, expectedSize)
}
}
if targetIndex < 0 {
t.Fatalf("ICO is missing %dx%d image", size, size)
}
entry := entries[targetIndex]
end := int(entry.ImageOffset + entry.BytesInRes)
if end > len(data) {
t.Fatalf("ICO image offset exceeds file size: offset=%d size=%d file=%d", entry.ImageOffset, entry.BytesInRes, len(data))
}
img, err := png.Decode(bytes.NewReader(data[entry.ImageOffset:end]))
if err != nil {
t.Fatal(err)
}
return img
}
+322 -9
View File
@@ -1,26 +1,28 @@
package main
import (
"context"
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
)
func TestWorkspaceRelative(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
const desktopTinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
func TestWorkspaceRelativeIn(t *testing.T) {
root := t.TempDir()
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
cwd, _ := os.Getwd()
if rel, ok := workspaceRelative(filepath.Join(cwd, "sub", "file.go")); !ok || rel != "sub/file.go" {
if rel, ok := workspaceRelativeIn(filepath.Join(root, "sub", "file.go"), root); !ok || rel != "sub/file.go" {
t.Fatalf("in-tree = (%q, %v), want (sub/file.go, true)", rel, ok)
}
if _, ok := workspaceRelative(filepath.Join(filepath.Dir(cwd), "sibling.txt")); ok {
if _, ok := workspaceRelativeIn(filepath.Join(filepath.Dir(root), "sibling.txt"), root); ok {
t.Fatal("a path above the workspace must not resolve as in-tree")
}
}
@@ -38,6 +40,168 @@ func TestIsImageExt(t *testing.T) {
}
}
func TestSavePastedImageUsesActiveWorkspaceRoot(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
launchRoot := t.TempDir()
projectRoot := t.TempDir()
if err := os.Chdir(projectRoot); err != nil {
t.Fatal(err)
}
projectRoot, _ = os.Getwd()
if err := os.Chdir(launchRoot); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", WorkspaceRoot: projectRoot},
},
activeTabID: "project",
}
got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
if err != nil {
t.Fatalf("SavePastedImage: %v", err)
}
if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got))); err != nil {
t.Fatalf("pasted image should be saved under active workspace: %v", err)
}
if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got))); !os.IsNotExist(err) {
t.Fatalf("pasted image should not be saved under launch root, stat err=%v", err)
}
preview, err := app.AttachmentDataURL(got)
if err != nil {
t.Fatalf("AttachmentDataURL: %v", err)
}
if !strings.HasPrefix(preview, "data:image/png;base64,") {
t.Fatalf("preview = %q, want png data URL", preview)
}
}
func TestSavePastedImageUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
isolateDesktopUserDirs(t)
orig, _ := os.Getwd()
defer os.Chdir(orig)
launchRoot := t.TempDir()
projectA := t.TempDir()
projectB := t.TempDir()
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
sessionDirA := desktopSessionDir(projectA)
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
t.Fatalf("mkdir project A sessions: %v", err)
}
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_attach_owner", "Attach owner", projectA, "project A prompt", time.Now())
if err := os.Chdir(launchRoot); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectB, SessionPath: sessionPathA},
},
activeTabID: "project",
}
got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
if err != nil {
t.Fatalf("SavePastedImage: %v", err)
}
if _, err := os.Stat(filepath.Join(projectA, filepath.FromSlash(got))); err != nil {
t.Fatalf("pasted image should be saved under pinned session owner project A: %v", err)
}
if _, err := os.Stat(filepath.Join(projectB, filepath.FromSlash(got))); !os.IsNotExist(err) {
t.Fatalf("pasted image should not be saved under stale project B, stat err=%v", err)
}
if gotRoot := normalizeProjectRoot(app.tabs["project"].WorkspaceRoot); gotRoot != normalizeProjectRoot(projectA) {
t.Fatalf("tab workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
}
}
func TestAttachDroppedUsesActiveWorkspaceRoot(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
launchRoot := t.TempDir()
projectRoot := t.TempDir()
if err := os.Chdir(projectRoot); err != nil {
t.Fatal(err)
}
projectRoot, _ = os.Getwd()
if err := os.Chdir(launchRoot); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", WorkspaceRoot: projectRoot},
},
activeTabID: "project",
}
if err := os.MkdirAll(filepath.Join(projectRoot, "sub"), 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(projectRoot, "sub", "notes.txt")
if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
t.Fatal(err)
}
got, err := app.AttachDropped(target)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
}
}
func TestAttachDroppedImageUsesActiveWorkspaceRoot(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
launchRoot := t.TempDir()
projectRoot := t.TempDir()
if err := os.Chdir(launchRoot); err != nil {
t.Fatal(err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", WorkspaceRoot: projectRoot},
},
activeTabID: "project",
}
raw, err := base64.StdEncoding.DecodeString(desktopTinyPNG)
if err != nil {
t.Fatal(err)
}
outside := filepath.Join(t.TempDir(), "shot.png")
if err := os.WriteFile(outside, raw, 0o644); err != nil {
t.Fatal(err)
}
got, err := app.AttachDropped(outside)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
t.Fatalf("got %+v, want png attachment", got)
}
if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got.Path))); err != nil {
t.Fatalf("dropped image should be saved under active workspace: %v", err)
}
if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got.Path))); !os.IsNotExist(err) {
t.Fatalf("dropped image should not be saved under launch root, stat err=%v", err)
}
if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
}
}
func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
@@ -112,3 +276,152 @@ func TestAttachDroppedImageStoresThumbnail(t *testing.T) {
t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
}
}
func TestAttachDroppedOutsideWorkspaceDirRegistersWorkspaceRef(t *testing.T) {
orig, _ := os.Getwd()
defer os.Chdir(orig)
workspace := t.TempDir()
outside := filepath.Join(t.TempDir(), "Folder With Spaces")
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
t.Fatal(err)
}
expectedOutside := outside
if resolved, err := filepath.EvalSymlinks(outside); err == nil {
expectedOutside = resolved
}
expectedDisplayPath := filepath.ToSlash(expectedOutside)
if err := os.Chdir(workspace); err != nil {
t.Fatal(err)
}
ctrl := control.New(control.Options{WorkspaceRoot: workspace})
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
},
activeTabID: "project",
}
got, err := app.AttachDropped(outside)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if got.Kind != "workspace" || !got.IsDir {
t.Fatalf("got %+v, want workspace directory ref", got)
}
if !strings.HasPrefix(got.Path, "__reasonix_external_folder/") || strings.ContainsAny(got.Path, " \t\r\n") {
t.Fatalf("external folder path token = %q, want whitespace-free external token", got.Path)
}
if got.DisplayPath != expectedDisplayPath {
t.Fatalf("display path = %q, want %q", got.DisplayPath, expectedDisplayPath)
}
block, errs := ctrl.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
if len(errs) != 0 {
t.Fatalf("ResolveScopedRefs errors = %v", errs)
}
if !strings.Contains(block, `<dir path="`+expectedDisplayPath+`">`) ||
!strings.Contains(block, "sub/") ||
!strings.Contains(block, "sub/notes.txt") {
t.Fatalf("external dropped folder should resolve as dir context:\n%s", block)
}
}
func TestAttachDroppedOutsideWorkspaceDirRegistersAfterPinnedOwnerRebuild(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "test/test-model"
cfg.Desktop.ProviderAccess = []string{"test"}
cfg.Providers = []config.ProviderEntry{
{Name: "test", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
orig, _ := os.Getwd()
defer os.Chdir(orig)
projectA := t.TempDir()
projectB := t.TempDir()
outside := filepath.Join(t.TempDir(), "External")
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
t.Fatal(err)
}
if err := addProject(projectA, "Project A"); err != nil {
t.Fatalf("add project A: %v", err)
}
if err := addProject(projectB, "Project B"); err != nil {
t.Fatalf("add project B: %v", err)
}
sessionDirA := desktopSessionDir(projectA)
sessionDirB := desktopSessionDir(projectB)
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
t.Fatalf("mkdir project A sessions: %v", err)
}
if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
t.Fatalf("mkdir project B sessions: %v", err)
}
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_external_ref", "External ref", projectA, "project A prompt", time.Now())
sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
oldCtrl := control.New(control.Options{
SessionDir: sessionDirB,
SessionPath: sessionPathB,
WorkspaceRoot: projectB,
Sink: event.Discard,
})
app := NewApp()
app.readyHook = func() {}
tab := &WorkspaceTab{
ID: "project",
Scope: "project",
WorkspaceRoot: projectB,
TopicID: "topic_external_ref",
TopicTitle: "External ref",
SessionPath: sessionPathA,
Ready: true,
model: "test/test-model",
Ctrl: oldCtrl,
sink: &tabEventSink{tabID: "project", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
})
got, err := app.AttachDropped(outside)
if err != nil {
t.Fatalf("AttachDropped: %v", err)
}
if tab.Ctrl == oldCtrl {
t.Fatal("stale controller was reused for external folder ref")
}
if gotRoot := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); gotRoot != normalizeProjectRoot(projectA) {
t.Fatalf("controller workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
}
resolver, ok := tab.Ctrl.(interface {
ResolveScopedRefs(context.Context, string) (string, []string)
})
if !ok {
t.Fatalf("rebuilt controller does not resolve scoped refs: %T", tab.Ctrl)
}
block, errs := resolver.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
if len(errs) != 0 {
t.Fatalf("ResolveScopedRefs errors = %v", errs)
}
if !strings.Contains(block, "sub/notes.txt") {
t.Fatalf("external dropped folder should resolve on rebuilt controller:\n%s", block)
}
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"errors"
"testing"
"time"
)
func TestReportTabSnapshotErrorDebouncesAutosave(t *testing.T) {
app := NewApp()
tab := &WorkspaceTab{ID: "tab_warn", sink: &tabEventSink{tabID: "tab_warn", app: app}}
failure := errors.New("disk unhappy")
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
first := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if first.IsZero() {
t.Fatal("first autosave warning did not record its timestamp")
}
// Within the window: suppressed, timestamp untouched.
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
second := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !second.Equal(first) {
t.Fatalf("suppressed warning moved the debounce timestamp: %v -> %v", first, second)
}
// Window expired: warns again.
tab.saveMu.Lock()
tab.lastAutosaveWarnAt = time.Now().Add(-autosaveWarnInterval - time.Second)
tab.saveMu.Unlock()
app.reportTabSnapshotError(tab, "autosave", failure)
tab.saveMu.Lock()
third := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !third.After(first) {
t.Fatal("expired window did not re-arm the autosave warning")
}
// Explicit user actions are never debounced (state untouched).
app.reportTabSnapshotError(tab, "changing model", failure)
tab.saveMu.Lock()
fourth := tab.lastAutosaveWarnAt
tab.saveMu.Unlock()
if !fourth.Equal(third) {
t.Fatal("action-save warning must not consume the autosave debounce window")
}
}
+982
View File
@@ -0,0 +1,982 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"reasonix/internal/bot"
"reasonix/internal/bot/feishu"
"reasonix/internal/bot/weixin"
"reasonix/internal/botruntime"
"reasonix/internal/config"
)
type BotConnectionCredentialView struct {
AppID string `json:"appId"`
AppSecretEnv string `json:"appSecretEnv"`
AccountID string `json:"accountId"`
TokenEnv string `json:"tokenEnv"`
SecretSet bool `json:"secretSet"`
}
type BotConnectionSessionMappingView struct {
RemoteID string `json:"remoteId"`
SessionID string `json:"sessionId"`
SessionSource string `json:"sessionSource"`
ChatType string `json:"chatType"`
UserID string `json:"userId"`
ThreadID string `json:"threadId"`
Scope string `json:"scope"`
WorkspaceRoot string `json:"workspaceRoot"`
UpdatedAt string `json:"updatedAt"`
}
type BotConnectionView struct {
ID string `json:"id"`
Provider string `json:"provider"`
Domain string `json:"domain"`
Label string `json:"label"`
Enabled bool `json:"enabled"`
Status string `json:"status"`
Model string `json:"model"`
ToolApprovalMode string `json:"toolApprovalMode"`
WorkspaceRoot string `json:"workspaceRoot"`
Access BotAccessView `json:"access"`
Credential BotConnectionCredentialView `json:"credential"`
SessionMappings []BotConnectionSessionMappingView `json:"sessionMappings"`
LastError string `json:"lastError"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type BotInstallStartResult struct {
OK bool `json:"ok"`
Provider string `json:"provider"`
Domain string `json:"domain"`
InstallID string `json:"installId"`
URL string `json:"url"`
DeviceCode string `json:"deviceCode"`
UserCode string `json:"userCode"`
Interval int `json:"interval"`
ExpireIn int `json:"expireIn"`
Message string `json:"message"`
}
type BotInstallPollResult struct {
Done bool `json:"done"`
Connection BotConnectionView `json:"connection"`
Status string `json:"status"`
Message string `json:"message"`
Error string `json:"error"`
}
type BotConnectionDiagnostic struct {
ID string `json:"id"`
Label string `json:"label"`
Status string `json:"status"`
Message string `json:"message"`
MessageID string `json:"messageId"`
Phase string `json:"phase"`
Code string `json:"code"`
ReportKind string `json:"reportKind"`
ReportDetail string `json:"reportDetail"`
OccurredAt string `json:"occurredAt"`
}
type botInstallSession struct {
Provider string
Domain string
PollDomain string
DeviceCode string
UserCode string
StartedAt time.Time
ExpireAt time.Time
Weixin *weixin.LoginSession
}
func (a *App) StartBotConnectionInstall(provider, domain string) (BotInstallStartResult, error) {
provider, domain = normalizeBotInstallTarget(provider, domain)
if provider == "weixin" {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
session, err := weixin.StartLogin(ctx)
if err != nil {
return BotInstallStartResult{OK: false, Provider: provider, Domain: domain, Message: err.Error()}, nil
}
installID := randomInstallID()
a.mu.Lock()
if a.botInstalls == nil {
a.botInstalls = map[string]*botInstallSession{}
}
a.botInstalls[installID] = &botInstallSession{
Provider: provider,
Domain: domain,
DeviceCode: session.QRCode,
StartedAt: session.StartedAt,
ExpireAt: time.Now().Add(2 * time.Minute),
Weixin: session,
}
a.mu.Unlock()
return BotInstallStartResult{
OK: true, Provider: provider, Domain: domain, InstallID: installID, URL: firstNonEmptyBot(session.QRCodeURL, session.QRCode),
DeviceCode: session.QRCode, Interval: 3, ExpireIn: 120, Message: "请使用微信扫码完成连接。",
}, nil
}
if provider != "feishu" {
return BotInstallStartResult{OK: false, Provider: provider, Domain: domain, Message: "unsupported bot provider"}, nil
}
return a.startFeishuConnectionInstall(domain)
}
func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult, error) {
installID = strings.TrimSpace(installID)
// Copy the session under a.mu: overlapping polls of the same install can
// race the locked PollDomain upgrade below with unlocked field reads.
a.mu.RLock()
sessionPtr := a.botInstalls[installID]
var sessionCopy botInstallSession
if sessionPtr != nil {
sessionCopy = *sessionPtr
}
a.mu.RUnlock()
if sessionPtr == nil {
return BotInstallPollResult{Error: "install session not found"}, nil
}
session := &sessionCopy
if time.Now().After(session.ExpireAt) {
a.deleteBotInstall(installID)
return BotInstallPollResult{Status: "expired", Error: "install session expired"}, nil
}
if session.Provider == "weixin" {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
result, status, err := weixin.PollLogin(ctx, session.Weixin)
if err != nil {
return BotInstallPollResult{Status: status, Error: err.Error()}, nil
}
if result == nil {
return BotInstallPollResult{Status: status, Message: weixinInstallStatusMessage(status)}, nil
}
a.deleteBotInstall(installID)
conn, err := a.upsertBotConnection(config.BotConnectionConfig{
ID: connectionID("weixin", "weixin"),
Provider: "weixin",
Domain: "weixin",
Label: "微信",
Enabled: true,
Status: "connected",
Access: botInstallAccess(result.UserID),
Credential: config.BotConnectionCredential{AccountID: result.AccountID, TokenEnv: "WEIXIN_BOT_TOKEN"},
}, func(c *config.Config) {
c.Bot.Enabled = true
c.Bot.Weixin.Enabled = true
c.Bot.Weixin.AccountID = result.AccountID
c.Bot.Weixin.APIBase = result.BaseURL
if c.Bot.Weixin.TokenEnv == "" {
c.Bot.Weixin.TokenEnv = "WEIXIN_BOT_TOKEN"
}
c.Bot.Allowlist.WeixinUsers = appendUniqueBotString(c.Bot.Allowlist.WeixinUsers, result.UserID)
})
if err != nil {
return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
}
a.refreshBotRuntimeAsync()
return BotInstallPollResult{Done: true, Status: "connected", Connection: conn, Message: "微信已连接。"}, nil
}
return a.pollFeishuConnectionInstall(installID, session)
}
func (a *App) DiagnoseBotConnection(id string) (BotConnectionDiagnostic, error) {
cfg, err := a.loadDesktopBotConfig()
if err != nil {
return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil
}
for _, conn := range cfg.Bot.Connections {
if conn.ID == id {
status := "ok"
message := "连接配置已保存。"
phase := "config"
code := "config_ok"
reportable := false
if !conn.Enabled {
status = "disabled"
message = "连接已保存但未启用。"
code = "connection_disabled"
} else if conn.Status != "connected" {
status = firstNonEmptyBot(conn.Status, "pending")
message = firstNonEmptyBot(conn.LastError, "连接还未完成。")
phase = "install"
code = "connection_not_connected"
reportable = status == "error" || strings.TrimSpace(conn.LastError) != ""
} else if conn.Credential.AppSecretEnv != "" && strings.TrimSpace(conn.Credential.AppSecretEnv) != "" && !envIsSet(conn.Credential.AppSecretEnv) {
status = "warning"
message = conn.Credential.AppSecretEnv + " 未设置。"
phase = "credential"
code = "secret_missing"
reportable = true
} else if conn.Credential.TokenEnv != "" && strings.TrimSpace(conn.Credential.TokenEnv) != "" && !botCredentialSecretSet(conn) {
status = "warning"
message = conn.Credential.TokenEnv + " 未设置,且未找到已保存的登录凭据。"
phase = "credential"
code = "secret_missing"
reportable = true
} else if conn.Provider == "weixin" && !botCredentialSecretSet(conn) {
status = "warning"
message = "未找到已保存的微信登录凭据。"
phase = "credential"
code = "secret_missing"
reportable = true
}
return botConnectionDiagnostic(&conn, conn.ID, status, phase, code, message, reportable), nil
}
}
return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil
}
func (a *App) TestBotConnection(id, target string) (BotConnectionDiagnostic, error) {
cfg, err := a.loadDesktopBotConfig()
if err != nil {
return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil
}
var conn *config.BotConnectionConfig
for i := range cfg.Bot.Connections {
if cfg.Bot.Connections[i].ID == strings.TrimSpace(id) {
conn = &cfg.Bot.Connections[i]
break
}
}
if conn == nil {
return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil
}
target = firstNonEmptyBot(strings.TrimSpace(target), firstSessionRemoteID(conn.SessionMappings))
if conn.Provider != "feishu" && conn.Provider != "weixin" {
return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_send_unsupported", "当前渠道暂不支持桌面端主动发送测试消息,可使用诊断检查基础配置。", false), nil
}
if target == "" {
return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_target_missing", "请输入测试会话 ID 后再发送测试消息。", false), nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var result bot.SendResult
switch conn.Provider {
case "feishu":
feishuCfg := cfg.Bot.Feishu
feishuCfg.Enabled = true
feishuCfg.Domain = firstNonEmptyBot(conn.Domain, feishuCfg.Domain)
feishuCfg.AppID = firstNonEmptyBot(conn.Credential.AppID, feishuCfg.AppID)
feishuCfg.AppSecretEnv = firstNonEmptyBot(conn.Credential.AppSecretEnv, feishuCfg.AppSecretEnv)
result, err = feishu.SendText(ctx, feishuCfg, target, "Reasonix bot 测试消息:连接和发送链路可用。")
case "weixin":
weixinCfg := cfg.Bot.Weixin
weixinCfg.Enabled = true
weixinCfg.AccountID = firstNonEmptyBot(conn.Credential.AccountID, weixinCfg.AccountID)
weixinCfg.TokenEnv = firstNonEmptyBot(conn.Credential.TokenEnv, weixinCfg.TokenEnv)
result, err = weixin.SendText(ctx, weixinCfg, target, "Reasonix bot 测试消息:连接和发送链路可用。")
}
if err != nil {
return botConnectionDiagnostic(conn, conn.ID, "error", "send", "test_send_failed", err.Error(), true), nil
}
_ = a.rememberBotConnectionRemote(conn.ID, target)
msg := "测试消息已发送。"
if result.MessageID != "" {
msg += " Message ID: " + result.MessageID
}
diag := botConnectionDiagnostic(conn, conn.ID, "ok", "send", "test_send_ok", msg, false)
diag.MessageID = result.MessageID
return diag, nil
}
func botConnectionDiagnostic(conn *config.BotConnectionConfig, id, status, phase, code, message string, reportable bool) BotConnectionDiagnostic {
id = strings.TrimSpace(id)
label := ""
if conn != nil {
id = firstNonEmptyBot(strings.TrimSpace(conn.ID), id)
label = strings.TrimSpace(conn.Label)
}
occurredAt := time.Now().UTC().Format(time.RFC3339)
diag := BotConnectionDiagnostic{
ID: id,
Label: label,
Status: strings.TrimSpace(status),
Message: strings.TrimSpace(message),
Phase: strings.TrimSpace(phase),
Code: strings.TrimSpace(code),
OccurredAt: occurredAt,
}
if reportable {
diag.ReportKind = "bot"
diag.ReportDetail = botConnectionReportDetail(conn, id, diag.Status, diag.Phase, diag.Code, diag.Message, occurredAt)
if diag.ReportDetail == "" {
diag.ReportKind = ""
}
}
return diag
}
func botConnectionReportDetail(conn *config.BotConnectionConfig, fallbackID, status, phase, code, message, occurredAt string) string {
provider := "unknown"
domain := "unknown"
configuredStatus := ""
enabled := false
workspaceScope := "global"
sessionMappings := 0
appIDSet := false
appSecretEnvConfigured := false
tokenEnvConfigured := false
secretAvailable := false
if conn != nil {
provider = firstNonEmptyBot(strings.TrimSpace(conn.Provider), provider)
domain = firstNonEmptyBot(strings.TrimSpace(conn.Domain), domain)
configuredStatus = strings.TrimSpace(conn.Status)
enabled = conn.Enabled
if strings.TrimSpace(conn.WorkspaceRoot) != "" {
workspaceScope = "project"
}
sessionMappings = len(conn.SessionMappings)
appIDSet = strings.TrimSpace(conn.Credential.AppID) != ""
appSecretEnvConfigured = strings.TrimSpace(conn.Credential.AppSecretEnv) != ""
tokenEnvConfigured = strings.TrimSpace(conn.Credential.TokenEnv) != ""
secretAvailable = botCredentialSecretSet(*conn)
}
summary := botConnectionReportSummary(code, message)
lines := []string{
"Bot connection diagnostic",
"",
"connection_id: " + safeBotReportValue(fallbackID),
"provider: " + safeBotReportValue(provider),
"domain: " + safeBotReportValue(domain),
"status: " + safeBotReportValue(status),
"phase: " + safeBotReportValue(phase),
"code: " + safeBotReportValue(code),
fmt.Sprintf("enabled: %t", enabled),
"configured_status: " + safeBotReportValue(configuredStatus),
fmt.Sprintf("app_id_set: %t", appIDSet),
fmt.Sprintf("app_secret_env_configured: %t", appSecretEnvConfigured),
fmt.Sprintf("token_env_configured: %t", tokenEnvConfigured),
fmt.Sprintf("secret_available: %t", secretAvailable),
"workspace_scope: " + workspaceScope,
fmt.Sprintf("session_mappings: %d", sessionMappings),
"",
"summary: " + summary,
}
payload := frontendCrashPayload{
SchemaVersion: 2,
Kind: "bot",
Source: "bot.runtime",
Label: botConnectionReportLabel(provider, domain, phase),
Message: strings.Join(lines, "\n"),
ErrorType: "BotConnectionDiagnostic",
ErrorMessage: summary,
TopFrame: "bot." + safeBotReportSegment(phase),
OccurredAt: occurredAt,
}
detail, err := json.Marshal(payload)
if err != nil {
return ""
}
return string(detail)
}
func botConnectionReportSummary(code, message string) string {
switch strings.TrimSpace(code) {
case "config_load_failed":
return "desktop bot config could not be loaded: " + scrubSensitiveText(message)
case "connection_missing":
return "bot connection record was not found"
case "connection_not_connected":
return "bot connection is not connected: " + scrubSensitiveText(message)
case "secret_missing":
return "required bot credential is not available"
case "test_send_failed":
return "bot test message failed: " + scrubSensitiveText(message)
default:
if strings.TrimSpace(message) == "" {
return strings.TrimSpace(code)
}
return scrubSensitiveText(message)
}
}
func botConnectionReportLabel(provider, domain, phase string) string {
parts := []string{"bot", safeBotReportSegment(provider), safeBotReportSegment(domain), safeBotReportSegment(phase)}
return strings.Trim(strings.Join(parts, "."), ".")
}
func safeBotReportSegment(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return "unknown"
}
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
b.WriteRune(r)
continue
}
if b.Len() == 0 || strings.HasSuffix(b.String(), ".") {
continue
}
b.WriteByte('.')
}
out := strings.Trim(b.String(), ".")
if out == "" {
return "unknown"
}
return out
}
func safeBotReportValue(s string) string {
s = safeBotReportSegment(s)
if len(s) > 80 {
return s[:80]
}
return s
}
func (a *App) startFeishuConnectionInstall(domain string) (BotInstallStartResult, error) {
// The official registration SDK always begins on the Feishu accounts domain.
// Lark tenants are detected from the first poll response, then polling moves
// to the Lark accounts domain for the final credential exchange.
beginDomain := "feishu"
data, err := postFeishuInstallForm(feishuAccountsBase(beginDomain), map[string]string{
"action": "begin", "archetype": "PersonalAgent", "auth_method": "client_secret", "request_user_info": "open_id",
})
if err != nil {
return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil
}
deviceCode := stringValue(data["device_code"])
verifyURL := stringValue(data["verification_uri_complete"])
userCode := stringValue(data["user_code"])
if deviceCode == "" || verifyURL == "" {
return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: "飞书/Lark 授权响应缺少 device_code 或二维码 URL。"}, nil
}
qrURL, err := feishuRegistrationQRCodeURL(verifyURL)
if err != nil {
return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil
}
installID := randomInstallID()
interval := intValue(data["interval"], 5)
expireIn := intValue(firstAny(data["expire_in"], data["expires_in"]), 300)
a.mu.Lock()
if a.botInstalls == nil {
a.botInstalls = map[string]*botInstallSession{}
}
a.botInstalls[installID] = &botInstallSession{
Provider: "feishu", Domain: domain, PollDomain: beginDomain, DeviceCode: deviceCode, UserCode: userCode,
StartedAt: time.Now(), ExpireAt: time.Now().Add(time.Duration(expireIn) * time.Second),
}
a.mu.Unlock()
return BotInstallStartResult{OK: true, Provider: "feishu", Domain: domain, InstallID: installID, URL: qrURL, DeviceCode: deviceCode, UserCode: userCode, Interval: interval, ExpireIn: expireIn}, nil
}
func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallSession) (BotInstallPollResult, error) {
pollDomain := firstNonEmptyBot(session.PollDomain, session.Domain, "feishu")
data, statusCode, err := postFeishuInstallFormResult(feishuAccountsBase(pollDomain), map[string]string{"action": "poll", "device_code": session.DeviceCode})
if err != nil {
return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
}
if errText := stringValue(data["error"]); errText != "" {
if errText == "authorization_pending" || errText == "slow_down" {
return BotInstallPollResult{Status: "pending", Message: "等待扫码授权。"}, nil
}
a.deleteBotInstall(installID)
return BotInstallPollResult{Status: "error", Error: firstNonEmptyBot(stringValue(data["error_description"]), errText)}, nil
}
if statusCode >= 400 {
a.deleteBotInstall(installID)
return BotInstallPollResult{Status: "error", Error: fmt.Sprintf("HTTP %d", statusCode)}, nil
}
if feishuInstallDomain(session.Domain, data) == "lark" && pollDomain != "lark" {
a.mu.Lock()
if current := a.botInstalls[installID]; current != nil {
current.PollDomain = "lark"
}
a.mu.Unlock()
return BotInstallPollResult{Status: "pending", Message: "已识别为 Lark 授权,继续等待授权完成。"}, nil
}
appID := stringValue(data["client_id"])
appSecret := stringValue(data["client_secret"])
if appID == "" || appSecret == "" {
return BotInstallPollResult{Status: "pending", Message: "等待授权完成。"}, nil
}
a.deleteBotInstall(installID)
domain := feishuInstallDomain(firstNonEmptyBot(pollDomain, session.Domain), data)
userID := feishuInstallUserID(data)
secretEnv := "FEISHU_BOT_APP_SECRET"
if domain == "lark" {
secretEnv = "LARK_BOT_APP_SECRET"
}
if err := upsertDotEnv(secretEnv, appSecret); err != nil {
return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
}
label := "飞书"
if domain == "lark" {
label = "Lark"
}
conn, err := a.upsertBotConnection(config.BotConnectionConfig{
ID: connectionID("feishu", domain),
Provider: "feishu",
Domain: domain,
Label: label,
Enabled: true,
Status: "connected",
Access: botInstallAccess(userID),
Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv},
}, func(c *config.Config) {
c.Bot.Enabled = true
c.Bot.Feishu.Enabled = true
c.Bot.Feishu.Domain = domain
c.Bot.Feishu.AppID = appID
c.Bot.Feishu.AppSecretEnv = secretEnv
c.Bot.Feishu.Mode = "websocket"
c.Bot.Feishu.RequireMention = true
c.Bot.Allowlist.FeishuUsers = appendUniqueBotString(c.Bot.Allowlist.FeishuUsers, userID)
})
if err != nil {
return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
}
a.refreshBotRuntimeAsync()
return BotInstallPollResult{Done: true, Status: "connected", Connection: conn, Message: label + " 已连接。"}, nil
}
func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy func(*config.Config)) (BotConnectionView, error) {
now := time.Now().UTC().Format(time.RFC3339)
if conn.CreatedAt == "" {
conn.CreatedAt = now
}
conn.UpdatedAt = now
if conn.Status == "" {
conn.Status = "connected"
}
if normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode) == "" {
conn.ToolApprovalMode = "ask"
}
if conn.ID == "" {
conn.ID = connectionID(conn.Provider, conn.Domain)
}
err := a.applyConfigOnly(func(c *config.Config) error {
if updateLegacy != nil {
updateLegacy(c)
}
replaced := false
for i, existing := range c.Bot.Connections {
if existing.ID == conn.ID {
conn.CreatedAt = firstNonEmptyBot(existing.CreatedAt, conn.CreatedAt)
if !botruntime.BotAccessActive(conn.Access) && botruntime.BotAccessActive(existing.Access) {
conn.Access = existing.Access
}
c.Bot.Connections[i] = conn
replaced = true
break
}
}
if !replaced {
c.Bot.Connections = append(c.Bot.Connections, conn)
}
return nil
})
return botConnectionView(conn), err
}
func (a *App) rememberBotConnectionRemote(id, remoteID string) error {
id = strings.TrimSpace(id)
remoteID = strings.TrimSpace(remoteID)
if id == "" || remoteID == "" {
return nil
}
now := time.Now().UTC().Format(time.RFC3339)
return a.applyConfigOnly(func(c *config.Config) error {
for i := range c.Bot.Connections {
if c.Bot.Connections[i].ID != id {
continue
}
for j := range c.Bot.Connections[i].SessionMappings {
if c.Bot.Connections[i].SessionMappings[j].RemoteID == remoteID {
workspaceRoot := firstNonEmptyBot(c.Bot.Connections[i].SessionMappings[j].WorkspaceRoot, c.Bot.Connections[i].WorkspaceRoot)
scope := botMappingScope(c.Bot.Connections[i].SessionMappings[j].Scope, workspaceRoot)
c.Bot.Connections[i].SessionMappings[j].Scope = scope
c.Bot.Connections[i].SessionMappings[j].WorkspaceRoot = botMappingWorkspaceRoot(scope, workspaceRoot)
c.Bot.Connections[i].SessionMappings[j].UpdatedAt = now
c.Bot.Connections[i].UpdatedAt = now
return nil
}
}
scope := botMappingScope("", c.Bot.Connections[i].WorkspaceRoot)
c.Bot.Connections[i].SessionMappings = append(c.Bot.Connections[i].SessionMappings, config.BotConnectionSessionMapping{
RemoteID: remoteID,
SessionID: "",
Scope: scope,
WorkspaceRoot: botMappingWorkspaceRoot(scope, c.Bot.Connections[i].WorkspaceRoot),
UpdatedAt: now,
})
c.Bot.Connections[i].UpdatedAt = now
return nil
}
return nil
})
}
func firstSessionRemoteID(mappings []config.BotConnectionSessionMapping) string {
for _, mapping := range mappings {
if strings.TrimSpace(mapping.RemoteID) != "" {
return strings.TrimSpace(mapping.RemoteID)
}
}
return ""
}
func (a *App) deleteBotInstall(installID string) {
a.mu.Lock()
delete(a.botInstalls, installID)
a.mu.Unlock()
}
func normalizeBotInstallTarget(provider, domain string) (string, string) {
provider = strings.ToLower(strings.TrimSpace(provider))
domain = strings.ToLower(strings.TrimSpace(domain))
if provider == "lark" {
provider = "feishu"
domain = "lark"
}
if provider == "weixin" || provider == "wechat" {
return "weixin", "weixin"
}
if domain != "lark" {
domain = "feishu"
}
return "feishu", domain
}
func feishuAccountsBase(domain string) string {
if domain == "lark" {
return "https://accounts.larksuite.com"
}
return "https://accounts.feishu.cn"
}
func feishuRegistrationQRCodeURL(rawURL string) (string, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return "", err
}
query := parsedURL.Query()
query.Set("from", "sdk")
query.Set("tp", "sdk")
query.Set("source", "go-sdk")
parsedURL.RawQuery = query.Encode()
return parsedURL.String(), nil
}
func postFeishuInstallForm(base string, body map[string]string) (map[string]any, error) {
data, status, err := postFeishuInstallFormResult(base, body)
if err != nil {
return nil, err
}
if status >= 400 {
return nil, fmt.Errorf("HTTP %d: %s", status, firstNonEmptyBot(stringValue(data["error_description"]), stringValue(data["message"])))
}
return data, nil
}
func postFeishuInstallFormResult(base string, body map[string]string) (map[string]any, int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
reqBody := url.Values{}
for k, v := range body {
reqBody.Set(k, v)
}
req, err := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(base, "/")+"/oauth/v1/app/registration", strings.NewReader(reqBody.Encode()))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, resp.StatusCode, err
}
return out, resp.StatusCode, nil
}
func botConnectionView(conn config.BotConnectionConfig) BotConnectionView {
return BotConnectionView{
ID: conn.ID, Provider: conn.Provider, Domain: conn.Domain, Label: conn.Label, Enabled: conn.Enabled, Status: conn.Status,
Model: conn.Model, ToolApprovalMode: normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode), WorkspaceRoot: conn.WorkspaceRoot,
Access: botAccessViewFromConfig(conn.Access),
Credential: BotConnectionCredentialView{
AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv,
SecretSet: botCredentialSecretSet(conn),
},
SessionMappings: botSessionMappingViews(conn.SessionMappings, conn.WorkspaceRoot),
LastError: conn.LastError, CreatedAt: conn.CreatedAt, UpdatedAt: conn.UpdatedAt,
}
}
func botCredentialSecretSet(conn config.BotConnectionConfig) bool {
if conn.Credential.AppSecretEnv != "" {
return envIsSet(conn.Credential.AppSecretEnv)
}
if conn.Credential.TokenEnv != "" && envIsSet(conn.Credential.TokenEnv) {
return true
}
if conn.Provider == "weixin" {
return weixin.HasSavedAccount(conn.Credential.AccountID)
}
return false
}
func feishuInstallDomain(fallback string, data map[string]any) string {
if userInfo, ok := data["user_info"].(map[string]any); ok {
if strings.EqualFold(stringValue(userInfo["tenant_brand"]), "lark") {
return "lark"
}
return "feishu"
}
if strings.EqualFold(fallback, "lark") {
return "lark"
}
return "feishu"
}
func feishuInstallUserID(data map[string]any) string {
if userInfo, ok := data["user_info"].(map[string]any); ok {
return firstNonEmptyBot(
stringValue(userInfo["open_id"]),
stringValue(userInfo["union_id"]),
stringValue(userInfo["user_id"]),
)
}
return ""
}
func botConnectionViews(connections []config.BotConnectionConfig) []BotConnectionView {
if connections == nil {
return []BotConnectionView{}
}
out := make([]BotConnectionView, 0, len(connections))
for _, conn := range connections {
out = append(out, botConnectionView(conn))
}
return out
}
func botConnectionConfig(view BotConnectionView) config.BotConnectionConfig {
return config.BotConnectionConfig{
ID: strings.TrimSpace(view.ID),
Provider: strings.TrimSpace(view.Provider),
Domain: strings.TrimSpace(view.Domain),
Label: strings.TrimSpace(view.Label),
Enabled: view.Enabled,
Status: strings.TrimSpace(view.Status),
Model: strings.TrimSpace(view.Model),
ToolApprovalMode: firstNonEmptyBot(normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode), "ask"),
WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot),
Access: botAccessConfigFromView(view.Access),
Credential: config.BotConnectionCredential{
AppID: strings.TrimSpace(view.Credential.AppID),
AppSecretEnv: strings.TrimSpace(view.Credential.AppSecretEnv),
AccountID: strings.TrimSpace(view.Credential.AccountID),
TokenEnv: strings.TrimSpace(view.Credential.TokenEnv),
},
SessionMappings: botSessionMappingConfigs(view.SessionMappings, view.WorkspaceRoot),
LastError: strings.TrimSpace(view.LastError),
CreatedAt: strings.TrimSpace(view.CreatedAt),
UpdatedAt: strings.TrimSpace(view.UpdatedAt),
}
}
func normalizeBotConnectionToolApprovalMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "ask":
return "ask"
case "auto":
return "auto"
case "yolo", "full", "full-access", "bypass":
return "yolo"
default:
return ""
}
}
func botConnectionConfigs(views []BotConnectionView) []config.BotConnectionConfig {
if views == nil {
return nil
}
out := make([]config.BotConnectionConfig, 0, len(views))
for _, view := range views {
cfg := botConnectionConfig(view)
if cfg.ID == "" || cfg.Provider == "" {
continue
}
out = append(out, cfg)
}
return out
}
func botMappingScope(scope, workspaceRoot string) string {
if strings.TrimSpace(scope) == "project" {
return "project"
}
if strings.TrimSpace(workspaceRoot) != "" {
return "project"
}
return "global"
}
func botMappingWorkspaceRoot(scope, workspaceRoot string) string {
if botMappingScope(scope, workspaceRoot) != "project" {
return ""
}
return strings.TrimSpace(workspaceRoot)
}
func botSessionMappingViews(mappings []config.BotConnectionSessionMapping, connectionWorkspaceRoot string) []BotConnectionSessionMappingView {
if mappings == nil {
return []BotConnectionSessionMappingView{}
}
out := make([]BotConnectionSessionMappingView, 0, len(mappings))
for _, m := range mappings {
workspaceRoot := firstNonEmptyBot(m.WorkspaceRoot, connectionWorkspaceRoot)
scope := botMappingScope(m.Scope, workspaceRoot)
out = append(out, BotConnectionSessionMappingView{
RemoteID: m.RemoteID,
SessionID: m.SessionID,
SessionSource: m.SessionSource,
ChatType: m.ChatType,
UserID: m.UserID,
ThreadID: m.ThreadID,
Scope: scope,
WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot),
UpdatedAt: m.UpdatedAt,
})
}
return out
}
func botSessionMappingConfigs(mappings []BotConnectionSessionMappingView, connectionWorkspaceRoot string) []config.BotConnectionSessionMapping {
if mappings == nil {
return nil
}
out := make([]config.BotConnectionSessionMapping, 0, len(mappings))
for _, m := range mappings {
workspaceRoot := firstNonEmptyBot(m.WorkspaceRoot, connectionWorkspaceRoot)
scope := botMappingScope(m.Scope, workspaceRoot)
out = append(out, config.BotConnectionSessionMapping{
RemoteID: strings.TrimSpace(m.RemoteID),
SessionID: strings.TrimSpace(m.SessionID),
SessionSource: strings.TrimSpace(m.SessionSource),
ChatType: strings.TrimSpace(m.ChatType),
UserID: strings.TrimSpace(m.UserID),
ThreadID: strings.TrimSpace(m.ThreadID),
Scope: scope,
WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot),
UpdatedAt: strings.TrimSpace(m.UpdatedAt),
})
}
return out
}
func connectionID(provider, domain string) string {
return strings.Trim(strings.ToLower(provider+"-"+domain), "-")
}
func botInstallAccess(userID string) config.BotAccessConfig {
userID = strings.TrimSpace(userID)
access := config.BotAccessConfig{Enabled: true, PairingEnabled: true}
if userID != "" {
access.Users = []string{userID}
}
return access
}
func randomInstallID() string {
var b [12]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("install-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
func envIsSet(name string) bool {
return strings.TrimSpace(name) != "" && strings.TrimSpace(os.Getenv(name)) != ""
}
func firstAny(values ...any) any {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}
func firstNonEmptyBot(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func appendUniqueBotString(values []string, next string) []string {
next = strings.TrimSpace(next)
if next == "" {
return values
}
for _, value := range values {
if strings.TrimSpace(value) == next {
return values
}
}
return append(values, next)
}
func stringValue(value any) string {
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
func intValue(value any, fallback int) int {
switch v := value.(type) {
case float64:
if v > 0 {
return int(v)
}
case int:
if v > 0 {
return v
}
case string:
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 {
return n
}
}
return fallback
}
func weixinInstallStatusMessage(status string) string {
switch status {
case "scaned":
return "已扫码,请在微信里确认。"
case "scaned_but_redirect":
return "已扫码,正在切换微信授权节点。"
default:
return "等待扫码。"
}
}
+620
View File
@@ -0,0 +1,620 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"reasonix/internal/config"
)
func TestNormalizeBotInstallTarget(t *testing.T) {
cases := []struct {
provider string
domain string
wantProvider string
wantDomain string
}{
{provider: "lark", wantProvider: "feishu", wantDomain: "lark"},
{provider: "feishu", domain: "lark", wantProvider: "feishu", wantDomain: "lark"},
{provider: "wechat", wantProvider: "weixin", wantDomain: "weixin"},
{provider: "weixin", domain: "anything", wantProvider: "weixin", wantDomain: "weixin"},
{provider: "unknown", domain: "unknown", wantProvider: "feishu", wantDomain: "feishu"},
}
for _, tc := range cases {
gotProvider, gotDomain := normalizeBotInstallTarget(tc.provider, tc.domain)
if gotProvider != tc.wantProvider || gotDomain != tc.wantDomain {
t.Fatalf("normalizeBotInstallTarget(%q,%q) = %q,%q; want %q,%q", tc.provider, tc.domain, gotProvider, gotDomain, tc.wantProvider, tc.wantDomain)
}
}
}
func TestLarkInstallFollowsSDKDomainSwitchAndStoresSecret(t *testing.T) {
isolateDesktopUserDirs(t)
t.Cleanup(func() { _ = os.Unsetenv("LARK_BOT_APP_SECRET") })
pollCount := 0
var beginHost string
var pollHosts []string
var actions []string
withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/oauth/v1/app/registration" {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
switch r.Form.Get("action") {
case "begin":
beginHost = r.Header.Get("X-Test-Original-Host")
actions = append(actions, "begin")
if r.Form.Get("archetype") != "PersonalAgent" || r.Form.Get("auth_method") != "client_secret" {
http.Error(w, "wrong begin form", http.StatusBadRequest)
return
}
writeJSON(t, w, map[string]any{
"device_code": "dev-lark",
"verification_uri_complete": "https://open.feishu.cn/page/launcher?user_code=CODE",
"user_code": "CODE",
"interval": 3,
"expire_in": 300,
})
case "poll":
pollHosts = append(pollHosts, r.Header.Get("X-Test-Original-Host"))
actions = append(actions, "poll")
if r.Form.Get("device_code") != "dev-lark" {
http.Error(w, "wrong device code", http.StatusBadRequest)
return
}
pollCount++
if pollCount == 1 {
writeJSON(t, w, map[string]any{"user_info": map[string]any{"tenant_brand": "lark"}})
return
}
writeJSON(t, w, map[string]any{
"client_id": "cli-1",
"client_secret": "secret-1",
"user_info": map[string]any{"tenant_brand": "lark", "open_id": "ou-installer"},
})
default:
http.Error(w, "unknown action", http.StatusBadRequest)
}
}))
app := NewApp()
start, err := app.StartBotConnectionInstall("lark", "")
if err != nil {
t.Fatalf("StartBotConnectionInstall: %v", err)
}
if !start.OK || start.Domain != "lark" || start.InstallID == "" || start.URL == "" || start.DeviceCode != "dev-lark" {
t.Fatalf("start result = %+v, want ok lark-capable QR result", start)
}
qrURL, err := url.Parse(start.URL)
if err != nil {
t.Fatalf("start URL = %q, want valid QR URL: %v", start.URL, err)
}
query := qrURL.Query()
if query.Get("user_code") != "CODE" || query.Get("from") != "sdk" || query.Get("tp") != "sdk" || query.Get("source") != "go-sdk" {
t.Fatalf("start URL query = %v, want SDK registration QR metadata with user_code", query)
}
if qrURL.Host != "open.feishu.cn" {
t.Fatalf("start URL host = %q, want SDK Feishu launcher host", qrURL.Host)
}
pending, err := app.PollBotConnectionInstall(start.InstallID)
if err != nil {
t.Fatalf("PollBotConnectionInstall pending: %v", err)
}
if pending.Done || pending.Status != "pending" {
t.Fatalf("pending poll result = %+v, want pending domain switch", pending)
}
poll, err := app.PollBotConnectionInstall(start.InstallID)
if err != nil {
t.Fatalf("PollBotConnectionInstall: %v", err)
}
if !poll.Done {
t.Fatalf("poll result = %+v, want done", poll)
}
if poll.Connection.Provider != "feishu" || poll.Connection.Domain != "lark" || poll.Connection.ID != "feishu-lark" {
t.Fatalf("connection = %+v, want feishu-lark from tenant_brand", poll.Connection)
}
if beginHost != "accounts.feishu.cn" {
t.Fatalf("begin host = %q, want SDK Feishu accounts host", beginHost)
}
if got := strings.Join(pollHosts, ","); got != "accounts.feishu.cn,accounts.larksuite.com" {
t.Fatalf("poll hosts = %q, want Feishu poll then Lark poll", got)
}
if got := strings.Join(actions, ","); got != "begin,poll,poll" {
t.Fatalf("registration actions = %q, want SDK begin, domain switch, final poll", got)
}
if poll.Connection.WorkspaceRoot != "" {
t.Fatalf("connection workspaceRoot = %q, want empty global default", poll.Connection.WorkspaceRoot)
}
if poll.Connection.Credential.AppID != "cli-1" || poll.Connection.Credential.AppSecretEnv != "LARK_BOT_APP_SECRET" || !poll.Connection.Credential.SecretSet {
t.Fatalf("credential = %+v, want stored Lark secret", poll.Connection.Credential)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if !cfg.Bot.Enabled || !cfg.Bot.Feishu.Enabled || cfg.Bot.Feishu.Domain != "lark" || cfg.Bot.Feishu.Mode != "websocket" || !cfg.Bot.Feishu.RequireMention {
t.Fatalf("saved feishu config = %+v, want enabled websocket lark with mention gating", cfg.Bot.Feishu)
}
if len(cfg.Bot.Allowlist.FeishuUsers) != 1 || cfg.Bot.Allowlist.FeishuUsers[0] != "ou-installer" {
t.Fatalf("feishu allowlist = %+v, want installer open_id", cfg.Bot.Allowlist.FeishuUsers)
}
if err := os.Unsetenv("LARK_BOT_APP_SECRET"); err != nil {
t.Fatalf("unset lark secret env: %v", err)
}
reloaded, err := config.Load()
if err != nil {
t.Fatalf("reload config: %v", err)
}
if got := os.Getenv("LARK_BOT_APP_SECRET"); got != "secret-1" {
t.Fatalf("reloaded LARK_BOT_APP_SECRET = %q, want persisted secret", got)
}
if len(reloaded.Bot.Connections) != 1 || !botConnectionView(reloaded.Bot.Connections[0]).Credential.SecretSet {
t.Fatalf("reloaded connections = %+v, want secret to survive restart", reloaded.Bot.Connections)
}
}
func TestFeishuInstallSwitchesToLarkDomainWhenTenantBrandIsLark(t *testing.T) {
isolateDesktopUserDirs(t)
t.Cleanup(func() { _ = os.Unsetenv("LARK_BOT_APP_SECRET") })
pollCount := 0
var beginHost string
var pollHosts []string
withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/oauth/v1/app/registration" {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
switch r.Form.Get("action") {
case "begin":
beginHost = r.Header.Get("X-Test-Original-Host")
writeJSON(t, w, map[string]any{
"device_code": "dev-feishu",
"verification_uri_complete": "https://accounts.example/verify?user_code=CODE",
"user_code": "CODE",
"interval": 3,
"expire_in": 300,
})
case "poll":
pollHosts = append(pollHosts, r.Header.Get("X-Test-Original-Host"))
if r.Form.Get("device_code") != "dev-feishu" {
http.Error(w, "wrong device code", http.StatusBadRequest)
return
}
pollCount++
if pollCount == 1 {
writeJSON(t, w, map[string]any{"user_info": map[string]any{"tenant_brand": "lark"}})
return
}
writeJSON(t, w, map[string]any{
"client_id": "cli-lark",
"client_secret": "secret-lark",
"user_info": map[string]any{"tenant_brand": "lark", "open_id": "ou-lark-installer"},
})
default:
http.Error(w, "unknown action", http.StatusBadRequest)
}
}))
app := NewApp()
start, err := app.StartBotConnectionInstall("feishu", "")
if err != nil {
t.Fatalf("StartBotConnectionInstall: %v", err)
}
if !start.OK || start.Domain != "feishu" || start.DeviceCode != "dev-feishu" {
t.Fatalf("start result = %+v, want Feishu QR result", start)
}
pending, err := app.PollBotConnectionInstall(start.InstallID)
if err != nil {
t.Fatalf("PollBotConnectionInstall pending: %v", err)
}
if pending.Done || pending.Status != "pending" {
t.Fatalf("pending poll result = %+v, want pending domain switch", pending)
}
poll, err := app.PollBotConnectionInstall(start.InstallID)
if err != nil {
t.Fatalf("PollBotConnectionInstall: %v", err)
}
if !poll.Done || poll.Connection.Domain != "lark" || poll.Connection.Credential.AppSecretEnv != "LARK_BOT_APP_SECRET" {
t.Fatalf("poll result = %+v, want stored Lark connection after domain switch", poll)
}
if beginHost != "accounts.feishu.cn" {
t.Fatalf("begin host = %q, want Feishu accounts host", beginHost)
}
if got := strings.Join(pollHosts, ","); got != "accounts.feishu.cn,accounts.larksuite.com" {
t.Fatalf("poll hosts = %q, want Feishu poll then Lark poll", got)
}
}
func TestFeishuInstallStoresFeishuSecretAndSurvivesReload(t *testing.T) {
isolateDesktopUserDirs(t)
t.Cleanup(func() { _ = os.Unsetenv("FEISHU_BOT_APP_SECRET") })
var hosts []string
withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/oauth/v1/app/registration" {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
hosts = append(hosts, r.Form.Get("action")+":"+r.Header.Get("X-Test-Original-Host"))
switch r.Form.Get("action") {
case "begin":
writeJSON(t, w, map[string]any{
"device_code": "dev-feishu",
"verification_uri_complete": "https://accounts.example/verify?user_code=CODE",
"user_code": "CODE",
"interval": 3,
"expire_in": 300,
})
case "poll":
writeJSON(t, w, map[string]any{
"client_id": "cli-feishu",
"client_secret": "secret-feishu",
"user_info": map[string]any{"tenant_brand": "feishu", "open_id": "ou-feishu-installer"},
})
default:
http.Error(w, "unknown action", http.StatusBadRequest)
}
}))
app := NewApp()
start, err := app.StartBotConnectionInstall("feishu", "")
if err != nil {
t.Fatalf("StartBotConnectionInstall: %v", err)
}
if !start.OK || start.Domain != "feishu" || start.InstallID == "" {
t.Fatalf("start result = %+v, want ok Feishu QR result", start)
}
poll, err := app.PollBotConnectionInstall(start.InstallID)
if err != nil {
t.Fatalf("PollBotConnectionInstall: %v", err)
}
if !poll.Done {
t.Fatalf("poll result = %+v, want done", poll)
}
if poll.Connection.Provider != "feishu" || poll.Connection.Domain != "feishu" || poll.Connection.ID != "feishu-feishu" {
t.Fatalf("connection = %+v, want feishu-feishu", poll.Connection)
}
if poll.Connection.Credential.AppID != "cli-feishu" || poll.Connection.Credential.AppSecretEnv != "FEISHU_BOT_APP_SECRET" || !poll.Connection.Credential.SecretSet {
t.Fatalf("credential = %+v, want stored Feishu secret", poll.Connection.Credential)
}
if got := strings.Join(hosts, ","); got != "begin:accounts.feishu.cn,poll:accounts.feishu.cn" {
t.Fatalf("registration hosts = %q, want Feishu begin and poll", got)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if !cfg.Bot.Enabled || !cfg.Bot.Feishu.Enabled || cfg.Bot.Feishu.Domain != "feishu" || cfg.Bot.Feishu.AppID != "cli-feishu" {
t.Fatalf("saved feishu config = %+v, want enabled Feishu websocket config", cfg.Bot.Feishu)
}
if len(cfg.Bot.Allowlist.FeishuUsers) != 1 || cfg.Bot.Allowlist.FeishuUsers[0] != "ou-feishu-installer" {
t.Fatalf("feishu allowlist = %+v, want installer open_id", cfg.Bot.Allowlist.FeishuUsers)
}
if err := os.Unsetenv("FEISHU_BOT_APP_SECRET"); err != nil {
t.Fatalf("unset feishu secret env: %v", err)
}
reloaded, err := config.Load()
if err != nil {
t.Fatalf("reload config: %v", err)
}
if got := os.Getenv("FEISHU_BOT_APP_SECRET"); got != "secret-feishu" {
t.Fatalf("reloaded FEISHU_BOT_APP_SECRET = %q, want persisted secret", got)
}
if len(reloaded.Bot.Connections) != 1 || !botConnectionView(reloaded.Bot.Connections[0]).Credential.SecretSet {
t.Fatalf("reloaded connections = %+v, want secret to survive restart", reloaded.Bot.Connections)
}
}
func TestWeixinInstallStoresSavedAccountAndConnection(t *testing.T) {
isolateDesktopUserDirs(t)
withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/ilink/bot/get_bot_qrcode":
if r.URL.Query().Get("bot_type") != "3" {
http.Error(w, "missing bot type", http.StatusBadRequest)
return
}
writeJSON(t, w, map[string]any{
"qrcode": "qr-weixin",
"qrcode_img_content": "data:image/png;base64,abc",
})
case "/ilink/bot/get_qrcode_status":
if r.URL.Query().Get("qrcode") != "qr-weixin" {
http.Error(w, "wrong qr", http.StatusBadRequest)
return
}
writeJSON(t, w, map[string]any{
"status": "confirmed",
"ilink_bot_id": "weixin-account",
"bot_token": "token-1",
"ilink_user_id": "user-1",
"baseurl": "https://ilinkai.weixin.qq.com",
})
default:
http.NotFound(w, r)
}
}))
app := NewApp()
start, err := app.StartBotConnectionInstall("weixin", "")
if err != nil {
t.Fatalf("StartBotConnectionInstall: %v", err)
}
if !start.OK || start.Provider != "weixin" || start.Domain != "weixin" || start.URL != "data:image/png;base64,abc" || start.DeviceCode != "qr-weixin" {
t.Fatalf("start result = %+v, want weixin QR result", start)
}
poll, err := app.PollBotConnectionInstall(start.InstallID)
if err != nil {
t.Fatalf("PollBotConnectionInstall: %v", err)
}
if !poll.Done {
t.Fatalf("poll result = %+v, want done", poll)
}
if poll.Connection.Provider != "weixin" || poll.Connection.Domain != "weixin" || poll.Connection.Credential.AccountID != "weixin-account" {
t.Fatalf("connection = %+v, want weixin account connection", poll.Connection)
}
if poll.Connection.WorkspaceRoot != "" {
t.Fatalf("connection workspaceRoot = %q, want empty global default", poll.Connection.WorkspaceRoot)
}
if poll.Connection.Credential.TokenEnv != "WEIXIN_BOT_TOKEN" || !poll.Connection.Credential.SecretSet {
t.Fatalf("credential = %+v, want saved account to count as configured token", poll.Connection.Credential)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if !cfg.Bot.Enabled || !cfg.Bot.Weixin.Enabled || cfg.Bot.Weixin.AccountID != "weixin-account" || cfg.Bot.Weixin.TokenEnv != "WEIXIN_BOT_TOKEN" {
t.Fatalf("saved weixin config = %+v, want enabled saved account", cfg.Bot.Weixin)
}
if len(cfg.Bot.Allowlist.WeixinUsers) != 1 || cfg.Bot.Allowlist.WeixinUsers[0] != "user-1" {
t.Fatalf("weixin allowlist = %+v, want installer user id", cfg.Bot.Allowlist.WeixinUsers)
}
reloaded, err := config.Load()
if err != nil {
t.Fatalf("reload config: %v", err)
}
if len(reloaded.Bot.Connections) != 1 {
t.Fatalf("reloaded connections = %+v, want saved weixin connection", reloaded.Bot.Connections)
}
reloadedConnection := botConnectionView(reloaded.Bot.Connections[0])
if reloadedConnection.Credential.AccountID != "weixin-account" || !reloadedConnection.Credential.SecretSet {
t.Fatalf("reloaded credential = %+v, want saved weixin account to survive restart", reloadedConnection.Credential)
}
}
func TestFeishuRegistrationQRCodeURLAddsSDKMetadata(t *testing.T) {
qrURL, err := feishuRegistrationQRCodeURL("https://open.larksuite.com/page/launcher?user_code=ABCD-1234&source=old")
if err != nil {
t.Fatalf("feishuRegistrationQRCodeURL: %v", err)
}
parsed, err := url.Parse(qrURL)
if err != nil {
t.Fatalf("parse QR URL: %v", err)
}
query := parsed.Query()
if query.Get("user_code") != "ABCD-1234" {
t.Fatalf("user_code = %q, want preserved code", query.Get("user_code"))
}
if query.Get("from") != "sdk" || query.Get("tp") != "sdk" || query.Get("source") != "go-sdk" {
t.Fatalf("query = %v, want SDK registration metadata", query)
}
}
func TestDiagnoseBotConnectionBuildsReportDetailForMissingSecret(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("FEISHU_BOT_APP_SECRET_PRIVATE", "")
app := NewApp()
if _, err := app.upsertBotConnection(config.BotConnectionConfig{
ID: "feishu-lark",
Provider: "feishu",
Domain: "lark",
Label: "Lark",
Enabled: true,
Status: "connected",
WorkspaceRoot: "/Users/alice/work/reasonix",
Credential: config.BotConnectionCredential{
AppID: "cli-private",
AppSecretEnv: "FEISHU_BOT_APP_SECRET_PRIVATE",
},
SessionMappings: []config.BotConnectionSessionMapping{{
RemoteID: "ou-private",
SessionID: "session-private",
Scope: "project",
WorkspaceRoot: "/Users/alice/work/reasonix",
}},
}, nil); err != nil {
t.Fatalf("upsert connection: %v", err)
}
diag, err := app.DiagnoseBotConnection("feishu-lark")
if err != nil {
t.Fatalf("DiagnoseBotConnection: %v", err)
}
if diag.Status != "warning" || diag.Phase != "credential" || diag.Code != "secret_missing" || diag.ReportKind != "bot" || diag.ReportDetail == "" {
t.Fatalf("diagnostic = %+v, want warning credential report", diag)
}
for _, leaked := range []string{"FEISHU_BOT_APP_SECRET_PRIVATE", "/Users/alice", "ou-private", "session-private"} {
if strings.Contains(diag.ReportDetail, leaked) {
t.Fatalf("diagnostic report leaked %q in %s", leaked, diag.ReportDetail)
}
}
var payload frontendCrashPayload
if err := json.Unmarshal([]byte(diag.ReportDetail), &payload); err != nil {
t.Fatalf("report detail is not structured JSON: %v", err)
}
if payload.Kind != "bot" || payload.Source != "bot.runtime" || payload.Label != "bot.feishu.lark.credential" {
t.Fatalf("payload = %+v, want bot runtime credential label", payload)
}
for _, want := range []string{
"app_secret_env_configured: true",
"secret_available: false",
"workspace_scope: project",
"session_mappings: 1",
"summary: required bot credential is not available",
} {
if !strings.Contains(payload.Message, want) {
t.Fatalf("payload message = %q, want it to contain %q", payload.Message, want)
}
}
report, err := crashReportFromDetail(diag.ReportKind, diag.ReportDetail)
if err != nil {
t.Fatalf("crashReportFromDetail: %v", err)
}
if report.Kind != "bot" || report.Source != "bot.runtime" || report.ErrorType != "BotConnectionDiagnostic" {
t.Fatalf("report = %+v, want accepted bot report", report)
}
}
func TestBotConnectionSendFailureReportRedactsEnvNames(t *testing.T) {
conn := config.BotConnectionConfig{
ID: "feishu-lark",
Provider: "feishu",
Domain: "lark",
Label: "Lark",
Enabled: true,
Status: "connected",
Credential: config.BotConnectionCredential{
AppSecretEnv: "FEISHU_BOT_APP_SECRET_PRIVATE",
},
}
diag := botConnectionDiagnostic(&conn, conn.ID, "error", "send", "test_send_failed", "feishu app_id or FEISHU_BOT_APP_SECRET_PRIVATE is not configured", true)
if diag.ReportKind != "bot" || diag.ReportDetail == "" {
t.Fatalf("diagnostic = %+v, want reportable bot diagnostic", diag)
}
if strings.Contains(diag.ReportDetail, "FEISHU_BOT_APP_SECRET_PRIVATE") {
t.Fatalf("diagnostic report leaked env name in %s", diag.ReportDetail)
}
var payload frontendCrashPayload
if err := json.Unmarshal([]byte(diag.ReportDetail), &payload); err != nil {
t.Fatalf("report detail is not structured JSON: %v", err)
}
if !strings.Contains(payload.ErrorMessage, "[redacted-env]") {
t.Fatalf("payload errorMessage = %q, want redacted env marker", payload.ErrorMessage)
}
}
func TestDiagnoseWeixinConnectionDetectsMissingSavedAccountWithoutTokenEnv(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
if _, err := app.upsertBotConnection(config.BotConnectionConfig{
ID: "weixin-weixin",
Provider: "weixin",
Domain: "weixin",
Label: "微信",
Enabled: true,
Status: "connected",
Credential: config.BotConnectionCredential{
AccountID: "missing-account",
},
}, nil); err != nil {
t.Fatalf("upsert connection: %v", err)
}
diag, err := app.DiagnoseBotConnection("weixin-weixin")
if err != nil {
t.Fatalf("DiagnoseBotConnection: %v", err)
}
if diag.Status != "warning" || diag.Phase != "credential" || diag.Code != "secret_missing" || diag.ReportKind != "bot" || diag.ReportDetail == "" {
t.Fatalf("diagnostic = %+v, want missing local credential warning", diag)
}
if strings.Contains(diag.ReportDetail, "missing-account") {
t.Fatalf("diagnostic report leaked account id in %s", diag.ReportDetail)
}
}
func TestRememberBotConnectionRemoteStoresStableScope(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
if _, err := app.upsertBotConnection(config.BotConnectionConfig{
ID: "feishu-lark",
Provider: "feishu",
Domain: "lark",
Label: "kun",
Enabled: true,
Status: "connected",
}, nil); err != nil {
t.Fatalf("upsert global connection: %v", err)
}
if err := app.rememberBotConnectionRemote("feishu-lark", "ou_global"); err != nil {
t.Fatalf("remember global remote: %v", err)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if got := cfg.Bot.Connections[0].SessionMappings[0]; got.Scope != "global" || got.WorkspaceRoot != "" || got.RemoteID != "ou_global" {
t.Fatalf("global mapping = %+v, want scope=global without workspace", got)
}
if _, err := app.upsertBotConnection(config.BotConnectionConfig{
ID: "weixin-project",
Provider: "weixin",
Domain: "weixin",
Label: "project",
Enabled: true,
Status: "connected",
WorkspaceRoot: "/tmp/reasonix-project",
}, nil); err != nil {
t.Fatalf("upsert project connection: %v", err)
}
if err := app.rememberBotConnectionRemote("weixin-project", "wxid_project"); err != nil {
t.Fatalf("remember project remote: %v", err)
}
cfg = config.LoadForEdit(config.UserConfigPath())
var projectMapping config.BotConnectionSessionMapping
for _, conn := range cfg.Bot.Connections {
if conn.ID == "weixin-project" && len(conn.SessionMappings) == 1 {
projectMapping = conn.SessionMappings[0]
}
}
if projectMapping.Scope != "project" || projectMapping.WorkspaceRoot != "/tmp/reasonix-project" || projectMapping.RemoteID != "wxid_project" {
t.Fatalf("project mapping = %+v, want project scope and workspace", projectMapping)
}
}
func writeJSON(t *testing.T, w http.ResponseWriter, value any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(value); err != nil {
t.Fatalf("write json: %v", err)
}
}
func withRewrittenHTTP(t *testing.T, handler http.Handler) {
t.Helper()
server := httptest.NewServer(handler)
target, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
previous := http.DefaultTransport
http.DefaultTransport = rewriteHTTPTransport{target: target, next: previous}
t.Cleanup(func() {
http.DefaultTransport = previous
server.Close()
})
}
type rewriteHTTPTransport struct {
target *url.URL
next http.RoundTripper
}
func (r rewriteHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
clone := req.Clone(req.Context())
clone.Header.Set("X-Test-Original-Host", req.URL.Host)
clone.URL.Scheme = r.target.Scheme
clone.URL.Host = r.target.Host
clone.Host = r.target.Host
if r.next == nil {
r.next = http.DefaultTransport
}
clone.URL.Path = "/" + strings.TrimLeft(clone.URL.Path, "/")
return r.next.RoundTrip(clone)
}
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"context"
"log"
"strings"
"sync"
"time"
"reasonix/internal/bot"
"reasonix/internal/event"
)
const botForwardSendTimeout = 30 * time.Second
const botForwardQueueSize = 64
// ── Forward target ──────────────────────────────────────────────────────────
// botForwardTarget identifies one remote chat to send forwarded events to.
type botForwardTarget struct {
ConnID string
Domain string
ChatID string
ChatType bot.ChatType
}
// ── Event forwarder ─────────────────────────────────────────────────────────
// botEventForwarder implements event.Sink and forwards relevant events to
// connected bot channels through the desktopBotRuntime. It is attached to a
// tabEventSink when a heartbeat task should push AI output to IM channels.
//
// It accumulates Text events and sends them as complete messages on TurnDone
// (and occasionally during generation when the buffer grows large enough), so
// the remote side sees progressive streaming output rather than one big blob.
type botEventForwarder struct {
runtime *desktopBotRuntime
targets []botForwardTarget
mu sync.Mutex
buf strings.Builder
queueMu sync.Mutex
queue chan string
closed bool
closeOnce sync.Once
}
// newBotEventForwarder creates a forwarder that sends to all given targets.
// runtime may be nil — Emit calls are then no-ops.
func newBotEventForwarder(runtime *desktopBotRuntime, targets []botForwardTarget) *botEventForwarder {
f := &botEventForwarder{
runtime: runtime,
targets: targets,
queue: make(chan string, botForwardQueueSize),
}
go f.run()
return f
}
// Emit implements event.Sink. It forwards text and lifecycle events to the
// connected bot channels; reasoning, tool dispatch, and other internal events
// are dropped to avoid noisy IM output.
func (f *botEventForwarder) Emit(e event.Event) {
if f.runtime == nil || len(f.targets) == 0 {
return
}
switch e.Kind {
case event.TurnStarted:
f.mu.Lock()
f.buf.Reset()
f.mu.Unlock()
case event.Text:
f.mu.Lock()
f.buf.WriteString(e.Text)
size := f.buf.Len()
f.mu.Unlock()
// Flush opportunistically when the buffer crosses a threshold, so long
// streams (e.g. "tell me three jokes") produce multiple messages.
if size >= 400 {
f.flush()
}
case event.TurnDone:
f.flush()
f.Close()
case event.ApprovalRequest:
// The heartbeat turn belongs to the desktop tab controller, not the bot
// gateway session, so remote /approve replies cannot satisfy this ID.
text := "⚠️ 需要在 Reasonix 桌面端批准操作: " + e.Approval.Tool + " — " + e.Approval.Subject
text += "\n请回到桌面窗口处理。"
f.sendToAll(text)
case event.AskRequest:
var qb strings.Builder
qb.WriteString("❓ 需要在 Reasonix 桌面端回答问题:\n")
for i, q := range e.Ask.Questions {
if i > 0 {
qb.WriteString("\n")
}
qb.WriteString(q.Prompt)
}
qb.WriteString("\n请回到桌面窗口处理。")
f.sendToAll(qb.String())
case event.Notice:
if e.Level == event.LevelWarn {
f.sendToAll("⚠️ " + e.Text)
}
case event.CompactionStarted:
f.sendToAll("🔄 正在压缩上下文...")
}
}
// flush sends the accumulated buffer as one message per target channel.
func (f *botEventForwarder) flush() {
f.mu.Lock()
text := strings.TrimSpace(f.buf.String())
if text == "" {
f.mu.Unlock()
return
}
f.buf.Reset()
f.mu.Unlock()
f.sendToAll(text)
}
// sendToAll dispatches text to every target channel. Errors are logged and
// non-fatal; a failed target does not block other targets.
func (f *botEventForwarder) sendToAll(text string) {
text = strings.TrimSpace(text)
if f.runtime == nil || len(f.targets) == 0 || text == "" {
return
}
f.queueMu.Lock()
defer f.queueMu.Unlock()
if f.closed {
return
}
select {
case f.queue <- text:
default:
log.Printf("[bot-forward] send queue full; dropping message for %d target(s)", len(f.targets))
}
}
func (f *botEventForwarder) run() {
for text := range f.queue {
f.sendToAllNow(text)
}
}
func (f *botEventForwarder) sendToAllNow(text string) {
for _, tgt := range f.targets {
ctx, cancel := context.WithTimeout(context.Background(), botForwardSendTimeout)
_, err := f.runtime.SendToAdapter(ctx, tgt.ConnID, tgt.Domain, bot.OutboundMessage{
ChatID: tgt.ChatID,
ChatType: tgt.ChatType,
Text: text,
})
cancel()
if err != nil {
log.Printf("[bot-forward] send to %s/%s failed: %v", tgt.ConnID, tgt.ChatType, err)
}
}
}
func (f *botEventForwarder) Close() {
f.closeOnce.Do(func() {
f.flush()
f.queueMu.Lock()
f.closed = true
close(f.queue)
f.queueMu.Unlock()
})
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"io"
"log/slog"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/bot"
"reasonix/internal/event"
)
type desktopForwardTestAdapter struct {
platform bot.Platform
name string
messages chan bot.InboundMessage
entered chan struct{}
release chan struct{}
sent chan bot.OutboundMessage
once sync.Once
}
func newDesktopForwardTestAdapter() *desktopForwardTestAdapter {
return &desktopForwardTestAdapter{
platform: bot.PlatformFeishu,
name: "forward-test",
messages: make(chan bot.InboundMessage),
entered: make(chan struct{}),
release: make(chan struct{}),
sent: make(chan bot.OutboundMessage, 8),
}
}
func (a *desktopForwardTestAdapter) Platform() bot.Platform { return a.platform }
func (a *desktopForwardTestAdapter) Name() string { return a.name }
func (a *desktopForwardTestAdapter) Start(context.Context) error {
return nil
}
func (a *desktopForwardTestAdapter) Stop() error { return nil }
func (a *desktopForwardTestAdapter) SendTyping(context.Context, string) error {
return nil
}
func (a *desktopForwardTestAdapter) Messages() <-chan bot.InboundMessage {
return a.messages
}
func (a *desktopForwardTestAdapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
a.once.Do(func() { close(a.entered) })
select {
case <-a.release:
case <-ctx.Done():
return bot.SendResult{}, ctx.Err()
}
a.sent <- msg
return bot.SendResult{MessageID: "sent"}, nil
}
func newDesktopForwardTestRuntime(adapter bot.Adapter) *desktopBotRuntime {
gw := bot.NewGatewayWithAdapterBindings(bot.GatewayConfig{}, []bot.AdapterBinding{{
ID: "feishu-lark",
Domain: "lark",
Platform: bot.PlatformFeishu,
Adapter: adapter,
}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
return &desktopBotRuntime{gw: gw}
}
func TestBotEventForwarderDoesNotBlockEventEmissionOnSlowSend(t *testing.T) {
adapter := newDesktopForwardTestAdapter()
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
ConnID: "feishu-lark",
Domain: "lark",
ChatID: "oc-group-1",
ChatType: bot.ChatGroup,
}})
done := make(chan struct{})
go func() {
forwarder.Emit(event.Event{Kind: event.Text, Text: strings.Repeat("x", 400)})
close(done)
}()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("Emit blocked behind slow bot send")
}
select {
case <-adapter.entered:
case <-time.After(500 * time.Millisecond):
t.Fatal("adapter send did not start")
}
forwarder.Close()
close(adapter.release)
select {
case <-adapter.sent:
case <-time.After(500 * time.Millisecond):
t.Fatal("queued bot message was not sent after adapter release")
}
}
func TestBotEventForwarderApprovalNoticeDoesNotExposeReplyID(t *testing.T) {
adapter := newDesktopForwardTestAdapter()
close(adapter.release)
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
ConnID: "feishu-lark",
Domain: "lark",
ChatID: "oc-group-1",
ChatType: bot.ChatGroup,
}})
forwarder.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
ID: "approval-1",
Tool: "shell",
Subject: "run command",
}})
forwarder.Close()
select {
case msg := <-adapter.sent:
if strings.Contains(msg.Text, "approval-1") || strings.Contains(msg.Text, "/approve") {
t.Fatalf("approval notice exposed unusable reply routing: %q", msg.Text)
}
if !strings.Contains(msg.Text, "桌面") {
t.Fatalf("approval notice = %q, want desktop guidance", msg.Text)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("approval notice was not sent")
}
}
+401
View File
@@ -0,0 +1,401 @@
package main
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
"reasonix/internal/bot"
"reasonix/internal/botruntime"
"reasonix/internal/config"
)
type BotRuntimeStatusView struct {
Running bool `json:"running"`
Status string `json:"status"`
Message string `json:"message"`
Connections int `json:"connections"`
StartedAt string `json:"startedAt"`
}
type desktopBotRuntime struct {
// lifecycleMu serializes start/stop transitions so two apply/stop calls
// can't race a gateway into existence. The slow work (gw.Stop teardown,
// gw.Start dials) runs while holding it but NOT r.mu, so status/send reads
// never block on a restart.
lifecycleMu sync.Mutex
mu sync.Mutex
cancel context.CancelFunc
gw *bot.BotGateway
status BotRuntimeStatusView
}
func newDesktopBotRuntime() *desktopBotRuntime {
return &desktopBotRuntime{status: BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}}
}
func desktopBotChannelsWithLegacyQQ(qq config.QQBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
channel := bot.ChannelConfig{
Model: strings.TrimSpace(qq.Model),
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(qq.ToolApprovalMode),
WorkspaceRoot: strings.TrimSpace(qq.WorkspaceRoot),
}
if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" {
return channels, connectionChannels
}
if channels == nil {
channels = make(map[bot.Platform]bot.ChannelConfig)
}
if _, ok := channels[bot.PlatformQQ]; !ok {
channels[bot.PlatformQQ] = channel
}
if connectionChannels == nil {
connectionChannels = make(map[string]bot.ChannelConfig)
}
if _, ok := connectionChannels[string(bot.PlatformQQ)]; !ok {
connectionChannels[string(bot.PlatformQQ)] = channel
}
return channels, connectionChannels
}
func (a *App) refreshBotRuntimeAsync() {
if a.ctx == nil {
return
}
a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
}
func (a *App) refreshBotRuntime() {
// NewApp always pre-fills botRuntime; a nil here means a test-constructed
// App with no bot runtime, which must not lazily create one from a
// background goroutine (that would race a concurrent refresh).
if a.botRuntime == nil {
return
}
cfg, err := a.loadDesktopBotConfig()
if err != nil {
a.botRuntime.stop("error", err.Error())
return
}
_ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode)
}
func (a *App) loadDesktopBotConfig() (*config.Config, error) {
// Read-only load feeding the bot runtime and connection diagnostics. It
// must load credentials: the runtime resolves app secrets and control
// tokens from the process env (AppSecretEnv, Control.TokenEnv), which the
// credential-free view load would leave unset on a fresh process.
cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
if err != nil {
return nil, err
}
return cfg, nil
}
func (a *App) stopBotRuntime() {
if a.botRuntime != nil {
a.botRuntime.stop("stopped", "bot runtime stopped")
}
}
func (a *App) BotRuntimeStatus() BotRuntimeStatusView {
if a.botRuntime == nil {
return BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}
}
return a.botRuntime.snapshot()
}
func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error) error {
if r == nil {
return nil
}
if parent == nil {
parent = context.Background()
}
plan := desktopBotRuntimePlan(cfg)
r.lifecycleMu.Lock()
defer r.lifecycleMu.Unlock()
r.stopCurrent()
if !plan.Start {
r.setStatus(BotRuntimeStatusView{Status: plan.Status, Message: plan.Message})
return nil
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
ctx, cancel := context.WithCancel(parent)
modelName := botruntime.ModelName(cfg, "")
channels := botruntime.ChannelConfigs(cfg.Bot.Connections, true, true)
connectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true)
channels, connectionChannels = desktopBotChannelsWithLegacyQQ(cfg.Bot.QQ, channels, connectionChannels)
gwCfg := bot.GatewayConfig{
Model: modelName,
ToolApprovalMode: cfg.Bot.ToolApprovalMode,
MaxSteps: cfg.Bot.MaxSteps,
QueueMode: cfg.Bot.QueueMode,
QueueCap: cfg.Bot.QueueCap,
QueueDrop: cfg.Bot.QueueDrop,
PairingEnabled: cfg.Bot.Pairing.Enabled,
PairingTTL: time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
SelfUserIDs: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
},
ControlEnabled: cfg.Bot.Control.Enabled,
ControlAddr: cfg.Bot.Control.Addr,
ControlToken: os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
WorkspaceRoot: workspaceRoot,
Channels: channels,
ConnectionChannels: connectionChannels,
Routes: botruntime.RouteConfigs(cfg.Bot.Routes, true, true),
ConnectionAccess: botruntime.ConnectionAccessConfigs(cfg),
Enabled: plan.Enabled,
Allowlist: bot.AllowlistConfig{
Enabled: cfg.Bot.Allowlist.Enabled,
AllowAll: cfg.Bot.Allowlist.AllowAll,
Users: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQUsers,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
},
Approvers: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
},
Admins: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
},
Groups: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinGroups,
},
},
Debounce: time.Duration(cfg.Bot.DebounceMs) * time.Millisecond,
OnInbound: botruntime.NewRemoteRememberer(logger),
OnSessionReady: botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
OnToolApprovalModeChange: onToolApprovalModeChange,
}
bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
if len(bindings) == 0 {
cancel()
r.setStatus(BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"})
return nil
}
gw := bot.NewGatewayWithAdapterBindings(gwCfg, bindings, logger)
if err := gw.Start(ctx); err != nil {
cancel()
gw.Stop()
r.setStatus(BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()})
return err
}
runningConnections := gw.AdapterCount()
startErrors := gw.StartErrors()
status := "running"
message := fmt.Sprintf("%d bot connection(s) running", runningConnections)
if len(startErrors) > 0 {
status = "degraded"
message = fmt.Sprintf("%d bot connection(s) running; %d failed to start: %s", runningConnections, len(startErrors), summarizeBotRuntimeErrors(startErrors))
}
r.mu.Lock()
r.cancel = cancel
r.gw = gw
r.status = BotRuntimeStatusView{
Running: true,
Status: status,
Message: message,
Connections: runningConnections,
StartedAt: time.Now().UTC().Format(time.RFC3339),
}
r.mu.Unlock()
return nil
}
func (a *App) persistRemoteBotToolApprovalMode(msg bot.InboundMessage, mode string) error {
mode = normalizeBotConnectionToolApprovalMode(mode)
if mode == "" {
return nil
}
return a.applyConfigOnly(func(c *config.Config) error {
id := strings.TrimSpace(msg.ConnectionID)
now := time.Now().UTC().Format(time.RFC3339)
if id != "" {
for i := range c.Bot.Connections {
if c.Bot.Connections[i].ID == id || botruntime.ConnectionRuntimeID(c.Bot.Connections[i]) == id {
c.Bot.Connections[i].ToolApprovalMode = mode
c.Bot.Connections[i].UpdatedAt = now
return nil
}
}
}
c.Bot.ToolApprovalMode = mode
return nil
})
}
func summarizeBotRuntimeErrors(errs []error) string {
parts := make([]string, 0, len(errs))
for _, err := range errs {
if err == nil {
continue
}
parts = append(parts, err.Error())
}
if len(parts) == 0 {
return ""
}
if len(parts) > 3 {
hidden := len(parts) - 3
parts = append(parts[:3], fmt.Sprintf("%d more", hidden))
}
return strings.Join(parts, "; ")
}
type botRuntimePlan struct {
Start bool
Status string
Message string
Enabled map[bot.Platform]bool
}
func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
if cfg == nil {
return botRuntimePlan{Status: "error", Message: "config is unavailable"}
}
if !cfg.Bot.Enabled {
return botRuntimePlan{Status: "stopped", Message: "bot is disabled"}
}
if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist, pairing, per-bot access, or allow_all=true"}
}
enabled, unknown := botruntime.EnabledPlatforms(cfg, nil)
if len(unknown) > 0 {
return botRuntimePlan{Status: "error", Message: "unknown bot channel: " + strings.Join(unknown, ", ")}
}
if !botruntime.HasEnabledPlatform(enabled) {
return botRuntimePlan{Status: "stopped", Message: "no bot channels enabled"}
}
return botRuntimePlan{Start: true, Status: "running", Message: "bot runtime can start", Enabled: enabled}
}
func (r *desktopBotRuntime) stop(status, message string) {
r.lifecycleMu.Lock()
defer r.lifecycleMu.Unlock()
r.stopCurrent()
r.setStatus(BotRuntimeStatusView{Status: status, Message: message})
}
// stopCurrent detaches the running gateway under r.mu, then tears it down
// off-lock: gw.Stop() closes every session controller (up to the jobs teardown
// grace each) and must not stall status/send readers. Callers hold lifecycleMu.
func (r *desktopBotRuntime) stopCurrent() {
r.mu.Lock()
cancel := r.cancel
gw := r.gw
r.cancel = nil
r.gw = nil
r.mu.Unlock()
if cancel != nil {
cancel()
}
if gw != nil {
gw.Stop()
}
}
func (r *desktopBotRuntime) setStatus(status BotRuntimeStatusView) {
r.mu.Lock()
r.status = status
r.mu.Unlock()
}
func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
r.mu.Lock()
defer r.mu.Unlock()
return r.status
}
// updateConnectionToolApprovalMode updates a connection's tool approval mode
// on the running gateway without restarting. Returns true if updated, false if
// the gateway is not running or the connection is unknown.
func (r *desktopBotRuntime) updateConnectionToolApprovalMode(connID, mode string) bool {
r.mu.Lock()
defer r.mu.Unlock()
if r.gw == nil {
return false
}
mode = normalizeBotConnectionToolApprovalMode(mode)
// Update ConnectionChannels in the internal GatewayConfig so new sessions
// pick up the mode. Existing sessions are updated by the gateway directly.
r.gw.UpdateConnectionToolApprovalMode(connID, mode)
return true
}
// SendToAdapter sends a message through the running gateway's adapter
// identified by connID. Returns an error if the gateway is not running
// or no matching adapter is found.
func (r *desktopBotRuntime) SendToAdapter(ctx context.Context, connID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
r.mu.Lock()
gw := r.gw
r.mu.Unlock()
if gw == nil {
return bot.SendResult{}, nil // gateway not running — silent no-op
}
return gw.SendToAdapter(ctx, connID, domain, msg)
}
// Running returns true if the bot gateway is currently active.
func (r *desktopBotRuntime) Running() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.gw != nil
}
// ForwardTargets returns the list of bot forward targets derived from the
// current config's bot connections and their session mappings. Each mapping
// produces one target (connID + chatID + chatType) for event forwarding.
func (r *desktopBotRuntime) ForwardTargets(cfg *config.Config) []botForwardTarget {
if cfg == nil {
return nil
}
var targets []botForwardTarget
seen := make(map[botForwardTarget]bool)
for _, conn := range cfg.Bot.Connections {
if !conn.Enabled {
continue
}
connID := botruntime.ConnectionRuntimeID(conn)
domain := strings.TrimSpace(conn.Domain)
for _, sm := range conn.SessionMappings {
remoteID := strings.TrimSpace(sm.RemoteID)
if remoteID == "" {
continue
}
chatType := bot.ChatDM
if sm.ChatType != "" {
chatType = bot.ChatType(sm.ChatType)
}
target := botForwardTarget{
ConnID: connID,
Domain: domain,
ChatID: remoteID,
ChatType: chatType,
}
if seen[target] {
continue
}
seen[target] = true
targets = append(targets, target)
}
}
return targets
}
+504
View File
@@ -0,0 +1,504 @@
package main
import (
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/bot"
"reasonix/internal/botruntime"
"reasonix/internal/config"
)
func TestDesktopBotRuntimePlanStartsSavedConnections(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = true
cfg.Bot.Allowlist.Enabled = true
cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
cfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"}
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true},
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true},
}
plan := desktopBotRuntimePlan(cfg)
if !plan.Start {
t.Fatalf("plan = %+v, want start", plan)
}
if !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] {
t.Fatalf("enabled = %+v, want feishu/lark and weixin platforms", plan.Enabled)
}
}
func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = true
cfg.Bot.Allowlist.Enabled = true
cfg.Bot.Pairing.Enabled = false
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
}
plan := desktopBotRuntimePlan(cfg)
if plan.Start || plan.Status != "blocked" {
t.Fatalf("plan = %+v, want blocked without allowlist", plan)
}
}
func TestDesktopBotRuntimePlanStartsWithPairing(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = true
cfg.Bot.Allowlist.Enabled = true
cfg.Bot.Pairing.Enabled = true
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
}
plan := desktopBotRuntimePlan(cfg)
if !plan.Start {
t.Fatalf("plan = %+v, want start with pairing enabled", plan)
}
}
func TestDesktopBotChannelsWithLegacyQQConfig(t *testing.T) {
channels, connectionChannels := desktopBotChannelsWithLegacyQQ(config.QQBotConfig{
Model: "qq-model",
ToolApprovalMode: "auto",
WorkspaceRoot: "/tmp/qq-project",
}, nil, nil)
channel, ok := channels[bot.PlatformQQ]
if !ok {
t.Fatalf("platform QQ channel missing: %+v", channels)
}
if channel.Model != "qq-model" || channel.ToolApprovalMode != "auto" || channel.WorkspaceRoot != "/tmp/qq-project" {
t.Fatalf("platform channel = %+v, want QQ-specific runtime fields", channel)
}
connectionChannel, ok := connectionChannels["qq"]
if !ok {
t.Fatalf("connection QQ channel missing: %+v", connectionChannels)
}
if connectionChannel.Model != "qq-model" || connectionChannel.ToolApprovalMode != "auto" || connectionChannel.WorkspaceRoot != "/tmp/qq-project" {
t.Fatalf("connection channel = %+v, want QQ-specific runtime fields", connectionChannel)
}
}
func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
cfg := config.Default()
cfg.Bot.Enabled = false
cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
cfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
}
plan := desktopBotRuntimePlan(cfg)
if plan.Start || plan.Status != "stopped" {
t.Fatalf("plan = %+v, want stopped when disabled", plan)
}
}
func TestDesktopBotRuntimeForwardTargetsDeduplicatesMappedChats(t *testing.T) {
cfg := config.Default()
cfg.Bot.Connections = []config.BotConnectionConfig{{
ID: "feishu-lark",
Provider: "feishu",
Domain: "lark",
Enabled: true,
SessionMappings: []config.BotConnectionSessionMapping{
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-1"},
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-2"},
{RemoteID: "oc-dm-1", ChatType: string(bot.ChatDM), UserID: "ou-user-1"},
},
}}
targets := newDesktopBotRuntime().ForwardTargets(cfg)
if len(targets) != 2 {
t.Fatalf("targets = %+v, want one group target plus one dm target", targets)
}
seen := map[string]bool{}
for _, target := range targets {
key := target.ConnID + "|" + target.Domain + "|" + target.ChatID + "|" + string(target.ChatType)
if seen[key] {
t.Fatalf("duplicate target %q in %+v", key, targets)
}
seen[key] = true
}
if !seen["feishu-lark|lark|oc-group-1|group"] || !seen["feishu-lark|lark|oc-dm-1|dm"] {
t.Fatalf("targets = %+v, want group and dm targets", targets)
}
}
func TestDesktopBotRuntimeConfigUsesUserBotSettings(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.LoadForEdit(config.UserConfigPath())
userCfg.Bot.Enabled = true
userCfg.Bot.Allowlist.Enabled = true
userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
userCfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Status: "connected"},
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[bot]
enabled = false
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
got, err := NewApp().loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
plan := desktopBotRuntimePlan(got)
if !plan.Start || !plan.Enabled[bot.PlatformFeishu] {
t.Fatalf("desktop runtime plan = %+v, want user-level Lark connection to start", plan)
}
}
func TestDesktopBotRuntimeConfigLoadsAllSavedCredentialsAfterRestart(t *testing.T) {
isolateDesktopUserDirs(t)
t.Cleanup(func() {
_ = os.Unsetenv("FEISHU_BOT_APP_SECRET")
_ = os.Unsetenv("LARK_BOT_APP_SECRET")
})
userCfg := config.Default()
userCfg.Bot.Enabled = true
userCfg.Bot.Allowlist.Enabled = true
userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-feishu-installer", "ou-lark-installer"}
userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-installer"}
userCfg.Bot.Feishu.Enabled = true
userCfg.Bot.Weixin.Enabled = true
userCfg.Bot.Weixin.AccountID = "weixin-account"
userCfg.Bot.Weixin.TokenEnv = "WEIXIN_BOT_TOKEN"
userCfg.Bot.Connections = []config.BotConnectionConfig{
{
ID: "feishu-feishu",
Provider: "feishu",
Domain: "feishu",
Enabled: true,
Status: "connected",
Credential: config.BotConnectionCredential{
AppID: "cli-feishu",
AppSecretEnv: "FEISHU_BOT_APP_SECRET",
},
},
{
ID: "feishu-lark",
Provider: "feishu",
Domain: "lark",
Enabled: true,
Status: "connected",
Credential: config.BotConnectionCredential{
AppID: "cli-lark",
AppSecretEnv: "LARK_BOT_APP_SECRET",
},
},
{
ID: "weixin-weixin",
Provider: "weixin",
Domain: "weixin",
Enabled: true,
Status: "connected",
Credential: config.BotConnectionCredential{
AccountID: "weixin-account",
TokenEnv: "WEIXIN_BOT_TOKEN",
},
},
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
t.Fatalf("create credentials dir: %v", err)
}
if err := os.WriteFile(config.UserCredentialsPath(), []byte("FEISHU_BOT_APP_SECRET=feishu-secret\nLARK_BOT_APP_SECRET=lark-secret\n"), 0o600); err != nil {
t.Fatalf("write credentials: %v", err)
}
weixinAccountPath := filepath.Join(config.MemoryUserDir(), "weixin", "accounts", "weixin-account.json")
if err := os.MkdirAll(filepath.Dir(weixinAccountPath), 0o700); err != nil {
t.Fatalf("create weixin account dir: %v", err)
}
if err := os.WriteFile(weixinAccountPath, []byte(`{"token":"weixin-token","base_url":"https://ilinkai.weixin.qq.com","user_id":"wx-installer"}`), 0o600); err != nil {
t.Fatalf("write weixin account: %v", err)
}
_ = os.Unsetenv("FEISHU_BOT_APP_SECRET")
_ = os.Unsetenv("LARK_BOT_APP_SECRET")
got, err := NewApp().loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
views := botConnectionViews(got.Bot.Connections)
if len(views) != 3 {
t.Fatalf("connection views = %+v, want Feishu, Lark, and Weixin", views)
}
for _, view := range views {
if !view.Credential.SecretSet {
t.Fatalf("connection %s credential = %+v, want saved credential loaded after restart", view.ID, view.Credential)
}
}
plan := desktopBotRuntimePlan(got)
if !plan.Start || !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] {
t.Fatalf("desktop runtime plan = %+v, want saved Feishu/Lark/Weixin connections to start", plan)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
bindings := botruntime.AdapterBindings(got, plan.Enabled, nil, logger)
if len(bindings) != 3 {
t.Fatalf("adapter bindings = %+v, want one per saved connection", bindings)
}
}
func TestDesktopBotRuntimeMigratesLegacyProjectBotSettings(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.Default()
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
t.Fatalf("set desktop appearance: %v", err)
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[bot]
enabled = true
[bot.allowlist]
enabled = true
feishu_users = ["ou-legacy"]
[[bot.connections]]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
label = "Lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
}
// The bot-runtime load is a pure read: the merge above stays in memory and
// the user config file is not rewritten.
preWrite := config.LoadForEdit(config.UserConfigPath())
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
}
// The first locked write path performs the on-disk migration.
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
}
if persisted.DesktopTheme() != "dark" {
t.Fatalf("desktop theme = %q, want preserved user preference", persisted.DesktopTheme())
}
}
func TestDesktopBotRuntimePersistsLegacyProjectBotWhenUserConfigMissing(t *testing.T) {
isolateDesktopUserDirs(t)
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[desktop]
theme = "dark"
[bot]
enabled = true
[bot.allowlist]
enabled = true
feishu_users = ["ou-legacy"]
[[bot.connections]]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
label = "Lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
app := NewApp()
got, err := app.loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
}
// The bot-runtime load is a pure read: it serves the legacy config from
// memory and must not create the user config file.
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("read path must not create the user config, stat err = %v", err)
}
// The first locked write path creates the user config with the migrated
// bot settings (adopting the legacy config, ConfigVersion-bumped).
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
t.Fatalf("applyConfigOnly: %v", err)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
}
}
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
isolateDesktopUserDirs(t)
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[desktop]
theme = "dark"
close_behavior = "quit"
[bot]
enabled = true
[bot.allowlist]
enabled = true
feishu_users = ["ou-legacy"]
[[bot.connections]]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
label = "Lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
settings := NewApp().Settings()
if !settings.Bot.Enabled || len(settings.Bot.Connections) != 1 || settings.Bot.Connections[0].ID != "feishu-lark" {
t.Fatalf("settings bot = %+v, want migrated legacy Lark connection", settings.Bot)
}
if settings.DesktopTheme != "dark" || settings.CloseBehavior != "quit" {
t.Fatalf("settings desktop prefs = theme:%q close:%q, want legacy seed visible before first edit", settings.DesktopTheme, settings.CloseBehavior)
}
persisted := config.LoadForEdit(config.UserConfigPath())
if persisted.DesktopTheme() == "dark" || persisted.DesktopCloseBehavior() == "quit" {
t.Fatalf("persisted desktop prefs = theme:%q close:%q, want bot-only migration", persisted.DesktopTheme(), persisted.DesktopCloseBehavior())
}
}
func TestDesktopBotRuntimeMigrationDoesNotOverwriteUserBotSettings(t *testing.T) {
isolateDesktopUserDirs(t)
userCfg := config.Default()
userCfg.Bot.Enabled = true
userCfg.Bot.Allowlist.Enabled = true
userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"}
userCfg.Bot.Connections = []config.BotConnectionConfig{
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true, Status: "connected"},
}
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save user config: %v", err)
}
project := robustTempDir(t)
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
[bot]
enabled = true
[bot.allowlist]
enabled = true
feishu_users = ["ou-legacy"]
[[bot.connections]]
id = "feishu-lark"
provider = "feishu"
domain = "lark"
enabled = true
status = "connected"
`), 0o644); err != nil {
t.Fatalf("write project config: %v", err)
}
orig, _ := os.Getwd()
defer func() { _ = os.Chdir(orig) }()
if err := os.Chdir(project); err != nil {
t.Fatalf("chdir project: %v", err)
}
got, err := NewApp().loadDesktopBotConfig()
if err != nil {
t.Fatalf("load desktop bot config: %v", err)
}
if len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "weixin-weixin" {
t.Fatalf("desktop bot config = %+v, want existing user WeChat connection", got.Bot)
}
}
func TestSummarizeBotRuntimeErrorsCapsOutput(t *testing.T) {
got := summarizeBotRuntimeErrors([]error{
errors.New("first"),
nil,
errors.New("second"),
errors.New("third"),
errors.New("fourth"),
})
for _, want := range []string{"first", "second", "third", "1 more"} {
if !strings.Contains(got, want) {
t.Fatalf("summary = %q, want %q", got, want)
}
}
if strings.Contains(got, "fourth") {
t.Fatalf("summary = %q, should cap extra errors", got)
}
}
+14 -4
View File
@@ -33,17 +33,27 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
if got := app.SlashArgs("/skill "); got.Items == nil {
t.Fatal("SlashArgs().Items is nil; frontend expects []")
}
if got := app.WorkspaceChanges(); got.Files == nil {
t.Fatal("WorkspaceChanges().Files is nil; frontend expects []")
if got := app.WorkspaceChanges(""); got.Files == nil {
t.Fatal(`WorkspaceChanges("").Files is nil; frontend expects []`)
}
if got := app.ContextPanel("missing"); got.ReadFiles == nil || got.ChangedFiles == nil {
t.Fatalf("ContextPanel(missing) arrays = read:%v changed:%v, want non-nil", got.ReadFiles, got.ChangedFiles)
}
if got := app.Settings(); got.Providers == nil || got.ProviderKinds == nil ||
if got := app.HooksSettings("global"); got.Hooks == nil || got.Events == nil {
t.Fatalf("HooksSettings(global) arrays = hooks:%v events:%v, want non-nil", got.Hooks, got.Events)
}
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderPresets == nil || got.ProviderKinds == nil ||
got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
got.Sandbox.AllowWrite == nil {
got.Sandbox.AllowWrite == nil || got.Sandbox.EffectiveWriteRoots == nil ||
got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
t.Fatalf("Settings() contains nil array fields: %+v", got)
}
if got := app.DesktopStartupSettings(); got.StatusBarItems == nil ||
got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
t.Fatalf("DesktopStartupSettings() contains nil array fields: %+v", got)
}
}
func assertNonNilSliceJSON(t *testing.T, name string, got any) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 61 KiB

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Reasonix">
<g transform="scale(2.56)">
<rect width="400" height="400" rx="126.37" ry="126.37" fill="#0153e5"/>
<path fill="#fff" d="M253.29,235.46l.02-.02c14.11-4.16,26.51-11.63,36.45-22.4v-.04c10.82-12.11,17.61-26.76,19.97-42.87,5.39-35.23-12.1-67.41-45.05-81.04-11.59-4.65-23.48-6.64-36.12-6.52l-122.5.04v234.74l60.22-.05v-79.23c6.57,2.11,13.49,3.25,20.59,3.22l19.71,27.21,35.83,48.92,71.52-.1-60.64-81.87ZM250.87,224.64c-8.21,2.71-17.01,2.31-25.39-.14-14.4-10.89-22.2-22.51-34.48-33.59-15.72-14.19-36.52-24.02-57.83-23.06,1.7-4.65,4.14-9.09,7.31-13.16,11-13.74,26-18.1,43.24-15.69,6.36.89,13.16-6.58,26.1-5.14.86.1,1.75.75,1.83,1.27.3,1.77-4.71,2.72-4.71,7.55,0,2.03.91,4.39,2.83,5.73,6.6,4.63,12.32,9.83,18.3,15.21,2.91,2.61,12.76,9.96,15.26,3.98,1.47-3.49,2.43-7.15,3.4-10.86.48-1.84-.54-2.93-2.02-3.8-10.17-6.03-13.99-18.56-10.02-29.67.38-1.07,1.38-1.59,2.22-1.63,3.41-.15,1.59,6.18,10.04,8.78,8.01,2.48,8.25,9.13,12.14,6.47,9.07-6.18,12.34-1.12,20.76-9.05.86-.81,2.36-.87,3.25-.25.56.38,1.04,1.38.99,2.55-.25,6.7-2.86,13.1-7.44,18.01-8.66,9.29-15.93,4.84-16.4,11.36-1.38,19.11-7.63,38.18-21.19,52.06-.44.45-.69,1.08-.62,1.51.07.45.61.89,1.2,1.08l11.42,3.89c1.61.55,2.79,2,2.58,3.54-.18,1.35-1.26,2.54-2.79,3.05Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Hardened-runtime entitlements required to notarize a Wails (Go + WKWebView)
app. The web view's JS engine needs JIT and writable/executable memory;
the Go binary links system libs that aren't team-signed. Kept minimal — no
network/file entitlements, since this is a hardened-runtime carve-out, not
an App Sandbox profile. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Reasonix">
<g transform="scale(2.56)">
<rect width="400" height="400" rx="126.37" ry="126.37" fill="#0153e5"/>
<path fill="#fff" d="M253.29,235.46l.02-.02c14.11-4.16,26.51-11.63,36.45-22.4v-.04c10.82-12.11,17.61-26.76,19.97-42.87,5.39-35.23-12.1-67.41-45.05-81.04-11.59-4.65-23.48-6.64-36.12-6.52l-122.5.04v234.74l60.22-.05v-79.23c6.57,2.11,13.49,3.25,20.59,3.22l19.71,27.21,35.83,48.92,71.52-.1-60.64-81.87ZM250.87,224.64c-8.21,2.71-17.01,2.31-25.39-.14-14.4-10.89-22.2-22.51-34.48-33.59-15.72-14.19-36.52-24.02-57.83-23.06,1.7-4.65,4.14-9.09,7.31-13.16,11-13.74,26-18.1,43.24-15.69,6.36.89,13.16-6.58,26.1-5.14.86.1,1.75.75,1.83,1.27.3,1.77-4.71,2.72-4.71,7.55,0,2.03.91,4.39,2.83,5.73,6.6,4.63,12.32,9.83,18.3,15.21,2.91,2.61,12.76,9.96,15.26,3.98,1.47-3.49,2.43-7.15,3.4-10.86.48-1.84-.54-2.93-2.02-3.8-10.17-6.03-13.99-18.56-10.02-29.67.38-1.07,1.38-1.59,2.22-1.63,3.41-.15,1.59,6.18,10.04,8.78,8.01,2.48,8.25,9.13,12.14,6.47,9.07-6.18,12.34-1.12,20.76-9.05.86-.81,2.36-.87,3.25-.25.56.38,1.04,1.38.99,2.55-.25,6.7-2.86,13.1-7.44,18.01-8.66,9.29-15.93,4.84-16.4,11.36-1.38,19.11-7.63,38.18-21.19,52.06-.44.45-.69,1.08-.62,1.51.07.45.61.89,1.2,1.08l11.42,3.89c1.61.55,2.79,2,2.58,3.54-.18,1.35-1.26,2.54-2.79,3.05Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+77
View File
@@ -0,0 +1,77 @@
# nfpm config for the Linux .deb package. scripts/desktop-build.sh's linux branch
# runs `nfpm package` after `wails build`, from the desktop/ dir (so the src paths
# below are relative to desktop/). The .deb is a human-download artifact only — like
# the macOS .dmg — so the Linux updater channel stays the .tar.gz and cmd/sign's
# manifest skips .deb files.
#
# $DEB_VERSION and $DEB_ARCH are exported by desktop-build.sh. DEB_VERSION is the tag
# minus a leading "v" and any prerelease suffix (a strict X.Y.Z that dpkg accepts);
# DEB_ARCH is the Go arch name, which already matches Debian's (amd64, arm64).
name: "reasonix-desktop"
arch: "${DEB_ARCH}"
platform: "linux"
version: "${DEB_VERSION}"
section: "utils"
priority: "optional"
maintainer: "esengine <[email protected]>"
description: |
Reasonix desktop — a Wails shell around the Go kernel.
vendor: "Reasonix Contributors"
homepage: "https://github.com/esengine/DeepSeek-Reasonix"
license: "MIT"
# Runtime libraries the WebKitGTK 4.1 build links against (-tags webkit2_41); apt
# pulls them in on install. These package names ship from Ubuntu 22.04 / Debian 12 on.
depends:
- libgtk-3-0
- libwebkit2gtk-4.1-0
contents:
- src: ./build/bin/reasonix-desktop
dst: /usr/bin/reasonix-desktop
file_info:
mode: 0755
- src: ./build/linux/reasonix.desktop
dst: /usr/share/applications/reasonix.desktop
file_info:
mode: 0644
# appicon.png is 1024x1024 — not a standard hicolor size dir, so install it under
# the size-agnostic pixmaps path that the .desktop's `Icon=reasonix-desktop` finds.
- src: ./build/appicon.png
dst: /usr/share/pixmaps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/16x16/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/16x16/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/24x24/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/24x24/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/32x32/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/32x32/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/48x48/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/48x48/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/64x64/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/64x64/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/128x128/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/128x128/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/256x256/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/256x256/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/512x512/apps/reasonix-desktop.png
dst: /usr/share/icons/hicolor/512x512/apps/reasonix-desktop.png
file_info:
mode: 0644
- src: ./build/linux/icons/hicolor/scalable/apps/reasonix-desktop.svg
dst: /usr/share/icons/hicolor/scalable/apps/reasonix-desktop.svg
file_info:
mode: 0644
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Reasonix
Comment=Reasonix desktop — a Wails shell around the Go kernel
Exec=reasonix-desktop
Icon=reasonix-desktop
Categories=Development;Utility;
Terminal=false
StartupWMClass=reasonix-desktop
Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 41 KiB

+70 -11
View File
@@ -14,11 +14,13 @@ Unicode true
## wails.deleteUninstaller macros hard-code HKLM, which a non-admin install
## cannot write - so we inline HKCU versions below instead.
## 3. InstallDir is remembered across updates via InstallDirRegKey +
## InstallLocation (HKCU\...\Uninstall\InstallLocation). Without this, every
## release forces the user back to %LOCALAPPDATA%\Programs\Reasonix even if
## they had moved the install to a different drive (e.g. D:\Tools\Reasonix);
## the silent auto-updater would re-run with /S into the wrong dir, leaving
## the old install orphaned.
## InstallLocation (HKCU\...\Uninstall\InstallLocation). When upgrading from
## a build that did not write InstallLocation yet, .onInit falls back to the
## old DisplayIcon path before using the default. Without this, every release
## forces the user back to %LOCALAPPDATA%\Programs\Reasonix even if they had
## moved the install to a different drive (e.g. D:\Tools\Reasonix); the silent
## auto-updater would re-run with /S into the wrong dir, leaving the old
## install orphaned.
##
## Everything else mirrors Wails' generated default. Defines below override the
## ProjectInfo values that wails_tools.nsh would otherwise populate.
@@ -33,6 +35,8 @@ Unicode true
## wails.* macros used below).
####
!include "wails_tools.nsh"
!include "FileFunc.nsh"
!include "LogicLib.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
@@ -73,6 +77,8 @@ ManifestDPIAware true
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
!define REASONIX_DEFAULT_INSTALLDIR "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"
!define REASONIX_UNLOCK_RETRIES 60
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
InstallDir "${REASONIX_DEFAULT_INSTALLDIR}" # Per-user install location (no admin rights required).
ShowInstDetails show # This will always show the installation details.
@@ -111,12 +117,53 @@ ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
; InstallDirRegKey leaves $INSTDIR empty when the InstallLocation value
; is missing (first install, or the user wiped the uninstaller registry).
; Fall back to the per-user default so the directory page lands on a
; usable path instead of crashing the install with "InstallDir empty".
StrCmp $INSTDIR "" 0 +2
; InstallDirRegKey leaves $INSTDIR empty when the InstallLocation value is
; missing. Older installers still wrote DisplayIcon, so use its parent folder
; as a compatibility bridge before falling back to the per-user default.
StrCmp $INSTDIR "" 0 done
ClearErrors
ReadRegStr $0 HKCU "${UNINST_KEY}" "DisplayIcon"
IfErrors fallback
StrCmp $0 "" fallback
${GetParent} "$0" $INSTDIR
StrCmp $INSTDIR "" fallback done
fallback:
StrCpy $INSTDIR "${REASONIX_DEFAULT_INSTALLDIR}"
done:
FunctionEnd
Function reasonix.waitForExecutableUnlock
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 done
StrCpy $0 0
retry:
ClearErrors
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
IfErrors locked
FileClose $1
Goto done
locked:
IntOp $0 $0 + 1
IntCmp $0 ${REASONIX_UNLOCK_RETRIES} failed 0 0
Sleep 1000
Goto retry
failed:
IfSilent silent interactive
interactive:
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Reasonix is still running. Close Reasonix, then click Retry to continue the installation." IDRETRY retry IDCANCEL abort
Goto retry
silent:
SetErrorLevel 1618
abort:
Abort "Reasonix is still running. Close Reasonix and run the installer again."
done:
FunctionEnd
Section
@@ -124,9 +171,16 @@ Section
!insertmacro wails.webview2runtime
Call reasonix.waitForExecutableUnlock
SetOutPath $INSTDIR
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!else
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fall back to installer-side waiting only."
!endif
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
@@ -142,7 +196,9 @@ Section "uninstall"
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
; Precision uninstall: delete main application files
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
@@ -151,4 +207,7 @@ Section "uninstall"
!insertmacro wails.unassociateCustomProtocols
!insertmacro reasonix.deleteUninstaller
; Only remove the installation directory if it is empty to prevent data loss
RMDir $INSTDIR
SectionEnd
+154
View File
@@ -0,0 +1,154 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/checkpoint"
"reasonix/internal/control"
"reasonix/internal/event"
)
func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) {
t.Helper()
b, err := json.Marshal(c)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ckptDir, "turn-"+strconv.Itoa(c.Turn)+".json"), b, 0o644); err != nil {
t.Fatal(err)
}
}
func assertCheckpointFilesEncodeAsArray(t *testing.T, metas []CheckpointMeta) {
t.Helper()
raw, err := json.Marshal(metas)
if err != nil {
t.Fatal(err)
}
var payload []struct {
Files json.RawMessage `json:"files"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatal(err)
}
for i, item := range payload {
if string(item.Files) == "null" {
t.Fatalf("checkpoint %d files encoded as null; frontend expects []", i)
}
if len(item.Files) == 0 || item.Files[0] != '[' {
t.Fatalf("checkpoint %d files encoded as %s, want JSON array", i, item.Files)
}
}
}
// TestCheckpointsCanCodePropagatesToEarlierTurns covers #3438: RestoreCode(turn)
// reverts files touched in that turn or any later one, so a turn with no file
// changes of its own can still rewind code when a later turn changed files. The
// desktop CanCode flag must reflect that suffix capability, not just the turn's
// own paths.
func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
content := "old"
now := time.Now()
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "ask only", MsgIndex: 0})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit a file", MsgIndex: 2,
Files: []checkpoint.FileSnap{{Path: "a.txt", Content: &content}}})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 2, Time: now, Prompt: "ask again", MsgIndex: 4})
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
ctrl.SetSessionPath(sessionPath)
app := &App{}
app.setTestCtrl(ctrl, "test")
metas := app.CheckpointsForTab("test")
if len(metas) != 3 {
t.Fatalf("checkpoints = %d, want 3", len(metas))
}
got := map[int]bool{}
for _, m := range metas {
got[m.Turn] = m.CanCode
}
if !got[0] {
t.Error("turn 0 (no files of its own) should allow code rewind — turn 1 changed files")
}
if !got[1] {
t.Error("turn 1 changed files, should allow code rewind")
}
if got[2] {
t.Error("turn 2 is after the last file-bearing turn, should NOT allow code rewind")
}
if metas[0].TurnFileCount != 0 {
t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
}
if metas[1].TurnFileCount != 1 {
t.Fatalf("turn 1 file count = %d, want 1 for this turn", metas[1].TurnFileCount)
}
if len(metas[0].Files) != 1 || metas[0].Files[0] != "a.txt" {
t.Fatalf("turn 0 cumulative files = %#v, want [a.txt]", metas[0].Files)
}
if metas[0].FileCount != 1 || metas[0].FilesTruncated {
t.Fatalf("turn 0 file summary = count %d truncated %v, want count 1 truncated false", metas[0].FileCount, metas[0].FilesTruncated)
}
if len(metas[2].Files) != 0 {
t.Fatalf("turn 2 cumulative files = %#v, want empty", metas[2].Files)
}
assertCheckpointFilesEncodeAsArray(t, metas)
}
func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "s.jsonl")
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
t.Fatal(err)
}
content := "old"
files := make([]checkpoint.FileSnap, 0, checkpointFilePreviewLimit+5)
for i := 0; i < checkpointFilePreviewLimit+5; i++ {
files = append(files, checkpoint.FileSnap{Path: "file-" + strconv.Itoa(1000+i) + ".txt", Content: &content})
}
now := time.Now()
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "before edits", MsgIndex: 0})
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit many files", MsgIndex: 2, Files: files})
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
ctrl.SetSessionPath(sessionPath)
app := &App{}
app.setTestCtrl(ctrl, "test")
metas := app.CheckpointsForTab("test")
if len(metas) != 2 {
t.Fatalf("checkpoints = %d, want 2", len(metas))
}
if metas[0].FileCount != checkpointFilePreviewLimit+5 {
t.Fatalf("turn 0 cumulative file count = %d, want %d", metas[0].FileCount, checkpointFilePreviewLimit+5)
}
if len(metas[0].Files) != checkpointFilePreviewLimit {
t.Fatalf("turn 0 preview files = %d, want %d", len(metas[0].Files), checkpointFilePreviewLimit)
}
if !metas[0].FilesTruncated {
t.Fatal("turn 0 should mark file preview as truncated")
}
if metas[0].TurnFileCount != 0 {
t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
}
if metas[1].TurnFileCount != checkpointFilePreviewLimit+5 {
t.Fatalf("turn 1 file count = %d, want %d", metas[1].TurnFileCount, checkpointFilePreviewLimit+5)
}
assertCheckpointFilesEncodeAsArray(t, metas)
}
+14 -4
View File
@@ -35,7 +35,7 @@ import (
// platforms are the manifest keys we publish. A built artifact is matched to a key
// by substring (file names embed the key, e.g. Reasonix-darwin-arm64.zip), so the
// generator and the updater agree on update.PlatformKey output.
var platforms = []string{"darwin-arm64", "darwin-amd64", "windows-amd64", "linux-amd64"}
var platforms = []string{"darwin-arm64", "darwin-amd64", "windows-amd64", "windows-arm64", "linux-amd64"}
func main() {
if len(os.Args) < 2 {
@@ -166,12 +166,12 @@ func signFiles(files []string) error {
// release tag used in download URLs (e.g. "desktop-v1.1.0").
func genManifest(dir, version, tag string) error {
repo := os.Getenv("GITHUB_REPOSITORY")
if repo == "" {
repo = "esengine/reasonix"
if repo == "" || repo == "esengine/reasonix" {
repo = "esengine/DeepSeek-Reasonix"
}
m := update.Manifest{
Version: version,
DownloadPage: fmt.Sprintf("https://github.com/%s/releases/latest", repo),
DownloadPage: "https://reasonix.io/#start",
Platforms: map[string]update.Asset{},
}
entries, err := os.ReadDir(dir)
@@ -207,6 +207,16 @@ func genManifest(dir, version, tag string) error {
// matchPlatform returns the platform key embedded in a file name, or "" if none.
func matchPlatform(name string) string {
// The .deb is a human-download package (like the macOS .dmg); the Linux updater
// channel is the .tar.gz. Skip it so it doesn't shadow the tarball's linux-amd64 key.
if strings.HasSuffix(name, ".deb") {
return ""
}
// The Windows updater channel is the per-arch -installer.exe; the portable .zip
// is a human download, so skip it or it would shadow the installer's key.
if strings.Contains(name, "windows-") && !strings.HasSuffix(name, "-installer.exe") {
return ""
}
for _, p := range platforms {
if strings.Contains(name, p) {
return p
+29 -3
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"aead.dev/minisign"
@@ -55,7 +56,11 @@ func TestGenManifest(t *testing.T) {
"Reasonix-darwin-arm64.zip",
"Reasonix-darwin-amd64.zip",
"Reasonix-windows-amd64-installer.exe",
"Reasonix-windows-amd64.zip", // portable download, not the updater channel
"Reasonix-windows-arm64-installer.exe",
"Reasonix-windows-arm64.zip", // portable download, not the updater channel
"Reasonix-linux-amd64.tar.gz",
"Reasonix-linux-amd64.deb", // human download, not the updater channel
"Reasonix-linux-amd64.tar.gz.minisig", // must be skipped
"README.txt", // unmatched, must be skipped
}
@@ -80,14 +85,17 @@ func TestGenManifest(t *testing.T) {
if m.Version != "v1.2.0" {
t.Fatalf("version = %q, want v1.2.0", m.Version)
}
if len(m.Platforms) != 4 {
t.Fatalf("want 4 platforms, got %d: %v", len(m.Platforms), m.Platforms)
if m.DownloadPage != "https://reasonix.io/#start" {
t.Fatalf("download_page = %q, want official install page", m.DownloadPage)
}
if len(m.Platforms) != 5 {
t.Fatalf("want 5 platforms, got %d: %v", len(m.Platforms), m.Platforms)
}
win, ok := m.Platforms["windows-amd64"]
if !ok {
t.Fatal("windows-amd64 missing")
}
wantURL := "https://github.com/esengine/reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
wantURL := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
if win.URL != wantURL {
t.Fatalf("windows url = %q, want %q", win.URL, wantURL)
}
@@ -97,4 +105,22 @@ func TestGenManifest(t *testing.T) {
if win.SHA256 == "" || win.Size == 0 {
t.Fatalf("windows asset missing digest/size: %+v", win)
}
// The Windows updater channel is the per-arch -installer.exe; the portable .zip
// must not shadow the windows-arm64 key.
arm, ok := m.Platforms["windows-arm64"]
if !ok {
t.Fatal("windows-arm64 missing")
}
if !strings.HasSuffix(arm.URL, "/Reasonix-windows-arm64-installer.exe") {
t.Fatalf("windows-arm64 url = %q, want the installer, not the portable zip", arm.URL)
}
// The Linux updater channel must stay the .tar.gz; the co-located .deb is a
// human download and must not shadow the linux-amd64 key.
lin, ok := m.Platforms["linux-amd64"]
if !ok {
t.Fatal("linux-amd64 missing")
}
if !strings.HasSuffix(lin.URL, "/Reasonix-linux-amd64.tar.gz") {
t.Fatalf("linux-amd64 url = %q, want the .tar.gz, not the .deb", lin.URL)
}
}
+11
View File
@@ -0,0 +1,11 @@
package main
import "fmt"
func installerCommandLine(installer, dir string) string {
line := fmt.Sprintf(`"%s" /S`, installer)
if dir != "" {
line += " /D=" + dir
}
return line
}
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"strings"
"testing"
)
func TestInstallerCommandLineIsSilentAndLeavesDFlagLast(t *testing.T) {
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
want := `"C:\Temp\Reasonix Installer.exe" /S /D=D:\Tools\Reasonix App`
if got != want {
t.Fatalf("installerCommandLine = %q, want %q", got, want)
}
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
}
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows
package main
import (
"fmt"
"os"
)
func main() {
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows desktop builds")
os.Exit(2)
}
+108
View File
@@ -0,0 +1,108 @@
//go:build windows
package main
import (
"flag"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"syscall"
"time"
"golang.org/x/sys/windows"
)
const parentExitTimeout = 2 * time.Minute
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
var parentPID uint
var installer, installDir, relaunch string
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
if err := fs.Parse(args); err != nil {
return 2
}
logger := newLogger()
if installer == "" {
logger.Print("missing --installer")
return 2
}
if parentPID != 0 {
if err := waitForProcessExit(uint32(parentPID), parentExitTimeout); err != nil {
logger.Printf("wait for parent process %d: %v", parentPID, err)
return 1
}
}
if err := runInstaller(installer, installDir); err != nil {
logger.Printf("run installer: %v", err)
return 1
}
if relaunch != "" {
if err := startRelaunch(relaunch, installDir); err != nil {
logger.Printf("relaunch: %v", err)
return 1
}
}
return 0
}
func newLogger() *log.Logger {
dir, err := os.UserCacheDir()
if err == nil {
dir = filepath.Join(dir, "Reasonix", "updates")
if err := os.MkdirAll(dir, 0o700); err == nil {
if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
return log.New(f, "", log.LstdFlags)
}
}
}
return log.New(os.Stderr, "", log.LstdFlags)
}
func waitForProcessExit(pid uint32, timeout time.Duration) error {
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
if err != nil {
if err == windows.ERROR_INVALID_PARAMETER {
return nil
}
return err
}
defer windows.CloseHandle(h)
waitMS := uint32(timeout / time.Millisecond)
result, err := windows.WaitForSingleObject(h, waitMS)
if err != nil {
return err
}
switch result {
case windows.WAIT_OBJECT_0:
return nil
case uint32(windows.WAIT_TIMEOUT):
return fmt.Errorf("timed out after %s", timeout)
default:
return fmt.Errorf("unexpected wait result %d", result)
}
}
func runInstaller(installer, installDir string) error {
cmd := exec.Command(installer)
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir), HideWindow: true}
return cmd.Run()
}
func startRelaunch(relaunch, installDir string) error {
cmd := exec.Command(relaunch)
cmd.Dir = installDir
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd.Start()
}
+275
View File
@@ -0,0 +1,275 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"regexp"
"runtime"
"strings"
)
// crash_app.go is the crash/feedback/performance reporting surface. Reports are
// sent only on an explicit user click in the frontend UI — never automatically.
var crashEndpoint = "https://crash.reasonix.io/v1/report"
const maxCrashDetailBytes = 16 << 10
const maxCrashStackBytes = 8 << 10
const maxCrashFieldBytes = 4 << 10
const maxCrashBreadcrumbs = 30
var (
userPathSegment = regexp.MustCompile(`(?i)([A-Z]:\\Users\\|/(?:home|Users)/)[^/\\:\s"']+`)
emailPattern = regexp.MustCompile(`\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b`)
secretKeyValuePattern = regexp.MustCompile(`(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|authorization|secret|password|passwd|pwd|token)\b\s*[:=]\s*(?:Bearer\s+)?['"]?[^'"\s,;]+['"]?`)
bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{16,}`)
explicitKeyPattern = regexp.MustCompile(`\b(?:sk|rk)-(?:proj-)?[A-Za-z0-9_-]{16,}\b`)
envIdentifierPattern = regexp.MustCompile(`\b[A-Z][A-Z0-9_]*(?:API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD)[A-Z0-9_]*\b`)
jwtPattern = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b`)
longHexPattern = regexp.MustCompile(`\b[0-9a-fA-F]{32,}\b`)
longBase64Pattern = regexp.MustCompile(`[A-Za-z0-9+/]{40,}={0,2}`)
longBase64URLPattern = regexp.MustCompile(`\b[A-Za-z0-9_-]{48,}\b`)
)
func scrubUserPaths(s string) string {
return userPathSegment.ReplaceAllString(s, "${1}_")
}
func scrubSensitiveText(s string) string {
s = scrubUserPaths(s)
s = emailPattern.ReplaceAllString(s, "[redacted-email]")
s = bearerTokenPattern.ReplaceAllString(s, "Bearer [redacted]")
s = secretKeyValuePattern.ReplaceAllString(s, "${1}=[redacted]")
s = envIdentifierPattern.ReplaceAllString(s, "[redacted-env]")
s = jwtPattern.ReplaceAllString(s, "[redacted-jwt]")
s = explicitKeyPattern.ReplaceAllString(s, "[redacted-key]")
s = longHexPattern.ReplaceAllString(s, "[redacted-hex]")
s = longBase64Pattern.ReplaceAllString(s, "[redacted-token]")
s = longBase64URLPattern.ReplaceAllString(s, "[redacted-token]")
return s
}
type crashBreadcrumb struct {
T int64 `json:"t,omitempty"`
Cat string `json:"cat,omitempty"`
Msg string `json:"msg,omitempty"`
}
type crashReport struct {
Kind string `json:"kind"`
Version string `json:"version"`
OS string `json:"os"`
Arch string `json:"arch"`
Message string `json:"message"`
Device deviceInfo `json:"device"`
SchemaVersion int `json:"schemaVersion,omitempty"`
Source string `json:"source,omitempty"`
Label string `json:"label,omitempty"`
ErrorType string `json:"errorType,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
Stack string `json:"stack,omitempty"`
ComponentStack string `json:"componentStack,omitempty"`
TopFrame string `json:"topFrame,omitempty"`
BuildCommit string `json:"buildCommit,omitempty"`
Channel string `json:"channel,omitempty"`
Language string `json:"language,omitempty"`
View string `json:"view,omitempty"`
Breadcrumbs []crashBreadcrumb `json:"breadcrumbs,omitempty"`
OccurredAt string `json:"occurredAt,omitempty"`
}
type frontendCrashPayload struct {
SchemaVersion int `json:"schemaVersion"`
Kind string `json:"kind"`
Source string `json:"source"`
Label string `json:"label"`
Message string `json:"message"`
ErrorType string `json:"errorType"`
ErrorMessage string `json:"errorMessage"`
Stack string `json:"stack"`
ComponentStack string `json:"componentStack"`
TopFrame string `json:"topFrame"`
BuildCommit string `json:"buildCommit"`
Channel string `json:"channel"`
Language string `json:"language"`
View string `json:"view"`
Breadcrumbs []crashBreadcrumb `json:"breadcrumbs"`
OccurredAt string `json:"occurredAt"`
}
func normalizeReportKind(kind string) (string, bool) {
switch strings.TrimSpace(kind) {
case "crash", "exception", "feedback", "performance", "bot":
return strings.TrimSpace(kind), true
default:
return "", false
}
}
func clipCrashField(s string, max int) string {
if len(s) > max {
return s[:max]
}
return s
}
func sanitizeCrashField(s string, max int) string {
return clipCrashField(scrubUserPaths(strings.TrimSpace(s)), max)
}
func sanitizeCrashText(s string, max int) string {
return clipCrashField(strings.TrimSpace(scrubSensitiveText(s)), max)
}
func sanitizeBreadcrumbs(in []crashBreadcrumb) []crashBreadcrumb {
if len(in) > maxCrashBreadcrumbs {
in = in[len(in)-maxCrashBreadcrumbs:]
}
out := make([]crashBreadcrumb, 0, len(in))
for _, b := range in {
cat := sanitizeCrashField(b.Cat, 64)
msg := sanitizeCrashText(b.Msg, 240)
if cat == "" && msg == "" {
continue
}
out = append(out, crashBreadcrumb{T: b.T, Cat: cat, Msg: msg})
}
return out
}
func baseCrashReport(kind string) crashReport {
return crashReport{
Kind: kind,
Version: version,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Device: collectDeviceInfo(),
Channel: channel,
}
}
func topFrameFromStack(stack string) string {
for _, line := range strings.Split(stack, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.Contains(line, ".go:") || strings.Contains(line, ".ts:") || strings.Contains(line, ".tsx:") || strings.Contains(line, ".js:") || strings.Contains(line, ".jsx:") {
if strings.Contains(line, "/runtime/") || strings.Contains(line, `\runtime\`) || strings.Contains(line, "crash_pending.go") {
continue
}
return sanitizeCrashText(line, 300)
}
}
return ""
}
func nativeResourceContext() string {
var m runtime.MemStats
runtime.ReadMemStats(&m)
mb := func(n uint64) string {
return fmt.Sprintf("%.1f MB", float64(n)/1024/1024)
}
return strings.Join([]string{
"go heap alloc: " + mb(m.Alloc),
"go heap sys: " + mb(m.HeapSys),
"go total sys: " + mb(m.Sys),
fmt.Sprintf("goroutines: %d", runtime.NumGoroutine()),
fmt.Sprintf("gc cycles: %d", m.NumGC),
}, "\n")
}
func appendNativeResourceContext(kind, message string) string {
if kind != "performance" {
return message
}
return sanitizeCrashText(message+"\n\n--- native runtime context ---\n"+nativeResourceContext(), maxCrashDetailBytes)
}
func crashReportFromDetail(kind, detail string) (crashReport, error) {
rawKind := kind
kind, ok := normalizeReportKind(kind)
if !ok {
return crashReport{}, fmt.Errorf("unknown report kind %q", rawKind)
}
if strings.TrimSpace(detail) == "" {
return crashReport{}, fmt.Errorf("empty report")
}
r := baseCrashReport(kind)
var payload frontendCrashPayload
if json.Unmarshal([]byte(detail), &payload) == nil && payload.SchemaVersion == 2 {
if payloadKind, ok := normalizeReportKind(payload.Kind); ok {
r.Kind = payloadKind
}
r.SchemaVersion = payload.SchemaVersion
r.Source = sanitizeCrashField(payload.Source, 32)
r.Label = sanitizeCrashField(payload.Label, 64)
r.ErrorType = sanitizeCrashField(payload.ErrorType, 128)
r.ErrorMessage = sanitizeCrashText(payload.ErrorMessage, maxCrashFieldBytes)
r.Stack = sanitizeCrashText(payload.Stack, maxCrashStackBytes)
r.ComponentStack = sanitizeCrashText(payload.ComponentStack, maxCrashStackBytes)
r.TopFrame = sanitizeCrashText(payload.TopFrame, 300)
r.BuildCommit = sanitizeCrashField(payload.BuildCommit, 64)
r.Channel = sanitizeCrashField(payload.Channel, 32)
r.Language = sanitizeCrashField(payload.Language, 64)
r.View = sanitizeCrashText(payload.View, 200)
r.Breadcrumbs = sanitizeBreadcrumbs(payload.Breadcrumbs)
r.OccurredAt = sanitizeCrashField(payload.OccurredAt, 64)
r.Message = sanitizeCrashText(payload.Message, maxCrashDetailBytes)
if r.TopFrame == "" {
r.TopFrame = topFrameFromStack(r.Stack)
}
if r.Message == "" {
r.Message = sanitizeCrashText(fmt.Sprintf("[%s]\n\n%s", r.Label, r.ErrorMessage), maxCrashDetailBytes)
}
if r.Source == "" {
r.Source = "frontend"
}
r.Message = appendNativeResourceContext(r.Kind, r.Message)
return r, nil
}
r.SchemaVersion = 1
r.Source = "legacy"
r.Label = kind
r.Message = sanitizeCrashText(detail, maxCrashDetailBytes)
r.Message = appendNativeResourceContext(r.Kind, r.Message)
return r, nil
}
func (a *App) ReportCrash(kind, detail string) error {
r, err := crashReportFromDetail(kind, detail)
if err != nil {
return err
}
c, err := httpClient()
if err != nil {
return err
}
return postCrashReport(a.reqCtx(), c, crashEndpoint, r)
}
func postCrashReport(ctx context.Context, c *http.Client, endpoint string, r crashReport) error {
body, err := json.Marshal(r)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("crash endpoint returned %s", resp.Status)
}
return nil
}
+207
View File
@@ -0,0 +1,207 @@
package main
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
func TestScrubUserPaths(t *testing.T) {
cases := map[string]string{
`at C:\Users\yuhua\proj\app.ts:12:3`: `at C:\Users\_\proj\app.ts:12:3`,
`at c:\users\someone\x.go`: `at c:\users\_\x.go`,
`/home/bob/.reasonix/config.toml`: `/home/_/.reasonix/config.toml`,
`/Users/alice/Library/Logs`: `/Users/_/Library/Logs`,
`Error: ENOENT open '/home/bob/secret'`: `Error: ENOENT open '/home/_/secret'`,
`no user path here: /usr/lib/node`: `no user path here: /usr/lib/node`,
"first /home/a/x\nsecond C:\\Users\\b\\y": "first /home/_/x\nsecond C:\\Users\\_\\y",
}
for in, want := range cases {
if got := scrubUserPaths(in); got != want {
t.Errorf("scrubUserPaths(%q) = %q, want %q", in, got, want)
}
}
}
func TestScrubSensitiveText(t *testing.T) {
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
bearer := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
longHex := "0123456789abcdef0123456789abcdef"
jwt := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signature"
got := scrubSensitiveText("user [email protected] Authorization: Bearer " + bearer + " api_key=" + apiKey + " jwt " + jwt + " hash " + longHex + " env FEISHU_BOT_APP_SECRET WEIXIN_BOT_TOKEN short abc1234 path /Users/alice/x")
for _, leaked := range []string{"[email protected]", bearer, apiKey, jwt, longHex, "FEISHU_BOT_APP_SECRET", "WEIXIN_BOT_TOKEN", "alice"} {
if strings.Contains(got, leaked) {
t.Fatalf("sensitive text leaked %q in %q", leaked, got)
}
}
for _, want := range []string{"[redacted-email]", "Authorization=[redacted]", "api_key=[redacted]", "[redacted-jwt]", "[redacted-hex]", "[redacted-env]", "short abc1234", "/Users/_/x"} {
if !strings.Contains(got, want) {
t.Fatalf("scrubSensitiveText() = %q, want it to contain %q", got, want)
}
}
}
func TestPostCrashReport(t *testing.T) {
var got crashReport
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("content-type = %q", ct)
}
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &got); err != nil {
t.Errorf("body not JSON: %v", err)
}
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
r := crashReport{Kind: "crash", Version: "v9.9.9", OS: "windows", Arch: "amd64", Message: "[react]\nboom"}
if err := postCrashReport(context.Background(), srv.Client(), srv.URL, r); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, r) {
t.Errorf("server received %+v, want %+v", got, r)
}
}
func TestPostCrashReportRejectedStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
err := postCrashReport(context.Background(), srv.Client(), srv.URL, crashReport{Kind: "crash"})
if err == nil || !strings.Contains(err.Error(), "429") {
t.Fatalf("want 429 error, got %v", err)
}
}
func TestReportCrashRejectsBadInput(t *testing.T) {
app := NewApp()
if err := app.ReportCrash("telemetry", "x"); err == nil {
t.Error("unknown kind should be rejected")
}
if err := app.ReportCrash("crash", ""); err == nil {
t.Error("empty detail should be rejected")
}
}
func TestCrashReportFromStructuredDetail(t *testing.T) {
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
secretHex := "abcdefabcdefabcdefabcdefabcdef12"
buildCommit := "0123456789abcdef0123456789abcdef01234567"
payload := frontendCrashPayload{
SchemaVersion: 2,
Kind: "exception",
Source: "frontend",
Label: "unhandledrejection",
Message: "[unhandledrejection]\n\ninvalid argument at C:\\Users\\alice\\app.ts:1 from [email protected]",
ErrorType: "TypeError",
ErrorMessage: "invalid argument at /Users/alice/project/app.ts api_key=" + apiKey,
Stack: "TypeError: invalid argument\n at run (/Users/alice/project/app.ts:12:3)\nsecret=" + secretHex,
TopFrame: "at run (/Users/alice/project/app.ts:12:3)",
BuildCommit: buildCommit,
Channel: "canary",
Language: "zh-CN",
View: "wails://wails.localhost/index.html?token=" + secretHex,
Breadcrumbs: []crashBreadcrumb{{T: 1, Cat: "bridge", Msg: "turn SubmitToTab token=" + apiKey}},
}
detail, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
r, err := crashReportFromDetail("crash", string(detail))
if err != nil {
t.Fatal(err)
}
if r.Kind != "exception" || r.Source != "frontend" || r.Label != "unhandledrejection" {
t.Fatalf("structured fields not preserved: %+v", r)
}
if strings.Contains(r.Message, "alice") || strings.Contains(r.ErrorMessage, "alice") || strings.Contains(r.Stack, "alice") {
t.Fatalf("user path was not scrubbed: %+v", r)
}
if r.TopFrame == "" || r.BuildCommit != buildCommit || r.Channel != "canary" || len(r.Breadcrumbs) != 1 {
t.Fatalf("metadata missing: %+v", r)
}
freeText := strings.Join([]string{
r.Message,
r.ErrorMessage,
r.Stack,
r.ComponentStack,
r.TopFrame,
r.View,
r.Breadcrumbs[0].Msg,
}, "\n")
for _, leaked := range []string{apiKey, secretHex, "[email protected]"} {
if strings.Contains(freeText, leaked) {
t.Fatalf("sensitive value leaked %q in %+v", leaked, r)
}
}
}
func TestCrashReportFromPerformanceDetail(t *testing.T) {
payload := frontendCrashPayload{
SchemaVersion: 2,
Kind: "performance",
Source: "frontend.performance",
Label: "performance.pressure",
Message: "[performance.pressure]\n\n--- performance context ---\nreason: event loop lag 1300ms",
ErrorType: "PerformancePressure",
ErrorMessage: "UI responsiveness degraded because the app observed long tasks, event-loop lag, or high JS heap pressure.",
TopFrame: "frontend.performance",
}
detail, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
r, err := crashReportFromDetail("performance", string(detail))
if err != nil {
t.Fatal(err)
}
if r.Kind != "performance" || r.Source != "frontend.performance" || r.Label != "performance.pressure" {
t.Fatalf("performance fields not preserved: %+v", r)
}
if !strings.Contains(r.Message, "--- native runtime context ---") || !strings.Contains(r.Message, "goroutines:") {
t.Fatalf("native runtime context missing from performance report: %q", r.Message)
}
}
func TestCrashReportFromBotDetail(t *testing.T) {
token := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
payload := frontendCrashPayload{
SchemaVersion: 2,
Kind: "bot",
Source: "bot.runtime",
Label: "bot.feishu.lark.send",
Message: "[bot]\n\nfailed at /Users/alice/project with token=" + token,
ErrorType: "BotConnectionDiagnostic",
ErrorMessage: "send failed with Bearer " + token,
TopFrame: "bot.send",
}
detail, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
r, err := crashReportFromDetail("bot", string(detail))
if err != nil {
t.Fatal(err)
}
if r.Kind != "bot" || r.Source != "bot.runtime" || r.Label != "bot.feishu.lark.send" {
t.Fatalf("bot fields not preserved: %+v", r)
}
if strings.Contains(r.Message, "alice") || strings.Contains(r.Message, token) || strings.Contains(r.ErrorMessage, token) {
t.Fatalf("bot report was not scrubbed: %+v", r)
}
if strings.Contains(r.Message, "--- native runtime context ---") {
t.Fatalf("bot report should not include performance runtime context: %q", r.Message)
}
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime/debug"
"reasonix/internal/config"
)
// crash_pending.go captures Go-side panics to disk and ships them on the next
// launch. Frontend crashes are click-to-send, but an unrecovered Go panic kills the
// process before the user can react, so the whole agent/provider/tool layer would
// otherwise never surface a single report. The resend is gated on the same
// desktop.telemetry opt-out as the launch ping.
const pendingCrashFile = "crash-pending.json"
func pendingCrashPath() string {
return filepath.Join(config.MemoryUserDir(), pendingCrashFile)
}
// recoverToPending records a panicking goroutine to the pending-crash file and
// re-raises, so the process still crashes exactly as before — the stack is now
// shipped next launch instead of lost.
func (a *App) recoverToPending(site string) {
r := recover()
if r == nil {
return
}
writePendingCrash(site, r, debug.Stack())
panic(r)
}
func writePendingCrash(site string, r any, stack []byte) {
stackText := string(stack)
msg := sanitizeCrashText(fmt.Sprintf("[go panic] %s: %v\n\n%s", site, r, stackText), maxCrashDetailBytes)
report := baseCrashReport("crash")
report.SchemaVersion = 2
report.Source = "go"
report.Label = sanitizeCrashField(site, 64)
report.ErrorType = sanitizeCrashField(fmt.Sprintf("%T", r), 128)
report.ErrorMessage = sanitizeCrashText(fmt.Sprint(r), maxCrashFieldBytes)
report.Stack = sanitizeCrashText(stackText, maxCrashStackBytes)
report.TopFrame = topFrameFromStack(report.Stack)
report.Message = msg
_ = writePendingReport(report, true)
}
func writePendingReport(report crashReport, overwrite bool) bool {
body, err := json.Marshal(report)
if err != nil {
return false
}
path := pendingCrashPath()
if os.MkdirAll(filepath.Dir(path), 0o755) != nil {
return false
}
if overwrite {
return os.WriteFile(path, body, 0o644) == nil
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return false
}
defer f.Close()
n, err := f.Write(body)
if err != nil || n != len(body) {
_ = os.Remove(path)
return false
}
return true
}
func (a *App) goSafe(site string, fn func()) {
go func() {
defer a.recoverToPending(site)
fn()
}()
}
// flushPendingCrash drains a Go panic captured on a prior run and POSTs it, then
// clears it. Runs at launch alongside the ping; honours the telemetry opt-out by
// dropping the file unsent.
func (a *App) flushPendingCrash() {
if version == "dev" {
return
}
path := pendingCrashPath()
body, err := os.ReadFile(path)
if err != nil {
return
}
cfg, err := config.Load()
if err != nil || !cfg.DesktopTelemetry() {
_ = os.Remove(path)
return
}
var r crashReport
if json.Unmarshal(body, &r) != nil {
_ = os.Remove(path)
return
}
c, err := httpClient()
if err != nil {
return
}
if postCrashReport(a.bootContext(), c, crashEndpoint, r) == nil {
_ = os.Remove(path)
}
}
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
)
func readPending(t *testing.T) (crashReport, bool) {
t.Helper()
body, err := os.ReadFile(pendingCrashPath())
if err != nil {
return crashReport{}, false
}
var r crashReport
if err := json.Unmarshal(body, &r); err != nil {
t.Fatalf("pending file not valid JSON: %v", err)
}
return r, true
}
func TestRecoverToPendingCapturesAndReraises(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
func() {
defer func() {
if recover() == nil {
t.Fatal("recoverToPending must re-raise the panic")
}
}()
app := NewApp()
defer app.recoverToPending("unit")
panic(`boom at C:\Users\alice\proj\x.go`)
}()
r, ok := readPending(t)
if !ok {
t.Fatal("expected a pending crash file")
}
if r.Kind != "crash" {
t.Errorf("kind = %q, want crash", r.Kind)
}
if !strings.Contains(r.Message, "[go panic] unit:") {
t.Errorf("message missing site prefix: %q", r.Message)
}
if r.Source != "go" || r.Label != "unit" || r.ErrorMessage == "" || r.Stack == "" || r.TopFrame == "" {
t.Errorf("structured panic metadata missing: %+v", r)
}
if strings.Contains(r.Message, `Users\alice`) {
t.Errorf("message not scrubbed: %q", r.Message)
}
}
func TestWritePendingCrashCaps(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
writePendingCrash("big", "x", []byte(strings.Repeat("a", 64<<10)))
r, ok := readPending(t)
if !ok {
t.Fatal("expected a pending crash file")
}
if len(r.Message) > maxCrashDetailBytes {
t.Errorf("message len = %d, want <= %d", len(r.Message), maxCrashDetailBytes)
}
}
func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
writePendingCrash("panic", "boom", []byte("stack"))
before, ok := readPending(t)
if !ok {
t.Fatal("expected initial pending crash")
}
hang := baseCrashReport("performance")
hang.Source = "native.watchdog"
hang.Label = "mac.main_thread.hang"
hang.Message = "hang"
if writePendingReport(hang, false) {
t.Fatal("writePendingReport overwrite=false should not replace existing report")
}
after, ok := readPending(t)
if !ok {
t.Fatal("expected pending crash after skipped write")
}
if after.Label != before.Label || after.Message != before.Message {
t.Fatalf("pending crash was overwritten: before=%+v after=%+v", before, after)
}
}
func TestWritePendingReportNonOverwriteAllowsOneConcurrentWriter(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
const writers = 32
start := make(chan struct{})
var ready sync.WaitGroup
var done sync.WaitGroup
var successes atomic.Int32
for i := 0; i < writers; i++ {
ready.Add(1)
done.Add(1)
go func() {
defer done.Done()
report := baseCrashReport("performance")
report.Source = "native.watchdog"
report.Label = "mac.main_thread.hang"
report.Message = strings.Repeat("hang", 1024)
ready.Done()
<-start
if writePendingReport(report, false) {
successes.Add(1)
}
}()
}
ready.Wait()
close(start)
done.Wait()
if got := successes.Load(); got != 1 {
t.Fatalf("successful non-overwrite writers = %d, want 1", got)
}
if _, ok := readPending(t); !ok {
t.Fatal("expected one pending report")
}
}
func TestWritePendingCrashScrubsSensitiveText(t *testing.T) {
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
bearer := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
longHex := "0123456789abcdef0123456789abcdef"
writePendingCrash("unit", "boom api_key="+apiKey+" user [email protected]", []byte("goroutine\nAuthorization: Bearer "+bearer+"\n/home/alice/project/x.go:12\nhash "+longHex))
r, ok := readPending(t)
if !ok {
t.Fatal("expected a pending crash file")
}
freeText := strings.Join([]string{r.Message, r.ErrorMessage, r.Stack, r.TopFrame}, "\n")
for _, leaked := range []string{apiKey, bearer, longHex, "[email protected]", "/home/alice"} {
if strings.Contains(freeText, leaked) {
t.Fatalf("sensitive value leaked %q in %+v", leaked, r)
}
}
}
func TestFlushPendingCrashSendsAndClears(t *testing.T) {
oldVersion, oldEndpoint := version, crashEndpoint
t.Cleanup(func() {
version, crashEndpoint = oldVersion, oldEndpoint
os.Remove(pendingCrashPath())
})
version = "v9.9.9"
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
crashEndpoint = srv.URL
writePendingCrash("flush", "boom", []byte("stack"))
NewApp().flushPendingCrash()
if hits.Load() != 1 {
t.Errorf("server hits = %d, want 1", hits.Load())
}
if _, ok := readPending(t); ok {
t.Error("pending file should be cleared after a successful send")
}
}
func TestFlushPendingCrashDevGuard(t *testing.T) {
oldVersion := version
t.Cleanup(func() {
version = oldVersion
os.Remove(pendingCrashPath())
})
version = "dev"
writePendingCrash("dev", "boom", []byte("stack"))
NewApp().flushPendingCrash()
if _, ok := readPending(t); !ok {
t.Error("dev build must leave the pending file untouched")
}
}
+237
View File
@@ -0,0 +1,237 @@
package main
import (
"errors"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
"reasonix/internal/agent"
)
// deferredRebuildRetryInterval is how often the retry loop probes a held
// session lease. Package-level so tests can shorten it.
var deferredRebuildRetryInterval = 2 * time.Second
// deferredRebuildState tracks tabs whose settings were saved to disk but whose
// runtime could not refresh because the session lease was held by another
// Reasonix process. A single background loop probes the lease and replays the
// rebuild once the other side releases it. The loop only runs after
// enableDeferredRebuildRetry (the wails startup hook); tests that never call
// it get the pending bookkeeping without a background goroutine.
type deferredRebuildState struct {
mu sync.Mutex
pending map[string]string // tab ID -> setting label for notices
enabled bool
running bool
stopped bool
stop chan struct{}
}
// enableDeferredRebuildRetry arms the retry loop; called from startup.
func (a *App) enableDeferredRebuildRetry() {
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
d.enabled = true
a.startDeferredRebuildLoopLocked()
}
// startDeferredRebuildLoopLocked starts the loop when it is armed, idle, and
// has work. Callers must hold d.mu.
func (a *App) startDeferredRebuildLoopLocked() {
d := &a.deferredRebuild
if !d.enabled || d.running || d.stopped || len(d.pending) == 0 {
return
}
d.running = true
if d.stop == nil {
d.stop = make(chan struct{})
}
go a.deferredRebuildLoop(d.stop)
}
// scheduleDeferredRebuild records that tabID needs a runtime refresh for
// setting and starts the retry loop if it is not running yet. Repeated calls
// for the same tab collapse into one retry carrying the latest label.
func (a *App) scheduleDeferredRebuild(tabID, setting string) {
tabID = strings.TrimSpace(tabID)
if tabID == "" {
return
}
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
if d.stopped {
return
}
if d.pending == nil {
d.pending = map[string]string{}
}
d.pending[tabID] = setting
a.startDeferredRebuildLoopLocked()
}
func (a *App) clearDeferredRebuild(tabID string) {
d := &a.deferredRebuild
d.mu.Lock()
delete(d.pending, tabID)
d.mu.Unlock()
}
func (a *App) deferredRebuildPending(tabID string) bool {
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
_, ok := d.pending[tabID]
return ok
}
// stopDeferredRebuildRetry permanently stops the retry loop; used on shutdown
// and by tests.
func (a *App) stopDeferredRebuildRetry() {
d := &a.deferredRebuild
d.mu.Lock()
defer d.mu.Unlock()
if d.stopped {
return
}
d.stopped = true
if d.stop != nil {
close(d.stop)
}
}
func (a *App) deferredRebuildLoop(stop <-chan struct{}) {
ticker := time.NewTicker(deferredRebuildRetryInterval)
defer ticker.Stop()
for {
select {
case <-stop:
d := &a.deferredRebuild
d.mu.Lock()
d.running = false
d.mu.Unlock()
return
case <-ticker.C:
}
if a.deferredRebuildTickDone() {
return
}
}
}
// deferredRebuildTickDone runs one retry pass and reports true when the loop
// should exit because nothing is pending anymore.
func (a *App) deferredRebuildTickDone() bool {
d := &a.deferredRebuild
d.mu.Lock()
if d.stopped || len(d.pending) == 0 {
d.running = false
d.mu.Unlock()
return true
}
pending := make(map[string]string, len(d.pending))
for id, setting := range d.pending {
pending[id] = setting
}
d.mu.Unlock()
for tabID, setting := range pending {
a.retryDeferredRebuild(tabID, setting)
}
return false
}
func (a *App) retryDeferredRebuild(tabID, setting string) {
if a.ctx == nil {
return
}
tab := a.tabByID(tabID)
if tab == nil || tab.ID != tabID {
// The tab is gone; nothing left to refresh.
a.clearDeferredRebuild(tabID)
return
}
// Hold the rebuild mutex across probe + rebuild: the probe briefly acquires
// the session lease, and a concurrent manual rebuild's ensureSessionLease
// would see that probe as "held by another runtime" and spuriously defer.
a.runtimeRebuildMu.Lock()
defer a.runtimeRebuildMu.Unlock()
// rebuildSettingLocked refreshes the active tab only. Wait until the user
// is back on this tab so we refresh the runtime the pending setting was
// meant for, not whichever tab happens to be focused.
if a.activeTab() != tab {
return
}
ctrl := a.controllerForTab(tab)
if ctrl == nil {
// Mid-(re)build on another path (provider retarget, workspace repair);
// racing a second build+swap against it is what this loop must avoid.
return
}
if controllerHasActiveRuntimeWork(ctrl) {
return
}
if !a.deferredRebuildLeaseLooksFree(tab) {
return
}
err := a.rebuildSettingLocked(setting)
if err == nil {
// rebuildSettingLocked already cleared the pending entry for the tab it
// refreshed; just announce it.
a.noticeForTab(tabID, fmt.Sprintf("%s applied: session refreshed after the lease was released", setting))
return
}
if errors.Is(err, agent.ErrSessionLeaseHeld) {
return // grabbed back before we could rebuild; keep waiting
}
var busy *rebuildBusyError
if errors.As(err, &busy) {
return // a turn started meanwhile; retry once it finishes
}
// Anything else will not resolve by waiting; give up loudly instead of
// retrying forever.
a.clearDeferredRebuild(tabID)
slog.Warn("desktop: deferred settings rebuild failed", "setting", setting, "tab", tabID, "err", err)
a.warnForTab(tabID, fmt.Sprintf("%s was saved but the session could not refresh: %s", setting, err.Error()))
}
// deferredRebuildLeaseLooksFree cheaply probes whether the tab's session lease
// could be acquired right now, without touching tab.sessionLease (only the
// serialized rebuild paths may mutate that). The probe path can lag the
// reconciled path the rebuild will use; a stale answer either re-defers on the
// next tick or lets the rebuild fail back into the pending set, so a mismatch
// only delays the retry.
func (a *App) deferredRebuildLeaseLooksFree(tab *WorkspaceTab) bool {
a.mu.RLock()
ctrl := tab.Ctrl
path := strings.TrimSpace(tab.SessionPath)
a.mu.RUnlock()
if ctrl != nil {
if p := strings.TrimSpace(ctrl.SessionPath()); p != "" {
path = p
}
}
if path == "" {
return true // nothing to probe; let the rebuild decide
}
lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(path))
if err != nil {
var leaseErr *agent.SessionLeaseError
if errors.As(err, &leaseErr) && leaseErr.Info != nil && leaseErr.Info.PID == os.Getpid() {
if host, _ := os.Hostname(); leaseErr.Info.Hostname == host {
// This process (usually this very tab) holds the probed path;
// the blocking lease is some other path. Attempt the rebuild
// and let its own lease checks decide.
return true
}
}
return !errors.Is(err, agent.ErrSessionLeaseHeld)
}
lease.Release()
return true
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"runtime"
"strconv"
"strings"
)
// devinfo.go collects coarse machine facts attached to crash reports: OS version,
// CPU model, core count, RAM. Nothing here identifies a user or machine.
type deviceInfo struct {
OSVersion string `json:"osVersion,omitempty"`
CPU string `json:"cpu,omitempty"`
Cores int `json:"cores"`
RAMGB int `json:"ramGb,omitempty"`
}
const gib = 1 << 30
func collectDeviceInfo() deviceInfo {
return deviceInfo{
OSVersion: platformOSVersion(),
CPU: platformCPU(),
Cores: runtime.NumCPU(),
RAMGB: int((platformRAMBytes() + gib/2) / gib),
}
}
func parseCPUModel(cpuinfo string) string {
for _, line := range strings.Split(cpuinfo, "\n") {
if name, ok := strings.CutPrefix(line, "model name"); ok {
if _, v, ok := strings.Cut(name, ":"); ok {
return strings.TrimSpace(v)
}
}
}
return ""
}
func parseMemTotalBytes(meminfo string) uint64 {
for _, line := range strings.Split(meminfo, "\n") {
if rest, ok := strings.CutPrefix(line, "MemTotal:"); ok {
kb, err := strconv.ParseUint(strings.TrimSuffix(strings.TrimSpace(rest), " kB"), 10, 64)
if err != nil {
return 0
}
return kb * 1024
}
}
return 0
}
func parseOSReleasePrettyName(osRelease string) string {
for _, line := range strings.Split(osRelease, "\n") {
if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
return strings.Trim(strings.TrimSpace(v), `"`)
}
}
return ""
}
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"os/exec"
"strconv"
"strings"
)
func sysctlString(name string) string {
out, err := exec.Command("sysctl", "-n", name).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func platformOSVersion() string {
out, err := exec.Command("sw_vers", "-productVersion").Output()
if err != nil {
return "macOS"
}
return "macOS " + strings.TrimSpace(string(out))
}
func platformCPU() string {
return sysctlString("machdep.cpu.brand_string")
}
func platformRAMBytes() uint64 {
n, err := strconv.ParseUint(sysctlString("hw.memsize"), 10, 64)
if err != nil {
return 0
}
return n
}
+26
View File
@@ -0,0 +1,26 @@
package main
import "os"
func readOr(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return string(b)
}
func platformOSVersion() string {
if name := parseOSReleasePrettyName(readOr("/etc/os-release")); name != "" {
return name
}
return "Linux"
}
func platformCPU() string {
return parseCPUModel(readOr("/proc/cpuinfo"))
}
func platformRAMBytes() uint64 {
return parseMemTotalBytes(readOr("/proc/meminfo"))
}
+40
View File
@@ -0,0 +1,40 @@
package main
import "testing"
func TestParseCPUModel(t *testing.T) {
cpuinfo := "processor\t: 0\nvendor_id\t: GenuineIntel\nmodel name\t: Intel(R) Core(TM) i7-12700K\nflags\t: fpu vme\n"
if got := parseCPUModel(cpuinfo); got != "Intel(R) Core(TM) i7-12700K" {
t.Errorf("parseCPUModel = %q", got)
}
if got := parseCPUModel("no such field"); got != "" {
t.Errorf("parseCPUModel on garbage = %q, want empty", got)
}
}
func TestParseMemTotalBytes(t *testing.T) {
meminfo := "MemTotal: 32652284 kB\nMemFree: 1234 kB\n"
if got := parseMemTotalBytes(meminfo); got != 32652284*1024 {
t.Errorf("parseMemTotalBytes = %d", got)
}
if got := parseMemTotalBytes("MemFree: 1 kB"); got != 0 {
t.Errorf("parseMemTotalBytes without MemTotal = %d, want 0", got)
}
}
func TestParseOSReleasePrettyName(t *testing.T) {
osRelease := "NAME=\"Ubuntu\"\nPRETTY_NAME=\"Ubuntu 24.04.1 LTS\"\nID=ubuntu\n"
if got := parseOSReleasePrettyName(osRelease); got != "Ubuntu 24.04.1 LTS" {
t.Errorf("parseOSReleasePrettyName = %q", got)
}
}
func TestCollectDeviceInfoSane(t *testing.T) {
d := collectDeviceInfo()
if d.Cores < 1 {
t.Errorf("Cores = %d", d.Cores)
}
if d.OSVersion == "" {
t.Error("OSVersion empty")
}
}
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"fmt"
"strings"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
func platformOSVersion() string {
v := windows.RtlGetVersion()
return fmt.Sprintf("Windows %d.%d build %d", v.MajorVersion, v.MinorVersion, v.BuildNumber)
}
func platformCPU() string {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE)
if err != nil {
return ""
}
defer k.Close()
name, _, err := k.GetStringValue("ProcessorNameString")
if err != nil {
return ""
}
return strings.TrimSpace(name)
}
func platformRAMBytes() uint64 {
var kb uint64
proc := windows.NewLazySystemDLL("kernel32.dll").NewProc("GetPhysicallyInstalledSystemMemory")
if ret, _, _ := proc.Call(uintptr(unsafe.Pointer(&kb))); ret == 0 {
return 0
}
return kb * 1024
}
+54 -86
View File
@@ -9,26 +9,15 @@ import (
"reasonix/internal/fileutil"
)
// credentialsPath is the reasonix-owned global secrets file the settings panel
// writes API keys to — the same file `reasonix setup` writes and config.loadDotEnv
// reads, so a key set in the desktop app resolves for the CLI from any directory.
// Never a project .env: keys stay out of the user's project tree. Falls back to
// ~/.env only when the user config dir can't be resolved.
func credentialsPath() string {
if p := config.UserCredentialsPath(); p != "" {
return p
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, ".env")
}
return ".env"
// upsertDotEnv stores KEY=value in Reasonix's global .env and applies it to the
// running process so a rebuild picks it up without a restart.
func upsertDotEnv(key, value string) error {
_, err := config.SetCredential(key, value)
return err
}
// upsertDotEnv sets KEY=value in the global credentials file (replacing an
// existing KEY line, else appending), and applies it to the running process so a
// rebuild picks it up without a restart.
func upsertDotEnv(key, value string) error {
return upsertEnvFile(credentialsPath(), key, value)
func removeDotEnv(key string) error {
return config.RemoveCredential(key)
}
// upsertEnvFile merges KEY=value into a KEY=value file at path, preserving
@@ -86,80 +75,59 @@ func upsertEnvFile(path, key, value string) error {
return os.Setenv(key, value)
}
// envFileKeys returns the set of KEY names assigned in a KEY=value file, empty
// when the file is absent.
func envFileKeys(path string) map[string]bool {
keys := map[string]bool{}
data, err := os.ReadFile(path)
if err != nil {
return keys
}
for _, raw := range strings.Split(string(data), "\n") {
line := strings.TrimPrefix(strings.TrimSpace(raw), "export ")
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if k, _, ok := strings.Cut(line, "="); ok {
keys[strings.TrimSpace(k)] = true
}
}
return keys
}
// promoteProviderKeysToCredentials copies any configured provider api_key_env that
// currently resolves (from a project .env, ~/.env, or the OS env) into the global
// credentials file when it isn't there yet, so a key set for one workspace follows
// the user across every project. Promoted keys are then stripped from ~/.env so the
// credentials file is the single source of truth; a project's own .env is
// user-owned and left untouched.
func promoteProviderKeysToCredentials(cfg *config.Config) {
credPath := credentialsPath()
have := envFileKeys(credPath)
for _, p := range cfg.Providers {
env := strings.TrimSpace(p.APIKeyEnv)
if env == "" || have[env] {
continue
}
val := os.Getenv(env)
if val == "" {
continue
}
if err := upsertEnvFile(credPath, env, val); err != nil {
continue
}
have[env] = true
removeHomeEnvKey(env)
}
}
// removeHomeEnvKey deletes a single KEY=value assignment from ~/.env (the legacy
// fallback the old migration wrote to), leaving every other line intact. No-op when
// ~/.env is absent or the credentials store resolves to ~/.env itself.
func removeHomeEnvKey(key string) {
home, err := os.UserHomeDir()
if err != nil {
return
}
path := filepath.Join(home, ".env")
if sameConfigPath(path, credentialsPath()) {
return
func removeEnvFile(path, key string) error {
key = strings.TrimSpace(key)
if key == "" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return
if os.IsNotExist(err) {
return os.Unsetenv(key)
}
return err
}
var kept []string
removed := false
for _, raw := range strings.Split(string(data), "\n") {
check := strings.TrimPrefix(strings.TrimSpace(raw), "export ")
if k, _, ok := strings.Cut(check, "="); ok && strings.TrimSpace(k) == key {
removed = true
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
outLines := make([]string, 0, len(lines))
for _, ln := range lines {
t := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(ln), "export "))
if t == "" || strings.HasPrefix(t, "#") {
outLines = append(outLines, ln)
continue
}
kept = append(kept, raw)
if k, _, ok := strings.Cut(t, "="); ok && strings.TrimSpace(k) == key {
continue
}
outLines = append(outLines, ln)
}
if !removed {
return
out := ""
if len(outLines) > 0 {
out = strings.Join(outLines, "\n") + "\n"
}
_ = os.WriteFile(path, []byte(strings.Join(kept, "\n")), 0o600)
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
tmp, err := os.CreateTemp(dir, "credentials.*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
if _, err := tmp.WriteString(out); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
if err := fileutil.ReplaceFile(tmpPath, path); err != nil {
os.Remove(tmpPath)
return err
}
return os.Unsetenv(key)
}
+33 -49
View File
@@ -39,63 +39,47 @@ func TestUpsertEnvFile(t *testing.T) {
}
}
// TestPromoteProviderKeysLiftsProjectKeyAndStripsHomeEnv proves a provider key
// that resolves only from a project .env / ~/.env is copied into the global
// credentials store, removed from ~/.env, and that unrelated env vars are ignored.
func TestPromoteProviderKeysLiftsProjectKeyAndStripsHomeEnv(t *testing.T) {
func TestRemoveEnvFileDeletesKeyAndUnsetsProcessEnv(t *testing.T) {
path := filepath.Join(t.TempDir(), "credentials")
if err := os.WriteFile(path, []byte("# comment\nFOO=old\nexport BAR=remove\nBAZ=keep\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("BAR", "remove")
if err := removeEnvFile(path, "BAR"); err != nil {
t.Fatalf("remove: %v", err)
}
b, _ := os.ReadFile(path)
got := string(b)
for _, want := range []string{"# comment", "FOO=old", "BAZ=keep"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
if strings.Contains(got, "BAR=") {
t.Errorf("removed key should be absent:\n%s", got)
}
if _, ok := os.LookupEnv("BAR"); ok {
t.Errorf("process env BAR should be unset")
}
}
func TestLegacyHomeEnvProviderKeyIsNotPromoted(t *testing.T) {
home := isolateDesktopUserDirs(t)
homeEnv := filepath.Join(home, ".env")
if err := os.WriteFile(homeEnv, []byte("DEEPSEEK_API_KEY=sk-test\nNPM_TOKEN=secret\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("DEEPSEEK_API_KEY", "sk-test")
t.Setenv("NPM_TOKEN", "secret")
promoteProviderKeysToCredentials(config.Default())
cred, err := os.ReadFile(config.UserCredentialsPath())
if err != nil {
t.Fatalf("credentials not written: %v", err)
if _, err := config.LoadForRoot(t.TempDir()); err != nil {
t.Fatalf("LoadForRoot: %v", err)
}
if !strings.Contains(string(cred), "DEEPSEEK_API_KEY=sk-test") {
t.Errorf("provider key not promoted to credentials:\n%s", cred)
if data, err := os.ReadFile(config.UserCredentialsPath()); err == nil && strings.Contains(string(data), "DEEPSEEK_API_KEY") {
t.Errorf("legacy ~/.env provider key must not be imported:\n%s", data)
}
if strings.Contains(string(cred), "NPM_TOKEN") {
t.Errorf("non-provider env var must not be promoted:\n%s", cred)
}
rest, _ := os.ReadFile(homeEnv)
if strings.Contains(string(rest), "DEEPSEEK_API_KEY") {
t.Errorf("promoted key must be stripped from ~/.env:\n%s", rest)
}
if !strings.Contains(string(rest), "NPM_TOKEN=secret") {
t.Errorf("unrelated ~/.env line must survive:\n%s", rest)
}
}
// TestPromoteProviderKeysLeavesExistingCredentialsKey proves promotion never
// overwrites a key already in the credentials store and leaves ~/.env untouched.
func TestPromoteProviderKeysLeavesExistingCredentialsKey(t *testing.T) {
home := isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(config.UserCredentialsPath(), []byte("DEEPSEEK_API_KEY=sk-global\n"), 0o600); err != nil {
t.Fatal(err)
}
homeEnv := filepath.Join(home, ".env")
if err := os.WriteFile(homeEnv, []byte("DEEPSEEK_API_KEY=sk-stale\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("DEEPSEEK_API_KEY", "sk-global")
promoteProviderKeysToCredentials(config.Default())
cred, _ := os.ReadFile(config.UserCredentialsPath())
if !strings.Contains(string(cred), "DEEPSEEK_API_KEY=sk-global") {
t.Errorf("existing credentials key was changed:\n%s", cred)
}
if data, err := os.Stat(homeEnv); err != nil || data.Size() == 0 {
t.Errorf("~/.env should be untouched when key already global, err=%v", err)
if !strings.Contains(string(rest), "DEEPSEEK_API_KEY=sk-test") || !strings.Contains(string(rest), "NPM_TOKEN=secret") {
t.Errorf("legacy ~/.env should be left untouched:\n%s", rest)
}
}
+3
View File
@@ -13,3 +13,6 @@ wailsjs
# Wails dev install-checksum cache
package.json.md5
# This project uses pnpm (see wails.json `frontend:install`); reject the npm lockfile.
package-lock.json
+3
View File
@@ -0,0 +1,3 @@
# pnpm 11: don't re-check deps before `pnpm build` and never prompt to purge node_modules headless.
confirm-modules-purge=false
verify-deps-before-run=false
+1
View File
@@ -0,0 +1 @@
-3675
View File
File diff suppressed because it is too large. Load diff
+29 -16
View File
@@ -5,33 +5,46 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"build": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css && tsc --noEmit && vite build",
"preview": "vite preview",
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
},
"dependencies": {
"@gsap/react": "^2.1.2",
"@tanstack/react-virtual": "^3.14.3",
"gsap": "^3.15.0",
"highlight.js": "^11.10.0",
"katex": "^0.17.0",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"lucide-react": "^1.21.0",
"mermaid": "^11.16.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0"
"remark-math": "^6.0.0",
"svg-pan-zoom": "^3.6.2",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"@types/jsdom": "^28.0.3",
"@types/node": "^26.0.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"jsdom": "^29.1.1",
"terser": "^5.48.0",
"typescript": "^5.6.3",
"vite": "^6.0.7"
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vite": "^8.0.16"
},
"pnpm": {
"overrides": {
"mdast-util-gfm-autolink-literal": "2.0.0"
}
"overrides": {
"mdast-util-gfm-autolink-literal": "2.0.0"
}
}
+1782 -738
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -1,2 +1,5 @@
allowBuilds:
esbuild: true
overrides:
mdast-util-gfm-autolink-literal: 2.0.0
Loaded 100 of 1110 files, more files were not shown because too many files have changed in this diff. Show more