Skip to content

Contributing to agent-sessions (BattleLab) ​

Thanks for your interest! This is a small, focused project. PRs and issues are welcome.

Dev setup ​

Backend (Python ≥ 3.11):

bash
python3 -m venv .venv && . .venv/bin/activate
pip install -e '.[dev]'

Frontend (Node ≥ 20):

bash
cd web && npm ci

Running the tests ​

bash
# Python
ruff check src tests          # lint
ruff format --check src tests # formatting
pytest                        # unit + integration
pytest -m "not e2e_install"   # same, minus the multi-minute install.sh end-to-end tests
pytest -n 8 --dist loadfile   # the full suite in parallel, exactly as pr-validate runs it

# Web
cd web
npm test          # vitest component/unit tests
npm run lint      # eslint
npm run build     # tsc + Vite production build
npm run test:e2e  # Playwright (desktop + emulated mobile); installs chromium on first run

Run all of the above locally before opening a PR — CI runs the same checks. The e2e_install deselect is a local-iteration convenience only; CI runs the full suite for any change under src/, tests/, scripts/, deploy/, .forgejo/ or to packaging and the installer scripts, so land nothing that hasn't passed a plain pytest at least once.

What CI runs is scoped to the diff (#1244). A PR that touches none of those paths (web, docs, landing only) runs just the test files that mention a path it changes. Several tests pin files under web/ and docs/, so this is never zero by design. It also skips the installer contract, the deploy-shape smoke and installer-smoke (the last only for docs/landing-only changes). .forgejo/scripts/pr_scope.py decides, and any doubt means the full suite. A red check stops its siblings: the long steps (pytest, the e2e shards, app-mode e2e) run under .forgejo/scripts/sibling_watch.py. When another check for the same commit has failed, it stops them within ~30 s and says so (aborted: sibling "…" failed), so a red commit stops holding runners. An aborted check did not fail on its own: fix the sibling it names.

Test-suite policies worth knowing:

  • PBKDF2 runs at a test-scoped work factor (#699). An autouse session fixture in tests/conftest.py drops auth._PBKDF2_ITERS from 600k to 1,000 for the test session — verify_password reads the iteration count from the hash string itself, so the full parse/derive/compare path is still exercised and production code is untouched. The fixture asserts the production constant is ≥ 600k before patching: weakening the real work factor fails the whole suite.
  • Markers are strict (--strict-markers): a typo'd @pytest.mark.… is a collection error, not a silent no-op. Register new markers in pyproject.toml.
  • deploy_shape marks the tests pr-validate's wheel pass re-runs (#704). The app resolves its Jinja templates and /static assets relative to the installed module (main.py's _HERE = Path(__file__).parent), so those reads can only break in a non-editable install — which is why that pass installs with pip install . and re-runs -m deploy_shape from outside the repo. Mark a new test deploy_shape when it covers a resource read off the installed package (a new template, a new /static asset). Presence of every packaged resource is checked separately in that job and derives from the source tree, so it can't drift; the marker is what proves the app can still resolve them. The rest of the suite runs once, in the editable pass — re-running it against the wheel catches nothing (a wheel built with no templates at all still passes 283 of the non-rendering tests).

install.sh / uninstall.sh are smoke-tested on every PR that touches the app, packaging, the installer or CI (installer-smoke.yml): a pristine container (no usable Python/Node — the vendored-toolchain path) installs from your checkout, the installed app must serve /healthz → {"ok":true}, and the uninstall must remove the install root + prefs + cache while a seeded ~/.claude survives. It takes ~5–10 min (vendors both toolchains + a real Vite build) and runs in parallel with the other checks. Reproduce locally with scripts/smoke-install (needs Docker). Toolchain tarballs (only the sha256-pinned ones) and the pip/npm caches persist in ~/.cache/agent-sessions-smoke between runs; set SMOKE_CACHE_DIR=off for a fully cold run (#1102).

Tests never touch your real ~/.claude: they use a mktemp -d AGENT_SESSIONS_HOME. Installer tests must set AGENT_SESSIONS_NO_SERVICE=1 so they don't write your real systemd unit. Heavy UI-build tests (real npm ci + Vite build) are gated behind AGENT_SESSIONS_TEST_UI_BUILD=1.

The shell-free rule (please read) ​

The engine launchers are shell-free by design — a load-bearing security property, not a style preference. Every subprocess call is subprocess.run([...], check=True) with a literal argv list; no shell=True, no os.system, no inline shell interpreter. UUIDs, cwds, and session/tab names are validated before they reach subprocess. CI (.forgejo/workflows/pr-validate.yml) greps src/ for shell patterns and fails the build if any appear, and the unit tests assert the same.

If you find yourself wanting to add a shell layer "just for this one case" — don't. Refactor the case to fit the argv model. See CLAUDE.md for the other project invariants (engine-qualified identity, single-writer session lock, the PWA service-worker denylist, the auth model).

PR conventions ​

  • One logical change per PR; keep the diff scoped.
  • Add a regression test for every bug fix and every new behavior.
  • Conventional, descriptive commit messages. No AI-attribution trailers.
  • Update the docs (README.md / CLAUDE.md / docs/) when you change behavior.
  • For UI changes, include before/after screenshots — see docs/visual-review.md.

UI changes — NIGHTJAR ​

The visual language is NIGHTJAR // OPS, docs/design.md v2. New or changed UI:

  • Uses the NIGHTJAR primitives from web/src/components/hud/ and nightjar.css (panels, plates, callsign headers, LEDs, pips and gauges, chips and tags) and the tokens in web/src/tokens.css. No literal colour, radius, shadow, z-index or off-scale font size; never copy v1 chrome. The unlinked /__nightjar route (behind the normal login) renders every primitive in its states, in whichever mode, motion mode and viewport you open it.
  • Adds its ledger rows. Every stylesheet under web/src, every component that renders a dialog, menu, listbox or portal, and every page has a row in docs/nightjar-ledger.md; a new one without a row fails nightjarLedger.test.ts. A done row names its sweep captures and its motion.
  • Runs its sweep rows. Add the states it introduces as capture ids in web/visual/nightjar/manifest.mjs and photograph them with web/visual/nightjar/sweep.mjs --only <ids> (how to run it is in docs/visual-review.md); those shots are the PR's evidence.
  • Honours the motion modes (Full / Reduced / Off): animate only through motion.css and theme/motion.ts, never read prefers-reduced-motion directly, and never move a work surface (the terminal, the editor, a focused field) — see docs/invariants/nightjar-motion.md.

Licensing of contributions ​

BattleLab is licensed AGPL-3.0-or-later (see LICENSE).

Contributions are inbound=outbound: by opening a pull request you agree that your contribution is licensed under the same AGPL-3.0-or-later terms as the project, and you confirm you have the right to license it that way (it's your own work, or you have permission from whoever owns it). No copyright assignment is requested and no CLA needs signing — you keep the copyright in what you wrote.

Please don't paste in code under an incompatible license. Permissive licenses (MIT, ISC, BSD, Apache-2.0) are fine to vendor with their notices intact; GPL-2.0-only or proprietary code is not, because it can't be redistributed under AGPL-3.0.

The BattleLab name and logo are not covered by the code license — see TRADEMARK.md.

Code of conduct ​

Be respectful and constructive. We follow the spirit of the Contributor Covenant. Report unacceptable behavior by opening a confidential issue or contacting the maintainers.

Built from v0.19.4-323-g62490f57 · 62490f5 · ahead of last publish: unknown