This repository is migrating away from ::global() singletons and Tauri-coupled services toward explicit dependency injection, actor-owned state, and pure domain services.
Behavioral guidelines reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
Tradeoff: These guidelines bias toward caution over speed. For trivial tasks, use judgment.
Keep CLAUDE.md and AGENTS.md synchronized as much as possible.
- When changing an architectural rule in one file, mirror it in the other file.
- Differences should be limited to tool-specific wording, if any.
- Prefer the same section order, same terminology, and same examples.
- Do not create a Claude-only or agent-only exception unless the tool truly requires it.
Don't assume. Don't hide confusion. Surface tradeoffs.
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
For small, obvious tasks, do this briefly. For architecture, migration, or cross-module work, be explicit.
Minimum code that solves the problem. Nothing speculative.
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
This rule does not override the architectural migration direction. Do not use ::global() or hidden mutable process state merely because it is fewer lines.
Touch only what you must. Clean up only your own mess.
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
For actor/DI migration work, the allowed scope is the smallest call path needed to migrate the touched service or API without leaving a hidden compatibility layer behind.
Define success criteria. Loop until verified.
Transform tasks into verifiable goals:
- "Add validation" -> "Write tests for invalid inputs, then make them pass"
- "Fix the bug" -> "Write a test that reproduces it, then make it pass"
- "Refactor X" -> "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
1. [Step] -> verify: [check]
2. [Step] -> verify: [check]
3. [Step] -> verify: [check]
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
The target architecture is:
Tauri commands / UI adapters
-> NyanpasuClient
-> typed actor clients
-> pure services
-> adapter traits
-> concrete Tauri / OS / filesystem / network implementations
Use these terms consistently:
- Dependency Injection / Pure DI: dependencies are passed explicitly through constructors, builders, function arguments, or actor startup arguments. Do not look them up through globals.
- Composition Root: the bootstrap / supervisor location that builds the full object graph and actor graph.
- Ports and Adapters: core/application code depends on traits; Tauri, filesystem, OS, network, and process implementations live behind adapters.
- Actor service: a ractor actor that owns mutable state, serializes commands, manages long-running resources, or supervises background work.
- Pure service: a stateless or short-lived service that performs deterministic computation, validation, conversion, config generation, serialization, or patch application without IPC or background lifecycle.
- Adapter / port: a narrow trait and concrete boundary implementation for infrastructure such as Tauri, filesystem, OS APIs, process spawning, HTTP, logging sinks, or storage.
NyanpasuClient is the application facade. The application bootstrap / supervisor is the composition root. It constructs concrete services, spawns actors, wires dependencies, and returns a ready-to-use NyanpasuClient.
In this repository, ractor means the Rust ractor crate. It is an in-process actor framework used for long-lived services that own state and communicate through typed messages. It is not Tauri IPC and not Ruby Ractor.
Use this mental model:
Actor = private mutable state + typed message enum + sequential message handling + lifecycle hooks
Core concepts:
Actor: the implementation trait. An actor defines itsMsg,State,Arguments, and lifecycle/message-handling methods.ActorRef<Msg>: a typed address used to send messages to an actor. Hide rawActorRefvalues behind typed clients such asStateClientorCoreClient.- Message enum: the actor's domain protocol. Prefer explicit messages such as
PatchAppConfig,RestartCore, orSelectProxyover generic commands. RpcReplyPort<T>: the usual request/reply mechanism for queries and fallible operations that must return a value.- Fire-and-forget messages: use only for notifications, invalidations, events, or best-effort work where the caller does not need a result.
- Startup arguments: the actor's dependency injection boundary. Pass dependencies when spawning the actor; do not fetch them from globals in actor code.
Project rules:
- Use actors for services with long-lived mutable state, serialized commands, background tasks, streams, timers, file watchers, process lifecycles, sockets, subscriptions, downloads, or child supervision.
- Do not use actors for deterministic computation. Use pure services for validation, schema conversion, patch application, runtime config building, serialization, and merge/enhance logic when it can be deterministic.
- Put infrastructure access behind adapter traits. Inject Tauri, OS, filesystem, network, process, and logging adapters into the actor or pure service that needs them.
- The composition root spawns actors, wires dependencies, and returns
NyanpasuClient. Do not use a ractor registry, actor name lookup, or rawActorRefmap as a replacement for dependency injection. NyanpasuClientand typed actor clients should expose ordinary async Rust methods. Most callers should not use ractor APIs directly.- Prefer finite timeouts for cross-actor request/reply calls when the caller can report failure or degraded state.
- Avoid synchronous cross-actor cycles such as
StateActor -> CoreActor -> StateActor.
If a mature ractor actor client already exists for a capability, use it instead of adding a new global singleton, raw channel loop, or direct Tauri-coupled service call.
Do not introduce new service accessors such as:
Service::global()
get_global_service()
static SERVICE: OnceCell<Service>
static SERVICE: OnceLock<Service>
static SERVICE: Lazy<Service>Exceptions are allowed only for immutable constants, static lookup tables, feature flags, or values that are truly process-wide and have no lifecycle, no mutable state, and no dependency graph.
If an existing ::global() service must still be used during migration, isolate it at the edge of a migration step and add an explicit comment:
// TODO(actor-migration): temporary bridge to the legacy global service.
// Reason: <why full migration is blocked>.
// Remove when: <service-name> is injected through NyanpasuClient.Services must be constructed through one of these forms:
Service::new(dependency_a, dependency_b)
ServiceBuilder::default().with_dependency(...).build()
AppSupervisor::start(args).awaitDependencies should be visible in struct fields, constructor parameters, builder parameters, function parameters, or actor startup arguments. Hidden dependencies are not allowed.
NyanpasuClient may expose stable application APIs, for example:
client.get_app_config().await?;
client.patch_app_config(patch).await?;
client.get_profiles().await?;
client.restart_core().await?;
client.select_proxy(group, name).await?;It must not expose arbitrary internal lookup APIs such as:
client.get_any_service::<T>()
client.resolve::<T>()
client.resolve("service-name")
client.get_service("name")
client.actor_registry()
client.get_actor_ref("state")Internally, NyanpasuClient may hold typed clients such as StateClient, CoreClient, SystemProxyClient, HotkeyClient, ProxiesClient, and pure services or adapter trait objects. Callers should not depend on ractor ActorRef directly unless they are part of the actor layer itself.
Business logic must not depend directly on Tauri types such as AppHandle, Window, Manager, tray handles, global command state, or Tauri event emitters. Use adapter traits instead.
Before adding or migrating a service, classify it as an actor service, pure service, or adapter/port.
- owns long-lived mutable state;
- must serialize commands to avoid races;
- manages background tasks, streams, timers, file watchers, process lifecycles, sockets, subscriptions, or downloads;
- supervises child tasks or child actors;
- needs request/reply or fire-and-forget messaging;
- coordinates side effects after state commits.
Expected examples:
- app/config state;
- core process lifecycle;
- system proxy state;
- hotkey registration;
- proxy cache and subscriptions;
- updater downloads;
- websocket connection managers;
- server lifecycle.
Actor implementation rules:
- Use a typed message enum.
- Keep owned mutable state inside the actor state.
- Use typed actor client wrappers for public calls.
- Do not expose raw
ActorRefoutside actor/application internals. - Use request/reply for fallible operations and queries.
- Use fire-and-forget only for events, notifications, or best-effort work.
- Avoid cross-actor synchronous cycles.
- Prefer finite timeouts for cross-actor RPCs where a caller can recover or report degradation.
- Actor startup arguments must contain all dependencies required to build the actor state.
- Do not share actor-owned mutable state with
Arc<Mutex<_>>orArc<RwLock<_>>unless it is a narrowly scoped implementation detail with a clear comment.
- performs deterministic computation;
- validates input;
- converts schemas;
- applies patches to owned data passed as parameters;
- builds runtime configuration from snapshots;
- serializes or deserializes data without owning long-lived state;
- has no background task and no independent lifecycle.
Expected examples:
- config validation;
- patch application;
- profile ordering;
- runtime config building;
- legacy schema conversion;
- serialization helpers;
- merge/enhance logic when it can be made deterministic.
Pure service rules:
- No global state.
- No background tasks.
- No hidden filesystem/network/Tauri access.
- All inputs must be explicit parameters.
- Return values or domain errors instead of mutating external state.
- Tauri events, windows, tray, dialogs, clipboard;
- filesystem and app directories;
- OS proxy APIs;
- global shortcuts;
- HTTP clients;
- child process spawning;
- logging sinks;
- persistent storage backends.
Adapter rules:
- Core/application code depends on traits.
- Concrete adapters live at the boundary crate/module.
- Keep adapter traits narrow and task-oriented.
- Prefer mockable traits for tests.
- Prefer traits owned by the consuming crate/module when that improves boundary clarity.
If you see patterns such as:
Config::global()
Config::verge()
Config::clash()
Config::profiles()
Config::runtime()
CoreManager::global()
Sysopt::global()
Hotkey::global()
Logger::global()
Handle::global()
ProxiesGuard::global()
UpdaterManager::global()
WindowManager::global()
consts::app_handle()prefer replacing the call path with one of:
client.some_domain_operation(...).await?;
state_client.some_state_operation(...).await?;
core_client.some_core_operation(...).await?;
system_proxy_client.some_system_operation(...).await?;
service.method(...)?;Do not add a new wrapper that simply hides the global unless full migration is blocked. If blocked, document it:
// TODO(actor-migration): temporary bridge to <legacy global>.
// Reason: <specific blocker>.
// Remove when: <specific migration step>.Configuration must be migrated before dependent services whenever possible.
Preferred direction:
- Move state ownership into
StateActoror a state manager owned byStateActor. - Keep schema and patch operations in pure services or domain types.
- Generate runtime config from snapshots rather than mutating runtime globals.
- Commit state first, then trigger side effects through actor messages.
- Report post-commit side-effect failures as degraded results instead of silently rolling back persisted state.
Avoid preserving old global configuration APIs. Prefer a migratable breaking change that updates callers to the new injected client/service API.
When refactoring or migrating services:
- Prefer fully migrating callers to the new injected/actor/pure-service API.
- Prefer migratable breaking changes over compatibility layers.
- Do not add a compatibility layer simply to avoid updating call sites.
- Add a compatibility or migration layer only when a full migration is not currently possible due to cyclic dependencies, public API constraints, external plugin behavior, large cross-cutting risk, platform limitation, or staged release requirements.
- Every compatibility layer must be explicitly marked with
TODO(actor-migration)orFIXME(actor-migration)and must explain the reason and removal condition. - New code must not call compatibility APIs unless the call site is itself part of a documented migration step.
Do this:
client.patch_app_config(patch).await?;Do not do this unless blocked:
LegacyConfigCompat::patch_verge(patch).await?;Required comment format:
// TODO(actor-migration): compatibility bridge for <legacy API>.
// Reason: <why full migration is blocked>.
// Remove when: <specific condition or tracking issue>.or:
// FIXME(actor-migration): legacy behavior kept temporarily for <reason>.
// New code must use <new API>. Remove after <condition>.Tauri commands should be thin adapters. They should:
- parse request DTOs;
- call
NyanpasuClient; - map domain errors into command errors;
- never perform business orchestration directly;
- never read or mutate config through globals;
- never spawn core/service background tasks directly.
Allowed shape:
#[tauri::command]
pub async fn patch_verge_config(
client: tauri::State<'_, NyanpasuClient>,
patch: NyanpasuAppConfigPatch,
) -> Result<()> {
client.patch_app_config(patch).await?;
Ok(())
}Avoid shape:
#[tauri::command]
pub async fn patch_verge_config(patch: IVerge) -> Result<()> {
Config::verge().draft().patch_config(patch)?;
CoreManager::global().update_config().await?;
Config::verge().apply();
Ok(())
}- Prefer testing pure services directly with plain values.
- For infrastructure dependencies, define narrow traits and inject them.
- Traits that are intended to be mocked should be compatible with
mockall/automockwhere practical. - Keep mock-only APIs behind
#[cfg(test)]or test-support modules. - Do not use global test fixtures for application services. Construct a test
NyanpasuClientor test-specific service graph. - Actor tests should spawn the actor with fake adapters and send typed messages through its typed client.
- Avoid sleeping in actor tests. Prefer explicit acknowledgements, request/reply messages, or test hooks.
Example mockable trait:
#[cfg_attr(test, mockall::automock)]
pub trait UiEventSink: Send + Sync + 'static {
fn emit_state_changed(&self, event: StateChanged) -> anyhow::Result<()>;
}Another acceptable trait shape:
#[cfg_attr(test, mockall::automock)]
pub trait ConfigStore: Send + Sync + 'static {
fn load(&self) -> anyhow::Result<Vec<u8>>;
fn save(&self, bytes: &[u8]) -> anyhow::Result<()>;
}Use names that reveal the role:
StateActor,CoreActor,SystemProxyActor,HotkeyActor,ProxiesActor,UpdaterActorfor actors.StateClient,CoreClient,SystemProxyClient,HotkeyClient,ProxiesClientfor typed actor clients.RuntimeBuilder,ProfileMerger,ConfigMigrator,PatchValidatorfor pure services.TauriUiEventSink,FsConfigStore,OsProxyBackend,ProcessRunnerfor adapters.AppSupervisororNyanpasuBootstrapfor the composition root.
Use comments only where they clarify migration state, invariants, or actor lifecycle assumptions.
Required compatibility-layer comment:
// TODO(actor-migration): compatibility bridge for <legacy API>.
// Reason: <why full migration is blocked>.
// Remove when: <specific condition or tracking issue>.Required temporary legacy behavior comment:
// FIXME(actor-migration): legacy behavior kept temporarily for <reason>.
// New code must use <new API>. Remove after <condition>.Before finishing a change, check:
- assumptions were stated when relevant;
- success criteria were verified;
- every changed line traces to the request;
- no new global singleton service was added;
- no new mutable static service state was added;
- dependencies are explicit;
- service classification is clear;
- actor state is not leaked through shared locks;
- Tauri is isolated behind adapters;
- compatibility layers are exceptional and documented;
- tests use injection, fakes, mocks, or pure values;
NyanpasuClientremains a facade, not a service locator.
Feature/migration work runs in isolated git worktrees. The worktree location is the developer's choice (any path outside the repo tree); this section only fixes the reuse policy, not where worktrees live. Worktrees share the main .git. The rule: reuse expensive branch-independent assets from the main checkout via symlink, and regenerate everything branch-dependent per worktree.
| Path (repo-relative) | Approx size | Policy | Reason |
|---|---|---|---|
backend/tauri/sidecar/ |
~213M | Symlink → main | gitignored downloaded cores (mihomo / clash-rs / clash / nyanpasu-service); branch-independent; re-fetch via pnpm prepare:check is slow |
backend/tauri/resources/ |
~21M | Symlink → main | gitignored static assets (geoip.dat, geosite.dat, Country.mmdb, wintun.dll, service exes); branch-independent |
node_modules/ |
~1.5G | Independent pnpm install |
pnpm global store already hardlink-dedupes; sharing risks concurrent lock conflicts |
backend/target/ |
~50G | Independent — never symlink | sharing causes Cargo incremental-fingerprint churn + concurrent build-lock waits across diverged source trees |
backend/tauri/tmp/dist/ |
build output | Independent — never symlink | branch-dependent frontend build; emptyOutDir: true means one worktree's web:build wipes the shared dir |
Only sidecar/ and resources/ are symlink candidates.
frontend/interface/dist—@nyanpasu/interface(main→./dist/index.js) is consumed by@nyanpasu/nyanpasu. Produce withpnpm -F interface build.backend/tauri/tmp/dist—backend/tauri/build.rscallstauri_build::build(), which validatesfrontendDist: ./tmp/distat compile time. When missing, everycargo build/clippy/cargo test --all-features/ rust-analyzer run on the tauri crate fails. Resolve one of:- Rust-only worktree → drop a placeholder (cheapest, no vite build).
- Runnable UI →
pnpm web:build(buildinterfacefirst; it clears and refillstmp/dist).
backend/tauri/tmp/git-info.json is optional (build.rs guards it with exists()); run pnpm generate:git-info only if accurate commit metadata must be baked in.
Commands shown for Windows / PowerShell (dir symlinks need Developer Mode, no elevation). <worktree-path> and <type>/<name> are yours to choose.
$main = git rev-parse --show-toplevel # capture main checkout root
git worktree add <worktree-path> -b <type>/<name>
cd <worktree-path>
# Reuse branch-independent downloads (symlink back to main)
New-Item -ItemType SymbolicLink backend/tauri/sidecar -Target "$main/backend/tauri/sidecar"
New-Item -ItemType SymbolicLink backend/tauri/resources -Target "$main/backend/tauri/resources"
pnpm install
pnpm -F interface build # -> frontend/interface/dist (gitignored)
# Satisfy tauri-build's frontendDist check — pick one:
New-Item -ItemType Directory -Force backend/tauri/tmp/dist | Out-Null # A) Rust-only placeholder
Set-Content backend/tauri/tmp/dist/index.html '<!doctype html><title>dev</title>'
# pnpm web:build # B) real UI (replaces tmp/dist)git worktree remove on Windows can fail with Filename too long because per-worktree node_modules / target hold paths over MAX_PATH. Force-delete with the extended-length prefix, then reconcile git:
Remove-Item -LiteralPath "\\?\<absolute-worktree-path>" -Recurse -Force
git worktree prune
git worktree listRemoval reclaims only the worktree's own files and its symlinks (pointers back to main) — it never touches the main checkout's real sidecar/ / resources/.
Before committing, run git status to review the changes, stage only the files related to this change with explicit paths (git add <specific-path>), then verify with git diff --cached --stat.
Never use blanket staging such as git add ., -A, --all, or *. If something was staged by mistake, unstage it with git reset HEAD <path>.
Every commit must be atomic, complete, and buildable.
- One indivisible task is one commit.
- Multiple independent tasks are split into multiple commits.
- Do not commit code you know is broken.
- Do not make fix-up (patch-style) commits on a development branch.
If a commit on a development branch is flawed and has not been pushed, fix it with git reset --soft HEAD~1 and recommit. If it has already been pushed, any rewrite, amend, or force push requires explicit consent first.
Self-check before committing: does this change complete or correct the previous commit? If yes, fold it into the previous commit with git reset --soft HEAD~1 and recommit instead of creating a new one. Even when two commits are each individually clean, a later commit that completes an earlier one is still a fix-up commit.
Exploratory work may live on temp/, wip/, or scratch/ branches. Do not merge those directly; create a clean branch and reorganize the work into atomic commits.
The subject states what changed; the body explains why when the problem or the fix is not obvious.
Subject rules:
- Use the imperative mood, stay within 72 characters, and do not end with a period.
- Describe the behavior or capability.
Body rules:
- A non-trivial change must have a body; the body may be omitted only when the subject is fully self-explanatory.
- Explain the root cause and the rationale for the fix: why this is a bug and why this change is needed.
- Do not enumerate changes file by file, and do not restate implementation steps that the diff already shows.
- Describe only the final state relative to the parent commit, not differences between intermediate versions of the same patch (e.g. "v2 fixes X").
Assume the reader is a competent developer familiar with the project; do not explain what they already know:
-
How to build the project — that belongs in documentation, not in a commit message.
-
Obvious statements of usage. Counter-example:
Example usage: # use mkv container: ffmpeg -hwaccel d3d12va -hwaccel_output_format d3d12 -i input.mp4 -c:v av1_d3d12va output.mkv -
"Build succeeded" or "all tests green" — the commit's existence already implies it passed.
Mention these only when they are genuinely non-obvious:
- New test commands or tools that do not yet exist in the project.
- Non-standard configuration required to reproduce the result.
- Unusual constraints that affect how the data should be interpreted.
These guidelines are working if: fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, clarifying questions come before implementation rather than after mistakes, and new code moves away from global singletons toward injected actor/pure-service composition.