feat: land native mobile foundation

This commit is contained in:
Hermes Agent
2026-07-24 07:16:39 +00:00
parent 73e17676b5
commit 26bff1174d
11 changed files with 1151 additions and 378 deletions
+4
View File
@@ -9,6 +9,7 @@ build/
.next/
coverage/
*.tsbuildinfo
.gradle-user/
# env/secrets
.env
@@ -25,6 +26,9 @@ uploads/
*.db-wal
logs/
# local agent/audit workspace
.dev/
# OS/editor
.DS_Store
.vscode/
+52
View File
@@ -0,0 +1,52 @@
# Changelog
All notable Hermes Mobile source changes are recorded here. Entries are added during the same work loop as their implementation; release artifacts are only listed after source commit, checks, and APK upload are verified.
## Unreleased
### Added
- Added native Android chat, workspace, settings, local defaults, companion health, and file editing surfaces for the beta client.
- Added `PROJECT_PLAN.md` as the project-controlled milestone plan.
### Changed
- Updated companion `/api/chat` to call `hermes chat -Q --source tool -q <prompt>`, sanitize CLI banners/help/control output, truncate large mobile replies, and return structured failures.
- Reconciled `docs/ROADMAP.md` to the current native Android beta state.
- Ignored `.gradle-user/` and `.dev/` alongside existing generated build artifacts.
### Fixed
- Fixed companion/mobile lint issues from missing Node globals and an unused React settings value.
### Verification
- `git diff --check` — passed
- `npm run typecheck --workspace @hermes-mobile/companion` — passed
- `npm run lint --workspace @hermes-mobile/companion` — passed
- `npm run build --workspace @hermes-mobile/companion` — passed
- `npm run typecheck` — passed
- `npm run lint` — passed
- `npm run build` — passed
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android` — blocked before compilation: network socket creation is not permitted while downloading Gradle 8.11.1
## Release history
_No versioned APK releases yet._
## Entry format
```markdown
## [v0.1.0-beta.1] - YYYY-MM-DD
### Added
- Concise user-visible change.
### Changed
- Concise refinement or architectural change.
### Fixed
- Concise defect fix.
### Verification
- `command` — passed
### APK
- `hermes-mobile-v0.1.0-beta.1.apk` — Telegram release topic message <id>
```
+113
View File
@@ -0,0 +1,113 @@
# Hermes Mobile — Delivery Plan
> **Operational contract:** Codex must read this file before each work loop. Work exactly one coherent milestone at a time. Move completed items to the completed section in the same commit that implements them. Add a concise entry to `CHANGELOG.md` in the same commit. Never mark work complete without running its listed verification.
## Product guardrails
- Native Android client for upstream Hermes Agent: **Kotlin + Jetpack Compose**.
- Material 3 base, deliberately customized through a reusable design system.
- Thin client: Hermes remains the authority for auth, sessions, streaming, tool semantics, and backend behavior.
- No WebView-first app, no cloned Hermes backend, no embedded credentials.
- Adaptive, accessible, compact-but-readable UI: phone, tablet, light/dark, font scaling, IME/keyboard, long code/output must remain safe.
- Keep DTO/transport, domain/UI models, repositories, view models/state, and composables separated.
- Never commit APKs, build outputs, credentials, or generated local environment files.
## Release policy
- Start artifacts at `v0.1.0-beta.1`.
- Patch: compatible fixes/polish. Minor: a coherent feature milestone. Major: stable `v1.0.0+` only.
- A release requires a pushed source commit, passing relevant checks, a versioned APK artifact, and a Telegram release post.
---
## Now — finish the foundation already in progress
- [ ] **F1 — Compose design system extraction**
- Extract tokens for color, spacing, typography, shape, elevation, motion, and semantic status colors.
- Implement light/dark themes with readable contrast; eliminate default-sample styling.
- Split oversized UI into clear design, feature, and reusable component packages.
- Add Compose previews: small phone, normal phone, tablet width, light, dark.
- Verification: Android compile/build plus visual preview review.
- [ ] **F2 — Polished fake-data chat proving ground**
- Session rail/list, conversation screen, context controls, keyboard-safe composer, send/cancel/retry behavior.
- Render assistant/user/system/error/streaming messages from UI models.
- Add markdown-friendly surfaces, selectable long text, code blocks with horizontal scroll + copy, compact expandable tool results.
- Cover empty/loading/reconnecting/error states without clipped/overlapping content.
- Verification: focused UI tests where practical; Android debug build.
## Beta path — upstream-connected MVP
- [ ] **B1 — Transport/domain contracts**
- Define transport DTOs and a repository interface for server profiles, authentication, sessions, messages, stream events, tool results, and artifacts.
- Map DTOs to stable UI models; fake repository powers previews/tests.
- Keep the existing companion as an optional adapter only; do not change upstream Hermes semantics.
- Verification: unit tests for mapping/state reducers; build/typecheck.
- [ ] **B2 — Connection and authentication UX**
- Server connection screen with validation, health, auth/session configuration, offline/unauthorized/retry states.
- Store sensitive values using Android-appropriate secure storage; never log secrets.
- Verification: connection-state tests and manual unhappy-path walkthrough.
- [ ] **B3 — Session list and durable chat state**
- Fetch/list/search/select/create sessions through repository contracts.
- Restore selected session and protect against duplicate sends / lost drafts.
- Verification: fake-repository UI tests; companion/integration checks where available.
- [ ] **B4 — Streaming, reconnects, and resilient long-running work**
- Stream event reducer: token deltas, tool lifecycle, final/error/cancel, reconnect/backoff.
- Preserve partial output and make recovery obvious and safe.
- Verification: deterministic fake stream tests and real upstream-compatible exercise.
- [ ] **B5 — Tool, artifact, and output experience**
- Compact tool cards with lifecycle/status, expandable output, copy/share/save affordances.
- Safe rendering for large logs and code; no unbounded nested scroll failures.
- Artifact/file handoff only where upstream capability is actually supported.
- Verification: large-output and rotation/resume checks.
## Beta hardening and release
- [ ] **R1 — Adaptive/accessibility quality pass**
- Small phone, normal phone, tablet/two-pane behavior; portrait/landscape.
- Font scaling, TalkBack semantics, contrast, touch targets, reduced motion, IME behavior.
- Verification: emulator/device matrix evidence documented in `docs/ROADMAP.md`.
- [ ] **R2 — Beta packaging and install guide**
- Version `v0.1.0-beta.1`, reproducible debug/beta APK build, install/run guide, known limitations.
- Publish source commit, APK, release notes, and verification result.
- Verification: clean install on a real device or emulator plus full required checks.
## V2 backlog
- [ ] Multiple server profiles and fast switching.
- [ ] Rich file/artifact workflows based on upstream capability.
- [ ] Notifications for task completion.
- [ ] Mature tablet/two-pane session + optional terminal/log pane.
- [ ] Offline cache/search and diagnostic export with privacy controls.
---
## Completed
_Move finished items here with date, commit, and verification. Keep this section factual; do not claim unverified work._
- [x] **F0 — Audit and safely land existing work** — 2026-07-24, commit `da79d48` (`feat: land native mobile foundation`).
- Reviewed existing Android and companion edits; preserved the native Android chat/workspace/settings work and companion chat hardening.
- Confirmed generated build outputs are ignored and added ignores for `.gradle-user/` and `.dev/`.
- Reconciled `docs/ROADMAP.md` to the actual native Android beta state and remaining validation gaps.
- Verification: `git diff --check` passed; `npm run typecheck --workspace @hermes-mobile/companion` passed; `npm run lint --workspace @hermes-mobile/companion` passed; `npm run build --workspace @hermes-mobile/companion` passed; `npm run typecheck` passed; `npm run lint` passed; `npm run build` passed.
- Android debug build attempted with `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android`; blocked before compilation because network socket creation is not permitted while downloading Gradle 8.11.1.
## Work-loop checklist
Before coding:
1. Read `PROJECT_PLAN.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, git status, and the prior commit.
2. Choose the single highest-value unchecked item or a narrowly scoped sub-item.
3. Inspect existing implementation before rewriting it.
Before committing:
1. Run relevant checks; record exact pass/fail scope in the changelog.
2. Update this plan: check the completed item, move it into **Completed**, and leave the next executable item unchecked.
3. Add a concise `CHANGELOG.md` entry under **Unreleased**.
4. Commit using Conventional Commits and push `origin/main`.
5. Do not claim a release without an uploaded, verified versioned APK.
+1
View File
@@ -1,3 +1,4 @@
import { Buffer } from 'node:buffer';
import { timingSafeEqual } from 'node:crypto';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { authValidateResponseSchema } from '@hermes-mobile/shared';
+113 -23
View File
@@ -5,33 +5,123 @@ import { toSafeAbsolutePath } from '../config/paths.js';
import { getHermesHealth } from '../system/hermesHealth.js';
import { runProcess } from '../system/processes.js';
const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[;?0-9]*[ -/]*[@-~]`, 'g');
export async function registerChatRoutes(app: FastifyInstance, config: CompanionConfig): Promise<void> {
app.post('/api/chat', async (request, reply) => {
const body = chatRequestSchema.parse(request.body);
const hermesHealth = await getHermesHealth();
if (!hermesHealth.cliAvailable) {
return chatResponseSchema.parse({
reply: 'Hermes CLI was not found on this machine. Install Hermes or make sure `hermes` is on PATH, then retry.',
exitCode: null,
stderr: undefined,
hermesAvailable: false,
});
const body = chatRequestSchema.safeParse(request.body);
if (!body.success) {
return reply.code(400).send(
chatResponseSchema.parse({
reply: 'Chat prompt is required.',
exitCode: null,
stderr: undefined,
hermesAvailable: false,
}),
);
}
const result = await runProcess(hermesHealth.cliPath ?? 'hermes', [], {
cwd: toSafeAbsolutePath(config.workspaceRoot),
timeoutMs: 30000,
input: body.prompt,
});
try {
const hermesHealth = await getHermesHealth();
return reply.code(result.exitCode === 0 || result.exitCode === null ? 200 : 502).send(
chatResponseSchema.parse({
reply: result.stdout.trim() || result.stderr.trim() || 'Hermes finished without output.',
exitCode: result.exitCode,
stderr: result.stderr || undefined,
hermesAvailable: true,
}),
);
if (!hermesHealth.cliAvailable) {
return reply.send(
chatResponseSchema.parse({
reply: 'Hermes CLI was not found on this machine. Install Hermes or make sure `hermes` is on PATH, then retry.',
exitCode: null,
stderr: undefined,
hermesAvailable: false,
}),
);
}
const result = await runProcess(hermesHealth.cliPath ?? 'hermes', ['chat', '-Q', '--source', 'tool', '-q', body.data.prompt], {
cwd: toSafeAbsolutePath(config.workspaceRoot),
timeoutMs: 120000,
});
const cleanStdout = sanitizeHermesOutput(result.stdout);
const cleanStderr = sanitizeHermesOutput(result.stderr);
const openedHelp = looksLikeTopLevelHermesHelp(result.stdout) || looksLikeTopLevelHermesHelp(result.stderr);
const spawnFailed = result.exitCode === null && cleanStderr.length > 0 && cleanStdout.length === 0;
const failed = result.timedOut || openedHelp || spawnFailed || (result.exitCode !== 0 && result.exitCode !== null);
const fallback = openedHelp
? 'Hermes opened its CLI help instead of running the prompt. Restart the companion server so it uses the updated chat command.'
: failed
? `Hermes did not finish successfully${result.exitCode === null ? '' : ` (exit ${result.exitCode})`}.${result.timedOut ? ' The request timed out.' : ''}`
: 'Hermes finished without output.';
const responseText = (openedHelp ? '' : cleanStdout) || (openedHelp ? '' : cleanStderr) || fallback;
return reply.code(failed ? (result.timedOut ? 504 : 502) : 200).send(
chatResponseSchema.parse({
reply: truncateForMobile(responseText),
exitCode: result.exitCode,
stderr: cleanStderr ? truncateForMobile(cleanStderr) : undefined,
hermesAvailable: true,
}),
);
} catch (error) {
const message = error instanceof Error ? error.message : 'Chat request failed.';
return reply.send(
chatResponseSchema.parse({
reply: sanitizeHermesOutput(message) || 'Chat request failed.',
exitCode: null,
stderr: undefined,
hermesAvailable: true,
}),
);
}
});
}
function sanitizeHermesOutput(text: string): string {
return text
.replace(ansiPattern, '')
.replace(/\r/g, '')
.split('\n')
.filter((line) => !isBannerOrHelpLine(line))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function isBannerOrHelpLine(line: string): boolean {
const trimmed = line.trim();
if (!trimmed) return false;
if (/session_id\s*:/i.test(trimmed)) return true;
if (/[\u2500-\u257F]/u.test(trimmed)) return true;
if (/^[+\-_|/\\()[\]{}<>\s*]+$/.test(trimmed) && trimmed.length > 8) return true;
if (/^(usage:|positional arguments:|options:|examples:|for more help on a command:|hermes <command> --help)/i.test(trimmed)) return true;
if (/^\{chat,model,gateway,setup,whatsapp,login,logout,auth,status,cron,webhook,doctor,config,pairing,skills,plugins,honcho,tools,mcp,sessions,insights,claw,version,update,uninstall,acp,profile,completion\}/i.test(trimmed)) return true;
if (/^(chat|model|gateway|setup|whatsapp|login|logout|auth|status|cron|webhook|doctor|config|pairing|skills|plugins|honcho|tools|mcp|sessions|insights|claw|version|update|uninstall|acp|profile|completion)\s{2,}/i.test(trimmed)) return true;
if (/^hermes\s+(chat|setup|gateway|status|doctor|model|config|sessions|update|uninstall)\b/i.test(trimmed)) return true;
if (/^(Hermes|⚕ Hermes)\b.*(Agent|CLI|v?\d)/i.test(trimmed)) return true;
if (/^Hermes Agent - AI assistant with tool-calling capabilities$/i.test(trimmed)) return true;
return false;
}
function looksLikeTopLevelHermesHelp(text: string): boolean {
const clean = text.replace(ansiPattern, '');
return /usage:\s+hermes\b/i.test(clean) && /Hermes Agent - AI assistant with tool-calling capabilities/i.test(clean);
}
function selfCheckSanitizer(): void {
const sample = [
'\u001B[36m╭─ ⚕ Hermes v1.2.3 ─╮\u001B[0m',
'│ session_id: abc123 │',
'╰────────────────────╯',
'',
'Final answer line.',
].join('\n');
if (sanitizeHermesOutput(sample) !== 'Final answer line.') {
throw new Error('Hermes output sanitizer self-check failed.');
}
}
selfCheckSanitizer();
function truncateForMobile(text: string): string {
const maxChars = 24000;
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}\n\n[Output truncated]`;
}
+1
View File
@@ -1,3 +1,4 @@
import { Buffer } from 'node:buffer';
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import type { FastifyInstance } from 'fastify';
+1
View File
@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process';
import { clearTimeout, setTimeout } from 'node:timers';
export type RunProcessResult = {
stdout: string;
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,7 @@ import { useCompanion } from '../../api/CompanionContext.js';
import { CompanionClient, DEFAULT_COMPANION_URL } from '../../api/companionClient.js';
export function SettingsScreen() {
const { client, settings, updateSettings } = useCompanion();
const { settings, updateSettings } = useCompanion();
const [baseUrl, setBaseUrl] = useState(settings.baseUrl);
const [accessKey, setAccessKey] = useState(settings.accessKey);
const [status, setStatus] = useState('Run companion setup, paste the access key, then test the connection.');
+28 -130
View File
@@ -1,136 +1,34 @@
# Roadmap
# Hermes Mobile Beta Roadmap
## Phase 0 — Planning Scaffold
## Current Milestone: Native Android Beta Chat and Connection UX
Status: completed.
Status: in progress, close to beta validation. F0 source landing is complete; Android debug build validation remains environment-blocked.
Deliverables:
- Repo created
- Product plan
- Architecture plan
- Design direction
- Install strategy
- Project structure
Done:
- Native Kotlin/Compose Android shell replaces the web-first mobile path for the beta surface.
- Companion settings flow stores companion URL and access key locally, validates bearer auth, and surfaces health errors in-app.
- Chat UI supports conversation list/thread navigation, native composer, busy state, selectable replies, error bubbles, and new-chat reset.
- Companion `/api/chat` invokes Hermes through the CLI with `hermes chat -Q --source tool -q <prompt>`.
- Companion chat responses sanitize terminal banners/help/control output, truncate large replies for mobile, report CLI availability, and return structured failure responses.
- Workspace utilities remain native: command runner, health view, file browsing, read, and write.
- Local build artifacts, Android transient build output, `.gradle-user/`, and `.dev/` are ignored.
- Companion and workspace TypeScript typecheck, build, and lint pass as of 2026-07-24.
## Phase 1 — Skeleton
Remaining for beta:
- Validate Android debug build in an environment where Gradle can fetch or reuse the Gradle 8.11.1 distribution. On 2026-07-24, `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` failed before compilation because network socket creation is blocked while downloading `https://services.gradle.org/distributions/gradle-8.11.1-all.zip`.
- Exercise a real paired device against a running companion and authenticated Hermes CLI.
- Exercise upstream-compatible transport/auth/session/streaming behavior end to end: bearer auth, health, chat request/response, session continuity expectations, streamed/final output handling, and failure recovery.
- Add or run focused Android UI validation for chat/settings flows on small and large screens.
- Confirm adaptive accessibility: font scaling, TalkBack labels, contrast, keyboard/IME behavior, and reduced-motion tolerance.
- Document beta install/run steps after the native build is verified.
Goal: empty but runnable monorepo.
## Beta Release Criteria
Status: in progress.
Deliverables:
- pnpm workspace
- `apps/mobile` Vite React app with Capacitor Android target and PWA fallback
- `apps/companion` Fastify server
- shared TypeScript package for event/API schemas
- basic app shell with bottom nav
- `/api/health`
- local dev scripts
## Phase 2 — Basic Hermes Prompting
Goal: phone can send text prompt and receive final answer.
Deliverables:
- pairing/auth MVP
- Settings connect flow
- task creation endpoint
- Hermes CLI/API adapter MVP
- Ask screen sends prompt
- response display
- task history in SQLite
## Phase 3 — Realtime Activity + Tool Timeline
Goal: see Hermes work live.
Deliverables:
- event bus
- WebSocket/SSE stream
- tool timeline UI
- Python worker adapter using AIAgent callbacks if needed
- coarse/fine tool event mapping
## Phase 4 — Uploads + Voice
Goal: prompt with files and voice notes.
Deliverables:
- multipart upload endpoint
- upload tray UI
- image/archive/pdf metadata
- voice recorder UI
- local STT or Hermes STT bridge
- attach files to task prompt
## Phase 5 — Notifications
Goal: close phone and get told when done.
Deliverables:
- Native Android push path or ntfy/Web Push fallback
- PWA service worker fallback
- notification provider abstraction
- notification permission onboarding
- completion/failure/approval notifications
## Phase 6 — Cron + Approvals
Goal: mobile control plane.
Deliverables:
- cron list/run/pause/resume/remove
- cron output viewer
- approval queue UI
- companion approval API
- Hermes approval integration strategy
## Phase 7 — Installer + Release
Goal: one-liner install.
Deliverables:
- production build
- install.sh
- systemd service
- update/uninstall modes
- release artifact
- pairing URL output
## Phase 8 — Android Polish
Goal: feels native.
Deliverables:
- refined Happy-inspired UI
- animations/microinteractions
- Capacitor Android build pipeline
- adaptive icons/splash
- native status/navigation bars
- share target
- haptics
- offline shell polish
- APK release/signing flow
## Phase 9 — Deep Hermes Integration
Goal: full Hermes-native experience.
Deliverables:
- structured event stream from Hermes core or stable Python adapter
- richer session browsing
- generated file detection
- background process control
- subagent visualization
- memory/skills dashboards possibly
## Open Feature Ideas
- Quick action tiles: “Check homelab”, “Run DV status”, “Start coding task”.
- Voice-to-voice mode.
- Per-task model/toolset selection.
- “Watch mode” for long-running coding tasks.
- Home Assistant widgets/actions.
- Secure notification actions: approve/deny from notification.
- Android native share target.
- QR pairing from terminal output.
- Native Kotlin/Compose Android app is the shipped beta client.
- No WebView, Capacitor runtime dependency, fake backend, embedded secrets, or new external infrastructure is introduced for beta.
- Companion transport and auth are upstream-compatible and exercised against the real Hermes CLI.
- Chat/session/streaming behavior is exercised with Hermes, including success, CLI unavailable, timeout, non-zero exit, and malformed response cases.
- Settings connection flow is reliable: save, validate, health, Hermes test, offline/unauthorized states, and recovery are clear.
- Adaptive accessibility passes on target Android devices or emulators.
- Required checks pass: `./gradlew :app:assembleDebug` from `apps/mobile/android`, companion typecheck/build, and relevant lint.
- Release notes clearly list remaining known beta limitations.
+13
View File
@@ -28,4 +28,17 @@ export default [
...tseslint.configs.recommended.rules,
},
},
{
files: ['apps/mobile/src/**/*.{ts,tsx}'],
languageOptions: {
globals: {
document: 'readonly',
fetch: 'readonly',
HTMLFormElement: 'readonly',
localStorage: 'readonly',
RequestInit: 'readonly',
URL: 'readonly',
},
},
},
];