edit_file on a UTF-8 file carrying CJK text rewrote bytes it never touched:
continuation bytes of characters far from the edit came back as 84 31 A4 37.
Detect took a nil error from the GB18030 decoder as proof the file was
GB18030. The decoder never fails: it maps every invalid sequence to U+FFFD.
So any file that was not valid UTF-8 -- one stray byte is enough -- was read
as GB18030, and the read-modify-write tools (edit_file, multi_edit,
delete_range, write_file over an existing file), checkpoint restore and the
config migrations then re-encoded every unreadable sequence as the GB18030
form of U+FFFD.
Detect now names a charset only when decoding and re-encoding restores the
input byte for byte. GB18030 is tried first, then GBK, which restores what
GB18030 cannot such as CP936's single-byte euro 0x80, so such a file stays
editable in its own encoding; anything else is LossyUTF8, whose bytes pass
through untouched. The new Kind is appended because checkpoints persist it
by value.
GBK cannot hold every character, so Encode now returns an error instead of
falling back to UTF-8, which rewrote a whole CP936 file as UTF-8 when an edit
added a character such as an emoji. A legacy charset that cannot represent a
character answers an *UnencodableError naming it, its byte offset and the
charset, with ErrUnencodable as its identity; the file tools, checkpoint
restore and the config migrations refuse the write and leave the file as it
was. Fixtures encode through MustEncode.
Readers that hold only part of a stream say so rather than inherit the
whole-file answer:
- DetectFragment runs the round trip on the prefix the decoder consumes when
not at EOF, so a character the cut split is dropped structurally. grep's
8 KiB peek, read_file's 256 KiB sample and the desktop's 2 MiB file preview
use it; the preview's next page starts at the split character, and lossy
text is shown as text rather than as binary.
- DecodeOutput is the one entry point for bytes that are only displayed.
The producer says which ends its bound cut (Cut{Head, Tail}); UTF-8 is read
without a character split at a cut end, and an uncut end keeps its bytes,
since a GBK pair there is output rather than half a rune. What no charset
restores is still read as GB18030. Hook output passes its tail cut when
truncated.
- DetectAndDecode decodes a legacy charset once; the edit tools and
DecodeToUTF8 use it instead of detecting and decoding separately.
Tests that fail before this change (on the base or an earlier revision of it)
and pass here:
- TestE2EEditKeepsBytesOfUTF8FileWithStrayByte: e4 b8 ad became
e4 b8 84 31 a4 37 outside the edit (fails on origin/main-v2).
- TestDetectUTF8WithStrayByteIsNotGB18030 (fails on origin/main-v2).
- TestWritesRefuseCharacterCP936CannotHold (edit_file, multi_edit,
write_file): nil error and the whole CP936 file rewritten as UTF-8.
- TestDecodeOutputTrimsCutUTF8Edges: UTF-8 cut at the front read as mojibake.
- TestDecodeOutputKeepsUncutCodePageEdges, TestDecodeHookOutputKeepsUncutCodePageEdges:
GBK pairs at an uncut edge (bc db, b0 a1 0d 0a, 6f 6b 20 e4 a1,
b0 a1 20 6f 6b) were dropped as partial UTF-8 runes.
- TestEditKeepsCP936FileWithEuroByte: an edit adding Chinese wrote UTF-8
into a CP936 file.
- TestGrepGB18030PeekWithoutNewline, TestGrepGB18030PeekCutMidCharacter:
no matches past a peek cut mid-character.
- TestReadFileGB18030SampleWithoutNewline: garbled text past the sample.
- TestReadFileGB18030TruncatedWithoutNewline,
TestReadFileGB18030TruncatedMidCharacter (desktop): the 2 MiB preview of a
GB18030 file was garbled or ended in U+FFFD.
- TestDecodeHookOutputReadsTruncatedCodePageText,
TestDecodeHookOutputReadsCP936Euro: mojibake for cut or euro-carrying cmd.exe output.
- TestEncodeRefusesCharacterTheCharsetCannotHold, TestDetectGBKWithEuroByte,
TestDetectFragmentDropsTheSplitCharacter, TestDecodeOutputReadsCutCodePageText:
the API's contract.
Problem
Desktop session discovery still risked quarantining healthy catalogs on
busy/permission errors, rebuilding by renaming the live DB first, dropping
repair work beyond a fixed queue, and serving stale ProjectTree search results.
Off-lock controller builds could also re-publish removed MCP tools onto a shared
host, and shell-only project trees never expanded enough for lazy topic pages.
Root cause
Open treated every open failure as corruption; Rebuild swapped before validation;
repair enqueue was lossy; ProjectTree dropped concurrent queries while loading;
shared Host mutation raced plugin config changes; and active-folder expansion
required already-loaded topic children.
Fix
- Introduce internal/projectiondb for open/migrate/rebuild/quarantine with
corruption-only quarantine, future-schema memory fallback, atomic rebuild,
and 0700/0600 permissions.
- Route sessioncatalog through projectiondb; empty CacheDir uses memory; resume
scan_cursor; drain unknown repairs from the durable index; bound per-topic
session pages.
- ProjectTree uses request sequence + debounce and expands shell folders by
workspace identity so lazy pages and bench mock load.
- Capture extensionGeneration around off-lock boot.Build and abandon publication
when plugin/MCP config changes mid-build.
- Fix lint modernize issues and Windows migration-marker test flake.
Verification
- go test ./internal/sessioncatalog ./internal/projectiondb ./internal/pluginpkg
- go test -race ./internal/sessioncatalog ./internal/projectiondb
- cd desktop && go test -run 'Catalog|TopicMigration|ProjectTree.*Migrat'
- pnpm --dir desktop/frontend run typecheck
Problem:
The initial session catalog implementation exceeded incremental repository size and complexity budgets in several existing owner files, and Desktop CI reported stale helpers plus two modernization findings.
Root cause:
Catalog lifecycle, runtime projection, frontend topic presentation, mock bindings, plugin compatibility, and CLI wiring were colocated with already-large modules. Removing the eager project tree also left legacy runtime-tree helpers unused.
Fix:
- split catalog lifecycle from runtime projection and metadata mapping
- extract frontend catalog contracts, mock bridge, topic presentation, and count formatting
- isolate plugin compatibility, session projection, and CLI completion wiring
- remove obsolete eager-tree helpers and address CI modernization findings
- preserve behavior without changing repolint baselines
Verification:
- go run ./tools/repolint
- golangci-lint v2.12.2: 0 issues
- go test affected root packages
- focused Desktop catalog and workspace regression tests
- pnpm test:all
- pnpm build
Problem:
Desktop startup and project-tree queries could synchronously migrate and decode legacy session JSONL, blocking navigation, creation, and shutdown.
Root cause:
Session discovery, metadata repair, controller admission, and plugin startup shared UI-critical paths and broad lifecycle locks.
Fix:
- add a disposable SQLite session catalog with incremental reconciliation, repair checkpoints, corruption recovery, and memory fallback
- expose revisioned cursor pagination and migrate the desktop project tree to lazy loading
- move controller construction outside lifecycle admission and isolate incompatible plugin startup
- add session doctor and reindex commands while preserving authoritative JSONL and metadata
- retain CGO-free builds with platform-specific fallbacks
Verification:
- go test ./...
- cd desktop && go test ./...
- go test -race ./internal/sessioncatalog ./internal/agent
- cd desktop && go test -race .
- go vet ./... in root and desktop
- cd desktop/frontend && pnpm test:all && pnpm build
- CGO_ENABLED=0 cross-builds for supported macOS, Windows, and Linux targets
Move Claude root-instruction helpers out of the oversized parser file and keep marketplace fixtures line-neutral while preserving the official skill-based coverage.
Verification: go run ./tools/repolint; go test ./...; ./scripts/cache-guard.sh.
Problem: Reasonix synthesized a SessionStart context hook from a Claude plugin root CLAUDE.md, although Claude Code does not load that file as plugin project context.
Root cause: Claude manifests shared the Codex compatibility helper that always appended root instructions.
Fix: keep supported Claude convention imports without the synthetic root hook, document the distinction, and update install-source fixtures to use official skills.
Verification: go test ./...; ./scripts/cache-guard.sh; focused plugin and install-source tests; desktop plugin/theme tests.
Problem: Native v2 manifests could silently import Claude compatibility sidecars, including repository instructions and executable hooks.
Root cause: parseNativeV2 applied Claude compatibility discovery after validating the explicit manifest.
Fix: Treat the native v2 manifest as the complete capability boundary while preserving compatibility discovery for Codex and Claude manifests. Document the contract and cover it with a regression test.
Verification: go test ./...; cd desktop && go test . -run "Plugin|Theme" -count=1.
Problem:
Runtime publish gates, receipts, file priors, message deduplication, and stream cancellation were process-global, so rebuilding one session could drain or overwrite another session's lifecycle evidence. The v2 manifest path also still exposed migration behavior for versioned manifests.
Root cause:
Lifecycle state was wired through package globals instead of the controller/session lineage, and migration/parser compatibility retained assumptions from the unpublished manifest v1 design. Drain cancellation also allowed callbacks to register after a generation had already expired.
Fix:
Introduce an explicit RuntimeOwner carried through boot, controller, provider, UI, host streams, receipts, and file-write compensation. Reuse it only within a rebuild lineage, defer replacement publication until commit, reject draining turns, make late drain registrations cancel immediately, and enforce v2-only versioned manifests while limiting explicit migration to pre-extension manifests without apiVersion.
Verification:
- go test -race ./internal/agent ./internal/boot ./internal/control ./internal/extension ./internal/extension/providerext ./internal/extension/uihub ./internal/tool/builtin -count=1
- go test ./internal/extension ./internal/pluginpkg ./internal/recovery ./internal/agent ./internal/control ./internal/boot -count=1
- go test ./... -count=1 (sdk/go)
- focused Desktop plugin lifecycle tests
* refactor(go): adopt modern stdlib idioms across kernel and desktop
Mechanical sweep with golangci-lint's modernize/intrange/copyloopvar/
usestdlibvars fixers plus hand-fixes for what they could not rewrite:
slices.Contains, min/max, slices.Backward, WaitGroup.Go, reflect.TypeFor,
maps.Copy, strings.Cut/CutPrefix/SplitSeq, strings.Builder, range-over-int.
Net -549 lines with no behavior change.
Three loops in session.go stay index-based: slices.Backward yields element
copies, so the fixer's rewrite silently dropped writes to DecisionReceipts
and ToolCalls (caught by TestDelivery* and TestDependentSameBatch*). All 49
slices.Backward sites were audited for that shape; only these three write
through the index, and they carry a nolint with the reason.
CaptureAfter's scan loop had an empty body and never ran any logic, so the
fixer could not express it. Drop it and the accessor it was the only caller
of. omitzero is deliberately untouched: turning omitempty into omitzero
changes the JSON written for nested structs, which is a wire-format change
to session files and the extension protocol.
* build(lint): gate the modern-idiom linters
Nothing stopped the codebase from drifting back to pre-1.22 idioms. Both
modules are now clean for modernize, intrange, copyloopvar, and
usestdlibvars, so enable them rather than leaving the sweep as a one-off.
omitzero stays disabled with the reason inline; usetesting, perfsprint, and
errorlint still have residue and land separately.
Every `err == Sentinel` and `err.(*T)` in the tree works today only because the
error happens to arrive unwrapped. That is not a property anyone maintains: one
`%w` added between producer and check silently stops the branch from firing, and
this same change adds 54 of them. So the comparisons are converted first, then
the wrapping.
Three helpers turned out to be hand-rolled reimplementations of the standard
library and are deleted rather than patched: remote.isPassphraseMissing,
remote.asKeyError (which open-coded the Unwrap() walk), and forward.containsErr
(which open-coded both the single- and multi-error walks). Call sites use
errors.As/errors.Is.
Two assertions deliberately keep `!=`: explainError and AnnotateToolSchemaError
must return the *same* error object, and errors.Is would still pass if they
started wrapping it. Both carry a nolint naming that.
errorlint is enabled in .golangci.yml; both modules are clean under it.
Summary:
- make extension content eviction deterministic and plugin path checks portable
- stabilize the parallel-task cancellation coverage test
- keep POSIX mode assertions meaningful on Windows and serialize tmux fixtures safely
Verification:
- go test -covermode=atomic ./...
- go test -race on all changed packages
- go vet ./...
- Windows test binaries cross-compiled for all changed packages
Stage 7 of the unified extension kernel / Extension Protocol v1 plan.
Problem: extension sidecars could declare providers at handshake, but the
host had no way to resolve or stream from them.
Fix:
- internal/extension/providerext: a provider.Resolver that merges each
sidecar's catalog into the host resolver under
plugin/<plugin>/<provider>/<model> refs, plus a provider.Provider whose
Stream drives extension/provider/stream/open|cancel with broker-grade
client semantics: 1-based contiguous sequence buffering (duplicates and
stale chunks dropped, 256-slot bounded delivery queue, drain goroutine),
stream/end.lastSeq as the frozen terminal boundary with a 1s gap timer
that fails interrupted naming the missing sequence, context cancel that
sends stream/cancel, and crash handling that drains buffered chunks then
fails the stream interrupted — never falling back to another provider.
Reasoning-policy interfaces (tool-call reasoning, reasoning round-trip,
missing-reasoning warning) are answered from the descriptor, and effort
is baked into the provider at resolve time like built-in providers.
- Ref conflicts are explicit: a sidecar descriptor colliding with an
existing host ref fails BuildRuntime unless the plugin claimed
provider:<ref> in runtime.replaces, in which case it replaces the base
catalog entry (ParseSlot now accepts plugin-namespaced provider refs;
the kernel's single-owner slot rules apply).
- internal/extension/providerconv: shared host-side provider<->protocol
DTO conversions, extracted verbatim from the agent interceptor wiring
so both consumers share one conversion path (agent behavior unchanged).
- sidecar.Client gains typed provider RPCs and a SetStreamRouter seam
with a Disconnected signal used for crash propagation; boot wraps the
effective resolver (local or remote broker — precedence preserved) and
exposes it as BuildResult.ProviderResolver. Credentials never cross the
wire: stream/open carries only ref, model, effort, and the request.
Verification: go test ./internal/extension/... ./internal/agent
./internal/boot pass (providerext and sidecar also under -race); boot E2E
with a re-exec fake sidecar resolves plugin/providerdemo/fake/x and
streams text+usage over the real wire; out-of-order/duplicate/missing/
late chunks, cancel, crash, catalog conflicts (unclaimed fails, claimed
wins), and concurrent-stream coverage included; protocol schema untouched
(generator -check green); golden baselines unregenerated; no new
dependencies.
Stage 5 of the unified extension kernel / Extension Protocol v1 plan.
Problem: Reasonix had no process model for code-carrying extensions — no
stable sidecar protocol, no schema generator to keep it honest, and no
lifecycle for spawning, supervising, and retiring extension processes.
Fix:
- internal/extension/protocol: a new public JSON-RPC 2.0 protocol
(reasonix.extension.v1) with a frozen 16-method registry (initialize,
initialized, shutdown, intercept, event, resources/changed,
provider/catalog, provider/stream open|cancel|chunk|end, ui/action,
ui/submit, host/ui/publish, host/ui/request, host/content/read), strict
DTO decoders, frozen limits (8 MiB frames, 64 KiB externalization
threshold, 256 KiB content chunks, 8 MiB content objects), a frozen
error table, and a v1 stability contract (only optional fields, enum
values, and methods may be added within the major). Provider DTOs are
public copies of the provider shapes — internal types never cross the
wire, and credentials never appear in any DTO.
- internal/extension/protocolgen + cmd/extension-protocol-gen: generates
the canonical JSON Schema (draft 2020-12, sha256:821c11cc…), the schema
hash constant, and docs/EXTENSION_PROTOCOL.generated.md; -check enforces
drift-free artifacts and a determinism test double-generates byte-equal
output.
- internal/extension/sidecar: the only sidecar launch path. Authorization
invariant: runtimes start only from installed+enabled plugin state —
project configuration can never declare a sidecar. Exec-form spawn (no
shell, ${REASONIX_PLUGIN_ROOT} expanded, absolute command required) with
unfiltered inherited environment (the full-trust contract), 16 KiB
redacted stderr diagnostics, initialize-first handshake with capability
subset validation (subscriptions/replaces/providers/UI beyond the
manifest fail with capability_not_declared, version mismatch with
unsupported_version), bounded shutdown (shutdown RPC → stdin close →
750ms grace → process-tree kill → 5s reap), crash supervision that
cancels pending RPCs and fails later calls fast, a host content store
with externalization and chunked host/content/read, and per-point
intercept timeouts (5s input/tool/permission, 30s slow family, manifest
override clamped to 60s).
- internal/extension SessionExtensions: per-session private plugin state
at <session>.extensions.json, keyed by plugin ID, 1 MiB per-plugin cap,
atomic writes, corrupt-file tolerant, never touches the session JSONL.
- boot: BuildResult.Extensions carries the sidecar Manager; the RuntimeSet
is chained into controller cleanup (like LSP) so sidecars live exactly
as long as their controller generation and Rebuild retires only the old
set; required-runtime start failures and replacement-slot conflicts fail
the build, optional failures degrade to warnings. No runtime packages
installed means zero sidecars and a byte-identical golden surface.
Verification: go test ./internal/extension/... (sidecar also -race) and
go test ./internal/boot pass incl. golden baselines; extension protocol
generator -check green; re-exec fake-sidecar tests cover handshake
variants, capability violations, early-traffic poisoning, bounded
shutdown, mid-intercept crash, content-ref round trips, stderr redaction,
and boot end-to-end start/stop; gofmt/go vet clean; no new dependencies.
Stage 4 of the unified extension kernel / Extension Protocol v1 plan.
Problem: reasonix-plugin.json had no way to declare prompts, themes, or a
code runtime, no strictness against typos, and no path-safety beyond
lexical checks; install previews gave no signal that a code extension runs
with full trust.
Fix:
- Manifest v1 (apiVersion reasonix.io/plugin/v1): strict decoding with
DisallowUnknownFields at every level so typos fail with the field path;
unknown major versions rejected. v1 may mix legacy top-level fields with
contributes (skills/agents/commands/prompts/hooks/mcpServers/themes):
identical paths dedupe, same-key-different-definition is a manifest
error naming the key. Prompts are a new path-list resource exposed as
/<plugin>:<name> via the existing plugin-command semantics; themes are
root-confined globs matched against .reasonix-theme files. v1 path
safety rejects traversal, absolute paths, symlink escapes, and
non-regular theme files; missing paths are warnings. Manifests without
apiVersion parse byte-for-byte as before (legacy compat gates green).
- runtime declaration: exec-form command/args/env (never shell-interpreted)
with required/priority/intercepts/replaces/capabilities validated against
the extension kernel's interceptor points, replacement slots, priority
bounds, and {interceptors,strategies,providers,ui}. pluginpkg cannot
import internal/extension (extension->hook->pluginpkg cycle), so the
validator lists are duplicated with a sync test pinning them equal.
- Full-trust disclosure: install plans carry RiskHigh + a FULL TRUST
reason plus omitempty RuntimePlanInfo (command, intercepts, replaces,
capabilities) that old consumers ignore; CLI plugin show/doctor and
capdiag render prompts/themes counts and the FULL TRUST block; the
Desktop install preview renders a danger-styled FULL TRUST section with
the command line, interceptors, replacement slots, capabilities, and the
session/environment/permission risk statement. Install/update/replace/
--link continue to imply full trust with no second confirmation.
- Desktop plugin themes: plugin themes are discovered from enabled v1
packages, validated by the existing v2 theme validator, and shown
read-only (never copied into the user theme dir) with IDs
plugin:<plugin>:<theme>; the internal pack ID regex is unchanged.
Activating a plugin theme persists the full ID; when the plugin is
disabled or uninstalled, rendering falls back to the base style while
the ID is preserved byte-for-byte in desktop-theme-state.json so a
reinstall of the same plugin restores the theme (non-plugin unknown IDs
keep the old clear-and-save behavior; legacy state files load
unchanged). Theme assets serve through the existing content-addressed
route with digest re-verification.
Verification: go test ./internal/pluginpkg ./internal/installsource
./internal/capdiag ./internal/cli ./internal/extension pass (legacy
compat gates unmodified); cd desktop && go test . passes (plugin theme
discovery/fallback-preserve/restore/read-only/asset-route tests);
desktop frontend pnpm typecheck and pnpm test pass; docs/THEME_PACK.md
updated in both languages; gofmt/go vet clean; no new dependencies.
Stage 1 of the unified extension kernel / Extension Protocol v1 plan.
Problem: the extension kernel rebuilds runtime assembly (tools, skills,
commands, hooks, MCP, providers) behind a Builder that freezes an immutable
RuntimeSnapshot. Before touching that assembly chain we need executable
proof of the current provider-visible surface, because any byte of drift in
the system prompt, tool schemas, or request serialization cold-starts the
provider prompt cache for every session.
Fix: add regression gates only; no production code changes.
- internal/boot TestGoldenBaselineNoExtensions: committed goldens for the
composed system prompt (machine-independent: environment probes disabled
in the fixture config, workspace root normalized to <ROOT>), the
provider-visible tool contract via the canonical schema path, a synthetic
provider request serialization, and the agent.PrefixShape cache hash.
Regenerable via REASONIX_UPDATE_GOLDEN=1 for reviewed, deliberate drift.
- internal/pluginpkg legacy manifest + plugin-packages.json round-trip
gates: manifests without apiVersion parse exactly as before (unknown
fields ignored, paths sorted, autoStart:false normalized to nil, hook
args presence bit preserved), and state v1 saves keep every non-empty
field while empty strings stay omitted (omitempty status quo).
- internal/agent legacy session JSONL round-trip: pre-event-log sessions
load and re-save with all seven messages intact.
- desktop theme state: v1 state migrates to v2 on save, user theme IDs
round-trip, base-style IDs are never persisted, garbage files fall back
to the default state.
Verification: go test ./internal/boot ./internal/pluginpkg ./internal/agent
and cd desktop && go test . -run TestLegacyThemeState pass;
./scripts/cache-guard.sh passes (all cases tail_avg >= 92, threshold 90).
* Fix plugin hook execution contracts
Problem: Windows plugin hooks with compound commands or nested quoting could still fail after the narrow quoted-batch compatibility fix, while explicit empty args could lose exec-form semantics across JSON round trips.
Root cause: Hook launch behavior was inferred from argument length and platform shell heuristics instead of preserving the manifest's exec-form or shell-form contract. CMD and PowerShell command strings also passed through incompatible quoting layers.
Fix: Preserve args field presence and shell selection through plugin parsing and loading, add explicit legacy/exec/shell execution modes, transport raw CMD and encoded PowerShell scripts without token re-rendering, retain the existing simple batch compatibility path, and document the cross-platform contract.
Verification: Focused Hook and plugin package tests, race and shuffled tests, fuzzing, root and Desktop full suites, go vet, and Windows/Linux cross-compilation.
* Fix Windows hook output regressions
Problem: The new compound CMD regression expected cmd.exe to strip quotes from %1, leaving the Windows job red. PowerShell hook scripts also preserved UTF-16LE input transport without forcing captured output to UTF-8.
Root cause: The test confused %1 with the dequoting %~1 form, and the Hook-specific EncodedCommand path did not reuse the shell runtime's existing PowerShell output-encoding prologue.
Fix: Align the CMD assertion with native batch semantics, share the PowerShell UTF-8 script wrapper across shell and Hook launchers, and exercise Chinese plus emoji output in the Windows runtime test.
Verification: Focused Hook/plugin/sandbox tests and vet; race/shuffle count 3; root and Desktop full suites; full vet; Windows amd64 test cross-compilation; PowerShell encoding fuzzing.
* fix(mcp): trust installed readers in subagents
Problem:
User-installed MCP tools that declared readOnlyHint could be exposed as readers but then fail before dispatch in planners and read-only subagents. Users were told to configure a second per-tool reader policy after already installing or authorizing the server.
Root cause:
Strict reader trust did not treat explicit install or an exact project launch grant as authority for non-destructive server metadata. Direct, lazy, and on-demand paths also duplicated cached-to-live safety reconciliation, while trusted_read_only_tools remained wired through configuration and Desktop contracts.
Fix:
Authorize non-destructive readOnlyHint tools for installed or launch-authorized servers, carry reader intent only for trusted readers, and share one cached-to-live safety reconciliation boundary across lazy and on-demand adapters. Remove trusted_read_only_tools from runtime, Desktop, examples, and docs while accepting and dropping legacy input on the next save.
Verification:
- env -u DEEPSEEK_API_KEY go test -count=1 ./...
- cd desktop && go test -count=1 ./...
- cd desktop/frontend && pnpm test:typecheck
- cd desktop/frontend && pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts
- ./scripts/cache-guard.sh
- ./scripts/check-cache-impact.sh
- git diff --check
* refactor(mcp): simplify installed server authorization
Problem:
User-installed MCP servers still depended on official-catalog and per-tool reader trust layers, so direct, lazy, and on-demand paths could disagree and strict child sessions could reject an already authorized reader.
Root cause:
Server authorization, signed package status, cached reader trust, and legacy plan-mode allowlists were modeled as separate overlapping decisions.
Fix:
Remove the official MCP catalog, signature/revocation plumbing, package verification, per-tool cached trust, and obsolete reader settings. Use one server-level authorization source for user installs, host-session installs, and exact project grants while retaining live read-only, destructive, and capability-fingerprint checks.
Verification:
- go test ./internal/plugin ./internal/agent ./internal/boot ./internal/config ./internal/installsource ./internal/pluginpkg ./internal/mcplaunch ./internal/planmode
- go test ./internal/acp ./internal/agent ./internal/plugin ./internal/boot
- cd desktop && go test ./...
- ./scripts/cache-guard.sh
- git diff --check
* refactor(mcp): make install authorization final
Problem:
Installed MCP servers could still encounter server, per-tool, writer, or destructive approval branches, and users had to understand settings that duplicated the install decision.
Root cause:
MCP authorization, cached reader trust, approval policy, and process sandboxing evolved as separate paths across direct, lazy, and on-demand execution.
Fix:
Treat install or exact project identity confirmation as the server authorization boundary, keep only explicit global deny plus Plan/read-only and sandbox safety checks, remove catalog and approval configuration, and unify the persistent MCP process sandbox.
Verification:
env -u DEEPSEEK_API_KEY go test ./...
go build ./...
cd desktop && go test ./...
cd desktop/frontend && pnpm typecheck
cd desktop/frontend && pnpm exec tsx src/__tests__/capabilities-panel-actions.test.ts
cd desktop/frontend && pnpm exec tsx src/__tests__/decision-surface.test.tsx
* chore(mcp): remove retired cache helper
Problem:
CI lint found writeBoolKV left unused after per-tool reader trust was removed.
Root cause:
The cached reader-map fingerprint was deleted, but its serialization helper remained.
Fix:
Remove the dead helper.
Verification:
go test ./internal/plugin
go vet ./internal/plugin
go build ./...
* refactor(mcp): stop blocking schema-only drift
Problem:
Authorized MCP calls could be rejected when a cached tool schema differed from the live server even though authorization and safety classification were unchanged.
Root cause:
A capability fingerprint mixed schema compatibility with read-only and destructive execution safety across direct, lazy, and on-demand paths.
Fix:
Remove the tool-level schema fingerprint chain, keep live server authorization and read-only/destructive reclassification checks, and refresh changed schemas for the next session without interrupting the current authorized call.
Verification:
- go test ./internal/plugin ./internal/agent ./internal/tool
- env -u DEEPSEEK_API_KEY go test ./...
- go vet ./...
- go build ./...
- ./scripts/cache-guard.sh
- pnpm --dir site test
- cd desktop && go test ./...
* fix(mcp): preserve short Windows temp paths
Problem:
Windows stdio MCP servers inherited a deeply nested Reasonix private TEMP path. Servers such as MATLAB create watchdog Unix sockets below TEMP and exit before initialization when the path exceeds the platform limit.
Root cause:
MCP private-state preparation redirected TMP, TEMP, and TMPDIR below the workspace-scoped mcp-state directory on every platform even though Reasonix does not currently provide an OS sandbox on Windows.
Fix:
Keep the host temporary-directory environment for Windows stdio MCP processes while retaining private cache and state directories. Preserve the existing private temporary directory behavior on other platforms and add focused regression coverage for both branches.
Verification:
- go test ./internal/plugin -count=1
- GOOS=windows GOARCH=amd64 go test -c ./internal/plugin
- go test ./...
- go vet ./internal/plugin
- git diff --check
* Simplify MCP runtime authorization state
Use Spec.Authorized as the sole resolved server authorization result so installed and host-session MCP servers no longer depend on launch-manager presence.
Keep exact identity grants for project sources, align planner and strict read-only registries with non-destructive readOnlyHint metadata, and document the cooperative trust boundary.
Add source and agent-surface matrix coverage while preserving the main provider-visible MCP tool surface.
* Limit MCP identity digests to project servers
Problem: User-installed and host-session MCP servers still resolved and hashed their executable identity during every startup even though installation was already the authorization decision.
Root cause: Project launch authorization and schema-cache ownership shared generic fingerprint terminology, and start() computed the project identity before checking whether launch approval was required.
Fix: Skip project identity resolution for installed servers, remove dead and non-participating identity state, and rename the remaining concepts to ProjectLaunchIdentityDigest and SchemaCacheKey while preserving existing JSON keys and digest bytes.
Verification: go test ./internal/mcplaunch ./internal/plugin ./internal/capability ./internal/boot ./internal/control ./internal/agent; go test ./...; cd desktop && go test .; go vet ./...; ./scripts/cache-guard.sh; git diff --check.
* Restore MCP 0.53 compatibility surfaces
Problem: MCP authorization was simplified, but the current client still lacked four compatibility surfaces present in the 0.53 line: legacy SSE, workspace roots, progress notifications, and registry browsing/install.
Root cause: the Go transport abstraction dropped inbound notifications and rejected legacy SSE; runtime specs did not carry a separate workspace root; the registry UI and CLI had no official MCP Registry client.
Fix: add inbound request/progress routing across transports, implement legacy SSE with same-origin endpoint protection, advertise roots/list, and add an explicit cached official Registry marketplace to CLI and desktop. Remove stale approval documentation and the dead helper that broke lint.
Verification: go test ./...; focused go test -race for plugin/registry/CLI/control; go vet in root and desktop; desktop go test ./...; frontend production build and marketplace UI test; Windows amd64 cross-build; official Registry browse smoke test.
* Avoid MCP progress capacity overflow
* Align MCP authorization documentation
Problem: CLI help and Chinese documentation still contained ambiguous or obsolete wording that implied installed MCP writer or destructive calls could require per-call approval.
Root cause: The server-level authorization simplification removed MCP approval modes, but not every user-facing explanation was updated consistently.
Fix: State explicitly that install or exact project identity confirmation authorizes the server, that writer and destructive hints do not prompt per call, and that deny, Plan, and strict read-only filtering remain separate boundaries.
Verification: go test ./internal/cli; go vet ./internal/cli; reasonix mcp help smoke test; stale-wording scan; git diff --check.
* Harden MCP registry installs and SSE replies
Problem:
Registry cache freshness was shared across queries, cached entries could flow into install paths, and legacy SSE server requests spawned an unbounded reply goroutine per request.
Root cause:
The cache stored one global timestamp, Resolve reused Search's offline fallback, the desktop installed browse payloads directly, and the SSE reader delegated every reply to a new goroutine.
Fix:
Track freshness per Registry query with legacy-cache compatibility, require live resolution before CLI and desktop installs, and serialize SSE replies through a bounded single-worker queue with finite deadlines.
Verification:
- env -u DEEPSEEK_API_KEY go test -count=1 ./...
- env -u DEEPSEEK_API_KEY go test -race -count=1 ./internal/mcpregistry ./internal/plugin
- env -u DEEPSEEK_API_KEY go test -count=1 ./... (desktop)
- env -u DEEPSEEK_API_KEY go vet ./... (root and desktop)
- pnpm run test:all
- pnpm run build
- env -u DEEPSEEK_API_KEY ./scripts/cache-guard.sh
- git diff --check
* Reject unavailable live registry installs
Problem:
A Registry entry could become non-installable between browsing and the desktop's live re-resolution, leaving the generic add flow with an invalid empty connection. The SSE concurrency regression also used a fixed delay after routing the client response.
Root cause:
The desktop resolver verified freshness but not the resolved entry's installability, and the test inferred worker startup from elapsed time.
Fix:
Validate the live entry through PluginEntry before returning it to the installer, and synchronize the SSE test on the first reply POST instead of sleeping.
Verification:
- env -u DEEPSEEK_API_KEY go test -count=5 ./internal/plugin
- env -u DEEPSEEK_API_KEY go test -race -count=3 ./internal/plugin
- env -u DEEPSEEK_API_KEY go test -count=1 ./...
- cd desktop && env -u DEEPSEEK_API_KEY go test -count=1 ./...
- git diff --check
- An unbounded Reasonix wait now omits TaskOutput's optional timeout
instead of claiming 0 — in Claude's schema timeout is the maximum wait
in ms, so 0 would read as "don't wait", the opposite of the call.
- Structural input gaps (WebFetch prompt, NotebookEdit cell_number,
TaskOutput multi-job) are reported once per hooks file instead of once
per hook item, so a wildcard-matcher plugin gets one warning per gap
rather than one per hook; file-level wording also keeps the emitted
set deterministic across the random event-map iteration order.
- Real-spawn hook tests share one generous realSpawnTimeout: node cold
starts and first-run scans of a freshly built test binary can stall
for seconds on a loaded machine, and these tests assert behavior, not
latency. The timeout-path case keeps its tight budget on purpose.
Fourth review round on #6434 found two P2 accuracy gaps once the two P1
security bypasses were confirmed fixed:
- claude_compat.go accepted a hook's matcher without checking whether it
names a real Claude tool Reasonix has no equivalent for. "WebSearch"
(no Reasonix web-search tool) and "ExitPlanMode"/"EnterPlanMode" (plan
approval is a controller-level decision, never dispatched through the
tool-call hook path; confirmed by tracing permissionRequestHookPayload's
explicit exclusion of planApprovalTool) are two concrete, independently
verified examples: a hook scoped to only these matchers would never fire
while the package still reported "full" compatibility. A plain
tool-name matcher, or a "|"-alternation of them, naming only tools from a
conservative, individually-verified unsupported set now downgrades the
package to "partial" with a structured warning; matchers using other
regex syntax, or naming even one tool Reasonix does support, are left
unevaluated to avoid false positives. Also fixed a real gap found while
auditing this: "run_skill" (Reasonix's skill-invocation tool) was never
mapped to Claude's "Skill" tool name, so a "Skill"-scoped hook silently
never fired either — same bug shape as the task/ask fixes from the
previous round.
- Added a regression test exercising the opts.fresh-only branch of the
fresh-human approval fix (PlanModeReadOnlyCommandApprovalTool, which is
fresh via requestFreshApprovalDecision without being one of
RequiresFreshHumanApprovalTool's fixed cases) — the existing tests only
covered tools protected through requiresFreshApprovalTool(tool).
Co-authored-by: SivanCola <[email protected]>
Third review round on #6434 found a real regression from the prior fix, plus
two remaining accuracy gaps:
- [P1, security] A Claude PermissionRequest hook's "allow" decision (added in
the previous fix) was honored unconditionally, including for tools that
RequiresFreshHumanApprovalTool deliberately excludes from YOLO/auto-approval
and Guardian: sandbox escapes, Reasonix config writes, and memory
remember/forget. A broadly-matched plugin hook returning allow could
silently rubber-stamp any of those. Auto-allow is now ignored for
fresh-human-required tools (falls through to the normal interactive
prompt); a hook's deny still applies universally since refusing is always
safe to auto-honor.
- [P1, security] The Claude subagent tool was renamed "Task" -> "Agent"
(confirmed against the current https://code.claude.com/docs/en/tools-reference);
Reasonix's "ask" tool (AskUserQuestion's equivalent) wasn't mapped at all.
A matcher or tool_name check written against either current name silently
never fired. Now emits the current name (task -> Agent, ask ->
AskUserQuestion) while still matching hooks whose matcher uses the legacy
"Task" alias.
- [P2] Documented, at both the code (compatibilityFor) and doc level, exactly
what "full" compatibility does and doesn't guarantee: it reflects
successful static mapping of declared capabilities, not full runtime
parity — updatedInput and PreToolUse's ask/defer decisions are chosen by a
hook's stdout at call time and can't be flagged during install the way
if/asyncRewake/Stop/SubagentStop already are.
Co-authored-by: SivanCola <[email protected]>
Second review round on #6434 found two more hook security gaps and an
over-claimed compatibility status:
- imported Claude hook matchers (e.g. "Bash", "Write|Edit") and the
tool_name field a hook script inspects were left in Reasonix's own
vocabulary (bash, write_file, ...), so a matcher-scoped or tool_name-gated
Claude hook silently never fired. Added a name-translation layer
(claudeFacingToolName) applied only to PayloadFormat "claude" hooks, both
at match time and in the emitted payload.
- UserPromptSubmit's standard exit-0 JSON block (top-level
{"decision":"block","reason":...}, distinct from PreToolUse/
PermissionRequest's hookSpecificOutput shape) was not parsed, so a hook
denying a sensitive prompt via JSON instead of exit 2 was ignored.
- PermissionRequest's "allow" decision (auto-approve without prompting) was
never implemented — only the deny half of Claude's auto-answer contract
from the prior fix. Runner.PermissionRequest now returns a tri-state
decision (nil/deny/allow) and the approval flow honors all three.
- claude_compat.go now parses the "if" and "asyncRewake" hook fields and a
Stop/SubagentStop hook import, and downgrades compatibility to "partial"
with a structured warning instead of claiming "full" for semantics
Reasonix doesn't evaluate (conditional execution, wake-on-exit-2 async,
and block-the-turn respectively). The hook is still imported best-effort.
An existing test (TestPlanClaudeCompatibilityReportsAgentsHooksAndMCP,
modeled on the PR's own agent-critter example) had asserted "full"
compatibility for exactly this Stop-hook case; updated to the corrected
"partial" expectation.
Co-authored-by: SivanCola <[email protected]>
Review on #6434 found that an imported Claude PreToolUse/PermissionRequest
hook's deny decision was silently dropped: ParseOutput only ever extracted
additionalContext from hook stdout, and PermissionRequest's exit code 2 was
explicitly downgraded to a notification instead of a block. A Claude plugin
that relies on hooks to refuse dangerous commands or permissions would show
as fully compatible while the tool call still ran.
- honor Claude's JSON deny decision (hookSpecificOutput.permissionDecision
for PreToolUse, hookSpecificOutput.decision.behavior for PermissionRequest)
even on exit 0, and have exit code 2 block an imported PermissionRequest
hook the same way Claude's own contract does. Scoped to PayloadFormat
"claude" so native Reasonix hook semantics (PermissionRequest stays
advisory-only) are unchanged.
- wire the PermissionRequest hook synchronously into the approval flow so a
deny preempts the prompt instead of only notifying after the fact.
- fix hook dedup (appendUniqueHook) to also compare Env, Timeout, Async, and
Cwd, so two hooks that share a command/matcher but differ in one of those
no longer collapse into one.
- fix Windows path-separator mismatch: ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PROJECT_DIR}
expansion left a mixed "C:\...\pkg/bin/server" string since the manifest's
"/"-authored suffix was never converted to the host separator.
- lint: tagged switch (QF1003) and remove two functions orphaned by this PR's
own refactor (appendClaudeSettingsHooks, pluginNameExists).
Co-authored-by: SivanCola <[email protected]>
Round-3 review follow-ups on the commands mapping:
- P1: plan and apply each cloned the source separately, so a repo that
moved between the approved plan and the apply resolution could install
content the approval never covered (and the optional planId echo could
not catch the apply call's own second clone). pluginSource now reports
the resolved commit for git sources; the plan records it on the action
— entering the plan ID, so approvals fingerprint the exact snapshot —
and apply pins a diverged clone back to the approved commit via a
full-SHA fetch + detached checkout, refusing with a re-plan error when
the snapshot is no longer reachable. Regression covers the unpinnable
drift refusal end to end.
- P2: the command-dir adoption gate capped recursion at 5 levels while
the runtime loader walks unbounded, so a plugin whose only command sat
deeper was never adopted. The gate now delegates to internal/command
itself — adoption and runtime discovery share one implementation and
cannot diverge; a six-level nesting regression pins it.
Review follow-ups on the commands mapping:
- P1: planGitHubPluginPackage assembled a sparse temp dir holding only
the fetched manifest, so convention-discovered capabilities (skills/,
commands/) and their warnings were invisible at plan time while apply
cloned the full repo — the approved plan under-described what actually
got installed. Plan and apply now resolve the source through one
shared pluginSource resolver (shallow clone for GitHub), making the
approval contract hold by construction; a plan/apply consistency
regression covers it.
- P2: dirContainsCommandMd only looked at the top level, so a plugin
whose commands all live in namespace subdirs (commands/git/commit.md)
was never adopted even though the runtime loader walks them. The
adoption gate now recurses (bounded depth, hidden dirs skipped).
- P3: appendInventoryText's empty-inventory fallback ignored commands, so
a commands-only plugin printed its command list followed by
"capabilities: no detailed inventory available".
Verified: reviewer's repro now plans skills=6 commands=11 for
github.com/OthmanAdi/planning-with-files with zero warnings.
Claude plugins like planning-with-files ship their primary interface as
commands/ (flat <name>.md prompt templates invoked as /<name>), which
Reasonix previously dropped with a "does not map yet" warning — the
plugin installed but /plan, /pwf etc. never appeared, reading to users
as a failed install (discussion #5762).
A Claude command is exactly Reasonix's custom slash command shape
(internal/command: frontmatter description/argument-hint, $ARGUMENTS /
$1..$N substitution, subdir namespacing), so map the capability instead
of warning about it:
- pluginpkg: Manifest gains Commands (explicit "commands" list in any
manifest kind; claude plugins adopt conventional commands/ and
.claude/commands/ automatically, even alongside explicit skills).
Inventory/CapabilityCounts/describe now report commands, loaded via
internal/command so names can never drift from runtime behavior.
- config: CommandDirsForRoot prepends enabled plugin packages' command
roots at the lowest priority — boot and hot ReloadCommands both pick
them up, and a user- or project-authored command wins name clashes.
- cli/installsource: plugin verify checks command roots; install plans
carry commands/commandCount; plugin show lists commands.
- desktop: plugin view exposes commands count + details; capabilities
panel renders a Commands section (en/zh/zh-TW strings).
- docs: EN/zh-CN plugin docs describe the mapping; commands removed
from the unmapped-capabilities warning list (agents, hooks/hooks.json,
.mcp.json still warn).
Verified end-to-end against planning-with-files v3.4.0: installs with
zero warnings, 6 skills + 11 commands, /plan invocable.
Review findings on the Claude manifest support:
- A standard Claude plugin ships a metadata-only plugin.json and relies
on Claude's auto-discovery of the conventional skills/ directory (packs
in the wild also use .claude/skills/). Reading only the explicit
`skills` field installed such packages as zero capabilities with no
warning — "install succeeded" while nothing usable landed. Claude-kind
parsing now falls back to the conventional directories when the
manifest declares none, adopting a directory only if it actually holds
a <name>/SKILL.md. Explicit declarations disable the fallback; codex
parsing is untouched.
- Conventional Claude capabilities Reasonix does not map yet (commands/,
agents/, hooks/hooks.json, .mcp.json) now surface as install warnings
instead of being silently dropped.
- Negative test pins the includeCodexSessionStartHook boundary: a
claude-kind package shipping hooks/session-start-codex must not get it
registered as an executable SessionStart hook.
- Docs: retitle the compatibility section to "Codex & Claude", document
the fallback, the warnings, and that marketplace.json indexes are out
of scope.
Fallback reverse-verified: removing the convention-dir call fails
TestParseClaudePluginConventionSkillDirs.
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).
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.