chroxy
AGENTS.md8 starsChanged 7 days agoMITView on GitHub
This is the authored project contract for every coding host. Edit AGENTS.md directly; CLAUDE.md and GEMINI.md import it. References to Claude, the claude CLI, or .claude/ describe that tooling; apply the equivalent for your host. Chroxy is a remote terminal app for Claude Code. Run a lightweight daemon on your dev machine, connect from your phone via a secure tunnel. Get both a full terminal view and a clean chat-like UI that parses Claude Code's output into readable messages. Tech…
- Pipes a download into a shell
- Reads credentials
- Installs packages
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,774 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository35 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- commandsSkill
- agentic-auditSkill
- agent-reviewSkill
- autonomous-dev-flowSkill
- batch-mergeSkill
- bug-huntSkill
- catchupSkill
- check-prSkill
- commitSkill
- create-issueSkill
- create-prSkill
- decideSkill
- decompose-issueSkill
- fix-ciSkill
- full-reviewSkill
- hallucination-checkSkill
- learnSkill
- manual-testing-modeSkill
- mergeSkill
- parallel-devSkill
- prime-directiveSkill
- project-auditSkill
- pulseSkill
- qa-updateSkill
- reconSkill
- session-lifecycleSkill
- skillSkill
- smoke-formSkill
- smoke-testSkill
- start-workingSkill
- swarm-auditSkill
- tackle-issuesSkill
- tiered-delegationSkill
- visual-briefSkill
The file
Show its rules and text
We split this AGENTS.md into the rules it gives an agent
25 rules. About 0 are common, 10 are distinctive, and 15 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈10
Distinctive · estimate
This author's own choices. Worth a look.
15
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 88
Check for skill drift: /skill outdated.
- Very specific: line 109
Install-on-miss is a rule: if /X is requested but unavailable, distinguish two cases by the neutral source .claude/commands/X.md: if it's …
- Distinctive: line 176
Commit messages should be clean and professional:
- Distinctive: line 216
CRITICAL: NEVER commit directly to main: Always use feature branches and PRs.
- Distinctive: line 218
Create feature branch from main
- Very specific: line 230
When gh pr merge fails with "not mergeable" or "base branch policy prohibits the merge", check in this order:
- Distinctive: line 244
No TypeScript — plain JavaScript
- Distinctive: line 258
Every interactive control (button, toggle, tab, tappable row or badge) must present at least a
- Very specific: line 288
Every guard in docs/false-safety-guards.md passed unit tests, lint, typecheck and CI — continuously, for months in some cases — because …
- Very specific: line 377
Never pass
--test-force-exit, and run what CI runs: Every package that runs node --test refuses it now … - Distinctive: line 389
If that hangs after printing its summary, the file leaks a handle — tear it down in the test.
- Very specific: line 392
Check the exit code, not the output — and note cmd | grep -c FAIL reports grep's status, not the script's.
- Very specific: line 400
Every test that constructs a SessionManager must pass stateFilePath pointing at a temp file.
- Very specific: line 409
If you need to write to the real home for a legitimate reason (no current test does), set process.env.CHROXY_TEST_ALLOW_REAL_HOME_WRITES = …
- Distinctive: line 538
When you modify app components (screens, UI elements, styling), verify with Maestro:
- Distinctive: line 540
Ensure a simulator is booted and Metro is running
- Very specific: line 541
Run the relevant flow(s) — screenshots are saved to packages/app/
- Distinctive: line 542
Read the screenshot PNGs with the Read tool to visually verify correctness
- Distinctive: line 543
Delete screenshots when done (they're gitignored)
- Very specific: line 569
Add a text.includes('<phrase>') branch alongside the show-todos block in mock-server.mjs that emits the right tool_start/tool_result for …
- Very specific: line 570
Add testID props to the renderer's key elements if not already present.
- Very specific: line 571
Add <renderer>.yaml using setup/ensure-session-screen.yaml, the input + send sequence, an extendedWaitUntil for the bubble header, …
- Very specific: line 572
Add the new flow to run-all.yaml.
- Very specific: line 596
Use get_related_files to find what else to look at, and get_dependency_graph to trace imports/dependents (useful for review-impact checks).
- Very specific: line 601
Read the full file (not just the summary) when you need exact implementation details, control flow, or to write code that matches the …
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Chroxy Development Notes
This is the authored project contract for every coding host. Edit AGENTS.md
directly; CLAUDE.md and GEMINI.md import it. References to Claude, the
claude CLI, or .claude/ describe that tooling; apply the equivalent for your host.
<!-- chroxy-required-rule:start project-overview -->Project Overview
Chroxy is a remote terminal app for Claude Code. Run a lightweight daemon on your dev machine, connect from your phone via a secure tunnel. Get both a full terminal view and a clean chat-like UI that parses Claude Code's output into readable messages.
Tech Stack: Node.js (server), React Native/Expo (mobile app), WebSocket over Cloudflare tunnel
Architecture: Monorepo with npm workspaces
chroxy/
├── packages/
│ ├── server/ # Node.js daemon + CLI (ES modules, no TypeScript)
│ ├── app/ # React Native mobile app (TypeScript, Expo 55)
│ ├── desktop/ # Tauri tray app (Rust, wraps the dashboard)
│ ├── dashboard/ # Web dashboard (React + Vite, served by the server)
│ ├── protocol/ # Shared protocol types and Zod schemas (@chroxy/protocol)
│ ├── store-core/ # Shared store logic and crypto (@chroxy/store-core)
│ ├── design-tokens/# Design tokens → generated dashboard theme.css/tokens.ts (@chroxy/design-tokens)
│ └── claude-hooks/ # Hook emitters for external Claude Code sessions (@chroxy/claude-hooks)
├── docs/ # Setup guides, architecture
└── scripts/ # Install helpersCurrent Status (v0.11.8):
- Server works: CLI headless mode, multi-provider registry (Claude SDK/CLI/TUI, BYOK, Gemini, Codex — app-server driver is now the default (#6616): approvals surfaced in Chroxy's permission pipeline + permission-mode switching incl. unattended Auto behind
allowAutoPermissionMode(#8619) + image vision + intra-session memory;CHROXY_CODEX_APPSERVER=0falls back to legacycodex exec— DeepSeek, Ollama, config-driven Anthropic-compatible + OpenAI-compatible endpoints), WebSocket protocol, Cloudflare tunnel (Quick + Named), supervisor auto-restart, push notifications + Discord status-embed sink, external-session event ingest (POST /api/events+ ingest secret), session management, model switching, plan mode detection, background agent tracking, container environments (Docker Compose, DevContainer, snapshots), container/worktree isolation, K8s/Rancher backends (config-driven selection; experimental — live-cluster validation pending #6275), embedded user-shell terminal, daemon identity-key rotation (chroxy identity rotate), Control Room env/runtime management, queue-while-processing (mid-turn send + auto-flush), permission rule engine, persistent pairing tokens with a configurable sliding TTL (#6598), encrypted credentials at rest (macOS Keychain / Linux libsecret / Windows DPAPI — #6644), opt-in IDE navigation surface (features.ide/CHROXY_ENABLE_IDE=1— VSCode-style collapsible file-tree navigator, symbol side-panel, go-to-definition, find-references, find-in-project, Cmd+P quick-open, syntax-highlit viewer; epic #6469 P1+P2 complete), extensible provider/handler system - Desktop works: Tauri tray app, multi-host LAN client (server picker, mDNS discovery, shared-session join), web dashboard with syntax highlighting, xterm.js terminal, Control Room, notifications, session tabs, voice-to-text (macOS), console page, environment management panel, startup-failure surfacing with retry
- App works: QR code scanning, connection flow with health checks and retries, ConnectionPhase state machine for resilient reconnection, markdown rendering, dual-view chat/terminal, xterm.js terminal emulation (WebView), plan approval UI, agent monitoring, settings screen, voice-to-text input, session rules UI, worktree toggle
- Claude-hooks works:
chroxy-hooks installregisters eight stateless emitters in Claude Code settings so plain (non-chroxy) sessions feed the daemon's notification pipeline — seedocs/guides/discord-notifications.md - Dev build required —
expo-speech-recognitionnative module means Expo Go no longer works. Usenpx expo run:iosornpx expo run:android.
<!-- chroxy-required-rule:end project-overview --><!-- chroxy-required-rule:start critical-dev-notes -->Critical Dev Notes
Node 22 Required
Node 22 is the minimum supported version. Always use Node 22:
PATH="/opt/homebrew/opt/node@22/bin:$PATH" npx chroxy startOr prefix any npm/node commands with the Node 22 path when running the server.
Cloudflare Tunnel Dependency
The server uses Cloudflare tunnels for secure remote access. Two modes:
- Quick Tunnel (default): random URL, no account needed
- Named Tunnel (
--tunnel named): stable URL, requires Cloudflare account + domain
brew install cloudflared
# Named tunnel setup (interactive):
npx chroxy tunnel setupTriage Runbooks
docs/troubleshooting/session-token-mismatch.md— how to enable the[session-binding-*]debug logs and correlate aSESSION_TOKEN_MISMATCHfailure byrequestId(runbook from the resolved #2832 investigation).docs/troubleshooting/lan-discovery.md— why the mobile app's "Scan Local Network" finds nothing on the same Wi-Fi (it's a unicast /24 sweep, not mDNS — router client/AP isolation blocks it) and the router-side fixes + manual/QR fallbacks (#6561).
<!-- chroxy-required-rule:end critical-dev-notes --><!-- chroxy-required-rule:start prime-compatibility-and-authority -->PRIME Compatibility and Authority
The current user's scope, merge holds and host restrictions survive every skill invocation and resume. A skill cannot widen a review-only/draft-only task. Chroxy's explicitly selected PRIME backlog mission, gated synchronous squash merge, old-debt quota, build invariants and paused cost breaker remain in .claude/commands/prime-directive.md and .claude/skill-profile.md.
Claude loads the compiled .claude/skills/prime-directive/SKILL.md through native /prime-directive. Other hosts must verify their own discovery; if unavailable, read that exact local artifact and disclose the manual fallback. Do not infer that this project contract loads machine-global Claude rules or registers slash skills. Preserve the run's owned branch, acceptance, holds, attempts and consumed limits; restored context is not new authority or a fresh budget. PRIME checkpoints continue within scope; NORMAL ends its selected package. Never refresh another active session's instructions as part of a repair.
<!-- chroxy-required-rule:end prime-compatibility-and-authority --><!-- chroxy-required-rule:start session-start-protocol -->Session Start Protocol
When user says "Resume" or "Let's start":
cat AGENTS.md- Read this filegit status && git log --oneline -5- Check current state- Review open PRs, separated by author so external contributions don't get lost in your own queue:
- Yours:
gh pr list --state open --author @me - External:
gh pr list --state open --search "-author:@me"— flag any results for review attention before starting new marathons (an open external PR may already cover an issue you'd otherwise queue)
- Yours:
- Check for skill drift:
/skill outdated. Refresh only the authorized isolated checkout at a deliberate boundary, then compile and verify; do not update active sessions or user-global targets implicitly.
Skills (pull-based registry)
"Skills" is overloaded in Chroxy — this section is the DEV-WORKFLOW
/skillsystem (authoring/reviewing commands like/full-review, compiled into each coding agent's native format for people building Chroxy). It is unrelated to runtime skills (~/.chroxy/skills/*.md, injected into a live session's prompt). User-facing docs: docs/dev-workflow-skills.md (this system) and docs/skills.md (runtime). This section stays the operational source of truth for the dev-workflow system; both docs link back here in the authoredAGENTS.md.
Skills live in the blamechris/skill-templates registry and install on demand via the
/skill client — think npm/brew for .claude/commands/*.md. There is no push-deploy and no
sync.sh; the old push/customizations/ workflow is retired.
/skill add <name>— resolve from the registry → fetchgeneric/<name>.md→ fill its{{CUSTOMIZE: ...}}markers from this repo'sAGENTS.md+.claude/skill-profile.md+ code → write a version-stamped.claude/commands/<name>.md→ compile to native targets → record in.claude/skills.lock./skill list//skill outdated//skill update [name]//skill remove <name>— manage installed skills.- Install-on-miss is a rule: if
/Xis requested but unavailable, distinguish two cases by the neutral source.claude/commands/X.md: if it's missing, the skill isn't installed — run/skill add X(fetch + customize + compile). If the source exists but the native artifact (.claude/skills/X/SKILL.md) is missing, it's just not compiled — runnode scripts/compile-skill-targets.mjs --name X(no registry fetch needed). Then invoke.
Model-agnostic compile (multi-target). .claude/commands/<name>.md is the provider-NEUTRAL
source; scripts/compile-skill-targets.mjs compiles each skill into every coding agent's NATIVE
custom-command format, so the same skill is first-party under whichever model you drive:
- claude →
.claude/skills/<name>/SKILL.md(the v2.1.x "skills" path; the legacy.claude/commands/slash-command discovery is broken — GH anthropics/claude-code#31846 — so Claude loads from.claude/skills/). Version-controlled. - gemini →
.gemini/commands/<name>.toml(TOML;$ARGUMENTS→{{args}}). Version-controlled. - codex (opt-in) →
~/.codex/prompts/<name>.md(invoked/prompts:<name>; user-global, not version-controlled). Codex CLI still supports~/.codex/prompts/(recent versions also ship a~/.codex/skills/dir); the emitter targets the stable prompts path. Off by default — the compiler prints a hint if~/.codexexists butcodexisn't a selected target. - pi (opt-in) →
~/.pi/agent/skills/<name>/SKILL.md(Pi Coding Agent,earendil-works/pi; invoked/skill:<name>; user-global, not version-controlled). Markdown + YAML frontmatter (name+description) like the claude target, but Pi appends invocation args asUser: <args>rather than substituting inline — so$ARGUMENTS/$Nin the body pass through literally and the compilerwarns if a body uses them. Off by default — the compiler prints a hint if~/.piexists butpiisn't a selected target. (#6573)
The active target list is the targets: line in .claude/skill-profile.md (this repo:
claude, gemini — both in-repo/version-controlled; codex and pi are per-machine opt-in via
--targets codex / --targets pi, kept out of the committed default so a clone never writes to an
unaware machine's ~/.codex or ~/.pi). With no targets: line the compiler falls back to
claude only and /skill prompts you. After editing a skill's generic source by hand, recompile:
node scripts/compile-skill-targets.mjs --name <name> (--dry-run to preview).
Forgetting to recompile is a CI failure, not a silent one (#7253). Scripts Tests runs
node scripts/compile-skill-targets.mjs --check, which compiles the repo-local targets in memory
and compares them against what is committed — the compiled-artifact drift gate. It fails on an
artifact whose bytes differ from its source, one that was never compiled, one left behind for a
target that now skips the skill, one whose source was deleted, and on a repo-local target that has
committed artifacts but is not being checked. It writes nothing, and it refuses codex/pi
outright: those compile into ~/.codex and ~/.pi, which are per-machine and must never be
touched by CI. The compiled skills are executable instruction artifacts — .claude/skills/<name>/SKILL.md
is what Claude actually loads, so a stale artifact means the skill you edited is not the skill
that runs.
This repo carries .claude/skill-profile.md (the customization profile + targets:) and
.claude/skills.lock (what's installed, at which template hash). Maintainers edit templates in
the registry's generic/ and run its scripts/build-index.sh; consumers pick changes up on the
next /skill update. Registry follow-up: the multi-target compile step should also land in the
registry's generic/skill.md so other repos inherit it (this repo's /skill is ahead of the
template until then).
<!-- chroxy-required-rule:end session-start-protocol --><!-- chroxy-required-rule:start git-workflow -->Git Workflow
Zero Attribution Policy
CRITICAL - READ THIS FIRST:
Claude must NEVER include ANY of the following in commits, PRs, or any files:
Co-Authored-By: Claudeor any Claude co-author lineGenerated with Claude Codeor similar phrasesGenerated by ClaudeorCreated by Claude- Any emoji + "Generated with" pattern
- Any mention of Claude, Anthropic, or AI assistance in commit messages
- Any attribution in PR descriptions
The user is the SOLE AUTHOR of all work. Period.
Commit messages should be clean and professional:
feat(server): add graceful shutdown on SIGTERM
- Clean up child processes on exit
- Close WebSocket connectionsNOT:
feat: Add feature
🤖 Generated with [Claude Code](...)
Co-Authored-By: Claude...Branch Naming
feat/feature-name # New features
fix/issue-description # Bug fixes
refactor/component # Refactoring
docs/topic # Documentation
test/test-description # Test additionsCommit Message Format
type(scope): Short summary in present tense
[Optional body with details]Types: feat, fix, refactor, docs, test, chore, style, perf Scopes: server, app, desktop, tunnel, ws, cli, ci, docs
PR Workflow
CRITICAL: NEVER commit directly to main. Always use feature branches and PRs.
- Create feature branch from
main - Develop and test
- Push and create PR
- Preserve existing scoped authority and explicit holds; pass the merge gate below before any authorized merge in NORMAL or PRIME
- Squash merge to main
Gated Merge Authority: Delegated implementation includes synchronous squash merge of its selected work in NORMAL and PRIME, except explicit holds, review-only/draft-only requests and actual repository/host restrictions. A session-created PR may be self-merged ONLY after the full review pipeline (/full-review: agent review + thread triage) passes with a clean verdict, ALL CI checks are green on the final commit, and ALL review threads are resolved. NEVER use gh pr merge --auto or GitHub auto-merge — verify the gates, then merge synchronously and confirm the PR reports MERGED. No --admin, no protection overrides. Every self-merged PR MUST appear as its own entry in the end-of-session report (PR, issue, review verdict, checks, merge SHA). If a gate fails, diagnose and repair within the remaining allowance or report the specific dependency, keep the PR blocked and advance independent authorized work. Delegate only the actual owner-reserved action.
Do not request routine authorization again solely because the user is present. Explicit merge holds remain binding regardless of clean CI/review. Never use GitHub auto-merge; a ready/open PR is not merged delivery.
Merge Gate — MANDATORY triage when merge is blocked:
When gh pr merge fails with "not mergeable" or "base branch policy prohibits the merge", check in this order:
- Re-read the gate at the CURRENT head first.
mergeStateStatusis a function of the head SHA — a block recorded before a push is often already gone. Re-rungh pr view {N} --json mergeable,mergeStateStatusafter every push, and never escalate to the user (or propose an override) off a stale reading.UNKNOWNmeans GitHub is recomputing after a base change, not that a blocker exists — poll it. Treat contradictory readings with equal suspicion:BLOCKED+ green CI + "0 unresolved threads" means a reading is stale or a requirement is missing from your view (an unreported required check, a ruleset such as a pending Copilot review, required approvals) — re-derive each gate input at the current head instead of assuming, and never override. - Check for merge conflicts:
gh pr view {N} --json mergeable,mergeStateStatus. IfCONFLICTING, rebase or merge main into the branch to resolve, then retry. - Check CI:
gh pr checks {N}. If any check isFAILUREorPENDING, fix the failing check or wait for pending checks, then retry. - Inspect review threads and general summaries at the current head. Fix, remove or verify containment of blocking findings within authority and remaining limits. Reply and resolve only supported dispositions; keep unremedied blockers open. Recheck actual required approvals/checks rather than assuming threads are the only possible cause. Ask the owner only for a genuinely reserved prerequisite; routine thread cleanup remains the coordinator's work.
<!-- chroxy-required-rule:end git-workflow --><!-- chroxy-required-rule:start code-style -->Code Style
Server (packages/server/)
- ES modules (
import/export) - No TypeScript — plain JavaScript
- No semicolons
- Single quotes for strings
- EventEmitter pattern for component communication
App (packages/app/)
- TypeScript (strict)
- Functional components with hooks
- Zustand for state management
- React Navigation for routing
Tap targets — 44pt minimum (#3751 / #3914 / #4876 / #4893 / #5634)
Every interactive control (button, toggle, tab, tappable row or badge) must present at least a
44×44 pt hit area. This is the Apple HIG minimum for iOS and WCAG 2.1 SC 2.5.5 (Target Size,
AAA) for the web dashboard — WCAG 2.2's AA floor is only 24×24, but we hold the stricter 44 as
the floor on every surface. Android is stricter still: Material's minimum is 48dp, so prefer 48
in packages/app wherever the layout allows.
Two ways to satisfy it, in order of preference:
- Grow the visible control —
minHeight: 44/minWidth: 44(RN),min-height: 44px/min-width: 44px(dashboard CSS). Prefer this whenever the layout has the room; the target then matches what the user sees. This is what #4893 did forconversationIdRow(32 → 44) and #5634 for the permission Approve/Deny buttons. hitSlop— for compact, information-dense rows where growing the visible element would break the layout (#4876's session-header badges, #3914's CheckInChip). Mobile app only:hitSlopis a React Native prop and does not exist in the dashboard, which must use padding or spacing instead.
A control whose visible box is smaller than 44pt and which has no hitSlop is a bug, not a style
preference — all five issues above were filed and fixed as such. When you shrink a control's
padding or font size, re-check the resulting box.
<!-- chroxy-required-rule:end code-style --><!-- chroxy-required-rule:start testing-conventions -->Testing Conventions
Every guard must be proven to fail — see docs/false-safety-guards.md
When you add or change a check, break the thing it protects and confirm it goes red. If you cannot make it fail, it is not a guard.
Every guard in docs/false-safety-guards.md passed unit tests, lint, typecheck
and CI — continuously, for months in some cases — because success and
not-checking were the same observable outcome (entry 11, #7273, is that
inverted: a check so broken it satisfied its own adversarial tests). Test
suites cannot find this class, because the green is real and the coverage is
what is wrong.
Don't put a count here. This sentence used to carry one, and the catalogue had already grown past it twice before anyone noticed — a hardcoded number beside a growing set, which is the first cause below. The catalogue says so itself: consult the entries, not a tally that is stale the moment one merges. The recurring causes:
- a hardcoded list next to a set that grows (
#7192,#7197,#7267) — including one that lived in CI config, where no lint, test or code review of the repo could reach it (#7270) - "cannot check this" silently treated as "nothing to check" (
#7195,#7210) - a precondition that is false, so the body never runs and the job is green
(
#7184,#7198) - testing the source tree when the artifact is what ships (
#7189) - a guard wired to only some of its callers, correct for every input it sees
and never reached by the rest (
#7262) - a check that denies everything, so its negative tests pass for the wrong
reason and would keep passing with the check deleted outright (
#7273) - a validated value handed on to something that parses it under a different
grammar — a pathspec, a revision, a glob, a shell word (
#7281) - a guard whose comment describes a stronger check than its code performs — a
character class that admits the flag it claims to block, a substring match
standing in for a token match (
#7290,#7291) - a real defence against the neighbouring class, mistaken for cover — shell
quoting stops the shell, and the quoted word still begins with
-when the spawned program runs its own option parser over it (#7295) - a guard that HANGS instead of failing —
assert.matchagainst a multi-KB source slice wedgesnode --testwith an empty TAP stream, so its two states are green and "flake", never red (#7340) - a guard whose precondition a NEIGHBOURING loop falsifies — a per-frame re-pin
holds the "this scroll was ours" flag continuously and restores the position
the guard tests, so
programmatic && atBottomis true by construction and the user-scrolled-up branch is never reached (#7399) - the TEST RUNNER itself dropping results —
--test-force-exitreported 154 to 192 of one file's 192 tests across five runs, every run# fail 0and exit 0, while a side-channel proved all 192 had executed (#7400) - a coverage test whose EXPECTATION is derived from its own subject — a parity
test that iterated
ALL_CATEGORIESand checked a map built fromALL_CATEGORIES, so it could not go red and never once compared the two rosters it was named for (#7424) - a filter whose terms match NOTHING, so the gate is satisfied by zero rows —
/batch-merge's CI gate named two check contexts this repo has never produced, selected 0 of a real PR's 23, and reported "no required check is failing" on a payload containingServer Tests=FAILURE(#7503); its first FIX then reintroduced the shape one layer down, classifying with an externaljqwhose failure yields the same empty list as "nothing wrong" — ask that question of every tool a gate shells out to (#7540review) - a roster checked in only ONE direction — every name in the list is a real job,
while nothing asks whether a real job is in the list; the same gap was filed
four times, once per job, before anyone read it as a missing invariant
(
#7199,#7216,#7544,#7639) - a test suite that runs in NO job — executable, passing, and named by no
workflow step for its whole life, while guarding the release updater-feed
merge; the same sweep found that nothing in
.github/had everbash -n'd a shell script (#7504) - a guard that asserts its subject's SPELLING because the subject lives where
nothing can execute it — three regexes over a CI step's YAML text, 6/6
mutations of that step surviving, four of them SILENTLY and two turning the
sweep into a no-op that reports success. The sharpest EXCLUDES a subtree, so
the asserted literal stays byte-for-byte intact while seven files leave the
sweep; the issue's own APPEND spelling widens instead, because git UNIONS
pathspecs (
#7646) - a hand-rolled harness that reports success over ZERO cases — eleven of this
repo's sixteen printed their usual summary with every counter at zero and
exited 0,
merge-updater-feeds.test.sh(the release updater-feed merge) among them. The bug report's own table listed five of the eleven, and the enumeration written to replace it found a seventeenth file nobody had scoped in (#7653)
Collapse to a boolean before asserting against file text. A failing
assert.match(subject, re) carries the ENTIRE subject as the error's actual
— 124 KB of TAP for a one-line assertion, and it has wedged the runner
(#7340, catalogue entry 17). Write assert.ok(re.test(src), 'message')
instead; assert.deepEqual against a large array wants the same treatment (map
to the short field first). #7401 swept the existing sites, and
scripts/lib/assert-match-payload-guard.mjs — installed via each
tests/_setup.mjs or --import assert-match-payload-guard-hook.mjs, in every
package that runs node --test (#7413 extended the original server-only
install to claude-hooks, protocol and design-tokens) — bounds the
payload for anything that lands later, without ever changing a verdict.
Subjects a test wrote itself (a log, a generated script) are small and fine
as-is.
Never pass --test-force-exit, and run what CI runs. Every package that runs
node --test refuses it now (scripts/lib/no-test-force-exit.mjs, installed via
each tests/_setup.mjs or the --import hook) because it drops test
RESULTS — the tests run, up to a fifth of them never report, and the run still
exits 0 with # fail 0 (#7400, catalogue entry 19). Nothing needs it: the
leaks that once required it were fixed in #6027/#6042, and it saves no wall
clock. A targeted local run is:
cd packages/server && node --import ./tests/_setup.mjs --experimental-test-module-mocks --test tests/<file>.test.jsIf that hangs after printing its summary, the file leaks a handle — tear it down in the test. That is the same leak CI will hit, and force-exiting hides it.
Check the exit code, not the output — and note cmd | grep -c FAIL reports
grep's status, not the script's. Then check the mutant went red, legibly,
and fast — != 0 also accepts hung, crashed-before-start and
killed-by-timeout, which read as flake rather than as a finding. Restore mutations with cp from a backup,
never git checkout --, which eats unrelated uncommitted work.
Server tests must not touch real user state (#4633)
Every test that constructs a SessionManager must pass stateFilePath pointing at a temp file. Otherwise the manager defaults to ~/.chroxy/session-state.json and the test silently clobbers your live state (this happened on 2026-05-30 — see feedback_test_state_contamination.md).
Two layers of defence are wired up:
Sandbox guard (
packages/server/tests/_setup.mjs, loaded vianode --import) — monkey-patches everyfsfunction that takes a path and mutates the filesystem, across the sync, callback andpromisessurfaces, so any test write to the real~/.chroxy/or~/.claude/tree throwsCHROXY_TEST_SANDBOX. The error includes the call site, so the next barenew SessionManager()fails loudly at the offending test.The method list is NOT written here, and not written in
_setup.mjseither: it expands from the one table inscripts/lib/test-fs-sandbox.mjs, whichpackages/claude-hooks/tests/_setup.mjsinstalls from too (the two guards were separate hand-written lists that had drifted in both directions — #7267, #7268). Adding a row there installs the guard AND its proof;tests/setup-sandbox-coverage.test.jsfails ifGUARDED ∪ EXEMPTstops covering the livefssurface, so a Node upgrade that adds a path-taking mutator goes red rather than quietly widening the hole. Reads are deliberately untouched — tests legitimately read the developer's real config.- CI lint (
packages/server/scripts/lint-tests-state-file-path.sh) — fails the build if anynew SessionManager(...)intests/is missingstateFilePath. Run locally withcd packages/server && ./scripts/lint-tests-state-file-path.sh.
If you need to write to the real home for a legitimate reason (no current test does), set process.env.CHROXY_TEST_ALLOW_REAL_HOME_WRITES = '1' scoped to the test and restore it after. The sandbox guard MUST stay enabled in package.json.
Provider session constructors must forward every BaseSession opt (#4797 / #5367)
Every class that extends BaseSession (or JsonlSubprocessSession, the middle layer above the subprocess providers) must forward every opt accepted by BaseSession's constructor. Dropping one silently disables it on its way down — the "middle-layer trap" that has bitten three times (#3224, #3231, #4790) and is documented in project memory as feedback_jsonl_subprocess_middle_layer.md.
Since #5367, the canonical way to forward is the picker, not a hand-maintained parallel destructure: each subclass takes a single constructor(opts = {}) and calls super(buildBaseSessionOpts(opts, { ...overrides })), where buildBaseSessionOpts (in base-session.js) copies exactly the keys in the exported BASE_SESSION_OPT_KEYS array. Subclass-local opts are read off opts directly; per-subclass defaults (e.g. provider, model) go in the overrides bag (which wins). The single source of truth is BASE_SESSION_OPT_KEYS + the BaseSession ctor destructure — the lint asserts they stay equal.
The CI lint (packages/server/scripts/lint-session-opt-forwarding.sh) now: (1) asserts BASE_SESSION_OPT_KEYS equals the BaseSession ctor destructure (drift → fail), and (2) requires every subclass to forward via super(buildBaseSessionOpts(...)) (or a rest-spread, or the legacy explicit super({ ... }) which is still checked per-key) — a hand-rolled super({ a, b }) that drops keys, or a super(someOtherFn(opts)), fails. Run locally with cd packages/server && ./scripts/lint-session-opt-forwarding.sh.
When adding a new BaseSession opt: add it to the BaseSession constructor destructure AND to BASE_SESSION_OPT_KEYS (same PR) — every picker subclass then inherits it for free. If an opt deliberately should not propagate to a particular subclass (rare), add // lint-ignore-opt-forwarding: <key> immediately above the class declaration and explain why.
<!-- chroxy-required-rule:end testing-conventions --><!-- chroxy-required-rule:start architecture -->Architecture
Server streams through ws-server.js → Cloudflare tunnel → mobile app / desktop dashboard:
- Server:
server-cli.js→sdk-session.js(Agent SDK) orcli-session.js(legacyclaude -p). Provider selected viaproviders.jsregistry. - Shared:
ws-server.js(WebSocket + auth + E2E encryption),tunnel/(Cloudflare),session-manager.js,providers.js,config.js,push.js - Desktop: Tauri tray app wrapping the web dashboard served by the server
- App: ConnectScreen → SessionScreen (ChatView + TerminalView), Zustand store (
connection.ts)
For component tables, WS protocol messages, data flow diagrams, and file listings: see docs/architecture/reference.md
Security model
docs/security/bearer-token-authority.md— token classes (primary / pairing-bound / hook secret), what each one grants, and the checklist for adding new endpoints. Read this before touchingws-auth.js,ws-permissions.js,pairing.js, or any new HTTP route that touches session state.docs/security/permission-floor.md— the protected-path / secret-read floor: what it covers, why it beats every lenient mode (auto/acceptEdits/ a broadallowrule), and the fact that it must hold on BOTH the in-process and the hook-routed pipeline. Read this before touchingpermission-floor.js,permission-manager.js'shandlePermission,ws-permissions.js, orhooks/permission-hook.sh— the floor has exactly one implementation and adding a second is #7004 all over again.docs/security/encryption-threat-model.md— transport-layer (key exchange + message encryption) threat model.docs/security/spawned-binary-provenance.md— provider-binary integrity + macOS quarantine preflight (verify-binary.js) and the supply-chain threat model. Read this before touchingutils/verify-binary.js,utils/preflight.js,utils/resolve-binary.js, or any providerresolvedBinary/spawn path.
<!-- chroxy-required-rule:end architecture --><!-- chroxy-required-rule:start dev-commands -->Dev Commands
# Server (use Node 22!)
PATH="/opt/homebrew/opt/node@22/bin:$PATH" npm run server:dev
# App — start Metro dev server (from packages/app/)
npx expo start
# Test client (validate server without mobile app)
node packages/server/src/test-client.js wss://your-urlApp Dev Builds (EAS)
The app requires a custom dev build (not Expo Go) because expo-speech-recognition and expo-secure-store include native code. Dev builds are configured via eas.json and built in the cloud with EAS Build.
One-time setup:
npm install -g eas-cli
eas login # create account at expo.dev if needed
cd packages/app
eas build:configure # already done — created eas.jsonBuilding the dev client:
# Build for Android (or --platform ios, or --platform all)
eas build --profile development --platform androidThis uploads to EAS cloud, builds (~10-15 min), and provides a download URL/QR code. Install the APK on your phone. The dev client app ("Chroxy") replaces Expo Go.
Only rebuild when native dependencies change (new native modules, SDK upgrades). For normal code changes (components, hooks, styles), hot reload works instantly — same as Expo Go.
Daily development workflows:
| Scenario | Command | Notes |
| Developing at home | npx expo start | LAN mode, phone + Mac on same wifi, hot reload |
| Testing on the go | eas build --profile preview | Standalone APK with bundled JS, no Metro needed |
| Local emulator | npx expo run:android or npx expo run:ios | Requires local SDK, builds locally |
Known issues:
npx expo start --tunnelfails on Android (ngrok URL triggersIDN.toASCIISSL error). Use LAN mode or preview builds instead.- First time opening the dev client, enter the Metro URL shown in terminal (e.g.,
exp://192.168.x.x:8081).
<!-- chroxy-required-rule:end dev-commands --><!-- chroxy-required-rule:start ui-verification-with-maestro -->UI Verification with Maestro
Maestro E2E flows let agents verify app UI after code changes. Flows live in packages/app/.maestro/.
Prerequisites
# Install Maestro (one-time)
curl -Ls "https://get.maestro.mobile.dev" | bash
# Boot a simulator
xcrun simctl boot <device-id> # e.g. xcrun simctl list devices available
# Build + install the chroxy dev client once (flows target com.blamechris.chroxy;
# Expo Go can no longer load the app — native modules)
cd packages/app && npx expo run:ios
# Start Metro dev server (from packages/app/)
npx expo start
# Session/chat flows need the mock server on port 9876
bash packages/app/.maestro/scripts/start-mock-server.shRunning Flows
export PATH="$PATH:$HOME/.maestro/bin"
# Full green gate (recommended) — each flow in its OWN maestro process (#6091).
# Reliable on a single simulator over long runs: avoids the XCUITest instability
# that accumulates when all 27 flows share one process (run-all.yaml). Parses the
# flow inventory from run-all.yaml, starts/reuses the mock server, retries a
# failing flow once (env flakiness vs real defect), prints a pass/fail summary.
bash packages/app/.maestro/scripts/run-all-sequential.sh --device <device-id>
# Single-process run of all flows (fine on stable hosts / for a quick pass, but
# flaky over long single-simulator runs — see #6091).
maestro test --device <device-id> packages/app/.maestro/run-all.yaml
# Run a single flow
maestro test --device <device-id> packages/app/.maestro/connect-screen.yamlAfter App Changes — Verification Workflow
When you modify app components (screens, UI elements, styling), verify with Maestro:
- Ensure a simulator is booted and Metro is running
- Run the relevant flow(s) — screenshots are saved to
packages/app/ - Read the screenshot PNGs with the Read tool to visually verify correctness
- Delete screenshots when done (they're gitignored)
Available Flows
| Flow | What it verifies |
connect-screen.yaml | ConnectScreen elements: title, QR button, LAN scan, manual entry, port |
manual-connect.yaml | Manual entry form, URL input, Connect → optimistic SessionScreen + reconnect banner on unreachable server, header Disconnect back to ConnectScreen |
lan-scan.yaml | LAN scan trigger, in-progress or results state (scan can finish near-instantly) |
chat-todolist.yaml | TodoList renderer end-to-end (mock-server emits TodoWrite tool_use+tool_result, entry expands, structured TodoList renders with testIDs) |
chat-websearch.yaml | WebSearch structured renderer (mock-server emits WebSearch tool_start+tool_result via show-websearch, entry expands, WebSearchResultList renders title/domain/snippet rows; asserts the fixture's javascript: result produced no row) |
chat-webfetch.yaml | WebFetch structured renderer (show-webfetch trigger → source-URL link + markdown-formatted body) |
compaction-marker.yaml | CompactionMarker render (show-compaction trigger → compact_boundary system event → "Context compacted" headline + token delta + trigger) |
run-all.yaml | Runs all 27 flows sequentially — see its commented list for the full per-flow inventory (session, plan approval, AskUserQuestion, terminal, reconnect, disconnected-permission no-op, diff inline-comment, WebSearch/WebFetch renderers, compaction marker, …) |
Fixture Seeding for Structured Renderers
Tests for chat message renderers (TodoList, future MCP tools, tool_input_delta, etc.) seed fixtures via mock-server trigger phrases rather than an in-app debug menu. The pattern:
- The Maestro flow types a trigger phrase (e.g.
show-todos) into the chat input. mock-server.mjsdetects the phrase in itscase 'input'handler and emits the correspondingtool_start+tool_resultpair on the WebSocket.- The app processes these through
store-core/handlers/{handleToolStart,handleToolResult}— the production wire path — and lights up the renderer. - The flow taps the bubble to expand and asserts on
testIDprops (todo-list-header,todo-list-item-<id>, etc.).
Adding a new renderer to the suite:
- Add a
text.includes('<phrase>')branch alongside theshow-todosblock inmock-server.mjsthat emits the righttool_start/tool_resultfor the renderer. - Add
testIDprops to the renderer's key elements if not already present. - Add
<renderer>.yamlusingsetup/ensure-session-screen.yaml, the input + send sequence, anextendedWaitUntilfor the bubble header, tap-to-expand, andassertVisible: id:checks. - Add the new flow to
run-all.yaml.
This keeps the app dependency-free (no debug menu, no URL scheme handler) and tests the same path production tool messages take.
Gotchas
- For a full multi-flow gate, prefer
scripts/run-all-sequential.sh(each flow in its own maestro process) overrun-all.yamlon a single simulator — the single-processrun-all.yamlaccumulates XCUITest instability and crashes non-deterministically mid-flow (kAXErrorInvalidUIElement/ abrupt termination) over long runs (#6091). Never pass multiple.yamlfiles to onemaestro test— that runs them in parallel and breaks on one simulator. - Dev-client menu + onboarding can appear on launch — flows dismiss them with
optional: truetaps (the dev menu's "Continue" sheet / a top-of-screen tap, then onboarding "Skip"); expect these as WARNED/SKIPPED steps in green runs - Flow
name:must not contain "/" — Maestro derives report filenames from it; a slash crashes standalone runs with FileNotFoundException waitis not a valid command — usewaitForAnimationToEndorextendedWaitUntil- Emoji/icon text matching is unreliable — prefer
testIDanchors; anaccessibilityLabeloverrides the visible text in Maestro's matcher, and accessible containers flatten children so exact text matches need.*wildcards.* - Screenshots save to CWD — always clean up after verification
- Device compatibility — tested on iPhone 16 Pro (iOS 18.6), portrait only; iPad and Android are untested (tap coordinates may differ)
<!-- chroxy-required-rule:end ui-verification-with-maestro --><!-- chroxy-required-rule:start repo-memory-mcp -->Repo Memory MCP
This repo has the repo-memory MCP server configured, with ~1,500 files pre-indexed (AST summaries) and a post-merge hook that keeps the cache warm. Use it to avoid re-reading files and save tokens — it is heavily underused (the cache is warm but agents rarely call it).
Exploration protocol — try repo-memory before Read/grep
- Before you
Reada file, callget_file_summary— it returns exports, imports, purpose, and line count for a fraction of a full Read (a summary runs ~150–400 tokens; a full Read of a large file is several thousand). Usebatch_file_summariesfor several related files at once (preferred over N×get_file_summary). - Before you grep for a concept (auth, validation, tunnel, pairing…), call
search_by_purpose. - Use
get_related_filesto find what else to look at, andget_dependency_graphto trace imports/dependents (useful for review-impact checks). get_project_mapfor structure/entry points at the start of a task;get_changed_filesto see what moved since the last session.
When to Read the full file anyway
Read the full file (not just the summary) when you need exact implementation details, control flow, or to write code that matches the file's style — or when a summary returns suggestFullRead: true (low-quality summary, read instead).
Subagents
Spawned subagents get a fresh context and do not inherit this guidance — when a skill or prompt launches an exploration/review subagent, repeat the exploration protocol in that subagent's prompt (the exploration-heavy skills already do). get_token_report summarizes savings; the repo-memory report CLI reads the same data at zero token cost.
<!-- chroxy-required-rule:end repo-memory-mcp --><!-- chroxy-required-rule:start reference -->Reference
For detailed component tables, WebSocket protocol messages, file listings, and state management details: see docs/architecture/reference.md
<!-- chroxy-required-rule:end reference -->Last Updated: 2026-07-02 Version: 0.11.8
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

