Files
aiturk-mobil/AGENTS.md
T

336 lines
18 KiB
Markdown

# AGENTS.md
Guidance for AI coding agents working on this repository.
This complements [README.md](README.md) (for humans) and [DESIGN.md](DESIGN.md)
(visual and interaction requirements) with agent-focused operational and
architecture context. Before changing navigation, state ownership, or module
boundaries, read the relevant implementation and the conventions below. For UI
changes, also read `DESIGN.md`; for theme implementation, read
[`THEMES.md`](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md).
Flag conflicts between requirements and implementation instead of silently diverging.
## Project Overview
**Hermes Mobile** is a Jetpack Compose Android app — a mobile control panel for
[Hermes Agent](https://hermes-agent.nousresearch.com). It talks to the Hermes
dashboard's REST API and WebSocket TUI Gateway (JSON-RPC 2.0) over a trusted LAN.
- **Package:** `com.m57.hermescontrol`
- **Min SDK 26 / Target SDK 37 / Compile SDK 37**
- **Kotlin and KSP:** versions are pinned in [`gradle/libs.versions.toml`](gradle/libs.versions.toml). KSP uses standalone versioning, NOT `kotlinVersion-kspVersion`.
- **Jetpack Compose**, Room, Navigation3, OkHttp WebSocket, Retrofit, Kotlinx Serialization. See the version catalog and [`app/build.gradle.kts`](app/build.gradle.kts) for versions and dependency scopes.
- **Auth:** `EncryptedSharedPreferences` (AES256-GCM); connection/authentication flows are documented in [README.md](README.md#authentication). Trace the active REST and WebSocket auth paths before changing them.
## Build & Test Commands
### ✅ Local Android SDK — if available
If you have a local Android SDK (`ANDROID_HOME` set), these work:
```bash
./gradlew assembleDebug # full APK build
./gradlew testDebugUnitTest # unit tests (MockK)
./gradlew ktlintCheck # style check
```
**ktlint standalone fallback** (no SDK needed; not the authoritative CI gate):
```bash
# Download the matching binary
curl -sSLO https://github.com/pinterest/ktlint/releases/download/1.8.0/ktlint
chmod +x ktlint
./ktlint <file> # check one file
./ktlint --format <file> # auto-fix
```
**No SDK? CI handles everything** — push small and watch the checks below.
### CI pipeline (`.github/workflows/android.yml`)
| Job | Purpose |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `ktlint` | ktlint 1.8.0 style check + `checkColorLiterals` (hardcoded Color guard, issue #622) |
| `android-lint` | Android Lint |
| `unit-tests` | JUnit |
| `build` | assembleDebug (gated by the 3 above) |
| `release-compile` | compileReleaseKotlin — catches release-variant compilation issues (e.g. debugImplementation-scoped deps referenced in main source) |
| `instrumented-tests` | Compose UI tests on Android emulator (API 34 ATD) |
| `ci-summary` | Aggregator (`if: always()`) — branch protection gates on THIS check |
Also: There is a separate workflow `merge-conflict-detector.yml` that auto-labels PRs with `has-conflicts`.
Every Gradle job validates `gradle-wrapper.jar` and uses the remote build cache
(`GRADLE_ENCRYPTED_KEY` secret). `concurrency.cancel-in-progress: true`.
### Releasing (`.github/workflows/release.yml`)
Triggers on `git push tag v*`. Release APK uses R8 minification + resource shrinking.
Requires `permissions: contents: write` on the `build-release` job.
## Code Style
- **ktlint 1.8.0** is enforced in CI. With an Android SDK, run `./gradlew ktlintFormat` followed by `./gradlew ktlintCheck`. Standalone formatting is only a fallback; it does not replace the Gradle CI check.
- Import ordering is the #1 CI failure: ktlint enforces ASCII-lexicographic order
(uppercase before lowercase: `LaunchedEffect` before `collectAsState`).
- `const val` must use SCREAMING_SNAKE_CASE.
- Trailing commas required. No trailing whitespace.
- 120 char max line length.
## Architecture Conventions
### Navigation (Navigation3 — NOT Navigation Compose)
Uses `androidx.navigation3` (`NavKey`, `NavBackStack`, `NavDisplay`, `entry<T>`).
**⚠ Always route navigation through `NavigationController.navigateTo()`.** Never call
`backStack.add(ScreenKey)` directly from UI callbacks — the controller has a
deduplication guard. Bypassing it stacks duplicate screen entries that compete for
touch events and become unresponsive.
**Drawer gesture state is screen-owned (issue #619).** Each screen declares whether
the modal drawer's swipe gestures should be active while it is visible, via a single
source of truth — no global gesture set, no defensive `LaunchedEffect(snapTo(Closed))`,
no `closeDrawer` callback on `NavigationController`.
- `HermesScaffold(drawerGesturesEnabled = true)` — default; primary screens and
most list screens. The scaffold reconciles this preference into the
`DrawerGestureController` via a `SideEffect`.
- `HermesScaffold(drawerGesturesEnabled = false)` — drill-down sub-pages (e.g.
`SettingsConnectionPage`, `SettingsAppearancePage`, …). The controller closes
the drawer itself if it was open when the screen composed, so the scrim can't
stick around and intercept the next tap.
- `DisableDrawerGestures()` — for entry screens that don't use `HermesScaffold`
(Landing, AuthLogin, PairingCodeEntry).
`ModalNavigationDrawer` in `Navigation.kt` reads `gestureController.enabled`
(provided via `LocalDrawerGestureController`) and passes it to Material's
`gesturesEnabled` parameter. To change a screen's gesture behavior, edit the
screen — not `Navigation.kt`.
### Activity-Scoped ViewModels
Some ViewModels (e.g. `AuthLoginViewModel`) are created at the Activity scope via
`viewModel()` at the navigation entry level. This means they **survive navigation**
and cached state like `connectionSuccess` persists across screen entries.
Always pair transient state flags with a `DisposableEffect` cleanup:
```kotlin
DisposableEffect(Unit) {
onDispose { viewModel.clearTransientState() }
}
```
The cleanup function should reset self-transitioning flags (`connectionSuccess`,
`isLoading`, `errorMessage`) so the screen can re-enter cleanly.
### WebSocket Lifecycle
`HermesWsClient` is a singleton that outlives individual screens. It must be
explicitly disconnected on logout and reconnected after login:
| Event | Action |
| ------------- | ------------------------------------------------------ |
| Logout | `HermesWsClient.disconnect()` before clearing tokens |
| Login success | `HermesWsClient.connect()` after `ApiClient.rebuild()` |
The singleton's `connect()` has a guard (`if connected → skip`) so it's safe to
call unconditionally.
### Typed Gateway RPC
Every gateway method in the contract (`app/src/test/resources/gateway-contract/openrpc.json`) is called through a
typed descriptor: `HermesWsClient.call(RpcMethods.X, params)` (suspending), `requestTyped(...)`, or the typed
`send(RpcMethods.X, params, onSent)`. Params are `@Serializable` classes under `data/ws/contract/`; results are
`JsonElement` passthrough or typed. To add a method: add the params class, register it in `RpcMethods` (and
`RpcMethods.all`), and `GatewayContractTest` checks it against the contract.
- The untyped `request(String, Map)` / `send(String, Map)` are `internal`. Use them only for `gateway.ping` and the
legacy `*.respond` fallbacks (`clarify`, `sudo`, `secret`, `vault.*`) that have no contract entry.
- Optional params are `T? = null`: the client Json has `encodeDefaults = false`, so unset fields are omitted and an
explicit `false`/`""` is still sent. Do not add a `profile` field unless callers pass an explicit override;
`WsProfileParams.decorate` injects it for profile-scoped methods.
- `RawRpcParamsRatchetTest.BASELINE` must stay empty.
### Shared Components
- **`HermesScaffold`** — drawer-aware Scaffold + TopAppBar with refresh slot,
pull-to-refresh, optional snackbar host. New screens MUST use this instead of raw
`Scaffold`. API: `title: @Composable () -> Unit` (not `String` — wrap in
`{ Text("...") }`).
- **`StateViews`** — `LoadingState`, `ErrorState`, `EmptyState`. Every data screen
must implement all three branches in its `when { }` block.
### ⚠ HermesScaffold Padding Foot-Gun
`HermesScaffold` handles system insets and the dynamic top-bar offset in its
internal content wrapper. Although it passes `paddingValues` to the content
lambda, applying those values again double-stacks the padding:
```kotlin
HermesScaffold(...) { paddingValues -> // ← scaffold already handles top bar offset via Box
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues), // ← ❌ double-stacked top padding
contentPadding = listContentPadding, // ← extra 8dp, now triple-stacked
) { ... }
}
```
**Correct pattern — do NOT apply `paddingValues` on inner content:**
```kotlin
// ✅ List screens:
LazyColumn(
modifier = Modifier.fillMaxSize(), // no .padding(paddingValues)!!
contentPadding = listContentPadding, // this is the ONLY padding needed
verticalArrangement = listItemSpacing,
)
// ✅ Non-list screens (Column/Box wrapping):
Column(
modifier = Modifier.fillMaxSize(), // no .padding(paddingValues)!!
) { ... }
// ✅ Loading/Error/Empty states inside HermesScaffold content follow the same rule:
LoadingState(modifier = Modifier.fillMaxSize())
```
This applies to every branch inside the content lambda. A state view outside
`HermesScaffold` must follow its own parent layout's inset contract instead.
See [`HermesScaffold.kt`](app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt)
for the current inset implementation.
### Room Persistence
- `ChatMessageEntity` / `ChatMessageDao` / `HermesDatabase` — chat messages survive
app kills. `getMessagesForSession()` returns `suspend fun ...: List<ChatMessageEntity>`
(not `Flow` — the caller controls the coroutine scope).
- Room schema configuration is defined in `app/build.gradle.kts`; preserve schema export and migrations when updating Room.
- `ChatViewModel` extends `AndroidViewModel` (needs Application for DB access).
### Theme
`Theme.kt` uses a preset-based theme system with 7 built-in presets: Default,
Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, and Garnet. Each preset provides light
and dark color schemes plus matching semantic status colors (AMOLED is
dark-only — light mode falls back to the default theme).
Every preset is a `ThemePalette` template fill (`PaletteTemplate.kt`): raw
`PaletteColors` + `HermesStatusColors` mapped into the Material 3
`ColorScheme` slots, with the error slots derived from the status set.
Dynamic color (Material You on API 31+) is controlled by a `useDynamicColors`
parameter on `HermesControlTheme`, not a compile-time constant. When dynamic
colors are active they override the preset's `ColorScheme`, but semantic status
colors (success/warning/error/info) are ALWAYS resolved from the active preset
via `LocalHermesStatusColors` — never from `MaterialTheme.colorScheme`.
Access status colors via `LocalHermesStatusColors.current.success`, not
`MaterialTheme.colorScheme`.
## Git Workflow
### PR-Always (ENFORCED)
**Every change goes through a PR, targeting `dev`. Never push directly to `dev` (or `main`).**
```bash
git checkout dev && git pull origin dev
git checkout -b fix/issue-N-description # or feat/...
# make changes, run ./gradlew ktlintFormat ktlintCheck (requires Android SDK)
git commit -m "fix(#N): description"
git push -u origin HEAD
gh pr create --base dev --title "fix(#N): description" --body "Closes #N"
```
### Commit Conventions — STRICT
**⚠ Agent-authored commits:** Do NOT override git authorship (`--author`) with the agent's identity. Use the default git config. No `Co-Authored-By` or `Generated with` trailers.
- **Short commits:** subject line + max 2 lines of body. Split into atomic commits rather than writing long bodies.
- **Conventional Commits** types: `feat`, `fix`, `refactor`, `docs`, `test`, `ci`, `chore`.
- Bug fixes annotated inline with the tracking issue/regression ID.
## Security Considerations
- **Cleartext HTTP/WS is intentional** — trusted LAN only. `usesCleartextTraffic="true"`
and `http://`/`ws://` URLs are by design. Don't "fix" this unless adding HTTPS support.
- **`HttpLoggingInterceptor.Level.BODY`** must stay gated on `BuildConfig.DEBUG` — it
prints the Authorization header. (B1)
- **AuthManager token** is ephemeral — it becomes invalid when the Hermes dashboard
restarts. Symptom: 401 on every call. Fix: re-extract the token from the running
gateway, don't assume the stored one is valid.
- **Release signing** uses a keystore with env-var credentials (`KEYSTORE_PATH`,
`KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`). Don't commit keystore files or
hardcode passwords.
## Things to Avoid
- Don't run `./gradlew` tasks without an Android SDK — CI handles compilation, lint, and tests.
- Don't remove `@OptIn(ExperimentalMaterial3Api::class)` from screens that use
`SegmentedButton`, `PullToRefreshBox`, or other experimental M3 APIs.
- Don't access `MaterialTheme.colorScheme.*` inside non-`@Composable` lambdas
(`remember {}`, `buildAnnotatedString`, `LaunchedEffect`). Extract to a local
`val` at the composable scope first.
- Don't add new screens without checking if an existing one already covers the
functionality (30+ screens exist). Extend rather than duplicate.
- **⚠ Never scope a dependency to `debugImplementation` if its import is used in
`main/` source code.** The CI `release-compile` job catches this, but save the
cycle. `okhttp3.logging.HttpLoggingInterceptor` is the classic example — it's
imported in `ApiClient.kt` (main source) so it must be `implementation`, not
`debugImplementation`. If you want it debug-only, wrap the usage in
`if (BuildConfig.DEBUG)` or extract it behind an interface.
### Hardcoded Color Guard (issue #622)
A custom Gradle task `checkColorLiterals` fails the build if `Color(0x...)` hex
literals or named `Color.White`/`Color.Black`/etc. appear outside the allowed
paths (`theme/`, `*Preview.kt`, `PairingScreen.kt`, `AuthLoginScreen.kt`).
Use `MaterialTheme.colorScheme.<token>`, `LocalHermesStatusColors.current.<semantic>`,
or `Color.Transparent` / `Color.Unspecified` instead. The guard runs in CI as part
of the `ktlint` job.
## Project Layout
```
com.m57.hermescontrol/
├── data/
│ ├── config/ ServerStore, ConnectionProfile, migrations
│ ├── local/ AuthManager, Room (ChatMessageEntity/Dao, HermesDatabase), AnalyticsCacheStore
│ ├── model/ 40+ data classes for API responses + requests
│ ├── remote/ ApiClient, Retrofit service, OkHttp provider, cookie management
│ ├── repository/ KanbanRepository, KanbanSyncCoordinator, VoiceNoteRepository
│ ├── session/ ActiveSessionHolder
│ ├── update/ AppUpdateChecker, AppUpdateCache, AppUpdateState
│ └── ws/ HermesWsClient, JSON-RPC models, WsEvent, BillingRepository
├── notification/ ChatNotificationService, NotificationReplyReceiver
├── theme/ Color, Theme, Motion, Spacing, Shapes, Type, HermesStatusColors
│ └── presets/ Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, Garnet
├── ui/
│ ├── common/ HermesScaffold, StateViews, SharedComponents, DetailDialog, DetailRows
│ └── 31 feature packages (achievements, analytics, authlogin, billing, bots,
│ channels, chat, config, connect, connectors, cron, files, gateway, kanban,
│ keys, landing, logs, mcp, memory, model, pairing, plugins, process,
│ profiles, providers, sessions, settings, skills, system, toolsets,
│ webhooks)
├── util/ CronExpressionFormatter, LocaleContextWrapper
├── HermesControlApp.kt Application class
├── Navigation.kt Drawer + NavDisplay + entry wiring
├── NavigationController.kt Central navigation guard (dedup)
├── NavigationKeys.kt @Serializable NavKey data objects (30 screens + 4 detail keys + 7 settings sub-pages)
├── ScreenRegistry.kt entry<T> registrations for all NavKeys
└── MainActivity.kt
```
## Further Reading
- [README.md](README.md) — human-facing overview, features, screenshots, tech stack
- [DESIGN.md](DESIGN.md) — visual and interaction requirements, accessibility, and token source map
- [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md) — theme template, dispatcher, and mode fallback implementation
- [Gateway contract test](CONTRIBUTING.md#gateway-contract-snapshot) — backend drift check; refresh with `scripts/sync-gateway-contract.sh`
- [CONTRIBUTING.md](CONTRIBUTING.md) — contributor workflow, PR checklist, code style
- [.github/workflows/android.yml](.github/workflows/android.yml) — CI pipeline source of truth
- [.github/workflows/merge-conflict-detector.yml](.github/workflows/merge-conflict-detector.yml) — auto-labels conflicting PRs