Compare commits
280
Commits
@@ -1,23 +1,40 @@
|
||||
# Hermes-Relay — CI Pipeline
|
||||
# Hermes-Relay — Android CI Pipeline
|
||||
#
|
||||
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
|
||||
# Android-affecting paths so Python-only changes don't spin up the JVM.
|
||||
#
|
||||
# Runs on every push to main and on pull requests targeting main.
|
||||
# Pipeline: lint -> build + test (parallel) -> upload artifacts
|
||||
#
|
||||
# Android: Kotlin + Jetpack Compose (root Gradle project)
|
||||
# Python: aiohttp relay server (relay_server/)
|
||||
|
||||
name: CI
|
||||
name: CI — Android
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "gradle/**"
|
||||
- "build.gradle.kts"
|
||||
- "settings.gradle.kts"
|
||||
- "gradle.properties"
|
||||
- "gradlew"
|
||||
- "gradlew.bat"
|
||||
- ".github/workflows/ci-android.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "gradle/**"
|
||||
- "build.gradle.kts"
|
||||
- "settings.gradle.kts"
|
||||
- "gradle.properties"
|
||||
- "gradlew"
|
||||
- "gradlew.bat"
|
||||
- ".github/workflows/ci-android.yml"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main finish
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
group: ci-android-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -85,11 +102,20 @@ jobs:
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Android Tests — unit tests + report upload
|
||||
#
|
||||
# Tests run on every branch but are ADVISORY on dev (push or PR) so WIP
|
||||
# commits don't block the merge queue. Strict on main — any PR retargeted
|
||||
# from dev → main will surface the real failures before release-merge.
|
||||
# ──────────────────────────────────────────────
|
||||
test:
|
||||
name: Test (Android)
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Advisory on dev, strict on main. Evaluates to false (= strict) for
|
||||
# pushes to main and PRs whose base branch is main; true (= advisory)
|
||||
# for everything else (dev pushes, dev-targeted PRs, feature branches).
|
||||
continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -103,8 +129,16 @@ jobs:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Run unit tests
|
||||
run: ./gradlew test
|
||||
# The broad Gradle `test` aggregate currently hangs in deferred JVM test
|
||||
# suites tracked by issue #32. Keep CI release-relevant until that suite is
|
||||
# split: pairing URL derivation plus connection switching are the stable
|
||||
# Android regression slice for the active release work.
|
||||
- name: Run focused Android unit tests
|
||||
run: |
|
||||
./gradlew :app:testSideloadDebugUnitTest \
|
||||
--tests com.hermesandroid.relay.network.RelayUrlDeriverTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
|
||||
--console=plain
|
||||
|
||||
# Upload test reports even if tests fail, for debugging
|
||||
- name: Upload test reports
|
||||
@@ -114,35 +148,3 @@ jobs:
|
||||
name: test-reports
|
||||
path: app/build/reports/tests/
|
||||
retention-days: 7
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Relay — syntax check + future tests
|
||||
# ──────────────────────────────────────────────
|
||||
relay-check:
|
||||
name: Relay Check (Python)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r relay_server/requirements.txt
|
||||
|
||||
- name: Syntax check (plugin.relay — canonical location)
|
||||
run: |
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/channels/terminal.py
|
||||
python -m py_compile plugin/relay/channels/chat.py
|
||||
python -m py_compile plugin/relay/channels/bridge.py
|
||||
|
||||
- name: Syntax check (relay_server shim)
|
||||
run: python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
# TODO: Add pytest step when relay tests exist
|
||||
# - name: Run tests
|
||||
# run: pytest plugin/relay/tests/
|
||||
@@ -0,0 +1,65 @@
|
||||
name: CI dashboard plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-dashboard-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build and test dashboard plugin
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: plugin/dashboard/package-lock.json
|
||||
|
||||
- name: Install dashboard deps
|
||||
working-directory: plugin/dashboard
|
||||
run: npm ci
|
||||
|
||||
- name: Build dashboard bundle
|
||||
working-directory: plugin/dashboard
|
||||
run: npm run build
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Verify server-owned version metadata
|
||||
run: python scripts/check-server-version-sync.py
|
||||
|
||||
- name: Install dashboard API test deps
|
||||
run: pip install -r relay_server/requirements.txt fastapi httpx pytest requests
|
||||
|
||||
- name: Run dashboard API tests
|
||||
run: python -m unittest plugin.dashboard.test_plugin_api
|
||||
|
||||
- name: Verify dashboard bundle outputs
|
||||
run: |
|
||||
test -s plugin/dashboard/dist/index.js
|
||||
test -s plugin/dashboard/dist/style.css
|
||||
grep -q "hr-modal-card" plugin/dashboard/dist/style.css
|
||||
@@ -0,0 +1,116 @@
|
||||
name: CI desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- '.github/workflows/ci-desktop.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- '.github/workflows/ci-desktop.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
typecheck-and-build:
|
||||
name: Type-check + build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build (tsc → dist/)
|
||||
run: npm run build
|
||||
|
||||
- name: Verify bin shim is executable
|
||||
# The published tarball depends on bin/hermes-relay.js having a valid
|
||||
# shebang + importing the freshly built dist/cli.js. Smoke the actual
|
||||
# invocation so we catch broken imports, missing main export, or a
|
||||
# prebuilt dist/ that references a source file that moved.
|
||||
run: node bin/hermes-relay.js --version
|
||||
|
||||
- name: Upload dist/
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-dist
|
||||
path: desktop/dist
|
||||
retention-days: 7
|
||||
|
||||
smoke-help:
|
||||
name: Smoke — --help + --version work on every target OS
|
||||
needs: typecheck-and-build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: --version
|
||||
run: node bin/hermes-relay.js --version
|
||||
|
||||
- name: --help
|
||||
run: node bin/hermes-relay.js --help
|
||||
|
||||
tray-shell:
|
||||
name: Tray shell checks
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Cargo check tray shell
|
||||
run: npm run tray:check
|
||||
|
||||
- name: Test tray shell
|
||||
run: npm run tray:test
|
||||
@@ -0,0 +1,49 @@
|
||||
# Required-checks sentinel — always runs on every PR + push to main/dev so
|
||||
# branch protection on `main` has a check name it can rely on, regardless
|
||||
# of which paths the PR touches.
|
||||
#
|
||||
# Why this exists. The other CI workflows (`ci-android.yml`, `ci-server.yml`,
|
||||
# `ci-desktop.yml`) are scoped via `paths:` filters so a docs-only or
|
||||
# desktop-only PR doesn't spin up the Android toolchain. Branch protection's
|
||||
# "required status checks" treat a check that doesn't run as failing — so
|
||||
# any PR that didn't touch the protected paths was blocked from merging,
|
||||
# even with all the relevant gates green. We were admin-overriding every
|
||||
# desktop-only PR. Same for relay-touching PRs (the protection rule named
|
||||
# `Relay Check (Python)` didn't even match any actual job — broken since
|
||||
# day one).
|
||||
#
|
||||
# This sentinel + claude-review become the only required checks. The
|
||||
# path-filtered workflows still run when relevant and surface their
|
||||
# results on the PR — visible, clickable, but advisory rather than
|
||||
# blocking. Reviewers (human + claude-review) eyeball them. This is the
|
||||
# standard pattern for monorepos with path-filtered CI.
|
||||
#
|
||||
# Trade-off acknowledged: a broken Android build on an Android-touching
|
||||
# PR could merge if the reviewer ignores the failing CI badge. Mitigation:
|
||||
# claude-review reads CI conclusions in its review prompt + the project's
|
||||
# release-merge cadence catches issues before they reach a tag. If a
|
||||
# stricter gate is later wanted, fold it into this workflow as a job that
|
||||
# fans out to the path-filtered work — but the simplest version (just an
|
||||
# `echo`) is what's needed to make branch protection useful again today.
|
||||
|
||||
name: Required checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR. Doesn't matter much for
|
||||
# a 5-second job, but matches every other workflow's concurrency shape.
|
||||
concurrency:
|
||||
group: ci-required-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
name: Required checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: OK
|
||||
run: echo "Required-checks sentinel — see ci-required.yml header for context."
|
||||
@@ -0,0 +1,128 @@
|
||||
# Hermes-Relay — Python Server CI Pipeline
|
||||
#
|
||||
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
|
||||
# server-affecting paths so Android-only changes don't spin up the
|
||||
# Python toolchain.
|
||||
#
|
||||
# Pipeline: syntax-check -> focused server tests
|
||||
|
||||
name: CI — Server
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/__init__.py"
|
||||
- "plugin/android_tool.py"
|
||||
- "plugin/cli.py"
|
||||
- "plugin/pair.py"
|
||||
- "plugin/plugin.yaml"
|
||||
- "plugin/dashboard/manifest.json"
|
||||
- "plugin/dashboard/package.json"
|
||||
- "plugin/dashboard/package-lock.json"
|
||||
- "plugin/relay/**"
|
||||
- "plugin/tools/**"
|
||||
- "plugin/tests/**"
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- "scripts/bump-server-version.sh"
|
||||
- ".github/workflows/ci-server.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/__init__.py"
|
||||
- "plugin/android_tool.py"
|
||||
- "plugin/cli.py"
|
||||
- "plugin/pair.py"
|
||||
- "plugin/plugin.yaml"
|
||||
- "plugin/dashboard/manifest.json"
|
||||
- "plugin/dashboard/package.json"
|
||||
- "plugin/dashboard/package-lock.json"
|
||||
- "plugin/relay/**"
|
||||
- "plugin/tools/**"
|
||||
- "plugin/tests/**"
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- "scripts/bump-server-version.sh"
|
||||
- ".github/workflows/ci-server.yml"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
concurrency:
|
||||
group: ci-server-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Server — py_compile syntax sanity
|
||||
# ──────────────────────────────────────────────
|
||||
syntax-check:
|
||||
name: Syntax check (Python)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r relay_server/requirements.txt
|
||||
|
||||
- name: Syntax check (server/plugin.relay — canonical location)
|
||||
run: |
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/channels/terminal.py
|
||||
python -m py_compile plugin/relay/channels/chat.py
|
||||
python -m py_compile plugin/relay/channels/bridge.py
|
||||
python -m py_compile plugin/relay/voice.py
|
||||
python -m py_compile plugin/relay/upstream_voice.py
|
||||
|
||||
- name: Syntax check (relay_server shim)
|
||||
run: python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
- name: Validate Server version metadata
|
||||
run: python scripts/check-server-version-sync.py
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Server — focused route/auth/session tests
|
||||
#
|
||||
# Tests are ADVISORY on dev (push or PR) so WIP commits don't block the
|
||||
# merge queue. Strict on main — the dev → main release-merge PR surfaces
|
||||
# any real failures before release.
|
||||
# ──────────────────────────────────────────────
|
||||
unit-tests:
|
||||
name: Focused Server tests (Python)
|
||||
needs: syntax-check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Advisory on dev, strict on main. Evaluates to false (= strict) for
|
||||
# pushes to main and PRs whose base branch is main; true (= advisory)
|
||||
# for everything else (dev pushes, dev-targeted PRs, feature branches).
|
||||
continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r relay_server/requirements.txt
|
||||
pip install pytest responses
|
||||
|
||||
- name: Run focused Server tests
|
||||
run: |
|
||||
python -m pytest \
|
||||
plugin/tests/test_relay_security.py \
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.py
|
||||
@@ -3,22 +3,53 @@ name: Claude Code Review
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
env:
|
||||
IS_RELEASE_PR: ${{ github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev' && startsWith(github.event.pull_request.title, 'release:') }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Skip aggregate release PR review
|
||||
if: env.IS_RELEASE_PR == 'true'
|
||||
run: |
|
||||
echo "Skipping Claude Code Review for aggregate dev -> main release PR."
|
||||
echo "Feature work is reviewed before it lands on dev; release PRs are gated by CI and release metadata checks."
|
||||
|
||||
- name: Checkout repository
|
||||
if: env.IS_RELEASE_PR != 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
if: env.IS_RELEASE_PR != 'true'
|
||||
timeout-minutes: 15
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
|
||||
+23
-112
@@ -6,134 +6,45 @@ on:
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned, labeled]
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
auth:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
authorized: ${{ steps.check.outputs.authorized }}
|
||||
steps:
|
||||
- name: Check collaborator status
|
||||
id: check
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === 'issues' && ['opened', 'labeled'].includes(context.payload.action)) {
|
||||
core.setOutput('authorized', 'true');
|
||||
return;
|
||||
}
|
||||
const sender = context.payload.sender?.login;
|
||||
if (!sender) { core.setOutput('authorized', 'false'); return; }
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner, repo: context.repo.repo, username: sender,
|
||||
});
|
||||
const allowed = ['admin', 'write', 'maintain'].includes(data.permission);
|
||||
core.setOutput('authorized', allowed ? 'true' : 'false');
|
||||
} catch {
|
||||
core.setOutput('authorized', 'false');
|
||||
}
|
||||
|
||||
triage:
|
||||
needs: auth
|
||||
claude:
|
||||
if: |
|
||||
needs.auth.outputs.authorized == 'true' && (
|
||||
(github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'claude') ||
|
||||
(github.event_name == 'issues' && github.event.action == 'opened')
|
||||
)
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Triage this GitHub issue. Analysis-only — do NOT write code or create PRs.
|
||||
|
||||
1. **Classify** — bug, feature request, question, or docs issue?
|
||||
2. **Priority** — critical, high, medium, low based on impact.
|
||||
3. **Affected area** — which module(s)? Check CLAUDE.md for architecture.
|
||||
(ui/, network/, viewmodel/, auth/, data/, relay_server/, plugin/)
|
||||
4. **Reproduction** — for bugs, is there enough info? Ask for device, Android version, steps.
|
||||
5. **Suggested approach** — brief outline (files, strategy).
|
||||
6. **Labels** — suggest appropriate labels.
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
Keep it concise and actionable.
|
||||
claude_args: "--max-turns 5"
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
fix:
|
||||
needs: auth
|
||||
if: |
|
||||
needs.auth.outputs.authorized == 'true' &&
|
||||
github.event_name == 'issues' &&
|
||||
github.event.action == 'labeled' &&
|
||||
github.event.label.name == 'claude-fix'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Implement a fix for this GitHub issue. Read CLAUDE.md for project conventions.
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr *)'
|
||||
|
||||
1. Understand the issue — read relevant source files.
|
||||
2. Implement the minimal fix.
|
||||
3. Follow conventions: Kotlin + Jetpack Compose, kotlinx.serialization, Conventional Commits.
|
||||
4. Run `./gradlew assembleDebug` and fix any errors.
|
||||
5. Create a PR with Conventional Commits format title.
|
||||
|
||||
Do NOT over-engineer. Only change what is needed.
|
||||
claude_args: "--max-turns 25"
|
||||
|
||||
chat:
|
||||
needs: auth
|
||||
if: |
|
||||
needs.auth.outputs.authorized == 'true' && (
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && github.event.action == 'assigned' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
Responding to a collaborator comment. Read CLAUDE.md for project context.
|
||||
|
||||
Default mode is analysis — investigate, explain, suggest. Do NOT write code
|
||||
unless explicitly asked ("fix this", "implement", "create a PR").
|
||||
|
||||
If asked to fix: follow conventions (Kotlin, Compose, Conventional Commits),
|
||||
run `./gradlew assembleDebug`, and create a PR.
|
||||
claude_args: "--max-turns 15"
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
# Hermes-Relay — Release Pipeline
|
||||
# Hermes-Relay-Android — Release Pipeline
|
||||
#
|
||||
# Triggered when a version tag (v*) is pushed.
|
||||
# Triggered when an Android release tag (android-v*) is pushed.
|
||||
# Validates the tag matches the app version in libs.versions.toml,
|
||||
# runs CI checks, builds a release APK, and creates a GitHub Release.
|
||||
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
|
||||
# GitHub Release. Server/Python package releases use server-v* tags.
|
||||
|
||||
name: Release
|
||||
name: Release Android
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "android-v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -26,7 +27,7 @@ jobs:
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
run: echo "version=${GITHUB_REF#refs/tags/android-v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify version sync
|
||||
run: |
|
||||
@@ -47,6 +48,7 @@ jobs:
|
||||
name: CI Checks
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -62,13 +64,21 @@ jobs:
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Run unit tests
|
||||
run: ./gradlew test
|
||||
# Keep the tag release gate aligned with CI — Android's broad Gradle
|
||||
# `test` aggregate currently hangs in deferred JVM suites tracked by
|
||||
# issue #32, so the release gate runs the stable connection/pairing slice.
|
||||
- name: Run focused Android unit tests
|
||||
run: |
|
||||
./gradlew :app:testSideloadDebugUnitTest \
|
||||
--tests com.hermesandroid.relay.network.RelayUrlDeriverTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
|
||||
--console=plain
|
||||
|
||||
release:
|
||||
name: Build & Publish Release
|
||||
needs: [validate, ci]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -123,9 +133,10 @@ jobs:
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: v${{ needs.validate.outputs.version }}
|
||||
name: Hermes-Relay-Android v${{ needs.validate.outputs.version }}
|
||||
tag_name: android-v${{ needs.validate.outputs.version }}
|
||||
body_path: RELEASE_NOTES.md
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
# Attach all four flavored artifacts — users sideload the
|
||||
@@ -144,7 +155,7 @@ jobs:
|
||||
env:
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
echo "## Release v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## Hermes-Relay-Android v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ -n "$HERMES_KEYSTORE_BASE64" ]; then
|
||||
echo "✅ **Signed with release keystore** — suitable for Play Store upload" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,241 @@
|
||||
name: Release Desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['desktop-v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-cli-binaries:
|
||||
name: Build cross-platform CLI binaries via Bun compile
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js (for npm ci + tsc)
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.x'
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build dist/ (tsc)
|
||||
run: npm run build
|
||||
|
||||
- name: Print Bun version (diagnostics)
|
||||
run: bun --version
|
||||
|
||||
- name: Prepare binary output dir
|
||||
run: mkdir -p dist/bin
|
||||
|
||||
# Keep the package.json scripts as the single source of truth for Bun
|
||||
# compile flags so release and local smoke builds cannot diverge.
|
||||
- name: Build Windows x64
|
||||
run: npm run build:bin:win
|
||||
|
||||
- name: Build Linux x64
|
||||
run: npm run build:bin:linux
|
||||
|
||||
- name: Build macOS x64
|
||||
run: npm run build:bin:mac-x64
|
||||
|
||||
- name: Build macOS arm64
|
||||
run: npm run build:bin:mac-arm
|
||||
|
||||
- name: Size guard (<150 MB each)
|
||||
run: |
|
||||
set -e
|
||||
for f in dist/bin/hermes-relay-*; do
|
||||
sz=$(stat -c%s "$f")
|
||||
mb=$(( sz / 1024 / 1024 ))
|
||||
echo " $f - ${mb} MB"
|
||||
if [ "$sz" -gt 157286400 ]; then
|
||||
echo "FAIL: $f exceeds 150 MB - Bun likely shipped a debug build or we added a large dep."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Smoke-test Linux binary
|
||||
run: |
|
||||
set -e
|
||||
chmod +x dist/bin/hermes-relay-linux-x64
|
||||
for cmd in --version --help doctor; do
|
||||
out=$(./dist/bin/hermes-relay-linux-x64 "$cmd" 2>&1 || true)
|
||||
exit_code=$?
|
||||
if [ -z "$out" ] || [ ${#out} -lt 10 ]; then
|
||||
echo "SMOKE FAIL: './hermes-relay-linux-x64 $cmd' produced no output (exit=$exit_code)"
|
||||
echo "Raw output was: [$out]"
|
||||
exit 1
|
||||
fi
|
||||
echo " smoke OK: $cmd -> $(echo "$out" | head -1)"
|
||||
done
|
||||
|
||||
- name: Upload CLI release assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-cli-release
|
||||
path: |
|
||||
desktop/dist/bin/hermes-relay-win-x64.exe
|
||||
desktop/dist/bin/hermes-relay-linux-x64
|
||||
desktop/dist/bin/hermes-relay-darwin-x64
|
||||
desktop/dist/bin/hermes-relay-darwin-arm64
|
||||
retention-days: 7
|
||||
|
||||
build-windows-tray-installer:
|
||||
name: Build Windows tray installer
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.x'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build dist/ (tsc)
|
||||
run: npm run build
|
||||
|
||||
- name: Test tray shell
|
||||
run: npm run tray:test
|
||||
|
||||
- name: Build tray installer
|
||||
run: npm run tray:build
|
||||
|
||||
- name: Normalize installer asset name
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist/tray | Out-Null
|
||||
$installer = Get-ChildItem -Path tray/src-tauri/target/release/bundle/nsis -Filter '*_x64-setup.exe' | Select-Object -First 1
|
||||
if (-not $installer) { throw 'NSIS installer was not produced' }
|
||||
Copy-Item -Force $installer.FullName dist/tray/hermes-relay-desktop-windows-x64-setup.exe
|
||||
|
||||
- name: Smoke-test tray exe launch
|
||||
shell: pwsh
|
||||
run: |
|
||||
$home = Join-Path $env:RUNNER_TEMP 'hermes-tray-smoke-home'
|
||||
New-Item -ItemType Directory -Force -Path $home | Out-Null
|
||||
$env:USERPROFILE = $home
|
||||
$env:HOME = $home
|
||||
$proc = Start-Process -FilePath tray/src-tauri/target/release/hermes-relay-desktop.exe -WindowStyle Hidden -PassThru
|
||||
Start-Sleep -Seconds 5
|
||||
if ($proc.HasExited) { throw "tray app exited early with code $($proc.ExitCode)" }
|
||||
Stop-Process -Id $proc.Id -Force
|
||||
Write-Host "tray launch smoke OK pid=$($proc.Id)"
|
||||
|
||||
- name: Upload Windows tray release asset
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-windows-tray-release
|
||||
path: desktop/dist/tray/hermes-relay-desktop-windows-x64-setup.exe
|
||||
retention-days: 7
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-cli-binaries
|
||||
- build-windows-tray-installer
|
||||
steps:
|
||||
- name: Extract desktop version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: release-assets
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
run: |
|
||||
set -e
|
||||
find release-assets -type f ! -name SHA256SUMS.txt -print0 \
|
||||
| sort -z \
|
||||
| xargs -0 sha256sum \
|
||||
| sed -E 's#release-assets/[^/]+/##' > release-assets/SHA256SUMS.txt
|
||||
cat release-assets/SHA256SUMS.txt
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }}
|
||||
fail_on_unmatched_files: true
|
||||
body: |
|
||||
# Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
|
||||
|
||||
**Experimental phase.** Assets are unsigned - Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows now ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
|
||||
|
||||
## Install
|
||||
|
||||
**Windows tray app (PowerShell):**
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Windows CLI only:**
|
||||
```powershell
|
||||
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**macOS / Linux CLI:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Pin this specific release with `HERMES_RELAY_VERSION=${{ github.ref_name }}`.
|
||||
|
||||
## Verify
|
||||
|
||||
```text
|
||||
hermes-relay --version
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
hermes-relay shell
|
||||
```
|
||||
|
||||
Open **Hermes Relay Desktop** from the Windows Start menu for tray pairing, devices, task log, settings, pause, and emergency stop.
|
||||
|
||||
See [Desktop docs](https://codename-11.github.io/hermes-relay/desktop/) for full usage.
|
||||
|
||||
files: |
|
||||
release-assets/desktop-cli-release/hermes-relay-win-x64.exe
|
||||
release-assets/desktop-cli-release/hermes-relay-linux-x64
|
||||
release-assets/desktop-cli-release/hermes-relay-darwin-x64
|
||||
release-assets/desktop-cli-release/hermes-relay-darwin-arm64
|
||||
release-assets/desktop-windows-tray-release/hermes-relay-desktop-windows-x64-setup.exe
|
||||
release-assets/SHA256SUMS.txt
|
||||
@@ -0,0 +1,118 @@
|
||||
name: Release Server
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "server-v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Server release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/server-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify Server version sync
|
||||
run: python scripts/check-server-version-sync.py --expect "$TAG_VERSION"
|
||||
env:
|
||||
TAG_VERSION: ${{ steps.version.outputs.version }}
|
||||
|
||||
test:
|
||||
name: Test Server package
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
pip install -r relay_server/requirements.txt
|
||||
pip install pytest responses
|
||||
|
||||
- name: Syntax check
|
||||
run: |
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/voice.py
|
||||
python -m py_compile plugin/relay/upstream_voice.py
|
||||
python -m py_compile plugin/relay/voice_auth.py
|
||||
python -m py_compile plugin/tools/android_tool.py
|
||||
python -m py_compile plugin/tools/desktop_tool.py
|
||||
python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
- name: Run focused Server tests
|
||||
run: |
|
||||
python -m pytest \
|
||||
plugin/tests/test_relay_security.py \
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.py
|
||||
|
||||
package:
|
||||
name: Build and publish Server package
|
||||
needs: [validate, test]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Build wheel and sdist
|
||||
run: |
|
||||
pip install build
|
||||
python -m build
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd dist
|
||||
sha256sum * > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay-Server v${{ needs.validate.outputs.version }}
|
||||
tag_name: server-v${{ needs.validate.outputs.version }}
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
fail_on_unmatched_files: true
|
||||
body: |
|
||||
# Hermes-Relay-Server v${{ needs.validate.outputs.version }}
|
||||
|
||||
This release contains the server/Python plugin package.
|
||||
Android releases use `android-v*` tags. Desktop releases use
|
||||
`desktop-v*` tags. Historical server releases before this lane
|
||||
rename used `relay-v*` tags.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install hermes-relay==${{ needs.validate.outputs.version }}
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
python -m relay_server --help
|
||||
```
|
||||
files: |
|
||||
dist/*.whl
|
||||
dist/*.tar.gz
|
||||
dist/SHA256SUMS.txt
|
||||
+25
-3
@@ -24,6 +24,9 @@ Thumbs.db
|
||||
local.properties
|
||||
/build/
|
||||
/app/build/
|
||||
/relay-core/build/
|
||||
/relay-ui/build/
|
||||
/quest/build/
|
||||
/app/release/
|
||||
*.apk
|
||||
*.aab
|
||||
@@ -43,19 +46,31 @@ certs/
|
||||
|
||||
# Local tools
|
||||
.subframe/
|
||||
voice-lab-runs/
|
||||
realtime-voice-runs/
|
||||
realtime-agent-runs/
|
||||
voice_rec_*.wav
|
||||
|
||||
# Ad-hoc debugging artifacts (logcat dumps, screenshots, UI XMLs)
|
||||
.scratch/
|
||||
|
||||
# VitePress
|
||||
user-docs/.vitepress/cache/
|
||||
user-docs/.vitepress/dist/
|
||||
node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
# Anchor VitePress-only npm manifests to root/user-docs so desktop/package.json is tracked.
|
||||
/package.json
|
||||
/package-lock.json
|
||||
/user-docs/package.json
|
||||
/user-docs/package-lock.json
|
||||
|
||||
# Local upstream reference
|
||||
# Local upstream references (not shipped in this repo)
|
||||
hermes-agent-upstream/
|
||||
hermes-agent-fork/
|
||||
|
||||
# Claude Code internal state (worktrees, image cache, conversation logs)
|
||||
.claude/
|
||||
.claude-launcher/
|
||||
|
||||
# Kotlin compiler cache
|
||||
.kotlin/
|
||||
@@ -63,3 +78,10 @@ hermes-agent-upstream/
|
||||
# Release signing & Play Store credentials — never commit these
|
||||
play-service-account.json
|
||||
keystore.properties
|
||||
|
||||
# Desktop TUI smoke harness runtime artifacts
|
||||
.smoke-relay.pid
|
||||
.smoke-relay.log
|
||||
|
||||
# Generated tray frontend vendor assets copied from desktop/node_modules
|
||||
desktop/tray/ui/vendor/
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
<!-- @subframe-version 0.15.1-beta -->
|
||||
<!-- @subframe-managed -->
|
||||
# hermes-android - SubFrame Project
|
||||
|
||||
This project is managed with **SubFrame**. AI assistants should follow the rules below to keep documentation up to date.
|
||||
|
||||
> **Note:** This file is named `AGENTS.md` to be AI-tool agnostic. CLAUDE.md and GEMINI.md contain a reference to this file.
|
||||
|
||||
---
|
||||
|
||||
## Core Working Principle
|
||||
|
||||
**Only do what the user asks.** Do not go beyond the scope of the request.
|
||||
|
||||
- Implement exactly what the user requested — nothing more, nothing less.
|
||||
- Do not change business logic, flow, or architecture unless the user explicitly asks for it.
|
||||
- If a user asks for a design change, only change the design. Do not refactor, restructure, or modify functionality alongside it.
|
||||
- If you have additional suggestions or improvements, **present them as suggestions** to the user. Never implement them without approval.
|
||||
- The user's request must be completed first. Additional ideas come after, as proposals.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to Native AI Tools
|
||||
|
||||
SubFrame **enhances** native AI coding tools — it does not replace them.
|
||||
|
||||
**Claude Code** works exactly as normal. Built-in features (`/init`, `/commit`, `/review-pr`, `/compact`, `/memory`, CLAUDE.md) are fully supported. CLAUDE.md is Claude Code's native instruction file — users can add their own tool-specific instructions freely. SubFrame adds a small backlink reference pointing to this AGENTS.md file using HTML comment markers (`<!-- SUBFRAME:BEGIN -->` / `<!-- SUBFRAME:END -->`). SubFrame will never overwrite user content in CLAUDE.md.
|
||||
|
||||
**Gemini CLI** works exactly as normal. Built-in features (`/init`, `/model`, `/memory`, `/compress`, `/settings`, GEMINI.md) are fully supported. GEMINI.md is Gemini CLI's native instruction file — same backlink approach as CLAUDE.md. Users can add their own instructions freely and SubFrame won't overwrite them.
|
||||
|
||||
**Codex CLI** gets SubFrame context via a wrapper script at `.subframe/bin/codex` that injects AGENTS.md as an initial prompt.
|
||||
|
||||
**This file (AGENTS.md)** contains SubFrame-specific rules that apply across all tools:
|
||||
- Sub-Task management (`.subframe/tasks/*.md`, index at `.subframe/tasks.json`)
|
||||
- Codebase mapping (`.subframe/STRUCTURE.json`)
|
||||
- Context preservation (`.subframe/PROJECT_NOTES.md`)
|
||||
- Internal docs and changelog (`.subframe/docs-internal/`)
|
||||
- Session notes and decision tracking
|
||||
|
||||
---
|
||||
|
||||
## Session Start
|
||||
|
||||
**Read these files at the start of each session:**
|
||||
|
||||
1. **`.subframe/STRUCTURE.json`** — Module map, file locations, architecture notes
|
||||
2. **`.subframe/PROJECT_NOTES.md`** — Project vision, past decisions, session notes
|
||||
3. **`.subframe/tasks.json`** — Sub-task index (pending, in-progress, completed)
|
||||
|
||||
This gives you full project context before making any changes. The session-start hook (if configured) automatically injects pending/in-progress sub-tasks into your context, but you should still read these files for deeper understanding.
|
||||
|
||||
### Concurrent Work & Worktrees
|
||||
|
||||
Before making changes, check whether other AI sessions or agent teams are already working on this repository. Signs of concurrent work include:
|
||||
- In-progress sub-tasks you didn't start (check `.subframe/tasks.json`)
|
||||
- Recent uncommitted changes in `git status` that aren't yours
|
||||
- Lock files or active worktrees (`git worktree list`)
|
||||
|
||||
**If concurrent work is detected**, ask the user: "Another session appears to be working on this project. Should I use a git worktree to avoid conflicts?"
|
||||
|
||||
**Git worktrees** create an isolated copy of the repo on a separate branch, allowing parallel work without merge conflicts:
|
||||
- Each worktree has its own working directory and branch
|
||||
- Changes in one worktree don't affect others until merged
|
||||
- Use worktrees when multiple agents or sessions work on different features simultaneously
|
||||
|
||||
**When to suggest a worktree:**
|
||||
- Agent teams spawning multiple workers on the same repo
|
||||
- User asks to work on a feature while another is in progress
|
||||
- The session-start hook flags concurrent sessions
|
||||
|
||||
**When worktrees are NOT needed:**
|
||||
- Single-session work with no concurrent agents
|
||||
- Read-only exploration or research tasks
|
||||
- Quick fixes that won't conflict with in-progress work
|
||||
|
||||
---
|
||||
|
||||
## Hooks (Automatic Awareness)
|
||||
|
||||
SubFrame can configure project-level hooks that automate sub-task awareness. These hooks fire automatically — no manual intervention needed.
|
||||
|
||||
| Hook | When it fires | What it does |
|
||||
|------|---------------|--------------|
|
||||
| **SessionStart** | Startup, resume, after compaction | Injects pending/in-progress sub-tasks into context |
|
||||
| **UserPromptSubmit** | Each user prompt | Fuzzy-matches prompt against pending sub-tasks, suggests starting a match |
|
||||
| **Stop** | When AI finishes responding | Reminds about in-progress sub-tasks; flags untracked work if source files changed |
|
||||
| **PreToolUse** | Before tool execution | Project-specific guardrails (if configured) |
|
||||
| **PostToolUse** | After tool execution | Project-specific follow-ups (if configured) |
|
||||
|
||||
These hooks ensure sub-task awareness even after context compaction. Hook configuration lives in `.claude/settings.json`.
|
||||
|
||||
---
|
||||
|
||||
## Skills (Slash Commands)
|
||||
|
||||
SubFrame provides optional slash commands for AI coding tools that support them (e.g., Claude Code):
|
||||
|
||||
| Skill | Purpose |
|
||||
|-------|---------|
|
||||
| `/sub-tasks` | Interactive sub-task management — list, start, complete, add, archive |
|
||||
| `/sub-docs` | Sync all SubFrame documentation after feature work (changelog, CLAUDE.md, PROJECT_NOTES, STRUCTURE) |
|
||||
| `/sub-audit` | Code review + documentation audit on recent changes |
|
||||
| `/onboard` | Bootstrap SubFrame files from existing codebase context |
|
||||
|
||||
Skills are deployed to `.claude/skills/` and enhance the workflow — but direct file editing always works as a fallback. If your AI tool doesn't support skills, follow the manual instructions in each section below.
|
||||
|
||||
---
|
||||
|
||||
## Sub-Task Management
|
||||
|
||||
> **Terminology:** "Sub-Tasks" are SubFrame's project task tracking system. The name plays on "Sub" from SubFrame and disambiguates from Claude Code's internal todo tools. When the user says "sub-task", they mean this system.
|
||||
|
||||
### Sub-Task File Format
|
||||
|
||||
Each sub-task lives in its own markdown file at `.subframe/tasks/<id>.md` with YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
id: task-abc12345
|
||||
title: Short and clear title (max 60 characters)
|
||||
status: pending | in_progress | completed
|
||||
priority: high | medium | low
|
||||
category: feature | fix | refactor | docs | test | chore
|
||||
description: AI's detailed explanation — what, how, which files affected
|
||||
userRequest: User's original prompt/request — copy exactly
|
||||
acceptanceCriteria: When is this task done? Concrete testable criteria
|
||||
blockedBy: [] # task IDs this depends on
|
||||
blocks: [] # task IDs that depend on this
|
||||
createdAt: ISO timestamp
|
||||
updatedAt: ISO timestamp
|
||||
completedAt: ISO timestamp | null
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
[YYYY-MM-DD] Session notes, alternatives considered, dependencies.
|
||||
|
||||
## Steps
|
||||
|
||||
- [x] Completed step
|
||||
- [ ] Pending step
|
||||
```
|
||||
|
||||
A generated index is kept at `.subframe/tasks.json` for hooks and quick lookups. After creating or modifying task `.md` files, regenerate the index by reading all `.subframe/tasks/*.md` files (excluding `archive/`) and building the JSON with tasks grouped by status.
|
||||
|
||||
### Sub-Task Recognition Rules
|
||||
|
||||
**These ARE SUB-TASKS:**
|
||||
- When the user requests a feature or change
|
||||
- Decisions like "Let's do this", "Let's add this", "Improve this"
|
||||
- Deferred work: "We'll do this later", "Let's leave it for now"
|
||||
- Gaps or improvement opportunities discovered while coding
|
||||
- Situations requiring bug fixes
|
||||
|
||||
**These are NOT SUB-TASKS:**
|
||||
- Error messages and debugging sessions
|
||||
- Questions, explanations, information exchange
|
||||
- Temporary experiments and tests
|
||||
- Work already completed and closed
|
||||
- Instant fixes (like typo fixes)
|
||||
|
||||
### Sub-Task Creation Flow
|
||||
|
||||
1. Detect sub-task patterns during conversation
|
||||
2. **Check existing sub-tasks first** — read `.subframe/tasks.json` to avoid duplicates
|
||||
3. Ask the user: "I identified these sub-tasks from our conversation, should I add them?"
|
||||
4. If approved, create `.subframe/tasks/<id>.md` with all required frontmatter fields
|
||||
5. Regenerate the `.subframe/tasks.json` index
|
||||
|
||||
### Sub-Task Content Rules
|
||||
|
||||
**title:** Short, action-oriented
|
||||
- OK: "Add tasks button to terminal toolbar"
|
||||
- Bad: "Tasks"
|
||||
|
||||
**description:** AI's detailed technical explanation
|
||||
- What will be done, how, which files affected
|
||||
- Minimum 2-3 sentences
|
||||
|
||||
**userRequest:** User's original words — copy verbatim for context preservation
|
||||
|
||||
**acceptanceCriteria:** Concrete, testable completion criteria
|
||||
|
||||
### Sub-Task Status Updates
|
||||
|
||||
**Before starting any work**, check `.subframe/tasks.json` for an existing sub-task that matches. If found, set it to `in_progress` — do not create a duplicate.
|
||||
|
||||
- `pending` → `in_progress` — immediately when you begin working (update `updatedAt`)
|
||||
- `in_progress` → `completed` — when done and verified (set `completedAt`, update `updatedAt`)
|
||||
- `completed` → `pending` — when reopening, add a note explaining why
|
||||
- After commit: check and update the status of all related sub-tasks
|
||||
- **Incomplete work:** If partially done at session end, leave as `in_progress` and add a notes entry
|
||||
|
||||
### Sub-Task Lifecycle
|
||||
|
||||
- If a sub-task grows beyond its original scope, split it — create new sub-tasks and reference the parent ID in notes
|
||||
- Cross-reference relevant commit hashes or PR numbers in notes
|
||||
- Update the description if the approach changes significantly
|
||||
|
||||
### Priority Guidelines
|
||||
|
||||
- **high** — Blocking other work or explicitly flagged as urgent by the user
|
||||
- **medium** — Normal feature work and standard bug fixes
|
||||
- **low** — Nice-to-have improvements, deferred items, minor polish
|
||||
|
||||
---
|
||||
|
||||
## .subframe/PROJECT_NOTES.md Rules
|
||||
|
||||
### When to Update?
|
||||
- When an important architectural decision is made
|
||||
- When a technology choice is made
|
||||
- When an important problem is solved and the solution method is noteworthy
|
||||
- When an approach is determined together with the user
|
||||
|
||||
### Format
|
||||
Free format. Date + title is sufficient:
|
||||
```markdown
|
||||
### [YYYY-MM-DD] Topic title
|
||||
Conversation/decision as is, with its context...
|
||||
```
|
||||
|
||||
### Update Flow
|
||||
- Update immediately after a decision is made
|
||||
- You can add without asking the user (for important decisions)
|
||||
- You can accumulate small decisions and add them in bulk
|
||||
|
||||
### Organization Rules
|
||||
- Keep **"Project Vision"** at the top, then **"Session Notes"** in chronological order
|
||||
- Notes should capture the **why** (decisions, trade-offs, alternatives rejected), not the **what** (code structure belongs in STRUCTURE.json)
|
||||
- When the same topic spans multiple sessions, consolidate related notes under the original heading rather than creating duplicates
|
||||
- When notes grow beyond ~500 lines, consider archiving older session notes or grouping by month
|
||||
|
||||
---
|
||||
|
||||
## Context Preservation (Automatic Note Taking)
|
||||
|
||||
SubFrame's core purpose is to prevent context loss. Capture important moments and ask the user.
|
||||
|
||||
### When to Ask?
|
||||
|
||||
Ask the user: **"Should I add this to .subframe/PROJECT_NOTES.md?"** when:
|
||||
|
||||
- A sub-task is successfully completed
|
||||
- An important architectural/technical decision is made
|
||||
- A bug is fixed and the solution method is noteworthy
|
||||
- "Let's do this later" is said (also add as a sub-task)
|
||||
- A new pattern or best practice is discovered
|
||||
|
||||
### Importance Threshold
|
||||
|
||||
**Would it take more than 5 minutes to re-derive or re-explain in a future session?** If yes, capture it.
|
||||
|
||||
**Always capture:** Architecture decisions, technology choices, approach changes, user preferences discovered during work.
|
||||
|
||||
**Never capture:** Routine debugging steps, simple config changes, typo fixes.
|
||||
|
||||
**Note failed approaches too** — a brief "We tried X, it didn't work because Y" prevents future re-exploration of dead ends.
|
||||
|
||||
### Completion Detection
|
||||
|
||||
Pay attention to these signals:
|
||||
- User approval: "okay", "done", "it worked", "nice", "fixed", "yes"
|
||||
- Moving from one topic to another
|
||||
- User continuing after build/run succeeds
|
||||
|
||||
### How to Add?
|
||||
|
||||
1. **DON'T write a summary** — Add the conversation as is, with its context
|
||||
2. **Add date** — In `### [YYYY-MM-DD] Title` format
|
||||
3. **Add to Session Notes section** — At the end of PROJECT_NOTES.md
|
||||
|
||||
### When NOT to Ask
|
||||
|
||||
- For every small change (it becomes spam)
|
||||
- Typo fixes, simple corrections
|
||||
- If the user already said "no" or "not needed", don't ask again for that topic
|
||||
|
||||
### If User Says "No"
|
||||
|
||||
No problem, continue. The user can also say what they consider important themselves: "add this to notes"
|
||||
|
||||
---
|
||||
|
||||
## .subframe/STRUCTURE.json Rules
|
||||
|
||||
**This file is the map of the codebase.**
|
||||
|
||||
### When to Update?
|
||||
- When a new file/folder is created
|
||||
- When a file/folder is deleted or moved
|
||||
- When module dependencies change
|
||||
- When an IPC channel is added or changed
|
||||
- When an important architectural pattern is discovered (architectureNotes)
|
||||
|
||||
### Full Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"modules": {
|
||||
"main/moduleName": {
|
||||
"file": "src/main/moduleName.ts",
|
||||
"description": "What this module does",
|
||||
"exports": ["init", "loadData"],
|
||||
"depends": ["fs", "path", "shared/ipcChannels"],
|
||||
"functions": {
|
||||
"init": { "line": 15 },
|
||||
"loadData": { "line": 42 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"ipcChannels": {
|
||||
"CHANNEL_NAME": {
|
||||
"direction": "renderer → main",
|
||||
"handler": "main/moduleName"
|
||||
}
|
||||
},
|
||||
"architectureNotes": {
|
||||
"topicName": {
|
||||
"issue": "Description of the pattern or concern",
|
||||
"solution": "How it was resolved"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Update Rules
|
||||
- The pre-commit hook (if configured) auto-updates STRUCTURE.json when source files in `src/` are committed
|
||||
- When deleting files, remove their entries from `modules` and update any `depends` arrays that referenced them
|
||||
- When adding IPC channels, also add them to the `ipcChannels` section with `direction` and `handler`
|
||||
- `architectureNotes` is for **structural patterns** (e.g., circular dependency workarounds, init ordering). Use PROJECT_NOTES.md for **decisions and session context**
|
||||
- If function line numbers drift significantly after edits, re-run the pre-commit hook or update manually
|
||||
|
||||
---
|
||||
|
||||
## .subframe/docs-internal/ Directory
|
||||
|
||||
This directory holds project documentation that doesn't belong in the root:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `changelog.md` | Track changes under `## [Unreleased]`, grouped by Added/Changed/Fixed/Removed |
|
||||
| `*.md` (ADRs) | Architecture Decision Records for significant design choices |
|
||||
|
||||
**What goes here:** Changelog entries, architecture decision records, internal reference docs.
|
||||
|
||||
**What does NOT go here:** User-facing docs (those go in `docs/` or project root), task files (those go in `.subframe/tasks/`).
|
||||
|
||||
---
|
||||
|
||||
## .subframe/QUICKSTART.md Rules
|
||||
|
||||
### When to Update?
|
||||
- When installation steps change
|
||||
- When new requirements are added
|
||||
- When important commands change
|
||||
|
||||
---
|
||||
|
||||
## Before Ending Work
|
||||
|
||||
After significant work (code changes, architecture decisions), verify SubFrame files are in sync:
|
||||
|
||||
1. **Sub-Tasks** — Was this work tracked? Check `.subframe/tasks.json` → create/complete as needed
|
||||
2. **PROJECT_NOTES.md** — Any decisions worth preserving? Ask the user
|
||||
3. **Changelog** — Does `.subframe/docs-internal/changelog.md` reflect the changes?
|
||||
4. **STRUCTURE.json** — Source files changed? The pre-commit hook handles this automatically if configured; otherwise update manually
|
||||
|
||||
The stop hook (if configured) will flag untracked work automatically.
|
||||
|
||||
---
|
||||
|
||||
## General Rules
|
||||
|
||||
1. **Language:** Write documentation in English (except code examples)
|
||||
2. **Date Format:** ISO 8601 (YYYY-MM-DDTHH:mm:ssZ)
|
||||
3. **After Commit:** Check sub-tasks (`.subframe/tasks/*.md`) and `.subframe/STRUCTURE.json`
|
||||
4. **Session Start:** Read STRUCTURE.json, PROJECT_NOTES.md, and tasks.json before making changes
|
||||
5. **Don't Duplicate:** Always check existing sub-tasks before creating new ones
|
||||
|
||||
---
|
||||
|
||||
*This file was automatically created by SubFrame.*
|
||||
*Creation date: 2026-04-14*
|
||||
|
||||
<!-- subframe-template-version: 1 -->
|
||||
+409
-5
@@ -6,6 +6,306 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Persistent Realtime Agent conversation.** Realtime Agent voice now keeps one provider session/socket open across turns instead of creating a fresh session per utterance, so the provider retains the live conversation (follow-up references work) and turns skip session-setup latency. The relay needed no change — it already supported multiple turns on one socket. A **Voice Settings → Realtime Agent → Persistent session** toggle (default on) falls back to the legacy per-utterance path. See `docs/plans/2026-05-24-realtime-persistent-session.md`.
|
||||
|
||||
- **Background Hermes runs in Realtime Agent voice (ADR 33).** Long Hermes tasks no longer freeze the realtime conversation. A run that exceeds a grace window is promoted to a tracked background task: the provider speaks a short handoff ("I'm on it"), the conversation stays responsive, and the answer is spoken once the run finishes. `hermes_run_task(mode="background")` starts a durable run immediately. New relay events `hermes.run.promoted` and `hermes.run.background_completed`, plus `tier`/`floor` fields on `hermes.run.progress`.
|
||||
|
||||
- **Relay audio floor owner.** A single-owner audio floor (provider / relay-TTS / Android-filler) makes explicit the serialization that the old blocking design provided implicitly, so a completed background result never barges in and two voices never overlap.
|
||||
|
||||
- **Voice Settings → Realtime Agent → Background tasks.** New controls to enable/disable promotion, toggle the spoken handoff, and choose result delivery (speak when idle / notify / show only). A persistent "working on it" chip appears in the voice overlay while a background task runs.
|
||||
|
||||
- **Provider idle-tolerance probe.** `scripts/realtime-provider-idle-probe.py` records a per-provider verdict (hold-floor-ok / needs-keepalive / must-reopen) for holding a realtime socket quiescent during a background run; see `docs/realtime-voice-poc.md`.
|
||||
|
||||
## [0.8.1] - 2026-05-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice mode crash with barge-in on legacy TTS playback.** When barge-in was enabled and the relay served audio over the legacy `/voice/synthesize` (Media3) path, the first agent sentence played for ~2 syllables and then the app crashed with `IllegalStateException: Player is accessed on the wrong thread`. The barge-in listener's `Dispatchers.IO` reader was reading `ExoPlayer.getAudioSessionId()` (a thread-confined accessor) to attach the echo canceller. `VoicePlayer.audioSessionId` now serves a `@Volatile` cache populated from main-thread Media3 callbacks, so it is safe to read from any thread.
|
||||
|
||||
## [0.8.0] - 2026-05-23
|
||||
|
||||
### Added
|
||||
|
||||
- **Provider-native Realtime Agent voice.** Android can opt into a Realtime Agent voice engine where Android streams mic PCM to the relay, xAI or OpenAI owns realtime speech recognition and speech generation, and Hermes remains the governed authority for tools, memory, profiles, confirmations, current-data checks, side effects, and durable transcript context.
|
||||
|
||||
- **Hermes-brokered realtime tool timeline.** Realtime Agent turns now mirror transcript, assistant speech, Hermes task state, concise tool-status rows, confirmation state, path badges, and compact result provenance into chat/voice UI without dumping raw tool output aloud.
|
||||
|
||||
- **Connection diagnostics and activity logs.** Settings now includes a Diagnostics surface with sanitized recent API, relay, session, endpoint, and voice activity. API / Relay / Session detail drawers also tail the relevant recent activity so hung or unreachable relays are visible without ADB first.
|
||||
|
||||
- **Realtime and Voice Settings active-engine layout.** Voice Settings now separates **Voice Engine** from global voice controls, shows only the selected engine's provider card, keeps fallback TTS visible as a global safety-net card, and provides **Test Current Engine**: stable voice plays the saved Voice Output sample, while Realtime Agent opens a provider-native `/voice/realtime-agent/*` test session and plays streamed realtime audio.
|
||||
|
||||
- **Voice Lab text and mic demos.** The realtime voice test screen now offers two clearly separated demos: a **Text demo** that plays raw provider TTS, and a **Mic demo** that exercises the full agent path — real speech recognition, Hermes brokering, and a spoken reply — with tap-to-record / tap-to-stop capture. A `scripts/realtime-voice-lab-smoke.ps1` smoke script accompanies the lab.
|
||||
|
||||
- **Realtime playback diagnostics.** Playback now records a time-to-first-audio metric, logs requested-vs-actual AudioTrack buffer sizes, runs a first-frame watchdog, and cross-checks playback drain drift so cold-start and underrun regressions surface in the Diagnostics log instead of as silent dead air.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Google Play Bridge Core split.** The Google Play Android track keeps relay pairing, chat, profiles, voice, terminal/TUI, media, notification companion, relay sessions, diagnostics, and status while removing AccessibilityService-backed Device Control declarations and permissions. Sideload remains the track for screen reading, gestures, screenshots, SMS/calls, contacts/location, overlays, wake locks, and unattended control.
|
||||
|
||||
- **Release lanes now use explicit product tags and names.** Future Android releases use `android-v*`, server/Python releases use `server-v*`, and desktop continues on `desktop-v*`. GitHub Release names now publish as `Hermes-Relay-Android vX.Y.Z`, `Hermes-Relay-Server vX.Y.Z`, and `Hermes-Relay-Desktop vX.Y.Z`; the old relay-named server scripts remain compatibility shims.
|
||||
|
||||
- **Realtime voice instructions are provider-neutral.** Realtime providers receive active interface context, local date/time, provider/model/voice/profile metadata, and guidance to ask Hermes for current facts, research, device/desktop state, project context, precise/versioned data, and any requested checks instead of guessing from model knowledge.
|
||||
|
||||
- **Play/user docs now match the actual artifact.** Release-track docs, feature matrix, getting-started copy, privacy/security references, and Play listing copy now say Google Play has no AccessibilityService, screen reading, gestures, screenshots, or phone-control utility permissions.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Silent / choppy first-turn realtime voice playback.** The AudioTrack deep-buffer cold-start was parking the playback head at zero so the first turn dropped or stuttered. The streaming buffer was shrunk from 4000ms to 700ms, the low-latency prebuffer threshold retuned, and a preroll force-start removed, giving reliable low-latency playback from the first frame. Confirmed on-device.
|
||||
|
||||
- **Voice Lab waveform now tracks the playback cursor.** The waveform is driven by `RealtimePcmPlayer.playbackAmplitude()` at the playback position instead of socket-arrival time, so the visual matches what is actually being heard.
|
||||
|
||||
- **Realtime Hermes calls no longer depend on the phone's saved Hermes API key.** Provider-native Hermes tool calls are brokered by the relay with its server-side Hermes credential, so a phone can be paired for realtime voice without exposing or misusing its saved API bearer.
|
||||
|
||||
- **Hung relay voice turns fail visibly.** Voice turns run relay health preflight and shorter realtime/session timeouts so Settings and Voice mode surface unreachable relay state instead of sitting indefinitely on Thinking.
|
||||
|
||||
- **OpenAI realtime is no longer treated as render-after-Hermes fallback.** `openai_realtime` is registered as a native Realtime Agent provider path alongside xAI, with provider-native audio events normalized through the same broker contract.
|
||||
|
||||
- **Local release signing no longer falls back to debug when `local.properties` uses a repo-root relative keystore path.** The Android Gradle signing config now resolves relative keystore paths from the repo root, matching the documented `release.keystore` setup.
|
||||
|
||||
## [0.7.0] - 2026-05-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Profile-aware Hermes sessions and voice settings.** Android now treats Hermes profiles as first-class connection state: profile selection resolves against the active server, profile-specific chat sessions are persisted separately, default/Victor display is normalized, and per-profile voice provider/model/voice settings can be read and saved through server-owned endpoints without depending on Hermes config mutations.
|
||||
|
||||
- **Realtime voice playground and provider lab.** The relay now includes standalone OpenAI/xAI/ElevenLabs-oriented voice lab tooling, provider adapters, provider option discovery routes, realtime playground routes, and generated WAV/JSONL artifact ignores for iterative voice quality testing outside production Hermes routes.
|
||||
|
||||
- **Streaming voice output routes.** server-owned `/voice/output/*`, realtime playground, profile voice config, and provider option endpoints support provider-neutral TTS rendering, dynamic voice/model option surfaces, and profile-scoped voice configuration for Android.
|
||||
|
||||
- **Experimental Android realtime voice overlay.** Android adds a richer voice overlay with tap-to-talk, continuous mode controls, optional system overlay mode, compact mode, realtime waveform visualization, playback controls, and an experimental badge around barge-in instead of treating all voice as experimental.
|
||||
|
||||
- **Experimental realtime Hermes voice-agent plan.** `docs/plans/2026-05-19-realtime-hermes-voice-agent.md` records the next architecture step: provider-native realtime speech with Hermes-brokered profiles, sessions, tools, confirmations, and transcript mirroring. The stable Hermes chat + voice-output path remains the default.
|
||||
|
||||
- **Desktop tray pairing and consent flow.** The desktop surface gained Tauri tray pairing, QR/consent affordances, sidecar preparation, and computer-action approval polish so desktop and Android pairing flows are closer to parity.
|
||||
|
||||
- **Shared relay/Quest scaffolding.** Experimental `relay-core`, `relay-ui`, and Quest prototype modules were added for shared pairing, terminal, transport, voice, and morphing-sphere work without changing the Android phone app's default route.
|
||||
|
||||
- **Desktop Chat tab with first-run route setup.** The Tauri tray dashboard now has a Chat tab inspired by the Hermes Desktop chat-first flow. It streams through the saved paired relay when `~/.hermes/remote-sessions.json` has an active session, or through a direct Hermes gateway/API URL when relay pairing is not available. The tab supports stop, retry, new chat, clear, current-session transcript history, and a setup panel that offers relay pairing or direct WebAPI configuration without saving the optional API key.
|
||||
|
||||
- **First-class desktop TUI tab.** The Tauri tray dashboard now gives the embedded xterm/PTY Hermes session its own sidebar tab instead of nesting it under Terminal / CLI. Terminal remains the external launcher, shim-state, and copyable-command surface, while plugin embeds route into the same TUI tab.
|
||||
|
||||
- **Desktop surface plugins.** The desktop CLI and Tauri tray now register built-in terminal surface plugins, starting with Herm (`herm-tui`) from `liftaris/herm`. Users can inspect plugin status, install or update Herm, launch a fresh dashboard, resume with `herm -c`, or embed the plugin in the tray's xterm/PTY surface with `bunx`/`npx` fallback when the `herm` binary is not installed.
|
||||
|
||||
- **Relay server release track.** Relay server and Python package releases now use `relay-v*` tags, validate relay-owned version metadata, build wheel/sdist artifacts, generate checksums, and publish through `.github/workflows/release-relay.yml`. This lets Relay server fixes ship independently from Android app `versionCode` bumps and desktop CLI alphas.
|
||||
|
||||
- **Dashboard plugin CI.** `.github/workflows/ci-dashboard.yml` builds the dashboard plugin, runs the dashboard API tests, and verifies the plugin-owned QR modal CSS markers are present in the built bundle.
|
||||
|
||||
- **Upstream integration sync reference.** `docs/upstream-integration-sync.md` now tracks which Hermes-Relay surfaces use upstream-supported extension points, which pieces are server-owned compatibility layers, and what has to be checked before changing relay, Android, desktop, dashboard, bootstrap, or user-doc surfaces.
|
||||
|
||||
- **Relay version sync verifier.** `scripts/check-relay-version-sync.py` validates the relay package version against plugin metadata and dashboard metadata so release and dashboard surfaces cannot silently drift.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Stable voice is now the main Android voice path.** Voice mode defaults to Hermes chat streaming plus relay-managed voice output, with realtime-provider work kept as a standalone lab/testbench and future experimental mode instead of replacing Hermes session/tool authority.
|
||||
|
||||
- **Realtime voice output uses balanced coalescing.** Normal assistant speech is batched into more natural chunks while tool/status speech stays immediate, reducing provider render resets and tone/volume variation during voice replies.
|
||||
|
||||
- **Voice settings are profile-scoped and option-aware.** Android can fetch provider/model/voice options from relay endpoints, show profile context in voice settings, save voice choices per Hermes profile, and expose advanced manual entry when provider metadata is incomplete.
|
||||
|
||||
- **Voice UI state is synchronized with chat state.** Voice mode now reuses more of the chat session/profile state, preserves live transcript and tool timeline visibility, and improves overlay exit/minimize behavior for hands-free use.
|
||||
|
||||
- **Release versioning is split by surface.** Android app releases remain on `v*` and use `gradle/libs.versions.toml`; Relay releases use `relay-v*` and keep `pyproject.toml`, `plugin/relay/__init__.py`, `plugin/plugin.yaml`, and dashboard plugin metadata in lockstep; desktop remains on `desktop-v*` and `desktop/package.json`. `scripts/bump-version.sh` is now a backward-compatible Android alias, with new explicit `scripts/bump-android-version.sh` and `scripts/bump-relay-version.sh` helpers.
|
||||
|
||||
- **Upstream voice imports are isolated.** Relay voice routes now call upstream Hermes STT/TTS helpers through `plugin.relay.upstream_voice`, keeping private upstream voice helper imports in one adapter module until Hermes exposes a stable HTTP voice API.
|
||||
|
||||
- **CI paths and release actions tightened.** Relay CI now watches Relay-owned paths instead of all `plugin/**`, validates Relay version metadata during syntax checks, uses explicit timeouts, and runs the focused route/auth/session test slice instead of broad test discovery. Release workflows now use `softprops/action-gh-release@v3`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Profile switching no longer silently falls back to the wrong local API host.** Profile API URL resolution now handles per-profile Hermes API servers, default/Victor compatibility, and relay-managed profile metadata so selecting a profile does not try to create sessions against `localhost` from the phone.
|
||||
|
||||
- **Non-default profile names remain visible in chat.** Agent display metadata is normalized so selected profile names persist above finalized assistant messages instead of disappearing back to the default label after stream completion.
|
||||
|
||||
- **Voice waveform and playback state are better aligned to real audio.** The output waveform waits for audio playback, handles processing separately, and avoids returning to the microphone too early at the end of an assistant response.
|
||||
|
||||
- **Continuous voice mode no longer starts a session just because auto mode is enabled.** Auto/continuous remains a preference, while explicit voice start/stop controls decide when a voice session is active.
|
||||
|
||||
- **Android voice mode no longer 403s when paired over plain-LAN `ws://` with a Hermes API key saved.** Symptom: tap the mic in Voice mode → red banner *"Voice access expired — extend or re-pair with voice grants"* even though the Connections card shows API Server / Relay / Session all green. Root cause: `RelayVoiceClient` preferred the saved Hermes API key over the paired Relay session token; the relay's `_request_is_secure_enough_for_api_bearer` correctly rejects API-bearer auth on `/voice/*` over plaintext outside loopback/Tailscale, returning a generic 403 that the client flattened to "expired." Fix: invert bearer precedence so paired devices use the session token first (no transport guard — it's the credential the QR/pair handshake already established), with the API key as fallback for chat+voice-only installs that never paired. `describeHttpError` now also reads the server's text/plain response body when present so future 403s show the relay's actual reason instead of a one-size-fits-all string.
|
||||
|
||||
## [0.6.1] - 2026-05-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Android bridge media sharing and MMS handoff.** New `android_share_media` and `android_send_mms` tools expose full file/attachment support through the relay media registry and Android `FileProvider` `content://` grants. Host-local paths are registered with `/media/register`, phones fetch bytes with their paired relay session, and the sideload app opens Android's native share or MMS compose UI after on-device confirmation. Relay HTTP now includes `/share_media` and `/send_mms`, and docs spell out that direct `android_send_sms` remains text-only `{to, body}`.
|
||||
|
||||
- **Relay voice endpoints accept Hermes API bearer tokens.** `/voice/config`, `/voice/transcribe`, and `/voice/synthesize` now accept either a Relay session token with explicit `voice:*` grants or the existing Hermes API bearer token. API bearer validation is voice-only, uses the configured Hermes API server's protected `/v1/models` endpoint with a short positive cache, and rejects non-loopback plaintext by default unless a trusted HTTPS proxy header or the explicit dev escape hatch is configured. Existing Relay sessions are backfilled with voice grants so paired phones do not need to re-pair.
|
||||
|
||||
- **Relay CLI can toggle plain-LAN API-key voice auth without restart.** `hermes relay insecure-api-key status|on|off` calls the running relay's loopback-only `/relay/security` endpoint and flips the runtime `allow_insecure_api_bearer` flag immediately. This keeps HTTPS as the default for API-key voice auth while making Android phone LAN smoke tests possible without exporting env vars or restarting the service.
|
||||
|
||||
- **Desktop CLI alpha.14 — `Ctrl+A ?` chord re-displays the chord-help banner.** The attach-time banner scrolls off as soon as anything writes to the terminal, so users mid-session forgot the verb list and had to detach + re-attach (or guess). New `Ctrl+A ?` (and `Ctrl+A h` synonym) reprints the banner to stderr without leaving the session. Banner text refactored into a single `CHORD_HELP` constant so the attach-time print, the `?` chord, and the unknown-chord hint can't drift out of sync. Unknown-chord hint now also lists `?` as one of the known verbs.
|
||||
|
||||
- **Desktop CLI alpha.13 — `Ctrl+A v` chord in `hermes-relay shell` for in-session paste.** Bailey: *"This isn't cohesive — we have to exit hermes-relay shell to run `hermes-relay paste`. Can we leverage a tmux hook?"* Tmux runs on the Linux server with no path back to the Windows clipboard, so server-side hooks can't help — but the existing client-side chord state machine (`Ctrl+A .` detach, `Ctrl+A k` kill, `Ctrl+A Ctrl+A` literal) is the right place. Added `Ctrl+A v`: client reads its own clipboard image (same `captureClipboardImage()` path as the `/paste` REPL command), POSTs to `/clipboard/inbox` via the new shared `stageClipboardImageToInbox(url, token)` helper exported from `commands/paste.ts`, then types `/paste\r` into the PTY so the upstream Hermes TUI consumes it in the same flow the user would have typed by hand. Status line goes to stderr so it doesn't pollute the PTY stream: `[shell] pasted 1920×1080 (245 KB) → /paste`. Reentrancy guard prevents double-stage on a fast double-press. Banner help and chord doc-comment updated to list the new verb.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android bridge tool/route contract drift.** The active plugin import now uses `plugin.tools.android_tool` as the single source of truth, while top-level `plugin/android_tool.py` remains as a compatibility shim. The relay now registers `/return_to_hermes`, matching the documented and phone-side command, and bridge status gating checks `/bridge/status` so tools are hidden unless a phone is actually connected.
|
||||
|
||||
- **Android CI/release gate no longer hangs on the broad Gradle test aggregate.** The Android CI and `v*` release workflows now run the stable sideload pairing/connection regression slice with explicit timeouts while the deferred full JVM test-suite cleanup remains tracked separately.
|
||||
|
||||
- **Android connection/profile state no longer leaks across switches.** Connection switches now clear the outgoing profile object immediately, load the destination connection's saved profile name only after that connection is active, and resolve it against the destination server's current profile list. The default local relay URL is now `ws://localhost:8767`, and auto-managed relay URLs are derived from the active API URL before reconnecting.
|
||||
|
||||
- **Desktop CLI alpha.12 — install scripts truncated the prerelease suffix in the "upgrading X → Y" line.** Bailey saw `existing install detected: 0.3.0-alpha.9 — upgrading to 0.3.` (literally truncated mid-token). Root cause: `normalize_pinned_version` (bash) and `Get-NormalizedPin` (PowerShell) stripped everything after the first `-`, including `-alpha.N`. Comment claimed this was "for comparison against the bare semver the binary reports" — but since alpha.4, the binary's `--version` reports the FULL semver (via the embedded `gen:version` constant), so the strip is no longer defensive, just lossy. Removed the suffix-strip from both normalizers; both now produce `0.3.0-alpha.11` from `desktop-v0.3.0-alpha.11`. The equality compare at line 138 still works because both sides include the prerelease tail.
|
||||
|
||||
- **Desktop CLI alpha.11 — `hermes-relay update` (and the install one-liners) saw the wrong "latest" release.** Bailey on alpha.9 ran `hermes-relay update --check`, expected to see alpha.10, got "Up to date." Root cause: GitHub's `/repos/.../releases` API returns rows ordered by the release object's `created_at`, NOT by SemVer of the tag — and `created_at` shifts whenever the row is touched (re-tag, manual edit, asset replacement). When alpha.9's release row got touched after alpha.10 was tagged, the API listed alpha.9 first and all three of our resolvers blindly took `[0]`. Fix: pick the SemVer-max from all desktop-v* tags explicitly. (1) `desktop/src/updater.ts` — `desktop.reduce((max, r) => compareVersions(r.tag_name, max.tag_name) > 0 ? r : max)`. (2) `desktop/scripts/install.sh` — `sort -V | tail -1` (zero new deps; bash + sort is sufficient). (3) `desktop/scripts/install.ps1` — custom `Sort-Object` comparator that packs (Major, Minor, Patch, PrereleaseRank, PrereleaseNum) into a zero-padded sortable string with alpha=1, beta=2, rc=3, stable=999. Live-verified against the real API: all three now return `desktop-v0.3.0-alpha.10` instead of `alpha.9`.
|
||||
|
||||
- **Desktop CLI alpha.10 — `hermes-relay paste` always returned "No image on clipboard" on Windows even when an image was present.** Root cause: the PowerShell invocation in `captureClipboardWindows` (`src/chatAttach.ts`) was missing the `-STA` flag. `powershell.exe -Command` defaults to MTA (Multi-Threaded Apartment), and `[System.Windows.Forms.Clipboard]::GetImage()` only returns a valid image from STA threads — from MTA it silently returns null, indistinguishable from "no image present." Also affects the `chat` REPL's `/paste` command which routes through the same Windows code path. Fix: added `-STA` to the powershell args list (now `['-NoProfile', '-NonInteractive', '-STA', '-Command', ps]`). Live verification: empty clipboard returns null; a cyan 100×80 PNG placed via `[System.Windows.Forms.Clipboard]::SetImage` returns the expected 305-byte capture with correct dimensions. Affects `desktop-v0.3.0-alpha.7` through `desktop-v0.3.0-alpha.9`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Android voice no longer requires Relay pairing when a Hermes API key is saved.** The phone now resolves voice auth from the saved Hermes API key first, then falls back to the paired Relay session for `/voice/config`, `/voice/transcribe`, and `/voice/synthesize`. Chat+voice-only setups can use manual/API-key configuration without the full pairing-code flow; bridge, terminal, media, clipboard, profile writes, and Android-control routes remain paired-session-only.
|
||||
|
||||
- **Relay grant labels are now human-readable in Android and dashboard management UI.** Relay session grant chips still preserve the server keys internally, but user-facing lists now sort the known grant set and render labels such as `Voice STT` / `Voice TTS` instead of raw `voice:stt` / `voice:tts`. Privacy and configuration docs now reflect that Voice mode uses runtime microphone permission and split voice grants.
|
||||
|
||||
- **Desktop CLI alpha.8 — `/screenshot` is multi-monitor aware by default.** The alpha.6/alpha.7 `screenshotHandler` / `captureScreenshot` captured only the primary display on Windows and treated `display` as a number-only param. alpha.8 changes the default to `-1` (all monitors stitched) and accepts string aliases so both the agent tool call and the `/screenshot` slash command can say `'all'` / `'primary'` / `'1'` / `'2'` etc. Windows path uses `System.Windows.Forms.SystemInformation.VirtualScreen` for the union rect (handles negative coordinates when monitors are arranged left-of-primary). macOS path uses `screencapture -D N` for 1-indexed per-display capture. Linux path relies on grim/scrot/import's inherent whole-X-screen behavior. REPL `/screenshot` defaults to all monitors; `/screenshot primary` or `/screenshot 0 | 1 | 2` narrow. Live smoke on a multi-monitor Windows box: all = 1.6 MB stitched, primary = 405 KB — 4× size ratio confirms virtual-screen path. Zero server changes; `image.attach.bytes` RPC consumes whatever bytes the client sends.
|
||||
|
||||
### Added
|
||||
|
||||
- **Desktop CLI alpha.7 — native image paste in `hermes-relay chat`.** `desktop-v0.3.0-alpha.7`. Plan: [`docs/plans/2026-04-23-desktop-alpha-7-native-paste.md`](docs/plans/2026-04-23-desktop-alpha-7-native-paste.md). Users now type `/paste` (system clipboard), `/screenshot` (primary display), or `/image <path>` (file on disk) inside the `chat` REPL, get a one-line feedback echo (`[📎 clipboard 1920×1080, 234 KB — attached to next message]`), and the NEXT `prompt.submit` ships with the image attached so the vision-capable model sees it in the same turn. Parity with Claude Desktop's paste behavior — minus OS-level Ctrl+V, which terminals fundamentally don't deliver image bytes through. Spans two repos: the client half is new `desktop/src/chatAttach.ts` (captureClipboardImage / captureScreenshot / readImageFile — platform-shelled like the alpha.6 clipboard handler: Windows PowerShell `Get-Clipboard -Format Image` + `System.Drawing.Bitmap.CopyFromScreen`, macOS `pngpaste`/`screencapture -x -t png`, Linux Wayland-first `wl-paste --type image/png`/`grim` with X11 `xclip`/`scrot` fallbacks) plus new slash-command branches in `desktop/src/commands/chat.ts`; the server half is ONE new `@method("image.attach.bytes")` RPC handler on the fork's `tui_gateway/server.py` (`Codename-11/hermes-agent` branch `feat/image-attach-bytes` → merged to `axiom`) that accepts `{session_id, format, bytes_base64, filename_hint?}`, validates magic bytes (PNG `89 50 4E 47` / JPEG `FF D8 FF` / WEBP `RIFF....WEBP`) to prevent content-type laundering, decodes to `~/.hermes/images/remote_<ts>_<rand6>.<ext>`, and appends to `session["attached_images"]`. The fork's **existing** `_enrich_with_attached_images` pipeline already handles the hard part — multimodal payload plumbing, session-scoped image state, vision-model routing — so this release is almost entirely about bridging client-captured bytes to the server-side state that's been there for months. The `tui` relay channel is a transparent RPC forwarder; zero relay changes. Fallback when hermes-host hasn't been updated yet: client's `image.attach.bytes` RPC call gets `method not found`, client catches it specifically and prints `[attach failed: method not found — server may need axiom rollout]` to stderr, REPL stays alive, user can still send text — no crash, and the exact error points the operator at the fix. Non-goals locked for this release: no Ctrl+V terminal keybinding (terminals don't pipe image bytes to stdin — that's OS-level), no Kitty/iTerm2 inline image protocols (defer to alpha.10+), no PTY shell-mode support (the remote `hermes` CLI has its own paste handling), no multimodal `prompt.submit` payload extension (the attach-then-submit pattern is cleaner and matches the existing server state model).
|
||||
|
||||
- **Desktop CLI alpha.6 — seamless-local dev pass.** Nine features across six parallel agent workstreams delivered in one integration. Plan: [`docs/plans/2026-04-23-desktop-alpha-6-seamless-local.md`](docs/plans/2026-04-23-desktop-alpha-6-seamless-local.md). (1) **Workspace-awareness envelope** (`#1+#8`) — new `src/workspaceContext.ts` detects `cwd`/`git_root`/`git_branch`/`git_status_summary`/`repo_name`/`hostname`/`platform`/`arch`/`active_shell` via parallel `git rev-parse`/`git status --porcelain=v1 --branch` calls under a 2 s total budget; `RelayTransport` auto-sends a `desktop.workspace` envelope after first `auth.ok` (guarded against reconnect re-send); server-side `plugin/relay/channels/desktop.py::DesktopChannel` stashes per-ws as ephemeral session metadata. Active-editor hints (`src/activeEditor.ts`) poll tmux (`display-message -p "#{pane_current_path}:#{pane_current_command}"`) or detect VSCode/Cursor via `$VSCODE_IPC_HOOK_CLI`+`TERM_PROGRAM`; dedupes envelopes so only actual changes fire. New `hermes-relay workspace` subcommand prints the context; `doctor` output gains a `workspace:` block. Gated client-side by `--watch-editor` for the poller; envelope itself is always-on. (2) **`hermes-relay update` self-update** (`#2`) — new `src/updater.ts` + `src/commands/update.ts`. Polls GitHub Releases API (the same prerelease-aware resolver the installer uses), semver-compares to `VERSION`, downloads asset with SHA256 verification, and atomic-swaps on POSIX (`fs.rename` — running process's inode stays live so the daemon keeps running; next invocation picks up new binary). Windows can't replace a running `.exe`, so the updater writes to `<bin>.new.exe` and `finalizePendingUpdate()` runs at the top of `main()` on every subsequent invocation to rename it into place. `--check` dry-runs; `--yes` skips confirm; `--json` emits machine-readable status. (3+4) **Editor tool + interactive patch approval** (`#3+#4`) — new `src/tools/handlers/editor.ts` for `desktop_open_in_editor(path, line?, col?, wait?)` with launcher detection (`$VISUAL`→`$EDITOR`→PATH probe for `code`/`cursor`/`subl`/`nvim`/`vim`→platform fallback); `-g` injection for GUI editors supports `:line:col`. `desktop_patch` now routes through `src/tools/patchApproval.ts` in interactive mode — renders unified diff with ANSI (green/red/cyan, NO_COLOR/isTTY aware), prompts `y/n/e/r` via readline on stderr; `e` opens the patch in `$EDITOR` and re-reads on close. Non-interactive modes (daemon, piped stdin) auto-reject with structured reason; never auto-accepts. Router (`src/tools/router.ts`) carries an `interactive` flag set at construct time (`stdin.isTTY && HERMES_RELAY_DAEMON !== '1'`). (5) **Conversation picker on connect** (`#5`) — new `src/sessionPicker.ts` calls tui_gateway's `session.list` JSON-RPC (same RPC upstream Ink TUI uses), renders a numbered list with human-readable age + first-prompt preview. `shell.ts` injects after banner / before PTY attach, appending `--resume '<id>'` to the hermes exec when a session is picked. `chat.ts` injects before the chat loop. `--session <id>` (chat: legacy alias for `--conversation`; shell: tmux session name — distinct), `--conversation <id>` and `--new` bypass the picker. Graceful degradation: 404 / "method not found" returns empty list silently, picker falls through to `'new'`. (9+12) **Clipboard + screenshot handlers** (`#9+#12`) — `src/tools/handlers/clipboard.ts` and `.../screenshot.ts`. Clipboard: Windows `powershell Get-Clipboard -Raw` / `$input | Set-Clipboard` (strips trailing CRLF); macOS `pbpaste`/`pbcopy`; Linux Wayland-first (`wl-paste`/`wl-copy` via `$WAYLAND_DISPLAY`), xclip fallback. 5 s timeout, 10 MB cap both directions. Screenshot: Windows writes a temp `.ps1` using `System.Drawing.Bitmap.CopyFromScreen` (honors multi-monitor via `Screen.AllScreens[display]`); macOS `screencapture -x -t png`; Linux `grim`→`scrot`→`import` fallback chain. `save_to` keeps the file; otherwise base64 + tempfile delete. 10 s timeout, 50 MB cap. All three wired into `shell.ts`/`chat.ts`/`daemon.ts` router handler map (9 handlers advertised now, up from 5). (13) **`hermes` alias** (`#13`) — `install.sh` creates a POSIX symlink `~/.hermes/bin/hermes → hermes-relay`; `install.ps1` drops a universal `.cmd` shim (no admin required — avoids Windows symlink Developer-Mode requirement). Collision-safe: only creates if nothing else lives at that name. Uninstall scripts remove the alias only when it points at our binary (preserves an unrelated upstream hermes-agent install).
|
||||
|
||||
- **Dev-iteration additions.** `npm run smoke` expanded from 4 to 5 assertions (added `workspace`); still runs locally in ~1 s post-build. CI workflow already runs the equivalent 5-command smoke on the Linux binary before publishing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **desktop CLI binary was a no-op on alpha.3** — installed cleanly, exited 0, produced zero stdout/stderr, wasn't "recognized" as a CLI. Root cause: cli.ts guarded its entry-point invocation with `fileURLToPath(import.meta.url) === process.argv[1]`, which is a valid Node idiom but fails in Bun-compiled binaries because the entry module has a synthetic URL that doesn't match the `.exe` path — the check evaluated false, `main()` was never called, binary exited 0 silently. Replaced with `import.meta.main` (cross-runtime: Bun, Node 20.11+, tsx) which is true in the entry module regardless of compile mode. All four invocation paths stay correct (Bun --compile binary, `bin/hermes-relay.js` shim, `tsx src/cli.ts`, test imports). Caught by adding a local `npm run smoke` target that runs the compiled Windows binary against `--version` / `--help` / `doctor` and verifies each produces output. Same smoke added to `release-desktop.yml` on the Linux target so future regressions of this class are caught pre-publish. Affects `desktop-v0.3.0-alpha.3`; fix ships as `desktop-v0.3.0-alpha.4`.
|
||||
- **`hermes-relay --version` printed `0.0.0` in compiled binaries.** `readVersion()` tried to read `package.json` via `__dirname + '../package.json'`, which doesn't resolve in a Bun `--compile` binary (no real filesystem layout). Replaced with a build-time-generated `src/version.ts` module (`npm run gen:version` writes the version from package.json before every build and every `build:bin:*`). `readVersion()` now just returns the embedded constant. Works identically in tsx / Node / Bun.
|
||||
- **desktop CLI binary segfaulted at startup on Bun 1.3.13 Windows x64** (`panic(main thread): Segmentation fault at address 0x100000D9C`). Root cause identified as Bun's experimental `--bytecode` flag; attempted fix in alpha.2 only edited `desktop/package.json`'s build scripts while the release workflow's inline `bun build` commands silently kept `--bytecode`, so alpha.2 shipped with the same crash. alpha.3 fixes the workflow two ways: (1) dropped `--bytecode` from release-desktop.yml, and (2) refactored the four build steps to delegate to `npm run build:bin:*` so the package.json scripts are the single source of truth for compile flags. Added a `bun --version` diagnostic step to the workflow for future triage. Versions affected: `desktop-v0.3.0-alpha.1` and `desktop-v0.3.0-alpha.2`. Fix ships as `desktop-v0.3.0-alpha.3`.
|
||||
- **Installer couldn't find alpha-only releases.** GitHub's `/releases/latest/download/` URL deliberately skips prereleases, so the default `curl | sh` / `irm | iex` one-liner failed against alpha.1 with "maybe no Windows release for this version yet?" Both `install.sh` and `install.ps1` now query the Releases API directly (`GET /repos/.../releases`, filter to `desktop-v*` tags, take first) when `HERMES_RELAY_VERSION=latest`. Pinned versions unchanged.
|
||||
|
||||
### Added
|
||||
|
||||
- **Pre-release hardening: uninstall, doctor, first-run prompts, version-aware install.** Four parallel workstreams that close the "feels like a dev preview" gap before tagging `desktop-v0.3.0-alpha.1`. (1) **Uninstall scripts** — new `desktop/scripts/uninstall.{sh,ps1}` matching install one-liners, 3-tier: default `--binary-only` (removes binary + PATH entry, preserves `~/.hermes/remote-sessions.json`), `--purge` (also wipes the shared session store with a loud cross-surface warning about Ink TUI + Android tooling dependencies), `--service` (stub for when daemon service installers ship — prints canonical systemd/launchd/sc.exe paths without acting). iex-pipe safety: Windows falls back to `HERMES_RELAY_UNINSTALL_{PURGE,SERVICE}` env vars since `$args` drops through `irm | iex`. Shell rc files deliberately untouched (mirrors install.sh philosophy). (2) **`hermes-relay doctor` subcommand** — local-only diagnostic report (225 lines, `src/commands/doctor.ts`); human format uses `!!` prefix for warnings + hint line at bottom, `--json` for support-paste / scripts. Fields: version / binary_path / install_dir / on_path / sessions file + size + count + summaries (no tokens — total omission, not even prefix) / daemon detection (stat of canonical service unit file paths) / platform + node version. Case-insensitive PATH comparison on Windows. (3) **Interactive first-run fallback** — new `src/relayUrlPrompt.ts` (~180 lines) with `promptForRelayUrl()` (readline on stderr, `^wss?:\/\/\S+$` validation, 3 retries) and `resolveFirstRunUrl()` (auto-picks single stored session, numbered picker for multiple, first-run banner for zero). Wired into `connectAndAuth` in `shell.ts` / `chat.ts` / `tools.ts` and `resolvePairTarget` in `pair.ts`, replacing the hard `No relay URL` error. Fresh-install UX: bare `hermes-relay` now prints `Welcome to hermes-relay. No stored sessions yet — let's pair with a Server.` → URL prompt → pairing code prompt → drops into shell. `--non-interactive` still fails fast. Daemon command deliberately untouched — headless binaries must never prompt; fails closed on missing credentials/consent as before. (4) **Version-aware install** — `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or `installing fresh` (no prior install); post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN rather than failing (pre-release version-name drift is expected). 5s timeout on the version call (where `timeout(1)` available); all diagnostic failures fall through to the "could not read version" path. Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for matching. All structural flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively. Type-check + build green; live smoke: `doctor` both modes, `daemon` fails-closed without credentials, help text includes all new surfaces.
|
||||
|
||||
- **`hermes-relay daemon` — headless WSS + tool router, lifts the "tools only work while a shell is open" ceiling.** New `desktop/src/commands/daemon.ts` subcommand that opens a persistent relay connection and attaches `DesktopToolRouter` without a TTY. The agent can now reach the user's machine any time of day — first step toward "feels-local" parity. Fails closed on missing credentials (no stored session + no `--token` → exits 1) and on missing consent (no `toolsConsented: true` on the stored record → exits 1 unless `--allow-tools` is passed alongside an explicit `--token`); a headless binary must never be the thing that first grants tool access. Inherits `RelayTransport`'s reconnect state machine as-is — exp backoff 1s → 30s (5min on 429), reconnect listeners persistent across close/reconnect cycles because `channelListeners` is a Map on the transport (not wiped on socket close), so the router's `attach()` fires exactly once. Structured logging defaults to JSON-line on stderr (parseable by journald / log shippers / jq), auto-switches to human-readable when stderr is a TTY, or force either with `--log-json` / `--log-human`. Lifecycle events: `starting` → `authed` (includes `server_version`, `transport`) → `ready` (with `advertised_tools` list) → `reconnecting` (attempt + delay_ms) / `reconnected` → `shutdown` on SIGTERM/SIGINT/SIGHUP → `transport_exited` when the transport exhausts reconnects (exits 1 so the service manager restarts fresh). Live smoke against `ws://172.16.24.250:8767`: `starting` → `authed` (server 0.6.0) → `ready` (5 tools advertised) in ~120ms. New BOOLEAN_FLAGS entries: `log-human`, `log-json`, `allow-tools`. Service installers for Windows `sc.exe` / systemd user unit / macOS launchd plist are the obvious follow-up; the daemon binary is runnable standalone today via `hermes-relay daemon --remote <url>`.
|
||||
|
||||
- **Desktop CLI v0.2 — PTY shell, local tool routing, multi-endpoint pairing, reconnect + TOFU, devices, contextual banner.** The `@hermes-relay/cli` package at `desktop/` grew from a chat-only scripting surface into a full Hermes-experience thin client. Bare `hermes-relay` now drops into `shell` mode (interactive PTY pipe through the existing relay `terminal` channel → `tmux new-session -A` + post-attach `exec hermes` → the full local `hermes` banner/skin/session id verbatim, zero server changes). `Ctrl+A .` detaches preserving tmux; `Ctrl+A k` destroys it. New `devices` subcommand drives the relay's `GET/DELETE/PATCH /sessions` HTTP endpoints for listing, revoking, and extending server-side paired-device tokens. Status now surfaces `grants:` (per-channel expiry) and `expires:` (session TTL) pulled from the `auth.ok` handshake the transport already received — `RemoteSessionRecord` gained `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented` (additive, back-compat preserved via a `SaveSessionOptions | string | null` overload on `saveSession`). Contextual connect banner (`Connected via LAN (plain) — server 0.6.0`) replaces the flat `Connected (server X)` line across `chat` + `shell`. Multi-endpoint pairing (ADR 24): `--pair-qr <payload>` / `HERMES_RELAY_PAIR_QR` accepts a full v3 QR payload (compact JSON or base64), decodes the `endpoints[]` array, probes each candidate with strict-priority-within-tier racing (`Promise.any` + `AbortSignal.any`, 4 s per-candidate timeout, 60 s reachability cache), and auto-selects the first reachable — role propagates into the banner + stored record. Reconnect-on-drop: `RelayTransport` gained a `ReconnectState` machine (`idle|connecting|connected|reconnecting`), exponential backoff (1 s → 30 s, 5 min on 429), `reconnectGate` re-checked both at schedule time and post-backoff (matches Android's mid-sleep purge-race lesson), `'reconnecting'` + `'reconnected'` events, and bufferedEvents-cleared-on-reconnect. TOFU cert pinning: TLS probe runs before the WebSocket opens on `wss://`, extracts peer-cert SPKI sha256 (`sha256/<base64>`, OkHttp-compatible), compares against the stored pin or captures it first-time; mismatches error out with a human-readable "re-pair to reset" pointer. Client-side tool routing (Phase B): new `desktop` relay channel on the server (`plugin/relay/channels/desktop.py` + `plugin/tools/desktop_tool.py` registering `desktop_read_file` / `desktop_write_file` / `desktop_terminal` / `desktop_search_files` / `desktop_patch`) forwards tool calls from Hermes to the connected Node CLI; client-side `DesktopToolRouter` dispatches to in-process handlers (`fs`, `terminal`, `search`) under a 30 s AbortController, 30 s heartbeat advertising the tool names. Gated behind a one-time per-URL consent prompt (`toolsConsented` on the session record) + `--no-tools` kill-switch; non-TTY stdin fails closed. New files on the client: `src/banner.ts`, `src/endpoint.ts`, `src/pairingQr.ts`, `src/certPin.ts`, `src/commands/devices.ts`, `src/tools/router.ts`, `src/tools/consent.ts`, `src/tools/handlers/{fs,terminal,search}.ts`. New files on the server: `plugin/relay/channels/desktop.py`, `plugin/tools/desktop_tool.py`, `docs/relay-protocol.md §3.5`. Still zero runtime deps on the client (Node ≥21 global `WebSocket` + `fetch` + `tls.connect` + `node:crypto` X509Certificate + `AbortSignal.any`). Build clean; live smoke passed for `status` / `tools` / `devices`; interactive `shell` + tool-call smoke pending user walk-through. Delivered as four parallel implementation agents (multi-endpoint, reconnect+TOFU, server-side desktop, client-side tool handlers) + one synthesis-and-integration pass; the `connectAndAuth → {relay, url, endpointRole}` return-shape refactor in `chat.ts` / `shell.ts` / `tools.ts` unifies how `--pair-qr`'s winning-endpoint URL overrides `--remote` across every subcommand.
|
||||
|
||||
- **Desktop thin-client CLI (`@hermes-relay/cli`) v0.1 under `desktop/`.** Node ≥21 package — installable via `npm install -g @hermes-relay/cli`, `npx @hermes-relay/cli`, or the new `scripts/install.sh` / `install.ps1` curl+iwr one-liners. One `hermes-relay` binary with four subcommands: `chat` (REPL + one-shot + piped-stdin, default), `pair` (one-time handshake → persists session token), `status` (local read of `~/.hermes/remote-sessions.json`), `tools` (`tools.list` RPC → enabled/available toolsets on the server). Credential precedence matches the Ink TUI exactly: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → stored session → interactive readline prompt. Reuses the **same** `~/.hermes/remote-sessions.json` store as the TUI, so a user paired via either surface sees the other work with no re-pair. Zero server changes: the CLI consumes the existing relay `tui` WSS channel + `tui_gateway` subprocess events (`message.delta`, `tool.start/complete`, `thinking.delta`, `status.update`, `error`, `approval.request`, …) and renders them as plain lines to stdout, with decorated tool arrows on stderr. Flags: `--remote <url>`, `--code <CODE>`, `--token <TOKEN>`, `--session <id>`, `--json` (event-per-line for `jq`), `--verbose`, `--quiet`, `--no-color`, `--non-interactive`, `--reveal-tokens` (opt-in full-token output on `status --json` — default redacts). Transport, gateway types, session storage, graceful-exit, and rpc helpers are **vendored verbatim** from `hermes-agent-tui-smoke/ui-tui/src/` (feat/tui-transport-pluggable) with a header note; the CLI and TUI stay in lockstep on the envelope protocol (docs/relay-protocol.md §3.7) until the shared surface can be lifted into a `@hermes-relay/core` package post-stabilization. SIGINT during a turn calls `session.interrupt` via a per-turn `{ promise, cancel }` handle — the REPL's cancellation state lives and dies with the turn so a late-arriving `error` event for a cancelled turn can't be misread by the next turn's handler. Smoke-tested end-to-end against `ws://172.16.24.250:8767` (hermes-relay 0.6.0, hermes-agent 0.10.0): connect/auth/session.create/prompt.submit/tools.list/--json/piped-stdin all clean. Not yet wired: interactive approval/clarify/sudo/secret request response (renderer logs a warning; out of scope for v0.1). Upstream PR candidate once the sibling Ink TUI stabilizes — see `desktop/README.md` and vault `Desktop Client.md` for the broader thin-client roadmap.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Transport Security badge is now role-aware — "Plain (on LAN)" instead of "Insecure (network unknown)".** The previous badge derived its label from `PairingPreferences.insecureReason`, which only got populated when the user toggled "Allow insecure connections" ON via the Ack dialog and picked a reason. If a user paired directly from a plain-`ws://` LAN QR, they never had to toggle that flag — the connection was already `ws://` — so the reason stayed blank and the badge degraded to the alarming `"Insecure (network unknown)"` even though the multi-endpoint resolver was tracking `activeEndpointRole = "lan"` in real time. Fix: `insecureReasonLabel` now accepts an optional `activeRole: String?` and prefers the live role over the stored ack reason (`Plain (on LAN)` / `Plain (on Tailscale)` / `Plain (on public URL)`). Neutral fallback when both role and reason are unknown is `"Plain (no TLS)"` — matches the new "Plain / Secure" vocabulary, drops the scary "Insecure" adjective. Binary-boolean `TransportSecurityBadge(isSecure, reason, ...)` overload gains an optional `activeRole` param with default `null` so existing call sites compile unchanged. `ConnectionViewModel.applyPairingPayload` auto-stamps `PairingPreferences.insecureReason` at pair time based on the selected endpoint's role (`lan` → `lan_only`, `tailscale` → `tailscale_vpn`, `public`/unknown → leave blank so the user thinks); clears any stale reason when upgrading to a secure endpoint. Only overwrites blank values — never clobbers a user-selected reason. Two user-visible "Insecure" strings inside the Advanced section's insecure-toggle subsection also rewritten to "Plain" for consistency (`"Plain connection — traffic is not encrypted"`, `"Allow plain (unencrypted) connections"`).
|
||||
|
||||
### Added
|
||||
|
||||
- **Bridge destructive-verb "Don't ask again" per verb.** `BridgeSafetyManager` now consults a new `trustedDestructiveVerbs: Flow<Set<String>>` in `BridgeSafetyPreferences` and short-circuits the confirmation overlay when the incoming verb is in the set (logging the auto-approval to the activity log so the trail is preserved). The `DestructiveVerbConfirmDialog` gets a `Don't ask again for "{verb}"` checkbox — off on every dialog open, so the user has to actively opt in per-action. Deny path never persists trust (denying a command is not consent). Kill-switch precedence is preserved and strictly ordered: master-disable wins over blocklist wins over per-verb trust. A trusted verb in a blocklisted app still 403s. `BridgeScreen` surfaces a `Trusted actions · N actions bypass confirmation` row with a `Reset` button under the existing safety section so a user who changes their mind can find the escape hatch without deep-linking to developer options. Addresses the confirmation-fatigue trap where approving `send_sms` 50 times trains the user to click through without reading the 51st.
|
||||
|
||||
- **AllInsecure pairing — one-time acknowledgment gate.** When every endpoint in a scanned QR is plain `ws://` / `http://` (no secure sibling to fall back to), `ConnectionWizard.ConfirmStep` now renders an `"I understand this pairing sends traffic in plain text — visible to anyone on the network."` checkbox that gates the Pair button. Per-install via new `PairingPreferences.allInsecurePairAckSeen` — once the user has acknowledged it, subsequent AllInsecure pairs pair one-tap. Mixed and AllSecure pairings are ungated (the amber "Mixed — secure fallback available" warning on Mixed is sufficient because the secure route exists). Matches the `InsecureConnectionAckDialog` precedent of per-install Tier-1 consent and complements the UX pass's explicit "subtle warning for Tier-2, forced confirm for Tier-1 absolute boundaries" philosophy documented in DEVLOG 2026-04-22.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Connection UX self-narration pass — Route / Relay sessions vocabulary + section headers + per-route security chips.** Three linked problems shipped as one commit: (1) pairing step 2 read as "you're stuck with insecure" for any multi-endpoint QR with LAN first, because the security badge + warning card were both computed from `endpoints[0]` alone — never acknowledging a secure Tailscale fallback in the same list; (2) the post-refactor active card had the right structure but no narration — sections stacked without headers, no captions explaining what Routes / Advanced / Security are for, Advanced surfaced manual URLs with no "most people don't need this" framing; (3) "Paired Devices" sounded like Bluetooth to anyone outside the project — the actual concept is server-side relay sessions with per-channel grants. Fix: introduce a shared vocabulary (Route for network path, Active/Fallback for state, Secure/Plain for transport, Relay sessions for server records) used consistently across `ConnectionWizard.kt` ConfirmStep, `ActiveConnectionSections.kt` (all three body sections), `EndpointsCard.kt`, and `PairedDevicesScreen.kt`. New `TransportSecurityState` tri-state (`AllSecure` / `Mixed` / `AllInsecure`) drives a context-aware pairing badge — the Mixed case now reads "LAN is plain ws:// — fine at home or the office, not on public Wi-Fi. Tailscale is encrypted (wss://) and the app uses it automatically when LAN is unreachable. You're safe on any network." — so users see they have a secure fallback without needing to understand the candidate-list mental model. Active card gains four labelMedium section headers (Connection health / Routes (N) / Advanced / Security) each with a one-line bodySmall caption above the section body. Endpoint rows in both surfaces carry per-row Secure/Plain chips (green 🔒 / amber 🔓, not scary red) so each route's security is visible at a glance; ordinal labels are humanized (`1st choice` / `Fallback` / `Fallback 2` on pairing step 2; `Active` / `Fallback` on the active card — different framings because pre-connection the commitment is ordinal and post-connection what matters is state). `PairedDevices` Kotlin identifier and deep-link route string stay — only the user-visible labels change — so nav deep links are unaffected. New intro paragraph on the Relay sessions screen explains that rows are sessions (not Bluetooth pairings), and a tap-for-info icon on "Channel grants" opens a dialog explaining that chat/bridge/voice are per-feature permissions with independent expiries. Delivered as three parallel `general-purpose` implementation agents (one per surface, isolated file ownership) plus a post-implementation `code-reviewer` sweep that caught seven leftover `endpoint`/`Paired Devices` strings across `ConnectionInfoSheet.kt`, `SessionTtlPickerDialog.kt`, `EndpointsCard.kt`, `SettingsScreen.kt`, and the `Screen.PairedDevices` nav title — all corrected before commit.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Add-Connection navigation now fires on the tap instead of waiting for placeholder persistence.** Pre-fix, `RelayApp.kt`'s `onAddConnection` lambda awaited `beginAddConnection().join()` *before* calling `navController.navigate(Screen.Pair)` — so three serialized DataStore writes (addConnection / persistUrls / setActiveConnection) blocked the QR scanner appearing. On a warm device this was ~15-50 ms; on a cold / flash-pressured device it spiked to 100-150 ms, a visible freeze on every FAB tap. Fix pre-allocates the placeholder UUID synchronously on the UI thread, fires `navController.navigate(Screen.Pair.route(connectionId = id, autoStart = "scan"))` immediately, and runs `connectionViewModel.beginAddConnection(preAllocatedId = id)` in a fire-and-forget background coroutine. `ConnectionViewModel.beginAddConnection` gains an optional `preAllocatedId: String? = null` param — when provided, skips UUID generation, does an existence check (idempotent re-entry on double-tap / recomposition), and falls through to the existing mutex-guarded placeholder-build path. PairScreen's existing reactive `collectAsState` on `connectionStore.connections` / `activeConnectionId` picks up the placeholder milliseconds later — the user is still framing the QR. Critical path drops from three DataStore writes to zero; the writes still happen, just off the critical path. Zero behavior change for `preAllocatedId == null` callers (the legacy placeholder-reuse scan path is preserved byte-for-byte).
|
||||
|
||||
### Added
|
||||
|
||||
- **`relayReady` signal gates voice + bridge surfaces.** New `ConnectionViewModel.relayReady: StateFlow<Boolean>` composes three inputs — WSS `ConnectionState.Connected`, `AuthState.Paired`, AND non-blank `relayUrl` — into a single "WSS is actually functional" truth. ChatScreen's mic button dims + Toasts "Voice mode unavailable — relay not connected" instead of launching an overlay that would immediately fail on `/voice/transcribe`. BridgeScreen surfaces an error-container banner at the top of the scroll region so the user doesn't enable the master toggle expecting commands to flow. Soft-gate semantics — neither surface hard-disables, matching the existing Chat-send / Terminal-Refresh patterns; BridgeScreen intentionally still lets the user pre-configure permissions and safety rails before a relay pairs. Three-input (rather than the simpler two-input `chatReady` form) because the Case-C teardown edge — last connection removed, `_apiServerUrl`/`_relayUrl` blanked — can leave a stale `Paired` token alive alongside a dead URL; without the URL check the banner would never surface in that state.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Connection settings unified — one screen, one mental model.** The pre-refactor app had two near-identically-named screens (`ConnectionSettings` singular, `ConnectionsSettings` plural) reached from two different Settings-top surfaces (Active Connection quick-look card vs. "Connections" category row), each covering overlapping functionality. Everything the singular screen did — pair QR entry, manual URL config, insecure toggle, manual pairing code fallback, 3 tappable status rows — now folds inline onto the **active card** of the plural screen as expandable body sections. The singular `ConnectionSettings` screen (1429 lines), its route, its `Screen` enum entry, its `onNavigateToConnectionSettings` param chain, and the Active Connection quick-look card on Settings have all been removed. New active-card structure: Status rows (always visible) → Endpoints expander → Advanced expander (manual URL / insecure toggle / manual pairing code) → Security posture strip (transport badge + Tailscale chip + hardware keystore badge + Paired Devices row). Non-active cards stay flat. Navigation path throughout the user docs updates from `Settings → Connection → X` to `Settings → Connections → [active card] → X` (or `→ Advanced → X`). New file `ui/components/ActiveConnectionSections.kt` (~650 lines) owns the active-card bodies; `ui/screens/ConnectionsSettingsScreen.kt` is rewritten (~580 lines) with screen-scope hoisting for info sheets + the insecure-Ack dialog so `LazyColumn` item disposal can't silently dismiss them mid-scroll. Team-delivered: three parallel `feature-dev:code-explorer` agents produced the full feature inventory + integration map + caller trace in under 2 minutes, which made the synthesis + implementation mechanical.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice-exit chime firing on every Add-connection tap.** `ConnectionSwitchCoordinator.switchConnection` fires the `voiceStopCallback` unconditionally at step 3 (correct for connection-to-connection switches while voice is active), but `beginAddConnection` also routes through `switchConnection` to bind the placeholder Connection's auth store before the pair wizard runs — and `VoiceViewModel.exitVoiceMode()` was playing `sfxPlayer.playExit()` regardless of whether voice mode was actually on. Logcat confirmed the chime on every Add-connection FAB tap. Fix adds an idempotence guard at the top of `exitVoiceMode()`: early-return when `_uiState.value.voiceMode` is already false. Teardown is still safe to skip because every inner statement is null-guarded + try/catch-wrapped and would be a no-op on an already-stopped voice session; the only meaningful line is the `playExit()` SFX, which is what we're silencing.
|
||||
- **500 ms freeze on every Add-connection tap.** `ConnectionSwitchCoordinator.switchConnection` runs a `withTimeoutOrNull(AUTH_HYDRATE_TIMEOUT_MS = 500L)` block at step 10 to wait for the freshly-bound `AuthManager` to flip `AuthState` from `Loading` to `Paired`. The comment acknowledged Add-connection is the common path and the 500 ms was meant to be "imperceptible," but on-device it wasn't — the user perceived the delay (and the voice chime masking it) on every tap. The placeholder Connection created by `beginAddConnection` has `pairedAt == null` and an empty EncryptedSharedPreferences store, so `AuthState` will NEVER reach `Paired` — the 500 ms is pure stall. Fix short-circuits the hydrate wait when `target.pairedAt == null`: skip `withTimeoutOrNull` entirely for placeholders and log at DEBUG instead of the misleading "auth hydrate timeout" INFO. Real paired-to-paired switches still run the full hydrate wait because both sides have `pairedAt != null`.
|
||||
- **KDoc nested-comment trap in `ConnectionViewModel.relayReady` doc block.** A literal `/voice/*` path pattern inside the `relayReady` KDoc opened a nested block comment (Kotlin supports nested `/* */`, Java does not) whose `*/` then closed only the nested level — leaving the outer `/**` open for the remaining ~2200 lines of the file. Symptom: `MainActivity.kt:67` "Unresolved reference 'isReady'" plus ~50 cascading "Cannot infer type" errors across `PairedDevicesScreen`, `SettingsScreen`, `TerminalScreen`. Real errors (`Missing '}`, `Unclosed comment`) were the last two lines of `./gradlew compileGooglePlayDebugKotlin` output, easy to miss. Fix was a two-character rewrite: path patterns now wrapped in backticks AND `/*` → `/...` so the glob-looking character isn't in a block-comment position. Lesson logged in `DEVLOG.md` 2026-04-21; worth a sweep of other KDoc blocks for shell/regex-looking patterns before the next large diff.
|
||||
|
||||
- **Orphan placeholder connections from abandoned Add-connection flows.** The `beginAddConnection` path pre-creates a placeholder Connection and switches to it before the pair wizard runs — so `applyPairingPayload` lands the token in the right auth store. Previously, cleanup of the placeholder was wired only to the explicit Cancel button and TopAppBar back arrow. System back (gesture back / predictive back) bypassed that branch, leaving the placeholder in the connection list forever. Two-part fix: (a) `PairScreen` now installs a `BackHandler` that routes system back through the same `onCancel` → `discardPlaceholderConnection` branch the explicit back arrow uses; (b) `ConnectionViewModel.init` sweeps for any existing orphans (tuple: `pairedAt == null && apiServerUrl.isBlank() && label == PLACEHOLDER_LABEL`) on cold start and removes them — the tuple cannot be produced by any real pairing, so the sweep is safe without a dry-run. If the active connection at startup points at an orphan, the sweep switches to the first surviving real connection before deleting. Fixes the "why does my chip say 'New connection…'" symptom on devices that were affected pre-fix.
|
||||
- **Pair flow now auto-starts the camera on Add connection.** `ConnectionWizard` gains an `autoStart: String?` param (currently only `"scan"` is honored). The Add-connection FAB on `ConnectionsSettingsScreen` passes it so the wizard fires the camera permission launcher on first composition instead of forcing users through the Method chooser — one obvious next step, one-tap flow. Re-pair surfaces intentionally leave `autoStart` null so the full Scan / Enter code / Show code chooser stays available there. The deep-link arg is plumbed through `Screen.Pair`'s route (`pair?connectionId=...&autoStart=...`) and `PairScreen`'s new `autoStart` param; unrecognized values fall through to the default Method step so future builds can add more targets without breaking old ones.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Top-bar connection chip → inline switcher in the Agent sheet.** The app-wide `ConnectionChip` row that used to sit above every primary tab has been removed. Multi-connection switching now renders as a radio list inside the existing Agent sheet's Connection section (matching the visual pattern of the Profile and Personality sections above it), visible only when ≥2 connections are paired. Tapping a non-active connection fires `switchConnection` + a confirmation toast. Reasons: the chip duplicated the Agent sheet's Connection metadata, ate vertical space above every screen, and exposed the placeholder's `New connection…` label whenever an orphan existed (the root cause of Bailey's double-pair confusion). Dead code removed: the `ConnectionChip` import, the `connectionSheetVisible` state, the `ConnectionSwitcherSheet` render block at the bottom of `RelayApp`, and the `connectionChipVisible` / `activeConnection` vals. `ConnectionSwitcherSheet.kt` itself is kept for future programmatic callers.
|
||||
|
||||
### Added
|
||||
|
||||
- **Card-dispatch → server session sync** (completes ADR 26). Every [HermesCardDispatch] now carries a `syncedToServer` idempotency flag; on the next chat send, `CardDispatchSyncBuilder` synthesizes unsynced dispatches into OpenAI-format `assistant`+`tool` pairs under a namespaced synthetic tool name `hermes_card_action` and splices them into the request body alongside the existing voice-intent synthetic messages. `ChatHandler.markCardDispatchesSynced` commits the flag after the API client accepts the request — same post-handoff timing as voice intents, so a thrown request-building exception leaves both streams retryable. Guarantees the LLM sees prior card interactions ("you approved the `Run shell command?` card") across server restarts and reconnects, including `open_url` dispatches that never go through `sendMessage`. Unit-tested under `CardDispatchSyncBuilderTest` (pure-function JVM tests, no Android deps).
|
||||
- **Rich cards in chat via `CARD:{json}` inline markers** (ADR 26). Assistant messages can now surface structured Material 3 cards — skill results, approval prompts, link previews, calendar entries, weather — emitted as a single-line `CARD:{...}` alongside prose text. Follows the same streaming-endpoint-agnostic marker recipe as `MEDIA:`, so it works unchanged on `/v1/runs`, `/api/sessions/{id}/chat/stream`, and `/v1/chat/completions`. New `HermesCard` data class (`@Serializable`, `ignoreUnknownKeys=true` so newer agent schemas don't crash older phone builds) carries `title` / `subtitle` / `body` (markdown) / `fields` / `actions` / `footer` / `accent` (`info`/`success`/`warning`/`danger`). Built-in types: `skill_result`, `approval_request`, `link_preview`, `calendar_event`, `weather`; unknown types render via a generic fallback. `approval_request` intentionally mirrors Slack's exec-approval pattern (Allow / Deny with primary/danger button styles) so upstream Phase B adapter parity is a translation exercise, not a data-model rethink. Action dispatch (`send_text` default, `slash_command`, `open_url`) routes through `ChatViewModel.dispatchCardAction`, which stamps a `HermesCardDispatch` on the owning message before forwarding so the card collapses into a "Chose: X" confirmation even if the side effect fails. Renderer is `HermesCardBubble.kt` — accent stripe + Icon + Title/Subtitle + markdown body + fields table + FlowRow of action buttons. Cards render between the assistant's prose and any attachments in `MessageBubble`.
|
||||
- **CI test jobs advisory on `dev`, strict on `main`.** Both `.github/workflows/ci-android.yml` (`test`) and `.github/workflows/ci-server.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (Bailey's call: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
|
||||
- **MorphingSphere on the docs site.** New `SphereMark.vue` component (in `user-docs/.vitepress/theme/components/`) renders a 58×34 sphere directly above the "Install in 30 seconds" block — mounted in the `home-hero-after` slot alongside `InstallSection` for a hero → sphere → install stack. Imports `preview/web/sphere.js` directly so `MorphingSphereCore.kt` remains the single source of truth across app / preview / docs. The cursor reactivity is **eye-only** — the sphere body stays anchored while the bright-spot gaze tracks the pointer (no canvas translate / body bounce). Gaze composition: **scroll-tracking is the always-on baseline** — the eye anchors to the Install section's top edge (via `.install-section` DOM query), not to the viewport center. `installGap = installRect.top − viewportH` is the runway until install enters view; as it shrinks below 50 % viewport-height, `scrollVy` ramps linearly to 1, so by the time install's top crosses into the viewport the eye is already looking straight down at it. Before that runway, the eye sits forward (`scrollVy = 0`). **Cursor-tracking is a soft overlay** — inside a rectangular detection band (full viewport width × container height, linear falloff over 1.0 × container height past the top/bottom edges) the cursor's unit-vector direction crossfades into the scroll target via `cursorWeight`. The eye always has one coherent target — no mode switching, no fbm drift fighting the cursor at the band boundary, no eye-flip between modes. Palette retarget Idle ↔ Listening is gated on `cursorWeight` (0.2 / 0.5 hysteresis) so the sphere reads as *calmly watching* at the scroll baseline and *attentive* on direct hover. A tiny fbm wander (±0.07 on top of the target) keeps the eye breathing when both scroll and cursor are stationary. Fallback when the install element isn't on the page: viewport-center reference preserves the gaze-follows-scroll feel without the anchor. Pointer inputs pass through a per-frame EMA low-pass (180 ms direction / 280 ms proximity time constants) before any math runs — stops the per-event jitter from `pointermove`'s big discrete jumps; asin/acos inputs are capped at ±0.9 so we stay off the infinite-slope end of the inverse-trig curves. Canvas is square (`aspect-ratio: 1 / 1`, `clamp(280px, 48vw, 420px)`) so the sphere fills the frame at the algorithm's natural 0.60-envelope sizing — no dead space between the phone video and the Install block. Respects `prefers-reduced-motion` (zeroes the gaze blend so the eye stops tracking but the ambient animation continues), pauses drawing while scrolled off-screen via `IntersectionObserver`, and resizes via `ResizeObserver` on the container. SSR-safe without a `<ClientOnly>` wrapper — `sphere.js` has no side-effectful imports and all DOM access lives inside `onMounted`, which Vue 3 never runs on the server.
|
||||
- **`SphereFrame` gaze-bias fields in `MorphingSphereCore.kt` (mirrored in `sphere.js`).** New `lightAngleBiasX`, `lightAngleBiasY`, `lightAngleBlend` (all default 0f / 0) let callers aim the sphere's bright spot at a specific direction without touching the sphere body. The light-angle computation blends between the natural `t * lightSpeedX + noise` rotation (`blend = 0`) and the caller-supplied bias (`blend = 1`). Defaults preserve byte-identical behavior for every existing caller — Android `MorphingSphere.kt` composable, the parity test, and the JS parity harness all stay green because they never set the new fields. First consumer: `SphereMark.vue` on the docs site, which uses the bias to make the sphere's eye track the reader's cursor without bouncing the canvas.
|
||||
- **`SphereFrame.shadowStrength`** (mirrored in `sphere.js`, default 0f / 0). Darkens `distBrightness` on the hemisphere facing away from the light, scaling it by `(1 − shadowStrength · (1 − directionalLight))` — the lit side is untouched, the shadow side dims proportionally. At 0 the legacy uniform "pearl" shading is preserved byte-for-byte. Docs-site `SphereMark.vue` uses 0.6 so the eye reads clearly against the unlit half of the sphere; Android composable doesn't set it and stays on legacy shading.
|
||||
- **`MorphingSphereCore.kt` — pure, platform-agnostic sphere algorithm.** Extracted from `MorphingSphere.kt` as the single source of truth for the sphere going forward. Uses only `kotlin.math` — no Android, no Compose, no `Paint` — so the same math can back a terminal TUI (Hermes CLI), the codename-11.dev user site, or a Compose Desktop port without visual drift between surfaces.
|
||||
- **`preview/web/` — zero-dep browser harness for the sphere.** `sphere.js` is a line-for-line JS mirror of `MorphingSphereCore.kt` (`Math.imul` + `|0` for Kotlin `Int` overflow, floored modulo for `.mod()`, `Math.trunc` for `.toInt()`). `index.html` exposes live panel controls for state / voice / layout (cols, rows, fill%, aspect, char size) + a `phone 9:16` preset matching Compose `@Preview(widthDp=360, heightDp=640)`. Serve via `python3 -m http.server --directory preview/web`.
|
||||
- **Runtime parity harness for the sphere.** `preview/web/parity-check.mjs` + JVM `MorphingSphereCoreParityTest` render the 8 Compose `@Preview` fixtures on both sides and emit FNV-1a 32-bit checksums. **8/8 structural checksums** (over discrete `(row, col, char)` tuples) and **8/8 zone histograms** match between JS and Kotlin; 6/8 full (color/alpha-inclusive) checksums match — the 2 voice-modulated fixtures drift at the 3rd decimal due to Float (Kotlin) vs Double (JS) precision in compound expressions, sub-perceptible.
|
||||
- **Multi-endpoint pairing QR** (ADR 24). A single pairing now carries an ordered list of endpoint candidates (`lan` / `tailscale` / `public` / operator-defined) so the same phone works seamlessly across LAN, Tailscale, and a public reverse-proxy URL. The phone picks the highest-priority reachable candidate at connect time and re-probes reachability on every `ConnectivityManager` network change with a 30s per-candidate cache. Strict-priority semantics — reachability only breaks ties among equal priorities, never promotes a lower priority over a higher one. New `plugin/pair.py` CLI flags `--mode {auto,lan,tailscale,public}` (default auto) and `--public-url <url>` drive candidate emission. See [`docs/remote-access.md`](docs/remote-access.md).
|
||||
- **First-class Tailscale helper** (ADR 25). New `plugin/relay/tailscale.py` + `hermes-relay-tailscale` CLI shim fronts the loopback-bound relay with `tailscale serve --bg --https=<port>` so the port is reachable over the tailnet with managed TLS + ACL-based identity. Safe to call unconditionally — no-ops with structured-dict failure when the `tailscale` binary is absent. `install.sh` gains an optional step [7/7] offering Tailscale enablement; skipped silently when the binary is missing, when `TS_DECLINE=1`, or under non-interactive shells without `TS_AUTO=1`. Auto-retires when upstream PR [#9295](https://github.com/NousResearch/hermes-agent/pull/9295) merges.
|
||||
- **Remote Access dashboard tab** (in the dashboard plugin). Operators can enable/disable the Tailscale helper, mint multi-endpoint pairing QRs, and inspect which endpoint modes are currently active — all from the hermes-agent web UI.
|
||||
- **Reachability probe + network-change re-probe** in the Android client. `ConnectionManager.resolveBestEndpoint()` does `HEAD /health` against each API candidate with a 2s timeout + 30s cache; `NetworkCallback.onAvailable` / `onLost` triggers a re-probe. `RelayUiState` gains `activeEndpointRole` so the UI can render which endpoint (LAN / Tailscale / Public) is currently serving.
|
||||
- **Opt-in terminal sessions.** Fresh terminal tabs no longer auto-attach — each tab shows a centered **Start session** overlay and spawns the tmux-backed shell only after the user taps it. Tabs that have already been started still auto-reattach on reconnect. Removes the previous behavior of creating persistent server-side shells just by opening the Terminal tab.
|
||||
- **`terminal.kill` envelope** — hard-destroy a session. The relay runs `tmux kill-session -t <name>` out-of-band before tearing down the PTY so the background shell (and any running commands) die with it. Closing a tab now opens a confirmation dialog with explicit **Detach** (preserve tmux session) vs **Kill** (destroy it) choices; the session info sheet also gains an error-tinted **Kill session** button.
|
||||
- **Touch-scroll + scrollback buttons for the terminal.** A vertical swipe on the terminal surface now moves xterm.js's scrollback (with a 12 px deadzone so long-press-to-select still works); the extras toolbar gains ⇑ / ⇓ / ⇲ buttons for ten-line scroll up, ten-line scroll down, and jump-to-bottom. Scrollback depth is unchanged at 10 000 lines.
|
||||
- **Friendly names for terminal tabs.** The session info sheet now has an inline rename field that persists a cosmetic name (up to 40 chars) keyed on the wire-side `session_name`. Names survive app restart and re-pair; cleared on Kill but preserved on Detach. The tab chip renders `1 · build` when named.
|
||||
- **`--prefer <role>` priority override** on every pair surface (`hermes-pair --prefer tailscale`, the `/hermes-relay-pair` skill, and the dashboard Remote Access tab's "Prefer role" dropdown). Open-vocab role string — promotes the named role to priority 0 with the rest renumbered in natural order. Unknown role emits a stderr warning and keeps the natural order. Case-insensitive matching; role string preserved verbatim for HMAC round-trip.
|
||||
- **Active-endpoint chip in the Chat top bar.** Compact tappable chip (e.g. "LAN" / "Tailscale" / "Public" / "Custom VPN (…)") rendered next to the ambient-mode button when the resolver has picked an endpoint. Tap jumps to the Connections screen so the user can probe / override / re-pair without leaving chat. Hidden for single-endpoint legacy pairings — the existing Settings row already spells the host out.
|
||||
- **Re-pair hint on single-endpoint connections.** When the active connection has exactly one endpoint (legacy single-URL pair), the Connections list card shows a tertiary-container info strip suggesting "Re-pair with Mode = Auto to get LAN + Tailscale + Public in one QR" with an inline Re-pair button. Silent when zero or ≥2 endpoints are stored.
|
||||
- **Tailscale Funnel auto-detect for the public candidate.** `plugin.relay.tailscale.funnel_url(port)` probes `tailscale serve status --json` for `AllowFunnel` flags and returns the `https://<hostname>/` URL when the relay port is funneled. `plugin/pair.py` `build_endpoint_candidates` calls it as a fallback whenever `mode=auto` or `mode=public` is picked without an explicit `--public-url` — removes the "pin the public URL on Remote Access tab" step when Funnel is already publishing. Soft-fail on every error path; missing CLI / non-funneled port / unparseable JSON all return None.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Install-command copy buttons stay pinned.** The copy buttons on the docs home's "Install in 30 seconds" commands used to scroll out of view with long one-liners because `.install-code` had both `position: relative` and `overflow-x: auto` — the button's absolute coordinates anchored to the scrolling content box, not the visible viewport. Split into `.install-code` (positioning context, no overflow) wrapping a new `.install-code-scroll` (padding + horizontal overflow). Button now overlays the code as a proper static copy affordance.
|
||||
- **Docs hero (mobile).** VitePress's default `.image-container` is a fixed 320×320 square on mobile (designed for round illustrations) with negative margins on `.image` that overlap `.main`. On a 9:16 phone-frame video this caused the frame to overflow the square and the text/CTAs to sit on top of the video. `custom.css` now overrides the container to `height: auto` and zeroes the negative margins below 960 px, and `HeroDemo.vue` swaps three breakpoint widths (280/240/200 px) for one `clamp(180px, 62vw, 280px)` rule with a `max-height: 70vh` safety rail so the frame can't dominate the fold on tall narrow viewports.
|
||||
- **`MorphingSphere.kt` is now a thin Compose renderer** that delegates all math to `MorphingSphereCore`. Public `@Composable` API is unchanged (same params, same defaults); call sites in `VoiceModeOverlay` and the chat empty state need no updates. Renderer also swapped legacy `android.graphics.Paint` + `Typeface` + `nativeCanvas.drawText` for Compose's `rememberTextMeasurer()` + `drawText`, dropping all `android.graphics.*` imports.
|
||||
- **Pairing QR now carries the `hermes: 3` schema when endpoints are emitted.** `plugin/pair.py` → `build_payload(endpoints=...)` bumps the version only when the `endpoints` array is present; pairs without endpoint candidates continue to emit `hermes: 2`. `canonicalize()` in `plugin/relay/qr_sign.py` preserves array order and role strings verbatim (no case/whitespace normalization) so HMAC signatures round-trip across Python / Kotlin.
|
||||
- **Paired Devices screen renders per-endpoint rows.** Each paired device now shows one row per `(device, endpoint)` pair, with a styled chip per role (LAN / Tailscale / Public / Custom VPN). Settings and Paired Devices both read from the new `PairingPreferences` per-device endpoint store.
|
||||
- **Terminal session info sheet is vertically scrollable** — tall phones in landscape with the new Start / Reattach / Kill action rows no longer clip the Done button.
|
||||
- **Connections list subtitle shows role names, not count.** Active card's subtitle was "hostname • Connected • LAN • 2 endpoints" — accurate but opaque (users couldn't tell which endpoints the QR carried without expanding). Now shows "hostname • Connected • Active: LAN • LAN + Public" — role set on display, not count. Non-active cards unchanged.
|
||||
- **Looser resolver probe timing.** Per-candidate HEAD `/health` timeout raised from 2s → 4s and cache TTL from 30s → 60s. ADR 24's 2s was tight enough that LTE hand-off and slow hotel Wi-Fi routinely got marked unreachable spuriously; 4s preserves fast-fail-on-real-outage while surviving the flaky-network case. NetworkCallback still invalidates the cache on real network changes, so the longer cache is functionally equivalent but saves battery.
|
||||
|
||||
### Backward compatible
|
||||
|
||||
- **Old v1 / v2 QRs keep parsing unchanged.** The Android parser's `ignoreUnknownKeys = true` plus the nullable `endpoints` field means pre-v3 QRs work on new phones (the phone synthesizes a single priority-0 `role: lan` candidate from the top-level fields, promoted to `role: tailscale` when the host matches `100.64.0.0/10` / `.ts.net`), and v3 QRs work on v0.6.x and earlier clients (they ignore `endpoints` and use the top-level fields). No forced re-pair for existing installs.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Profile `PUT` endpoints restored.** The ADR 24 commit collaterally deleted ~479 lines of `handle_profile_soul_put` / `handle_profile_memory_put` while adding multi-endpoint passthrough to the pairing handlers. `PUT /api/profiles/{name}/soul` and `PUT /api/profiles/{name}/memory/{filename}` are back at their canonical positions; atomic-write semantics and loopback-or-bearer auth unchanged.
|
||||
- **Stray terminal errors no longer poison the wrong tab.** Server-level error envelopes without a `session_name` (e.g. "Unknown terminal message type" from an older relay) previously fell through to the active tab and flashed an error overlay on whichever tab the user happened to be looking at. Errors without session scope now log only.
|
||||
- **Dashboard-minted QRs now show the correct 10-minute expiry.** `handle_pairing_mint` was returning `expires_at = now + 60` whenever the caller didn't pin a session TTL (every dashboard mint), which conflated the pairing-code window with the future session's lifetime and made the dashboard dialog count down from ~1 minute even though the underlying code was valid for 10. Now stamps `expires_at = now + _PAIRING_CODE_TTL` explicitly — the pairing-code TTL is what the UI cares about. Session TTL continues to ride the QR payload's `ttl_seconds` field for the phone's TTL picker.
|
||||
- **PairDialog: multi-endpoint aware, Authelia-trap guardrail.** The dashboard Management tab's "Pair new device" button was still minting legacy single-endpoint QRs (no `endpoints[]`, no `mode`, no `prefer`) while the Remote Access tab had been on the modern path for months. Swapped to `mintPairingWithMode` with `Mode` + `Prefer role` dropdowns as primary inputs; the legacy host/port/tls fields moved under a collapsed "Advanced · API-server override" section with a warning that triggers when the typed host looks like a forward-auth-gated FQDN (the root cause of "relay pairs but phone drops config" reports: e.g. `wss://hermes.example.com` fronted by Authelia gets pinned into the QR's API block, relay WSS succeeds over LAN, then API probes return 401 and the wizard cleans up). Modal widened from `max-w-md` to `max-w-xl` to fit the endpoints receipt without horizontal scroll.
|
||||
- **PairDialog: proxy-fronted override now requires explicit consent.** Previously the Advanced warning was purely informational — the dialog still auto-minted a QR the phone would fail to use. Now the auto-mint is gated: when the pinned host matches the proxy-fronted heuristic, the dialog pauses and shows "Mint anyway / Clear override" instead of proceeding. Consent is per-host — changing the host resets `proxyConfirmed` so a new host triggers a fresh confirm step.
|
||||
|
||||
## [0.6.0] — 2026-04-18
|
||||
|
||||
### Added
|
||||
|
||||
- **Pair with multiple Hermes servers** and switch in one tap. A new Connection chip on the left of the Chat top bar opens a switcher sheet with a health indicator for each paired server — tap one to cancel in-flight chat, disconnect the old relay, rebind to the new server, and reload sessions + personalities + profiles. The chip is hidden automatically when you only have one Connection. Existing single-server installs migrate transparently on first launch of this version — zero re-pair, zero token migration. See `docs/decisions.md` §19.
|
||||
- **Connections management screen** at Settings → Connections. Each paired server is a card with inline rename, re-pair (reuses the QR onboarding flow), revoke, and remove. Add a new Connection from the same screen. Per-connection state kept separate: sessions, memory, personalities, skills, profiles, relay URL + cert pin, voice endpoints, last-active session. Theme, bridge safety preferences, and TOFU cert-pin map stay global.
|
||||
- **Agent Profiles** — the relay now auto-discovers upstream Hermes profiles by scanning `~/.hermes/profiles/*/` (plus a synthetic "default" for the root config) and advertises them in the `auth.ok` payload. On chat send with a profile selected, the phone overlays the request's `model` and `system_message` with the profile's `model.default` + `SOUL.md`. Selection is ephemeral and clears on Connection switch. Gated by `RELAY_PROFILE_DISCOVERY_ENABLED=1` (default on) — operators can set it to `false` to keep the picker empty. See `docs/decisions.md` §21.
|
||||
- **Consolidated agent sheet** on the Chat top bar. Tap the agent name in the middle of the top bar to open a scrollable bottom sheet holding Profile selection, Personality selection, and session info + analytics (message count, tokens in/out, avg TTFT). Replaces the separate top-bar chips from intermediate v0.5.x builds. Toast confirmations fire on Profile and Personality switches.
|
||||
- **"Active agent" card** at the top of Settings — summarizes the current Connection / Profile / Personality. Tap navigates to Chat with the agent sheet auto-opened via the `openAgentSheet` nav arg, giving Settings-originating users a one-tap path to change agent context.
|
||||
- **Three-layer agent model** formalized: Connection (server) → Profile (agent directory) → Personality (system-prompt preset). Documented in `docs/spec.md`, `docs/decisions.md` §8 / §19 / §21, and `user-docs/features/{connections,profiles,personalities}.md`.
|
||||
- **Pair wizard URL scheme cross-validation** — an inline hint fires when the API field is given a `wss://` URL (or any obviously-wrong scheme), so misplaced values surface before the pair attempt instead of after.
|
||||
- **Pair-stamp on the active Connection** — successful auth now stamps the active Connection's pairing metadata (paired-at, transport hint, expiry) in place, so a re-pair from Settings doesn't leave stale state on the card.
|
||||
- **Live WSS state on the active Connection row** in the Connections list — the active card now reflects Connected / Reconnecting… / Stale in real time instead of a static "Paired N minutes ago" timestamp. A Stale state also surfaces an inline **Reconnect** action button (promoted above Rename) tinted to signal "attention."
|
||||
- **Reconnect taps get explicit feedback.** Every Stale-recovery affordance (the Relay row, the Reconnect button in Connection Settings, and the new Reconnect action in the Connections list) now shows a snackbar / toast "Reconnecting to relay…" so users know the tap registered even during the sub-second before the row flips to Connecting.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Unified relay status across screens.** `SettingsScreen`, `ConnectionSettingsScreen`, and the Connections list used to resolve relay status independently (each with its own ad-hoc stale / auto-reconnect / probing combinator), which let them disagree on what state the relay was in — e.g. the Settings card said **Disconnected** red while the Connection sub-screen said **Reconnecting…** amber for the same moment. State resolution now lives on `ConnectionViewModel.relayUiState: StateFlow<RelayUiState>` with five well-defined cases (`NotConfigured` / `Connected` / `Connecting` / `Stale` / `Disconnected`) and a 5 s grace window before a Paired-but-Disconnected pose is promoted to `Stale` — every screen maps the single source of truth onto the existing `ConnectionStatusRow` API.
|
||||
- **Settings "Connection" card → "Active Connection".** Title renamed, and the current Connection's label now renders as the card subtitle so installs with multiple servers can see at a glance which one the status rows describe. Fresh `reconnectIfStale()` tick on first compose so the Relay row doesn't flash red before the lifecycle observer's resume path lands.
|
||||
- **Status-badge UX polish.** `ConnectionStatusBadge` top-aligns cleanly on multi-line rows (was vertically centered and drifted off-center when the label wrapped). The Settings screen now treats a paired Connection with a briefly-down relay as **Connecting** (amber) instead of **Disconnected** (red) — avoids scare-red during the few seconds around a relay restart.
|
||||
- **Top-bar chip layout.** `ProfilePicker.kt` and `PersonalityPicker.kt` as standalone top-bar chips are gone; their selection now lives inside the consolidated agent sheet.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`POST /pairing/mint` emits the correct wire format.** Dashboard-minted QRs were unscannable — the relay endpoint put the freshly-minted pairing code in top-level `key` and defaulted the top-level port to the relay's own `8767` (its `server.config.port`) instead of the Hermes API server's `8642`. The Android scanner reads top-level `host:port` as the **API** server URL and expects the minted code inside `relay.code`, so phones saw `serverUrl=http://host:8767` (wrong port, no API reachable) and an empty `relay` block — `applyServerIssuedCodeAndReset` bailed on the empty code and the WSS never handshook. Silent fail. The `hermes-pair` CLI and `/hermes-relay-pair` skill were unaffected because they go through `pair.py`'s CLI path which builds the payload correctly; only the dashboard's "Pair new device" flow hit the bug. `handle_pairing_mint` now mirrors `pair.py:762` — top-level `host/port/key/tls` default from `RelayConfig.webapi_url` (resolved to a LAN-routable IP via `_resolve_lan_ip`) with `host`/`port`/`tls`/`api_key` body overrides, and the `relay` block carries `url` from `_relay_lan_base_url(server.config.host, server.config.port, ...)` plus the minted `code`. Shape now matches `docs/spec.md` §3.3.1 and `QrPairingScanner.kt`. Regression test at `plugin/tests/test_pairing_mint_schema.py` (8 cases) pins the payload shape against what the Android parser expects so the two sides can't drift silently again.
|
||||
- **Dashboard Relay Management tab no longer crashes on paired-session list.** `RelayManagement.jsx:172` wrapped a dict-shaped `s.grants` (`{chat, terminal, bridge}`) in a 1-element array and rendered each entry as a React child, tripping minified React error #31 ("objects are not valid as a React child"). Now uses `Object.keys(s.grants)` when the value is dict-shaped so Badge children are always strings; existing array path preserved for future callers. Rebuilt bundle at `plugin/dashboard/dist/index.js` — the hermes-agent dashboard loads that file verbatim so source changes require a rebuild.
|
||||
|
||||
### Deferred
|
||||
|
||||
- True per-profile isolation on a single Connection (memory + sessions + `.env` shared today; use separate Connections for full isolation).
|
||||
- Persisted Profile selection per Connection across app restarts.
|
||||
- Gateway-running probe (hermes-desktop-inspired) on the Connection health indicator.
|
||||
|
||||
## [0.5.x] — Unreleased feature work
|
||||
|
||||
### Added — Voice silence auto-stop (2026-04-18)
|
||||
|
||||
- **Silence-based auto-stop for Listening turns.** `VoiceViewModel.startListening()`
|
||||
@@ -31,6 +331,108 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
— the FrozenList is still mutable at middleware-install time. 31/31
|
||||
tests in `test_command_middleware.py` pass.
|
||||
|
||||
### Added — Dashboard plugin
|
||||
|
||||
- **Hermes-agent dashboard plugin** at `plugin/dashboard/` — surfaces
|
||||
relay state in the gateway's web UI via four tabs. **Relay
|
||||
Management** lists paired devices + health + Server version;
|
||||
**Bridge Activity** renders the in-memory ring buffer of recent
|
||||
bridge commands (method / path / decision, with safety-rail
|
||||
`executed` / `blocked` / `confirmed` / `timeout` / `error`
|
||||
filters); **Push Console** ships as a stub with an
|
||||
"FCM not configured" banner until FCM lands; **Media Inspector**
|
||||
lists active `MediaRegistry` tokens with live TTL countdowns and
|
||||
basename-only file names (absolute paths never leave the server).
|
||||
Frontend is a pre-built React IIFE at `plugin/dashboard/dist/index.js`
|
||||
(~16 KB) loaded verbatim by the dashboard shell; backend is a thin
|
||||
FastAPI proxy at `plugin/dashboard/plugin_api.py` mounted at
|
||||
`/api/plugins/hermes-relay/*`.
|
||||
- **Three new loopback-only relay routes** feeding the plugin —
|
||||
`GET /bridge/activity` (ring buffer; `?limit=N`, max 500),
|
||||
`GET /media/inspect` (token list; `?include_expired=true` to
|
||||
include evicted entries), and `GET /relay/info` (aggregate
|
||||
`{version, uptime_seconds, session_count, paired_device_count,
|
||||
pending_commands, media_entry_count, health}`). Plus a
|
||||
loopback-exempt branch on the existing `GET /sessions` so the
|
||||
plugin proxy doesn't need to mint a bearer.
|
||||
- **`BridgeCommandRecord` ring buffer** on `BridgeHandler`
|
||||
(`deque(maxlen=100)`) — records `request_id`, `method`, `path`,
|
||||
redacted `params`, `sent_at`, `response_status`, `result_summary`,
|
||||
`error`, and `decision`. Commit `777a06a` wires append/update into
|
||||
`handle_command()` / `handle_response()` without changing external
|
||||
behaviour; timeouts flip `decision=timeout`, phone-side safety
|
||||
denials flip `blocked`. Params are redacted for keys in
|
||||
`{password, token, secret, otp, bearer}`.
|
||||
- **`MediaRegistry.list_all(include_expired=False)`** — lock-guarded
|
||||
snapshot method returning `{token, file_name, content_type, size,
|
||||
created_at, expires_at, last_accessed, is_expired}` dicts sorted
|
||||
newest-first. Absolute paths are never included. Commit `2212fbc`.
|
||||
- **Pairing workflow from the dashboard** — new `POST /pairing/mint`
|
||||
relay route (loopback-only) generates a random 6-char A-Z/0-9 code,
|
||||
registers it with the existing `PairingManager`, and returns the
|
||||
signed QR payload built via `plugin.pair.build_payload`. The
|
||||
dashboard backend exposes it at
|
||||
`POST /api/plugins/hermes-relay/pairing`. A new **PairDialog** in
|
||||
the Management tab renders the QR (via the `qrcode` npm lib bundled
|
||||
into the IIFE), shows the code + expiry countdown, and lets the
|
||||
operator **override Host / Port / TLS** in the QR payload — useful
|
||||
for Traefik-fronted deploys where the phone needs
|
||||
`wss://relay.example.com:443` even when the dashboard itself is
|
||||
served at a different hostname. Settings persist per-browser in
|
||||
localStorage.
|
||||
- **Functional session revocation** — loopback-exempt branch on
|
||||
`DELETE /sessions/{token_prefix}` plus a proxy route at
|
||||
`DELETE /api/plugins/hermes-relay/sessions/{prefix}`. The Revoke
|
||||
button on the Management tab now confirms via native dialog, calls
|
||||
the proxy, and auto-reloads the session list on success.
|
||||
|
||||
### Added — Installer
|
||||
|
||||
- **`--dashboard-plugin=yes|no`** flag on `install.sh` (default `yes`;
|
||||
also via `HERMES_RELAY_DASHBOARD_PLUGIN` env var). Passing `no`
|
||||
renames `plugin/dashboard/manifest.json` → `manifest.json.disabled`
|
||||
so the hermes-agent dashboard loader skips the plugin entirely.
|
||||
Re-running with the opposite flag flips it back — no config lives
|
||||
anywhere else.
|
||||
- **Live dashboard rescan** in both `install.sh` and `uninstall.sh` —
|
||||
parses `hermes-dashboard.service` ExecStart for `--host` / `--port`
|
||||
and GETs `/api/dashboard/plugins/rescan`, falling back to loopback
|
||||
and common ports. The relay tab appears/disappears without a
|
||||
dashboard restart. Silent no-op when the dashboard isn't running.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Dashboard plugin UI** uses plain tab buttons instead of Radix
|
||||
`<Tabs>`: Radix's `Tabs` container expects `TabsContent` children
|
||||
(not exposed in the SDK whitelist) and its internal context blew
|
||||
up at first render as `o is not a function` after minification.
|
||||
- **Install banner** no longer claims "Phase 3 — Bridge channel +
|
||||
status tool" (stale since v0.2.x). Phase-agnostic copy now.
|
||||
|
||||
### Added — Sideload in-app update check
|
||||
|
||||
- **In-app update banner** on the `sideload` flavor. On cold start (at
|
||||
most once every 6h) the app queries the GitHub `releases/latest`
|
||||
endpoint and, if it's behind, shows a slim `UpdateBanner` at the
|
||||
top of the scaffold with the current and latest versions. Tap
|
||||
**Update** → opens the `-sideload-release.apk` asset URL directly in
|
||||
the browser; Android's DownloadManager fetches it and hands it to
|
||||
the OS installer. Tap the **X** to dismiss for this version — the
|
||||
banner reappears automatically on the next release.
|
||||
- **"Updates" row in About → About** card — manual "Check" button
|
||||
with the same plumbing. After a successful check shows either
|
||||
"You're on the latest release" or "Update available — v0.x.y" with
|
||||
a **Download** CTA. The row is hidden on the `googlePlay` flavor
|
||||
(Play Store owns update delivery there).
|
||||
- **No new permissions** — the app never installs APKs itself; it
|
||||
only opens the asset URL via `ACTION_VIEW`. The Android download +
|
||||
install path is unchanged from what sideload users already use.
|
||||
- Files: `update/UpdateChecker.kt`, `UpdatePreferences.kt`,
|
||||
`UpdateModels.kt`, `SemverCompare.kt`;
|
||||
`viewmodel/UpdateViewModel.kt`;
|
||||
`ui/components/UpdateBanner.kt`;
|
||||
wire-up in `ui/RelayApp.kt` + `ui/screens/AboutScreen.kt`.
|
||||
|
||||
### Added — v0.4.1 Bridge page polish pass
|
||||
|
||||
- **`UnattendedGlobalBanner`** — thin 28dp amber strip at the top of
|
||||
@@ -707,7 +1109,7 @@ picker.
|
||||
- **Stats for Nerds enhancements** — reset button, tokens per message average, peak TTFT, slowest completion, seconds subtext on all ms values
|
||||
- **Feature gating** — `FeatureFlags` singleton with compile-time defaults (`BuildConfig.DEV_MODE`) and runtime DataStore overrides
|
||||
- **Developer Options** — hidden settings section, tap version 7 times to unlock (same pattern as Android system Developer Options)
|
||||
- **Relay feature toggle** — relay server settings and pairing sections gated behind developer options in release builds
|
||||
- **Relay feature toggle** — Server settings and pairing sections gated behind developer options in release builds
|
||||
- **Dynamic onboarding** — terminal, bridge, and relay pages excluded from onboarding when relay feature disabled
|
||||
- **Parse tool annotations** — experimental annotation parsing for Sessions mode (marked with badge, disabled for Runs mode)
|
||||
- **Privacy policy link** — accessible from Settings → About
|
||||
@@ -741,7 +1143,7 @@ MVP release — native Android companion app for Hermes agent with direct API ch
|
||||
#### Core Chat
|
||||
- **Direct API chat** — connects to Hermes API Server via `/api/sessions/{id}/chat/stream` with SSE streaming
|
||||
- **HermesApiClient** — full session CRUD + SSE streaming, health checks, cancel support
|
||||
- **Dual connection model** — API Server (HTTP) for chat, Relay Server (WSS) for bridge/terminal
|
||||
- **Dual connection model** — API Server (HTTP) for chat, Server (WSS) for bridge/terminal
|
||||
- **API key auth** — optional Bearer token stored in EncryptedSharedPreferences
|
||||
- **Cancel streaming** — stop button to cancel in-flight chat responses
|
||||
- **Error retry** — retry button in error banner re-sends last failed message
|
||||
@@ -771,20 +1173,22 @@ MVP release — native Android companion app for Hermes agent with direct API ch
|
||||
- **Auth flow** — 6-character pairing code with session token persistence
|
||||
- **Material 3 + Material You** — dynamic theming with light/dark/auto
|
||||
- **Onboarding** — multi-page pager with feature overview and connection setup
|
||||
- **Settings** — API Server + Relay Server config, theme, reasoning toggle, data export/import/reset
|
||||
- **Settings** — API Server + Server config, theme, reasoning toggle, data export/import/reset
|
||||
- **Offline detection** — banner shown when network connectivity is lost
|
||||
- **What's New dialog** — shown automatically when app version changes
|
||||
- **Splash screen** — branded splash via core-splashscreen API
|
||||
- **Network security** — cleartext restricted to localhost only
|
||||
|
||||
#### Infrastructure
|
||||
- **Relay server** — Python aiohttp WSS server for bridge/terminal channels
|
||||
- **Server** — Python aiohttp WSS server for bridge/terminal channels
|
||||
- **CI/CD** — GitHub Actions for lint, build, test, and tag-driven releases
|
||||
- **Claude Code automation** — issue triage, PR fix, chat, and code review workflows
|
||||
- **Dependabot** — weekly Gradle + GitHub Actions dependency updates with auto-merge
|
||||
- **Dev scripts** — build, install, run, test, relay via scripts/dev.bat
|
||||
- **ProGuard rules** — okhttp-sse, markdown renderer, intellij-markdown parser
|
||||
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/v0.1.0...HEAD
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...HEAD
|
||||
[0.8.0]: https://github.com/Codename-11/hermes-relay/compare/v0.7.0...android-v0.8.0
|
||||
[0.7.0]: https://github.com/Codename-11/hermes-relay/compare/v0.6.1...v0.7.0
|
||||
[0.1.0]: https://github.com/Codename-11/hermes-relay/compare/v0.1.0-beta...v0.1.0
|
||||
[0.1.0-beta]: https://github.com/Codename-11/hermes-relay/releases/tag/v0.1.0-beta
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
A native Android app (Kotlin + Jetpack Compose) paired with a Python relay server (aiohttp) for the Hermes agent platform. Chat connects directly to the Hermes API Server via HTTP/SSE; bridge and terminal use a relay over WSS.
|
||||
|
||||
**Current state:** v0.4.x — Phase 0–3 complete. Direct API chat, session management, pairing + security, inbound media, voice mode, bridge/accessibility control, notification companion, and safety rails. Two product flavors: `googlePlay` (conservative) and `sideload` (full-capability).
|
||||
**Current state:** v0.8.0 (release-prep on `dev`) — Phase 0–3 complete. Direct API chat, session management, pairing + security (now multi-endpoint, ADR 24), inbound media, voice mode (stable Hermes Chat + Voice Output plus opt-in provider-native Realtime Agent with reliable low-latency playback and a text/mic Voice Lab), bridge/accessibility control, notification companion, safety rails, multi-Connection, agent profiles + inspector, connection diagnostics, and first-class Tailscale (ADR 25). Two product flavors: `googlePlay` (conservative, Bridge Core without Device Control) and `sideload` (full-capability).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -31,29 +31,35 @@ Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is
|
||||
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
|
||||
| `GET /v1/models` | List available models | — |
|
||||
| `GET /health` | Health check | — |
|
||||
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management | — |
|
||||
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management (api_server surface) | — |
|
||||
|
||||
**Non-standard endpoints (provided by fork OR by plugin bootstrap):**
|
||||
**Baseline upstream endpoints vs compatibility endpoints:**
|
||||
|
||||
These endpoints are not in stock upstream `gateway/platforms/api_server.py`. There are three ways a hermes-agent install can serve them:
|
||||
Upstream hermes-agent now has a native baseline for API Server session control and skill/toolset discovery:
|
||||
|
||||
1. **Codename-11 fork** (`feat/session-api` branch, deployed on the `axiom` branch) — adds them natively. Submitted upstream as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) *"feat(api-server): add session management API for frontend clients"* — scope is broader than the title: sessions CRUD + session chat/stream + memory + skills + config + available-models.
|
||||
2. **Bootstrap injection** (`hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file. Does NOT inject `/api/sessions/{id}/chat/stream` — use `/v1/runs` for chat.
|
||||
3. **Upstream-merged** (post PR #8556) — bootstrap auto-detects and no-ops.
|
||||
1. **Native upstream** — commit [`f7527b0`](https://github.com/NousResearch/hermes-agent/commit/f7527b0fdb54f01691547df03fc65a6d367f9fde), merged via PR [#33134](https://github.com/NousResearch/hermes-agent/pull/33134), salvaged the focused session-control work from closed PR [#29302](https://github.com/NousResearch/hermes-agent/pull/29302). It provides `/api/sessions/*`, session chat/stream, fork/messages, plus `/v1/skills` and `/v1/toolsets`.
|
||||
2. **Codename-11 `axiom` fork** — still carries compatibility/client-metadata routes that upstream does not provide yet: `/api/sessions/search`, `/api/memory`, `/api/skills` detail routes, `/api/config`, and `/api/available-models`.
|
||||
3. **Bootstrap injection** (`hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file and injects only missing compatibility routes for older or partial upstream builds. It should remain per-route/per-feature, not all-or-nothing.
|
||||
|
||||
| Endpoint | Purpose | Provided by |
|
||||
|----------|---------|-------------|
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/sessions/search` | Full-text message search | Fork OR bootstrap OR upstream-merged |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Fork OR upstream-merged ONLY (NOT bootstrap) |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/skills`, `/categories`, `/{name}` | Skill discovery | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/available-models` | Provider model list | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream OR fork OR bootstrap |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream OR fork OR bootstrap |
|
||||
| `GET /api/sessions/search` | Full-text message search | Fork OR bootstrap only |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Native upstream OR fork only (NOT bootstrap) |
|
||||
| `GET /v1/skills` | Skill list metadata | Native upstream OR fork |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Fork OR bootstrap only |
|
||||
| `GET /api/skills`, `/{name}` | Legacy skill discovery/detail routes | Fork OR bootstrap only; Android prefers `/v1/skills` first |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; mirrored into bootstrap |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Fork OR bootstrap only |
|
||||
| `GET /api/available-models` | Provider-aware model list | Fork OR bootstrap only |
|
||||
|
||||
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions` or `runs` based on the capability snapshot.
|
||||
|
||||
**Dashboard web server (separate surface — loopback-only):**
|
||||
|
||||
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info`, `/api/logs`, `/api/analytics/usage`. Auth is a page-injected `window.__HERMES_SESSION_TOKEN__` — loopback-only, no external issuance. **Do not proxy this surface over the relay.** Phone consumes the narrower, fork/bootstrap `api_server.py` surface or relay-native profile-scoped endpoints.
|
||||
|
||||
**Tool call rendering paths:**
|
||||
1. **Runs API** — Emits `tool.started`/`tool.completed` as real SSE events → `ToolProgressCard` in real-time.
|
||||
2. **Sessions API** — No structured tool events during streaming; reloads message history on stream complete ("session_end reload" pattern).
|
||||
@@ -62,7 +68,7 @@ The Android client probes per-endpoint capability via `HermesApiClient.probeCapa
|
||||
## Key Instructions
|
||||
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document whether bootstrap injects it or it requires the fork.
|
||||
- If we use a non-standard endpoint, ensure `probeCapabilities()` covers it and the auto-resolver degrades gracefully.
|
||||
- **Bootstrap maintenance:** Remove `hermes_relay_bootstrap/` in one PR once PR #8556 merges. It's no-op-compatible, so leaving it in place during rollout is harmless.
|
||||
- **Bootstrap maintenance:** Do not remove `hermes_relay_bootstrap/` just because upstream has native sessions. It can start shrinking only after each Relay-consuming compatibility route has a native replacement or the Android/Desktop clients have migrated away from it.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
@@ -79,13 +85,30 @@ hermes-android/
|
||||
│ ├── accessibility/ # HermesAccessibilityService, ScreenReader, ActionExecutor
|
||||
│ ├── bridge/ # BridgeSafetyManager, BridgeForegroundService, BridgeStatusOverlay
|
||||
│ └── notifications/ # HermesNotificationCompanion
|
||||
├── desktop/ ← Node thin-client CLI (`@hermes-relay/cli`)
|
||||
│ ├── bin/hermes-relay.js # #!/usr/bin/env node shim → dist/cli.js
|
||||
│ ├── src/
|
||||
│ │ ├── cli.ts # argv parser + subcommand dispatcher (bare → shell)
|
||||
│ │ ├── commands/ # chat, shell, pair, status, tools, devices
|
||||
│ │ ├── banner.ts # contextual connect line (LAN / Tailscale / Plain / Secure)
|
||||
│ │ ├── renderer.ts # GatewayEvent → plain-line stdout formatter (chat only)
|
||||
│ │ ├── endpoint.ts # ADR 24 EndpointCandidate + role helpers
|
||||
│ │ ├── pairingQr.ts # v3 QR decode + priority-raced reachability probe
|
||||
│ │ ├── pairing.ts # readline 6-char prompt + payload validator
|
||||
│ │ ├── credentials.ts # token → pair-qr → code → stored → prompt precedence
|
||||
│ │ ├── certPin.ts # TOFU SPKI sha256 extract / pinKey / compare
|
||||
│ │ ├── tools/ # desktop.command router + fs/terminal/search handlers + consent
|
||||
│ │ ├── transport/ # RelayTransport (reconnect state machine + TLS probe TOFU)
|
||||
│ │ └── lib/ # gracefulExit, rpc, circularBuffer (vendored)
|
||||
│ └── scripts/ # install.sh + install.ps1 curl/iwr one-liners
|
||||
├── plugin/ ← Hermes agent plugin
|
||||
│ ├── android_tool.py # 18 android_* tool handlers
|
||||
│ ├── pair.py # QR pairing implementation
|
||||
│ ├── relay/ # Canonical WSS relay (server.py, auth.py, channels/, media.py, voice.py)
|
||||
│ └── tools/ # android_navigate.py, android_notifications.py
|
||||
│ ├── tools/ # android_navigate.py, android_notifications.py
|
||||
│ └── dashboard/ # hermes-agent dashboard plugin — manifest, React UI, FastAPI proxy
|
||||
├── relay_server/ ← Thin compat shim → plugin.relay (legacy entrypoint)
|
||||
├── hermes_relay_bootstrap/ ← Runtime patch for vanilla upstream; removable after PR #8556
|
||||
├── hermes_relay_bootstrap/ ← Runtime patch for vanilla/partial upstream compatibility routes
|
||||
├── skills/devops/hermes-relay-pair/ ← /hermes-relay-pair slash command
|
||||
├── scripts/ ← dev.bat, bridge-smoke.sh, bump-version.sh
|
||||
└── docs/ ← spec, decisions, security, relay-server, mcp-tooling
|
||||
@@ -108,6 +131,13 @@ hermes-android/
|
||||
- **applicationId:** `com.axiomlabs.hermesrelay` (googlePlay), `com.axiomlabs.hermesrelay.sideload` (sideload)
|
||||
- **Min SDK 26, Target SDK 35, Compile SDK 36** / **Kotlin 2.0+**, JVM toolchain 17
|
||||
|
||||
### Code Style — Desktop CLI (Node/TypeScript)
|
||||
- **Node ≥21** — uses built-in global `WebSocket` (no `ws`/`undici` dep). Strict TS, ES modules, `NodeNext` resolution.
|
||||
- **Zero runtime deps** — `@types/node` + `tsx`/`rimraf`/`typescript` are devDeps only. Ship compiled `dist/`, not tsx.
|
||||
- **One binary, subcommands** — idiomatic for Node CLIs (codex, continue, vite pattern). Bare invocation is `chat`.
|
||||
- **Vendor-for-now** — transport/gateway/types are copied verbatim from `hermes-agent-tui-smoke/ui-tui/src/` with a header note. Extract to a shared package when the TUI and CLI stabilize.
|
||||
- **Dev loop:** `npx tsx src/cli.ts <args>` (no rebuild). `npm run build` + `npm link` before pushing to verify the bin shim. Never ship tsx in the published tarball — pre-build with `tsc` so Windows `npm install -g` can cmd-shim the JS directly.
|
||||
|
||||
### Code Style — Server (Python)
|
||||
- **aiohttp** — async, matches existing Hermes relay patterns
|
||||
- **Type hints everywhere** — Python 3.11+ syntax
|
||||
@@ -115,15 +145,17 @@ hermes-android/
|
||||
|
||||
### Git
|
||||
- **Conventional Commits:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
|
||||
- **Feature branches** as of 2026-04-13. Straight-to-main for single-file typos only.
|
||||
- **Merge style:** `git merge --no-ff` — no squash. Preserves per-commit trail for agent-team branches.
|
||||
- **Version bumps on `main` only.** Use `bash scripts/bump-version.sh <new-version>` to bump all three sources atomically (`gradle/libs.versions.toml`, `pyproject.toml`, `plugin/relay/__init__.py`).
|
||||
- **Branch protection** on `main` since 0.3.0 — PRs must pass CI; direct push blocked except `release: vX.Y.Z`.
|
||||
- **Branching model (as of 2026-04-19):** `main` + `dev`. Feature branches target `dev`, not `main`. `main` receives only release merges (and tags). No straight-to-main exemption — even single-file typos go through `dev`.
|
||||
- **Merge style:** `git merge --no-ff` — no squash. Preserves per-commit trail for agent-team branches on every merge in the chain (feature → dev → main).
|
||||
- **Merging ≠ releasing.** Feature branches land on `dev` continuously as CI goes green; each PR appends to `[Unreleased]` in `CHANGELOG.md` on `dev`. Releases are a separate act — cut when accumulated state is worth shipping, not per-feature. See `RELEASE.md` "When to cut a release."
|
||||
- **Version bumps happen on `dev`, then release-merge to `main`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-server-version.sh` for `server-vX.Y.Z`, and `desktop/package.json` for `desktop-vX.Y.Z`. The release commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the surface tag is cut from `main`.
|
||||
- **Server tracks `dev` for staging.** The hermes-host deployment pulls `dev` so merged features are exercised before they reach a tag. Released state lives on tags cut from `main`.
|
||||
- **Branch protection** on `main` — direct push blocked; only release-merge PRs from `dev` land here. `dev` also requires CI to pass on PRs but accepts feature-branch merges freely.
|
||||
|
||||
### Testing
|
||||
- **Android:** JUnit + Compose testing for UI, MockK for mocks
|
||||
- **Python:** `python -m unittest plugin.tests.test_<name>` — avoid bare `pytest` (conftest imports `responses` which may not be installed in the venv)
|
||||
- **CI runs on every push** — build must pass before merge
|
||||
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-server.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
|
||||
|
||||
## Key Files
|
||||
|
||||
@@ -136,7 +168,8 @@ hermes-android/
|
||||
| **App — Core** | |
|
||||
| `ui/RelayApp.kt` | Main scaffold — bottom nav, Compose navigation |
|
||||
| `viewmodel/ChatViewModel.kt` | Chat orchestration — send, stream, cancel, slash commands |
|
||||
| `viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay); `resolveStreamingEndpoint()` |
|
||||
| `viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay); `resolveStreamingEndpoint()`; derived `relayUiState` flow + `markPaired` hook stamp the active Connection |
|
||||
| `viewmodel/RelayUiState.kt` | Shared sealed state for the relay row — 5 cases + `asBadgeState()` / `statusText()` extensions; 5s grace window before Stale |
|
||||
| `network/HermesApiClient.kt` | Direct HTTP/SSE — `sendRunStream()`, `sendChatStream()`, `probeCapabilities()` |
|
||||
| `network/ConnectionManager.kt` | WSS to relay with auto-reconnect; rebuilds OkHttpClient with fresh CertPinner on connect |
|
||||
| `network/ChannelMultiplexer.kt` | Envelope routing by channel; `sendNotification()` for notification outbound |
|
||||
@@ -148,6 +181,7 @@ hermes-android/
|
||||
| `auth/SessionTokenStore.kt` | Keystore (StrongBox) + EncryptedSharedPrefs fallback; lossless migration on upgrade |
|
||||
| `auth/CertPinStore.kt` | TOFU cert pinning — SHA-256 SPKI per host:port in DataStore |
|
||||
| `auth/PairedSession.kt` | PairedSession state + PairedDeviceInfo wire model |
|
||||
| `data/Endpoint.kt` | `EndpointCandidate` / `ApiEndpoint` / `RelayEndpoint` — multi-endpoint pairing (ADR 24); `displayLabel()` for LAN/Tailscale/Public/Custom chips |
|
||||
| `network/RelayHttpClient.kt` | OkHttp for /media, /sessions (list/revoke/extend), /health |
|
||||
| **App — Bridge** | |
|
||||
| `network/handlers/BridgeCommandHandler.kt` | Routes `bridge.command` → ActionExecutor; full path inventory + safety-rail integration |
|
||||
@@ -164,24 +198,32 @@ hermes-android/
|
||||
| **App — Voice** | |
|
||||
| `voice/VoiceViewModel.kt` | Voice turn state machine; TTS queue; `ignoreAssistantId`; `errorEvents: SharedFlow` |
|
||||
| `audio/VoiceRecorder.kt` | MediaRecorder wrapper; perceptual amplitude curve; `.m4a` at 16kHz/64kbps |
|
||||
| `audio/VoicePlayer.kt` | MediaPlayer + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine |
|
||||
| `audio/VoicePlayer.kt` | Media3 ExoPlayer (gapless TTS queue) + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine; `audioSessionId` is a thread-safe `@Volatile` cache |
|
||||
| `network/RelayVoiceClient.kt` | OkHttp for `/voice/transcribe`, `/synthesize`, `/config` |
|
||||
| `voice/VoiceBridgeIntentHandler.kt` | Interface routing voice utterances to bridge; impls per flavor via factory |
|
||||
| `voice/VoiceIntentClassifier.kt` | Regex phone-control classifier (sideload only); false-negatives preferred over false-positives |
|
||||
| `ui/components/VoiceModeOverlay.kt` | Full-screen voice UI — MorphingSphere + VoiceWaveform + mic button |
|
||||
| `ui/components/MorphingSphere.kt` | Compose renderer for the agent sphere — delegates math to `MorphingSphereCore` |
|
||||
| `ui/components/MorphingSphereCore.kt` | Platform-agnostic sphere algorithm (`kotlin.math` only) — single source of truth; mirrored byte-for-byte in `preview/web/sphere.js` |
|
||||
| `preview/web/` | Zero-dep browser harness — live `index.html` preview + `parity-check.mjs`; paired with `MorphingSphereCoreParityTest` (JVM) for struct/full checksum diffing |
|
||||
| `user-docs/.vitepress/theme/components/SphereMark.vue` | Docs-site sphere embed — imports `preview/web/sphere.js` directly; autonomous fbm drift + pointer-proximity gaze/state blend; `<ClientOnly>` + `IntersectionObserver` + `prefers-reduced-motion` aware |
|
||||
| **App — Media + Notifications** | |
|
||||
| `util/MediaCacheWriter.kt` | `cacheDir/hermes-media/` LRU writer; returns FileProvider URIs |
|
||||
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic |
|
||||
| `data/HermesCard.kt` | `CARD:{json}` envelope (ADR 26) — type/accent/fields/actions; kotlinx.serialization |
|
||||
| `ui/components/HermesCardBubble.kt` | Rich-card renderer — accent stripe + FlowRow actions + dispatch stamp collapse |
|
||||
| `viewmodel/CardDispatchSyncBuilder.kt` | Twin of VoiceIntentSyncBuilder — synthesizes card dispatches as `hermes_card_action` OpenAI pairs for session memory |
|
||||
| `notifications/HermesNotificationCompanion.kt` | NotificationListenerService; cold-start buffer (50); forwards via ChannelMultiplexer |
|
||||
| `util/RelayErrorClassifier.kt` | `classifyError(Throwable, context) → HumanError`; used by Voice/Chat/Connection |
|
||||
| **Relay — Server** | |
|
||||
| `plugin/relay/server.py` | Canonical relay — WSS + HTTP routes; bridge, media, voice, session, pairing handlers |
|
||||
| `plugin/relay/server.py` | Canonical relay — WSS + HTTP routes; bridge, media, voice, session, pairing handlers. `handle_pairing_mint` mirrors `pair.py:762` — top-level = API server, `relay.{url,code}` nested |
|
||||
| `plugin/relay/auth.py` | PairingManager, SessionManager, RateLimiter; `math.inf` for never-expire |
|
||||
| `plugin/relay/channels/bridge.py` | Bridge handler — `handle_command()` mints request_id, awaits response, 30s timeout |
|
||||
| `plugin/relay/channels/notifications.py` | Bounded deque (100) of notification metadata; in-memory only |
|
||||
| `plugin/relay/media.py` | MediaRegistry — LRU token store; `strict_sandbox` off by default for `/media/by-path` |
|
||||
| `plugin/relay/voice.py` | Voice endpoints — transcribe, synthesize, voice_config; lazy tool imports |
|
||||
| `plugin/relay/qr_sign.py` | HMAC-SHA256 QR signing; secret at `~/.hermes/hermes-relay-qr-secret` |
|
||||
| `plugin/relay/qr_sign.py` | HMAC-SHA256 QR signing; secret at `~/.hermes/hermes-relay-qr-secret`; canonical form preserves `endpoints` array order + role strings verbatim (ADR 24) |
|
||||
| `plugin/relay/tailscale.py` | First-class Tailscale helper (ADR 25) — `status()` / `enable(port)` / `disable(port)` / `canonical_upstream_present()`; safe-absent via shell-out to `tailscale` CLI |
|
||||
| `plugin/relay/_env_bootstrap.py` | Loads `~/.hermes/.env` before relay imports; called from both entry points |
|
||||
| **Plugin — Tools + Installer** | |
|
||||
| `plugin/tools/android_tool.py` | 18 `android_*` tool handlers (14 baseline + send_sms, call, search_contacts, return_to_hermes); `android_screenshot` first consumer of `register_media()` |
|
||||
@@ -189,7 +231,56 @@ hermes-android/
|
||||
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
|
||||
| `install.sh` | Canonical installer — 6 steps; idempotent; drops `hermes-relay-update` shim |
|
||||
| `uninstall.sh` | Canonical uninstaller; reverses install.sh; never touches `.env` or `state.db` |
|
||||
| `hermes_relay_bootstrap/` | Runtime patch for vanilla upstream; no-op on fork/upstream-merged; remove after PR #8556 |
|
||||
| `hermes_relay_bootstrap/` | Runtime patch for vanilla/partial upstream compatibility routes; shrink per route group after native parity or client migration |
|
||||
| **Plugin — Dashboard** | |
|
||||
| `plugin/dashboard/manifest.json` | Declares tab, entry bundle, and FastAPI module for hermes-agent discovery |
|
||||
| `plugin/dashboard/plugin_api.py` | FastAPI router proxying 5 routes to relay over loopback; `/pairing` body = API-server overrides (host/port/tls/api_key), relay URL auto-derived |
|
||||
| `plugin/dashboard/src/index.jsx` | React root registering `hermes-relay` plugin with 4-tab shell |
|
||||
| `plugin/dashboard/dist/index.js` | Committed IIFE bundle loaded verbatim by dashboard |
|
||||
| **Desktop CLI** | |
|
||||
| `desktop/package.json` | `@hermes-relay/cli` package manifest — Node ≥21, one `hermes-relay` bin, pre-built dist |
|
||||
| `desktop/bin/hermes-relay.js` | Tiny shim: `import('../dist/cli.js').then(m => m.main())` + error surfacing |
|
||||
| `desktop/src/chatAttach.ts` | captureClipboardImage / captureScreenshot / readImageFile; ships base64 to server via `image.attach.bytes` RPC before next prompt.submit |
|
||||
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat` |
|
||||
| `desktop/src/commands/chat.ts` | REPL + one-shot + piped-stdin; `runOneTurn` returns `{promise, cancel}` for safe SIGINT; auto-wires `DesktopToolRouter` when consented |
|
||||
| `desktop/src/commands/shell.ts` | Pipes the `terminal` relay channel to raw-mode stdin/stdout; post-attach `exec hermes` 350ms after tmux settles; `Ctrl+A .` detach / `Ctrl+A k` kill / `Ctrl+A Ctrl+A` literal |
|
||||
| `desktop/src/commands/pair.ts` | Either 6-char code + `--remote`, or full v3 QR via `--pair-qr` — probes + picks endpoint, records role; `--grant-tools` (TTY prompt) / `--auto-grant-tools` (silent) stamp `toolsConsented` so `daemon` works without a `shell` round-trip |
|
||||
| `desktop/src/commands/tools.ts` | `tools.list` RPC → enabled/available toolsets; `--verbose` lists individual tools |
|
||||
| `desktop/src/commands/status.ts` | Local read of `~/.hermes/remote-sessions.json`; renders `grants:` + `expires:` + `route:`; `--json` redacts tokens, `--reveal-tokens` opts in |
|
||||
| `desktop/src/commands/devices.ts` | Server-side session management — `GET/DELETE/PATCH /sessions` via `fetch` over http(s)://host:port; `list` / `revoke <prefix>` / `extend <prefix> --ttl <s>` |
|
||||
| `desktop/src/banner.ts` | `buildConnectBanner({url, meta, endpointRole})` → "Connected via LAN (plain) — server 0.6.0"; `humanExpiry()` for TTL formatting |
|
||||
| `desktop/src/endpoint.ts` | `EndpointCandidate` / `EndpointRole` types + `displayLabel()` — mirrors Android `data/Endpoint.kt` |
|
||||
| `desktop/src/pairingQr.ts` | `decodePairingPayload` (JSON or base64), `payloadToCandidates` (v3 verbatim / v1–v2 synthesized), `probeCandidatesByPriority` (`Promise.any` within tier, `AbortSignal.any`, 4s timeout, 60s cache) |
|
||||
| `desktop/src/certPin.ts` | `extractSpkiSha256(der)` via `crypto.X509Certificate` + `publicKey.export({type:'spki'})`; `pinKey(url)`, `comparePins()`, `isSecureUrl()` |
|
||||
| `desktop/src/tools/router.ts` | `DesktopToolRouter.attach(relay)` — `onChannel('desktop')` dispatch under 30s `AbortController`; heartbeat enriched with host/platform/version/uptime_ms + sticky `last_error` for `desktop_health` |
|
||||
| `desktop/src/tools/handlerSet.ts` | Single source of truth for the desktop tool map — `DESKTOP_HANDLERS` + `DESKTOP_ADVERTISED_TOOLS`; consumed by `chat.ts` / `shell.ts` / `daemon.ts` so adding a tool is a one-file change |
|
||||
| `desktop/src/tools/consent.ts` | `ensureToolsConsent(url)` — stored per-URL in `toolsConsented`; TTY prompt; non-TTY fails closed |
|
||||
| `desktop/src/tools/handlers/fs.ts` | `readFileHandler` / `writeFileHandler` / `patchHandler` — strict unified-diff applier, no fuzz |
|
||||
| `desktop/src/tools/handlers/terminal.ts` | `bash -lc` / `cmd /c`, SIGKILL on timeout or abort, returns `{stdout, stderr, exit_code, duration_ms}` |
|
||||
| `desktop/src/tools/handlers/powershell.ts` | Spawns `pwsh`/`powershell` directly with `-Command -`, script piped via stdin — no cmd.exe quote-mangling; auto-picks pwsh > powershell |
|
||||
| `desktop/src/tools/handlers/process.ts` | `spawn_detached` (unref'd, returns pid+log_path), `list_processes` (tasklist /FO CSV — no /V to dodge window-title latency), `kill_process`, `find_pid_by_port` (netstat/lsof/ss) |
|
||||
| `desktop/src/tools/handlers/jobs.ts` | Job API — `~/.hermes/desktop-jobs/<id>/{stdout.log, stderr.log, meta.json}` is source of truth across daemon restarts; `taskkill /T` on Windows so build trees die fully |
|
||||
| `desktop/src/tools/handlers/transfer.ts` | `copy_directory` via `fs.cp`, `zip`/`unzip` via tar > zip > PowerShell probe, `checksum` streamed (sha256/sha1/md5) |
|
||||
| `desktop/src/tools/handlers/search.ts` | ripgrep with pure-Node fallback, skips `.git`/`node_modules`/`dist`/`.next`/`.cache` |
|
||||
| `desktop/src/renderer.ts` | Streams `message.delta` → stdout, tool events → decorated lines; NO_COLOR / --json / --quiet aware |
|
||||
| `desktop/src/pairing.ts` | readline-based 6-char prompt (`A-Z0-9`); headless mirror of TUI's Ink prompt; `validatePairingPayloadString` discriminated-union wrapper |
|
||||
| `desktop/src/credentials.ts` | Precedence: `--token` → `--pair-qr` (probe+pair) → `--code` → stored → prompt; returns `Credentials{sessionToken?, pairingCode?, resolvedEndpoint?}` |
|
||||
| `desktop/src/transport/RelayTransport.ts` | Fork of ui-tui's transport + reconnect state machine (`idle/connecting/connected/reconnecting`, exp backoff 1→30s, 5min on 429, gate re-check post-sleep) + pre-WS TLS probe for TOFU |
|
||||
| `desktop/src/remoteSessions.ts` | Same file path as TUI (`~/.hermes/remote-sessions.json`, 0600); schema widened with `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented`; `saveSession` back-compat overload |
|
||||
| `desktop/src/commands/daemon.ts` | Headless WSS + tool router for always-on access; JSON-line logs; fails closed on missing consent unless `--allow-tools` with explicit `--token` |
|
||||
| `desktop/src/commands/doctor.ts` | Local-only diagnostic report — version / binary path / PATH / sessions / daemon detection; `--json` for support-paste; omits tokens entirely |
|
||||
| `desktop/src/relayUrlPrompt.ts` | First-run URL fallback — `resolveFirstRunUrl()` auto-picks single stored session, numbered picker for multiple, welcome banner for zero; throws on non-interactive + ambiguous |
|
||||
| `desktop/src/version.ts` | Build-time-generated constant (`npm run gen:version` before every build) — Bun compiled binaries can't read package.json via `__dirname` so version is embedded at build |
|
||||
| `desktop/scripts/install.sh` / `install.ps1` | curl/iwr one-liner installers — download prebuilt Bun binary (no Node required), SHA256-verified, API-resolver for `latest` that includes prereleases, version-aware pre/post-install readback |
|
||||
| `desktop/scripts/uninstall.sh` / `uninstall.ps1` | 3-tier removal — default (binary + PATH), `--purge` (also wipes `~/.hermes/remote-sessions.json`), `--service` (stub for future service installers); Windows iex-safe env-var fallback |
|
||||
| `desktop/README.md` | User-facing install + usage reference |
|
||||
| **Desktop CLI — dev iteration** | |
|
||||
| `npm run smoke` (in `desktop/`) | Builds Windows binary + runs `--version` / `--help` / `doctor`, fails loud on zero-output. Local pre-flight before cutting any tag. |
|
||||
| `npm run gen:version` | Regenerates `src/version.ts` from `package.json`. Runs automatically before every `build` / `build:bin:*`. |
|
||||
| `release-desktop.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
|
||||
| **Server — Desktop tool routing (Phase B)** | |
|
||||
| `plugin/relay/channels/desktop.py` | Mirrors `bridge.py` — `desktop.command`/`desktop.response`/`desktop.status`, UUID-correlated futures, 30s timeout, single-client MVP, per-session advertised-tools set |
|
||||
| `plugin/tools/desktop_tool.py` | 24 `desktop_*` tools (fs/shell/powershell/process/jobs/transfer/health) — registers with `tools.registry` under `desktop` toolset; per-tool `check_fn` pings `/desktop/_ping?tool=<name>`; `desktop_health` is `_RELAY_ONLY` and pings `/desktop/health` so it works even when the client is wedged |
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
@@ -238,7 +329,7 @@ Curls every bridge HTTP route via `localhost:8767`. Catches the silent-drop regr
|
||||
2. **Python syntax check** — `python -m py_compile plugin/<file>.py`. Full tests run on the server.
|
||||
3. **Kotlin changes** — do NOT run `gradle build`. Bailey builds via Android Studio's ▶ button. Never `adb install` from Claude.
|
||||
4. **Before pushing Kotlin changes** — run `./gradlew lint` locally. It's the exact task CI runs (see `.github/workflows/ci.yml` → `gradlew lint` fallback) and catches errors Android Studio's live inspections miss — e.g. `UnsafeOptInUsageError` with `kotlin.OptIn` vs `androidx.annotation.OptIn`, `FlowOperatorInvokedInComposition` (mapped flows inside Composables), Media3 `@UnstableApi` propagation. Lint is a hard blocker in CI: Build + Test show "skipping" until lint passes, and lint prints only the **first failure** before aborting — so CI iterations reveal errors one at a time while a single local lint run surfaces all of them.
|
||||
5. **Commit + push** — feature branch for anything >1-2 commits.
|
||||
5. **Commit + push** — feature branch off `dev`, merged back to `dev` via PR. `main` is reserved for release merges.
|
||||
6. **Pull + restart on server** — see Server Deployment below.
|
||||
7. **Test on phone** — Bailey builds from Studio, installs to Samsung device, pairs via `/hermes-relay-pair`.
|
||||
|
||||
@@ -288,8 +379,10 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | No live tool events; reloads history on stream complete |
|
||||
| Chat (compat) | `POST /v1/chat/completions` (stream=true) | Inline tool annotations only |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Non-standard; bootstrap or fork |
|
||||
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim |
|
||||
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim; accepts optional `endpoints` for multi-endpoint QRs |
|
||||
| Pairing (multi-endpoint) | QR `endpoints` array (ADR 24) | `hermes: 3` schema; ordered `lan`/`tailscale`/`public`/... candidates; phone re-probes on network change |
|
||||
| Pairing auth | WSS `auth.ok` payload | Includes `expires_at`, `grants`, `transport_hint` |
|
||||
| Tailscale Serve (ADR 25) | `hermes-relay-tailscale enable\|disable\|status` CLI | Fronts loopback `:8767` with `tailscale serve --bg --https=<port>`; auto-retires on upstream PR #9295 |
|
||||
| Inbound media (token) | `GET /media/{token}` | Bearer auth; 24h TTL |
|
||||
| Inbound media (path) | `GET /media/by-path?path=<abs>` | Permissive by default; `RELAY_MEDIA_STRICT_SANDBOX=1` to restrict |
|
||||
| Session management | `GET /sessions`, `DELETE /sessions/{prefix}`, `PATCH /sessions/{prefix}` | List/revoke/extend; RelayHttpClient |
|
||||
@@ -299,6 +392,13 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
| Notifications | `GET /notifications/recent?limit=N` | Loopback callers skip bearer |
|
||||
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
|
||||
| Capabilities | `HEAD /api/sessions`, `HEAD /v1/runs`, etc. | HEAD avoids CORS 403 on OPTIONS preflight |
|
||||
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
|
||||
| Desktop CLI (terminal channel) | WSS `terminal.attach` / `terminal.input` / `terminal.output` / `terminal.resize` / `terminal.detached` | Existing channel (shared with Android). CLI `shell` subcommand attaches, injects `clear; exec hermes\n` 350ms after ack, pipes raw bytes. `Ctrl+A .` detaches (tmux preserved), `Ctrl+A k` kills. |
|
||||
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
|
||||
| Desktop CLI devices | HTTP `GET/DELETE/PATCH /sessions` on the relay's same port | Wrapped by `hermes-relay devices list | revoke <prefix> | extend <prefix> --ttl <s>`; bearer token from stored session; token prefix only (never full token) |
|
||||
| Desktop tool routing (Phase B) | WSS `desktop.command` (s→c) + `desktop.response` (c→s) + `desktop.status` (c→s heartbeat) | New channel. Hermes calls `desktop_read_file(path)` → Python handler POSTs to `/desktop/desktop_read_file` → relay forwards over `desktop.command` → Node client's `DesktopToolRouter` runs the handler locally → response bubbles back. Mirror of Android's `bridge.command` pattern. |
|
||||
| Desktop tool check_fn | HTTP `GET /desktop/_ping?tool=<name>` | Returns 200 if a client is connected AND advertises this tool; 503 otherwise. Hermes uses this to fail the tool quickly when no desktop client is live, instead of waiting 30s for the dispatch timeout. |
|
||||
| Desktop health | HTTP `GET /desktop/health` | Returns full status snapshot — connected/host/platform/version/pid/uptime/advertised_tools/last_error/recent_commands. Loopback-only. Backs the `desktop_health` agent tool, which intentionally does NOT round-trip through the client so it remains callable when other tools are wedged. |
|
||||
|
||||
## Upstream References
|
||||
|
||||
|
||||
+3
-3
@@ -92,16 +92,16 @@ After the plugin is in place, restart hermes and verify pairing with `hermes-pai
|
||||
|
||||
We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`.
|
||||
|
||||
Feature branches are the house style — `feature/<name>`, `fix/<name>`, `docs/<name>`, `chore/<name>` — merged into `main` via `--no-ff` merge commits so the per-branch history stays visible in `git log --graph`. Straight-to-`main` is reserved for single-file typo fixes.
|
||||
**Branching model (as of 2026-04-19): `main` + `dev`.** Feature branches — `feature/<name>`, `fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back into `dev` via `--no-ff` PRs. `main` is released state only; it receives release merges from `dev` and nothing else. There is no straight-to-main exemption — even single-file typos go through `dev`.
|
||||
|
||||
Release-prep commits (version bump + tag) are allowed to push directly to `main` via a branch-protection carve-out — see [RELEASE.md](RELEASE.md) for the full release process.
|
||||
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a surface-specific release PR merges `dev` → `main` with `--no-ff`. Tags are cut from `main` after the merge: `android-vX.Y.Z`, `server-vX.Y.Z`, or `desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
||||
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
||||
|
||||
CI (`.github/workflows/ci.yml`) runs lint, Android build, Android unit tests, and a Python relay syntax check on every push.
|
||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -1,5 +1,811 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-06-05 — Refresh upstream baseline docs after native session merge
|
||||
|
||||
**Context.** Upstream Hermes Agent merged native API Server session controls as commit `f7527b0` via PR #33134, and Android now prefers native `/v1/skills` with legacy fallback. Several Relay docs still treated PR #8556/#29302 as the future removal trigger for `hermes_relay_bootstrap/`.
|
||||
|
||||
**What changed.** Updated contributor/user docs to separate native upstream baseline routes (`/api/sessions/*`, `/v1/skills`, `/v1/toolsets`) from Relay compatibility routes that still require `axiom` or bootstrap (`/api/sessions/search`, `/api/memory`, `/api/config`, legacy skill detail routes, `/api/available-models`, voice aliases). The bootstrap retirement rule is now per route group, not wholesale deletion after one upstream session merge.
|
||||
|
||||
**Verification.** Ran stale-reference searches for `#8556`, `#29302`, and legacy skills routes after edits; remaining references are historical devlog/plans or explicitly marked compatibility/fallback.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-05 — Prefer upstream `/v1/skills` with legacy fallback
|
||||
|
||||
**Context.** Upstream Hermes Agent now has baseline skill/session API surface area, while Axiom's fork still preserves richer Relay-specific `/api/*` compatibility routes. The Android client should begin consuming upstream-compatible skill listings when present without breaking older fork/bootstrap installs.
|
||||
|
||||
**What changed.** `HermesApiClient.getSkills()` now tries `/v1/skills` first, then falls back to `/api/skills`. Skill parsing accepts upstream OpenAI-style list envelopes (`{"object":"list","data":[...]}`), legacy fork envelopes (`{"skills":[...]}` / `{"items":[...]}`), and direct arrays.
|
||||
|
||||
**Verification.** Added pure Kotlin unit coverage for endpoint order and `/v1/skills` `data` parsing. Verified with `ANDROID_HOME=$HOME/Android/Sdk ./gradlew :app:testGooglePlayDebugUnitTest --tests 'com.hermesandroid.relay.network.HermesApiClientTest'` → BUILD SUCCESSFUL. `git diff --check` passes.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-26 — Fix voice-mode crash: ExoPlayer audio session id read off-main (barge-in + legacy TTS)
|
||||
|
||||
**Report.** Discord user, sideload latest: voice chat crashes the instant Hermes starts answering — "I hear just 2 letters and it crashes." Stack: `IllegalStateException: Player is accessed on the wrong thread. Current thread: 'DefaultDispatcher-worker-4', Expected thread: 'main'` with the Media3 `player-accessed-on-wrong-thread` doc link and a `Suppressed: ... Dispatchers.IO`.
|
||||
|
||||
**Root cause.** `Dispatchers.IO` threads are named `DefaultDispatcher-worker-N` (IO and Default share one scheduler pool), so the crash is on an IO coroutine. `BargeInListener` runs its mic reader on `Dispatchers.IO` and, to attach `AcousticEchoCanceler`, polls an `audioSessionIdProvider` lambda. On the **legacy `/voice/synthesize` (Media3) playback path**, `VoiceViewModel` wires that provider to `{ player.audioSessionId }` → `exoPlayer.audioSessionId`. ExoPlayer is thread-confined; its `getAudioSessionId()` getter calls `verifyApplicationThread()` and throws when read off-main. The realtime PCM path is unaffected because it wires the provider to an `AudioTrack` session id (thread-safe), which is why the bug only hit legacy/fallback setups. Sequence: first sentence starts → `runPlayWorker.onFileReady` → `startBargeInListenerIfEnabled()` → IO reader → `awaitNonZeroSessionId()` → off-main getter → crash ~2 syllables in.
|
||||
|
||||
**Fix.** `VoicePlayer.audioSessionId` now serves a `@Volatile cachedAudioSessionId` instead of the raw thread-confined getter. The cache is populated from main-thread Media3 callbacks: an `AnalyticsListener.onAudioSessionIdChanged` hook (authoritative, fires when Media3 allocates/reallocates the AudioTrack) plus a belt-and-braces read inside the existing `onIsPlayingChanged`. Reads from any thread are now safe.
|
||||
|
||||
**Tests.** Added `VoicePlayerTest` coverage: getter reflects the analytics-listener-cached id, never re-invokes `exoPlayer.audioSessionId` (the off-main call), and defaults to 0 before allocation. Captured the `AnalyticsListener` in the MockK harness. Verified locally: `:app:lintGooglePlayDebug` + `:app:testGooglePlayDebugUnitTest --tests VoicePlayerTest` both green (BUILD SUCCESSFUL).
|
||||
|
||||
**Next.** Landed on `dev` via PR #60, then shipped as the focused patch release `android-v0.8.1` (cherry-picked off the `android-v0.8.0` tag, PR #62); `main` merged back to `dev`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-24 — Background Hermes runs in Realtime Agent voice (ADR 33)
|
||||
|
||||
**Context.** Realtime Agent ran each Hermes turn synchronously *inside* the provider event pump (`_run_brokered_tool` did `return await task`), so a long research/multi-tool/desktop run froze the whole realtime session until it finished. ADR 33 + `docs/plans/2026-05-24-realtime-background-hermes-runs.md` define a three-tier model (foreground / promoted / durable) with the relay as an explicit audio-floor owner. Branch `feature/realtime-background-hermes-runs`.
|
||||
|
||||
**What shipped (phased, per the plan):**
|
||||
|
||||
- **Phase 0 — idle-tolerance probe + verdict.** `scripts/realtime-provider-idle-probe.py` + an "Idle tolerance" section in `docs/realtime-voice-poc.md`. **Ran live against OpenAI** (`VOICE_TOOLS_OPENAI_KEY` in `~/.hermes/.env`): the session survived 10s/20s/30s quiescent windows and returned clean audio on every post-idle turn → verdict **`hold-floor-ok`**. xAI has no creds on the dev box, so its verdict is recorded analytically as `hold-floor-ok` (same `turn_detection:None` multi-turn model; the implementation closes the pending call rather than holding an open response) — confirm on the relay host. Incidental finding logged: OpenAI now wants `session.audio.output.format.rate` at `session.update` (minor `_session_update` follow-up; session still worked).
|
||||
- **Phase 1 — floor owner.** New `plugin/relay/realtime_agent/floor.py`: pure, single-owner audio floor (`provider` / `relay_tts` / `android_filler` mouths; `idle/provider_speaking/hermes_filler/result_pending` labels). Wired behavior-preservingly into the broker (acquire/release on AUDIO_DELTA/AUDIO_DONE/RESPONSE_DONE; relay-TTS render holds the floor; filler gated by `can_speak`). Invariants in `test_realtime_floor.py`.
|
||||
- **Phase 2 — Tier B promotion (was default off).** `_run_brokered_tool` shields the run and waits `promote_after_ms`; if still running it detaches to the background, closes the pending provider call with an interim ack, optionally speaks a handoff, and `_deliver_background_result` speaks the answer once the floor is idle. New events `hermes.run.promoted` / `hermes.run.background_completed` + `tier`/`floor` on progress; 8 new settings. `test_realtime_promotion.py` (promote+pump-responsive, short=no-promote, cancel, detach-resume-replays).
|
||||
- **Phase 3 — default-on + Tier C + Android + docs.** Flipped `promotion_enabled` default **true** (safe: the path closes the pending call rather than holding an open response, so the socket only sees the normal between-turns idle gap). `hermes_run_task(mode="background")` detaches immediately (`tier:"durable"`). Settings exposed on `GET/PATCH /voice/realtime-agent/config`. Android: parse new events → "working on it" chip; Voice Settings → Realtime Agent → Background tasks (promote toggle, spoken-handoff toggle, result-delivery segmented control) → `RelayVoiceClient.updateRealtimeAgentPromotion()`.
|
||||
|
||||
**Why default-on despite the Phase 0 gate.** The implementation closes the pending function call with an interim background ack instead of parking an open provider response, so the worst-case "idle open-response" the gate worried about doesn't occur — the socket sits in the same idle state it does between any two user turns. The probe is retained to confirm per-provider survival; documented in config + ADR.
|
||||
|
||||
**Verified.** Python realtime suite **58 tests green** (`test_realtime_floor`, `test_realtime_promotion`, both provider suites, routes, profile-voice-config). `./gradlew lint` — Kotlin compiles clean; the only 2 lint errors are in the gitignored `local.properties` (absent in CI). Pre-existing unrelated `test_reads_hermes_xai_oauth_credential_pool` failure confirmed on `origin/dev` baseline.
|
||||
|
||||
**Next.** Confirm the xAI idle verdict on the relay host (where xAI creds live); fix the OpenAI `_session_update` rate field; run the lab smoke on a paired device. Open the PR to `dev`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-23 — Un-defer the voice/audio test suite (issue #32) + barge-in resume bug
|
||||
|
||||
**Context.** GitHub issue #32 tracked 5 voice/audio unit tests `@Ignore`'d during the v0.5.1 release because the full `:app:testGooglePlayDebugUnitTest` task "hung indefinitely." Scope had quietly grown to **8** ignored classes (3 of the "pure-logic, should-work" ones got swept in defensively). Branch `fix/voice-test-suite`.
|
||||
|
||||
**Root-cause of the hang.** Not Robolectric's classloader (the v0.5.1 hypothesis) — it was `BargeInPreferencesTest` building its DataStore on a `TestScope(StandardTestDispatcher() + Job())` whose scheduler is **never advanced**. DataStore's reader actor never ran, so `repo.flow.first()` suspended forever. Fixed by backing the DataStore with a real dispatcher scope (`CoroutineScope(Dispatchers.IO + Job())`); the `runTest{}` bodies still drive the suspend calls within the dispatch timeout.
|
||||
|
||||
**Real product bug found (not just test infra).** Un-ignoring `VoiceViewModelBargeInTest` surfaced a genuine regression: the barge-in **"resume after interruption"** feature was silently broken. `onBargeInDetected()` → `interruptSpeaking()` → `startTtsConsumer()` restarts the play worker, which immediately hits an empty `audioQueue`, fires `onQueueDrained` → `clearSpokenChunksState()` **synchronously** (on `Dispatchers.Main.immediate`) — wiping `spokenChunks` before the 600 ms resume watchdog reads it. The watchdog always saw an empty tail and dropped the resume. Fixed by snapshotting the un-played tail (`pendingResumeTail`) synchronously in `onBargeInDetected()`, the instant the interrupt fires, instead of re-reading live state later.
|
||||
|
||||
**VoicePlayerTest / Robolectric.** No separate source set needed (the issue's proposed Phase 3). The "Robolectric leaks across forks and hangs the suite" symptom was a misattribution of the DataStore hang. With that fixed, VoicePlayerTest runs cleanly in the normal `test` source set under `@RunWith(RobolectricTestRunner) @Config(sdk=[34])` — added `robolectric 4.14.1` (testImplementation) + `unitTests.isIncludeAndroidResources = true`.
|
||||
|
||||
**Result.** All 8 issue-#32 classes un-ignored and green. Full suite: **525 completed, 12 skipped, 0 failed, no hang (~30 s)**. The 12 skipped are unrelated pre-existing `@Ignore`s (`ConnectionStoreTest` et al.).
|
||||
|
||||
**Also fixed (pre-existing failures surfaced while greening the suite):**
|
||||
|
||||
- **`CardDispatchSyncBuilder` bug** — `buildSyntheticMessages` short-circuited on `msg.cards.isEmpty()`, silently dropping dispatches whose card was trimmed from the rolling buffer. This directly contradicted the SUT's own docstring (and `CardDispatchSyncBuilderTest.buildSyntheticMessages_unknownCardKey_stillEmitsBareEnvelope`), which require a bare-envelope audit record in that case. Fixed the guard to gate on `cardDispatches.isEmpty()` only; the `card == null` fallback already handles the missing-card path.
|
||||
- **4 lint errors in sideload-only bridge code compiled into googlePlay.** `NotificationPermission` (`AutoDisableWorker`) — the real `hasPostNotificationsPermission()` early-return guard was already correct; the existing `@SuppressLint("MissingPermission")` just used the wrong ID, so added `"NotificationPermission"`. `ForegroundServiceType` ×3 (`BridgeForegroundService`) — the service + its `FOREGROUND_SERVICE_*`/`POST_NOTIFICATIONS` permissions are declared only in the **sideload** manifest; googlePlay deliberately omits them (no device-control, Play-Store compliance), making the code unreachable there. Suppressed with a justification rather than weakening googlePlay.
|
||||
|
||||
**Verified.** `:app:testGooglePlayDebugUnitTest` green (0 failures); `:app:lint` green (0 errors).
|
||||
|
||||
**Next.** Commit, merge `fix/voice-test-suite` → `dev`, close issue #32.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-19 — Experimental Realtime Hermes Voice Agent
|
||||
|
||||
**Plan.** [docs/plans/2026-05-19-realtime-hermes-voice-agent.md](docs/plans/2026-05-19-realtime-hermes-voice-agent.md) — add a switchable Android voice engine that brokers a realtime provider session (OpenAI first, xAI ready) while keeping Hermes as authority for profiles, sessions, memory, tool execution, Android bridge safety, confirmations, and cancellation. Stable `Hermes chat + voice output` remains the default and is untouched.
|
||||
|
||||
**Surface added.**
|
||||
|
||||
- **Relay broker** — `plugin/relay/realtime_agent/` package with `RealtimeAgentHandler` (HTTP + WSS) mounted at `/voice/realtime-agent/*` next to the existing `/voice/realtime/*` lab routes. Five HTTP routes (`config GET/PATCH`, `providers/{id}/options GET`, `providers/{id}/validate POST`, `session POST`) plus a websocket at `/voice/realtime-agent/{session_id}`. Disabled by default — `realtime_agent_enabled=False` until an operator opts in via Settings → Voice or `RELAY_REALTIME_AGENT_ENABLED=1`.
|
||||
- **Hermes tool broker** — `realtime_agent/hermes_tool_broker.py` is the narrow bridge from a realtime provider's function call into the Hermes `/v1/runs` SSE surface. Only the four `hermes_*` schemas (`hermes_run_task`, `hermes_get_status`, `hermes_cancel`, `hermes_confirm`) are visible to the provider — the realtime side can ask Hermes to work, check progress, cancel, or answer a confirmation, but never call Android bridge or skill tools directly. Hermes events are normalized into `hermes.*` ws events that mirror into the chat timeline so voice mode never has to be exited to see what happened.
|
||||
- **Provider adapters** — `realtime_agent/providers/{base,openai,xai}.py` behind the normalized adapter contract. OpenAI is the first-class implementation; xAI is ready behind the same contract and toggled by configured auth. Both adapters keep all provider-event vocabulary local to the adapter — broker logic switches on the normalized `ProviderEvent` shape only.
|
||||
- **Android engine selector** — `VoicePreferences.voiceEngineMode` (DataStore-backed, persists across launches) with a clean radio selector at the top of Settings → Voice. The Realtime Agent option carries an `Experimental` badge and a concise limitation note; defaults always coerce unknown stored values back to the stable engine so a downgraded build cannot strand a user.
|
||||
- **Android client + overlay timeline** — `RelayVoiceClient.runRealtimeAgent` drives the brokered ws end-to-end and surfaces realtime transcripts, Hermes tool state, confirmation prompts, and final responses through `RealtimeAgentTimelineMirror` into the same overlay + chat surfaces the stable engine already uses. No exit/reload required to see brokered tool work.
|
||||
|
||||
**What is preserved.** `/voice/realtime/*` lab routes, `/voice/output/*`, `/voice/transcribe`, and `/voice/synthesize` are unchanged. Stable voice mode tests still pass. `voice:realtime` capability gates both surfaces, so the existing pairing-grant flow covers the new engine.
|
||||
|
||||
**Validation.**
|
||||
- `python -m unittest plugin.tests.test_realtime_agent_*` — relay broker + Hermes tool broker + provider adapter tests passing.
|
||||
- Sibling Python tests (`test_realtime_voice_routes`, `test_profile_voice_config`, `test_provider_options`) still green — stable voice surface is regression-clean.
|
||||
- `:app:compileSideloadDebugKotlin` builds clean. Android UI tests live on-device and were not in scope for this pass (no adb requested).
|
||||
|
||||
**Notes.** Realtime providers cannot pre-empt Hermes safety — destructive Android bridge actions still surface as Hermes confirmation prompts and require an operator `hermes_confirm` ws answer routed through the existing approval flow. Provider disconnect surfaces a `voice.error` with `recoverable=true` so the Android client can fall back to stable voice without corrupting the Hermes chat session.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-25 (II) — Remote-PC ergonomics pass: PowerShell / process / job / transfer / health tools
|
||||
|
||||
**Context.** Bailey shipped a feedback list from a real remote-PC session: `desktop_terminal` was 502'ing on long-lived launches, no process-management primitives (had to `netstat | taskkill` manually), no bulk file sync, PowerShell echoing instead of executing, and no daemon-health introspection. The biggest single ask was "detached job/process API with persistent logs." Explicit no-go: program-specific shortcuts (no ComfyUI helper).
|
||||
|
||||
**Surface added (all routed through the existing `desktop` channel — no new channels, no hermes-agent core changes):**
|
||||
|
||||
- **`desktop_powershell`** — script text fed to `pwsh`/`powershell.exe` via `-Command -` over stdin. Bypasses the `cmd /c "powershell -Command \"...\""` quoting hellscape that was causing scripts to echo instead of execute. Probes `pwsh` first, falls back to Windows PowerShell on win32; non-Windows hosts without `pwsh` fail loud rather than degrade silently.
|
||||
- **Process tools** — `desktop_spawn_detached` (returns within ~10ms with `{pid, log_path}`, child unref'd + `detached:true` so it survives the relay's 30s RPC ceiling), `desktop_list_processes` (substring filter), `desktop_kill_process` (pid or name + force=KILL), `desktop_find_pid_by_port` (cross-platform via netstat/lsof/ss). Tasklist defaults to `/FO CSV` *without* `/V` — verbose mode reads window titles which can take 30+s on a host with many GUI windows, the same latency landmine that was making `desktop_terminal` time out.
|
||||
- **Job API** (`desktop_job_*`) — start / status / logs / cancel / list. On-disk layout `~/.hermes/desktop-jobs/<id>/{stdout.log, stderr.log, meta.json}` is the source of truth across daemon restarts. `desktop_job_logs` supports `offset` + `limit` (negative offset → from-end) so the agent can paginate forward through a long log without re-reading. Cancel uses `taskkill /T` on Windows so build trees (npm → node, gradle → java) die fully, not just the immediate shell child.
|
||||
- **File-transfer tools** — `desktop_copy_directory` (Node's `fs.cp({recursive:true})` — no `xcopy`/`cp -r` shell-out so behavior is uniform), `desktop_zip` / `desktop_unzip` (probe + dispatch order: `tar` > `zip`/`unzip` > PowerShell `Compress-Archive`/`Expand-Archive`; Windows 10+ ships `tar` so the same code path works on every platform), `desktop_checksum` (streamed sha256/sha1/md5 — handles arbitrary file sizes).
|
||||
- **`desktop_health`** — connected client name, host, platform, uptime, advertised tools, last error, recent commands. Answered by a new `GET /desktop/health` route on the relay — does NOT round-trip through the desktop channel — so it remains callable when the client is wedged on a long tool call. Heartbeat enriched with `host/platform/arch/version/pid/uptime_ms` and a sticky `last_error` snapshot stamped from `DesktopToolRouter.dispatch`'s catch arm.
|
||||
|
||||
**Drift-prevention.** `chat.ts`, `shell.ts`, `daemon.ts` all constructed their own copies of the handler map. Replaced with single import from `tools/handlerSet.ts` (`DESKTOP_HANDLERS` + `DESKTOP_ADVERTISED_TOOLS`). Adding the next tool is a one-file change instead of three.
|
||||
|
||||
**Tests + smoke.**
|
||||
- `plugin/tests/test_desktop_health.py` (3 tests) covers the new relay endpoint: 200/connected:false when no client, full surface when a `desktop.status` envelope has been received, 403 on non-loopback. Full Python suite still green (692 passing).
|
||||
- `desktop/scripts/smoke-tools.mjs` exercises PowerShell (with literal `"quotes"` and `$dollar` to prove the cmd-quote-bypass works), process listing, sha256 checksum, and the full job lifecycle in-process. PS smoke confirmed `pwsh` selected, exit 0, output untouched. Job lifecycle: start → wait → status → logs → list, all green.
|
||||
- `npm run type-check` + `npm run build` clean. The bin shim (`hermes-relay --version` / `--help`) still works; new tools enumerate in the help block via the shared advertise list.
|
||||
|
||||
**Why no client roundtrip for `desktop_health`.** It's the diagnostic — needs to work when other tools don't. Routing it as a `desktop.command` would gate it behind the very condition it's meant to inspect. Same pattern as `/desktop/_ping`: relay-only, loopback-required, sub-2s.
|
||||
|
||||
**What's deliberately excluded.** Program-specific shortcuts (e.g. `restart_comfyui`) — Bailey rejected them mid-session; the generic `desktop_job_*` + `desktop_powershell` already cover that surface without coupling the relay to one app's quirks. ComfyUI users (or anyone else) can wrap a local `.ps1` and `desktop_job_start` it.
|
||||
|
||||
**Touchpoints.** `desktop/src/tools/handlers/{powershell,process,jobs,transfer}.ts` (new), `desktop/src/tools/handlerSet.ts` (new), `desktop/src/tools/router.ts` (heartbeat enrichment + `lastError` stamping), `desktop/src/commands/{chat,shell,daemon}.ts` (consume `DESKTOP_HANDLERS`), `plugin/tools/desktop_tool.py` (rewrite — adds 14 new schemas / handlers / dispatch entries, adds `_get` for relay-only tools, adds `_RELAY_ONLY_TOOLS` to skip the per-tool `_check_tool` ping for `desktop_health`), `plugin/relay/server.py` (`handle_desktop_health` + route registration before the wildcard), `plugin/tests/test_desktop_health.py` (new).
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-25 — `pair --grant-tools` / `--auto-grant-tools`: collapse the `pair → shell → daemon` dance to two commands
|
||||
|
||||
**Context.** Bailey wanted a CLI-only path from "I just installed the binary" to "Hermes can RC my PC." The historical flow was three commands (`pair` → `shell` to capture consent → `daemon`), and the middle step was a permanent papercut: a fresh user installs via the iwr/iex one-liner, runs `pair`, runs `daemon`, gets a `consent_missing` error pointing at the interactive `shell` command they hadn't asked for. The gate exists for good reason — a headless binary must never be the surface that first grants tool access — but the gap between *that constraint* and *the user's mental model* lived in the wrong place.
|
||||
|
||||
**Fix.** Two new opt-in flags on `pair`, both deliberately explicit so consent is never implicit:
|
||||
|
||||
- `--grant-tools` — after a successful pair, runs `ensureToolsConsent(url)` (the same helper `shell` has always used). TTY prompt with the standard "AGENT-CONTROLLED access" warning, persists `toolsConsented: true` on the stored session if the user types `yes`. Pair still succeeds even if consent is declined; the user just gets a hint to rerun on a TTY.
|
||||
- `--auto-grant-tools` — folds `toolsConsented: true` into the same `saveSession` call that writes the token, no prompt. For CI / provisioning scripts where the operator has decided in writing that this URL is trusted. Auto wins if both flags are passed (no point prompting after the user already committed).
|
||||
|
||||
Daemon stays unchanged structurally — its consent gate already accepts `toolsConsented: true` from the stored session, which is exactly what the new flags write. Only diff in `daemon.ts` is the error message: the `consent_missing` path now points users at `pair --grant-tools` instead of suggesting they run `shell` for a consent grant they never asked to capture interactively.
|
||||
|
||||
**Why split into two flags rather than one.** A single `--grant-tools` flag would have to decide "prompt or not" from ambient context (TTY detection), and security-sensitive consent should never be implicit. `--grant-tools` = "ask me," `--auto-grant-tools` = "I already decided" — neither path is silent, and a malicious shell history that runs `pair` without flags can't broaden tool access. The boundary the original `pair`/`shell` split protected (scriptable token mint vs. interactive consent capture) is preserved; users who care about that boundary keep getting it, users who don't can opt into the shortcut.
|
||||
|
||||
**One subtle thing in `saveSession`.** It already implements a merge-onto-prev pattern for grants/ttl/endpointRole/certPin/toolsConsented (line 161–173 of `remoteSessions.ts`), so calling it a second time with just `{ toolsConsented: true }` from `ensureToolsConsent` is non-destructive. That's why the interactive `--grant-tools` path doesn't need to thread the consent flag through `pair`'s mainline `saveSession` — it can let `ensureToolsConsent` write its own follow-up save with no risk of clobbering the token, route, or grant set. Only the `--auto-grant-tools` path folds into the first call (atomic, no race window where the token exists without the consent stamp).
|
||||
|
||||
**Touchpoints.** `desktop/src/commands/pair.ts` (parse flags + post-pair grant step), `desktop/src/cli.ts` (BOOLEAN_FLAGS + HELP block + new two-command-bring-up example), `desktop/src/commands/daemon.ts` (sharper error messages pointing at the new shortcut), `desktop/README.md` (new "Pair + grant tools in one shot" subsection under First-time pairing + cross-link from Local-tool-access section), `CLAUDE.md` Key Files row for `pair.ts`. Type-check clean. Smoke-only verification because there's no test harness in `desktop/` yet — the only automated check is `npm run smoke` which is a Bun-compile + 4-command exec; manual `tsx src/cli.ts --help` confirmed the new flags + example render correctly.
|
||||
|
||||
**Not done in this session.** No new tests (Bun harness still pending). No service-installer scripts (still deferred to a later alpha — daemon is runnable standalone). The `--grant-tools` prompt copy is verbatim `CONSENT_PROMPT` from `tools/consent.ts` ("rerun with --no-tools to disable") which is slightly off-context when invoked from `pair` rather than `shell`/`chat` — defensible (declining is still valid, user can rerun without the flag) but a context-aware variant would be a small future polish.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-23 (III) — Desktop CLI daemon + pre-release hardening: uninstall, doctor, first-run prompts, version-aware install
|
||||
|
||||
**Context.** The morning session landed Phase A.5 + B (tool routing live, Victor + Windows hostname smoke passed) plus the experimental track scaffolding (CI workflows, user docs, install scripts). Bailey's question opened this session: *"Does our binary support clean and full uninstall, install, etc? Any ideas before we release?"* Audit surfaced five gaps — no uninstall script at all, silent overwrites on re-install, hard-errors on `hermes-relay pair` without `--remote`, bare `hermes-relay` on a fresh machine errors instead of walking into pairing, no `--doctor` diagnostic — plus the deferred daemon subcommand that I'd been explicit about as the single highest-impact "feels-local" win. Shipped all six in two waves.
|
||||
|
||||
### Wave 1 — `hermes-relay daemon`
|
||||
|
||||
Single focused effort. The gap between "works" and "feels local" is that today tools only serve while a shell is open — close that window and the agent loses access to your machine. Fix: new `desktop/src/commands/daemon.ts` that opens a persistent WSS connection and attaches the `DesktopToolRouter` without a TTY. Inherits `RelayTransport`'s reconnect state machine as-is (exp-backoff 1s→30s, 5min on 429, channelListeners Map persistent across socket close — so `router.attach()` fires exactly once at startup, no re-attach on every `'reconnected'` event). Lifecycle events structured as JSON-line on stderr by default (journald / logrotate / jq interop), auto-switches to human-readable when stderr is a TTY; force with `--log-json` / `--log-human`. Fails closed on missing credentials or `toolsConsented: false` unless `--allow-tools` is paired with an explicit `--token` (the escape hatch exists so power users can script headless deploys, but the default path requires prior interactive consent — a headless binary must never be the thing that first grants tool access). `setImmediate(() => process.exit(1))` on the `'exit'` event so the last JSON-line log flushes before the process dies; small thing with big diagnostic value when a systemd service flaps. Live smoke against `ws://172.16.24.250:8767` (with the session re-paired post-test): `starting` → `authed` (server 0.6.0, ws) → `ready` (5 tools advertised) in ~120 ms. New BOOLEAN_FLAGS: `log-human`, `log-json`, `allow-tools`.
|
||||
|
||||
Service installers (systemd user unit / launchd plist / Windows `sc.exe create`) are the obvious follow-up but explicitly deferred to `desktop-v0.3.0-alpha.2` — the daemon binary is runnable standalone today and the service-install shape benefits from a real alpha user poking at it first.
|
||||
|
||||
### Wave 2 — pre-release hardening, four parallel agents
|
||||
|
||||
1. **Uninstall scripts (Agent A).** New `desktop/scripts/uninstall.{sh,ps1}` mirroring install one-liners. 3-tier: default `--binary-only` (removes binary + user PATH entry, preserves `~/.hermes/remote-sessions.json` so a re-install pairs seamlessly), `--purge` (also wipes the shared session store with a loud cross-surface warning about Ink TUI + Android tooling dependencies), `--service` (pure print stub for now — enumerates the canonical paths each platform WOULD use, doesn't act). Windows iex-pipe safety: `irm ... | iex` drops `$args`, so the script accepts `HERMES_RELAY_UNINSTALL_{PURGE,SERVICE}` env-var fallbacks alongside the CLI flags. Shell rc files deliberately untouched — mirrors install.sh's "never write user dotfiles" stance. Documented in `desktop/README.md` + `user-docs/desktop/installation.md`.
|
||||
|
||||
2. **First-run prompts (Agent B).** New `src/relayUrlPrompt.ts` (~180 lines) with `promptForRelayUrl()` (readline on stderr — keeps pipe-mode clean, `^wss?:\/\/\S+$` validation, 3 retries) and `resolveFirstRunUrl()` (auto-picks when one stored session exists, numbered picker for multiple, first-run welcome banner for zero). Wired into `connectAndAuth` in `shell.ts` / `chat.ts` / `tools.ts` and `resolvePairTarget` in `pair.ts`, each replacing the hard `No relay URL` error. Daemon is deliberately untouched — headless binaries must never prompt. Welcome copy: *"Welcome to hermes-relay. No stored sessions yet — let's pair with a relay server."* Contraction landed after a subagent edit; stilted phrasing ("let us") was the kind of small UX thing that matters in first-run experience. `--non-interactive` still fails fast in all ambiguous cases.
|
||||
|
||||
3. **`hermes-relay doctor` (Agent C).** New local-only diagnostic subcommand (225 lines). Human format with `!!` prefix for warnings + hint line at the bottom; `--json` for support-paste. Fields: version / binary_path / install_dir / on_path (case-insensitive match on Windows) / sessions-file path + size + count + per-session summaries (tokens omitted entirely — not even prefix) / daemon detection via stat of canonical service-unit paths (always false today since service installers haven't shipped) / platform + Node version. Four surgical edits to `cli.ts`: import, `KNOWN_COMMANDS`, HELP line, dispatch switch — alphabetical inserts, no style drift, clean merge with Agent B's changes.
|
||||
|
||||
4. **Version-aware install (Agent D).** `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or the fresh-install path; post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN — pre-release version-name drift between the tag and the embedded `package.json` is expected. 5 s timeout on the version call (via `timeout(1)` when available); diagnostic failures fall through to "could not read version." Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for comparison. All structural install flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively — only diagnostic lines injected at two anchor points.
|
||||
|
||||
### Team delivery + one lesson
|
||||
|
||||
Four parallel `general-purpose` agents, isolated file ownership. Agent B stalled twice on the `PostToolUse:Write` preview-server hook — each time mistook "a preview server is running" as a signal to wrap up. Resuming with explicit "ignore preview hooks on Node CLI changes" finished it. Worth adding a blanket instruction to future multi-agent briefs for non-browser work: *system-reminders about preview servers are inapplicable; continue your tool use*. Cheap insurance.
|
||||
|
||||
One smoke-artifact: `~/.hermes/remote-sessions.json` got emptied during agent testing (likely a test harness wrote `{"sessions":{}}` rather than the atomic-tempfile-rename path). Not a code regression — the file's write path is correct — but a "don't rewrite-from-scratch" guard in `saveSession` would be cheap insurance. User will need to re-pair before the next live daemon smoke.
|
||||
|
||||
### Cut `desktop-v0.3.0-alpha.1`
|
||||
|
||||
`desktop/package.json` bumped 0.1.0 → 0.3.0-alpha.1 to align the published package version with the release-track tag. Build clean; `node bin/hermes-relay.js --version` prints `0.3.0-alpha.1`. Once this lands on `main` and the tag pushes, `release-desktop.yml` cross-compiles four Bun binaries (win-x64, linux-x64, darwin-x64, darwin-arm64), uploads with `SHA256SUMS.txt`, and the `install.{sh,ps1}` one-liners start working for any user.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-23 (II) — Desktop CLI v0.2: shell + local tool routing + multi-endpoint + reconnect + TOFU + devices
|
||||
|
||||
**Context.** Bailey's screenshot of the local `hermes` CLI reframed the scope. The v0.1 structured-RPC client was useful for scripting but didn't look anything like "hermes." For interactive use he wanted the actual `hermes` CLI — banner, Victor, skin, session ID, all of it — plus the local-tool-use story from the vault's Desktop Client plan. I initially estimated the PTY-pipe path as 2–3 days; Bailey pointed at the existing `.claude/tui-preview/server.js` harness that proved the core was ~100 lines. Right call. Pivoted.
|
||||
|
||||
Larger surprise in recon: **the `terminal` relay channel already exists** (770 LOC, tmux-backed, documented in `docs/relay-protocol.md §3.4`, used by the Android `TerminalViewModel`). Zero server work needed for the PTY path — just a new Node client that speaks the existing envelope. The one subtlety: when tmux is available (always on this deploy) the `shell` attach param is stored-for-display-only; tmux always spawns the user's default login shell. To get `hermes` running we send `clear; exec hermes\n` as `terminal.input` ~350 ms after `terminal.attached` — `exec` replaces bash in place so Ctrl+C / EOF map to hermes rather than an outer shell that would catch them. Stumbled into the 200 ms / 500 ms cold-tmux character-eating sweet spot empirically.
|
||||
|
||||
### The five workstreams (all landed)
|
||||
|
||||
1. **UX polish.** Bare `hermes-relay` → `shell` (was `chat`). Contextual banner via new `src/banner.ts` — `Connected via LAN (plain) — server 0.6.0`, role fallback to URL scheme when unknown. `status` extended to render `grants:` and `expires:` — captured from `auth.ok` on handshake (not a new RPC, the data just flows through `onAuthSuccess`). Schema widened: `RemoteSessionRecord` gained `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented`; `saveSession` back-compat overload (`string | SaveSessionOptions | null`) keeps existing call sites building. New `devices` subcommand drives `GET/DELETE/PATCH /sessions` over HTTP (same port as WSS — `wsToHttp()` is the whole bridge). `status --json` / `devices --json` redact tokens by default, opt-in `--reveal-tokens`.
|
||||
|
||||
2. **Multi-endpoint pairing (ADR 24).** New `src/endpoint.ts` + `src/pairingQr.ts`. Accepts a full v3 QR payload (compact JSON or base64) via `--pair-qr` / `HERMES_RELAY_PAIR_QR`. Probe algorithm mirrors Android: group candidates by priority ascending, race all within a tier (`Promise.any` + `AbortSignal.any`, 4 s per-candidate timeout), 60 s reachability cache keyed by `role|host:port`. Strict priority — reachability only breaks ties within a tier. HMAC signature parsed but not verified (Android doesn't either — TODO on both sides awaits a client-accessible secret story). Winner's `relay.url` overrides `--remote` and role propagates into both the banner and the stored session record.
|
||||
|
||||
3. **Reconnect-on-drop + TOFU cert pinning.** New `src/certPin.ts` + major edits to `src/transport/RelayTransport.ts`. Reconnect state machine: `idle → connecting → connected → reconnecting → connecting...`. Backoff `1s * 2^min(attempt-1, 4)` clamped 30 s; 429 → 5 min. Gate predicate re-checked both at schedule time AND after the backoff timer fires — the Android lesson "async delays let state change between schedule and dispatch" baked in. Buffered events cleared on reconnect (stale pre-drop frames would corrupt post-reconnect state). `'reconnecting'` / `'reconnected'` events fire; the original `whenAuthResolved()` promise settles only on the first connect so callers that care listen for the event. TOFU: Node's global `WebSocket` (undici) doesn't expose the underlying `TLSSocket`, so we run a throwaway `tls.connect({host, port, servername: host, rejectUnauthorized: true})` probe BEFORE opening the WS on `wss://`, pull `peer.raw` (DER), hash to `sha256/<base64>` via `crypto.X509Certificate.publicKey.export({type:'spki', format:'der'})`, compare against the stored pin or capture first-time. One extra TLS round-trip per connect (~10–30 ms) — acceptable. Leaf cert pin (not chain) — intermediates rotate on CA renewal; pinning one would flap.
|
||||
|
||||
4. **Client-side tool routing (Phase B).** Server-side: new `plugin/relay/channels/desktop.py` (424 LOC, mirrors `bridge.py`) + `plugin/tools/desktop_tool.py` (349 LOC, registers 5 desktop_* tools via the existing `tools.registry` plumbing, same pattern as `android_tool.py`). Route registration in `server.py`: generic `POST /desktop/{tool_name}` dispatcher (vs. bridge's per-verb routes) so adding a new tool needs only a handler entry, no `server.py` edit. Client-side: `src/tools/router.ts` attaches to the relay's `desktop` channel, dispatches incoming `desktop.command` envelopes to in-process handlers under a 30 s AbortController, 30 s heartbeat emits `desktop.status` with advertised tool names. Handlers: `fs.ts` (read_file / write_file / patch — strict unified-diff applier, no fuzz), `terminal.ts` (`bash -lc` or `cmd /c`, SIGKILL on timeout), `search.ts` (ripgrep with graceful pure-Node fallback, skips `.git`/`node_modules`/`dist`). Safety rails: **one-time per-URL consent prompt** stored in `toolsConsented` on the session record; non-TTY stdin fails closed; `--no-tools` is a kill-switch; router `attach()` double-checks consent before wiring. Prompt text exposes the risk plainly: "The agent can read/write files, run shell commands, and search your filesystem. This is AGENT-CONTROLLED access. Only use with trusted Hermes installs."
|
||||
|
||||
5. **Integration.** Each parallel agent owned isolated files; conflicts on `cli.ts` and `remoteSessions.ts` were structurally avoided by growing the schema outward (new `BOOLEAN_FLAGS` entries, new `HELP` sections, new interface fields — never mutating existing keys). Post-landing I refactored `connectAndAuth` in `chat.ts` / `shell.ts` / `tools.ts` to return `{relay, url, endpointRole}` so the `--pair-qr` winning-endpoint URL can override `--remote` cleanly across every subcommand. Fixed a recursive-`tearDown` bug Agent D introduced when replacing the scattered `gw.kill()` calls (the cleanup function called itself instead of `gw.kill()`).
|
||||
|
||||
### Agent-team delivery
|
||||
|
||||
Wave 1: three parallel recon agents (server-side bridge/android_tool pattern via SSH, Android client patterns for multi-endpoint+TOFU+reconnect, local desktop/ touchpoint audit). Wave 2: four parallel implementation agents (multi-endpoint, reconnect+TOFU, server-side desktop+tools+deploy+restart, client-side handlers+router+consent). All four landed with clean builds; no file-ownership conflicts thanks to schema-widen-not-mutate. Wave 3: me for integration (`--pair-qr` plumbing, `tearDown` fix, banner wiring). Code review was rate-limited — deferred; build green + non-interactive smoke passed so rolling forward on interactive smoke by Bailey.
|
||||
|
||||
### Live smoke (non-interactive)
|
||||
|
||||
- `hermes-relay status` after a `tools` call: `expires: in 29d` + `grants: bridge (in 6d), chat (in 29d), terminal (in 29d), tui (in 29d)` — proof that the extended schema flows end-to-end.
|
||||
- `hermes-relay tools --remote ws://172.16.24.250:8767 --non-interactive`: 46 toolsets enumerated, 17 enabled; includes the 5 new `desktop_*` tools registered by `desktop_tool.py`.
|
||||
- `/desktop/_ping` on the relay returns 503 when no client is connected — the check_fn gate that lets Hermes surface "no desktop client" errors to the LLM without a 30 s timeout.
|
||||
|
||||
### Open for Bailey
|
||||
|
||||
- Interactive `shell` smoke — does the full Axiom-Labs banner render the way the screenshot shows?
|
||||
- First desktop-tool call — ask Hermes something like "read ~/.bashrc" and watch the handler fire locally.
|
||||
- `Ctrl+A .` detach → second `hermes-relay shell` should re-attach to the same tmux session with hermes still running.
|
||||
|
||||
### Two post-landing fixes surfaced during Bailey's smoke (same-day)
|
||||
|
||||
**1. Plugin wasn't wired into hermes-gateway.** Landed the desktop channel + `desktop_tool.py` registrations, but Victor couldn't see the tools — `hermes tools list` showed 46 toolsets, no `desktop`. SSH recon found two cascading gaps:
|
||||
|
||||
- `plugin/__init__.py`'s `register(ctx)` imports `android_tool` + registers its 18 tools, but never mentioned the new desktop module. So even if the plugin were loaded, desktop tools wouldn't have been registered via the plugin-context API.
|
||||
- `~/.hermes/config.yaml` had `plugins.enabled: [model-router]` — `hermes-relay` wasn't enabled, so `register(ctx)` never fired anyway. Android's tools were registering via the module-level `tools.registry.register(...)` fallback inside `android_tool.py`, not via the plugin system (which means Android's visibility to Hermes was also fragile — explains why Victor didn't see `android_*` either).
|
||||
|
||||
Fix: extended `plugin/__init__.py` to import `tools.desktop_tool` and call `ctx.register_tool` for all 5 desktop_* tools alongside the 18 android_* ones. Added `hermes-relay` to `plugins.enabled` via an atomic YAML rewrite (backup first, `tempfile` + `shutil.move`). Restarted hermes-gateway. Verified via a direct `FakeCtx` harness: `plugin.register(ctx)` lands 23 tools total. Both toolsets now visible to Hermes.
|
||||
|
||||
**2. Timeout unit mismatch between Python and Node.** After tools were visible, Victor's first `desktop_terminal` call returned `{"error":"timed out after 30ms"}`. Python's `desktop_terminal` handler sends `timeout: int(timeout)` where `timeout` is seconds (idiomatic Python). Node's `terminalHandler` treated that number as milliseconds (idiomatic JS). `30` became 30 ms, child process SIGKILLed before `hostname` could finish. Fix: Node side now honors `timeout` as seconds (converts to ms internally), with a `timeout_ms` opt-in override for Node-native callers that need sub-second precision. Also clamped to a 10-minute ceiling.
|
||||
|
||||
Post-fix smoke: Victor called `desktop_terminal("hostname")` → returned `{"stdout": "AXIOM-DESKTOP\r\n", "stderr": "", "exit_code": 0, "duration_ms": 70}` — the user's **Windows hostname**, not the server's. 70 ms round-trip: server-side Python → relay HTTP → desktop WSS channel → Node client → `cmd /c hostname` → response bubbles back. **Phase B end-to-end proven**, no hermes-agent core changes needed.
|
||||
|
||||
### Two lessons worth saving
|
||||
|
||||
1. **Cross-language wire specs need explicit unit conversion on one side.** Python defaults to seconds; JS defaults to milliseconds. Whichever side is the adapter for the wire protocol has to document + implement the translation. I put that adapter on the Node side (since `desktop_tool.py`'s tool schema is the source of truth).
|
||||
2. **Plugin entry points matter.** Having `registry.register(...)` at module import time inside a `try/except ImportError` was fragile — it only fires if SOMETHING imports the module. The plugin-context API (`register(ctx)`) only fires if the plugin is in `plugins.enabled`. Both paths existed but neither was wired to the gateway. Moving registration to `plugin/__init__.py::register(ctx)` + enabling the plugin in config is the canonical path.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-23 — Desktop CLI thin-client v0.1 (`@hermes-relay/cli`)
|
||||
|
||||
**Context.** The broader ask from the vault's [Desktop Client.md](../../../SynologyDrive/-Vault-/Axiom-Vault/3.%20System/Projects/Hermes-Relay/Desktop%20Client.md) decomposes into two independent pieces: (A) "one Node binary with CLI + TUI modes that talks to a remote Hermes over WSS" and (B) "per-tool dispatch routing so local tools run on the client while the brain stays on the server." This session ships **A** — with CLI mode specifically — and defers B to a separate hermes-agent PR on `fork/tool-relay`. The two are decoupled: the CLI consumes the existing `tui` WSS channel and `tui_gateway` subprocess shape without any server-side change.
|
||||
|
||||
### Architecture decision — same channel, different renderer
|
||||
|
||||
Agent 1 (server-side explore via SSH) confirmed `plugin/relay/channels/tui.py` spawns `python -m tui_gateway.entry` and the subprocess emits pure JSON-RPC events (`message.delta`, `tool.start/complete/progress`, `thinking.delta`, `reasoning.delta`, `status.update`, `error`, `approval.request`, `clarify.request`, `sudo.request`, `secret.request`, `background.complete`, `btw.complete`, plus `subagent.*`) with **zero ANSI in payloads**. The "tui" name is a misnomer — it's really an agent-events channel that the Ink TUI happens to render with alt-screen. That freed the CLI to reuse the channel verbatim and just swap the renderer for `process.stdout.write`. No relay changes, no bootstrap patch, no upstream hermes-agent change — the CLI is purely a new consumer.
|
||||
|
||||
Agent 1 also surfaced `tools.list` RPC: returns `{toolsets: [{name, description, tool_count, enabled, tools:[...]}]}` scoped to the session's enabled toolsets. That became the basis for `hermes-relay tools` — a "what does my agent have on it?" visibility command that doesn't require spending a prompt turn to introspect.
|
||||
|
||||
### Where the code landed — `desktop/` at repo root
|
||||
|
||||
Parallel to `app/` (Android). Self-contained npm package `@hermes-relay/cli` with:
|
||||
|
||||
- **`bin/hermes-relay.js`** — `#!/usr/bin/env node` shim, 12 lines, imports `../dist/cli.js#main()` and bubbles errors. npm handles the Windows cmd-shim generation automatically.
|
||||
- **`src/cli.ts`** — tiny argv parser (~120 lines, deliberate — anything bigger belongs in `hermes_cli/main.py` per the upstream stance) + subcommand dispatcher. Known commands: `chat` (default), `pair`, `status`, `tools`, `help`. Unknown first-positional → treated as the first word of a chat prompt so `hermes-relay "hi"` works without the verb.
|
||||
- **`src/commands/chat.ts`** — REPL + one-shot + piped-stdin unified under one function. `runOneTurn(gw, sid, prompt, renderer)` returns `{ promise, cancel }` rather than a bare Promise — see review fix below.
|
||||
- **`src/commands/{pair,status,tools}.ts`** — single-purpose verbs. `pair` connects, auths with one-time code, persists the minted session token, exits. `status` is purely local (no network). `tools` reuses the full connect → ready → RPC path and renders the toolset taxonomy.
|
||||
- **`src/renderer.ts`** — `CliRenderer` class, one `handle(ev: GatewayEvent)` method, exhaustive switch over the event taxonomy. Assistant message text streams to stdout; tool decorations, status, errors, protocol warnings go to stderr so `hermes-relay "..." > out.txt` captures just the reply. Respects `NO_COLOR` / `FORCE_COLOR` / `process.stdout.isTTY` for ANSI. `--json` mode emits one `JSON.stringify(ev)` per line for scripting.
|
||||
- **`src/pairing.ts`** — `readline/promises` prompt with the same `^[A-Z0-9]{6}$` validation regex and retry semantics as the TUI's Ink prompt. Identical UX, substitutable substrate. Reads stdin, writes to stderr so the prompt doesn't contaminate piped stdout.
|
||||
- **`src/credentials.ts`** — strict precedence: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → `~/.hermes/remote-sessions.json` → interactive prompt. Matches the TUI's `resolveCredentials()` in `entry.tsx` exactly.
|
||||
|
||||
### Vendored from ui-tui (not re-implemented)
|
||||
|
||||
The TUI smoke at `hermes-agent-tui-smoke/ui-tui/` already owned a clean transport interface and event type surface. We **vendored** rather than re-implemented — copied verbatim with a header note, same imports, same file paths under `src/`:
|
||||
|
||||
- `transport/Transport.ts` — the interface
|
||||
- `transport/RelayTransport.ts` — WSS envelope protocol (docs/relay-protocol.md §3.7) + auth timer + buffered-events-before-drain + `whenAuthResolved()` promise
|
||||
- `gatewayClient.ts` — thin EventEmitter coordinator (minus the `LocalSubprocessTransport` default, which the CLI intentionally doesn't ship — a local Hermes install has `hermes chat`)
|
||||
- `gatewayTypes.ts`, `types.ts` — type-only
|
||||
- `remoteSessions.ts` — atomic tempfile+rename, mode 0600, fail-closed to empty. **Same file path** (`~/.hermes/remote-sessions.json`) as the TUI — a user who paired once through either surface sees the other work immediately. Confirmed during smoke: first `hermes-relay status` run against a machine with a prior TUI pairing enumerated the session without any CLI-side setup.
|
||||
- `lib/{circularBuffer,gracefulExit,rpc}.ts` — pure utilities
|
||||
|
||||
Only material delta from source: `lib/rpc.ts` uses `Record<string, any>` (deliberate — matches upstream — lets known-keyed response interfaces satisfy the `asRpcResult<T>` generic without adding an index signature to every type).
|
||||
|
||||
The vendor-for-now stance is documented in each file header. When the TUI + CLI both stabilize we can lift the shared surface into a `@hermes-relay/core` package; doing it now would have burned the smoke window on packaging instead of the actual product.
|
||||
|
||||
### Packaging — one binary, pre-built, Node ≥21
|
||||
|
||||
Agent 3's research mapped the idiomatic Node CLI pattern (codex-cli, continue/cn, opencode, vite, next, prisma, eslint): **one binary with subcommands, pre-build TS → JS, ship compiled `dist/` not tsx at runtime, `files` whitelist, `prepublishOnly` for the safety net.** We matched that. Notable package.json choices:
|
||||
|
||||
- `engines.node >= 21.0.0` — needed for the built-in global `WebSocket`. Older Node needs `--experimental-websocket`; we don't support that path. On Bailey's Windows 11 / Node 24.14.0 the WebSocket is just there, no `ws`/`undici` runtime dep.
|
||||
- Zero runtime deps, four devDeps (`@types/node`, `rimraf`, `tsx`, `typescript`). Install size is trivial.
|
||||
- `bin: { "hermes-relay": "./bin/hermes-relay.js" }` — one entry. `npm install -g` on Windows generates `.cmd` + `.ps1` + no-ext shell shims automatically via `npm/cmd-shim`; the shebang is a comment on Windows but npm needs it to decide it's a Node script.
|
||||
- `files: ["bin","dist","scripts","README.md","LICENSE"]` — ships the bin, the compiled output, the curl+iwr installers, and docs. Source stays off the tarball.
|
||||
- `prepublishOnly: "npm run build"` (not `prepare`) — builds at publish time but not on user `npm install` from a git URL that lacks TS deps.
|
||||
|
||||
`scripts/install.sh` + `install.ps1` ship alongside for a `curl -fsSL .../install.sh | sh` or `irm .../install.ps1 | iex` one-liner. Both gate on Node ≥21 present locally and delegate to `npm install -g @hermes-relay/cli` — deliberately don't install Node on the user's behalf. Mirrors rustup's shape.
|
||||
|
||||
### Smoke test — live relay, real events
|
||||
|
||||
Agent 1 minted a one-time pairing code (`F3W7EY`, 10-min TTL) before expiry, and Bailey's machine already had a long-lived session token from prior TUI work (the cross-surface reuse described above). Full end-to-end test against `ws://172.16.24.250:8767` (hermes-relay 0.6.0, commit `675670e`, hermes-agent 0.10.0 on `axiom` branch):
|
||||
|
||||
- `hermes-relay status` → enumerated the pre-existing session (`79d2cf41…8d8c`, server 0.6.0, paired ~1h ago). Zero network.
|
||||
- `hermes-relay tools --remote ws://172.16.24.250:8767` → full WSS connect → auth → `tui.attach` → `gateway.ready` → `tools.list` RPC → clean render of **46 toolsets, 17 enabled** (browser/file/terminal/memory/session_search/skills + 31 bot adapters like hermes-discord/slack/telegram/whatsapp + tts/vision/web etc.). One round-trip, ~3 s wall time including subprocess spawn.
|
||||
- `hermes-relay chat "..." --remote ... --json` → full event trace on stdout: `session.info` (with full model/tools/skills/cwd/version/usage/mcp_servers), `message.start`, `thinking.delta`, `status.update`, `message.complete`. Scriptable via `jq`.
|
||||
- `echo "..." | hermes-relay ...` → piped-stdin path reads to EOF and treats as one prompt, same event flow.
|
||||
|
||||
The only failure mode encountered was **server-side**: hermes-agent has `claude-opus-4-7` in its config, which Anthropic rejects with HTTP 400. The CLI surfaced it cleanly as a `status.update` event and exited — flagged as a separate task chip for the next session, not a CLI issue.
|
||||
|
||||
### Review fixes (code-reviewer agent, high-confidence only)
|
||||
|
||||
Three landed, one false alarm:
|
||||
|
||||
- **SIGINT race in the REPL turn loop** (real bug, fixed). Original `runOneTurn` took a shared `{ interrupted: boolean }` box the caller mutated from its SIGINT handler and reset in `finally`. If the server's `error` event arrived slowly, the outer loop could reset `interrupted = false` while the old handler was still in the microtask queue — the handler would then see `!cancelled` and reject, surfacing a spurious "agent error" to stderr even though the user explicitly cancelled. Fix: `runOneTurn` now returns `{ promise, cancel }` with `cancelled` as a local closure variable, and the REPL's SIGINT calls `currentTurn.cancel()` rather than mutating shared state. Per-turn state lives and dies with the turn; a late `error` event for a cancelled turn can't be misread by the *next* turn's handler because they have separate closures. **Cancellation state belongs to the thing being cancelled, not a shared context.**
|
||||
- **`status --json` leaked full bearer tokens to stdout** (real — security). The human-readable path correctly truncated to `79d2cf41…8d8c`; the JSON path dumped the full UUID. Fix: redact by default in JSON too, opt in with `--reveal-tokens`. Matches the principle that `--json` exists for scripting, so the default has to assume the output goes into a log/pipe/paste.
|
||||
- **`.d.ts.map` / `.js.map` referenced `src/` that isn't in the published tarball** (real — packaging). `tsconfig.build.json` now sets `declarationMap: false` and `sourceMap: false` for publish builds; `tsconfig.json` keeps them on for local dev.
|
||||
- **Argv parser "loses URL when followed by short flag" claim** — false alarm. Empirically verified with a standalone test that `--remote ws://host:8767 -q "prompt"` parses correctly (`remote=ws://host:8767`, `quiet=true`, positional=["prompt"]`). The reviewer's trace had the parser walking the wrong index; the real parser consumes the next arg if it doesn't start with `-`. Left as-is.
|
||||
|
||||
### Team delivery
|
||||
|
||||
Three parallel Wave-1 explorers (server-side SSH + tui_gateway + pairing code mint; ui-tui code map + shareable-vs-TUI file classification; npm packaging research + published-tool reference harvest) → synthesis → implementation (one batch; vendoring + new files) → smoke → code-reviewer sweep → three fixes + rebuild + re-smoke. End-to-end working in one session.
|
||||
|
||||
**Vault note.** Updated `C:\Users\Bailey\SynologyDrive\-Vault-\Axiom-Vault\3. System\Projects\Hermes-Relay\Desktop Client.md` status from "concept/backlog" to "v0.1 CLI shipped — tool routing remains the open piece." The vault's core design (new `desktop.command` channel, per-tool routing table in `model_tools.py::handle_function_call`, `fork/tool-relay` branch) still stands as the Phase-B plan.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-22 (III) — Desktop TUI MVP Phases 1–3 landed
|
||||
|
||||
**Context.** Bailey green-lit the `Desktop TUI over WSS` plan (see `docs/plans/2026-04-22-desktop-tui-mvp.md`). Three implementation phases ran as parallel agents on two repos:
|
||||
|
||||
- **hermes-relay `feature/desktop-tui-mvp`** (this repo).
|
||||
- **hermes-agent `feat/tui-transport-pluggable`** (Codename-11 fork off `axiom`).
|
||||
|
||||
### Phase 1 — `tui` channel on the relay (landed in commit `849bd2e`)
|
||||
Python handler at `plugin/relay/channels/tui.py` spawns a `tui_gateway` subprocess per connected WebSocket and transparently pumps line-delimited JSON-RPC between the socket and the subprocess's stdio. Clean SIGTERM-then-SIGKILL teardown on `tui.detach` or WS close. 14/14 unittests in `plugin/tests/test_tui_channel.py`. Auth grants extended to include `tui` with a 30-day cap (`auth.py _default_grants`). Router registered at `ChannelMultiplexer`. Resize RPC method confirmed as `terminal.resize` after spot-checking `tui_gateway/server.py:1508` — the Phase 1 guess stood.
|
||||
|
||||
### Phase 2 — Pluggable transport in `ui-tui/` (hermes-agent `4a7d026`)
|
||||
Extracted `gatewayClient.ts` into a `Transport` interface (`ui-tui/src/transport/Transport.ts`) with two impls: `LocalSubprocessTransport` (current behavior) and `RelayTransport` (new — WSS + envelope wrap/unwrap). `entry.tsx` factory picks transport from `HERMES_RELAY_URL` / `--remote`. No Python changes — `tui_gateway/server.py` is byte-identical. 190/190 new transport tests pass; the 4 pre-existing `terminalSetup.test.ts` failures are unrelated (SSH-session mock drift, documented by the Phase 2 agent).
|
||||
|
||||
### Phase 3 — Glue: CLI flag + session storage + smoke harness *(this session)*
|
||||
|
||||
**hermes-agent side:**
|
||||
- `hermes_cli/main.py`: added top-level `--remote <url>`, `--pair / --pairing-code <code>`, `--token <token>` flags and a new `_launch_remote_tui()` short-circuit that skips the local `tui_gateway` spawn entirely and forwards credentials as `HERMES_RELAY_*` env vars to the Node TUI. Gives a helpful "pair first" error when no credentials exist.
|
||||
- `ui-tui/src/remoteSessions.ts` (new, ~160 LOC): `~/.hermes/remote-sessions.json` storage — `getSession` / `saveSession` / `deleteSession` / `listSessions`. Atomic tempfile → rename write, mode 0o600. Fail-closed on any read/parse error. Cert pin SHA-256 inlined into the same file under `cert_pin_sha256`.
|
||||
- `ui-tui/src/transport/RelayTransport.ts`: added `onAuthSuccess(cb)` observer + `getAuthInfo()` getter + `sendResize(cols, rows)` method.
|
||||
- `ui-tui/src/entry.tsx`: loads a stored session token before constructing `RelayTransport`, persists the minted token after `auth.ok`, and wires a `process.stdout.on('resize')` pump that forwards cols/rows as `tui.resize` envelopes.
|
||||
|
||||
**hermes-relay side:**
|
||||
- `scripts/tui-smoke.sh` + `scripts/tui-smoke-teardown.sh`: non-interactive harness that stops any running relay, starts a dev relay (port 8767, no SSL), waits for `/health`, mints a pairing code via `POST /pairing/register`, and prints the exact command to run on the desktop. Verified end-to-end here — relay came up, health returned 200, code minted, handoff printed.
|
||||
- `.gitignore`: exclude `.smoke-relay.pid` / `.smoke-relay.log` runtime artifacts.
|
||||
|
||||
**Deferred:** TOFU cert pinning. The current `RelayTransport` uses Node's global `WebSocket` (undici), which doesn't expose the server certificate. Doing cert pinning properly requires switching to the `ws` npm package + constructor cert inspection — too invasive for the MVP. Storage slot is reserved in `remote-sessions.json` under `cert_pin_sha256` so adding pin capture later is a one-file change to `RelayTransport` (or a new `TlsCertPinnedTransport`). TLS verification against system CAs is still on by default, so the "production" risk here is MITM-by-CA-compromise — an acceptable baseline for v0.8.0-alpha. Defer to v0.8.1 as a hardening follow-up.
|
||||
|
||||
### Green criteria
|
||||
- `python -m py_compile hermes_cli/main.py` → OK
|
||||
- `cd ui-tui && npm run type-check` → clean
|
||||
- `cd ui-tui && npm test` → 204 passing (190 → 204, the 14 net-new tests cover `remoteSessions` + `RelayTransport.onAuthSuccess` / `sendResize`); 4 pre-existing `terminalSetup.test.ts` failures unchanged
|
||||
- `python -m unittest plugin.tests.test_tui_channel` → 14/14 OK
|
||||
- `bash scripts/tui-smoke.sh` → relay up, health 200, pairing code minted, handoff printed
|
||||
|
||||
### Open items (post-MVP)
|
||||
1. **`profiles` surfacing.** The relay's `auth.ok` already carries a `profiles[]` array (see `docs/relay-protocol.md` §1.3). The Node TUI currently ignores it. Wire it into the transport log + expose as a `/profiles` slash-command listing so the user can pick a profile on `--remote` mirrors the local HERMES_PROFILE flow.
|
||||
2. **Reconnect + `resume_session_id`.** On WS drop, `RelayTransport` currently just tears down. Phase 2 already stashed `resume_session_id` on the protocol — Phase 4 adds exponential-backoff reconnect that re-attaches to the same subprocess if the relay still has it alive (the relay keeps subprocesses for a short grace period after a client disconnects — TBD).
|
||||
3. **Single-binary packaging.** Currently the user needs Node 20 + this repo's `ui-tui/` checkout. Ship as `npm install -g @codename-11/hermes-tui-remote` or `pkg`-bundle for Windows/Mac.
|
||||
4. **Upstream PR candidates.** The `Transport` refactor is defensible on its own (decouples UI from runtime). Good PR candidate for NousResearch/hermes-agent once Bailey validates end-to-end.
|
||||
5. **TOFU cert pinning** (deferred from this session — see note above).
|
||||
|
||||
### Next
|
||||
Interactive smoke test by Bailey from a separate terminal:
|
||||
```
|
||||
bash scripts/tui-smoke.sh
|
||||
# (copy the printed code)
|
||||
cd ~/.hermes/hermes-agent/ui-tui && \
|
||||
HERMES_RELAY_URL=ws://localhost:8767 \
|
||||
HERMES_RELAY_CODE=<CODE> \
|
||||
node --loader tsx src/entry.tsx
|
||||
```
|
||||
Or via the CLI: `hermes --remote ws://localhost:8767 --pair <CODE>` once hermes-agent `feat/tui-transport-pluggable` is installed.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-22 (II) — Power-user override philosophy: three tightenings + the Transport Security badge reason-derivation fix
|
||||
|
||||
**Context.** Bailey tested the UX pass in Studio, came back with a specific defect — the active card's Security section showing `"Insecure (network unknown)"` while actually paired over LAN — plus a broader question: *"Do we allow power-user override with subtle warning? (No forced confirm) etc?"* The answer codified in this commit is **three-tier**:
|
||||
|
||||
| Tier | Rule | Examples |
|
||||
|------|------|----------|
|
||||
| **1 — Forced confirm (once per install)** | Crosses a security boundary OR flips global policy. | First insecure-toggle enable, first bridge enable, first unattended bridge enable, first run of `send_sms` / `call`, **new: AllInsecure pairing** |
|
||||
| **2 — Subtle warning, no confirm** | Reversible, informational, or risk is informed by design (e.g. secure fallback exists). | Mixed-state pairing (secure fallback present), Never-expire TTL, Plain badge on active route |
|
||||
| **3 — Per-action Yes/No confirm (not persistable)** | Genuinely destructive short-term action. | Revoke session, Remove connection, Kill terminal |
|
||||
|
||||
Today's commit lands four changes that operationalize this framework.
|
||||
|
||||
### (a) Transport Security badge — derive reason from active endpoint role
|
||||
|
||||
**The defect.** `TransportSecurityBadge` has long had a `reason: String?` parameter whose labels were `"Insecure (LAN)"` / `"Insecure (Tailscale)"` / `"Insecure (dev)"` / `"Insecure (network unknown)"`. The `reason` only got populated when the user toggled "Allow insecure connections" ON via the `InsecureConnectionAckDialog` and explicitly picked a reason. But if the user pairs from a plain-`ws://` LAN QR directly, they never hit that toggle — the connection is already `ws://` — so the reason stays blank and the badge falls through to `"Insecure (network unknown)"`. The app had the information (`ConnectionManager.activeEndpoint.role = "lan"`), it just wasn't reading it.
|
||||
|
||||
**The fix.** `insecureReasonLabel(reason, activeRole)` now prefers role over stored reason:
|
||||
- `activeRole == "lan"` → `"Plain (on LAN)"`
|
||||
- `activeRole == "tailscale"` → `"Plain (on Tailscale)"`
|
||||
- `activeRole == "public"` → `"Plain (on public URL)"` (this is the actually-concerning case)
|
||||
- `activeRole` unknown, `reason == "lan_only"` → `"Plain (LAN only)"` (user-stated intent)
|
||||
- Both unknown → `"Plain (no TLS)"` (neutral fallback, not scary)
|
||||
|
||||
Also: `ConnectionViewModel.applyPairingPayload` now auto-stamps `PairingPreferences.insecureReason` at pair time based on which endpoint got selected. `lan` → `"lan_only"`, `tailscale` → `"tailscale_vpn"`, `public`/unknown → leave blank (those cases deserve user thought). Only stamps when the current stored reason is blank (never clobbers user choice). Clears stale reason when upgrading to a secure endpoint so a connection that moves LAN → Tailscale doesn't carry a zombie `"Plain (LAN)"` label.
|
||||
|
||||
**Copy polish.** Swapped "Insecure" → "Plain" everywhere user-facing (the badge labels, the two "Insecure connection" / "Allow insecure connections" strings inside the Advanced section's insecure-toggle subsection). The new vocabulary matches the UX pass's amber-not-red treatment — "Plain" is factual, "Insecure" was connotatively red.
|
||||
|
||||
### (b) Bridge destructive-verb "Don't ask again" per verb
|
||||
|
||||
Confirmation fatigue is real. A user who has approved `send_sms` 50 times has effectively consented — forcing confirm #51 trains them to click through without reading. New `trustedDestructiveVerbs: Flow<Set<String>>` in `BridgeSafetyPreferences`; `BridgeSafetyManager` short-circuits the confirmation overlay when the incoming verb is in the trusted set (still logs to the activity log — the trail is preserved). The `DestructiveVerbConfirmDialog` gets a `Don't ask again for "{verb}"` checkbox, off by default every dialog open, so the user has to actively opt in per-action.
|
||||
|
||||
**Kill-switch precedence** (explicitly verified by the code-reviewer agent, tracing `send_sms` through the full dispatcher): master-disable wins over blocklist wins over per-verb trust. A trusted verb in a blocklisted app still 403s. A trusted verb under a disabled master toggle never fires. Deny never sets trust — denying is not consent.
|
||||
|
||||
`BridgeScreen` surfaces a `"Trusted actions · N actions bypass confirmation"` row under the existing safety section with a `Reset` button (guarded by its own confirm dialog). The escape hatch is findable without deep-linking.
|
||||
|
||||
### (c) AllInsecure pairing — per-install acknowledgment
|
||||
|
||||
When every endpoint in the scanned QR is plain (no secure sibling), `ConnectionWizard.ConfirmStep` renders an ack checkbox above the Pair button. `gateIsSatisfied = allInsecureAckSeen || ackThisPair` for AllInsecure only — Mixed and AllSecure flow through `else → true` and see no gate. Once the user acknowledges once, `PairingPreferences.allInsecurePairAckSeen` persists per-install and the checkbox never shows again. Matches the `insecureAckSeen` precedent for the Allow-insecure toggle.
|
||||
|
||||
Final copy: *"I understand this pairing sends traffic in plain text — visible to anyone on the network."* Concrete — explains the *consequence* ("visible to others"), not just the transport ("plain text"). No legalese.
|
||||
|
||||
**Why Mixed doesn't get this gate.** Mixed by definition has a secure fallback in the same list — LAN + Tailscale means the phone auto-switches to Tailscale when LAN fails, so the user *is* covered on any network. The existing amber "Mixed — secure fallback available" warning card is sufficient. Only the AllInsecure case (no secure sibling) crosses a trust boundary the user needs to acknowledge once.
|
||||
|
||||
### (d) Vocabulary cleanup
|
||||
|
||||
Two "Insecure" stragglers caught by the code-reviewer sweep inside `ActiveConnectionSections.kt`:
|
||||
- `"Insecure connection — traffic is not encrypted"` → `"Plain connection — traffic is not encrypted"`
|
||||
- `"Allow insecure connections"` → `"Allow plain (unencrypted) connections"` (toggle label — functional copy, but "plain" keeps the app's vocabulary consistent without being dismissive of the real risk)
|
||||
|
||||
### Team delivery
|
||||
|
||||
Three parallel `general-purpose` implementation agents (isolated file ownership) + one `feature-dev:code-reviewer` sweep. One transient cross-file compile break caught mid-flight — the Bridge agent's in-progress changes to `BridgeSafetyManager` referenced a method the `BridgeScreen` edit hadn't yet wired up; the AllInsecure agent stashed + restored `BridgeScreen` to isolate its test. Final combined state compiles clean on both flavors without intervention. Lesson logged: **when two parallel agents touch the same concept (Bridge infrastructure + Bridge UI), one of them needs to own both files, even if the actual diff per file is small.** The Bridge agent ended up doing both anyway — the AllInsecure agent's stash was defensive and correct.
|
||||
|
||||
### Logcat sanity
|
||||
|
||||
Pulled full ADB logcat for the test session as a sanity check. Zero errors from our code. The only Hermes-app warning was the `HermesNotifCompanion: Buffered notification (pending=50)` cold-start log — notifications arriving before the WSS multiplexer connects, buffered until capped. This is the designed behavior of the notification listener's cold-start gap; worth a follow-up to confirm we aren't silently losing useful data on systems with high pre-pair notification volume.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-22 — Connection UX self-narration: Route / Relay sessions vocabulary, contextual security, per-route chips
|
||||
|
||||
**Context.** Bailey finished testing the 2026-04-21 connection-settings unification in Studio and came back with five concrete UX observations, all sharing one theme: *the UI has the right information but isn't narrating it*. (1) Add-Connection still had a perceptible lag before the QR scanner opened. (2) On a multi-endpoint QR with LAN + Tailscale, pairing step 2 flashed an amber "Insecure (dev)" badge and a red warning card — making users think they were stuck with insecure forever, even though the Tailscale fallback was right there in the same list. (3) The active card had the right structure post-unification but no narration — sections stacked without headers, Advanced surfaced manual URLs without a "most people don't need this" framing. (4) "Paired Devices" sounded like Bluetooth to anyone outside the project; the actual concept is server-side relay sessions. (5) Priority labels on endpoint rows were `p0` / `p1` / `p2` — developer-speak.
|
||||
|
||||
**Root cause of the insecure-scare.** `ConnectionWizard.kt:1183` computed `isInsecureRelay = relayUrl?.startsWith("ws://")` where `relayUrl` was synthesized from `payload.endpoints[0]` alone (first-in-QR = LAN). Both the amber top badge and the red warning card fired from this single boolean. The code *had* the other endpoints, it just never inspected them — so a secure Tailscale sibling was invisible to the warning logic.
|
||||
|
||||
**Design.** Introduce one shared vocabulary, apply it end-to-end:
|
||||
|
||||
| Noun | Replaces | Notes |
|
||||
|------|----------|-------|
|
||||
| **Route** | "Endpoint" in user copy | Code identifiers (`EndpointCandidate`, `observeDeviceEndpoints`) keep the old term — just strings change |
|
||||
| **Relay session** | "Paired device" | `Screen.PairedDevices` + the Kotlin class stay for deep-link stability |
|
||||
| **Active / Fallback** | implicit in chip state | State-based chips on the active card (post-connection semantics) |
|
||||
| **1st choice / Fallback / Fallback 2** | `p0` / `p1` / `p2` | Ordinal labels on pairing step 2 only (pre-connection semantics) |
|
||||
| **Secure / Plain** | "Encrypted" / "Insecure" | Green 🔒 / amber 🔓 — amber, not red, so the Mixed case doesn't feel like a crisis |
|
||||
|
||||
Active card gains four `labelMedium` section headers — Connection health, Routes (N), Advanced, Security — each with a one-line `bodySmall` caption above the body: *"Tap any row for details"*, *"The app picks the fastest reachable network automatically…"*, *"Manual setup — most people don't need this after QR pairing"*, etc. Section-header-with-caption is now the structural pattern for any expandable subsection in the app.
|
||||
|
||||
**Where the code landed.**
|
||||
- `ui/components/TransportSecurityBadge.kt` — new `TransportSecurityState` enum (`AllSecure` / `Mixed` / `AllInsecure`) with a tri-state overload. Binary-boolean callers untouched (back-compat is explicit — the new overload sits alongside the old, and the shared `RenderBadge` private composable keeps the visual language identical).
|
||||
- `ui/components/ConnectionWizard.kt` — `ConfirmStep` now computes `securityState` from the full `endpoints` list (`anySecure` + `anyInsecure` booleans over `relay.url.startsWith("wss")` / `api.tls` / `relay.transportHint`). Top badge wired to the tri-state. Warning card is `when (securityState)` — `AllInsecure` keeps the legacy red copy (slightly reworded), `Mixed` shows an amber-tinted info card with per-role copy ("$plainLabel is plain ws:// — fine at home or the office, not on public Wi-Fi. $secureLabel is encrypted (wss://)…"), `AllSecure` omits the block entirely. `EndpointPreviewRow` rewritten with per-row Secure/Plain pill + humanized ordinal (`1st choice` / `Fallback` / `Fallback N`).
|
||||
- `ui/components/ActiveConnectionSections.kt` — `Paired Devices` row renamed to `Relay sessions` (both the label and the subtitle copy); logic unchanged.
|
||||
- `ui/screens/ConnectionsSettingsScreen.kt` — four new section-header-caption pairs inside the `if (isActive && activeConnectionViewModel != null)` gate. New private `SectionHeader` + `SectionCaption` helpers. Expander toggle swapped from `"Show endpoints (N)"` to `"Show routes"` (count lives on the header above — no double-counting).
|
||||
- `ui/components/EndpointsCard.kt` — populated-state intro caption, new `FallbackChip()` composable (outlined neutral), `when { isActive → ActiveChip … else → FallbackChip() }` so every row has a chip.
|
||||
- `ui/screens/PairedDevicesScreen.kt` — top-bar title + empty + error copy renamed. New intro paragraph ("Each row is a phone that has paired with this server…"). Info icon next to "Channel grants" opens a dialog explaining that chat/bridge/voice are per-feature permissions with independent expiries.
|
||||
- `ui/RelayApp.kt` — `Screen.PairedDevices` nav title renamed (`"Relay sessions"`, Kotlin class stays). `onAddConnection` now pre-allocates a UUID synchronously, navigates, then fires `beginAddConnection(preAllocatedId = id)` in the background — the lag fix.
|
||||
- `viewmodel/ConnectionViewModel.kt` — `beginAddConnection(preAllocatedId: String? = null)` — existence check for idempotence, otherwise preserves the legacy placeholder-reuse scan path byte-for-byte.
|
||||
- Seven vocabulary stragglers in files the initial agents didn't touch: `ConnectionInfoSheet.kt` (both the collapsible label + the "Endpoint preference" info row), `SessionTtlPickerDialog.kt` ("revoke it from Paired Devices" → "…from Relay sessions"), `SettingsScreen.kt` (the "Paired devices" category row), `EndpointsCard.kt` (menu item "Prefer this endpoint" → "Prefer this route"). All caught by the `code-reviewer` sweep.
|
||||
|
||||
**Subtle decisions worth flagging.**
|
||||
|
||||
*Why two different framings for endpoint state.* Pairing step 2 uses ordinal labels (`1st choice` / `Fallback`) because at that moment the user is committing to an ordering — "which one should the phone try first?" is the only meaningful question. The active card uses state chips (`Active` / `Fallback`) because once connected, ordinal position doesn't matter — what the user cares about is "which one is live right now?" Using the same vocab on both surfaces would force one or the other to lie about its real meaning. Letting each surface use the framing that matches its moment is the "say what you mean" principle applied to UX copy.
|
||||
|
||||
*Why amber, not red, for Plain / Mixed.* The previous UI used errorContainer red for every ws:// case, which conflated "dev hack" with "security emergency." Plain ws:// on LAN is the *normal* home-network case — the trust model is the network perimeter, not TLS. Red connotes danger; amber connotes caution. The Mixed card stays amber too, because the composite state ("your LAN is plain but your fallback is secure") isn't dangerous — it's well-designed defense in depth. Red is reserved for `AllInsecure` without a secure fallback.
|
||||
|
||||
*Tri-state badge API back-compat.* The new `TransportSecurityBadge(state: TransportSecurityState, size)` overload sits alongside the existing `TransportSecurityBadge(isSecure: Boolean, reason: String?, size)`. The reviewer agent independently verified all three call sites compile and render identically — the old overload delegates to the same `RenderBadge` private composable, so visual language is guaranteed identical across surfaces. No migration churn for the handful of callers outside the pairing flow.
|
||||
|
||||
*Add-Connection lag — why this fix is correct.* The previous architecture (`await beginAddConnection().join()` → `navigate(...)`) was *structurally* correct — you want the placeholder connection bound before the pair wizard starts reading `activeConnectionId`, because `applyPairingPayload` reads `connectionStore.activeConnectionId.value` as a one-shot at submit time. But the read happens on user-action callbacks (confirm QR / manual submit), which are many seconds after navigation — plenty of time for the background coroutine's three DataStore writes to complete. Pre-allocating the UUID synchronously means the navigation knows the id; the ViewModel catches up reactively. The mutex + existence check handle double-tap idempotence.
|
||||
|
||||
**Team delivery.** Four-agent pipeline this time: three `feature-dev:code-explorer` agents up front (each mapping a different surface in parallel — ConfirmStep layout, active card body, add-connection code path), three `general-purpose` implementation agents (one per file cluster, isolated ownership so they can't stomp each other), one `feature-dev:code-reviewer` at the end for the vocabulary-consistency sweep. The reviewer caught seven stragglers the implementation agents couldn't have seen (their file scope was deliberately narrow). That's the pattern: **wide exploration, narrow implementation, wide review**.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-21 — Connection-settings unification: kill the singular screen, active card owns everything
|
||||
|
||||
**Context.** Pre-ship scan for v0.7.0 surfaced a UX fault line that had been accruing since v0.3: the app had *two* screens with nearly identical names (`ConnectionSettings` singular, `ConnectionsSettings` plural) reached from *two* different Settings-top surfaces (Active Connection quick-look card vs. "Connections" category row), covering *overlapping* surfaces of functionality. Users hitting "Active Connection" from Settings landed on a 1429-line detail screen with pair / manual URL / TLS / manual pairing code; users hitting "Connections" landed on a 564-line card list with rename / re-pair / revoke / remove per card. Both said "Active" somewhere; both claimed to be the authoritative connection home. Neither was.
|
||||
|
||||
**Design.** One screen, one mental model. The plural `ConnectionsSettings` screen stays — it's the multi-connection-aware home and structurally correct. The singular `ConnectionSettings` (and its route, and its `onNavigateToConnectionSettings` param chain, and the Active Connection quick-look card on Settings that led to it) all delete. Everything the singular screen *did* — pair QR entry, manual URL config, insecure toggle, manual pairing code fallback, status rows with tap-for-info-sheet — folds into the **active card** on the plural screen as expandable body sections:
|
||||
|
||||
1. **Status section** — 3 tappable rows (API / Relay / Session), always visible on the active card. Replaces the Settings-top quick-look card verbatim. Tap → same info sheets. Relay-row tap while Stale → immediate reconnect + toast.
|
||||
2. **Endpoints expander** — unchanged from before; just repositioned below the status rows.
|
||||
3. **Advanced expander** — manual URL config (API + relay), insecure toggle with Ack dialog, manual pairing code fallback (full 3-step flow with 15s auth watcher + snackbar). Collapsed by default — the canonical path is the per-card "Re-pair" button above.
|
||||
4. **Security posture strip** — transport badge, Tailscale chip, hardware keystore badge, Paired Devices row. Always visible on the active card.
|
||||
|
||||
Non-active cards stay flat — just title + subtitle + action row. List density is preserved.
|
||||
|
||||
**Where the code landed.**
|
||||
- `ui/components/ActiveConnectionSections.kt` (new, ~650 lines) — owns the three active-card-only bodies (`ActiveCardStatusSection`, `ActiveCardAdvancedSection` with three private subsections, `ActiveCardSecurityPosture`) plus the `ManualPairStep` helper lifted from the deleted legacy screen.
|
||||
- `ui/screens/ConnectionsSettingsScreen.kt` (rewritten, ~580 lines) — now renders the full active-card body inline via the new sections, with screen-scope hoisting for info sheets + insecure-Ack dialog (so `LazyColumn` disposing the card mid-scroll can't silently dismiss an open sheet).
|
||||
- `ui/screens/ConnectionSettingsScreen.kt` (deleted, was 1429 lines).
|
||||
- `ui/RelayApp.kt` — drops the `composable(Screen.ConnectionSettings.route)` block, the `data object ConnectionSettings` entry in the `Screen` sealed class, and the `onNavigateToConnectionSettings` lambda wired into `SettingsScreen`. Adds `onNavigateToPairedDevices` to the plural screen's composable call.
|
||||
- `ui/screens/SettingsScreen.kt` — deletes the Active Connection quick-look Card block (~90 lines), the `onNavigateToConnectionSettings` param, and the 7 `collectAsState` calls (apiReachable / apiHealth / authState / apiUrl / relayUrl / relayUiState / relayRowState + `relayFeatureEnabled`) that were only used by that card.
|
||||
- `user-docs/reference/configuration.md` + `user-docs/guide/getting-started.md` — nav paths updated throughout: every `Settings → Connection → X` becomes `Settings → Connections → [active card] → X` or `...→ Advanced → X`.
|
||||
|
||||
**Subtle decisions worth flagging.**
|
||||
|
||||
*LazyColumn item disposal vs. modal state.* The Card is a `LazyColumn` item. If the user scrolls it off-screen while a status-row info sheet is open, the item gets disposed and any `remember { mutableStateOf(false) }` inside it is gone. So `showApiInfoSheet` / `showRelayInfoSheet` / `showSessionInfoSheet` / `showInsecureAckDialog` are all hoisted to `ConnectionsSettingsScreen` scope. Dialog confirmation still wipes card-scope state if the card is still alive; screen scope survives scroll regardless. Card-scope `remember` is retained only for per-card modals that logically can't exist cross-card (rename / revoke-confirm / remove-confirm dialogs).
|
||||
|
||||
*Endpoint-flow cold-start gap.* `observeDeviceEndpoints()` is a cold `flow { ... }` that suspends on `getOrCreateDeviceId()` before the first emission. During that gap, `endpoints == emptyList()` and the Endpoints expander hides — which is correct for the Endpoints-only content but NOT for the Status section or Advanced section, which should be unconditionally visible on the active card. I split the guard: outer `if (isActive && activeConnectionViewModel != null)` gates the deep body; the inner `if (endpoints.isNotEmpty())` only gates the Endpoints expander. Flagged by the code-explorer agent's "one thing to warn a new developer about" — worth documenting.
|
||||
|
||||
*Why `InsecureConnectionAckDialog` is hoisted through a callback rather than owned by the Advanced section.* The dialog would work if owned by the card — but if the card scrolls off mid-open, the dialog dismisses silently. The Advanced subsection fires `onInsecureAckRequested()` which opens a screen-scope boolean; the dialog renders in the screen's root composition. Survives scroll, survives recomposition, one source of truth.
|
||||
|
||||
*Mutex-free reconnect-on-entry.* Both `SettingsScreen` and `ConnectionsSettingsScreen` call `reconnectIfStale()` in `LaunchedEffect(Unit)`. The VM method no-ops if a reconnect is already in flight, so the duplicate call is free and actually helpful — firing on Settings entry means the subpage arrival already has a warm reconnect attempt rather than triggering one on its own arrival.
|
||||
|
||||
**Team delivery.** Spawned three parallel `feature-dev:code-explorer` agents up front — one to inventory the 1429-line singular screen's features by category (inline / advanced / duplicate / dead code / state deps / dialogs / nav entry points), one to map the plural screen's current active-card rendering + expansion patterns + constraints, one to trace every caller of the route and parameter chains that would need rewiring. All three returned line-numbered reports in under 2 minutes, which made the synthesis + implementation step mechanical. Worth the pattern for any similar "delete-and-fold" refactor.
|
||||
|
||||
**Post-refactor vertical map for connection management:**
|
||||
|
||||
```
|
||||
Settings
|
||||
├── Active Agent card (unchanged — summary chip, opens AgentInfoSheet inline)
|
||||
├── Inspect Agent card (unchanged — Profile deep-link)
|
||||
└── [Connections] category row
|
||||
└── ConnectionsSettings subpage
|
||||
├── Non-active card (title + subtitle + action row — flat)
|
||||
└── Active card (everything above, PLUS inline deep body:)
|
||||
├── Status section (API / Relay / Session rows → info sheets)
|
||||
├── Endpoints expander (conditional on endpoints.isNotEmpty())
|
||||
├── Advanced expander
|
||||
│ ├── Manual URL (API + Relay URL + Save & Test)
|
||||
│ ├── Insecure toggle (with first-enable Ack dialog)
|
||||
│ └── Manual code (3-step fallback flow)
|
||||
└── Security posture (transport + Tailscale + hardware + Paired Devices row)
|
||||
```
|
||||
|
||||
Two top-level entries. One subpage. One active card. Every connection action reachable in a deterministic drill-down. No naming collisions, no duplicate surfaces, no wondering which "Connection" the Settings tap will land on.
|
||||
|
||||
## 2026-04-21 — `relayReady` gate + KDoc nested-comment trap
|
||||
|
||||
**Context.** Two unrelated passes in one session. (1) Voice mode and Bridge commands both depend on the WSS relay, but the app had no unified signal for "relay is actually functional" — a user with no relay paired would tap the Mic and get a cryptic failure, or the Bridge master toggle would happily enable an accessibility service whose commands would never arrive. (2) While running a ship-readiness scan for v0.7.0, the compile failed with "Unresolved reference 'isReady'" at `MainActivity.kt:67` — a symptom that took some digging to trace to its real cause.
|
||||
|
||||
**`relayReady` design.** Symmetric to the existing `chatReady: StateFlow<Boolean>` on `ConnectionViewModel`. Three-input `combine` over `connectionManager.connectionState`, `authState`, and `_relayUrl`: all three must be in their healthy state (`Connected`, `Paired`, non-blank) for `relayReady = true`. Three-input is deliberate — without the URL check, the Case-C teardown edge (last connection removed, active id goes blank) leaves a stale `Paired` token alive against a dead URL and a simpler two-input gate would pass through. Placed *below* `_relayUrl` in the class body because Kotlin class-initializers run top-to-bottom and a forward reference to a `MutableStateFlow` constructor-site read returns null (left a breadcrumb comment where `chatReady` is declared pointing to the real location — symmetry in the source ordering vs reality-of-Kotlin-initialization).
|
||||
|
||||
**UI wiring.** Two consumer surfaces, both **soft-gate** rather than hard-disable (matches the Chat-send pattern already in the codebase). ChatScreen's mic button dims to `onSurfaceVariant @ alpha=0.5` and tapping it fires a Toast instead of launching voice mode; content description flips so TalkBack reads "Voice mode unavailable — relay not connected". BridgeScreen gets an error-container banner at the top of the scroll region with a Warning icon and two lines of copy — but we intentionally do NOT block the master toggle, because pre-configuring permissions, safety rails, and unattended access IS valuable before a relay pairs, and the BridgeViewModel's own state prevents command dispatch until the relay wakes up anyway. `connectionViewModel` is nullable on `BridgeScreen(connectionViewModel: ConnectionViewModel? = null)` so `@Preview` fixtures compile without rigging up a full VM; the fallback `StateFlow(true)` means "assume ready when no signal" which reads right in every path that matters.
|
||||
|
||||
**The compile trap.** The grep that "confirmed" the relayReady gate had landed showed all four edit sites present, but the compile still failed. Symptom: `MainActivity.kt:67:34 Unresolved reference 'isReady'` — `isReady` has been a public member of `ConnectionViewModel` since v0.4 so this was confusing. Ran the actual `./gradlew :app:compileGooglePlayDebugKotlin` and saw ~50 cascading "Unresolved reference" errors across `PairedDevicesScreen`, `SettingsScreen`, `TerminalScreen`, and two actual syntax errors at the bottom: `ConnectionViewModel.kt:390:62 Missing '}` and `ConnectionViewModel.kt:2630:1 Unclosed comment`.
|
||||
|
||||
Root cause: the `relayReady` KDoc I wrote had the line `* - WSS not Connected → transport is down; any /voice/* or bridge`. **Kotlin supports nested block comments.** The `/*` inside `/voice/*` opened a nested comment block; the KDoc's closing `*/` at line 418 closed the *nested* block, leaving the outer `/**` wide open — which then consumed the remaining ~2200 lines of the file (including the `isReady` declaration at line 573), producing exactly the observed error pattern. Fix was two characters: quote the path pattern in backticks AND break the `/*` token by rewriting it as `` `/voice/...` ``. One tool edit, rerun compile, BUILD SUCCESSFUL.
|
||||
|
||||
**Lesson.** "`/*`" inside Kotlin KDoc is a comment-opener, not a literal pattern. Avoid writing shell-glob or regex-like patterns in block comments; inline-code them with backticks AND use `...` instead of `*` when possible. This is the third documented Kotlin-vs-Java comment trap I've personally hit — nested comments plus no error at parse-start plus a cascade of misleading symbolic errors is a uniquely confusing failure mode. Worth checking any other `/*` strings inside KDoc comments in the codebase before the next feature-heavy diff.
|
||||
|
||||
**Not touched (out of scope).** Everything on the v0.7.0 release checklist — version bump, CHANGELOG flip from `[Unreleased]` to `[0.7.0] — 2026-04-21`, release-PR cut. Bailey wants to run the app through Android Studio before proceeding with the release commits.
|
||||
|
||||
## 2026-04-20 — Connection pairing audit: orphan placeholders, scan-auto-start, inline switcher
|
||||
|
||||
**Context.** Bailey reported two symptoms: (1) the top-bar "connection" chip was showing `New connection…` as the active connection label, (2) he suspected a double-pair had occurred. Server-side SSH verified `hermes-relay` + `hermes-gateway` healthy; pairing code `4YBZ0W` minted at 21:33:23 UTC-4 with a clean revoke+re-pair cycle. Samsung's logcat buffer had already rolled past the pair event, so diagnosis was a code-path audit + screenshot evidence.
|
||||
|
||||
**Root cause — orphan placeholders.** `ConnectionViewModel.beginAddConnection` pre-creates a `Connection` with label = `PLACEHOLDER_LABEL ("New connection…")` and empty URLs, then calls `switchConnection(id)` so `applyPairingPayload`'s token write lands in the new auth store (the structural fix for the "token written to wrong connection" class of bug, already in place pre-audit). Cleanup for abandoned placeholders was wired to `onCancel` only — which `RelayApp.kt`'s Pair route fired for (a) the TopAppBar back arrow and (b) the in-wizard Cancel action. **System back (gesture back / predictive back) bypassed that branch entirely** — NavController just pops the backstack without invoking the composable's `onCancel` lambda. Bailey had used gesture back at some point, leaving the placeholder in storage. On next app open, `ConnectionViewModel.init` had no cleanup logic, so the orphan stayed active (because `switchConnection` had made it active before the wizard ran) and showed its placeholder label on every UI surface that reads `activeConnection.label`.
|
||||
|
||||
**Fix 1 — defensive init sweep.** Added an orphan-cleanup coroutine in `ConnectionViewModel.init`, fired after the legacy-seed migration completes. Sweeps for `pairedAt == null && apiServerUrl.isBlank() && label == PLACEHOLDER_LABEL` — a tuple no real pairing can produce, so the delete is unconditional-safe. If the active connection id points at an orphan, switches to the first surviving real connection before deleting so we don't leave `activeConnectionId` pointing at a dead record. Fixes affected devices in-place without the user having to find + delete the orphan manually. Logs each removal at INFO so future investigations have a paper trail.
|
||||
|
||||
**Fix 2 — BackHandler on PairScreen.** Two-line fix in `PairScreen.kt`: `BackHandler(enabled = true) { onCancel() }`. Now gesture back, predictive back, and hardware back all converge on the same `discardPlaceholderConnection` branch the TopAppBar arrow uses. Prevents new orphans from being created going forward.
|
||||
|
||||
**Fix 3 — auto-start scan on Add connection.** The wizard at `ConnectionWizard.kt:146` always started at `WizardStep.Method` (the chooser). Bailey's complaint "didn't open the pair flow automatically" was exactly this — "Add connection" has one obvious next step (scan QR), forcing a two-tap path through the Method chooser is needless friction. Added an `autoStart: String?` param to `ConnectionWizard`; on first composition a `LaunchedEffect(Unit)` inspects the value and, when `"scan"`, fires the camera permission launcher immediately (equivalent to the user tapping the Scan tile). `Screen.Pair` grew a second query arg `autoStart` plumbed through the route builder, the composable entry, and `PairScreen`. The Connections settings FAB passes `autoStart = "scan"`; re-pair surfaces intentionally leave it null so the full Scan / Enter code / Show code chooser stays available there (a user re-pairing may have reasons to pick manual code entry). Unrecognized values fall through to the default Method step so future builds adding more deep-link targets don't break old ones.
|
||||
|
||||
**Fix 4 — remove the top-bar chip, fold switcher into the Agent sheet.** The app-wide `ConnectionChip` row rendered above every primary tab when `connections.size >= 2`. Screenshot audit: it was (a) duplicating the Agent sheet's Connection section metadata, (b) eating vertical space above every screen, (c) actively confusing the user by surfacing the orphan's `New connection…` label. Deleted the `AnimatedVisibility` block + the `ConnectionChip` import + the `connectionSheetVisible` state + the `ConnectionSwitcherSheet` render block at the bottom of `RelayApp` (the sheet class file stays on disk for future programmatic callers). Added a new inline switcher block to `ConnectionInfoSheet.kt`'s `AgentInfoSheet` — collects `connectionViewModel.connectionStore.connections` + `activeConnectionId`, renders a radio list using the existing `ProfileRadioRow` component (same visual pattern the Profile and Personality sections already use), visible only when `connections.size >= 2`. Tapping a non-active entry fires `switchConnection` + a confirmation toast. Switching is now reachable from the same tap the user already uses to switch Profile or Personality, which is exactly what Bailey asked for in "it doesn't seem properly wired with our top bar agent name drawer".
|
||||
|
||||
**Scope discipline.** The `rename-on-pair` logic at `ConnectionViewModel.kt:1266` is correct — its guard `current.label == PLACEHOLDER_LABEL && current.apiServerUrl.isNotBlank()` is what's SUPPOSED to rename a placeholder once the scan populates the URL. Bailey's orphan didn't reach that guard because the pair never completed — `apiServerUrl` stayed blank, so the rename path never triggered and the placeholder stayed labeled. No fix needed there.
|
||||
|
||||
**Not touched (out of scope):**
|
||||
- Phase B upstream rich-card adapter work — still held pending real usage.
|
||||
- Session-sync story for card dispatches already shipped earlier today (ADR 26 / CardDispatchSyncBuilder).
|
||||
|
||||
## 2026-04-20 — Card-dispatch session sync (completes ADR 26)
|
||||
|
||||
**Context.** Immediately after shipping Phase A of the `CARD:{json}` pipeline, went back for the deferred session-sync piece. The value proposition: card dispatches in `send_text` / `slash_command` modes already reach the server (they go through `sendMessage`), but `open_url` dispatches NEVER do — the intent launches locally and the server is blind to it. Even for `send_text`/`slash_command` the server only sees the reply *text*, not the structural link back to the card that prompted it — so after a server restart or reconnect the LLM can't say "you approved the `Run shell command?` card" without guessing.
|
||||
|
||||
**Design.** Mirror `VoiceIntentSyncBuilder` beat-for-beat. Every `HermesCardDispatch` grows a `syncedToServer: Boolean = false` flag (identical spelling to `VoiceIntentTrace.syncedToServer`). New `CardDispatchSyncBuilder` in `viewmodel/` (pure function, JVM-testable) walks history, builds an index of `card.id ?: "idx:$index"` → card, then for each unsynced dispatch synthesizes an OpenAI-format `assistant`+`tool` pair. Namespaced the synthetic tool name as `hermes_card_action` so the upstream tool dispatcher — which could look at the session's history on a cold-start replay — has zero chance of trying to execute a card audit record as if it were a real `android_*` call.
|
||||
|
||||
**Arguments envelope.** Chose a fat structured object: `card_key` + `action_value` (always), plus `card_type` + `card_title` + `action_label` + `action_mode` + `action_style` when the card and action are still in the message. The LLM can describe the interaction naturally from any of those keys, which matters because the card itself may have been trimmed from the rolling context window by the time the LLM reads the synthesized trace. Fallback path (card no longer in message history) emits just the key + value — still a valid audit record, just less descriptive.
|
||||
|
||||
**Wire splice.** Didn't rename the API client's `voiceIntentMessages` parameter — it's already a generic `JsonArray?` of synthetic messages and renaming is a ripple across `HermesApiClient` I didn't want to carry on this PR. Instead `ChatViewModel.startStream` builds BOTH arrays (voice intents first, cards second — preserves chronological order within each stream; cross-stream ordering doesn't matter to the LLM since cause-and-effect is preserved within each) and concatenates into one, passing through the existing slot. Guarded with `hasUnsynced` on both builders so the common no-op turn allocates nothing.
|
||||
|
||||
**Commit timing.** Matches voice intents exactly — `markVoiceIntentsSynced` and `markCardDispatchesSynced` both fire *after* the API client accepts the request. If request building throws, both streams stay unsynced and retry on the next turn. If the request succeeds but the SSE stream fails partway through, the server-side session has already absorbed the synthetic messages and won't re-receive them — correct for at-least-once delivery semantics.
|
||||
|
||||
**Tests.** `CardDispatchSyncBuilderTest` covers six cases: empty history, dispatches-but-no-cards, single success pair, skip-already-synced, orphan dispatch (card trimmed), idx-form fallback key, and `hasUnsynced` boolean. Pure JVM tests — no Robolectric, no MockK, same JUnit style as `VoiceIntentSyncBuilderTest`.
|
||||
|
||||
**What closes from ADR 26.** The "deferred session sync" item in the tradeoff list is now shipped and the ADR is updated accordingly. Phase B (upstream Discord/Slack adapter parity) remains held until real phone-side card usage surfaces concrete fidelity issues worth translating for.
|
||||
|
||||
## 2026-04-20 — Rich cards in chat + dev-branch CI relaxation
|
||||
|
||||
**Context.** Two asks in the same session, from Bailey. (1) Dev-branch CI was blocking WIP pushes on test failures that were transient or irrelevant to the commit — tests take a long time, fail noisily on in-progress work, and are a drag on the "commit early, commit often" loop the dev-branch model exists to enable. Lint failures are rare enough that he's fine keeping them strict. (2) The chat feed only knew how to render prose + tool-progress cards + attachments; we wanted Discord-embed-style rich cards for skills and agent commands — approval prompts, link previews, weather, calendar, generic skill output — with a pattern that could extend to Discord/Slack adapters upstream later.
|
||||
|
||||
**CI — the change.** Added `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` to the `test` job in `ci-android.yml` and the `unit-tests` job in `ci-relay.yml`. The expression reads as "if this build is NOT heading toward main (push to main OR PR targeting main), mark the job green even if tests fail." Tests still *run* — reports and annotations upload as always — they just don't red-gate the dev merge queue. When the dev → main release-merge PR goes up, `base_ref == 'main'` flips the expression to false and the same job is strict again, so nothing sneaks into a tagged release untested. Lint stays unconditionally strict on both branches (lint debt compounds and is cheap to fix at commit time).
|
||||
|
||||
**Cards — the design.** Upstream research first: `gateway/platforms/base.py` exposes no rich-content abstraction at all (just `send()` / `send_image()` / `edit_message()`). Discord uses zero `discord.Embed()`. Slack uses Block Kit *only* for the `exec-approval` dialog. Only lead is the `REQUIRES_EDIT_FINALIZE` attribute + its DingTalk AI Cards reference, which acknowledges rich-card surfaces exist but doesn't abstract them. So: no prior art to copy, one Slack pattern (exec-approval) worth mirroring, and free rein on the envelope shape.
|
||||
|
||||
Chose to **reuse the `MEDIA:` marker pattern** rather than invent structured SSE events. Reasons: (a) works unchanged across every streaming endpoint we support — `/v1/runs`, `/api/sessions/{id}/chat/stream`, `/v1/chat/completions` — which the structured-event path would not (chat/completions has no event channel); (b) ships today with zero server dependency; (c) the `HermesCard` data model is stable regardless of wire format, so if we later want structured events the parser fans out and nothing else changes. Marker is `CARD:{json}` on its own line, single-line JSON (escape newlines in string fields as `\n`), greedy `\{.*\}` so nested braces in `fields` / `actions` survive.
|
||||
|
||||
**Cards — the data.** `HermesCard(type, title?, subtitle?, body?, accent?, fields[], actions[], footer?, id?)`. `type` is a dispatcher key — `skill_result`, `approval_request`, `link_preview`, `calendar_event`, `weather` are the built-ins, unknown types render via a generic title+body+fields+actions fallback so new types don't break older phone builds. `accent` is semantic (`info` / `success` / `warning` / `danger`) not raw hex so the renderer pulls from `colorScheme`. `fields` are simple label/value rows. `actions` are tappable buttons with `label` / `value` / `style` (`primary` / `secondary` / `danger`) / `mode` (`send_text` default / `slash_command` / `open_url`). `@Serializable` with `ignoreUnknownKeys = true` so server schema evolution doesn't break parse.
|
||||
|
||||
**Cards — the parser.** Mirrors the `MEDIA:` path byte-for-byte in structure: dedicated `cardLineBuffer` + `dispatchedCardMarkers` fields, `scanForCardMarkers` called from `onTextDelta`, `tryDispatchCardMarker` as the line inspector, `finalizeCardMarkers` called from both `onTurnComplete` and `onStreamComplete`, `extractCardsFromContent` on history reload. `clearMessages` wipes the state. Cards are synchronous (no async fetch like media), so they attach straight onto `ChatMessage.cards` instead of going through a callback-to-ViewModel roundtrip.
|
||||
|
||||
**Cards — the renderer.** `HermesCardBubble.kt` is a Material 3 `Card` with a 3dp accent stripe on the leading edge (same visual language as the voice/phone-action accent bar in `MessageBubble`), Icon + Title/Subtitle header, optional markdown body, fields as label/value rows (monospace heuristic on values that look like paths or commands), FlowRow of action buttons, muted footer. Tapping an action triggers `ChatViewModel.dispatchCardAction(messageId, cardKey, action)` which stamps a `HermesCardDispatch` on the owning message *before* doing the side effect — so the card collapses into a "Chose: X" confirmation even if `sendMessage` or the `ACTION_VIEW` intent throws. The stripe color and header icon both branch on `type` / `accent`, so the visual distinction between an approval card and a weather card is immediate.
|
||||
|
||||
**Approval-request card parity with Slack.** Slack's `send_exec_approval` uses a Block Kit section with 4 buttons: Allow Once (primary), Allow Session (default), Always Allow (default), Deny (default, though semantically destructive). Our `approval_request` card uses the same 4-button shape with `style: "primary"` on Allow Once and `style: "danger"` on Deny — so a future upstream Phase B contribution is a translation pass (`gateway/rich_cards.py` → Slack blocks / Discord embeds / markdown fallback), not a data-model rethink. Didn't ship Phase B in this session — scope-capped to phone-side Phase A per Bailey's call.
|
||||
|
||||
**What's still on the table.** Server-side session sync of card dispatches (analogous to `VoiceIntentSyncBuilder` for voice intents) so the LLM remembers "user approved shell command X" across a server restart. And the Phase B adapter-side upstream PR — both deferred to future sessions.
|
||||
|
||||
## 2026-04-19 — Docs site: mobile hero fix + MorphingSphere on codename-11.github.io
|
||||
|
||||
**Context.** Two issues on the VitePress docs home at `codename-11.github.io/hermes-relay/`: (1) on mobile the 9:16 phone-frame video overflowed its container and the hero text/CTAs sat on top of the video, (2) Bailey wanted the freshly-extracted MorphingSphere embedded somewhere tasteful on the docs site with a "looking around" loop and mouse-proximity reactivity.
|
||||
|
||||
**Mobile hero — the fault.** VitePress's `VPHero` sets `.image-container` to `width: 320px; height: 320px` below 640 px and `392×392` at 640–960 px, with `.image { margin: -76px -24px -48px }` negative margins meant to visually overlap a round illustration. That shape works for a 320-square picture, not for a portrait phone frame that's ~433 px tall at 200 px wide. The frame overflowed the square and the `.main` block (text + actions) got pulled up through the overflow zone.
|
||||
|
||||
**Mobile hero — the fix.** Two edits. `custom.css` adds a `@media (max-width: 959px)` override that clears the `.image` negative margins and makes `.image-container` `height: auto; width: auto; max-width: 100%`. `HeroDemo.vue` replaces three breakpoint widths (280/240/200 px at three steps) with `width: clamp(180px, 62vw, 280px)` + `max-height: 70vh` — one fluid rule, plus a safety rail so a tall narrow viewport (folded Z-fold, tall Android) can't push the Get Started button below the fold.
|
||||
|
||||
**Sphere placement.** Option A (hero corner) would compete with the phone video. Option C (nav logo) is subtle but a tiny 32×32 canvas can't show enough detail to read as "alive." First pass went with option B — a dedicated band between hero and features (`home-features-before` slot) at 56×26 / 720×220 px. Second pass moved it **directly above the "Install in 30 seconds" block** by collapsing both slots into `home-hero-after: () => [h(SphereMark), h(InstallSection)]`. Third pass (same session, after Bailey saw the first render): the 9:16 phone-shape frame put a tiny sphere inside a tall envelope — the algorithm's baseRadius = 0.60 × min(rows/2, cols×charAspect/2) means the sphere was only ~20% of canvas width by design, leaving 200+ px of dead space above and below the visible blob. Fix: keep the 58×34 mobile density but switch the canvas aspect to **1:1 square** with `clamp(280px, 48vw, 420px)` width. At square, the charAspect (34/58 ≈ 0.586) makes `maxRadiusFromRows == maxRadiusFromCols`, so the sphere fills both axes equally and the data ring extends to ~93% of the frame. Gap killed, sphere is now the focal object of the band.
|
||||
|
||||
**Gaze without bounce.** First pass translated the whole canvas toward the cursor via `ctx.translate(offsetX, offsetY)` — effective as a "tracking" cue but Bailey correctly called it a *bounce*: the sphere body should stay anchored, only the eye should move. Cleaner fix required algorithmic access to the light direction, so added three new `SphereFrame` fields to `MorphingSphereCore.kt`: `lightAngleBiasX`, `lightAngleBiasY`, `lightAngleBlend` (all default 0f). The light-angle computation now blends between the natural `t * lightSpeedX + noise` rotation (`blend = 0`) and the caller's bias (`blend = 1`). Mirrored into `sphere.js` with `?? 0` coalescing. Defaults preserve byte-identical behavior for existing callers — the Android composable, the parity test (`MorphingSphereCoreParityTest`), and the JS parity harness (`parity-check.mjs`) all stay green because none of them set the new fields.
|
||||
|
||||
**Gaze math.** The algorithm computes `lx = sin(lightAngle1) * 0.65` and `ly = cos(lightAngle2) * 0.65`. To make the bright spot face a direction (cursorDx, cursorDy) normalized to the unit circle, solve: `lightAngle1 = asin(cursorDx)`, `lightAngle2 = acos(cursorDy)`. `SphereMark.vue` computes these per frame from the cursor vector, blends with a slow fbm wander for idle gaze, and ramps `lightAngleBlend` from 0.35 (idle wander) up to 0.90 (cursor near). The 0.35 floor is deliberate — below it, the autonomous fbm barely registers as gaze because natural light rotation drowns it out; at 0.35 the bias owns enough of the angle that the eye reads as *looking around* rather than *being illuminated randomly*.
|
||||
|
||||
**Fourth pass — jitter fix.** First draft fed raw pointer inputs straight into asin/acos every frame. Hovering felt jerky because (a) `pointermove` fires at event-driven cadence with big gaps between events, not once per animation frame, and (b) `asin'(x) = 1 / √(1 − x²)` blows up as `x → ±1`, so small input steps near the edges produced disproportionately large angle jumps. Fix: two EMAs on the raw inputs before they hit the trig — 180 ms time constant on `lookVx` / `lookVy`, 280 ms on `proximity` (proximity wants a bit more hang-time so rapid hover-in/hover-out doesn't thrash the state machine). The `alpha = 1 − exp(−dt/tau)` form is frame-rate-independent — same responsiveness on a 144 Hz monitor as 30 fps. Also capped the asin/acos inputs at ±0.9 to stay off the asymptotic slope at the boundaries; effective gaze range drops from `lx ∈ ±0.65` to `±0.585`, an imperceptible loss for a large smoothness win.
|
||||
|
||||
**Fifth pass — scroll-watching + rectangular detection.** Bailey wanted (a) the detection field to cover the full page width at the container's height (rectangular band, not circle), and (b) the sphere to watch the reader scroll toward install when they haven't interacted yet — "looking straight down at install" when the install section is in view. Implementation:
|
||||
- Added a `userHasInteracted` flag, flipped on the first `pointermove`. Once true it stays true for the session.
|
||||
- **Scroll-watching mode** (not interacted yet): compute `scrollOffsetY = viewportH/2 − sphereCenterY` in viewport coordinates. When the reader scrolls down, the sphere rises in the viewport and `scrollOffsetY` grows positive — feeding `rawLookVy = scrollOffsetY / (viewportH * 0.6)` into the gaze math rotates the bright spot to the bottom of the sphere. Proximity locked to 0.75 so the smoothed gazeBlend settles at 0.35 + 0.55·0.75 ≈ 0.76 — enough to read as intentional gaze without slamming into full Listening-state intensity. Horizontal component held at 0 so the eye doesn't drift left/right while watching scroll.
|
||||
- **Cursor-tracking mode** (post-interaction): gaze direction is still the unit vector from sphere center to cursor. Proximity is now purely **vertical band-based** — 1.0 when `|mdy| ≤ containerHeight/2`, falling off linearly over 0.6 × containerHeight beyond each edge. No horizontal falloff at all — cursor anywhere from x = 0 to x = viewportWidth in the vertical band gets full proximity. That matches Bailey's "fill width" spec and feels natural because the eye already targets cursor direction regardless of distance; proximity just gates intensity / state / blend strength.
|
||||
- **Drift fallback** (interacted + pointer off the page): both mode blocks skip, raw inputs stay at 0, smoothed EMA relaxes toward 0, autonomous fbm drift provides the gaze. The sphere effectively "loses track" and wanders.
|
||||
|
||||
The three modes compose cleanly because they all feed through the same smoothed-EMA → asin/acos → `lightAngleBias` pipeline. Transitions between modes just change the raw targets; EMA + tweens handle everything else.
|
||||
|
||||
**Sixth pass — unified target + install-anchored scroll.** Ultrathink pass: the scroll reference point was wrong, and the cursor↔drift mode flip at the band boundary was causing visible eye freakout when hovering near the edge. Two changes:
|
||||
|
||||
- **Unified target pipeline.** Dropped the `userHasInteracted` flag and the fbm-drift fallback mode. Scroll-tracking is now the always-on baseline; cursor-tracking overlays on top via `cursorWeight`. `rawLookVy = cursorVy × cursorWeight + scrollVy × (1 − cursorWeight)` — one coherent target, no two sources competing for the EMA to smooth. The boundary freakout was fundamentally a *discontinuous target function*: two signals with possibly opposite directions feeding a single smoother only looks smooth if they agree at the transition. They didn't. Now there's only one target curve, and the EMA just smooths motion along it.
|
||||
- **Install-anchored scroll reference.** Replaced `viewportH/2 − sphereCenterY` with an Install-element anchor via `document.querySelector('.install-section')`. `installGap = installRect.top − viewportH` is the runway until install enters view; scrollVy ramps linearly from 0 (install still more than 50 % viewport-height below the fold) to 1 (install just touching viewport bottom). By the time install *first* becomes visible, the eye is *already* looking straight down at it — the animation leads the scroll rather than chasing it. With the old viewport-center reference, scrollVy only saturated to ±1 when the sphere was nearly off-screen, which meant "fully looking down" happened after the sphere had already scrolled away. Fallback when the install element isn't on the page (other routes) uses viewport-center so the gaze still follows scroll.
|
||||
- **Widened the cursor falloff** from 0.6 × container height to 1.0 × height. Over 180 ms EMA with a narrow falloff, the crossfade from cursor to scroll target happens inside the EMA's bandwidth and reads as a jump; widening it to 1.0 × height spreads the mathematical transition over enough cursor travel that the EMA can smooth it out fully.
|
||||
|
||||
**Seventh pass — eye legibility.** Gaze tracking worked but the bright spot wasn't standing out as the *eye*. Analyzed the math: `brightness = (1 - li) · distBrightness + li · directionalLight + heartbeatFx`. For Idle, `lightInfluence = 0.35`, so 65% of sphere brightness comes from *position* (pearl shading, bright at center, dim at edges) and only 35% from the *light direction*. Worse, at the sphere's geometric center the surface normal is (0, 0, 1) so `directionalLight ≈ lz`, and `lz = √(1 − lx² − ly²)` is always positive — the center stays bright regardless of where the eye is pointing, which visually flattens the eye-to-shadow contrast. Fix: new `shadowStrength` field on `SphereFrame` (mirror in sphere.js, default 0). Multiplies `distBrightness` by `(1 − shadowStrength · (1 − directionalLight))` — lit side (`directionalLight = 1`) scales by 1.0, shadow side (`directionalLight = 0`) scales by `1 − shadowStrength`. Picked the factor to act on `distBrightness` rather than `directionalLight` because boosting the light side's directional term (or gamma-sharpening it) would flatten the 3D pearl shading everywhere — the shadow-side multiplier dims only the unlit hemisphere, keeping the shading on the lit side intact while doubling eye-to-shadow contrast. Docs `SphereMark.vue` uses 0.6 (dark side at 40% of legacy distBrightness). Android composable doesn't set it — legacy pearl shading preserved byte-for-byte, parity test stays green.
|
||||
|
||||
**Sphere reuse — no duplication.** The Vue component imports directly from `../../../../preview/web/sphere.js`. Vite resolves the relative path through the repo root; `sphere.js` is pure (no DOM, no side effects) so SSR doesn't choke. This keeps `MorphingSphereCore.kt` → `sphere.js` as the single algorithm seam — same math on Android, preview harness, and docs site. No mirror, no copy to sync.
|
||||
|
||||
**"Looking around" + mouse reaction.** Two behaviors layered onto the existing `SphereFrame` inputs without touching the core algorithm:
|
||||
- **Autonomous drift.** Low-frequency fbm noise (`nowSec * 0.05 + 2.1`, same fbm the core uses for its light jitter) produces a (dx, dy) in ±1 range. Applied as a canvas `translate()` of up to 14 px. Gives a subtle gaze loop that never repeats visibly.
|
||||
- **Proximity.** Pointer tracked on `window`; distance from mouse to canvas center normalized against a 320 px "reach radius" → proximity ∈ [0, 1]. Blends cursor-pull with autonomous drift (cursor dominates near, drift dominates far), ramps `intensity` tween (0 → 0.7), and retargets state Idle → Listening above 0.35 proximity, back to Idle below 0.15 (hysteresis band prevents flicker). Listening's params have higher `lightInfluence` and tighter core — reads as "the agent is paying attention."
|
||||
|
||||
**Perf / a11y.** `IntersectionObserver` pauses the fillText loop when the band is scrolled off-screen (still advances time so state tweens stay in sync on re-enter — cheaper than rebuilding state but skips the expensive draw). `prefers-reduced-motion` freezes `t` and zeroes the gaze offset so the band stays readable but static. `aria-hidden` on the canvas — the sphere is decorative, no screen reader value.
|
||||
|
||||
**VitePress SSR — `<ClientOnly>` was the wrong tool.** First attempt wrapped the component in `<ClientOnly>` as "SSR defense." Turned out to be the bug: `<ClientOnly>` only mounts its children after its own `onMounted` fires, which is later than the parent component's `onMounted` — so `canvasEl.value` and `containerEl.value` were `null` when we tried to start the rAF loop. The first render call bailed early without scheduling the next frame, killing the loop permanently. Fix: drop the wrapper (a `<canvas>` tag is inert on SSR, no side effects) and make `render()` re-schedule itself whenever refs or canvas dimensions aren't ready yet. One transient null no longer turns into a dead loop.
|
||||
|
||||
## 2026-04-19 — MorphingSphere: pure-core extraction + browser preview + runtime parity proof
|
||||
|
||||
**Context.** The sphere had grown into a 494-line Compose file with Android `Paint` + `Typeface` + `nativeCanvas.drawText` wired into the same function that did the math. That made it impossible to iterate on the algorithm without building to a device — and blocked any future port to a non-Android surface (user site, Hermes TUI, Compose Desktop). Branch `claude/ui-dev-preview-exploration-vKvzr` split the file into a pure algorithm + a Compose renderer and added a zero-dep browser harness; this session added the parity proof.
|
||||
|
||||
**Layout — three artifacts, one seam.**
|
||||
- `MorphingSphereCore.kt` (new, 412 lines) — `kotlin.math` only. Owns `SphereState`, `SphereParams`, `SphereColors`, `SphereFrame`, `SphereCell`, `paramsFor()`, `colorsFor()`, `forEachSphereCell()`. No Android, no Compose, no `Paint`.
|
||||
- `MorphingSphere.kt` (494 → ~45 lines of renderer + `@Preview` decorators) — Compose Canvas binding. Swapped legacy `Paint` + `Typeface` + `nativeCanvas.drawText` for `rememberTextMeasurer()` + Compose `drawText`. Owns animation state (`animateFloatAsState`, `rememberInfiniteTransition`) and feeds a `SphereFrame` into the core per tick.
|
||||
- `preview/web/sphere.js` — line-for-line JS mirror of `MorphingSphereCore.kt`. `Math.imul` + `|0` to match Kotlin's `Int` overflow in the hash, `((x % n) + n) % n` for Kotlin's floored-positive `.mod()`, `Math.trunc` for `.toInt()` truncation-toward-zero.
|
||||
|
||||
**Browser harness.** `preview/web/index.html` — live HTML+canvas, `python3 -m http.server --directory preview/web`, no build step. Panel exposes State / Voice mode / Voice amp / Intensity / Tool burst / Pause / reset t, plus a Layout section (Cols, Rows, Fill %, Aspect, Char size) added this session with a `phone 9:16` preset that pins canvas aspect to 0.5625 to match Compose `@Preview(widthDp=360, heightDp=640)`. Hitting `1`..`6` cycles state; `Space` pauses.
|
||||
|
||||
**Runtime parity proof.** Line-by-line code audit was first, but proving parity needs evidence, not inspection. Added:
|
||||
- `preview/web/parity-check.mjs` — Node harness running sphere.js at the 8 Compose `@Preview` fixtures (Idle/Thinking/Streaming/Error/Compact/Listening/Speaking-low/Speaking-peak), emitting two FNV-1a 32-bit checksums per fixture. `struct` hashes only `(row, col, charCode)` — discrete, robust to Float/Double precision drift. `full` hashes the same plus color/alpha rounded to 3 decimals.
|
||||
- `app/src/test/kotlin/com/hermesandroid/relay/ui/components/MorphingSphereCoreParityTest.kt` — JVM unit test (no Android deps, runs on `testGooglePlayDebugUnitTest`), mirrors the JS harness exactly: same 8 fixtures, same FNV-1a impl via Kotlin `UInt`, same `%.3f` formatting via `Locale.ROOT`, same tuple layout.
|
||||
|
||||
**Result.** 8 / 8 structural checksums match. 8 / 8 zone histograms match (inside/glow/ring counts identical). 6 / 8 full checksums match — Listening and Speaking-low drift on color/alpha at the 3rd decimal, which is expected Float (Kotlin) vs Double (JS) mantissa precision in compound expressions like `(colR + lightBoost + warmth).coerceIn(0, 1)`. Speaking-peak avoids the drift because `amp=0.95` puts `lerp()` results near the endpoints where Float mantissa has room.
|
||||
|
||||
**Decision.** `MorphingSphereCore` is declared the single source of truth going forward. The Compose `MorphingSphere` composable keeps its public API (same params, same defaults) so call sites — `VoiceModeOverlay`, the chat empty state — need no changes. Future edits to the algorithm go in `MorphingSphereCore.kt`, mirror into `sphere.js`, and `MorphingSphereCoreParityTest` catches drift between sides.
|
||||
|
||||
**Reusable surface.** The core is now droppable into:
|
||||
- A terminal TUI (Hermes CLI) — swap Compose `drawText` for ANSI-colored `print`, keep everything else.
|
||||
- The user site (codename-11.dev) — reuse `sphere.js` directly with an HTML `<canvas>` host.
|
||||
- Compose Desktop — reuse `MorphingSphere.kt` unchanged once the host project adds the `ui/components` dir to its source set.
|
||||
|
||||
**Worktree gotcha.** `git worktree add` doesn't copy `.gitignore`d files. `local.properties` (SDK path + keystore creds) has to be recreated in any new worktree before gradle tasks run. For the parity test a minimal `sdk.dir=...` is enough — signing creds stay in the main checkout.
|
||||
|
||||
## 2026-04-19 — Multi-endpoint pairing + first-class Tailscale (ADR 24 + 25)
|
||||
|
||||
**Branches:** Wave 1 + Wave 2 landed on `dev`. ADRs 24 and 25 written and committed. Docs pass (this entry) closes the work out.
|
||||
|
||||
**Shipped.**
|
||||
|
||||
- **ADR 24 — Multi-endpoint pairing.** `plugin/pair.py` now emits an ordered `endpoints` array (`lan` / `tailscale` / `public` / operator-defined roles) in a new `hermes: 3` QR schema; `hermes: 2` stays the default when no endpoints are present. New CLI flags `--mode {auto,lan,tailscale,public}` and `--public-url <url>` drive candidate emission; `--mode auto` autodetects LAN IP + Tailscale status and composes them with an optional public URL. `plugin/relay/qr_sign.py` canonical form preserves array order and role strings verbatim — HMAC round-trip test pins this against future refactors. `plugin/relay/server.py` `handle_pairing_mint` / `handle_pairing_register` accept the optional `endpoints` body field. Phone side: new `data/Endpoint.kt` (`EndpointCandidate` / `ApiEndpoint` / `RelayEndpoint` / `displayLabel()`), `data/PairingPreferences.kt` per-device endpoint store, `ui/components/QrPairingScanner.kt` parses the new field with a v1/v2 synthesizer for back-compat, `auth/AuthManager.kt` persists the endpoint list on `auth.ok`, and `viewmodel/ConnectionViewModel.kt` stages endpoints at pair time. Wave 2 Kt-Probe added `ConnectionManager.resolveBestEndpoint()` + `NetworkCallback` re-probing and `RelayUiState.activeEndpointRole`.
|
||||
- **ADR 25 — First-class Tailscale helper.** New `plugin/relay/tailscale.py` with `status()` / `enable(port)` / `disable(port)` / `canonical_upstream_present()` — all shell out to the `tailscale` CLI with short timeouts, return structured dicts, never raise. New `plugin/relay/tailscale_cli.py` argparse wrapper + `scripts/hermes-relay-tailscale` shell shim mirroring the `hermes-pair` pattern. `install.sh` gains optional step [7/7] offering Tailscale enablement; honours `TS_DECLINE=1` / `TS_AUTO=1`.
|
||||
- **Dashboard Remote Access tab (Wave 2 Dashboard-R4).** `plugin/dashboard/plugin_api.py` + React UI + committed `dist/index.js` — operator can enable/disable the helper, mint multi-endpoint QRs, and inspect which modes are active without dropping to a shell.
|
||||
- **Docs pass.** New `docs/remote-access.md` (263 lines) — decision matrix + per-mode setup recipes + troubleshooting. Updated `docs/security.md` with a "Remote connectivity" subsection + top-of-list Tailscale recommendation. Updated `docs/relay-server.md` with `TS_AUTO` / `TS_DECLINE` env vars, the dashboard plugin proxy-route table, and a Tailscale-helper subsection. Updated `docs/spec.md` §3.3 + §3.3.1 for v3 QR schema + endpoints array. Updated `README.md` "What's new" with the one-line connect-from-anywhere pitch. Updated `CHANGELOG.md` `[Unreleased]` with Added / Changed / Backward compatible subsections. Updated `CLAUDE.md` Key Files + Integration Points (hygiene pass only).
|
||||
|
||||
**Key architectural decisions (restated from the ADRs).**
|
||||
|
||||
1. **Strict priority, not reachability-weighted.** Priority 0 wins when reachable; reachability is a tiebreaker among equal priorities, never a promoter across priority boundaries. Matches DNS SRV semantics — nothing new to debate, and keeps operator intent authoritative.
|
||||
2. **Open-string `role`, not a closed enum.** `wireguard`, `zerotier`, `netbird-eu`, etc. render as generic "Custom VPN (<role>)" without a release. HMAC canonicalization preserves the exact emitted string.
|
||||
3. **Tailscale helper auto-retires on upstream merge.** `canonical_upstream_present()` probes `hermes gateway run --help | grep tailscale`; once PR #9295 lands, the helper prints a log line pointing at the canonical flag and exits 0. Same removal pattern as `hermes_relay_bootstrap/` after PR #8556.
|
||||
4. **No second crypto layer.** The operator already owns both endpoints and the transport (Tailscale / Caddy / WireGuard / Cloudflare Tunnel) is TLS-terminated by the operator's chosen path. Adding Noise / libsignal over WSS would add complexity without defending any threat in the trust model.
|
||||
|
||||
**What's next.**
|
||||
|
||||
- Monitor upstream PR #9295 for merge. When it lands, verify `canonical_upstream_present()` detection works on a vanilla hermes-agent install, update the helper to print the retirement log line, and schedule the helper's removal PR (one file + the install.sh step + the shim).
|
||||
- Bailey's Studio build + `./gradlew lint` pass before the multi-endpoint work gets pushed from Kotlin side. No Python blockers.
|
||||
|
||||
**Blockers.** None. Awaiting Bailey's lint + build pass on the Kotlin changes before the feature branches merge into `dev`.
|
||||
|
||||
### Same-day follow-up — `--prefer` priority override + regression fix
|
||||
|
||||
Two commits landed on `dev` after the initial ADR 24 + 25 bundle:
|
||||
|
||||
- **`feat(pairing): --prefer priority override on all pair surfaces` (`e914810`).** Adds explicit "promote this role to priority 0" control so operators can force a specific endpoint path without re-ordering defaults globally. Surfaces: CLI `hermes-pair --prefer tailscale`, the `/hermes-relay-pair` skill (SKILL.md updated), and a dropdown below the Endpoint Preview card on the dashboard's Remote Access tab. Open-vocab role string; case-insensitive matching; unknown role emits a stderr warning and keeps natural order. 6 new `BuildEndpointCandidatesPreferTests` cover the happy paths and edge cases. Dashboard bundle rebuilt to 61.3 KB.
|
||||
|
||||
- **`fix(relay): restore profile PUT handlers clobbered by ADR 24 commit` (`ee653d4`).** The ADR 24 bundle (`fae8ccd`) had an Edit that replaced a wider chunk of `plugin/relay/server.py` than intended while adding the endpoints passthrough to `handle_pairing_mint` / `handle_pairing_register` — collaterally deleting ~479 lines of `handle_profile_soul_put` / `handle_profile_memory_put` + `_extract_write_content`. CI — Relay went from 2 pre-existing failures (`test_profile_discovery`, unrelated) → 27 on the bundle push → back down to the same 2 after the fix. Restored by resetting `server.py` to the pre-ADR-24 state (`47667bd`) and re-applying only the intended ~30-line endpoints passthrough. Full suite: 673 pass / 6 skipped locally. Same bug class as the `AuthManager.profilesUpdatedEvents` collateral wipe caught mid-deploy — worth a note in any future "agent-assisted refactor" guidance: tight `old_string` anchors + verify-by-compile after every agent's work.
|
||||
|
||||
- **Server deploy** (`~/.hermes/hermes-relay/` on `dev`, PID restarted for `hermes-relay` + `hermes-gateway` + `hermes-dashboard`) verified at each step: `{"status": "ok"}` health, `tailscale.status()` returning the live `docker-server.tail6f460.ts.net` / `100.71.8.56`, and `POST /api/plugins/hermes-relay/pairing {"mode":"auto","prefer":"tailscale"}` returning `[(0, 'tailscale'), (1, 'lan')]` over the wire. Pre-existing `test_profile_discovery` failures on Linux CI runners (Windows-local passes) are a separate Bailey in-progress item — not introduced by this work.
|
||||
|
||||
- **Docs sync pass.** `docs/remote-access.md` → added "Promoting a role to priority 0 — `--prefer`" subsection under Combining modes. `user-docs/features/connections.md` → new "Multi-endpoint pairing" section (end-user facing). `user-docs/guide/getting-started.md` → new "Connecting from Anywhere (Tailscale, VPN, Public URL)" section between Relay Server and Verify Connection. `CHANGELOG.md` `[Unreleased]` gains `--prefer` under Added + the PUT-handler restore under Fixed.
|
||||
|
||||
## 2026-04-18 — Profile Inspector UI (v0.7.0)
|
||||
|
||||
**Branch:** `feature/profile-inspector-ui`. Kotlin-worker slice of the v0.7.0 Profile Inspector feature — Python worker runs in parallel and owns the `/soul` + `/memory` relay endpoints. Two feature commits + docs.
|
||||
|
||||
**Shipped (Kotlin).**
|
||||
|
||||
- **`RelayProfileInspectorClient` + wire models.** New read-only HTTP client for the four inspector endpoints (`/api/profiles/{name}/config`, `/skills`, `/soul`, `/memory`) mirroring `RelayHttpClient`'s constructor shape (OkHttp, lazy bearer-token provider, `ws://` → `http://` URL flip). All four fetch methods return `Result<T>` and hop to `Dispatchers.IO` before any network I/O — reinforcing the v0.6.0 post-mortem fix for `NetworkOnMainThreadException` in the voice client. Profile names are URL-encoded before being spliced into the path so names with spaces / non-ASCII characters don't blow up the route. Wire models (`ProfileConfigResponse`, `ProfileSkillsResponse`, `ProfileSoulResponse`, `ProfileMemoryResponse`) are `@Serializable` with snake_case → camelCase mapping via `@SerialName`. Optional wire fields (`truncated`, `readonly`, `enabled`) default to safe values so pre-v0.7.0 relays that omit them deserialize cleanly.
|
||||
- **`ProfileInspectorScreen` (4 tabs).** Config renders as a collapsible JSON tree (nested objects click to expand, monospace leaf values, 120-char truncation). SOUL is a scrollable monospace box with byte-size caption + truncation banner when the server flags the content as sliced; absent SOUL renders an empty-state with the expected path. Memory is a list of expandable cards per entry (filename + size in the header, content revealed on tap, per-entry truncation banner). Skills groups by category with a "(disabled)" label on skills where `enabled=false`. Top-bar Refresh icon fires `loadAll()`; each pane has its own inline Retry button on error state. PullToRefresh was skipped in favour of the explicit Refresh icon + per-section Retry — matches `PairedDevicesScreen`'s "still-experimental PullRefresh" note.
|
||||
- **`ProfileInspectorViewModel`.** Four independent `LoadState<T>` flows — one per section — so a slow `/memory` fetch never blocks the already-arrived `/config` tab. Lazy (no fetch until `loadAll()`) and stateful per-section (`refreshSection(InspectorSection.Config)` re-fetches just that tab). Profile name comes in via `SavedStateHandle` so a process-death restore inspects the same profile. The VM is keyed off the profile name in the nav backstack so entering a different profile produces a fresh VM rather than reusing stale state.
|
||||
- **`ProfileInspectorCard` in Settings.** Sits directly under `ActiveAgentCard` so the "active agent → inspect it" reads naturally top-to-bottom. When no profile is selected, the card renders at 50% alpha with "No active agent" and becomes a no-op — kept visible (not hidden) so the feature stays discoverable before a pair-and-pick happens.
|
||||
- **`Screen.ProfileInspector` nav destination.** Typed String path arg, registered via a tiny `ViewModelProvider.Factory` that pulls `SavedStateHandle` out of `CreationExtras` so nav args propagate into the VM constructor cleanly. Back navigation pops the destination and the VM is GC'd with it.
|
||||
|
||||
**Key architectural decisions.**
|
||||
|
||||
1. **Four independent load states, not one "screen state".** Each section's flow transitions Idle → Loading → Loaded/Error independently. One combined state would have meant the fastest-arriving tab gets blocked by the slowest — unnecessary UX cost given the 3 - 4 tabs the user flips between.
|
||||
2. **URL-encoded profile-name splice, not an OkHttp path segment.** We use `URLEncoder.encode(..., "UTF-8").replace("+", "%20")` before splicing into the literal path string, then build the `HttpUrl` from the full string. OkHttp's `addPathSegment` would re-encode and produce double-encoding for profile names with `%` already in them (pathological but possible). A single round of encoding is the safe choice.
|
||||
3. **ViewModel keyed on nav arg.** `viewModel(key = "profile-inspector-$name", ...)` means the same profile navigated to twice yields the same VM (back-stack reuse), but switching to profile B produces a fresh VM — avoids the "I opened inspector for axiom, changed profiles, reopened and saw axiom's data" class of bug.
|
||||
|
||||
**Deferred.**
|
||||
|
||||
- **Pull-to-refresh gesture.** Explicit Refresh button is enough for v0.7.0; if users ask for the gesture we'll revisit once Material3's `PullToRefreshBox` graduates out of experimental.
|
||||
- **Edit-in-place.** Inspector is strictly read-only by design. Editing is still "SSH to the server and `$EDITOR config.yaml`" territory. A "Copy to clipboard" affordance on each section is a possible follow-up if users ask for it.
|
||||
- **MockWebServer integration tests.** `testImplementation` doesn't include MockWebServer and the spec forbids adding deps for this slice — we covered wire-contract parsing + URL encoding + required-field enforcement via JVM-local tests, and the client's actual network path is exercised by the on-device relay smoke test.
|
||||
|
||||
**Upstream dependency.** Soul and Memory endpoints land via the parallel Python worker on the same branch. The Kotlin side is fetch-and-render — if either endpoint returns a 5xx / 404 before the Python change merges, the relevant tab just shows a Retry error state and the rest of the inspector keeps working.
|
||||
|
||||
## 2026-04-18 — Profile metadata + read-only config API (v0.7.0 groundwork)
|
||||
|
||||
**Branch:** `feature/profile-config-readonly`. Kotlin-worker slice of the v0.7.0 groundwork (Python-worker slice runs in parallel, owns the relay-side wire contract). Three feature commits + docs.
|
||||
|
||||
**Shipped (Kotlin).**
|
||||
|
||||
- **Extended `Profile` data class.** Added three optional fields — `gatewayRunning: Boolean`, `hasSoul: Boolean`, `skillCount: Int` — mapped via `@SerialName` to the snake_case wire keys (`gateway_running`, `has_soul`, `skill_count`) the relay will emit in `auth.ok` profiles entries. All three default to safe zero-values so pre-v0.7 relays deserialize cleanly. `AuthManager.parseAgentProfiles` reads them with `booleanOrNull` / `intOrNull` fallbacks — malformed values fall through to defaults rather than crashing the pairing handshake.
|
||||
- **Runtime-metadata indicators in the agent sheet.** Each Profile row in `AgentInfoSheet` now carries a 6 dp status dot (green when gateway_running, grey otherwise), an optional "N skills" chip (hidden when skill_count == 0), and an optional "SOUL" badge (primary-container, hidden when has_soul is false). Gateway-off profiles stay selectable at 50% alpha — the probe is best-effort, and a stale dot shouldn't lock a user out. `ProfileRadioRow` grew three optional params (`contentAlpha`, `leadingDotColor`, `secondaryTrailing` slot) without changing the Default/personality-row call sites. Also added an inline "Profile SOUL overrides personality while active" caption under the personality section when a profile-with-SOUL + non-default personality are both selected, mirroring the existing caption in the Profile section.
|
||||
- **Persisted `selectedProfile` per Connection.** New `ProfileSelectionStore` — its own DataStore (`profile_selections`) keyed by connectionId — lets each Connection remember its last-picked profile across app restart. `ConnectionViewModel.selectProfile` writes through. Connection switch clears `_selectedProfile` first (so a stale A pick never dangles on B) then loads B's persisted name and resolves it against `agentProfiles` once the post-switch `auth.ok` repopulates the list. `removeConnection` calls `store.clear(connectionId)` after the switch-away to avoid racing the unmounted store.
|
||||
|
||||
**Key architectural decisions.**
|
||||
|
||||
1. **Name-based persistence, not Profile-object persistence.** The persisted value is the profile `name: String`, resolved at read time against the server's fresh `agentProfiles` list. Profiles can be renamed, removed, or re-modelled on the server between app launches; persisting a full `Profile` snapshot risks silently using a stale model override. Resolution-at-read yields null when the name no longer exists, and the UI falls through to the Default row.
|
||||
2. **Dedicated DataStore.** `profile_selections` is separate from `relay_settings` so clearing or migrating one doesn't threaten the other. A new per-connection key prefix (`selected_profile_<id>`) lets us clear-per-connection cleanly on removal.
|
||||
3. **Gateway-off profiles remain selectable.** Spec explicitly allows the user to pick a profile whose gateway probe is grey. Rationale: the probe is best-effort, can miss a recent restart, and hard-disabling a row based on a stale probe would confuse users who just ran `hermes profile use` and haven't restarted the relay.
|
||||
|
||||
**Deferred.**
|
||||
|
||||
- **Migration of any old "ephemeral" pick into the new store.** N/A — the prior behaviour was to always reset on restart, so there's nothing to migrate from. First boot on v0.7.0 starts clean; next `selectProfile` call writes through.
|
||||
- **UI for inspecting the gateway-running timestamp.** Useful for debugging stale probes but noise for the common path. Track in a future dashboard pass if it's actually asked about.
|
||||
|
||||
**Upstream dependency.** The three new profile fields are optional on the wire and fall back to defaults, so this PR is safe to land ahead of the Python-worker relay change. Pairing against a pre-v0.7 relay continues to work — the phone just renders every dot grey and hides every badge.
|
||||
|
||||
## 2026-04-18 — v0.6.0: Multi-Connection + Agent Profiles
|
||||
|
||||
**Branch:** `feature/multi-profile-connections`. Landing as v0.6.0. Two orthogonal pieces of work that compose into a three-layer agent model (Connection → Profile → Personality), plus a top-bar + Settings UX consolidation.
|
||||
|
||||
**Shipped.**
|
||||
|
||||
- **Multi-Connection (`docs/decisions.md` §19).** Renamed the original internal "profile" concept to **Connection** — a paired Hermes *server*. `ConnectionStore` persists N connections in DataStore with the active one, migration seeds connection 0 from the existing `hermes_companion_auth_hw` store (zero re-pair, transparent to the user), `SessionTokenStore` takes a `prefsName` parameter so each Connection gets its own `EncryptedSharedPreferences` file, `AuthManager` gains a `connectionId` ctor. Switch is a heavy context swap: cancel in-flight SSE, disconnect relay WSS, rebind provider StateFlows (`RelayHttpClient` / `RelayVoiceClient` re-read lazily), rebuild `HermesApiClient`, reconnect, reprobe capabilities, reload sessions + personalities + profiles, restore per-Connection last-active session. Top-bar Connection chip + `ConnectionSwitcherSheet` for switching; Settings → Connections for CRUD (rename, re-pair via `ConnectionWizard`, revoke, remove). Per-connection scope: sessions, memory, personalities, skills, profiles, relay cert pin, voice endpoints, last-active session. Global scope: theme, bridge safety prefs, TOFU cert-pin map, notification companion state.
|
||||
- **Agent Profiles (`docs/decisions.md` §21).** Directory-scan + overlay. Relay walks `~/.hermes/profiles/*/`, reads each profile's `config.yaml` + `SOUL.md`, and advertises the list in `auth.ok` as `{name, model, description, system_message}` (plus a synthetic "default" entry for the root config). Phone sends `model` override + `system_message` from `SOUL.md` on chat turns when a profile is selected. Gated by `RELAY_PROFILE_DISCOVERY_ENABLED=1` (default on). Shipped as `overlay-not-isolation`: memory, sessions, `.env`, skills, and cron jobs still ride the Connection's default gateway. For full isolation, run the profile's own gateway on its own port and pair as a separate Connection.
|
||||
- **Consolidated agent sheet.** Deleted standalone `ProfilePicker.kt` and `PersonalityPicker.kt` top-bar chips in favor of a single bottom sheet opened from the top-bar agent name. Sheet holds Profile list, Personality list, and session info + analytics (message count, tokens in/out, avg TTFT). Scrollable. Toast confirmations on switch. Reclaims top-bar real estate and reduces the visual layer count (one tap instead of two chips).
|
||||
- **Settings "Active agent" card** — top-of-Settings summary of the current Connection / Profile / Personality with a `openAgentSheet` nav arg that navigates to Chat and auto-opens the sheet. Closes the "how do I change my agent" discoverability gap for Settings-originating users.
|
||||
- **Polish pass (7031115).** Pair wizard URL-scheme cross-validation (inline hint when API field has `wss://`); pair-stamp of the active Connection's metadata on successful auth (no stale state after re-pair); `ConnectionStatusBadge` top-aligns on multi-line rows; Settings treats paired + briefly-disconnected Connection as **Connecting** (amber) rather than **Disconnected** (red).
|
||||
|
||||
**Key architectural decisions.**
|
||||
|
||||
1. **Directory-scan for profiles.** First pass parsed a fictional top-level `profiles:` / `agents:` list in `config.yaml`. Upstream has never used that shape — profiles upstream are isolated directory instances at `~/.hermes/profiles/<name>/`, each with its own config, `.env`, `SOUL.md`, memory, sessions, and (optionally) its own gateway daemon. Old path always returned empty on real installs. Rewrite: walk `~/.hermes/profiles/*/`, read config + SOUL, report what's really there. See the "Earlier (abandoned) design" paragraph in `docs/decisions.md` §21 and commits `0303a4f` / `b9d2914` / `ec7559c`.
|
||||
2. **Overlay-not-isolation for v1.** A profile overrides `model` + `system_message`; everything else (memory, sessions, skills, API keys) rides the Connection's gateway. Rationale: a real per-profile isolation layer is "run the profile's gateway as its own service and pair it as a separate Connection" — we already have the plumbing for that via Multi-Connection. Building a second isolation layer inside one gateway would double the attack surface for no UX win.
|
||||
3. **Three-layer model = Connection → Profile → Personality.** Connection picks the server. Profile picks the agent directory on that server. Personality picks a system-prompt preset within the agent's config. Hierarchy flows top to bottom; picking a Connection resets Profile (Profile is ephemeral and server-scoped). `docs/decisions.md` §8 now carries a terminology-note block making this explicit, since the §8 title still says "Profiles" in the legacy sense.
|
||||
4. **Kept scope intentionally small for v0.6.0.** No per-Connection Profile persistence, no gateway-running probe, no mode where one Connection hosts multiple profile-gateways behind one pairing. Those are §21 follow-ups (see Deferred).
|
||||
|
||||
**Deferred to v0.7+.**
|
||||
|
||||
- **True per-profile isolation via separate Connections.** Doc how `hermes -p <name> platform start api --port 8643` + pair-as-new-Connection achieves this today, in user-docs.
|
||||
- **Persisted profile selection per Connection.** Currently resets on app restart and Connection switch. ~30-line extension: add `lastSelectedProfileName` to the `Connection` data class and restore on switch.
|
||||
- **Gateway-running probe** (hermes-desktop-inspired). Would let the Connection health indicator distinguish "server unreachable" from "paired, awake, responding" without waiting for a first request to fail. Low-priority; the existing probe behavior is adequate for v0.6.0.
|
||||
|
||||
**Docs pass.**
|
||||
|
||||
- `CHANGELOG.md` — new `[0.6.0]` section above the existing (v0.5.x-work-in-progress) entries.
|
||||
- `docs/spec.md` — Chat top-bar section rewritten around the three-chip reality; `auth.ok` `profiles[]` field documented.
|
||||
- `docs/decisions.md` — §8 gained a terminology-note block pointing forward to §19 and §21.
|
||||
- `user-docs/features/connections.md`, `profiles.md`, `personalities.md` — top-bar chip references updated to the agent sheet. `index.md` feature grid picked up Connections + Profiles rows.
|
||||
- `user-docs/guide/chat.md` — Personalities section expanded into "Agent Sheet — Profile + Personality" + Connection Chip subsection. `getting-started.md` gained a "Multiple Hermes servers" tip pointing at `features/connections.md`.
|
||||
- `user-docs/architecture/decisions.md` — new ADR-14 (Multi-Connection) + ADR-15 (Agent Profile picker) mirroring `docs/decisions.md`.
|
||||
- `README.md` — new "What's new in v0.6.0" block above the feature list; added a feature bullet for multi-Connection + profiles.
|
||||
|
||||
## 2026-04-18 — Dashboard pairing: `/pairing/mint` schema rework + grants render fix
|
||||
|
||||
**Context.** Bailey hit a silent pairing failure scanning QRs minted by the dashboard's "Pair new device" flow. Logcat showed the app attempting pairing with `serverUrl=http://172.16.24.250:8767` (relay's port, not API's) and an empty `relay` block (`relayUrl=` and `code=` both blank), causing `applyServerIssuedCodeAndReset` to bail with "empty code, returning early — authState NOT reset" and the WSS never handshook.
|
||||
|
||||
**Root cause.** `handle_pairing_mint` in `plugin/relay/server.py` was written as if the QR was relay-only: it defaulted top-level `host/port/tls` to `server.config.host/port` (which is the RELAY's bind — `0.0.0.0:8767`), put the freshly-minted pairing code in top-level `key`, and emitted only session metadata inside the `relay` block (`ttl_seconds`, `grants`, `transport_hint` — no `url`, no `code`). But the wire format documented at `docs/spec.md` §3.3.1 and implemented by `QrPairingScanner.kt` expects top-level = **API** server (port 8642 default) with `relay.{url,code}` nested. The CLI path (`pair.py` → `build_payload` at line 762) was correct; the endpoint was the outlier. The dashboard plugin's "editable pair URL" feature (commit `d7e5fc8`) inherited the broken semantics because its request body forwards `host/port/tls` verbatim to the endpoint.
|
||||
|
||||
**Fix.** `handle_pairing_mint` now mirrors the CLI shape exactly:
|
||||
- Top-level `host/port/tls` default from `RelayConfig.webapi_url` (parsed via `urllib.parse.urlparse`; host resolved through `pair._resolve_lan_ip` so `localhost` / `0.0.0.0` become the machine's LAN IP, which is what the phone needs)
|
||||
- Body overrides: `host` / `port` / `tls` / `api_key`
|
||||
- `relay_block["url"]` derived from `_relay_lan_base_url(server.config.host, server.config.port, tls=bool(server.config.ssl_cert))` — the relay knows its own WSS address
|
||||
- `relay_block["code"]` carries the minted value (used to live at top-level `key`; `key` is now the optional API bearer)
|
||||
|
||||
Regression test at `plugin/tests/test_pairing_mint_schema.py` — 8 AioHTTP cases pinning the payload shape that `QrPairingScanner.kt` parses, including that the minted code lands in `relay.code` (not top-level `key`) and top-level port defaults to 8642 (not 8767). All pass. Test file uses `unittest` per CLAUDE.md — `pytest` tripped on `conftest.py`'s `responses` import on the server venv.
|
||||
|
||||
**Doc sync.** `docs/spec.md` §3.3.1 already documented the correct wire format — this fix brought the server code back into line. Bumped the Updated stamp from 2026-04-13 to 2026-04-18 and added a line under Implementation references pointing at `handle_pairing_mint` + the new regression test. `CLAUDE.md` Key Files rows for `plugin/relay/server.py` + `plugin/dashboard/plugin_api.py` updated to reflect the API-server-at-top-level semantics.
|
||||
|
||||
**Deployment hazard discovered along the way.** The server had two parallel checkouts: `~/hermes-relay/` (a dev clone, looked authoritative because SYSTEM.md lists `~/` as the project root) and `~/.hermes/hermes-relay/` (the actual symlinked install per CLAUDE.md). Running `git pull` in the wrong one updated nothing visible — restart-and-check reported stale version. Verified via `systemctl --user cat hermes-relay | grep WorkingDirectory` that the installed path is the `.hermes/` copy; then pulled + restarted on that one. Also noticed the installed repo was on a long-dead `feature/dashboard-plugin` branch with 10 local WIP commits (bisect diagnostics from the dashboard build-up). Switched to `main` (fast-forward clean, 45 commits) after confirming the local feature branch's meaningful work was already on origin/main through the merged PR. The dead branch stays in local refs for history; origin has no matching ref.
|
||||
|
||||
**Bonus fix in the same branch.** Dashboard's Relay Management tab was crashing with minified React error #31 (`object with keys {chat, terminal, bridge}`) when rendering the paired-sessions list. `RelayManagement.jsx:172` wrapped a dict-shaped `s.grants` in a 1-element array then rendered each entry as a React child — Badge doesn't accept objects. Fix: `Object.keys(s.grants)` when the value is dict-shaped so Badge children are always channel-name strings. Rebuilt `plugin/dashboard/dist/index.js` via `npm run build` (the dashboard loads the pre-built IIFE verbatim, source edits without a rebuild are invisible).
|
||||
|
||||
**Branch + PR.** `fix/pairing-mint-schema` — two commits (`4f0affa`, `ca50524`), pushed, deployed to `~/.hermes/hermes-relay/`, restarted, verified live `curl /pairing/mint` round-trip produces the correct shape. PR open at the origin-suggested URL — merge to land in v0.6.0.
|
||||
|
||||
**Principle.** The original endpoint divergence would have been caught immediately by a schema round-trip test between the server's minted payload and the Android parser's data class. Added that test now as guardrail, not post-mortem — the two implementations will drift again eventually, this time CI yells instead of a silent field-mapping failure.
|
||||
|
||||
## 2026-04-18 — v0.5.1 release prep: lint iterations + VoicePlayerTest deferred
|
||||
|
||||
**PR #31** (`feature/voice-quality-pass` → `main`, v0.5.1 candidate) — CI iteration, no app-behavior changes beyond the preceding v0.5.1 feature commits.
|
||||
@@ -109,6 +915,47 @@ Plan written to `docs/plans/2026-04-16-voice-quality-pass.md` covering 8 work un
|
||||
|
||||
**Rebase note (2026-04-17).** This branch was rebased onto `main` at v0.5.0 (from earlier base `b811da1`). Conflicts resolved: a single-import collision in `VoiceViewModel.kt` (kept both `supervisorScope` + `contentOrNull`), DEVLOG + CHANGELOG prepends (combined both days' entries in reverse chronological order, this entry placed above the v0.4.1 bridge polish entry since VQP is the newer work on 2026-04-17), and a `docs/spec.md` auto-merge. No code logic changed during rebase.
|
||||
|
||||
## 2026-04-18 — Dashboard plugin
|
||||
|
||||
**Context.** The upstream Dashboard Plugin System landed on hermes-agent's `axiom` branch in three commits (`01214a7f` plugin system, `3f6c4346` theme, `247929b0` OAuth providers). The gateway now scans `~/.hermes/plugins/<name>/dashboard/manifest.json` at startup and exposes registered plugins as tabs in its web UI. Since `~/.hermes/plugins/hermes-relay` already points at our `plugin/` subtree (from `install.sh`), we can ship a relay-specific dashboard plugin by only adding a `plugin/dashboard/` subtree plus a few relay HTTP routes — no hermes-agent fork needed. The four deferred-audit items that only the relay knows about — paired-device state, bridge command history, push delivery (future), media-registry tokens — map cleanly onto four tabs of a single plugin.
|
||||
|
||||
**Decision summary.**
|
||||
|
||||
1. **Single plugin with internal shadcn `Tabs`**, not four plugins. Manifest allows one `tab.path` per plugin; four plugins would fragment the nav. Recorded as ADR 19 in `docs/decisions.md`.
|
||||
2. **Pre-built IIFE bundle committed to git.** Upstream's example plugin uses plain `React.createElement` (no build), but four non-trivial tabs are painful to maintain that way. Source lives under `plugin/dashboard/src/`, bundled with esbuild to a single ~16 KB IIFE at `plugin/dashboard/dist/index.js`. Dashboard never runs the build — operators get a ready-to-serve bundle.
|
||||
3. **Loopback-only for the new relay routes.** `/bridge/activity`, `/media/inspect`, `/relay/info` are gated the same way `/bridge/status` and `/pairing/register` already are. The plugin backend runs inside the gateway process (also localhost) and calls the relay at `http://127.0.0.1:8767/...` — no bearer minting. Also added a loopback-exempt branch on `/sessions` so the plugin proxy can list paired devices without one.
|
||||
4. **Dashboard backend is a thin proxy.** `plugin/dashboard/plugin_api.py` exposes five routes at `/api/plugins/hermes-relay/*` and forwards to the relay. No business logic in the plugin — relay stays source of truth.
|
||||
5. **Push Console is a real tab, stub data.** Keeps the four-tab nav layout correct for when FCM lands; swapping in real data is additive.
|
||||
|
||||
**Implementation (Wave-by-wave).**
|
||||
|
||||
- **Wave 1 (relay state plumbing).** `2212fbc — feat(relay): add MediaRegistry.list_all for dashboard inspector` added the lock-guarded snapshot method that strips absolute paths, returns basename-only `file_name` fields, and filters expired entries by default (+8 unit tests). `777a06a — feat(relay): add bridge command ring buffer for dashboard activity feed` added the `BridgeCommandRecord` dataclass + `deque(maxlen=100)` on `BridgeHandler`, wired append/update into `handle_command()` / `handle_response()`, and taught `get_recent()` to redact params keyed on `{password, token, secret, otp, bearer}` (+9 unit tests covering append, update, timeout path, redaction, ring-buffer eviction).
|
||||
- **Wave 2 (relay HTTP routes).** `4370806 — feat(relay): add /bridge/activity /media/inspect /relay/info for dashboard` added the three loopback-gated handlers in `plugin/relay/server.py` adjacent to `/bridge/status` (factored through a `_require_loopback()` helper), plus the tiny `handle_sessions_list` loopback-exempt branch so the dashboard proxy can list paired devices without a bearer. Route tests extend `test_bridge_activity.py` + `test_media_inspect.py` and a new `test_relay_info.py` covers the aggregate status shape.
|
||||
- **Wave 3 (dashboard plugin body).** `b51940c — feat(dashboard): add plugin_api.py proxy to relay HTTP` added the FastAPI router with five routes (`/overview`, `/sessions`, `/bridge-activity`, `/media`, `/push`), a shared `_proxy_get()` helper, and structured 502 translation on relay connect-error / timeout / 5xx so the UI can show "relay unreachable" (+10 unit tests in `plugin/dashboard/test_plugin_api.py`). `087149e — feat(dashboard): add React UI for four relay tabs` added the `plugin/dashboard/{src,dist}/` subtree — JSX sources under `src/` (index + four tab components + `lib/api.js` + `lib/formatters.js`), esbuild toolchain (`package.json` + `build.sh`), and the committed ~16 KB IIFE bundle at `dist/index.js` that registers as `window.__HERMES_PLUGINS__.register("hermes-relay", …)` via the SDK global.
|
||||
- **Wave 4 (manifest wiring).** `78c209e — feat(dashboard): add plugin manifest + plan file` added `plugin/dashboard/manifest.json` — `name: "hermes-relay"`, `label: "Relay"`, `icon: "Activity"` (from the 20-name Lucide whitelist), `tab.path: "/relay"`, `tab.position: "after:skills"`, `entry: "dist/index.js"`, `api: "plugin_api.py"`. Verified the existing `~/.hermes/plugins/hermes-relay` symlink resolves `dashboard/manifest.json` correctly.
|
||||
|
||||
**Files touched (this session — Doc1 wave only).**
|
||||
|
||||
- `CHANGELOG.md` — new `[Unreleased]` "Added — Dashboard plugin" bullet group
|
||||
- `DEVLOG.md` — this entry
|
||||
- `README.md` — one-line mention under Quick Start
|
||||
- `CLAUDE.md` — `plugin/dashboard/` added to Repository Layout; four Key Files rows under a new "Plugin — Dashboard" group
|
||||
- `docs/spec.md` — "Dashboard plugin" subsection appended to §10 Hermes Integration Points
|
||||
- `docs/decisions.md` — new ADR 19 "Dashboard plugin: single plugin with internal tabs + pre-built IIFE bundle"
|
||||
- `docs/relay-server.md` — three new rows in the HTTP Routes table + loopback-branch note on `/sessions`
|
||||
- `user-docs/features/dashboard.md` — new user-facing page
|
||||
- `user-docs/.vitepress/config.mts` — sidebar nav entry for dashboard.md
|
||||
|
||||
**Deferred.**
|
||||
|
||||
- **Push Console needs FCM.** The tab renders a static "FCM not configured" banner from `GET /api/plugins/hermes-relay/push`. Swapping in real data is additive — only `PushConsole.jsx` + `plugin_api.py::get_push` change. Tracked under the `deferred_items` memory entry.
|
||||
- **Session revoke button is a placeholder.** The Relay Management tab exposes "Revoke" buttons per paired device but they currently log to the console — the plugin proxy would need a new `DELETE /api/plugins/hermes-relay/sessions/{prefix}` route forwarded to the relay's existing `DELETE /sessions/{token_prefix}`. Blocked on deciding the auth story (loopback-only? dashboard session token?). Keeps the UI placement correct for when the proxy route lands.
|
||||
- **Session extend button** not yet built; would follow the same proxy pattern against `PATCH /sessions/{token_prefix}`.
|
||||
|
||||
**Next session.** Bailey restarts `hermes-gateway` on the server, verifies `GET /api/dashboard/plugins` includes `hermes-relay`, loads the dashboard UI, and exercises all four tabs against a live paired phone. Screenshot the tabs so we can drop real images into `user-docs/features/dashboard.md` in place of the placeholder captions.
|
||||
|
||||
**No blockers.**
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-17 — v0.4.1 Bridge page polish pass
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
<h1 align="center">Hermes-Relay</h1>
|
||||
|
||||
<p align="center">
|
||||
Native Android client for the Hermes agent platform.<br>
|
||||
Chat, control, and connect — one app for your AI agent.
|
||||
<strong>One Hermes agent. Two ways to use it.</strong><br>
|
||||
A native Android remote-control app for your phone, plus a desktop CLI that lets you<br>
|
||||
use a server-deployed Hermes from your laptop as if it were running locally.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT"></a>
|
||||
<a href="https://developer.android.com"><img src="https://img.shields.io/badge/Platform-Android-green.svg" alt="Android"></a>
|
||||
<a href="https://developer.android.com"><img src="https://img.shields.io/badge/Surface%201-Android-green.svg" alt="Android"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/Surface%202-Desktop%20CLI-orange.svg" alt="Desktop CLI"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Min%20SDK-26-brightgreen.svg" alt="Min SDK 26"></a>
|
||||
</p>
|
||||
@@ -29,30 +31,84 @@
|
||||
|
||||
---
|
||||
|
||||
## Two surfaces, one pair
|
||||
|
||||
| Surface | What | Status |
|
||||
|---------|------|--------|
|
||||
| **[Android app](#1a-android-app)** | Native phone control — chat, voice, the agent reads your screen and acts on it (tap, type, swipe), notification companion, multi-Connection. | Available — Google Play (Internal testing) + sideload APK |
|
||||
| **[Desktop app + CLI](#1b-desktop-app--cli-experimental)** | Use a server-deployed Hermes from your laptop **like it's local**. Windows gets the native tray app first: pair, start/pause the daemon, view devices, task log, settings, overlay status, and emergency stop. The CLI remains the terminal/headless surface and powers macOS/Linux installs. Experimental computer-use tools are opt-in. | **Experimental** — `desktop-v0.3.0-alpha.18` (Windows tray installer + native CLI binaries, no Node required) |
|
||||
|
||||
Both share `~/.hermes/remote-sessions.json` and the same WSS relay. **Pair once from either, both work.**
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
Two steps: install the Android app on your phone, then install the plugin on your Hermes server.
|
||||
Three steps: pick your surface (or install both), then install the relay plugin on your Hermes server.
|
||||
|
||||
### 1. Install the Android app
|
||||
### 1a. Android app
|
||||
|
||||
<!-- TODO: Uncomment when Play Store listing is live
|
||||
<a href="https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80"></a>
|
||||
-->
|
||||
|
||||
- **Google Play** — coming soon (currently on Internal testing)
|
||||
- **APK** — download from [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases/latest)
|
||||
- **APK** — download from [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and choose the newest Android release (`android-v*`; historical Android releases used bare `v*`)
|
||||
|
||||
#### Sideload APK (GitHub Releases)
|
||||
|
||||
Prefer not to wait for Google Play? Grab the signed APK directly:
|
||||
|
||||
1. Download the file ending in **`-sideload-release.apk`** from [the latest release](https://github.com/Codename-11/hermes-relay/releases/latest) — that's the full-featured "Hermes Dev" build. (Skip any `.aab` file — those are the Google Play bundle format and won't install directly.)
|
||||
1. Download the file ending in **`-sideload-release.apk`** from the newest Android release (`android-v*`; historical Android releases used bare `v*`) on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) — that's the full-featured "Hermes Dev" build. (Skip any `.aab` file — those are the Google Play bundle format and won't install directly.)
|
||||
2. On your phone: **Settings → Apps → Special app access → Install unknown apps** and allow your browser (first time only).
|
||||
3. Open the APK from your downloads and tap **Install**.
|
||||
4. Optionally verify integrity against `SHA256SUMS.txt` from the same release (`sha256sum` on macOS/Linux, `Get-FileHash -Algorithm SHA256` on Windows).
|
||||
|
||||
Full walkthrough, including signing-certificate fingerprint: [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
|
||||
**Staying up to date (sideload):** the app checks GitHub for a newer release on cold start (at most once every 6 hours) and shows a dismissable banner when you're behind. Tapping **Update** opens the next APK in your browser so Android's Downloads notification hands it to the system installer — no second app required. You can also trigger a check manually under **Settings → About → Updates**. Google Play installs get auto-updates through the Play Store and don't show this banner.
|
||||
|
||||
### 1b. Desktop app + CLI (experimental)
|
||||
|
||||
The desktop surface talks to a server-deployed Hermes over WSS. On Windows, the default installer launches the native tray app with pairing, daemon control, devices, task log, settings, overlay status, pause, and emergency stop. The same release still ships the `hermes-relay` CLI for shell/TUI use, scripting, headless daemon mode, and macOS/Linux.
|
||||
|
||||
The remote agent can also reach back through the relay and run `desktop_read_file`, `desktop_terminal`, `desktop_search_files`, `desktop_screenshot`, `desktop_clipboard_*`, `desktop_open_in_editor`, etc. **on your machine** while its brain stays on the host. One pair, two surfaces (with the Android app), no `ssh`.
|
||||
|
||||
**Install tray app** (Windows PowerShell):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Install CLI only** (Windows PowerShell):
|
||||
|
||||
```powershell
|
||||
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Install CLI** (macOS / Linux):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767 # once
|
||||
hermes-relay # interactive Hermes TUI in tmux
|
||||
hermes-relay "summarize the last commit" # one-shot
|
||||
hermes-relay --json "..." | jq # structured events for scripting
|
||||
hermes-relay daemon # headless tool router (agent reaches you anytime)
|
||||
hermes-relay update # self-update via GitHub Releases
|
||||
```
|
||||
|
||||
**Native paste workflow** (the killer demo): inside `hermes-relay shell`, hit `Win+Shift+S` to screenshot, then `Ctrl+A v` — the client reads your clipboard, ships the image to the server's inbox, and types `/paste` into the TUI for you. Identical UX to native local-Hermes paste. The same chord set works on macOS (`Cmd+Shift+4` → `Ctrl+A v`) and Linux (Wayland/X11 detected automatically).
|
||||
|
||||
**No Node required** — the Windows tray installer bundles the compiled CLI sidecar; CLI-only installs use Bun-compiled native binaries (~60–110 MB per platform) via curl/irm. Version-aware install (`upgrading X → Y`), collision-safe `hermes` short alias for CLI installs, self-update via `hermes-relay update`. Assets are **unsigned** during the experimental phase — SmartScreen/Gatekeeper warnings are expected. Code signing, multi-client server-side routing, and service installers (sc.exe / systemd / launchd) land with v1.0.
|
||||
|
||||
- **Docs**: [Desktop guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **Release track**: tagged `desktop-v*`, [separate from Android](https://github.com/Codename-11/hermes-relay/releases?q=desktop)
|
||||
- **AI-agent setup recipe**: `/hermes-relay-desktop-setup` (the agent can run `desktop_terminal` on your machine to diagnose install/pair issues live)
|
||||
|
||||
### 2. Install the server plugin (one-liner)
|
||||
|
||||
On the machine running your Hermes agent:
|
||||
@@ -61,35 +117,37 @@ On the machine running your Hermes agent:
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
```
|
||||
|
||||
The installer clones Hermes-Relay to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`), `pip install -e`s the package into the hermes-agent venv, registers the `skills/` directory in your `~/.hermes/config.yaml` under `skills.external_dirs` (so updates flow through `git pull`), symlinks the plugin into `~/.hermes/plugins/hermes-relay`, drops a thin `hermes-pair` shim into `~/.local/bin/`, and (optionally) installs a systemd user service for the WSS relay. After restart, pair your phone via either of these equivalent entry points:
|
||||
The installer clones Hermes-Relay to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`), `pip install -e`s the package into the hermes-agent venv, registers the `skills/` directory in your `~/.hermes/config.yaml` under `skills.external_dirs` (so updates flow through `git pull`), symlinks the plugin into `~/.hermes/plugins/hermes-relay`, drops a thin `hermes-pair` shim into `~/.local/bin/`, and (optionally) installs a systemd user service for the WSS relay. After restart, pair your client via either of these equivalent entry points:
|
||||
|
||||
- **From any Hermes chat surface** (CLI, Discord, Telegram, etc.): type `/hermes-relay-pair` and the `hermes-relay-pair` skill renders the QR inline. Shortest path if you're already chatting with the agent.
|
||||
- **From any Hermes chat surface** (CLI, Discord, Telegram, etc.): type `/hermes-relay-pair` and the `hermes-relay-pair` skill renders the QR + 6-char code inline. Shortest path if you're already chatting with the agent.
|
||||
- **From a shell**: `hermes-pair` (dashed) — a thin wrapper around `python -m plugin.pair` in the hermes-agent venv. Use this in scripts or when you want the raw output.
|
||||
- **No camera?** `hermes-pair --register-code ABCD12` — manual fallback for SSH-only / camera-less setups. Read the 6-char code from the app's **Settings → Connection → Manual pairing code (fallback)** card, pre-register it on the host with this command, then tap **Connect** in the app. Composes with `--ttl` / `--grants`.
|
||||
- **No camera?** `hermes-pair --register-code ABCD12` — manual fallback for SSH-only / camera-less setups. For Android: read the 6-char code from the app's **Settings → Connection → Manual pairing code (fallback)** card, pre-register it on the host with this command, then tap **Connect** in the app. For the desktop CLI: just pass it as `hermes-relay pair ABCD12 --remote ws://<host>:8767`. Composes with `--ttl` / `--grants`.
|
||||
|
||||
Scan the QR from the Android app's onboarding screen and you're connected. One scan configures **both** the direct-chat API server **and** the WSS relay (for terminal/bridge) — if a local relay is running at `localhost:8767`, the pair command pre-registers a fresh 6-char pairing code with it and embeds the relay URL + code in the same QR. If you only want direct chat, pass `--no-relay` (or just don't start the relay). Plain-text connection details are always printed alongside the QR so you can copy values by hand if your terminal can't render QR blocks.
|
||||
Scan the QR from the Android app's onboarding screen, OR paste the 6-char code into `hermes-relay pair --remote ws://<host>:8767` on your laptop, and you're connected. One pair configures **both** the direct-chat API server **and** the relay (WSS for terminal / bridge / TUI / desktop tools, HTTP for voice routes) — if a local relay is running at `localhost:8767`, the pair command pre-registers a fresh 6-char pairing code with it and embeds the relay URL + code in the same QR. If you only want direct chat from the Android app, pass `--no-relay` (or just don't start the relay). Plain-text connection details are always printed alongside the QR so you can copy values by hand if your terminal can't render QR blocks.
|
||||
|
||||
**Dashboard plugin.** If your hermes-agent install has the Dashboard Plugin System (upstream `axiom` branch), Hermes-Relay ships a plugin at `plugin/dashboard/` that surfaces paired devices, bridge command activity, and active inbound-media tokens in the gateway's web UI. It auto-registers through the same `~/.hermes/plugins/hermes-relay` symlink created by `install.sh` — restart the gateway and a "Relay" tab appears. See [docs/relay-server.md](docs/relay-server.md) and `user-docs/features/dashboard.md` for details.
|
||||
|
||||
**Updating:** `hermes-relay-update` (shortest path — installed as part of the one-liner) or re-run the same `curl … | bash` from above. Both are equivalent and fully idempotent: pulls latest main, refreshes the editable install, recreates all three shims, restarts `hermes-relay`, and prompts before restarting `hermes-gateway`. Set `HERMES_RELAY_RESTART_GATEWAY=1` to opt into the gateway restart non-interactively. For routine plugin/skill updates without restarting anything, a plain `cd ~/.hermes/hermes-relay && git pull` is enough — the editable install picks up the new code on next process start.
|
||||
|
||||
**Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` reverses every install step in the opposite order. Idempotent, never touches state shared with other Hermes tools (`.env`, sessions DB, hermes-agent venv core). Flags: `--dry-run`, `--keep-clone`, `--remove-secret`. Or pull the script via curl if you've already removed the clone.
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26), [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+.
|
||||
**Requirements:** Android 8.0+ (SDK 26) for the Android app · macOS / Linux / Windows for the desktop CLI · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+ on the server.
|
||||
|
||||
### For AI Agents
|
||||
|
||||
If you have an AI assistant (Claude, GPT, etc.) and want it to install or maintain Hermes-Relay for you, paste the block below into the chat. The agent will fetch the canonical setup recipe from this repo and walk you through it — verification, pairing, troubleshooting included.
|
||||
|
||||
```text
|
||||
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay), a native Android client + Python plugin for the Hermes AI agent platform.
|
||||
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a desktop CLI + a Python plugin for the Hermes AI agent platform.
|
||||
|
||||
Read the canonical setup recipe before acting:
|
||||
https://raw.githubusercontent.com/Codename-11/hermes-relay/main/skills/devops/hermes-relay-self-setup/SKILL.md
|
||||
|
||||
Then guide me through:
|
||||
- Verifying hermes-agent is already installed (it's a prerequisite — Hermes-Relay is a plugin, not standalone)
|
||||
- Running the install one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash`
|
||||
- Pairing my phone via `hermes-pair` or `/hermes-relay-pair`
|
||||
- Verifying with `hermes-status`
|
||||
- Running the server-plugin install one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash`
|
||||
- Pairing my phone via `hermes-pair` or `/hermes-relay-pair` (Android), OR pairing my laptop via the `hermes-relay` desktop CLI (binary one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh` or `irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
|
||||
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (desktop CLI)
|
||||
|
||||
Always confirm before running shell commands. Never restart hermes-gateway without asking. If any step fails, consult the Troubleshooting section in the SKILL.md and ask me for the exact error.
|
||||
```
|
||||
@@ -98,19 +156,37 @@ Already have Hermes-Relay installed? The same recipe is auto-loaded as a Hermes
|
||||
|
||||
## What It Does
|
||||
|
||||
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — all native on Android.
|
||||
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — native on Android, native in the terminal, with the agent able to reach back through the relay and act on either surface.
|
||||
|
||||
| Channel | What | Status |
|
||||
|---------|------|--------|
|
||||
| **Chat** | Stream conversations to Hermes via HTTP/SSE | Available |
|
||||
| **Voice** | Real-time voice conversation via relay TTS/STT | Available |
|
||||
| **Bridge** | Agent reads the screen and performs UI actions (tap, long-press, drag, type, clipboard, media, macros, events) | Available |
|
||||
| **Terminal** | Secure remote shell via tmux | Phase 2 |
|
||||
| Surface | Channel | What | Status |
|
||||
|---------|---------|------|--------|
|
||||
| Android | **Chat** | Stream conversations to Hermes via HTTP/SSE | Available |
|
||||
| Android | **Voice** | Real-time voice conversation via relay TTS/STT | Available |
|
||||
| Android | **Bridge** | Agent reads the screen and performs UI actions (tap, long-press, drag, type, clipboard, media, macros, events) | Available |
|
||||
| Android | **Terminal** | Secure remote shell via tmux | Phase 2 |
|
||||
| Desktop CLI | **Shell** | Full Hermes Ink TUI piped over PTY in tmux on the host. Bare `hermes-relay` drops you in. | Available (experimental) |
|
||||
| Desktop CLI | **Chat** | Structured-event REPL / one-shot / piped stdin. `--json` for scripting. REPL supports `/paste`, `/screenshot`, `/image <path>`. | Available (experimental) |
|
||||
| Desktop CLI | **In-shell paste / screenshot** | `Ctrl+A v` (clipboard image → server inbox → `/paste` auto-typed). `/screenshot` is multi-monitor by default. | Available (experimental) |
|
||||
| Desktop CLI | **Local tool routing** | Agent calls `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` / `_clipboard_*` / `_screenshot` / `_open_in_editor` — runs on YOUR machine over the same relay | Available (experimental) |
|
||||
| Desktop CLI | **Daemon** | Headless tool router — keeps tools advertised even when no shell is open | Available (experimental) |
|
||||
| Desktop CLI | **Self-update** | `hermes-relay update` polls GitHub Releases, atomic-swaps the binary | Available (experimental) |
|
||||
|
||||
## What's new in v0.6.0
|
||||
|
||||
- **Connect from anywhere** — multi-endpoint pairing with first-class Tailscale support; plug in any VPN or reverse proxy mode. See [`docs/remote-access.md`](docs/remote-access.md).
|
||||
- **Multi-Connection support** — pair with multiple Hermes servers (home + work, dev + prod, etc.) and switch in one tap from the Chat top bar. Each Connection keeps its own sessions, personalities, profiles, and relay state; theme and safety preferences stay global. Existing installs migrate transparently.
|
||||
- **Agent Profiles** — the relay auto-discovers upstream Hermes profiles at `~/.hermes/profiles/*/` and the phone overlays the selected profile's model + `SOUL.md` on chat turns. Ephemeral, chat-only, clears on Connection switch. Gated by `RELAY_PROFILE_DISCOVERY_ENABLED` (default on).
|
||||
- **Consolidated agent sheet** — Profile + Personality selection and per-session analytics now live in one scrollable bottom sheet opened from the Chat top-bar agent name.
|
||||
|
||||
See the [changelog](CHANGELOG.md) for the full list.
|
||||
|
||||
## Features
|
||||
|
||||
### Android
|
||||
|
||||
- **Streaming chat** — Direct SSE to the Hermes API Server with real-time markdown rendering, session history, tool-call visualization, personality picker, searchable command palette (29+ gateway commands), file attachments, and send-while-streaming message queuing
|
||||
- **Voice mode** — Real-time voice conversation via the relay; the sphere listens with you and performs the agent's reply as it speaks. Uses your server's configured TTS/STT providers (Edge TTS, ElevenLabs, OpenAI, MiniMax, Mistral, NeuTTS / faster-whisper, Groq, OpenAI Whisper)
|
||||
- **Multi-Connection + agent profiles** — Pair with multiple Hermes servers and switch targets from the top bar; select an upstream-discovered agent profile to overlay model + `SOUL.md` on chat turns. Three-layer model: Connection (server) → Profile (agent directory) → Personality (prompt preset)
|
||||
- **Voice mode** — Experimental server-mediated voice conversation via the relay; the sphere listens with you and performs the agent's reply as it speaks. Hermes owns chat, tool calls, and approvals, while relay voice output defaults to provider-neutral streaming TTS (`xai_tts` first) with realtime voice-agent providers kept as a separate lab mode.
|
||||
- **Phone control (bridge)** — The agent can read what's on screen and act on it — tap, long-press, drag, swipe, scroll, type, and press system keys — plus take screenshots, read/write the clipboard, and control system-wide media playback. Gesture reliability is hardened for dim/idle screens, and a smarter tap-fallback cascade handles apps where labels sit inside non-clickable wrappers
|
||||
- **Screen understanding** — Filtered accessibility-tree search, per-node property lookups with stable IDs, cheap screen-hash change detection, and multi-window reads (system overlays, popups, notification shade) so the agent can reason about UI without guessing
|
||||
- **Workflow automation** — Batched macro execution for multi-step flows, real-time accessibility event streaming for "wait until something happens" waits, and a raw-Intent escape hatch for apps that expose deep-link actions
|
||||
@@ -121,33 +197,58 @@ Talk to your Hermes agent from anywhere. Direct API streaming, session history,
|
||||
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free voice intents like "text Sam I'll be 10 minutes late". See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the full sideload capability matrix.
|
||||
|
||||
### Desktop CLI
|
||||
|
||||
- **Shell mode (default)** — bare `hermes-relay` pipes the host's actual `hermes` Ink TUI through a PTY in tmux. Same banner, same skin, same slash commands as a local install. `Ctrl+A .` detaches (preserves tmux), `Ctrl+A k` kills, `Ctrl+A v` pastes a clipboard image, `Ctrl+A ?` re-prints chord help, `Ctrl+A Ctrl+A` literal.
|
||||
- **Chat mode** — REPL or one-shot or piped stdin. `--json` emits `GatewayEvent`s per line for `jq` / automation. REPL slash commands `/paste` (clipboard), `/screenshot` (multi-monitor by default; `primary` / `1` / `2` to narrow), `/image <path>` attach the next message.
|
||||
- **Local tool routing** — agent calls `desktop_read_file`, `desktop_write_file`, `desktop_terminal`, `desktop_search_files`, `desktop_patch`, `desktop_clipboard_read/write`, `desktop_screenshot`, `desktop_open_in_editor` — all run on YOUR machine over the same WSS relay. One-time per-URL consent gate; `--no-tools` kill-switch; non-TTY stdin fails closed; agent-proposed patches render as colored diffs with `y/n/e/r` interactive approval. Experimental `desktop_computer_*` control tools require `--experimental-computer-use` / `HERMES_RELAY_EXPERIMENTAL_COMPUTER_USE=1`, task-scoped grants, and visible local approval.
|
||||
- **Daemon mode** — `hermes-relay daemon` runs the tool router headless so the agent can reach you even when no shell is open. JSON-line lifecycle logs by default, auto-human on TTY. Fails closed on missing consent.
|
||||
- **Self-update** — `hermes-relay update` polls GitHub Releases (SemVer-max picker, prerelease-aware), verifies SHA256, atomic-swaps the binary on POSIX (running daemon keeps inode), cooperative `.new.exe` swap on Windows.
|
||||
- **Multi-endpoint pairing + reconnect-on-drop + TOFU cert pinning** — same as the Android app. One QR carries LAN + Tailscale + public; client races candidates in priority order, re-probes on every network change.
|
||||
- **Workspace awareness** — on connect, client advertises `cwd`, `git_root`, `git_branch`, `repo_name`, `hostname`, `platform`, `active_shell` to the relay (server-side prompt-context consumption coming).
|
||||
- **Conversation picker on attach** — without `--conversation` / `--new`, you get a numbered list of recent server-side hermes sessions to resume.
|
||||
- **One install, one binary, no Node required** — Bun-compiled native binaries via curl/irm one-liners; collision-safe `hermes` short alias auto-installed.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Install the app** from the link above
|
||||
2. **Enter your Hermes server URL** (e.g. `http://192.168.1.100:8642`) during onboarding
|
||||
**Android:**
|
||||
|
||||
1. **Install the app** from the [link above](#1a-android-app)
|
||||
2. **Enter your Hermes server URL** (e.g. `http://192.168.1.100:8642`) during onboarding, or scan a QR via `/hermes-relay-pair`
|
||||
3. **Start chatting** — the app connects directly to the Hermes API Server
|
||||
|
||||
**Desktop CLI:**
|
||||
|
||||
1. **Install the binary** — [PowerShell `irm`](#1b-desktop-cli-experimental) (Windows) / curl (macOS / Linux) one-liner
|
||||
2. **Pair once** — `hermes-relay pair --remote ws://<host>:8767` (mint code via `hermes-pair` or `/hermes-relay-pair` on the server first)
|
||||
3. **Drop into the shell** — bare `hermes-relay` opens the full Hermes TUI in tmux on the host
|
||||
|
||||
For detailed setup, server configuration, and feature guides, see the **[full documentation](https://codename-11.github.io/hermes-relay/)**.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
|
||||
Phone (WSS) --> Relay Server (:8767) [terminal, bridge — future]
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
|
||||
Phone (HTTP) --> Server (:8767) [voice routes — API key or relay session]
|
||||
Phone (WSS/HTTP) --> Server (:8767) [terminal, bridge, media, sessions]
|
||||
Desktop CLI (WSS) --> Server (:8767) [tui, terminal, desktop tools]
|
||||
```
|
||||
|
||||
Chat connects directly to the Hermes API Server — same pattern used by Open WebUI and other Hermes frontends. The relay server is a separate lightweight Python service for terminal and bridge channels (coming in Phase 2/3).
|
||||
Chat from the Android app connects directly to the Hermes API Server with the Hermes API key — same pattern used by Open WebUI and other Hermes frontends. Voice calls the relay's `/voice/*` HTTP routes and authenticates with that Hermes API bearer when present, falling back to the relay session token for paired devices. Remote control surfaces such as terminal, bridge, TUI, media/session management, and desktop tools require relay pairing on `:8767`, so one scan can configure both the API route and the relay route without merging their auth models.
|
||||
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Getting started, features, configuration — start here** |
|
||||
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the app works under the hood |
|
||||
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by the app |
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Getting started, both surfaces, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android-specific install + setup + features |
|
||||
| [Desktop CLI](https://codename-11.github.io/hermes-relay/desktop/) | Desktop CLI guide — shell/chat, pairing, subcommands, local tool routing |
|
||||
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
|
||||
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
|
||||
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
|
||||
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
|
||||
| [Changelog](CHANGELOG.md) | Release history |
|
||||
| [Upstream Integration Sync](docs/upstream-integration-sync.md) | Supported Hermes extension points vs server-owned compatibility layers |
|
||||
| [Changelog](CHANGELOG.md) | Release history (Android `android-v*`, Server `server-v*`, and Desktop `desktop-v*`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -168,7 +269,7 @@ scripts/dev.bat bundle # Build release AAB for Google Play
|
||||
scripts/dev.bat run # Build + install + launch + logcat
|
||||
scripts/dev.bat test # Run unit tests
|
||||
scripts/dev.bat version # Show current version
|
||||
scripts/dev.bat relay # Start relay server (dev, no TLS)
|
||||
scripts/dev.bat relay # Start Server (dev, no TLS)
|
||||
```
|
||||
|
||||
### Repository Structure
|
||||
@@ -176,15 +277,21 @@ scripts/dev.bat relay # Start relay server (dev, no TLS)
|
||||
```
|
||||
hermes-relay/
|
||||
├── app/ # Android app (Kotlin + Jetpack Compose)
|
||||
├── relay_server/ # WSS relay server (Python + aiohttp)
|
||||
├── plugin/ # Hermes agent plugin (18 android_* tools + pair module)
|
||||
├── desktop/ # Desktop CLI thin-client (@hermes-relay/cli — TS + Bun-compiled binary)
|
||||
├── relay_server/ # WSS Server (Python + aiohttp; thin shim → plugin/relay)
|
||||
├── plugin/ # Hermes agent plugin
|
||||
│ ├── relay/ # - canonical relay (server.py, channels/, media, voice, desktop tools)
|
||||
│ ├── tools/ # - android_* bridge + desktop_* tool handlers
|
||||
│ └── pair.py # - QR pairing CLI + multi-endpoint payload builder
|
||||
├── skills/ # Hermes agent skills
|
||||
│ └── devops/
|
||||
│ └── hermes-relay-pair/ # /hermes-relay-pair slash-command skill
|
||||
├── user-docs/ # VitePress documentation site
|
||||
│ ├── hermes-relay-pair/ # /hermes-relay-pair slash-command skill
|
||||
│ ├── hermes-relay-self-setup/ # AI-agent setup recipe (Android + desktop)
|
||||
│ └── hermes-relay-desktop-setup/ # AI-agent recipe specifically for the desktop CLI
|
||||
├── user-docs/ # VitePress documentation site (Android + desktop sections)
|
||||
├── docs/ # Spec, decisions, security
|
||||
├── scripts/ # Dev helper scripts
|
||||
├── .github/workflows/ # CI + release pipelines
|
||||
├── .github/workflows/ # CI + release pipelines (ci-android / ci-server / ci-desktop)
|
||||
└── gradle/ # Wrapper (8.13) + version catalog
|
||||
```
|
||||
|
||||
@@ -193,13 +300,14 @@ hermes-relay/
|
||||
| Component | Stack |
|
||||
|-----------|-------|
|
||||
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Relay Server** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 |
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact) |
|
||||
| **Desktop CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
|
||||
| **Server** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization (Android) |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (desktop) |
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact, desktop binaries per platform) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
|
||||
|
||||
### Relay Server (optional — terminal/bridge only)
|
||||
### Server (optional — bridge, terminal, TUI, media, and voice routes)
|
||||
|
||||
```bash
|
||||
hermes relay start --no-ssl # if you installed the plugin
|
||||
@@ -225,7 +333,7 @@ cp -r plugin ~/.hermes/plugins/hermes-relay
|
||||
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
|
||||
```
|
||||
|
||||
Then restart hermes and run `hermes-pair` (dashed shell shim) or type `/hermes-relay-pair` in any Hermes chat surface to verify pairing. The 14 `android_*` tools register regardless of hermes-agent version. **Note:** a top-level `hermes pair` CLI sub-command is *not* currently exposed — hermes-agent v0.8.0's top-level argparser doesn't yet forward to third-party plugins' `register_cli_command()` dict. Use the slash command or the dashed shim instead.
|
||||
Then restart hermes and run `hermes-pair` (dashed shell shim) or type `/hermes-relay-pair` in any Hermes chat surface to verify pairing. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. **Note:** a top-level `hermes pair` CLI sub-command is *not* currently exposed — hermes-agent v0.8.0's top-level argparser doesn't yet forward to third-party plugins' `register_cli_command()` dict. Use the slash command or the dashed shim instead.
|
||||
|
||||
## Hermes Agent
|
||||
|
||||
@@ -233,7 +341,7 @@ Hermes-Relay is built for [Hermes Agent](https://github.com/NousResearch/hermes-
|
||||
|
||||
## Found a bug? Let us know!
|
||||
|
||||
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line "this didn't work on my Pixel 7" is genuinely useful.
|
||||
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line "this didn't work on my Pixel 7" / "the alpha.14 Windows binary segfaults on my Surface" is genuinely useful.
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
+255
-93
@@ -3,7 +3,7 @@
|
||||
> The full recipe for cutting a new release. Read this end-to-end before
|
||||
> tagging your first release.
|
||||
|
||||
## Versioning
|
||||
## Release Tracks And Versioning
|
||||
|
||||
Hermes-Relay follows [SemVer](https://semver.org/): `MAJOR.MINOR.PATCH`,
|
||||
with optional prerelease identifiers.
|
||||
@@ -13,6 +13,23 @@ with optional prerelease identifiers.
|
||||
- `PATCH` — bug fixes, backwards compatible
|
||||
- Prerelease suffixes: `-alpha`, `-beta`, `-rc.N` (e.g. `0.2.0-beta.1`)
|
||||
|
||||
Hermes-Relay now ships three independently versioned surfaces:
|
||||
|
||||
| Surface | Tag prefix | Version source | Bump script | Release workflow |
|
||||
|---|---|---|---|---|
|
||||
| Android app | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
|
||||
| Server / Python package | `server-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-server-version.sh` | `.github/workflows/release-server.yml` |
|
||||
| Desktop CLI | `desktop-v*` | `desktop/package.json` | `npm version` or manual package bump | `.github/workflows/release-desktop.yml` |
|
||||
|
||||
This split is intentional. The server now carries features for both Android
|
||||
and desktop, so server fixes can ship without forcing an Android app
|
||||
`versionCode` bump, and desktop CLI alphas can continue on their own cadence.
|
||||
Historical Android releases before this naming split used bare `v*` tags, and
|
||||
historical server releases used `relay-v*` tags. New releases use the explicit
|
||||
surface prefixes above.
|
||||
|
||||
### Android app versioning
|
||||
|
||||
**Source of truth:** `gradle/libs.versions.toml`
|
||||
|
||||
```toml
|
||||
@@ -44,36 +61,68 @@ Never decrement `appVersionCode` — Play Console rejects any upload whose
|
||||
code is lower than or equal to a previous upload on the same track. Confirm
|
||||
current values with `scripts\dev.bat version`.
|
||||
|
||||
### The three version sources (MUST stay in lockstep)
|
||||
|
||||
There are **three** places the version lives, and they MUST all match on
|
||||
every release commit. Drift is silent and painful — we chased a "why does
|
||||
/health say 0.2.0" bug for hours on 2026-04-12 because `pyproject.toml`
|
||||
had drifted to `0.5.0` speculatively and `plugin/relay/__init__.py` was
|
||||
still at a stale `0.2.0`.
|
||||
|
||||
| File | Line | Written by |
|
||||
|---|---|---|
|
||||
| `gradle/libs.versions.toml` | `appVersionName = "…"` | You (canonical) |
|
||||
| `pyproject.toml` | `version = "…"` | You (Python package) |
|
||||
| `plugin/relay/__init__.py` | `__version__ = "…"` | You (runtime, reported by `/health`) |
|
||||
|
||||
**Always bump them atomically via `scripts/bump-version.sh`**:
|
||||
Always bump Android releases via:
|
||||
|
||||
```bash
|
||||
bash scripts/bump-version.sh 0.3.0
|
||||
bash scripts/bump-android-version.sh 0.6.2
|
||||
```
|
||||
|
||||
The script validates SemVer, bumps `appVersionCode` monotonically, rewrites
|
||||
all three files, runs a post-bump sanity grep, prints the diff, and tells
|
||||
you the next steps. It deliberately does NOT commit, tag, or touch
|
||||
`CHANGELOG.md` / `RELEASE_NOTES.md` — those need human prose.
|
||||
`scripts/bump-version.sh` remains as a backward-compatible alias for the
|
||||
Android script.
|
||||
|
||||
### Server / Python package versioning
|
||||
|
||||
Server version metadata lives in these server-owned files and must stay in
|
||||
lockstep:
|
||||
|
||||
| File | Line | Purpose |
|
||||
|---|---|---|
|
||||
| `pyproject.toml` | `version = "..."` | Python package metadata |
|
||||
| `plugin/relay/__init__.py` | `__version__ = "..."` | runtime version reported by `/health` |
|
||||
| `plugin/plugin.yaml` | `version: ...` | Hermes plugin metadata |
|
||||
| `plugin/dashboard/manifest.json` | `"version": "..."` | Hermes dashboard plugin metadata |
|
||||
| `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata |
|
||||
| `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata |
|
||||
|
||||
Always bump Server releases via:
|
||||
|
||||
```bash
|
||||
bash scripts/bump-server-version.sh 0.6.2
|
||||
```
|
||||
|
||||
Check the current metadata with:
|
||||
|
||||
```bash
|
||||
python scripts/check-server-version-sync.py
|
||||
```
|
||||
|
||||
The `server-v*` release workflow validates the tag against the same metadata,
|
||||
runs server tests, builds a wheel and sdist, generates checksums, and publishes
|
||||
a GitHub Release with the package artifacts.
|
||||
|
||||
## Branching policy
|
||||
|
||||
Hermes-Relay uses **feature branches + no-ff merges**, with version bumps
|
||||
gated to release-prep commits on `main`. `main` is always "last release +
|
||||
unreleased features," never mid-refactor.
|
||||
> **Updated 2026-04-19:** moved from `main`-only to `main + dev`. See
|
||||
> `docs/decisions.md` §23 for the rationale.
|
||||
|
||||
Hermes-Relay uses **`main` + `dev` with feature branches and no-ff
|
||||
merges**. `main` is **released state only** — every commit on `main`
|
||||
corresponds to a shipped version or a release-merge of `dev`. Day-to-day
|
||||
integration happens on `dev`.
|
||||
|
||||
**Merging is decoupled from releasing.** Feature branches land on `dev`
|
||||
continuously as they go green in CI — there is no "one feature per
|
||||
release" rule. The `[Unreleased]` section of `CHANGELOG.md` on `dev` is
|
||||
the accumulator: every merged PR appends bullets there. A release is a
|
||||
separate act, taken when the accumulated state on `dev` is worth shipping
|
||||
(see "When to cut a release" below). Cutting a release means opening a
|
||||
surface-specific release PR from `dev` into `main`, merging it `--no-ff`,
|
||||
then tagging `main`.
|
||||
|
||||
**Server tracks `dev` for staging.** The hermes-host deployment pulls
|
||||
`dev` so merged features get exercised against real data before they
|
||||
reach a tag. Users (Play Store, sideload, `hermes-relay-update`) only
|
||||
see state that lives on `main` and on release tags.
|
||||
|
||||
### Branch names
|
||||
|
||||
@@ -84,14 +133,17 @@ unreleased features," never mid-refactor.
|
||||
| `docs/<name>` | Docs-only changes larger than a typo | `docs/sideload-guide` |
|
||||
| `chore/<name>` | Cleanup / refactor / tooling | `chore/sync-version-sources` |
|
||||
|
||||
Straight-to-main is still OK for **single-file typo fixes** and **tiny
|
||||
one-liner tweaks**. Judgment call — if in doubt, branch.
|
||||
All of the above branch off `dev` and merge back to `dev`. There is no
|
||||
straight-to-main exemption — even single-file typos go through a feature
|
||||
branch and PR into `dev`.
|
||||
|
||||
### Merge style: `--no-ff`
|
||||
|
||||
Always merge with `git merge --no-ff <branch>` (or the "Create a merge
|
||||
commit" option in the GitHub PR UI). This preserves the branch context
|
||||
as a visible merge commit in `git log --graph`, which is valuable when:
|
||||
commit" option in the GitHub PR UI). This applies at every level —
|
||||
feature → `dev`, and `dev` → `main` for release merges. `--no-ff`
|
||||
preserves the branch context as a visible merge commit in
|
||||
`git log --graph`, which is valuable when:
|
||||
|
||||
- An agent team pushed several commits to a branch — the per-commit trail
|
||||
is useful for "which agent did what"
|
||||
@@ -101,31 +153,32 @@ as a visible merge commit in `git log --graph`, which is valuable when:
|
||||
|
||||
Squash merges lose that detail and are **not** the house style.
|
||||
|
||||
### Version bumps happen at release-prep, NOT on feature branches
|
||||
### Version bumps happen at release-prep on `dev`, NOT on feature branches
|
||||
|
||||
Feature branches **never** touch `gradle/libs.versions.toml`,
|
||||
`pyproject.toml`, or `plugin/relay/__init__.py`. If two feature branches
|
||||
both bumped the version, they'd collide on `appVersionCode` (which must
|
||||
be monotonic) and you'd hit a merge conflict for no good reason.
|
||||
server-owned version metadata, or `desktop/package.json`.
|
||||
If two feature branches both bumped a release version, they'd collide on
|
||||
version files and, for Android, on `appVersionCode` (which must be
|
||||
monotonic).
|
||||
|
||||
The version-bump commit lives on `main`, created via
|
||||
`scripts/bump-version.sh`, immediately before `git tag`. It's a dedicated
|
||||
commit with the message `release: vX.Y.Z` that also lands the CHANGELOG
|
||||
and RELEASE_NOTES updates.
|
||||
Version-bump commits live on `dev` as the last commit of release-prep
|
||||
work. Android commits use `release(android): android-vX.Y.Z`; server commits
|
||||
use `release(server): server-vX.Y.Z`; desktop commits use the existing
|
||||
`release: desktop-vX.Y.Z` convention. A release PR then merges `dev` →
|
||||
`main` with `--no-ff`, and the matching tag is cut from the resulting
|
||||
`main` tip.
|
||||
|
||||
### Branch protection on `main`
|
||||
### Branch protection
|
||||
|
||||
Light branch protection is enabled on `main` to enforce the above:
|
||||
Light branch protection is enabled:
|
||||
|
||||
- Direct pushes blocked (must go through PR)
|
||||
- PR must pass CI (build + unit tests) before merge
|
||||
- Force push and branch deletion blocked
|
||||
- Signed commits + review approval NOT required (solo-dev overhead)
|
||||
|
||||
Release-prep commits (`release: vX.Y.Z`) are an exception — they're the
|
||||
one time direct push is allowed via a short-lived bypass, because they
|
||||
include the version bump + tag push in one transaction. Everything else
|
||||
goes through a PR.
|
||||
- **`main`** — direct pushes blocked; only release PRs from `dev` merge
|
||||
here. PR must pass CI (Android + Server) before merge. Force push and
|
||||
branch deletion blocked.
|
||||
- **`dev`** — direct pushes blocked for non-trivial work; feature
|
||||
branches PR in. PR must pass CI. Force push and branch deletion
|
||||
blocked.
|
||||
- Signed commits + review approval NOT required (solo-dev overhead).
|
||||
|
||||
## One-time Setup
|
||||
|
||||
@@ -156,10 +209,12 @@ hermes.key.password=YOUR_KEY_PASSWORD
|
||||
```
|
||||
|
||||
`local.properties`, `*.keystore`, and `*.jks` are already gitignored.
|
||||
Relative `hermes.keystore.path` values resolve from the repo root, so
|
||||
`release.keystore` works when the keystore lives beside this file.
|
||||
|
||||
> If the keystore at `hermes.keystore.path` is missing, `app/build.gradle.kts`
|
||||
> silently falls back to debug signing. The build succeeds but Play Console
|
||||
> rejects the AAB — always verify with `keytool -list -printcert` (step 3
|
||||
> rejects the AAB — always verify with `keytool -printcert` (step 3
|
||||
> below).
|
||||
|
||||
#### CI builds
|
||||
@@ -240,23 +295,48 @@ to Play Console. Manual UI uploads work without this.
|
||||
### 4. GitHub Actions secrets
|
||||
|
||||
In the repo: **Settings > Secrets and variables > Actions > New repository
|
||||
secret.** Add all four (see the table in "Required GitHub Secrets" below).
|
||||
secret.** Add all four (see the table in "Required Android Release Secrets"
|
||||
below).
|
||||
|
||||
If `HERMES_KEYSTORE_BASE64` is missing, CI release builds fall back to
|
||||
debug signing and print a warning in the workflow summary — those
|
||||
artifacts will not be accepted by Play Console.
|
||||
|
||||
## When to cut a release
|
||||
|
||||
Cut a release when **any of the following** is true:
|
||||
|
||||
- The `[Unreleased]` section of `CHANGELOG.md` has enough user-facing
|
||||
change that a version number is worth attaching.
|
||||
- A user-facing bug is fixed and you want affected users to pick it up
|
||||
via `hermes-relay-update` or a Play Store auto-update.
|
||||
- A regulatory / policy deadline applies (new Play Console target SDK,
|
||||
etc).
|
||||
- You've been sitting on unreleased work for more than a couple of
|
||||
weeks and the delta-from-last-release is growing faster than it
|
||||
should.
|
||||
|
||||
**Don't** cut a release just because a feature landed. If one feature
|
||||
isn't enough to justify a version bump, wait — merge the next one, let
|
||||
it sit alongside in `[Unreleased]`, and ship them together. A release
|
||||
is a statement to users that "this is a thing worth updating to," so
|
||||
the threshold is intent-driven, not event-driven.
|
||||
|
||||
If you want to dogfood accumulated `main` state without declaring GA,
|
||||
tag a **pre-release** (`android-vX.Y.Z-rc.N`). Users can opt in via
|
||||
`hermes-relay-update --branch rc/vX.Y.Z-rc.N` without being auto-pushed
|
||||
the unstable build.
|
||||
|
||||
## Release Process
|
||||
|
||||
### 1. Bump the version (atomic across all three sources)
|
||||
### 1. Bump the Android app version
|
||||
|
||||
Use `scripts/bump-version.sh` — it rewrites `libs.versions.toml`,
|
||||
`pyproject.toml`, AND `plugin/relay/__init__.py::__version__` in lockstep,
|
||||
increments `appVersionCode` monotonically, and runs a sanity check. Don't
|
||||
edit the files by hand; drift is silent and painful.
|
||||
Use `scripts/bump-android-version.sh`. It rewrites
|
||||
`gradle/libs.versions.toml`, increments `appVersionCode` monotonically,
|
||||
and runs a sanity check. Don't edit the Android version files by hand.
|
||||
|
||||
```bash
|
||||
bash scripts/bump-version.sh 0.3.0
|
||||
bash scripts/bump-android-version.sh 0.6.2
|
||||
```
|
||||
|
||||
Confirm the bump:
|
||||
@@ -265,20 +345,29 @@ Confirm the bump:
|
||||
scripts\dev.bat version
|
||||
```
|
||||
|
||||
The script's diff output should show exactly three files changed and all
|
||||
three carrying the new version string.
|
||||
The script's diff output should show `gradle/libs.versions.toml` carrying
|
||||
the new app version and a higher `appVersionCode`.
|
||||
|
||||
### 2. Update release notes and changelog
|
||||
|
||||
- `CHANGELOG.md` — promote the accumulated `[Unreleased]` block to a
|
||||
versioned header. The block already exists: every feature PR has
|
||||
been appending to it. All you do here is:
|
||||
1. Change the `## [Unreleased]` header to `## [X.Y.Z] - YYYY-MM-DD`.
|
||||
2. Insert a fresh empty `## [Unreleased]` header above it so the
|
||||
next PR has a landing spot.
|
||||
3. Skim the new versioned block and tighten / reorder if needed —
|
||||
Keep-a-Changelog grouping (`Added` / `Changed` / `Fixed`) should
|
||||
already be in place from the accumulator phase.
|
||||
- `RELEASE_NOTES.md` — body of the GitHub Release for this version
|
||||
(rewritten each release; the workflow uses this as-is). Keep the
|
||||
(rewritten each release; the workflow uses this as-is). This is the
|
||||
operator-facing summary, not the CHANGELOG mirror. Keep the
|
||||
**Download** section near the top — it should spell out which file
|
||||
to grab by its `-sideload-release.apk` / `-googlePlay-release.aab`
|
||||
suffix (every artifact is version-tagged as
|
||||
`hermes-relay-<version>-<flavor>-<buildType>` via `archivesName`
|
||||
in `app/build.gradle.kts`) and link to the sideload guide.
|
||||
The v0.3.0 body is a good template.
|
||||
- `CHANGELOG.md` — cumulative history; append a new section.
|
||||
- `app/src/main/assets/whats_new.txt` — in-app "What's New" content
|
||||
shown in the settings/about screen. Update with the version number
|
||||
and a brief feature summary. Gets stale silently if forgotten
|
||||
@@ -291,7 +380,7 @@ three carrying the new version string.
|
||||
|
||||
```bat
|
||||
scripts\dev.bat bundle
|
||||
keytool -list -printcert -jarfile app\build\outputs\bundle\googlePlayRelease\hermes-relay-*-googlePlay-release.aab
|
||||
keytool -printcert -jarfile app\build\outputs\bundle\googlePlayRelease\hermes-relay-*-googlePlay-release.aab
|
||||
```
|
||||
|
||||
The `keytool` output must show your release certificate (the CN/OU/O
|
||||
@@ -308,31 +397,65 @@ prefixed `hermes-relay-<version>-` via `archivesName` in
|
||||
Optional device smoke test: `scripts\dev.bat release` then
|
||||
`adb install -r app\build\outputs\apk\sideload\release\hermes-relay-*-sideload-release.apk`.
|
||||
|
||||
### 4. Commit and tag
|
||||
### 4. Commit on `dev`, merge to `main`, tag from `main`
|
||||
|
||||
The release-prep commit is one of the few allowed direct-to-main pushes
|
||||
(see Branching Policy above — branch protection exempts the
|
||||
`release: vX.Y.Z` pattern because tagging + bumping must be atomic):
|
||||
The release-prep commit lands on `dev` first. Then a release PR merges
|
||||
`dev` → `main` with `--no-ff`, and the `android-v<version>` tag is cut from the
|
||||
resulting merge commit on `main`:
|
||||
|
||||
```bash
|
||||
git add gradle/libs.versions.toml pyproject.toml plugin/relay/__init__.py \
|
||||
RELEASE_NOTES.md CHANGELOG.md \
|
||||
app/src/main/assets/whats_new.txt docs/play-store-listing.md
|
||||
git commit -m "release: v0.3.0"
|
||||
git push origin main
|
||||
# From a clean dev checkout:
|
||||
git checkout dev
|
||||
git pull --ff-only origin dev
|
||||
|
||||
git tag v0.3.0
|
||||
git push origin v0.3.0
|
||||
git add gradle/libs.versions.toml RELEASE_NOTES.md CHANGELOG.md \
|
||||
app/src/main/assets/whats_new.txt docs/play-store-listing.md
|
||||
git commit -m "release(android): android-v0.6.2"
|
||||
git push origin dev
|
||||
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from the new main tip:
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag android-v0.6.2
|
||||
git push origin android-v0.6.2
|
||||
```
|
||||
|
||||
Pushing a tag matching `v*` triggers `.github/workflows/release.yml`,
|
||||
Pushing a tag matching `android-v*` triggers `.github/workflows/release-android.yml`,
|
||||
which builds, signs, checksums, and creates a GitHub Release. Watch the
|
||||
run under the **Actions** tab.
|
||||
|
||||
> **Why all three files in the commit?** See "The three version sources"
|
||||
> above — `bump-version.sh` rewrites them atomically, so they must be
|
||||
> staged + committed atomically too. Missing one creates the same drift
|
||||
> the script was built to prevent.
|
||||
Server/Python version files are intentionally not part of an Android app
|
||||
release unless the server package itself is also being released.
|
||||
|
||||
### Server / Python package release
|
||||
|
||||
Use this when Server behavior changes independently of Android app
|
||||
delivery, for example desktop channel support, bridge routes, pairing
|
||||
server fixes, voice auth, or packaging changes.
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull --ff-only origin dev
|
||||
|
||||
bash scripts/bump-server-version.sh 0.6.2
|
||||
git add pyproject.toml plugin/relay/__init__.py plugin/plugin.yaml plugin/dashboard/manifest.json plugin/dashboard/package.json plugin/dashboard/package-lock.json CHANGELOG.md
|
||||
git commit -m "release(server): server-v0.6.2"
|
||||
git push origin dev
|
||||
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from the new main tip:
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag server-v0.6.2
|
||||
git push origin server-v0.6.2
|
||||
```
|
||||
|
||||
Pushing `server-v*` triggers `.github/workflows/release-server.yml`, which
|
||||
validates all server-owned version metadata with
|
||||
`scripts/check-server-version-sync.py`, runs server tests, builds a wheel and
|
||||
sdist, generates `SHA256SUMS.txt`, and creates a GitHub Release for the server
|
||||
package.
|
||||
|
||||
### 5. Upload to Play Console
|
||||
|
||||
@@ -388,9 +511,9 @@ Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
|
||||
`RELEASE_NOTES.md` this will already be baked in. If for some reason
|
||||
it's missing, edit the body with:
|
||||
```bash
|
||||
gh release view vX.Y.Z --repo Codename-11/hermes-relay --json body --jq .body > /tmp/body.md
|
||||
gh release view android-vX.Y.Z --repo Codename-11/hermes-relay --json body --jq .body > /tmp/body.md
|
||||
# edit /tmp/body.md to add/fix the Download section
|
||||
gh release edit vX.Y.Z --repo Codename-11/hermes-relay --notes-file /tmp/body.md
|
||||
gh release edit android-vX.Y.Z --repo Codename-11/hermes-relay --notes-file /tmp/body.md
|
||||
```
|
||||
(This step was only needed as a retrofit for v0.1.0 — v0.1.1+ inherit
|
||||
the Download section automatically from `RELEASE_NOTES.md`.)
|
||||
@@ -399,23 +522,47 @@ Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
|
||||
|
||||
## CI Behavior
|
||||
|
||||
On every push of a tag matching `v*`, `.github/workflows/release.yml`:
|
||||
Android, Server, dashboard, and desktop now have separate CI/release lanes.
|
||||
This keeps a dashboard CSS fix from running the full server suite, and keeps
|
||||
server changes from forcing an Android app `versionCode` bump.
|
||||
|
||||
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
|
||||
|
||||
1. Validates the tag matches `appVersionName` in
|
||||
`gradle/libs.versions.toml` (mismatches fail the workflow).
|
||||
2. Runs `./gradlew assembleDebug` and `./gradlew test`.
|
||||
2. Runs the Android debug build and the stable sideload pairing/connection
|
||||
regression slice with explicit timeouts.
|
||||
3. Decodes `HERMES_KEYSTORE_BASE64` into `$RUNNER_TEMP/release.keystore`
|
||||
and exports `HERMES_KEYSTORE_PATH` (skipped if the secret is unset).
|
||||
4. Builds both artifacts: `./gradlew bundleRelease assembleRelease`.
|
||||
4. Builds both Android release artifacts:
|
||||
`./gradlew bundleRelease assembleRelease`.
|
||||
5. Generates `SHA256SUMS.txt` covering both.
|
||||
6. Creates a GitHub Release named `v<version>` with `RELEASE_NOTES.md` as
|
||||
6. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
|
||||
the body. Attaches the APK, AAB, and `SHA256SUMS.txt`. Tags any version
|
||||
containing a dash (e.g. `v0.2.0-beta.1`) as a prerelease automatically.
|
||||
containing a dash (e.g. `android-v0.2.0-beta.1`) as a prerelease automatically.
|
||||
7. Prints a `$GITHUB_STEP_SUMMARY` showing whether release signing
|
||||
succeeded. If `HERMES_KEYSTORE_BASE64` is missing, the summary warns
|
||||
that the artifacts are debug-signed and unsuitable for Play Store.
|
||||
|
||||
## Required GitHub Secrets
|
||||
On every push of a tag matching `server-v*`,
|
||||
`.github/workflows/release-server.yml`:
|
||||
|
||||
1. Validates the tag matches all server-owned version metadata checked by
|
||||
`scripts/check-server-version-sync.py`.
|
||||
2. Runs server syntax checks and the focused route/auth/session test slice.
|
||||
3. Builds the Python wheel and sdist with `python -m build`.
|
||||
4. Generates `dist/SHA256SUMS.txt`.
|
||||
5. Creates a GitHub Release named `Hermes-Relay-Server v<version>` with the wheel,
|
||||
sdist, and checksum file attached.
|
||||
|
||||
On every push of a tag matching `desktop-v*`,
|
||||
`.github/workflows/release-desktop.yml` builds and publishes the desktop
|
||||
CLI binaries. Dashboard-only changes are covered by
|
||||
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
|
||||
runs the dashboard API tests, and verifies the modal CSS markers are present
|
||||
in the built bundle.
|
||||
|
||||
## Required Android Release Secrets
|
||||
|
||||
| Secret | Purpose | How to populate |
|
||||
|-----------------------------|-------------------------------------|--------------------------------------------------|
|
||||
@@ -427,26 +574,41 @@ On every push of a tag matching `v*`, `.github/workflows/release.yml`:
|
||||
## Hotfix Recipe
|
||||
|
||||
When production has a bug and you need to ship a fix without picking up
|
||||
unrelated `main` changes:
|
||||
unreleased work from `dev`, branch from the affected release tag and only
|
||||
bump the version source for the surface you are shipping.
|
||||
|
||||
1. `git checkout -b fix/short-name v0.1.0` — branch from the released tag.
|
||||
For an Android app hotfix:
|
||||
|
||||
1. `git checkout -b fix/short-name android-v0.6.1` — branch from the released
|
||||
Android tag (not from `main` or `dev`).
|
||||
2. Apply the fix, add a test, commit.
|
||||
3. Bump `appVersionName` and `appVersionCode` in
|
||||
3. Run `bash scripts/bump-android-version.sh 0.6.2` to update
|
||||
`gradle/libs.versions.toml`.
|
||||
4. Update `RELEASE_NOTES.md` and `CHANGELOG.md`.
|
||||
5. `git tag v0.1.1 && git push origin v0.1.1` — CI builds and publishes.
|
||||
6. Upload to Play Console as normal.
|
||||
7. Merge the hotfix branch back into `main` so the fix isn't lost.
|
||||
4. Update `RELEASE_NOTES.md`, `CHANGELOG.md`, in-app What's New, and Play
|
||||
listing notes as needed.
|
||||
5. Open a PR from `fix/short-name` into `main`, merge with `--no-ff`.
|
||||
6. `git tag android-v0.6.2` from the new `main` tip and `git push origin android-v0.6.2`
|
||||
so Android release CI builds and publishes.
|
||||
7. Upload to Play Console as normal.
|
||||
8. Merge `main` back into `dev` (`git checkout dev && git merge --no-ff main`)
|
||||
so `dev` picks up the hotfix and the versionCode bump. Without this,
|
||||
`dev`'s `appVersionCode` lags behind `main` and the next app release
|
||||
bump collides.
|
||||
|
||||
For a Server hotfix, branch from the affected `server-v*` tag, apply
|
||||
the fix, run `bash scripts/bump-server-version.sh <next-version>`, merge to
|
||||
`main`, and tag `server-v<next-version>`. Do not touch
|
||||
`gradle/libs.versions.toml` unless an Android app release is also shipping.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`Tag version (X) does not match appVersionName (Y)` in CI validate step**
|
||||
You pushed a tag before bumping `gradle/libs.versions.toml`, or vice versa.
|
||||
Fix: update the file, commit, delete the remote tag
|
||||
(`git push --delete origin vX`), re-tag, and push again.
|
||||
(`git push --delete origin android-vX`), re-tag, and push again.
|
||||
|
||||
**Play Console rejects the AAB as debug-signed**
|
||||
Run `keytool -list -printcert -jarfile <aab>` locally — if it shows
|
||||
Run `keytool -printcert -jarfile <aab>` locally — if it shows
|
||||
`CN=Android Debug`, fix `local.properties` for local builds or
|
||||
`HERMES_KEYSTORE_BASE64` for CI. For CI, check the workflow summary; if it
|
||||
says "Debug-signed", one of the four `HERMES_*` secrets is missing or the
|
||||
|
||||
+16
-57
@@ -1,74 +1,33 @@
|
||||
# Hermes-Relay v0.5.1
|
||||
# Hermes-Relay-Android v0.8.1
|
||||
|
||||
**Release Date:** April 18, 2026
|
||||
**Since v0.5.0:** Voice-focused patch release — TTS quality pass, conversational barge-in, silence-based auto-stop, plus a bootstrap crash fix
|
||||
**Release Date:** May 26, 2026
|
||||
**Since v0.8.0:** A focused patch fixing a voice-mode crash. No new features.
|
||||
|
||||
> **The voice release.** v0.5.0 shipped the bridge polish; v0.5.1 makes voice mode feel like a real conversation. Gapless ExoPlayer playback, client + relay sanitizers so the agent stops reading emoji and markdown fences aloud, sentence-prefetch so there's no dead air between chunks, barge-in so you can interrupt by just speaking, and silence-based auto-stop so Continuous mode actually ends your turn when you stop talking.
|
||||
v0.8.1 is a patch release. If you don't use voice mode with barge-in enabled, v0.8.0 is unaffected — but updating is still recommended.
|
||||
|
||||
---
|
||||
|
||||
## 📥 Download
|
||||
## Download
|
||||
|
||||
v0.5.1 ships in **two build flavors**. APK filenames are version-tagged:
|
||||
v0.8.1 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
|
||||
| Flavor | File | Who it's for |
|
||||
|---|---|---|
|
||||
| **sideload** (recommended) | `hermes-relay-0.5.1-sideload-release.apk` | Full feature set — bridge channel, voice intents, unattended access, vision-driven `android_navigate`. Installs alongside the Play build with a `.sideload` applicationId. |
|
||||
| **Google Play** | `hermes-relay-0.5.1-googlePlay-release.aab` | Conservative feature set (chat, voice, safety rails — no agent device control) to match Play Store's Accessibility policy. |
|
||||
| googlePlay APK | `hermes-relay-0.5.1-googlePlay-release.apk` | Parity + diff tooling — not the primary download. |
|
||||
| sideload AAB | `hermes-relay-0.5.1-sideload-release.aab` | Parity + diff tooling — not the primary download. |
|
||||
| Google Play | `hermes-relay-0.8.1-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, wake locks, or unattended phone control. |
|
||||
| sideload | `hermes-relay-0.8.1-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-0.8.1-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-0.8.1-sideload-release.aab` | Parity/testing artifact. |
|
||||
|
||||
**Verify integrity** with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for install steps.
|
||||
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
## Fixed
|
||||
|
||||
### Voice quality pass
|
||||
### Voice mode crash with barge-in on legacy TTS playback
|
||||
|
||||
- **Gapless TTS playback.** Swapped `MediaPlayer` for Media3 `ExoPlayer` with a persistent instance and `addMediaItem` queuing. No more 200–400 ms silence between sentence chunks, no more pop / click on chunk boundaries.
|
||||
- **Client + relay text sanitizers.** Assistant output is stripped of markdown fences, tool-call annotations (`` `💻 terminal` ``), URLs, and emoji *before* hitting ElevenLabs — on the relay (`plugin/relay/tts_sanitizer.py`) and on the phone (`VoiceViewModel.sanitizeForTts`). The chat UI still shows emoji; only the voice path is cleaned. Solves "agent reads `colon rocket` out loud" and "agent reads `https colon slash slash github dot com`."
|
||||
- **Sentence coalescing + secondary-break chunking.** Minimum 40-char chunks with 800 ms idle flush; ellipses / dashes treated as soft breaks when the primary sentence end is far away. Keeps prosody natural without waiting for a full paragraph.
|
||||
- **Prefetch synth-while-playing pipeline.** Two coroutines in a `supervisorScope` — one synthesizing the next sentence while the previous one plays. Channel-backed with capacity 2 so the player never starves.
|
||||
Starting voice mode with **barge-in enabled** while the relay served audio over the legacy `/voice/synthesize` path crashed the app the instant the agent began speaking — the first word or two played, then the app died with `Player is accessed on the wrong thread`.
|
||||
|
||||
### Voice barge-in (off by default, opt-in via Voice Settings)
|
||||
The barge-in listener reads the audio session id from a background thread to attach the echo canceller, but Media3's `ExoPlayer` is thread-confined and throws when its `audioSessionId` getter is read off the main thread. `VoicePlayer.audioSessionId` is now backed by a thread-safe cache populated from main-thread playback callbacks, so it's safe to read from any thread.
|
||||
|
||||
- **Silero VAD + AcousticEchoCanceler + hysteresis.** Duplex AudioRecord (`VOICE_COMMUNICATION` source) monitored by a Silero VAD engine with 2–3 consecutive-frame hysteresis. AEC binds to the ExoPlayer audio session id so the VAD sees your voice, not the agent's playback echo.
|
||||
- **Soft-duck → hard-cut interrupt.** Single VAD positive triggers a 30 % volume duck; confirmed hysteresis pass hard-cuts playback and starts a new listening turn. 500 ms duck-watchdog un-ducks on a single-frame false positive so stray clicks only briefly dip the volume.
|
||||
- **Optional resume-from-next-sentence.** After a barge-in interrupt, a 600 ms silence watchdog checks whether the user actually continued speaking. If not (cough, false positive, stray laugh), the remaining un-played sentences are re-queued. Toggle in Voice Settings.
|
||||
- **Sensitivity picker (Off / Low / Default / High)** with an always-visible AEC compatibility badge so users know when their device's echo canceler isn't loaded (affects false-positive rate on some Samsung / Motorola / older Pixel builds).
|
||||
|
||||
### Silence-based auto-stop for listening turns
|
||||
|
||||
- **The `silenceThresholdMs` preference is finally wired.** Previously the Settings slider persisted a value nothing ever read — Continuous mode would re-arm the mic after TTS drained and then wait forever for a manual tap to send. Now `VoiceViewModel.startListening()` arms a watchdog that polls amplitude every 150 ms and auto-calls `stopListening()` after the configured silence window (default 3 s) following at least one above-floor frame.
|
||||
- **Grace window** — auto-stop never fires before the user's first above-floor frame, so "tap mic, take a beat" doesn't insta-close the turn.
|
||||
- **Skipped in Hold-to-Talk.** The physical release is the authoritative stop there; auto-stopping mid-hold would be surprising.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Fixes
|
||||
|
||||
- **"Final short sentence with emoji not spoken in Continuous mode."** Race in `maybeAutoResume` where Continuous mode's `startListening()` → `player.stop()` clobbered the still-in-flight final chunk's playback pipeline. Fixed with an `AtomicInteger` gate on the synth queue; auto-resume now defers until the TTS pipeline actually drains.
|
||||
- **Continuous mode didn't persist across app restarts.** `VoiceViewModel` never subscribed to `VoicePreferencesRepository.settings.interactionMode`, so cold starts always defaulted to Tap-to-Talk regardless of the saved pref. Now subscribes on `initialize` and mirrors the saved value into `uiState`.
|
||||
- **Bootstrap gateway crash: `'tuple' object has no attribute 'freeze'`.** `hermes_relay_bootstrap/_command_middleware.py::maybe_install_middleware` was replacing aiohttp's `FrozenList` with a plain tuple, which broke when `AppRunner.setup()` later called `.freeze()`. Switched to in-place `app._middlewares.append(middleware)`. 31/31 middleware tests pass.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Verification checklist (post-install)
|
||||
|
||||
- Voice mode → ask the agent a multi-sentence question. No gap / click between sentences; no emoji or markdown spoken aloud.
|
||||
- Speak over the agent mid-response with barge-in ON → playback cuts within ~100 ms, a new listening turn starts.
|
||||
- Briefly cough during playback with barge-in ON and "Resume after interruption" ON → playback ducks briefly but resumes from the next unplayed sentence.
|
||||
- Voice Settings → Interaction Mode → **Continuous** → force-stop the app → relaunch → Voice mode still comes up in Continuous.
|
||||
- Voice Settings → Silence Threshold slider at 3 s → start a Tap-to-Talk turn → speak one sentence → stop talking → within ~3 s the turn auto-submits.
|
||||
- Device without AEC → Voice Settings shows the compatibility badge next to the Barge-in section.
|
||||
|
||||
## 🧩 Known — test suite deferred
|
||||
|
||||
8 new voice/audio unit tests added in this release are `@Ignore`'d pending a test-infra follow-up. See [issue #32](https://github.com/Codename-11/hermes-relay/issues/32) for the root-cause breakdown (coroutine `.cancel()` without `.join()` + Media3 static init on pure JVM + Robolectric classloader leakage). No app-behavior impact — the tests describe intent + assertions for the new voice code and will be un-`@Ignore`'d once the separate-source-set split lands. On-device smoke testing (by Bailey, Samsung) validated the feature behavior.
|
||||
|
||||
See `CHANGELOG.md` for the full file-level diff and `DEVLOG.md` for the per-feature session narrative.
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
This only affected the **opt-in** barge-in feature on the legacy text-to-speech path; the provider-native Realtime Agent and Voice Output paths were never affected.
|
||||
|
||||
+52
-2
@@ -12,6 +12,52 @@ Native Android companion for the [Hermes agent platform](https://github.com/Nous
|
||||
- **v0.2.0** — Voice mode foundation, terminal preview, TOFU cert pinning, Paired Devices screen. [CHANGELOG](CHANGELOG.md)
|
||||
- **v0.1.0** — Chat, sessions, QR pairing, encrypted storage, Play Store submission.
|
||||
|
||||
### Desktop track (parallel lane to Android) — **experimental**
|
||||
|
||||
Release tags: `desktop-v*` (separate cadence from Android `android-v*` and Server `server-v*`). Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-desktop.yml`](.github/workflows/release-desktop.yml).
|
||||
|
||||
**Shipped (2026-04-23 — first tagged release `desktop-v0.3.0-alpha.1`):**
|
||||
|
||||
- **`@hermes-relay/cli` v0.1** — Node thin-client at [`desktop/`](desktop/). Remote chat + pair + status + tools subcommands over the relay's `tui` WSS channel. Shares `~/.hermes/remote-sessions.json` with the Android client (pair once, both work).
|
||||
- **v0.2 — resilience + pairing UX** — multi-endpoint pairing (ADR 24: `--pair-qr` probes LAN/Tailscale/Public, strict-priority within-tier race, 4s timeout, 60s cache), reconnect-on-drop state machine (1s→30s exp backoff, 5min on 429, gate re-check post-sleep), TOFU cert pinning via pre-WS TLS probe (SPKI sha256, `sha256/<base64>` OkHttp-compatible).
|
||||
- **v0.2 — UX polish** — bare `hermes-relay` → `shell` (full Hermes CLI over PTY with `clear; exec hermes` after tmux settles); contextual connect banner (`Connected via LAN (plain) — server 0.6.0`); `status` surfaces grants + TTL + endpoint role from `auth.ok`; new `devices` subcommand talking to relay `GET/DELETE/PATCH /sessions` over HTTP.
|
||||
- **Phase B — client-side tool routing** — server-side `plugin/relay/channels/desktop.py` + `plugin/tools/desktop_tool.py` register `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` via `tools.registry` (mirror of `android_*` pattern — **zero hermes-agent core change**). Client-side `DesktopToolRouter` attaches to the `desktop` channel, dispatches under a 30s AbortController, heartbeats `desktop.status` every 30s. One-time per-URL consent gate + `--no-tools` kill-switch.
|
||||
- **`hermes-relay daemon`** — headless WSS + tool router that keeps desktop tools serving without a visible shell. Fails closed on missing stored consent (`--allow-tools` escape hatch with an explicit `--token`). JSON-line logs by default, auto-human on TTY. Inherits transport's reconnect state machine; `setImmediate(exit)` to flush final log line before process dies.
|
||||
- **Pre-release hardening** — `hermes-relay doctor` (local diagnostic report, human + `--json`, no token leakage); `uninstall.{sh,ps1}` (3-tier: default keeps session store, `--purge` wipes it with cross-surface warning, `--service` stub); interactive first-run prompts (`resolveFirstRunUrl` — auto-picks single stored session, numbered picker for multiple, welcome banner for fresh install); version-aware install (`upgrading X → Y` readback pre-install, post-install confirmation).
|
||||
- **Self-setup skill** — [`skills/devops/hermes-relay-desktop-setup/SKILL.md`](skills/devops/hermes-relay-desktop-setup/SKILL.md) lets any Hermes agent install, pair, and troubleshoot the CLI with **live local diagnostics** via `desktop_terminal` (can read the user's Node version, PATH, binary location directly — something the Android setup skill can't match).
|
||||
|
||||
**Shipped — `desktop-v0.3.0-alpha.6` (seamless-local dev pass, done 2026-04-23):** Plan at [`docs/plans/2026-04-23-desktop-alpha-6-seamless-local.md`](docs/plans/2026-04-23-desktop-alpha-6-seamless-local.md). Nine features across six parallel agent workstreams, all opt-in: workspace-awareness envelope + active-editor signal (#1+#8), `hermes-relay update` self-update subcommand (#2), `desktop_open_in_editor` tool + interactive patch approval with unified-diff rendering (#3+#4), conversation picker on connect (#5), clipboard bridge + screenshot handlers (#9+#12), and a `hermes` alias so muscle-memory works without the `-relay` suffix (#13). Integration day: 2026-04-23.
|
||||
|
||||
**Active — `desktop-v0.3.0-alpha.7` (native image paste):** Plan at [`docs/plans/2026-04-23-desktop-alpha-7-native-paste.md`](docs/plans/2026-04-23-desktop-alpha-7-native-paste.md). Two-repo workstream: client slash commands `/paste` (clipboard), `/screenshot` (primary display), `/image <path>` (file) land in `hermes-relay chat`, each echoes a one-line feedback and attaches the image to the next `prompt.submit` so the vision-capable model sees it in the same turn — parity with Claude Desktop's paste UX minus OS-level Ctrl+V (terminals don't pipe image bytes to stdin). Client half is new `desktop/src/chatAttach.ts` + slash-command branches in `desktop/src/commands/chat.ts`. Server half is ONE new `@method("image.attach.bytes")` on the fork's `tui_gateway/server.py` (branch `feat/image-attach-bytes` → merged to `axiom`); the fork's existing `_enrich_with_attached_images` already handles multimodal payload plumbing and session-scoped image state, so this release is almost entirely about bridging client-captured bytes to server-side state that's been there for months. Relay channel unchanged — `tui` is a transparent RPC forwarder. Graceful fallback when hermes-host hasn't been updated yet: client catches `method not found`, prints a pointer at the axiom rollout, REPL stays alive.
|
||||
|
||||
**Active — desktop control / computer-use:** Enhanced plan at [`docs/plans/desktop-control-computer-use-enhanced.md`](docs/plans/desktop-control-computer-use-enhanced.md); earlier MVP implementation record at [`docs/plans/desktop-computer-use-mvp.md`](docs/plans/desktop-computer-use-mvp.md). Windows now has the first Tauri tray/overlay app as the primary Easy/Standard install surface: pair, start/pause daemon, Devices/Revoke, Task Log, Settings, overlay status chip, emergency stop, and bundled CLI sidecar. The existing CLI and daemon remain the primary advanced/headless surface. `desktop_computer_*` schemas are registered on the normal desktop tool channel but advertised only behind the explicit experimental computer-use flag. Host input still requires desktop-tool consent plus a visible, task-scoped assist/control grant; there is no unrestricted or silent mouse/keyboard automation.
|
||||
|
||||
**Desktop control UX direction:** Tauri v2 (Rust + static web UI) is the native shell for the polished Easy-tier experience: tray icon, always-visible overlay chip, task log, settings, and one-click pause/emergency stop. Easy tier pairs once, shows a connected/observing chip, and exposes Devices / Revoke / Task Log / Settings / Emergency Stop from the tray. Standard tier adds full tray management; Advanced tier remains CLI + daemon + JSON policy (`~/.hermes/desktop-control.json`) for operators. The default policy baseline blocks password managers, credential prompts, banking/payment/crypto surfaces, OS security/admin settings, and private-key/token material until locally overridden.
|
||||
|
||||
**Deferred to alpha.8 / alpha.9 / v1.0:**
|
||||
|
||||
- **Per-project session stickiness** — blocked on hermes-agent plugin hook that consumes the workspace envelope; premature until the envelope shape stabilizes in use.
|
||||
- **Shell-history context hook** — needs rc-file-edit install path, which our install philosophy currently avoids. Design pass required.
|
||||
- **Desktop notifications for long-running daemon work** — let daemon bake in real-world use first; latency/idle-detection thresholds best tuned with telemetry.
|
||||
- **Environment-variable passthrough** — security-sensitive; needs per-var prompt UX + threat model before shipping.
|
||||
- **Global hotkey to summon a prompt** — OS-specific helper installers; out of scope for binary-only release.
|
||||
- **Watch mode** (`hermes-relay daemon --watch`) — needs a DSL and clear safety bounds; own feature branch.
|
||||
- **Native assist/control grant modal hardening** — the tray-managed daemon now has a local grant bridge and Grant Requests view. Next pass should polish native modal behavior, notification routing, and multi-client grant ownership.
|
||||
- **Kitty / iTerm2 inline image protocols for paste feedback** — would show a thumbnail of the attached image directly in the terminal after `/paste` instead of a plain text line. Most terminals don't support them; the slash-command feedback line works anywhere. Revisit if users request it.
|
||||
|
||||
**Earlier alpha.2–alpha.5 workstreams (now in-flight / done — see DEVLOG 2026-04-23 entries for specifics):**
|
||||
|
||||
- **`hermes-relay update` subcommand + auto-update nudge.** The binary today does NOT self-update — users have to re-run the `curl | sh` / `irm | iex` one-liner to pick up a new release. Close the gap: `hermes-relay update` polls the GitHub Releases API, filters to `desktop-v*`, compares to `readVersion()`, and either shells out to the installer or downloads the binary directly + `rename` over the current one (Windows can rename while running; Linux/macOS atomic replace is fine for long-lived daemons because the running process keeps the old inode open). Add a once-per-day background check in `daemon` mode that emits `update_available` as a log event — opt-in via `--check-updates`, never auto-installs without user action. Signing prerequisite: SmartScreen/Gatekeeper would warn on every auto-downloaded binary until we sign, so this is behind code signing.
|
||||
- **Workspace-awareness — desktop client sends cwd/git/hostname on connect.** Biggest lingering "is the agent working against the right tree?" problem. On WSS auth, the client advertises an ephemeral workspace descriptor — `cwd`, `git_root`, `git_branch`, `git_status_summary` (staged/modified counts), `repo_name`, `hostname`, `platform`, `active_shell`. Server-side `DesktopHandler` stashes it as live session metadata (NOT persistent state). New hermes-agent plugin hook injects a one-line ephemeral prompt prefix into the session context — *"Active desktop workspace: machine=Bailey-PC · repo=hermes-relay · branch=dev · staged=3"* — so the LLM reads it every turn without the operator having to explain. Also default `desktop_terminal` / `desktop_read_file` / `desktop_search_files` `cwd` to the repo root when unset. Expose the snapshot in `hermes-relay doctor` + `hermes-relay status` + a new `hermes-relay workspace` subcommand + a relay dashboard tab so both operator and agent have a common view. Pair with a `.hermes/workspace-context.json` file-based fallback for when the socket path can't be reached. Requires: new WSS envelope (`desktop.workspace` on connect), hermes-agent plugin hook for ephemeral context injection, schema coordination with the upstream `ContextVar` multi-client work.
|
||||
- **Service installers** — `scripts/install-service-{win,linux,mac}.{ps1,sh}` — Windows Service via `sc.exe create`, `systemd --user` unit with `loginctl enable-linger`, `launchctl load` plist for macOS. Auto-start on login so the daemon is always reachable.
|
||||
- **Multi-client routing on the `desktop` channel** — replace single-client MVP with per-token indexing + device-id reconnect handoff. Hermes session state carries `desktop_session_token` via a new `ContextVar` in `gateway/session_context.py` (hermes-agent PR candidate — won't affect Android). Natural pairing with the workspace-awareness envelope — the ContextVar scheme determines which client's workspace the active session sees.
|
||||
- **Harden `release-desktop.yml` retag semantics.** The `softprops/action-gh-release` step failed during the alpha.1 retag with `tag_name already_exists` after deleting + re-uploading all 5 assets; recovered by `gh api` cleanup (delete orphan draft + PATCH draft→false on the release with the real assets). Follow-up: pin the action version, add `make_latest: false` + explicit `release_id` lookup, or switch to `ncipollo/release-action` which handles retags without the duplicate-draft creation.
|
||||
- **Signed binaries** — Windows EV code-signing (~$300/yr, DigiCert or SSL.com) + Apple Developer ID + notarization ($99/yr). Removes SmartScreen/Gatekeeper warnings. Prerequisite for the auto-update path.
|
||||
- **npm registry publication** — future v1.0 distribution work. The package name is local workspace metadata today; current install paths are GitHub Release binaries or local clone + `npm link`.
|
||||
- **HMAC verification on QR payloads** — defer until a client-accessible secret story exists (same deferral as the Android app). Not blocking GA.
|
||||
|
||||
**Docs + references:** user-docs `/desktop/` section (Overview → Installation → Pairing → Subcommands → Local tool routing → Troubleshooting → FAQ) with an `<ExperimentalBadge />` Vue component on every page. README.md landing has a dedicated "Experimental: Desktop CLI" section with the install one-liners.
|
||||
|
||||
## Current — Axiom-Labs migration
|
||||
|
||||
Moving the Play Store listing from a personal account to the DUNS-verified Axiom-Labs LLC org account. Unblocks straight-to-production rollout (no 14-day closed-testing requirement). New applicationId `com.axiomlabs.hermesrelay`; keystore identity + SHA256 fingerprint preserved. In progress — waiting on Google DUNS verification.
|
||||
@@ -55,7 +101,7 @@ Small follow-ons to v0.4 deliberately deferred to keep the v0.4.0 release surfac
|
||||
|
||||
**What the middleware can do (near-term, ships via install.sh).** New aiohttp middleware in `hermes_relay_bootstrap/_command_middleware.py`, installed at the same `_PatchedApplication.__setitem__` hook as the current route injection so it lands before `AppRunner.setup()` freezes the app. Filters by `request.path in ("/v1/runs", "/v1/chat/completions")` — zero-cost fast path for everything else. On chat paths: parses the body, lazy-imports `GATEWAY_KNOWN_COMMANDS` + `resolve_command()` + `gateway_help_lines()` from `hermes_cli.commands`, and splits on command type:
|
||||
- **Stateless commands** (`/help`, `/commands`, and any others the upstream Option B PR ends up supporting without router state) — actually dispatch, emit a synthetic SSE stream matching the runs handler's existing event shape so the Android client at `HermesApiClient.kt:655-715` renders it as a normal assistant turn.
|
||||
- **Stateful commands** (`/model`, `/new`, `/retry`, `/undo`, `/compress`, `/title`, `/resume`, `/branch`, `/rollback`, `/yolo`, `/reasoning`, `/personality`, etc. — most of the registry) — emit a synthetic SSE stream whose content is a short, helpful notice: *"The `/model` command requires a persistent session and isn't available on the stateless `/v1/runs` endpoint. Use `/api/sessions/{id}/chat/stream` (post-PR-#8556) or a channel with session state. For commands that work here, type `/help`."* This replaces the LLM hallucination with a deterministic, accurate message that points the user at the real fix.
|
||||
- **Stateful commands** (`/model`, `/new`, `/retry`, `/undo`, `/compress`, `/title`, `/resume`, `/branch`, `/rollback`, `/yolo`, `/reasoning`, `/personality`, etc. — most of the registry) — emit a synthetic SSE stream whose content is a short, helpful notice: *"The `/model` command requires a persistent session and isn't available on the stateless `/v1/runs` endpoint. Use `/api/sessions/{id}/chat/stream` or a channel with session state. For commands that work here, type `/help`."* This replaces the LLM hallucination with a deterministic, accurate message that points the user at the real fix.
|
||||
|
||||
**On no match** (unknown command, cli-only command, or plain text): falls through to `handler(request)` unchanged. Fork-detects the same way the existing injection does — if the upstream preprocessor PR lands first, the middleware no-ops.
|
||||
|
||||
@@ -63,12 +109,16 @@ Small follow-ons to v0.4 deliberately deferred to keep the v0.4.0 release surfac
|
||||
|
||||
**Files.** New `hermes_relay_bootstrap/_command_middleware.py` (~150 LOC), one-line append in `_patch.py` inside `_maybe_register_routes`, stdlib `unittest` coverage in `plugin/tests/test_bootstrap_command_middleware.py` mirroring the existing `test_bootstrap_patch.py` harness. Mirrors the upstream Option B PR exactly so the two can be reviewed side-by-side.
|
||||
|
||||
**Phase 2 — stateful dispatch on the session chat stream endpoint (post PR #8556).** Once PR #8556 merges and `/api/sessions/{id}/chat/stream` ships natively in upstream, a separate middleware (or a follow-up upstream PR) can add a preprocessor **scoped to that endpoint only**, leveraging the `session_id` in the URL as the persistence handle. At that point stateful commands become a dict write against session-scoped state — `session.model_override = new_model` — without needing to refactor `GatewayRouter` or plumb api_server into the router. Much smaller than a full router refactor, and it matches upstream's partition: `/v1/*` stays stateless, statefulness lives on `/api/sessions/*`. Blocked on #8556 landing.
|
||||
**Phase 2 — stateful dispatch on the session chat stream endpoint (unblocked by PR #33134 / commit `f7527b0`).** Since `/api/sessions/{id}/chat/stream` now ships natively in upstream, a separate middleware (or a follow-up upstream PR) can add a preprocessor **scoped to that endpoint only**, leveraging the `session_id` in the URL as the persistence handle. At that point stateful commands become a dict write against session-scoped state — `session.model_override = new_model` — without needing to refactor `GatewayRouter` or plumb api_server into the router. Much smaller than a full router refactor, and it matches upstream's partition: `/v1/*` stays stateless, statefulness lives on `/api/sessions/*`.
|
||||
|
||||
## Future — v0.5+
|
||||
|
||||
Shape subject to change. Each theme needs a separate design + plan pass before implementation; file design notes as research matures.
|
||||
|
||||
### Desktop thin-client — Phase B (client-side tool routing)
|
||||
|
||||
v0.1 ships a remote-chat CLI. Phase B is the bigger win: **per-tool dispatch routing** so file/terminal/browser tools run against the user's machine while state tools (memory, skills, sessions, cron) stay on the server. Design detailed in the vault under `Axiom-Vault/3. System/Projects/Hermes-Relay/Desktop Client.md`. Key insertion point is hermes-agent `model_tools.py::handle_function_call()` (~line 517) — before `registry.dispatch()`, consult a session-scoped routing table populated by a relay handshake extension where the client advertises which tools it can service. Isomorphic to how `android_*` tools already flow through the `bridge.command` channel. Proposed branch: `fork/tool-relay` on the hermes-agent fork; upstream issue to open before merging. Blocked on: (a) the handshake extension in `plugin/relay/auth.py` to carry the advertised-tools list, (b) a new `desktop.command` channel mirroring `bridge.command` semantics, (c) the upstream PR conversation.
|
||||
|
||||
### Observability & introspection
|
||||
- Real-time accessibility event streaming for reactive workflows (`android_events`, `android_event_stream`)
|
||||
- On-device text-to-speech through the phone's system speaker for hands-free responses (distinct from the in-app voice mode)
|
||||
|
||||
@@ -6,6 +6,63 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Hands-free agentic voice backlog
|
||||
|
||||
Goal: make Hermes usable for hands-free work without leaving the operator blind
|
||||
to tool state, safety prompts, or the current task.
|
||||
|
||||
- **Waveform output-start sync** — current input waveform timing feels good, but
|
||||
the agent-output waveform can unfold and begin movement before audible speech
|
||||
starts. Split "preparing audio" from "speaking audio" in the visual layer, or
|
||||
gate the unfolded Speaking waveform on the first real playback frame/audio
|
||||
amplitude. Processing can stay as the folded circular spinner until output is
|
||||
actually audible.
|
||||
- **Voice command layer** — reserve local commands that bypass normal agent
|
||||
routing: "pause", "resume", "stop talking", "cancel", "repeat that", "open
|
||||
overlay", "return to Hermes", and "new chat". These should work while the
|
||||
agent is thinking, speaking, or using tools.
|
||||
- **Spoken tool progress** — when Hermes uses tools, voice mode should speak
|
||||
short status updates such as "I'm checking the relay logs" or "I found an
|
||||
error" without waiting for final assistant text. Long tool calls should emit
|
||||
periodic, low-noise progress updates.
|
||||
- **Realtime tool timeline parity** — the voice overlay should render the same
|
||||
live thinking blocks, streaming assistant text, and tool call progress as the
|
||||
normal chat surface without requiring exit/reload.
|
||||
- **Hands-free confirmation flow** — risky actions need first-class spoken and
|
||||
visual confirmation: "yes", "no", "cancel", "confirm", plus a visible and
|
||||
audible countdown for destructive actions.
|
||||
- **Voice session memory/status** — add a compact "where are we?" summary for
|
||||
the current voice task: active objective, last tool result, pending next step,
|
||||
and whether the agent is waiting on the user.
|
||||
- **Mode presets** — add presets such as Hands-free, Low latency, Careful tool
|
||||
mode, and Quiet/visual-only. Hands-free should favor Continuous listening,
|
||||
spoken tool progress, confirmations, and overlay availability.
|
||||
- **Barge-in hardening** — keep barge-in experimental until echo/self-recording
|
||||
is solved. The target path is proper AEC, playback-ducking, and a rule that
|
||||
output audio can never become a user turn.
|
||||
- **Audio quality guardrails** — normalize output volume across realtime and
|
||||
fallback TTS providers, keep pronunciation hints/profile voice tuning, and
|
||||
measure provider-specific delay, chunk gaps, and tail clipping.
|
||||
- **Pluggable Realtime Agent media transports** — add an OpenAI-first WebRTC
|
||||
transport option for Realtime Agent so mobile audio can use provider-native
|
||||
jitter buffering, interruption, and media handling instead of only relay
|
||||
WebSocket PCM. Design this as a provider transport interface
|
||||
(`websocket`, `webrtc`, future `livekit`/SIP-style bridges) so other
|
||||
realtime providers can opt in without forking the Hermes broker/tool
|
||||
contract. Hermes must still own tools, memory, confirmations, current data,
|
||||
and durable transcript state.
|
||||
- **Voice engine selector** — implemented as an opt-in experimental Realtime
|
||||
Agent engine in `docs/plans/2026-05-19-realtime-hermes-voice-agent.md`.
|
||||
Follow-up work is provider-native turn-taking, richer confirmation handling,
|
||||
and quality/latency evaluation before promotion beyond Experimental.
|
||||
- **Realtime-native Hermes bridge prototype** — first relay-brokered slice
|
||||
implemented in `docs/plans/2026-05-19-realtime-hermes-voice-agent.md`.
|
||||
Remaining work: let OpenAI/xAI realtime sessions own more of the live speech
|
||||
turn while still proxying every tool, confirmation, memory, and Android bridge
|
||||
action through Hermes/relay safety.
|
||||
|
||||
---
|
||||
|
||||
## Research / open questions
|
||||
|
||||
### Proper Hermes plugin / skill / tool distribution
|
||||
@@ -21,10 +78,10 @@ Things to look into:
|
||||
- **Tool registration discoverability** — `android_*` tools register at gateway import time. There's no canonical "list installed plugin tools" API. Would adding one to upstream make sense, or is `gateway tool list` already enough?
|
||||
- **Versioning + compatibility ranges** — `pip install -e` doesn't enforce version pins between hermes-agent and our plugin. A breaking change in upstream's plugin loader could silently break us. Do we need a `hermes_compat: ">=0.8.0,<1.0.0"` field somewhere?
|
||||
- **`hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla upstream. This is intentional but feels like a hack. Upstream PR #8556 (`feat/session-api`) will eventually let us delete it — verified 2026-04-15 that its scope covers the full bootstrap surface (sessions, memory, skills, config, available-models). Track that PR's status periodically.
|
||||
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to #8556. Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
|
||||
- **Bootstrap injection shrink path** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. Upstream commit `f7527b0` via PR #33134 now covers baseline sessions/chat/fork/message history, and `/v1/skills` covers list metadata. Do **not** delete the bootstrap wholesale yet: Relay still depends on compatibility routes that upstream lacks or does not match (`/api/sessions/search`, `/api/memory`, `/api/config`, legacy `/api/skills` detail routes, `/api/available-models`, and voice aliases). Shrink per route group only after native parity or client migration.
|
||||
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Follow-up to the native session-control baseline from PR #33134 / commit `f7527b0`. Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
|
||||
- **Gateway slash-command preprocessor — bootstrap middleware (Stage 1 equivalent).** Sibling shim in `hermes_relay_bootstrap/_command_middleware.py` that mirrors the upstream Stage 1 PR as an aiohttp middleware injected at bootstrap time. Ships the hallucination fix to vanilla-upstream installs before the upstream PR lands. Planned for v0.4.1, after the current bridge feature branch wraps. See `ROADMAP.md` v0.4.1 entry.
|
||||
- **Stage 2 — stateful slash-command dispatch on `/api/sessions/{id}/chat/stream`.** Blocked on PR #8556 merging. Once session primitives ship upstream, add a preprocessor scoped to the session chat stream endpoint only, using `session_id` as the persistence handle. Separate upstream PR + matching bootstrap middleware. See `docs/upstream-contributions.md` §5 ("Stage 2").
|
||||
- **Stage 2 — stateful slash-command dispatch on `/api/sessions/{id}/chat/stream`.** Unblocked by upstream PR #33134 / commit `f7527b0`. Add a preprocessor scoped to the session chat stream endpoint only, using `session_id` as the persistence handle. Separate upstream PR + matching bootstrap middleware. See `docs/upstream-contributions.md` §5 ("Stage 2").
|
||||
|
||||
When the answer becomes clearer, this section becomes either an ADR in `docs/decisions.md` or a Plan under `Plans/`.
|
||||
|
||||
|
||||
+22
-17
@@ -54,11 +54,10 @@ android {
|
||||
Properties().apply { localProps.inputStream().use { stream -> load(stream) } }
|
||||
} else null
|
||||
|
||||
storeFile = file(
|
||||
System.getenv("HERMES_KEYSTORE_PATH")
|
||||
?: props?.getProperty("hermes.keystore.path")
|
||||
?: "/nonexistent"
|
||||
)
|
||||
val keystorePath = System.getenv("HERMES_KEYSTORE_PATH")
|
||||
?: props?.getProperty("hermes.keystore.path")
|
||||
?: "/nonexistent"
|
||||
storeFile = rootProject.file(keystorePath)
|
||||
storePassword = System.getenv("HERMES_KEYSTORE_PASSWORD")
|
||||
?: props?.getProperty("hermes.keystore.password") ?: ""
|
||||
keyAlias = System.getenv("HERMES_KEY_ALIAS")
|
||||
@@ -68,20 +67,18 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 3 — Bridge channel release tracks ────────────────────────────────
|
||||
// Google Play scrutinizes AccessibilityService heavily (policy review + manual
|
||||
// appeals are common), so Phase 3 ships two distinct tracks via flavor-merged
|
||||
// manifests + flavor-scoped strings + flavor-scoped accessibility configs:
|
||||
// ─── Bridge release tracks ─────────────────────────────────────────────────
|
||||
// Google Play ships Bridge Core only: pairing, chat, voice, terminal/TUI,
|
||||
// media, notification companion, relay sessions, and status. It does not
|
||||
// declare AccessibilityService, overlay, MediaProjection, wake-lock device
|
||||
// control, SMS/call/contact/location, or unattended-control permissions.
|
||||
//
|
||||
// googlePlay — conservative use-case description targeted at Play Store
|
||||
// policy review. Subset of event types + flagDefault only.
|
||||
// No gestures, no interactive-window reporting. Feature gates
|
||||
// in BuildFlavor.kt hide tier 3/4/6 surfaces in the UI.
|
||||
// googlePlay — canonical Play Store install. Bridge Core only.
|
||||
//
|
||||
// sideload — full agent-control description for users who install the
|
||||
// APK directly (GitHub Releases, F-Droid, ADB). typeAllMask,
|
||||
// gestures, interactive windows, view-id reporting. All six
|
||||
// tiers enabled.
|
||||
// sideload — Device Control for users who install directly (GitHub
|
||||
// Releases, F-Droid, ADB). AccessibilityService, gestures,
|
||||
// screenshots, overlay/status chip, and phone utilities are
|
||||
// declared in the sideload manifest.
|
||||
//
|
||||
// applicationIdSuffix decision: sideload gets `.sideload` so both tracks can
|
||||
// coexist on the same device. The Play build keeps the base
|
||||
@@ -166,6 +163,9 @@ android {
|
||||
// both failing with RuntimeException from unmocked Log.w calls.
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
// Robolectric (VoicePlayerTest) needs merged Android resources +
|
||||
// manifest on the unit-test classpath to bootstrap its sandbox.
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,8 +259,13 @@ dependencies {
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.kotlinx.serialization.json)
|
||||
// MockWebServer for ADR 24 EndpointResolver tests — probes HEAD /health
|
||||
// across priority groups against real local sockets so the behavior we
|
||||
// validate matches on-device.
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Verifies the voice overlay renders exactly one transcript row per turn
|
||||
* even when [VoiceUiState.transcribedText] and [VoiceUiState.responseText]
|
||||
* are populated alongside the same content in [ChatMessage]s.
|
||||
*
|
||||
* Pre-fix bug: the overlay rendered from THREE sources — `transcribedText`
|
||||
* (a top "YOU" row), `responseText` (a `StreamingResponseRow`), and
|
||||
* `transcriptMessages` (the scrolling chat-history list). During a voice
|
||||
* turn ChatViewModel committed the user's send and streamed the assistant
|
||||
* reply into its own message flow, so the same content ended up in both
|
||||
* `transcribedText`/`responseText` AND in `transcriptMessages` — every
|
||||
* turn appeared twice on screen.
|
||||
*
|
||||
* Fix: the overlay consumes only `transcriptMessages` now. This test asserts
|
||||
* that even when the other two fields are set, the on-screen count of each
|
||||
* turn's text is exactly one.
|
||||
*/
|
||||
class VoiceModeOverlayTranscriptTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun userAndAgentTurns_renderExactlyOnce_evenWithLegacyFieldsSet() {
|
||||
val userText = "Hello agent"
|
||||
val agentText = "Hi there"
|
||||
|
||||
val messages = listOf(
|
||||
ChatMessage(
|
||||
id = "u-1",
|
||||
role = MessageRole.USER,
|
||||
content = userText,
|
||||
timestamp = 1L,
|
||||
isStreaming = false,
|
||||
),
|
||||
ChatMessage(
|
||||
id = "a-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = agentText,
|
||||
timestamp = 2L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
VoiceModeOverlay(
|
||||
uiState = VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Speaking,
|
||||
// Legacy fields — if the overlay still read these,
|
||||
// each turn's text would appear twice.
|
||||
transcribedText = userText,
|
||||
responseText = agentText,
|
||||
interactionMode = InteractionMode.TapToTalk,
|
||||
),
|
||||
onMicTap = {},
|
||||
onMicRelease = {},
|
||||
onInterrupt = {},
|
||||
onDismiss = {},
|
||||
onModeChange = {},
|
||||
onClearError = {},
|
||||
transcriptMessages = messages,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
val userOccurrences = composeTestRule
|
||||
.onAllNodesWithText(userText, substring = false)
|
||||
.fetchSemanticsNodes()
|
||||
.size
|
||||
val agentOccurrences = composeTestRule
|
||||
.onAllNodesWithText(agentText, substring = false)
|
||||
.fetchSemanticsNodes()
|
||||
.size
|
||||
|
||||
assertEquals(
|
||||
"user turn must render exactly once (no double-entry from transcribedText)",
|
||||
1,
|
||||
userOccurrences,
|
||||
)
|
||||
assertEquals(
|
||||
"agent turn must render exactly once (no double-entry from responseText)",
|
||||
1,
|
||||
agentOccurrences,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,24 +5,23 @@
|
||||
Merged on top of `app/src/main/AndroidManifest.xml` by AGP when the
|
||||
`googlePlayDebug` / `googlePlayRelease` variants are built.
|
||||
|
||||
The AccessibilityService is declared exactly once in `app/src/main/AndroidManifest.xml`.
|
||||
The flavor distinction is purely at the resource layer: this flavor's
|
||||
`res/xml/accessibility_service_config.xml` carries the conservative use-case
|
||||
description required for Google Play policy review, and `res/values/strings.xml`
|
||||
carries the description string. Gradle's resource merger picks the right
|
||||
files at build time, so we don't need to redeclare the <service> here.
|
||||
Google Play ships Hermes Bridge Core only. It intentionally does not merge
|
||||
any Device Control services or permissions.
|
||||
|
||||
This file is intentionally kept as an empty overlay so future flavor-specific
|
||||
permissions / activities have an obvious home. Mirror structural additions
|
||||
in `app/src/sideload/AndroidManifest.xml` unless the change is intentionally
|
||||
track-specific.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Media3 ExoPlayer contributes a power-management permission from its
|
||||
library manifest. Strip it from the merged Play artifact. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WAKE_LOCK"
|
||||
tools:node="remove" />
|
||||
|
||||
<!-- googlePlay inherits main manifest's specialUse-only FGS type
|
||||
directly — no override needed. The sideload manifest ADDS
|
||||
mediaProjection via tools:replace; googlePlay gets the safe
|
||||
default. -->
|
||||
<application />
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Google Play flavor strings.
|
||||
|
||||
`a11y_description_googleplay` is the user-facing description shown in
|
||||
Android's Accessibility settings when enabling the Hermes Bridge service.
|
||||
It is ALSO what Play Store reviewers read when evaluating our
|
||||
AccessibilityService use-case declaration, so phrasing matters: stay
|
||||
narrowly scoped, emphasize user confirmation, emphasize dormancy until
|
||||
the user opts in inside the app.
|
||||
|
||||
Do not reference voice or vision features here — tier 3/4/6 are gated
|
||||
off for this flavor via FeatureFlags.BuildFlavor.
|
||||
-->
|
||||
<resources>
|
||||
<string name="a11y_service_label">Hermes-Bridge</string>
|
||||
<string name="a11y_description_googleplay">Hermes assists you by reading on-screen content and summarizing notifications. The service is read-only — it does not perform taps, type text, or control other apps. It is dormant until you explicitly enable Bridge mode in the app.</string>
|
||||
</resources>
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Google Play AccessibilityService configuration.
|
||||
|
||||
Conservative event-type subset targeted at the "read notifications,
|
||||
summarize messages, reply with confirmation" use case Play Store policy
|
||||
review expects. Does NOT subscribe to typeAllMask, does NOT request
|
||||
gestures, does NOT request flagRetrieveInteractiveWindows.
|
||||
|
||||
Keep these attributes aligned with the description in strings.xml
|
||||
(`a11y_description_googleplay`) — if the description widens, reviewers
|
||||
will expect the config to widen too.
|
||||
-->
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/a11y_description_googleplay"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged|typeViewClicked"
|
||||
android:accessibilityFlags="flagDefault"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:notificationTimeout="100"
|
||||
android:canRetrieveWindowContent="true" />
|
||||
@@ -6,74 +6,9 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<!-- === A8 wake-lock: keep CPU awake while dispatching bridge gestures === -->
|
||||
<!-- Normal-protection permission (no runtime prompt). Held only inside
|
||||
WakeLockManager.wakeForAction { ... }, with a 10s hard timeout and
|
||||
ref-counted release. -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- === PHASE3-accessibility: AccessibilityService + bridge permissions === -->
|
||||
<!-- BIND_ACCESSIBILITY_SERVICE is intentionally NOT declared as a
|
||||
<uses-permission> here — it's a system-only permission granted to
|
||||
services that declare android:permission on their <service> tag
|
||||
(see the BridgeAccessibilityService entry below). Declaring it as
|
||||
a uses-permission trips lint's [ProtectedPermissions] check.
|
||||
|
||||
FOREGROUND_SERVICE* are for the persistent notification that
|
||||
Agent safety-rails will wire in Wave 2 for MediaProjection-backed
|
||||
screenshots. POST_NOTIFICATIONS is required on API 33+ for that
|
||||
same foreground-service notification. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!-- FOREGROUND_SERVICE_MEDIA_PROJECTION moved to sideload manifest.
|
||||
googlePlay doesn't need screen recording: /screenshot route is
|
||||
gated sideload-only in BridgeCommandHandler. Declaring the
|
||||
permission on the Play track would flag review since our
|
||||
accessibility use-case ("read-only screen reading") doesn't
|
||||
justify screen capture. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- === END PHASE3-accessibility === -->
|
||||
|
||||
<!-- === PHASE3-safety-rails: safety service + overlay === -->
|
||||
<!-- SYSTEM_ALERT_WINDOW is user-granted via Settings.ACTION_MANAGE_OVERLAY_PERMISSION.
|
||||
Used for (a) the destructive-verb confirmation modal that must be
|
||||
visible even when Hermes isn't in the foreground, and (b) the optional
|
||||
floating "Hermes active" status chip. The permission is declared here
|
||||
so the user-visible grant flow triggers, but the overlay itself only
|
||||
appears when the user has explicitly consented.
|
||||
|
||||
FOREGROUND_SERVICE_SPECIAL_USE is required on Android 14+ for the
|
||||
persistent "Bridge active" notification (BridgeForegroundService),
|
||||
because the specialUse type needs its own declared permission. -->
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<!-- === END PHASE3-safety-rails === -->
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<!-- === PHASE3-baseline-handlers: package visibility for /get_apps + /open_app === -->
|
||||
<!-- On Android 11+ (API 30+), apps can only see other packages that
|
||||
are implicitly visible (own UID, system apps, etc.) unless they
|
||||
declare a <queries> filter or hold QUERY_ALL_PACKAGES. The
|
||||
BridgeCommandHandler /get_apps route uses
|
||||
queryIntentActivities(ACTION_MAIN + CATEGORY_LAUNCHER) to enumerate
|
||||
launchable apps, and BridgeSafetySettingsScreen uses the same call
|
||||
to populate the blocklist UI — both need this declaration to see
|
||||
the full launcher set. Without it queryIntentActivities silently
|
||||
returns a near-empty list (typical symptom: blocklist UI shows
|
||||
only Hermes-Relay itself + a handful of system apps).
|
||||
|
||||
Declaring an <intent> filter with ACTION_MAIN + CATEGORY_LAUNCHER
|
||||
is the Play-policy-safe approach — it does NOT require the
|
||||
restricted QUERY_ALL_PACKAGES permission, which Play would
|
||||
otherwise demand a policy declaration for. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
</queries>
|
||||
<!-- === END PHASE3-baseline-handlers === -->
|
||||
|
||||
<application
|
||||
android:name=".HermesRelayApp"
|
||||
android:allowBackup="true"
|
||||
@@ -106,30 +41,6 @@
|
||||
android:resource="@xml/file_provider_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- === PHASE3-accessibility: AccessibilityService declaration === -->
|
||||
<!-- The @xml/accessibility_service_config resource is provided by
|
||||
the flavor-specific source sets (app/src/googlePlay/ and
|
||||
app/src/sideload/) owned by Agent flavor-split. Each flavor declares its
|
||||
own accessibility use-case description and flag bitset — the
|
||||
googlePlay track declares a conservative "notifications + reply
|
||||
with confirmation" use case, the sideload track declares the
|
||||
full agent-control use case. Gradle merges the flavor XML into
|
||||
main at build time.
|
||||
-->
|
||||
<service
|
||||
android:name=".accessibility.HermesAccessibilityService"
|
||||
android:exported="true"
|
||||
android:label="@string/a11y_service_label"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_service_config" />
|
||||
</service>
|
||||
<!-- === END PHASE3-accessibility === -->
|
||||
|
||||
<!-- === PHASE3-notif-listener: notification companion service === -->
|
||||
<service
|
||||
android:name=".notifications.HermesNotificationCompanion"
|
||||
@@ -142,46 +53,6 @@
|
||||
</service>
|
||||
<!-- === END PHASE3-notif-listener === -->
|
||||
|
||||
<!-- === PHASE3-safety-rails: safety service + overlay === -->
|
||||
<!-- BridgeForegroundService is a plain (non-exported) foreground
|
||||
service driven by BridgeViewModel based on the master toggle.
|
||||
It owns the persistent "Hermes agent has device control"
|
||||
notification.
|
||||
|
||||
foregroundServiceType is the OR of two API 34+ subtypes:
|
||||
|
||||
- specialUse — backs the persistent "bridge active"
|
||||
indicator we shipped with Tier 5 safety rails. Comes
|
||||
with the SPECIAL_USE foreground-service permission and
|
||||
the Play Console policy declaration.
|
||||
|
||||
- mediaProjection — REQUIRED by Android 14+ before any
|
||||
call to MediaProjectionManager.getMediaProjection().
|
||||
Without this declaration, getMediaProjection() returns
|
||||
a projection that the system auto-revokes within a
|
||||
frame, leaving us with a permanently-null
|
||||
MediaProjectionHolder.projection. Symptom on the
|
||||
device: consent dialog appears, user allows full
|
||||
screen, dialog closes, grant evaporates. Sample-tested
|
||||
on a Samsung S24 / Android 14 on 2026-04-12.
|
||||
|
||||
Both types share the same notification + same lifecycle —
|
||||
one service, one notification, two type slots.
|
||||
|
||||
Android 14+ requires a <property> tag justifying the
|
||||
specialUse subtype. The mediaProjection subtype does NOT
|
||||
need a property tag because it has its own dedicated
|
||||
permission (FOREGROUND_SERVICE_MEDIA_PROJECTION). -->
|
||||
<service
|
||||
android:name=".bridge.BridgeForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Maintains a persistent WebSocket connection to the user's Hermes server for real-time chat relay and notification mirroring. The service is dormant until the user explicitly enables Bridge mode in the app." />
|
||||
</service>
|
||||
<!-- === END PHASE3-safety-rails === -->
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -239,6 +239,166 @@
|
||||
}
|
||||
};
|
||||
|
||||
// ── Scroll shims + gesture ────────────────────────────────────────
|
||||
// xterm.js ships a scrollback buffer (scrollback: 10000 above) but
|
||||
// has no built-in mobile touch-to-scroll — its input handlers are
|
||||
// designed around mouse/wheel events, and it sets touch-action on
|
||||
// its root to swallow gestures for selection. That leaves the
|
||||
// scrollback unreachable on phones unless we add a translator.
|
||||
//
|
||||
// Strategy: one finger, mostly-vertical drag → convert delta to
|
||||
// line-scroll via term.scrollLines(). The threshold keeps short
|
||||
// taps (and their tiny jitter) from triggering scroll; the axis
|
||||
// dominance check lets users still long-press for selection or
|
||||
// horizontal-swipe for future features without false positives.
|
||||
// Every scroll intent on this terminal — gesture, toolbar button,
|
||||
// whatever — gets funneled through a synthetic WheelEvent dispatched
|
||||
// on xterm's render root. This is deliberately NOT a direct call to
|
||||
// term.scrollLines(), because that skips xterm's own buffer + mouse-
|
||||
// mode routing. Letting xterm handle the wheel gives us all three
|
||||
// correct behaviors for free:
|
||||
//
|
||||
// 1. Main buffer (at the shell prompt): xterm scrolls its own
|
||||
// 10k-line scrollback locally — same as our first version.
|
||||
// 2. Alternate buffer + TUI has enabled mouse tracking
|
||||
// (claude-code, hermes TUI, tmux with `mouse on`, less, vim
|
||||
// with `set mouse=a` — basically any modern TUI; it's what
|
||||
// makes hover-to-scroll work on desktop): xterm encodes the
|
||||
// wheel as an SGR mouse-wheel escape (\e[<64;col;rowM for
|
||||
// up / <65 for down) and forwards it to the PTY, so the TUI
|
||||
// scrolls its own content natively.
|
||||
// 3. Alternate buffer + TUI has NOT enabled mouse tracking
|
||||
// (rare on modern TUIs; mostly old curses apps): xterm
|
||||
// ignores the wheel — safe no-op, no garbage injected.
|
||||
const wheelTarget = function () {
|
||||
return document.querySelector('.xterm-screen') ||
|
||||
document.querySelector('.xterm') ||
|
||||
document.getElementById('terminal');
|
||||
};
|
||||
const dispatchWheel = function (deltaY) {
|
||||
const target = wheelTarget();
|
||||
if (!target) {
|
||||
console.warn('scrollTerminal: no wheel target element found');
|
||||
return;
|
||||
}
|
||||
const rect = target.getBoundingClientRect();
|
||||
// Mouse position matters for SGR wheel encoding — use the
|
||||
// viewport center so TUIs that care (tmux split-pane) hit the
|
||||
// right pane instead of always the top-left cell.
|
||||
const clientX = rect.left + rect.width / 2;
|
||||
const clientY = rect.top + rect.height / 2;
|
||||
const evt = new WheelEvent('wheel', {
|
||||
deltaY: deltaY,
|
||||
deltaMode: 0, // DOM_DELTA_PIXEL
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: clientX,
|
||||
clientY: clientY,
|
||||
});
|
||||
const altBuffer = (function () {
|
||||
try { return term.buffer.active.type === 'alternate'; }
|
||||
catch (_) { return false; }
|
||||
})();
|
||||
// Visible in logcat via TerminalWebView's onConsoleMessage —
|
||||
// confirms the wheel fired and on which buffer. If scroll is
|
||||
// not working in a TUI, this is the first thing to check:
|
||||
// no line here = gesture path broken; line present but no
|
||||
// TUI response = TUI hasn't enabled mouse tracking.
|
||||
console.log('dispatchWheel: deltaY=' + deltaY +
|
||||
' altBuffer=' + altBuffer +
|
||||
' target=' + (target.className || target.id));
|
||||
target.dispatchEvent(evt);
|
||||
};
|
||||
|
||||
const lineHeightPx = function () {
|
||||
const size = term.options.fontSize || 13;
|
||||
const lh = term.options.lineHeight || 1.15;
|
||||
return Math.max(10, size * lh);
|
||||
};
|
||||
|
||||
window.scrollTerminalLines = function (lines) {
|
||||
const n = Math.round(lines);
|
||||
if (n === 0) return;
|
||||
dispatchWheel(n * lineHeightPx());
|
||||
};
|
||||
window.scrollTerminalPages = function (pages) {
|
||||
const n = Math.round(pages);
|
||||
if (n === 0) return;
|
||||
dispatchWheel(n * lineHeightPx() * Math.max(1, term.rows - 2));
|
||||
};
|
||||
// Jump-to-bottom only makes sense in the main buffer (alt buffer is
|
||||
// always "at the bottom" — the TUI owns every visible row). In alt
|
||||
// buffer we simply no-op rather than guess what "bottom" means for
|
||||
// whichever app is running.
|
||||
window.scrollTerminalToBottom = function () {
|
||||
try {
|
||||
if (term.buffer.active.type === 'alternate') return;
|
||||
term.scrollToBottom();
|
||||
} catch (_) {}
|
||||
};
|
||||
window.scrollTerminalToTop = function () {
|
||||
try {
|
||||
if (term.buffer.active.type === 'alternate') return;
|
||||
term.scrollToTop();
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
(function installTouchScroll() {
|
||||
const root = document.getElementById('terminal');
|
||||
if (!root) return;
|
||||
let touchId = null;
|
||||
let startY = 0;
|
||||
let accumulated = 0;
|
||||
// lineHeightPx() is defined at module scope above — it already
|
||||
// tracks setFontSize() via term.options and returns a fresh
|
||||
// value per call, so we just use it directly here.
|
||||
const onStart = function (ev) {
|
||||
if (ev.touches.length !== 1) { touchId = null; return; }
|
||||
touchId = ev.touches[0].identifier;
|
||||
startY = ev.touches[0].clientY;
|
||||
accumulated = 0;
|
||||
};
|
||||
const onMove = function (ev) {
|
||||
if (touchId === null) return;
|
||||
let t = null;
|
||||
for (let i = 0; i < ev.touches.length; i++) {
|
||||
if (ev.touches[i].identifier === touchId) { t = ev.touches[i]; break; }
|
||||
}
|
||||
if (!t) return;
|
||||
const dy = t.clientY - startY;
|
||||
// Only fire once past a small deadzone so long-press+select
|
||||
// isn't stolen from xterm.
|
||||
if (Math.abs(dy) < 12) return;
|
||||
const lh = lineHeightPx();
|
||||
const lines = Math.trunc((dy - accumulated) / lh);
|
||||
if (lines !== 0) {
|
||||
// Route through the shared shim so alt-buffer detection
|
||||
// kicks in — TUIs (claude-code, hermes, vim, less) live
|
||||
// in the alt buffer and need real input events, while
|
||||
// the shell's main buffer uses local scrollback.
|
||||
// Finger down = older content, so we flip the sign.
|
||||
window.scrollTerminalLines(-lines);
|
||||
accumulated += lines * lh;
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
const onEnd = function (ev) {
|
||||
for (let i = 0; i < ev.changedTouches.length; i++) {
|
||||
if (ev.changedTouches[i].identifier === touchId) {
|
||||
touchId = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
// passive:false is required because we preventDefault above to
|
||||
// stop the browser from also hijacking the gesture for refresh
|
||||
// or selection.
|
||||
root.addEventListener('touchstart', onStart, { passive: true });
|
||||
root.addEventListener('touchmove', onMove, { passive: false });
|
||||
root.addEventListener('touchend', onEnd, { passive: true });
|
||||
root.addEventListener('touchcancel', onEnd, { passive: true });
|
||||
})();
|
||||
|
||||
// Refit on any container size change. ResizeObserver is more reliable
|
||||
// than `window.resize` on Android WebView — the window doesn't always
|
||||
// fire `resize` when Compose resizes the parent View, so the initial
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
v0.5.1 — Voice Mode Quality Pass
|
||||
v0.8.1 - Voice mode crash fix
|
||||
|
||||
Voice Quality
|
||||
• Gapless TTS playback — Media3 ExoPlayer with persistent player + addMediaItem
|
||||
• Sanitizer strips markdown, tool annotations, URLs, and emoji before ElevenLabs
|
||||
(chat UI still shows emoji — only the voice path is cleaned)
|
||||
• Sentence coalescing + 800ms idle flush so short fragments join naturally
|
||||
• Prefetch-while-playing pipeline — no dead air between sentence chunks
|
||||
|
||||
Conversational Barge-In (opt-in, Voice Settings)
|
||||
• Interrupt the agent by just speaking — Silero VAD + AEC + hysteresis
|
||||
• Soft-duck on first positive, hard-cut on confirmed speech
|
||||
• Optional resume-from-next-sentence if the barge-in was a cough
|
||||
• Sensitivity picker (Off / Low / Default / High) + AEC compatibility hint
|
||||
|
||||
Silence Auto-Stop
|
||||
• The Silence Threshold slider in Voice Settings finally works — Continuous
|
||||
and Tap-to-Talk modes now auto-submit after your configured silence window
|
||||
• Grace window: auto-stop waits until you've actually started speaking
|
||||
• Hold-to-Talk unchanged (physical release is the stop signal)
|
||||
|
||||
Fixes
|
||||
• Final short sentence with emoji now spoken in Continuous mode
|
||||
• Continuous mode preference survives app restarts
|
||||
• Bootstrap gateway crash on startup ('tuple' has no attribute 'freeze') — fixed
|
||||
Voice
|
||||
* Fixed a crash that could hit voice mode when barge-in was enabled on the
|
||||
legacy text-to-speech path — the agent's first words no longer cut off
|
||||
into a crash. Barge-in is opt-in; the Realtime Agent and Voice Output
|
||||
paths were never affected.
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.hermesandroid.relay.accessibility.ScreenCaptureRequester
|
||||
import com.hermesandroid.relay.bridge.BridgeForegroundService
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.util.ComposeArrWorkaround
|
||||
import com.hermesandroid.relay.util.NavRouteRequest
|
||||
@@ -50,6 +51,10 @@ class MainActivity : ComponentActivity() {
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
val data = result.data
|
||||
if (!BuildFlavor.isSideload) {
|
||||
Log.w(TAG, "Ignoring MediaProjection result on Google Play Bridge Core build")
|
||||
return@registerForActivityResult
|
||||
}
|
||||
if (result.resultCode == RESULT_OK && data != null) {
|
||||
Log.i(TAG, "MediaProjection consent granted — handing off to FGS")
|
||||
BridgeForegroundService.grantMediaProjection(this, result.resultCode, data)
|
||||
@@ -88,13 +93,15 @@ class MainActivity : ComponentActivity() {
|
||||
// Hand the launcher to the process-singleton rendezvous so
|
||||
// BridgeViewModel.requestScreenCapture() can fire the consent
|
||||
// dialog without holding an Activity reference.
|
||||
ScreenCaptureRequester.install {
|
||||
val mgr = getSystemService(Context.MEDIA_PROJECTION_SERVICE)
|
||||
as MediaProjectionManager
|
||||
try {
|
||||
mediaProjectionLauncher.launch(mgr.createScreenCaptureIntent())
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "failed to launch MediaProjection consent: ${t.message}")
|
||||
if (BuildFlavor.isSideload) {
|
||||
ScreenCaptureRequester.install {
|
||||
val mgr = getSystemService(Context.MEDIA_PROJECTION_SERVICE)
|
||||
as MediaProjectionManager
|
||||
try {
|
||||
mediaProjectionLauncher.launch(mgr.createScreenCaptureIntent())
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "failed to launch MediaProjection consent: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
@@ -140,15 +147,21 @@ class MainActivity : ComponentActivity() {
|
||||
// we don't leak the Activity past its lifecycle. The unattended-
|
||||
// access manager only attempts dismiss when an activity is
|
||||
// registered AND the user has opted in.
|
||||
UnattendedAccessManager.setHostActivity(this)
|
||||
if (BuildFlavor.isSideload) {
|
||||
UnattendedAccessManager.setHostActivity(this)
|
||||
}
|
||||
// Re-probe the credential-lock state on resume so the Bridge
|
||||
// tab badge updates immediately if the user just changed their
|
||||
// lock screen in system Settings between app sessions.
|
||||
UnattendedAccessManager.refreshKeyguardState()
|
||||
if (BuildFlavor.isSideload) {
|
||||
UnattendedAccessManager.refreshKeyguardState()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
UnattendedAccessManager.setHostActivity(null)
|
||||
if (BuildFlavor.isSideload) {
|
||||
UnattendedAccessManager.setHostActivity(null)
|
||||
}
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
@@ -157,7 +170,9 @@ class MainActivity : ComponentActivity() {
|
||||
// Drop the launcher closure so we don't hold a stale Activity ref
|
||||
// after destroy. ScreenCaptureRequester.request() will return false
|
||||
// until the next MainActivity instance reinstalls itself.
|
||||
ScreenCaptureRequester.uninstall()
|
||||
if (BuildFlavor.isSideload) {
|
||||
ScreenCaptureRequester.uninstall()
|
||||
}
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -140,8 +140,11 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
fun ok(data: Map<String, Any?> = mapOf("ok" to true)): ActionResult =
|
||||
ActionResult(ok = true, data = data)
|
||||
|
||||
fun failure(message: String): ActionResult =
|
||||
ActionResult(ok = false, error = message)
|
||||
fun failure(
|
||||
message: String,
|
||||
data: Map<String, Any?> = emptyMap(),
|
||||
): ActionResult =
|
||||
ActionResult(ok = false, data = data, error = message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1622,13 +1625,22 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
*/
|
||||
suspend fun sendSms(to: String, body: String): ActionResult {
|
||||
if (to.isBlank()) {
|
||||
return ActionResult.failure("send_sms: recipient must be non-blank")
|
||||
return ActionResult.failure(
|
||||
"send_sms: recipient must be non-blank",
|
||||
mapOf("status" to "failed", "reason" to "invalid_recipient"),
|
||||
)
|
||||
}
|
||||
if (body.isEmpty()) {
|
||||
return ActionResult.failure("send_sms: body must be non-empty")
|
||||
return ActionResult.failure(
|
||||
"send_sms: body must be non-empty",
|
||||
mapOf("status" to "failed", "reason" to "invalid_schema"),
|
||||
)
|
||||
}
|
||||
if (!to.matches(Regex("^[+0-9 ()\\-.]{2,}$"))) {
|
||||
return ActionResult.failure("send_sms: recipient contains invalid characters")
|
||||
return ActionResult.failure(
|
||||
"send_sms: recipient contains invalid characters",
|
||||
mapOf("status" to "failed", "reason" to "invalid_recipient"),
|
||||
)
|
||||
}
|
||||
|
||||
val ctx: Context = service
|
||||
@@ -1636,7 +1648,12 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return ActionResult.failure(
|
||||
"Grant SMS permission in Settings > Apps > Hermes-Relay > Permissions"
|
||||
"Grant SMS permission in Settings > Apps > Hermes-Relay > Permissions",
|
||||
mapOf(
|
||||
"status" to "blocked",
|
||||
"reason" to "permission_denied",
|
||||
"required_permission" to Manifest.permission.SEND_SMS,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1652,7 +1669,10 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
null
|
||||
}
|
||||
if (smsManager == null) {
|
||||
return ActionResult.failure("SmsManager unavailable on this device")
|
||||
return ActionResult.failure(
|
||||
"SmsManager unavailable on this device",
|
||||
mapOf("status" to "failed", "reason" to "sms_manager_unavailable"),
|
||||
)
|
||||
}
|
||||
|
||||
// Pick one intent action value — the receiver identifies us by
|
||||
@@ -1708,7 +1728,10 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
ContextCompat.registerReceiver(ctx, receiver, filter, flags)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "registerReceiver for SMS_SENT threw: ${t.message}")
|
||||
return ActionResult.failure("sms receiver registration failed: ${t.message}")
|
||||
return ActionResult.failure(
|
||||
"sms receiver registration failed: ${t.message}",
|
||||
mapOf("status" to "failed", "reason" to "receiver_registration_failed"),
|
||||
)
|
||||
}
|
||||
|
||||
// One PendingIntent per part — SmsManager fires the broadcast with
|
||||
@@ -1741,12 +1764,20 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
} catch (se: SecurityException) {
|
||||
try { ctx.unregisterReceiver(receiver) } catch (_: Throwable) { }
|
||||
return ActionResult.failure(
|
||||
"SMS permission revoked or restricted — re-grant in system Settings"
|
||||
"SMS permission revoked or restricted — re-grant in system Settings",
|
||||
mapOf(
|
||||
"status" to "blocked",
|
||||
"reason" to "permission_denied",
|
||||
"required_permission" to Manifest.permission.SEND_SMS,
|
||||
),
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
try { ctx.unregisterReceiver(receiver) } catch (_: Throwable) { }
|
||||
Log.w(TAG, "sendTextMessage threw: ${t.message}")
|
||||
return ActionResult.failure("send_sms failed: ${t.message}")
|
||||
return ActionResult.failure(
|
||||
"send_sms failed: ${t.message}",
|
||||
mapOf("status" to "failed", "reason" to "android_exception"),
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for the receiver to complete — with a 15s cap. If the radio
|
||||
@@ -1757,18 +1788,28 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
|
||||
if (result == null) {
|
||||
return ActionResult.failure(
|
||||
"send_sms timeout after ${SEND_SMS_TIMEOUT_MS}ms — carrier never acked"
|
||||
"send_sms timeout after ${SEND_SMS_TIMEOUT_MS}ms — carrier never acked",
|
||||
mapOf(
|
||||
"status" to "timeout",
|
||||
"reason" to "carrier_ack_timeout",
|
||||
"android_result" to "timeout",
|
||||
"parts" to expectedParts,
|
||||
),
|
||||
)
|
||||
}
|
||||
return if (result == android.app.Activity.RESULT_OK) {
|
||||
ActionResult.ok(
|
||||
mapOf(
|
||||
"status" to "sent",
|
||||
"android_result" to "RESULT_OK",
|
||||
"to" to to,
|
||||
"length" to body.length,
|
||||
"parts" to expectedParts,
|
||||
"summary" to "SMS sent to $to ($expectedParts part(s))",
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val androidResult = smsResultName(result)
|
||||
val reason = when (result) {
|
||||
SmsManager.RESULT_ERROR_GENERIC_FAILURE -> "generic failure"
|
||||
SmsManager.RESULT_ERROR_NO_SERVICE -> "no service"
|
||||
@@ -1776,8 +1817,25 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
SmsManager.RESULT_ERROR_RADIO_OFF -> "radio off (airplane mode?)"
|
||||
else -> "result code $result"
|
||||
}
|
||||
ActionResult.failure("send_sms failed: $reason")
|
||||
ActionResult.failure(
|
||||
"send_sms failed: $reason",
|
||||
mapOf(
|
||||
"status" to "failed",
|
||||
"reason" to reason,
|
||||
"android_result" to androidResult,
|
||||
"parts" to expectedParts,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun smsResultName(result: Int): String = when (result) {
|
||||
android.app.Activity.RESULT_OK -> "RESULT_OK"
|
||||
SmsManager.RESULT_ERROR_GENERIC_FAILURE -> "RESULT_ERROR_GENERIC_FAILURE"
|
||||
SmsManager.RESULT_ERROR_NO_SERVICE -> "RESULT_ERROR_NO_SERVICE"
|
||||
SmsManager.RESULT_ERROR_NULL_PDU -> "RESULT_ERROR_NULL_PDU"
|
||||
SmsManager.RESULT_ERROR_RADIO_OFF -> "RESULT_ERROR_RADIO_OFF"
|
||||
else -> "RESULT_$result"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -65,10 +65,10 @@ import kotlinx.serialization.json.put
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* `unattended.supported` is false on the googlePlay flavor — the Play
|
||||
* APK has no wake-lock path — which lets the agent distinguish "user
|
||||
* hasn't opted in" from "this build can't do unattended at all" without
|
||||
* a separate probe.
|
||||
* `bridge.device_control_supported` and `unattended.supported` are false on
|
||||
* the googlePlay flavor — the Play APK ships Bridge Core without
|
||||
* AccessibilityService, wake locks, overlays, screenshots, or unattended
|
||||
* control — which lets the agent avoid attempting sideload-only tools.
|
||||
*
|
||||
* The legacy top-level keys (`screen_on`, `battery`, `current_app`,
|
||||
* `accessibility_enabled`, `ts`) are ALSO emitted for backwards
|
||||
@@ -188,6 +188,7 @@ class BridgeStatusReporter(
|
||||
val currentApp = HermesAccessibilityService.instance?.currentApp
|
||||
val accessibilityGranted = HermesAccessibilityService.instance != null
|
||||
val masterEnabled = HermesAccessibilityService.instance?.isMasterEnabled() ?: false
|
||||
val deviceControlSupported = BuildFlavor.isSideload
|
||||
|
||||
// Screen-capture grant — the process-singleton holder is non-null
|
||||
// iff the user granted MediaProjection consent this session.
|
||||
@@ -257,10 +258,17 @@ class BridgeStatusReporter(
|
||||
})
|
||||
})
|
||||
put("bridge", buildJsonObject {
|
||||
put("master_enabled", masterEnabled)
|
||||
put("accessibility_granted", accessibilityGranted)
|
||||
put("screen_capture_granted", screenCaptureGranted)
|
||||
put("overlay_granted", overlayGranted)
|
||||
put("device_control_supported", deviceControlSupported)
|
||||
put("master_enabled", if (deviceControlSupported) masterEnabled else false)
|
||||
put(
|
||||
"accessibility_granted",
|
||||
if (deviceControlSupported) accessibilityGranted else false,
|
||||
)
|
||||
put(
|
||||
"screen_capture_granted",
|
||||
if (deviceControlSupported) screenCaptureGranted else false,
|
||||
)
|
||||
put("overlay_granted", if (deviceControlSupported) overlayGranted else false)
|
||||
put("notification_listener_granted", notificationListenerGranted)
|
||||
})
|
||||
put("safety", buildJsonObject {
|
||||
@@ -285,11 +293,18 @@ class BridgeStatusReporter(
|
||||
// when both `enabled=true` and this is true, commands
|
||||
// will wake the screen but stop at the lock screen.
|
||||
put("unattended", buildJsonObject {
|
||||
put("supported", BuildFlavor.isSideload)
|
||||
put("enabled", UnattendedAccessManager.enabled.value)
|
||||
put("supported", deviceControlSupported)
|
||||
put(
|
||||
"enabled",
|
||||
if (deviceControlSupported) UnattendedAccessManager.enabled.value else false,
|
||||
)
|
||||
put(
|
||||
"credential_lock_detected",
|
||||
UnattendedAccessManager.credentialLockDetected.value,
|
||||
if (deviceControlSupported) {
|
||||
UnattendedAccessManager.credentialLockDetected.value
|
||||
} else {
|
||||
false
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -300,8 +315,8 @@ class BridgeStatusReporter(
|
||||
// groups above.
|
||||
put("screen_on", screenOn)
|
||||
put("battery", batteryFinal)
|
||||
put("current_app", currentApp ?: "unknown")
|
||||
put("accessibility_enabled", accessibilityGranted)
|
||||
put("current_app", if (deviceControlSupported) currentApp ?: "unknown" else "unknown")
|
||||
put("accessibility_enabled", if (deviceControlSupported) accessibilityGranted else false)
|
||||
put("ts", System.currentTimeMillis())
|
||||
}
|
||||
)
|
||||
|
||||
+6
-7
@@ -267,13 +267,12 @@ class HermesAccessibilityService : AccessibilityService() {
|
||||
* # Fallback semantics
|
||||
*
|
||||
* `service.windows` returns an empty list unless the accessibility
|
||||
* config XML requests `flagRetrieveInteractiveWindows`. That flag is
|
||||
* **only** set in the `sideload` flavor — the `googlePlay` flavor
|
||||
* deliberately runs on the conservative config subset to pass Play
|
||||
* Store policy review. When `windows` is empty (or throws, or every
|
||||
* window's root is null) we fall back to a single-element list
|
||||
* wrapping [rootInActiveWindow], preserving pre-P1 behaviour on
|
||||
* `googlePlay` builds.
|
||||
* config XML requests `flagRetrieveInteractiveWindows`. The service is
|
||||
* declared only by the `sideload` manifest, and that sideload config sets
|
||||
* the flag. When `windows` is empty (or throws, or every window's root is
|
||||
* null) we fall back to a single-element list wrapping
|
||||
* [rootInActiveWindow], preserving pre-P1 behaviour for tests and
|
||||
* defensive runtime fallback.
|
||||
*
|
||||
* Returns an empty list only if the service cannot read any window
|
||||
* root at all (e.g. lock screen, master-off state). Callers should
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -175,11 +176,26 @@ class BargeInListener internal constructor(
|
||||
_aecAttached.value = false
|
||||
readerJob = scope.launch(readerDispatcher) {
|
||||
try {
|
||||
audioSource.start()
|
||||
try {
|
||||
audioSource.start()
|
||||
} catch (t: CancellationException) {
|
||||
throw t
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "AudioFrameSource.start failed: ${t.message}")
|
||||
return@launch
|
||||
}
|
||||
Log.i(TAG, "Barge-in AudioRecord reader started")
|
||||
maybeAttachEffects()
|
||||
|
||||
while (isActive) {
|
||||
val read = audioSource.read(frameBuffer, VadEngine.FRAME_SIZE_SAMPLES)
|
||||
val read = try {
|
||||
audioSource.read(frameBuffer, VadEngine.FRAME_SIZE_SAMPLES)
|
||||
} catch (t: CancellationException) {
|
||||
throw t
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "AudioFrameSource.read failed; stopping reader: ${t.message}")
|
||||
break
|
||||
}
|
||||
if (read <= 0) {
|
||||
// Negative values are AudioRecord error codes; 0 means
|
||||
// no data yet. Either way, yield briefly and retry
|
||||
@@ -196,7 +212,16 @@ class BargeInListener internal constructor(
|
||||
continue
|
||||
}
|
||||
|
||||
val result = vadEngine.analyze(frameBuffer)
|
||||
if (!isActive) break
|
||||
|
||||
val result = try {
|
||||
vadEngine.analyze(frameBuffer)
|
||||
} catch (t: CancellationException) {
|
||||
throw t
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "VadEngine.analyze failed; stopping reader: ${t.message}")
|
||||
break
|
||||
}
|
||||
if (result.probability > 0f) {
|
||||
_maybeSpeech.tryEmit(Unit)
|
||||
}
|
||||
@@ -228,9 +253,14 @@ class BargeInListener internal constructor(
|
||||
* actual release happens in the reader coroutine's `finally` block, which
|
||||
* is typically a single frame later.
|
||||
*/
|
||||
fun stop() {
|
||||
readerJob?.cancel()
|
||||
fun stop(): Job? {
|
||||
val job = readerJob
|
||||
if (job?.isActive == true) {
|
||||
Log.i(TAG, "Stopping barge-in AudioRecord reader")
|
||||
}
|
||||
job?.cancel()
|
||||
readerJob = null
|
||||
return job
|
||||
}
|
||||
|
||||
private suspend fun maybeAttachEffects() {
|
||||
@@ -253,6 +283,7 @@ class BargeInListener internal constructor(
|
||||
created.enabled = true
|
||||
aec = created
|
||||
_aecAttached.value = true
|
||||
Log.i(TAG, "AcousticEchoCanceler attached to session=$sessionId")
|
||||
} else {
|
||||
Log.i(TAG, "AcousticEchoCanceler.create returned null; continuing without")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,830 @@
|
||||
package com.hermesandroid.relay.audio
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Small streaming PCM sink for the realtime voice dev testbench.
|
||||
*
|
||||
* The relay sends mono 16-bit little-endian PCM chunks over the websocket. This
|
||||
* writes them directly to an AudioTrack so the Android Studio dev build can
|
||||
* hear provider output without waiting for an encoded file.
|
||||
*/
|
||||
class RealtimePcmPlayer(context: Context? = null) {
|
||||
private val trackLock = Any()
|
||||
private val writeLock = Any()
|
||||
private val audioManager =
|
||||
context?.applicationContext?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
private val realtimeAudioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { change ->
|
||||
Log.i(TAG, "Realtime PCM audio focus change=$change")
|
||||
}
|
||||
private var audioTrack: AudioTrack? = null
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private var audioFocusHeld: Boolean = false
|
||||
private var currentSampleRate: Int = 0
|
||||
private var currentVolume: Float = 1f
|
||||
private var estimatedPlaybackEndAtMs: Long = 0L
|
||||
private var playbackStarted: Boolean = false
|
||||
private var pendingStartBytes: Int = 0
|
||||
private var firstBufferedAtMs: Long = 0L
|
||||
private var lastUnderrunCount: Int = 0
|
||||
private var lastHeadPositionLogAtMs: Long = 0L
|
||||
private var lastLoggedHeadFrames: Int = 0
|
||||
private var headAdvanceConfirmed: Boolean = false
|
||||
private var playbackStartedAtMs: Long = 0L
|
||||
private var totalFramesWritten: Long = 0L
|
||||
// (endFrame, rms) per written chunk — lets [playbackAmplitude] report the
|
||||
// amplitude of the audio actually at the hardware cursor right now, instead
|
||||
// of the chunk that most recently *arrived* over the socket.
|
||||
private val playbackAmpQueue = ArrayDeque<FrameAmp>()
|
||||
private var lastPlaybackGapDiagnosticAtMs: Long = 0L
|
||||
private var lastMutedVolumeDiagnosticAtMs: Long = 0L
|
||||
private var adaptiveStartPrebufferMs: Long = RealtimePcmBufferPolicy.START_PREBUFFER_MS
|
||||
private var playbackGapSeenThisTrack: Boolean = false
|
||||
private val _amplitude = MutableStateFlow(0f)
|
||||
val amplitude: StateFlow<Float> = _amplitude.asStateFlow()
|
||||
|
||||
val isActive: Boolean
|
||||
get() = synchronized(trackLock) { audioTrack != null }
|
||||
|
||||
val audioSessionId: Int
|
||||
get() = synchronized(trackLock) { audioTrack?.audioSessionId ?: 0 }
|
||||
|
||||
fun write(pcm: ByteArray, sampleRate: Int): Float {
|
||||
if (pcm.isEmpty()) return 0f
|
||||
val level = computePcm16LeRms(pcm)
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val written = synchronized(writeLock) {
|
||||
val track = try {
|
||||
synchronized(trackLock) {
|
||||
val currentTrack = ensureTrackLocked(sampleRate)
|
||||
notePlaybackGapLocked(currentTrack, now)
|
||||
currentTrack
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "PCM track preparation failed: ${e.message}")
|
||||
synchronized(trackLock) { releaseTrackLocked(reason = "PCM track preparation failure") }
|
||||
return@synchronized 0
|
||||
}
|
||||
|
||||
try {
|
||||
val prerollWritten = maybeWriteStartupPreroll(track, sampleRate)
|
||||
if (prerollWritten < 0) {
|
||||
Log.w(TAG, "PCM preroll write returned $prerollWritten; restarting track")
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) releaseTrackLocked(reason = "PCM preroll write error")
|
||||
}
|
||||
return@synchronized 0
|
||||
}
|
||||
// Intentionally do NOT start playback on the bare silent preroll.
|
||||
// Starting here would begin draining ~120ms of silence with zero
|
||||
// real audio queued, guaranteeing an immediate underrun on the
|
||||
// first speech chunk. The real audio written just below feeds the
|
||||
// normal start decision, and the end-of-turn flush
|
||||
// (voice.output_audio.done) force-starts anything still buffered.
|
||||
|
||||
val writtenBytes = writeBlocking(track, pcm)
|
||||
if (writtenBytes < 0) {
|
||||
Log.w(TAG, "PCM write returned $writtenBytes; restarting track")
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) releaseTrackLocked(reason = "PCM write error")
|
||||
}
|
||||
return@synchronized 0
|
||||
}
|
||||
|
||||
var accepted = 0
|
||||
if (writtenBytes > 0) {
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) {
|
||||
noteWrittenBytesLocked(writtenBytes, sampleRate)
|
||||
enqueuePlaybackAmplitudeLocked(level)
|
||||
maybeStartPlaybackLocked(track, sampleRate, force = false)
|
||||
updateUnderrunCursorLocked(track)
|
||||
logPlaybackHealthLocked(track, now)
|
||||
accepted = writtenBytes
|
||||
}
|
||||
}
|
||||
}
|
||||
accepted
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "PCM write failed: ${e.message}")
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) releaseTrackLocked(reason = "PCM write failure")
|
||||
}
|
||||
return@synchronized 0
|
||||
}
|
||||
}
|
||||
if (written > 0) {
|
||||
_amplitude.value = level
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
private fun writeBlocking(track: AudioTrack, pcm: ByteArray): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
track.write(pcm, 0, pcm.size, AudioTrack.WRITE_BLOCKING)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
track.write(pcm, 0, pcm.size)
|
||||
}
|
||||
|
||||
fun flushBufferedPlayback(cushionMs: Long = DEFAULT_DRAIN_CUSHION_MS): Long {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
return synchronized(trackLock) {
|
||||
val track = audioTrack ?: return@synchronized 0L
|
||||
maybeStartPlaybackLocked(track, currentSampleRate, force = true)
|
||||
val remaining = remainingPlaybackMsLocked(now, cushionMs)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM flush playState=${readPlayState(track)} " +
|
||||
"headFrames=${readHeadFrames(track)} remainingMs=$remaining " +
|
||||
"underruns=${readUnderrunCount(track)}",
|
||||
)
|
||||
remaining
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
synchronized(writeLock) {
|
||||
synchronized(trackLock) {
|
||||
releaseTrackLocked(reason = "stop")
|
||||
currentSampleRate = 0
|
||||
estimatedPlaybackEndAtMs = 0L
|
||||
}
|
||||
}
|
||||
_amplitude.value = 0f
|
||||
}
|
||||
|
||||
fun estimatedRemainingPlaybackMs(cushionMs: Long = DEFAULT_DRAIN_CUSHION_MS): Long {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
return synchronized(trackLock) {
|
||||
remainingPlaybackMsLocked(now, cushionMs)
|
||||
}
|
||||
}
|
||||
|
||||
fun setVolume(volume: Float) {
|
||||
val clamped = volume.coerceIn(0f, 1f)
|
||||
synchronized(trackLock) {
|
||||
currentVolume = clamped
|
||||
try { audioTrack?.setVolume(clamped) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
fun duck() {
|
||||
setVolume(0.3f)
|
||||
}
|
||||
|
||||
fun unduck() {
|
||||
setVolume(1f)
|
||||
}
|
||||
|
||||
private fun releaseTrackLocked(reason: String) {
|
||||
audioTrack?.let { track ->
|
||||
Log.i(TAG, "Stopping streaming PCM playback ($reason)")
|
||||
try { track.pause() } catch (_: Exception) { }
|
||||
try { track.flush() } catch (_: Exception) { }
|
||||
try { track.release() } catch (_: Exception) { }
|
||||
}
|
||||
abandonAudioFocusLocked()
|
||||
settleAdaptivePrebufferLocked()
|
||||
audioTrack = null
|
||||
playbackStarted = false
|
||||
pendingStartBytes = 0
|
||||
firstBufferedAtMs = 0L
|
||||
lastUnderrunCount = 0
|
||||
lastHeadPositionLogAtMs = 0L
|
||||
lastLoggedHeadFrames = 0
|
||||
headAdvanceConfirmed = false
|
||||
playbackStartedAtMs = 0L
|
||||
totalFramesWritten = 0L
|
||||
playbackAmpQueue.clear()
|
||||
playbackGapSeenThisTrack = false
|
||||
}
|
||||
|
||||
private fun enqueuePlaybackAmplitudeLocked(rms: Float) {
|
||||
// [totalFramesWritten] has already been advanced past this chunk, so it
|
||||
// is the chunk's end frame. The cursor reaches this amplitude once
|
||||
// playbackHeadPosition passes the previous end frame.
|
||||
playbackAmpQueue.addLast(FrameAmp(endFrame = totalFramesWritten, rms = rms))
|
||||
while (playbackAmpQueue.size > MAX_AMP_QUEUE) playbackAmpQueue.removeFirst()
|
||||
}
|
||||
|
||||
/**
|
||||
* Amplitude of the audio currently at the hardware cursor (0 if not playing
|
||||
* or drained). This is the playback-synced signal a UI waveform should draw:
|
||||
* it advances with [AudioTrack.getPlaybackHeadPosition], so it matches what
|
||||
* the user hears rather than what most recently arrived over the socket.
|
||||
*/
|
||||
fun playbackAmplitude(): Float = synchronized(trackLock) {
|
||||
val track = audioTrack ?: return@synchronized 0f
|
||||
if (!playbackStarted) return@synchronized 0f
|
||||
val head = readHeadFrames(track).toLong()
|
||||
// Drop fully-played chunks so the head of the queue is the one playing now.
|
||||
while (playbackAmpQueue.size > 1 && playbackAmpQueue.first().endFrame <= head) {
|
||||
playbackAmpQueue.removeFirst()
|
||||
}
|
||||
amplitudeAtHead(playbackAmpQueue, head)
|
||||
}
|
||||
|
||||
private fun ensureTrackLocked(sampleRate: Int): AudioTrack {
|
||||
val existing = audioTrack
|
||||
if (existing != null && currentSampleRate == sampleRate) {
|
||||
return existing
|
||||
}
|
||||
releaseTrackLocked(reason = "sample rate changed")
|
||||
|
||||
val minBuffer = AudioTrack.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
val bufferSize = RealtimePcmBufferPolicy.streamBufferSize(
|
||||
minBufferBytes = minBuffer,
|
||||
sampleRate = sampleRate,
|
||||
)
|
||||
val format = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build()
|
||||
|
||||
val track = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
AudioTrack.Builder()
|
||||
.setAudioAttributes(realtimeAudioAttributes)
|
||||
.setAudioFormat(format)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.setBufferSizeInBytes(bufferSize)
|
||||
.build()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
AudioTrack(
|
||||
AudioManager.STREAM_MUSIC,
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
bufferSize,
|
||||
AudioTrack.MODE_STREAM,
|
||||
)
|
||||
}
|
||||
|
||||
if (track.state != AudioTrack.STATE_INITIALIZED) {
|
||||
try { track.release() } catch (_: Exception) { }
|
||||
throw IllegalStateException("AudioTrack failed to initialize")
|
||||
}
|
||||
|
||||
requestAudioFocusLocked()
|
||||
audioTrack = track
|
||||
currentSampleRate = sampleRate
|
||||
playbackStarted = false
|
||||
pendingStartBytes = 0
|
||||
firstBufferedAtMs = 0L
|
||||
totalFramesWritten = 0L
|
||||
lastUnderrunCount = readUnderrunCount(track)
|
||||
// Log requested vs. actual allocated frames. If a device coerces our
|
||||
// sub-second request back up to a multi-second allocation, that's the
|
||||
// tell-tale of deep-buffer routing (the cold-start parking class) and
|
||||
// explains a regression of the silent-first-turn bug on new hardware.
|
||||
val requestedFrames = bufferSize / BYTES_PER_FRAME
|
||||
val actualFrames = try { track.bufferSizeInFrames } catch (_: Exception) { -1 }
|
||||
Log.i(
|
||||
TAG,
|
||||
"Initialized streaming PCM playback at ${sampleRate}Hz " +
|
||||
"session=${track.audioSessionId} buffer=${bufferSize}B " +
|
||||
"requestedFrames=$requestedFrames actualFrames=$actualFrames " +
|
||||
"(${frameMs(actualFrames, sampleRate)}ms)",
|
||||
)
|
||||
return track
|
||||
}
|
||||
|
||||
private fun frameMs(frames: Int, sampleRate: Int): Long {
|
||||
if (frames <= 0 || sampleRate <= 0) return 0L
|
||||
return (frames * 1000L / sampleRate)
|
||||
}
|
||||
|
||||
private fun noteWrittenBytesLocked(writtenBytes: Int, sampleRate: Int) {
|
||||
if (writtenBytes <= 0 || sampleRate <= 0) return
|
||||
totalFramesWritten += (writtenBytes / BYTES_PER_FRAME).toLong()
|
||||
val durationMs = ((writtenBytes / 2.0) / sampleRate * 1000.0)
|
||||
.toLong()
|
||||
.coerceAtLeast(1L)
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (!playbackStarted) {
|
||||
if (firstBufferedAtMs == 0L) firstBufferedAtMs = now
|
||||
pendingStartBytes += writtenBytes
|
||||
return
|
||||
}
|
||||
val base = max(now, estimatedPlaybackEndAtMs)
|
||||
estimatedPlaybackEndAtMs = base + durationMs
|
||||
}
|
||||
|
||||
private fun maybeWriteStartupPreroll(track: AudioTrack, sampleRate: Int): Int {
|
||||
if (
|
||||
synchronized(trackLock) {
|
||||
playbackStarted ||
|
||||
pendingStartBytes > 0 ||
|
||||
firstBufferedAtMs > 0L ||
|
||||
sampleRate <= 0 ||
|
||||
audioTrack !== track
|
||||
}
|
||||
) {
|
||||
return 0
|
||||
}
|
||||
|
||||
val prerollMs = startupPrerollMsLocked()
|
||||
val silenceBytes = RealtimePcmBufferPolicy.bytesForDurationMs(sampleRate, prerollMs)
|
||||
if (silenceBytes <= 0) return 0
|
||||
|
||||
val written = writeBlocking(track, ByteArray(silenceBytes))
|
||||
if (written > 0) {
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) {
|
||||
noteWrittenBytesLocked(written, sampleRate)
|
||||
enqueuePlaybackAmplitudeLocked(0f) // preroll is silence
|
||||
Log.i(
|
||||
TAG,
|
||||
"Primed realtime PCM playback with " +
|
||||
"${RealtimePcmBufferPolicy.durationMsForBytes(written, sampleRate)}ms " +
|
||||
"silent preroll",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
private fun startupPrerollMsLocked(): Long {
|
||||
return RealtimePcmBufferPolicy.STARTUP_PREROLL_MS
|
||||
}
|
||||
|
||||
private fun maybeStartPlaybackLocked(
|
||||
track: AudioTrack,
|
||||
sampleRate: Int,
|
||||
force: Boolean,
|
||||
) {
|
||||
if (playbackStarted || pendingStartBytes <= 0 || sampleRate <= 0) return
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val waitedMs = if (firstBufferedAtMs > 0L) now - firstBufferedAtMs else 0L
|
||||
val decision = RealtimePcmBufferPolicy.startDecision(
|
||||
pendingBytes = pendingStartBytes,
|
||||
sampleRate = sampleRate,
|
||||
waitedMs = waitedMs,
|
||||
force = force,
|
||||
startPrebufferMs = adaptiveStartPrebufferMs,
|
||||
)
|
||||
if (!decision.shouldStart) return
|
||||
|
||||
try {
|
||||
requestAudioFocusLocked()
|
||||
track.play()
|
||||
try { track.setVolume(currentVolume) } catch (_: Exception) { }
|
||||
} catch (e: Exception) {
|
||||
try { track.release() } catch (_: Exception) { }
|
||||
audioTrack = null
|
||||
throw e
|
||||
}
|
||||
|
||||
playbackStarted = true
|
||||
estimatedPlaybackEndAtMs = now + decision.bufferedMs
|
||||
playbackStartedAtMs = now
|
||||
lastHeadPositionLogAtMs = now
|
||||
lastLoggedHeadFrames = readHeadFrames(track)
|
||||
headAdvanceConfirmed = false
|
||||
Log.i(
|
||||
TAG,
|
||||
"Started streaming PCM playback at ${sampleRate}Hz " +
|
||||
"session=${track.audioSessionId} prebuffer=${decision.bufferedMs}ms " +
|
||||
"waited=${waitedMs}ms target=${adaptiveStartPrebufferMs}ms " +
|
||||
"reason=${decision.reason} playState=${readPlayState(track)} " +
|
||||
"headFrames=$lastLoggedHeadFrames ${mediaVolumeSummaryLocked()}",
|
||||
)
|
||||
pendingStartBytes = 0
|
||||
firstBufferedAtMs = 0L
|
||||
lastUnderrunCount = readUnderrunCount(track)
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodically logs whether the AudioTrack hardware cursor is actually
|
||||
* advancing. This is the decisive signal for the "speaking animation + valid
|
||||
* PCM logs but no sound" class of bug:
|
||||
*
|
||||
* - head frames advancing + still no sound → output route / volume problem
|
||||
* (e.g. the Samsung HAL not opening the path until a volume key nudges it).
|
||||
* - head frames pinned at the start value → the track was play()'d but the
|
||||
* mixer never pulled from it (focus / state problem on this device).
|
||||
*/
|
||||
private fun logPlaybackHealthLocked(track: AudioTrack, now: Long) {
|
||||
if (!playbackStarted) return
|
||||
val headFrames = readHeadFrames(track)
|
||||
// First-frame detection runs on EVERY write until confirmed (not gated by
|
||||
// the throttle) and uses a fresh timestamp, so time-to-first-audio is
|
||||
// accurate to write cadence rather than the 1s health-log window — the
|
||||
// throttle/stale-`now` combination otherwise inflates it by ~1.5s.
|
||||
if (!headAdvanceConfirmed && headFrames > 0) {
|
||||
headAdvanceConfirmed = true
|
||||
val freshNow = SystemClock.elapsedRealtime()
|
||||
val ttfaMs = if (playbackStartedAtMs > 0L) freshNow - playbackStartedAtMs else -1L
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM time-to-first-audio=${ttfaMs}ms (headFrames=$headFrames)",
|
||||
)
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Realtime audio started",
|
||||
detail = "First sample reached the speaker after ${ttfaMs}ms.",
|
||||
)
|
||||
}
|
||||
if (now - lastHeadPositionLogAtMs < HEAD_POSITION_LOG_THROTTLE_MS) return
|
||||
val advancedFrames = headFrames - lastLoggedHeadFrames
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM playback health playState=${readPlayState(track)} " +
|
||||
"headFrames=$headFrames advanced=$advancedFrames " +
|
||||
"underruns=${readUnderrunCount(track)} ${mediaVolumeSummaryLocked()}",
|
||||
)
|
||||
if (advancedFrames <= 0) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Realtime PCM hardware cursor not advancing (headFrames=$headFrames " +
|
||||
"playState=${readPlayState(track)}); audio queued but mixer is not pulling",
|
||||
)
|
||||
maybeRecordStuckCursorDiagnosticLocked(track, now)
|
||||
}
|
||||
lastHeadPositionLogAtMs = now
|
||||
lastLoggedHeadFrames = headFrames
|
||||
}
|
||||
|
||||
/**
|
||||
* If the hardware cursor never started after [STUCK_CURSOR_DIAGNOSTIC_MS] of
|
||||
* "playing", surface it to the in-app Diagnostics screen once per track —
|
||||
* this is the field-visible signal for the cold-start parking class when no
|
||||
* logcat cable is attached. Write-sampled here; the [VoiceViewModel] watchdog
|
||||
* provides the timer-driven guarantee when writes stall.
|
||||
*/
|
||||
private fun maybeRecordStuckCursorDiagnosticLocked(track: AudioTrack, now: Long) {
|
||||
if (headAdvanceConfirmed || playbackStartedAtMs <= 0L) return
|
||||
val stuckMs = now - playbackStartedAtMs
|
||||
if (stuckMs < STUCK_CURSOR_DIAGNOSTIC_MS) return
|
||||
if (now - lastPlaybackGapDiagnosticAtMs < PLAYBACK_GAP_DIAGNOSTIC_THROTTLE_MS) return
|
||||
lastPlaybackGapDiagnosticAtMs = now
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime audio not starting",
|
||||
detail = "Playback running ${stuckMs}ms but no audio reached the speaker " +
|
||||
"(${mediaVolumeSummaryLocked()}).",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable snapshot of playback progress for the [VoiceViewModel] watchdog
|
||||
* and drain cross-check. Reads are cheap and lock-guarded.
|
||||
*/
|
||||
fun snapshot(): RealtimePlaybackSnapshot = synchronized(trackLock) {
|
||||
val track = audioTrack
|
||||
RealtimePlaybackSnapshot(
|
||||
active = track != null,
|
||||
playbackStarted = playbackStarted,
|
||||
headFrames = track?.let { readHeadFrames(it) } ?: 0,
|
||||
framesWritten = totalFramesWritten,
|
||||
sampleRate = currentSampleRate,
|
||||
playStatePlaying = track != null && readPlayState(track) == "playing",
|
||||
startedAtElapsedMs = playbackStartedAtMs,
|
||||
)
|
||||
}
|
||||
|
||||
private fun readHeadFrames(track: AudioTrack): Int =
|
||||
try { track.playbackHeadPosition } catch (_: Exception) { lastLoggedHeadFrames }
|
||||
|
||||
private fun readPlayState(track: AudioTrack): String =
|
||||
try {
|
||||
when (track.playState) {
|
||||
AudioTrack.PLAYSTATE_PLAYING -> "playing"
|
||||
AudioTrack.PLAYSTATE_PAUSED -> "paused"
|
||||
AudioTrack.PLAYSTATE_STOPPED -> "stopped"
|
||||
else -> "unknown"
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
"error"
|
||||
}
|
||||
|
||||
private fun notePlaybackGapLocked(track: AudioTrack, now: Long) {
|
||||
if (!playbackStarted) return
|
||||
val underrunCount = readUnderrunCount(track)
|
||||
val platformUnderrun = underrunCount > lastUnderrunCount
|
||||
val estimatedDrained = estimatedPlaybackEndAtMs > 0L &&
|
||||
now > estimatedPlaybackEndAtMs + RealtimePcmBufferPolicy.UNDERFLOW_GRACE_MS
|
||||
if (!platformUnderrun && !estimatedDrained) return
|
||||
|
||||
val reason = if (platformUnderrun) {
|
||||
"platform underrun ${lastUnderrunCount}→$underrunCount"
|
||||
} else {
|
||||
"stream gap ${now - estimatedPlaybackEndAtMs}ms"
|
||||
}
|
||||
Log.w(TAG, "Realtime PCM continuing after $reason")
|
||||
recordPlaybackGapDiagnosticLocked(now, reason)
|
||||
playbackGapSeenThisTrack = true
|
||||
increaseAdaptivePrebufferLocked(reason)
|
||||
|
||||
// Provider-native realtime streams can legitimately arrive in uneven
|
||||
// bursts while the model decides to call tools. Keep the AudioTrack
|
||||
// alive so already queued speech is not flushed and the next chunk can
|
||||
// resume naturally after Android's underrun recovery.
|
||||
if (estimatedDrained) {
|
||||
estimatedPlaybackEndAtMs = now
|
||||
}
|
||||
lastUnderrunCount = underrunCount
|
||||
}
|
||||
|
||||
private fun increaseAdaptivePrebufferLocked(reason: String) {
|
||||
val previous = adaptiveStartPrebufferMs
|
||||
adaptiveStartPrebufferMs = (adaptiveStartPrebufferMs + ADAPTIVE_PREBUFFER_STEP_MS)
|
||||
.coerceAtMost(RealtimePcmBufferPolicy.MAX_ADAPTIVE_START_PREBUFFER_MS)
|
||||
if (adaptiveStartPrebufferMs != previous) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM adaptive prebuffer increased to ${adaptiveStartPrebufferMs}ms " +
|
||||
"after $reason",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun settleAdaptivePrebufferLocked() {
|
||||
if (playbackGapSeenThisTrack) return
|
||||
val previous = adaptiveStartPrebufferMs
|
||||
adaptiveStartPrebufferMs = (adaptiveStartPrebufferMs - ADAPTIVE_PREBUFFER_DECAY_MS)
|
||||
.coerceAtLeast(RealtimePcmBufferPolicy.START_PREBUFFER_MS)
|
||||
if (adaptiveStartPrebufferMs != previous) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM adaptive prebuffer relaxed to ${adaptiveStartPrebufferMs}ms",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordPlaybackGapDiagnosticLocked(now: Long, reason: String) {
|
||||
if (now - lastPlaybackGapDiagnosticAtMs < PLAYBACK_GAP_DIAGNOSTIC_THROTTLE_MS) return
|
||||
lastPlaybackGapDiagnosticAtMs = now
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime audio stream gap",
|
||||
detail = reason,
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestAudioFocusLocked() {
|
||||
val manager = audioManager ?: return
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val mediaVolume = runCatching { manager.getStreamVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
val maxVolume = runCatching { manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
if (mediaVolume == 0 && now - lastMutedVolumeDiagnosticAtMs > MUTED_VOLUME_DIAGNOSTIC_THROTTLE_MS) {
|
||||
lastMutedVolumeDiagnosticAtMs = now
|
||||
Log.w(TAG, "Realtime PCM playback is starting while media volume is muted")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime voice volume muted",
|
||||
detail = "Media volume is 0/${maxVolume ?: "?"}.",
|
||||
)
|
||||
}
|
||||
if (audioFocusHeld) {
|
||||
Log.i(TAG, "Realtime PCM audio focus already held ${mediaVolumeSummaryLocked()}")
|
||||
return
|
||||
}
|
||||
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val request = audioFocusRequest ?: AudioFocusRequest.Builder(
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
|
||||
)
|
||||
.setAudioAttributes(realtimeAudioAttributes)
|
||||
.setAcceptsDelayedFocusGain(false)
|
||||
.setOnAudioFocusChangeListener(audioFocusChangeListener)
|
||||
.build()
|
||||
.also { audioFocusRequest = it }
|
||||
manager.requestAudioFocus(request)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
manager.requestAudioFocus(
|
||||
audioFocusChangeListener,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
|
||||
)
|
||||
}
|
||||
audioFocusHeld = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM audio focus result=$result held=$audioFocusHeld ${mediaVolumeSummaryLocked()}",
|
||||
)
|
||||
}
|
||||
|
||||
private fun abandonAudioFocusLocked() {
|
||||
val manager = audioManager ?: return
|
||||
if (!audioFocusHeld) return
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let { manager.abandonAudioFocusRequest(it) }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
manager.abandonAudioFocus(audioFocusChangeListener)
|
||||
}
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Realtime PCM audio focus abandon failed: ${it.message}")
|
||||
}
|
||||
audioFocusHeld = false
|
||||
}
|
||||
|
||||
private fun mediaVolumeSummaryLocked(): String {
|
||||
val manager = audioManager ?: return "mediaVolume=unknown"
|
||||
val volume = runCatching { manager.getStreamVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
val maxVolume = runCatching { manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
val musicActive = runCatching { manager.isMusicActive }.getOrNull()
|
||||
return "mediaVolume=${volume ?: "?"}/${maxVolume ?: "?"} musicActive=${musicActive ?: "?"}"
|
||||
}
|
||||
|
||||
private fun updateUnderrunCursorLocked(track: AudioTrack) {
|
||||
val underrunCount = readUnderrunCount(track)
|
||||
if (underrunCount > lastUnderrunCount) {
|
||||
lastUnderrunCount = underrunCount
|
||||
}
|
||||
}
|
||||
|
||||
private fun readUnderrunCount(track: AudioTrack): Int =
|
||||
try { track.underrunCount } catch (_: Exception) { lastUnderrunCount }
|
||||
|
||||
private fun remainingPlaybackMsLocked(now: Long, cushionMs: Long): Long {
|
||||
if (audioTrack == null) return 0L
|
||||
if (!playbackStarted) {
|
||||
return RealtimePcmBufferPolicy.durationMsForBytes(
|
||||
bytes = pendingStartBytes,
|
||||
sampleRate = currentSampleRate,
|
||||
) + cushionMs.coerceAtLeast(0L)
|
||||
}
|
||||
return (estimatedPlaybackEndAtMs - now + cushionMs).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
private fun computePcm16LeRms(pcm: ByteArray): Float {
|
||||
val usable = pcm.size - (pcm.size % 2)
|
||||
if (usable <= 0) return 0f
|
||||
|
||||
var sumSquares = 0.0
|
||||
var samples = 0
|
||||
var index = 0
|
||||
while (index < usable) {
|
||||
val low = pcm[index].toInt() and 0xff
|
||||
val high = pcm[index + 1].toInt()
|
||||
val sample = ((high shl 8) or low).toShort().toInt()
|
||||
val normalized = sample / Short.MAX_VALUE.toDouble()
|
||||
sumSquares += normalized * normalized
|
||||
samples++
|
||||
index += 2
|
||||
}
|
||||
if (samples == 0) return 0f
|
||||
|
||||
val rms = sqrt(sumSquares / samples)
|
||||
val lifted = sqrt((rms / 0.28).coerceIn(0.0, 1.0))
|
||||
return if (lifted.isNaN() || lifted.isInfinite()) 0f else lifted.toFloat().coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RealtimePcmPlayer"
|
||||
private const val DEFAULT_DRAIN_CUSHION_MS = 250L
|
||||
private const val PLAYBACK_GAP_DIAGNOSTIC_THROTTLE_MS = 5_000L
|
||||
private const val MUTED_VOLUME_DIAGNOSTIC_THROTTLE_MS = 10_000L
|
||||
private const val ADAPTIVE_PREBUFFER_STEP_MS = 240L
|
||||
private const val ADAPTIVE_PREBUFFER_DECAY_MS = 120L
|
||||
private const val HEAD_POSITION_LOG_THROTTLE_MS = 1_000L
|
||||
private const val STUCK_CURSOR_DIAGNOSTIC_MS = 1_200L
|
||||
private const val BYTES_PER_FRAME = 2 // mono 16-bit PCM
|
||||
private const val MAX_AMP_QUEUE = 1_024
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock-free value snapshot of realtime playback progress, consumed by the
|
||||
* [com.hermesandroid.relay.viewmodel.VoiceViewModel] first-frame watchdog and
|
||||
* drain cross-check.
|
||||
*/
|
||||
data class RealtimePlaybackSnapshot(
|
||||
val active: Boolean,
|
||||
val playbackStarted: Boolean,
|
||||
val headFrames: Int,
|
||||
val framesWritten: Long,
|
||||
val sampleRate: Int,
|
||||
val playStatePlaying: Boolean,
|
||||
val startedAtElapsedMs: Long,
|
||||
)
|
||||
|
||||
/** A written PCM chunk's RMS amplitude tagged with the frame it finishes at. */
|
||||
internal data class FrameAmp(val endFrame: Long, val rms: Float)
|
||||
|
||||
/**
|
||||
* Returns the amplitude of the first chunk that has not finished playing
|
||||
* ([FrameAmp.endFrame] > [headFrames]) — i.e. the audio at the cursor right now.
|
||||
* 0 when the queue is empty or fully drained. Pure for unit testing.
|
||||
*/
|
||||
internal fun amplitudeAtHead(queue: List<FrameAmp>, headFrames: Long): Float {
|
||||
for (entry in queue) {
|
||||
if (entry.endFrame > headFrames) return entry.rms
|
||||
}
|
||||
return 0f
|
||||
}
|
||||
|
||||
internal data class RealtimePcmStartDecision(
|
||||
val shouldStart: Boolean,
|
||||
val bufferedMs: Long,
|
||||
val reason: String,
|
||||
)
|
||||
|
||||
internal object RealtimePcmBufferPolicy {
|
||||
// Realtime voice is latency-sensitive: the provider streams PCM at (or faster
|
||||
// than) realtime, so the start prebuffer only needs to cover network jitter,
|
||||
// not the whole turn. The large [STREAM_BUFFER_MS] AudioTrack buffer absorbs
|
||||
// bursts *after* playback starts; the start thresholds just decide when the
|
||||
// very first sample is allowed to leave the queue.
|
||||
//
|
||||
// A short turn whose audio arrives faster than realtime used to satisfy
|
||||
// neither the (2.4s) prebuffer nor the (1.2s) max-wait, so it never started
|
||||
// mid-stream and depended entirely on the end-of-turn flush. Lowering these
|
||||
// lets streaming start on the first few chunks while keeping enough cushion
|
||||
// to ride out jitter.
|
||||
const val STARTUP_PREROLL_MS = 120L
|
||||
const val START_PREBUFFER_MS = 320L
|
||||
const val MIN_PREBUFFER_MS = 160L
|
||||
const val MAX_PREBUFFER_WAIT_MS = 280L
|
||||
const val MAX_ADAPTIVE_START_PREBUFFER_MS = 1_200L
|
||||
// Keep the AudioTrack buffer modest. A multi-second buffer gets routed to
|
||||
// Samsung's "deep buffer" output mixer, whose thread is suspended at rest and
|
||||
// cold-starts very slowly — the hardware cursor (playbackHeadPosition) stays
|
||||
// pinned at 0 for ~2-5s after play() even though playState=PLAYING, focus is
|
||||
// held and volume is up. That parked window is the inaudible first/short
|
||||
// turn. A sub-second buffer keeps playback on the primary (fast) mixer path,
|
||||
// which begins pulling immediately. The ~700ms still absorbs normal network
|
||||
// jitter; longer provider gaps (tool calls) underrun-and-resume regardless of
|
||||
// buffer size and are handled by notePlaybackGapLocked.
|
||||
const val STREAM_BUFFER_MS = 700L
|
||||
const val UNDERFLOW_GRACE_MS = 180L
|
||||
|
||||
fun streamBufferSize(minBufferBytes: Int, sampleRate: Int): Int {
|
||||
val target = bytesForDurationMs(sampleRate, STREAM_BUFFER_MS)
|
||||
return max(minBufferBytes, target)
|
||||
}
|
||||
|
||||
fun startDecision(
|
||||
pendingBytes: Int,
|
||||
sampleRate: Int,
|
||||
waitedMs: Long,
|
||||
force: Boolean,
|
||||
startPrebufferMs: Long = START_PREBUFFER_MS,
|
||||
): RealtimePcmStartDecision {
|
||||
val bufferedMs = durationMsForBytes(pendingBytes, sampleRate)
|
||||
val targetPrebufferMs = startPrebufferMs.coerceIn(
|
||||
START_PREBUFFER_MS,
|
||||
MAX_ADAPTIVE_START_PREBUFFER_MS,
|
||||
)
|
||||
val reason = when {
|
||||
force && pendingBytes > 0 -> "flush"
|
||||
bufferedMs >= targetPrebufferMs -> "prebuffer"
|
||||
bufferedMs >= MIN_PREBUFFER_MS && waitedMs >= MAX_PREBUFFER_WAIT_MS -> "max-wait"
|
||||
else -> "buffering"
|
||||
}
|
||||
return RealtimePcmStartDecision(
|
||||
shouldStart = reason != "buffering",
|
||||
bufferedMs = bufferedMs,
|
||||
reason = reason,
|
||||
)
|
||||
}
|
||||
|
||||
fun durationMsForBytes(bytes: Int, sampleRate: Int): Long {
|
||||
if (bytes <= 0 || sampleRate <= 0) return 0L
|
||||
return ((bytes / 2.0) / sampleRate * 1000.0)
|
||||
.toLong()
|
||||
.coerceAtLeast(1L)
|
||||
}
|
||||
|
||||
fun bytesForDurationMs(sampleRate: Int, durationMs: Long): Int {
|
||||
if (sampleRate <= 0 || durationMs <= 0L) return 0
|
||||
return (sampleRate * 2L * durationMs / 1000L).toInt()
|
||||
}
|
||||
|
||||
fun startupPrerollBytes(sampleRate: Int): Int =
|
||||
bytesForDurationMs(sampleRate, STARTUP_PREROLL_MS)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.hermesandroid.relay.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Captures mono 16-bit PCM for realtime voice test runs.
|
||||
*
|
||||
* [capture] grabs a fixed short window (legacy/back-compat). [captureUntilStopped]
|
||||
* records open-endedly until [requestStop] is called (tap-to-stop), which is what
|
||||
* the Mic demo needs to capture a full spoken sentence.
|
||||
*/
|
||||
class RealtimePcmRecorder(
|
||||
private val sampleRate: Int = 16_000,
|
||||
) {
|
||||
@Volatile
|
||||
private var capturing = false
|
||||
|
||||
/** Signals an in-flight [captureUntilStopped] to finish and return. */
|
||||
fun requestStop() {
|
||||
capturing = false
|
||||
}
|
||||
|
||||
val isCapturing: Boolean
|
||||
get() = capturing
|
||||
|
||||
/**
|
||||
* Records until [requestStop] is called or [maxDurationMs] elapses, invoking
|
||||
* [onLevel] (0..1 RMS) per read so the UI can show a live input waveform.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun captureUntilStopped(
|
||||
maxDurationMs: Long = 15_000,
|
||||
onLevel: ((Float) -> Unit)? = null,
|
||||
): ByteArray = withContext(Dispatchers.IO) {
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
val maxBytes = ((sampleRate * maxDurationMs) / 1000L * 2L).toInt()
|
||||
|
||||
val recorder = AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer)
|
||||
.build()
|
||||
|
||||
val out = ByteArrayOutputStream(minBuffer * 4)
|
||||
val buffer = ByteArray(minBuffer)
|
||||
capturing = true
|
||||
try {
|
||||
recorder.startRecording()
|
||||
while (capturing && out.size() < maxBytes) {
|
||||
val read = recorder.read(buffer, 0, buffer.size)
|
||||
if (read > 0) {
|
||||
out.write(buffer, 0, read)
|
||||
onLevel?.invoke(rms16Le(buffer, read))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
capturing = false
|
||||
try { recorder.stop() } catch (_: Exception) { }
|
||||
recorder.release()
|
||||
}
|
||||
out.toByteArray()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun capture(durationMs: Long = 800): ByteArray = withContext(Dispatchers.IO) {
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
val targetBytes = ((sampleRate * durationMs) / 1000L * 2L)
|
||||
.toInt()
|
||||
.coerceAtLeast(minBuffer)
|
||||
|
||||
val recorder = AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer)
|
||||
.build()
|
||||
|
||||
val out = ByteArrayOutputStream(targetBytes)
|
||||
val buffer = ByteArray(minBuffer)
|
||||
try {
|
||||
recorder.startRecording()
|
||||
while (out.size() < targetBytes) {
|
||||
val read = recorder.read(
|
||||
buffer,
|
||||
0,
|
||||
min(buffer.size, targetBytes - out.size()),
|
||||
)
|
||||
if (read > 0) {
|
||||
out.write(buffer, 0, read)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { recorder.stop() } catch (_: Exception) { }
|
||||
recorder.release()
|
||||
}
|
||||
out.toByteArray()
|
||||
}
|
||||
|
||||
private fun rms16Le(buffer: ByteArray, length: Int): Float {
|
||||
val usable = length - (length % 2)
|
||||
if (usable <= 0) return 0f
|
||||
var sum = 0.0
|
||||
var i = 0
|
||||
while (i < usable) {
|
||||
val low = buffer[i].toInt() and 0xff
|
||||
val high = buffer[i + 1].toInt()
|
||||
val sample = ((high shl 8) or low).toShort().toInt() / 32768.0
|
||||
sum += sample * sample
|
||||
i += 2
|
||||
}
|
||||
val rms = sqrt(sum / (usable / 2))
|
||||
return sqrt((rms / 0.28).coerceIn(0.0, 1.0)).toFloat()
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -75,18 +76,41 @@ class VoicePlayer(
|
||||
// polling the player from arbitrary threads.
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
|
||||
// Mirrors ExoPlayer.getMediaItemCount() via the Player.Listener events
|
||||
// that can mutate it (onMediaItemTransition for drain, explicit add/clear
|
||||
// calls for growth). Kept as a StateFlow so awaitCompletion can reactively
|
||||
// wait for queue-drained + idle without polling.
|
||||
// Logical count of media items still owned by this playback turn. ExoPlayer
|
||||
// retains played playlist items after STATE_ENDED, so this cannot mirror
|
||||
// mediaItemCount blindly at end-of-queue.
|
||||
private val _queueCount = MutableStateFlow(0)
|
||||
|
||||
private var visualizer: Visualizer? = null
|
||||
private var visualizerAttached = false
|
||||
|
||||
// Thread-safe mirror of [ExoPlayer.getAudioSessionId]. ExoPlayer is
|
||||
// thread-confined — every accessor (the audioSessionId getter included)
|
||||
// calls verifyApplicationThread() and throws "Player is accessed on the
|
||||
// wrong thread" if touched off the player's construction thread. The
|
||||
// barge-in pipeline reads [audioSessionId] from BargeInListener's
|
||||
// Dispatchers.IO reader coroutine to attach AcousticEchoCanceler, so we
|
||||
// can't expose the raw getter. Instead we cache the id from the
|
||||
// main-thread Media3 callbacks below and serve the getter from this
|
||||
// @Volatile field. (Fixes the legacy-TTS + barge-in crash where the
|
||||
// first sentence played for ~2 syllables before the IO read threw.)
|
||||
@Volatile private var cachedAudioSessionId: Int = 0
|
||||
|
||||
private val exoPlayer: ExoPlayer = exoPlayerFactory(context.applicationContext)
|
||||
|
||||
init {
|
||||
// AnalyticsListener callbacks are delivered on the player's
|
||||
// application (main) thread, so caching the id here is the
|
||||
// authoritative, thread-correct way to track it as Media3 allocates
|
||||
// and reallocates the underlying AudioTrack.
|
||||
exoPlayer.addAnalyticsListener(object : AnalyticsListener {
|
||||
override fun onAudioSessionIdChanged(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioSessionId: Int,
|
||||
) {
|
||||
cachedAudioSessionId = audioSessionId
|
||||
}
|
||||
})
|
||||
exoPlayer.addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
_isPlaying.value = isPlaying
|
||||
@@ -95,8 +119,16 @@ class VoicePlayer(
|
||||
// actually begins — the audio session id is stable from
|
||||
// player construction on Media3 1.x but some OEM pipelines
|
||||
// don't allocate the track until playback starts.
|
||||
if (isPlaying && !visualizerAttached) {
|
||||
attachVisualizer(exoPlayer.audioSessionId)
|
||||
if (isPlaying) {
|
||||
// Belt-and-braces with the analytics listener above: this
|
||||
// runs on the main thread too, so reading the getter here
|
||||
// is safe and guarantees the cache is warm by the time
|
||||
// playback is audible (and thus by the time barge-in
|
||||
// starts its IO reader).
|
||||
cachedAudioSessionId = exoPlayer.audioSessionId
|
||||
if (!visualizerAttached) {
|
||||
attachVisualizer(cachedAudioSessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,10 +142,21 @@ class VoicePlayer(
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
if (state == Player.STATE_ENDED || state == Player.STATE_IDLE) {
|
||||
// ENDED fires when the full queue has been consumed;
|
||||
// sync queueCount so awaitCompletion can release.
|
||||
_queueCount.value = exoPlayer.mediaItemCount
|
||||
when (state) {
|
||||
Player.STATE_ENDED -> {
|
||||
// Media3 keeps consumed playlist entries around. Clear
|
||||
// them here so awaitCompletion observes a true drain and
|
||||
// voice mode can leave Speaking when the last TTS chunk ends.
|
||||
exoPlayer.clearMediaItems()
|
||||
_queueCount.value = 0
|
||||
_isPlaying.value = false
|
||||
_amplitude.value = 0f
|
||||
}
|
||||
Player.STATE_IDLE -> {
|
||||
if (exoPlayer.mediaItemCount == 0) {
|
||||
_queueCount.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -145,7 +188,7 @@ class VoicePlayer(
|
||||
*
|
||||
* **Semantic change from the old MediaPlayer implementation.** Previously
|
||||
* this returned when the *current file* completed. Now it returns when
|
||||
* the entire queue has been consumed — i.e. `mediaItemCount == 0 &&
|
||||
* the entire logical queue has been consumed — i.e. `_queueCount == 0 &&
|
||||
* !isPlaying`. This matches the gapless-playback model where adjacent
|
||||
* sentences play back-to-back from the same ExoPlayer, and it's exactly
|
||||
* what the V4 prefetch pipelining rewrite needs (synth worker can enqueue
|
||||
@@ -201,13 +244,20 @@ class VoicePlayer(
|
||||
* poll this property briefly rather than assume it's hot-ready at
|
||||
* [VoicePlayer] construction time.
|
||||
*
|
||||
* Exposed read-only. Internally the same id drives the Visualizer
|
||||
* attach logic in [attachVisualizer]; B4 reads it via a provider
|
||||
* lambda so the listener can re-check across the 1 s poll window
|
||||
* without holding a stale reference.
|
||||
* **Thread-safe.** Backed by [cachedAudioSessionId] rather than the raw
|
||||
* `ExoPlayer.getAudioSessionId()` getter, because ExoPlayer is
|
||||
* thread-confined and [BargeInListener] reads this from its
|
||||
* `Dispatchers.IO` reader coroutine. Reading the raw getter off-main
|
||||
* throws `IllegalStateException: Player is accessed on the wrong thread`.
|
||||
* The cache is populated from main-thread Media3 callbacks (the
|
||||
* [AnalyticsListener.onAudioSessionIdChanged] hook and `onIsPlayingChanged`).
|
||||
*
|
||||
* Exposed read-only. B4 reads it via a provider lambda so the listener
|
||||
* can re-check across the 1 s poll window without holding a stale
|
||||
* reference.
|
||||
*/
|
||||
val audioSessionId: Int
|
||||
get() = exoPlayer.audioSessionId
|
||||
get() = cachedAudioSessionId
|
||||
|
||||
/**
|
||||
* Set the playback volume of the underlying ExoPlayer.
|
||||
|
||||
@@ -2,60 +2,47 @@ package com.hermesandroid.relay.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Captures the user's voice into an `.m4a` (AAC-in-MP4) file for V2a voice
|
||||
* mode. The relay's `/voice/transcribe` endpoint feeds this to whisper-1 via
|
||||
* OpenAI, which accepts m4a/mp4 natively.
|
||||
* Captures the user's voice as 16 kHz mono PCM and writes a `.wav` file for
|
||||
* the relay STT endpoint. The raw PCM is retained for the server-mediated
|
||||
* `/voice/realtime/{session}` path so the main voice UI can send the same utterance
|
||||
* through the realtime websocket without opening a second microphone stream.
|
||||
*
|
||||
* A live [amplitude] flow is exposed for the UI (MorphingSphere + meter) —
|
||||
* driven by polling `MediaRecorder.maxAmplitude` every ~16 ms. The polling
|
||||
* coroutine runs on the caller-supplied [scope] so it dies with the owning
|
||||
* ViewModel.
|
||||
*
|
||||
* One recorder instance owns at most one active recording at a time. Calling
|
||||
* [startRecording] again while a recording is in flight will stop the
|
||||
* previous one first. [stopRecording] is safe to call when nothing is
|
||||
* running (it just returns the last file, or throws if there never was one).
|
||||
* A live [amplitude] flow is exposed for the UI (MorphingSphere + meter). The
|
||||
* value is computed from the same PCM frames that are written to disk, which
|
||||
* keeps legacy STT fallback and realtime voice testing on a single capture
|
||||
* path.
|
||||
*/
|
||||
class VoiceRecorder(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
@Suppress("UNUSED_PARAMETER") private val scope: kotlinx.coroutines.CoroutineScope,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "VoiceRecorder"
|
||||
private const val SAMPLE_RATE = 16_000
|
||||
private const val BIT_RATE = 64_000
|
||||
private const val AMPLITUDE_POLL_MS = 16L
|
||||
private const val BYTES_PER_SAMPLE = 2
|
||||
private const val CHANNEL_COUNT = 1
|
||||
private const val MAX_AMPLITUDE_SHORT = 32_767f
|
||||
private const val MAX_PCM_BYTES = 25 * 1024 * 1024
|
||||
|
||||
// Perceptual amplitude mapping constants. Raw PCM peak values from
|
||||
// MediaRecorder.maxAmplitude for a phone at arm's length:
|
||||
// silence / ambient : 100..500 (≤0.015 of max)
|
||||
// quiet speech : 500..3000 (0.015..0.09)
|
||||
// normal speech : 3000..8000 (0.09..0.24)
|
||||
// loud speech : 8000..18000 (0.24..0.55)
|
||||
// shout / clipping : 18000..32767 (0.55..1.0)
|
||||
//
|
||||
// Linear 0..1 puts normal conversation between 0.09 and 0.24 — the
|
||||
// meter barely moves. Subtract a noise floor, rescale into the
|
||||
// speech-ceiling window, then apply a sqrt curve so quiet speech
|
||||
// still registers visually without drowning loud speech at the top.
|
||||
// Keep the perceptual curve from the previous MediaRecorder-backed
|
||||
// implementation so the on-screen meter feels the same.
|
||||
private const val NOISE_FLOOR = 0.01f
|
||||
private const val SPEECH_CEILING = 0.35f
|
||||
}
|
||||
@@ -63,162 +50,231 @@ class VoiceRecorder(
|
||||
private val _amplitude = MutableStateFlow(0f)
|
||||
val amplitude: StateFlow<Float> = _amplitude.asStateFlow()
|
||||
|
||||
private var mediaRecorder: MediaRecorder? = null
|
||||
val sampleRate: Int get() = SAMPLE_RATE
|
||||
|
||||
private val bufferLock = Any()
|
||||
private val stopRequested = AtomicBoolean(false)
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var currentOutputFile: File? = null
|
||||
private var pollJob: Job? = null
|
||||
private var readThread: Thread? = null
|
||||
private var readDone: CountDownLatch? = null
|
||||
private var pcmBuffer = ByteArrayOutputStream(SAMPLE_RATE * BYTES_PER_SAMPLE * 4)
|
||||
private var lastPcmBytes: ByteArray = ByteArray(0)
|
||||
|
||||
/**
|
||||
* Begin a new recording. Returns the output [File] that will receive the
|
||||
* audio once [stopRecording] is called. Throws on permission failure or
|
||||
* encoder init failure — callers should catch and surface to the UI.
|
||||
* Begin a new recording. Returns the output [File] that will contain WAV
|
||||
* audio once [stopRecording] is called.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startRecording(): File {
|
||||
// Defensive: if a recording is somehow still running, tear it down
|
||||
// before starting a new one. MediaRecorder transitions are strict.
|
||||
if (mediaRecorder != null) {
|
||||
Log.w(TAG, "startRecording called while another recording is in flight — stopping it first")
|
||||
if (audioRecord != null) {
|
||||
Log.w(TAG, "startRecording called while another recording is in flight; stopping it first")
|
||||
try {
|
||||
stopRecording()
|
||||
} catch (_: Exception) {
|
||||
// Swallow — we're about to overwrite state anyway.
|
||||
releaseRecorder()
|
||||
}
|
||||
}
|
||||
|
||||
val outFile = File(context.cacheDir, "voice_rec_${System.currentTimeMillis()}.m4a")
|
||||
currentOutputFile = outFile
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(SAMPLE_RATE / 10 * BYTES_PER_SAMPLE)
|
||||
|
||||
val recorder = buildRecorder()
|
||||
try {
|
||||
recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
recorder.setAudioSamplingRate(SAMPLE_RATE)
|
||||
recorder.setAudioEncodingBitRate(BIT_RATE)
|
||||
recorder.setAudioChannels(1)
|
||||
recorder.setOutputFile(outFile.absolutePath)
|
||||
recorder.prepare()
|
||||
recorder.start()
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.e(TAG, "MediaRecorder failed to start: ${e.message}")
|
||||
try {
|
||||
recorder.reset()
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
val outFile = File(context.cacheDir, "voice_rec_${System.currentTimeMillis()}.wav")
|
||||
currentOutputFile = outFile
|
||||
synchronized(bufferLock) {
|
||||
pcmBuffer = ByteArrayOutputStream(SAMPLE_RATE * BYTES_PER_SAMPLE * 4)
|
||||
lastPcmBytes = ByteArray(0)
|
||||
}
|
||||
stopRequested.set(false)
|
||||
_amplitude.value = 0f
|
||||
|
||||
val recorder = AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(SAMPLE_RATE)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer * 2)
|
||||
.build()
|
||||
|
||||
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||
recorder.release()
|
||||
mediaRecorder = null
|
||||
currentOutputFile = null
|
||||
throw e
|
||||
throw IllegalStateException("AudioRecord failed to initialize")
|
||||
}
|
||||
|
||||
try {
|
||||
recorder.startRecording()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "MediaRecorder setup failed: ${e.message}")
|
||||
try {
|
||||
recorder.reset()
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
recorder.release()
|
||||
mediaRecorder = null
|
||||
currentOutputFile = null
|
||||
throw e
|
||||
}
|
||||
|
||||
mediaRecorder = recorder
|
||||
startAmplitudePolling()
|
||||
audioRecord = recorder
|
||||
val done = CountDownLatch(1)
|
||||
readDone = done
|
||||
readThread = Thread(
|
||||
{
|
||||
readPcmLoop(recorder, minBuffer)
|
||||
done.countDown()
|
||||
},
|
||||
"HermesVoiceRecorder",
|
||||
).also { it.start() }
|
||||
return outFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the active recording, flush the encoder, and return the completed
|
||||
* output [File]. Safe to call when nothing is recording — in that case
|
||||
* it returns the last file produced, or throws if there never was one.
|
||||
* Stop the active recording, write the WAV container, and return it.
|
||||
*/
|
||||
fun stopRecording(): File {
|
||||
val file = currentOutputFile
|
||||
?: throw IllegalStateException("stopRecording called with no active recording")
|
||||
|
||||
stopAmplitudePolling()
|
||||
|
||||
val recorder = mediaRecorder
|
||||
if (recorder != null) {
|
||||
val record = audioRecord
|
||||
stopRequested.set(true)
|
||||
if (record != null) {
|
||||
try {
|
||||
recorder.stop()
|
||||
record.stop()
|
||||
} catch (e: IllegalStateException) {
|
||||
// MediaRecorder.stop throws if called before any audio was
|
||||
// captured (sub-300ms recordings). Treat as recoverable —
|
||||
// the output file may be 0 bytes but the caller can check.
|
||||
Log.w(TAG, "MediaRecorder.stop threw — recording may be empty: ${e.message}")
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "MediaRecorder.stop runtime error: ${e.message}")
|
||||
} finally {
|
||||
releaseRecorder()
|
||||
Log.w(TAG, "AudioRecord.stop threw; recording may be empty: ${e.message}")
|
||||
}
|
||||
}
|
||||
readDone?.await(1, TimeUnit.SECONDS)
|
||||
releaseRecorder()
|
||||
|
||||
val pcm = synchronized(bufferLock) {
|
||||
pcmBuffer.toByteArray().also { lastPcmBytes = it }
|
||||
}
|
||||
writeWav(file, pcm)
|
||||
_amplitude.value = 0f
|
||||
return file
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a recording is currently active. Cheap — just checks whether
|
||||
* we have a live [MediaRecorder] reference.
|
||||
*/
|
||||
fun isRecording(): Boolean = mediaRecorder != null
|
||||
fun isRecording(): Boolean = audioRecord != null && !stopRequested.get()
|
||||
|
||||
fun lastPcmBytes(): ByteArray = synchronized(bufferLock) {
|
||||
lastPcmBytes.copyOf()
|
||||
}
|
||||
|
||||
/**
|
||||
* Release any recorder resources without returning a file. Safe fallback
|
||||
* for error paths where the output file is known-invalid.
|
||||
* Release any recorder resources without returning a file.
|
||||
*/
|
||||
fun cancel() {
|
||||
stopAmplitudePolling()
|
||||
mediaRecorder?.let { r ->
|
||||
try {
|
||||
r.stop()
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
stopRequested.set(true)
|
||||
audioRecord?.let { record ->
|
||||
try { record.stop() } catch (_: Exception) { }
|
||||
}
|
||||
readDone?.await(500, TimeUnit.MILLISECONDS)
|
||||
releaseRecorder()
|
||||
currentOutputFile?.let { f ->
|
||||
try { f.delete() } catch (_: Exception) { /* ignore */ }
|
||||
currentOutputFile?.let { file ->
|
||||
try { file.delete() } catch (_: Exception) { }
|
||||
}
|
||||
currentOutputFile = null
|
||||
synchronized(bufferLock) {
|
||||
pcmBuffer.reset()
|
||||
lastPcmBytes = ByteArray(0)
|
||||
}
|
||||
_amplitude.value = 0f
|
||||
}
|
||||
|
||||
private fun releaseRecorder() {
|
||||
mediaRecorder?.let { r ->
|
||||
try { r.reset() } catch (_: Exception) { /* ignore */ }
|
||||
try { r.release() } catch (_: Exception) { /* ignore */ }
|
||||
}
|
||||
mediaRecorder = null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun buildRecorder(): MediaRecorder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}
|
||||
|
||||
private fun startAmplitudePolling() {
|
||||
pollJob?.cancel()
|
||||
pollJob = scope.launch(Dispatchers.Default) {
|
||||
while (isActive) {
|
||||
val recorder = mediaRecorder ?: break
|
||||
val raw = try {
|
||||
recorder.maxAmplitude
|
||||
} catch (e: IllegalStateException) {
|
||||
// Recorder torn down under us — exit quietly.
|
||||
break
|
||||
private fun readPcmLoop(record: AudioRecord, minBuffer: Int) {
|
||||
val buffer = ByteArray(minBuffer)
|
||||
while (!stopRequested.get()) {
|
||||
val read = try {
|
||||
record.read(buffer, 0, buffer.size)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "AudioRecord.read failed: ${e.message}")
|
||||
break
|
||||
}
|
||||
if (read > 0) {
|
||||
synchronized(bufferLock) {
|
||||
if (pcmBuffer.size() + read <= MAX_PCM_BYTES) {
|
||||
pcmBuffer.write(buffer, 0, read)
|
||||
} else {
|
||||
stopRequested.set(true)
|
||||
}
|
||||
}
|
||||
val raw01 = (raw.toFloat() / MAX_AMPLITUDE_SHORT).coerceIn(0f, 1f)
|
||||
val floored = ((raw01 - NOISE_FLOOR) / (SPEECH_CEILING - NOISE_FLOOR))
|
||||
.coerceIn(0f, 1f)
|
||||
_amplitude.value = sqrt(floored)
|
||||
delay(AMPLITUDE_POLL_MS)
|
||||
updateAmplitude(buffer, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopAmplitudePolling() {
|
||||
pollJob?.cancel()
|
||||
pollJob = null
|
||||
private fun updateAmplitude(buffer: ByteArray, read: Int) {
|
||||
var peak = 0
|
||||
var index = 0
|
||||
val usable = read - (read % BYTES_PER_SAMPLE)
|
||||
while (index < usable) {
|
||||
val low = buffer[index].toInt() and 0xff
|
||||
val high = buffer[index + 1].toInt()
|
||||
val sample = (high shl 8) or low
|
||||
val abs = kotlin.math.abs(sample.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()))
|
||||
if (abs > peak) peak = abs
|
||||
index += BYTES_PER_SAMPLE
|
||||
}
|
||||
val raw01 = (peak.toFloat() / MAX_AMPLITUDE_SHORT).coerceIn(0f, 1f)
|
||||
val floored = ((raw01 - NOISE_FLOOR) / (SPEECH_CEILING - NOISE_FLOOR))
|
||||
.coerceIn(0f, 1f)
|
||||
_amplitude.value = sqrt(floored)
|
||||
}
|
||||
|
||||
private fun releaseRecorder() {
|
||||
audioRecord?.let { record ->
|
||||
try { record.release() } catch (_: Exception) { }
|
||||
}
|
||||
audioRecord = null
|
||||
readThread = null
|
||||
readDone = null
|
||||
}
|
||||
|
||||
private fun writeWav(file: File, pcm: ByteArray) {
|
||||
try {
|
||||
file.outputStream().use { out ->
|
||||
out.write(wavHeader(pcm.size))
|
||||
out.write(pcm)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw IOException("Failed to write WAV recording: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun wavHeader(pcmBytes: Int): ByteArray {
|
||||
val totalDataLen = pcmBytes + 36
|
||||
val byteRate = SAMPLE_RATE * CHANNEL_COUNT * BYTES_PER_SAMPLE
|
||||
return ByteArray(44).also { header ->
|
||||
fun ascii(offset: Int, value: String) {
|
||||
value.encodeToByteArray().copyInto(header, offset)
|
||||
}
|
||||
fun leInt(offset: Int, value: Int) {
|
||||
header[offset] = (value and 0xff).toByte()
|
||||
header[offset + 1] = ((value shr 8) and 0xff).toByte()
|
||||
header[offset + 2] = ((value shr 16) and 0xff).toByte()
|
||||
header[offset + 3] = ((value shr 24) and 0xff).toByte()
|
||||
}
|
||||
fun leShort(offset: Int, value: Int) {
|
||||
header[offset] = (value and 0xff).toByte()
|
||||
header[offset + 1] = ((value shr 8) and 0xff).toByte()
|
||||
}
|
||||
|
||||
ascii(0, "RIFF")
|
||||
leInt(4, totalDataLen)
|
||||
ascii(8, "WAVE")
|
||||
ascii(12, "fmt ")
|
||||
leInt(16, 16)
|
||||
leShort(20, 1)
|
||||
leShort(22, CHANNEL_COUNT)
|
||||
leInt(24, SAMPLE_RATE)
|
||||
leInt(28, byteRate)
|
||||
leShort(32, CHANNEL_COUNT * BYTES_PER_SAMPLE)
|
||||
leShort(34, 16)
|
||||
ascii(36, "data")
|
||||
leInt(40, pcmBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,24 +2,31 @@ package com.hermesandroid.relay.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
@@ -57,17 +64,145 @@ sealed class AuthState {
|
||||
class AuthManager(
|
||||
private val context: Context,
|
||||
private val multiplexer: ChannelMultiplexer,
|
||||
private val scope: CoroutineScope
|
||||
private val scope: CoroutineScope,
|
||||
/**
|
||||
* Multi-connection: the id of the [com.hermesandroid.relay.data.Connection]
|
||||
* this AuthManager is bound to. Drives which EncryptedSharedPreferences
|
||||
* file the underlying [SessionTokenStore] reads/writes.
|
||||
*
|
||||
* Defaults to [CONNECTION_ID_LEGACY] so the pre-multi-connection call site
|
||||
* in `ConnectionViewModel` still compiles. Worker B removes the default
|
||||
* and passes a real connection id when they wire the active connection
|
||||
* through.
|
||||
*/
|
||||
private val connectionId: String = CONNECTION_ID_LEGACY,
|
||||
/**
|
||||
* Exact EncryptedSharedPreferences filename for this connection. New
|
||||
* connections use the deterministic id-derived name, but the migrated
|
||||
* legacy connection intentionally keeps [Connection.LEGACY_TOKEN_STORE_KEY].
|
||||
*/
|
||||
private val tokenStoreKey: String? = null,
|
||||
) : ChannelMultiplexer.ChannelHandler {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AuthManager"
|
||||
private const val KEY_SESSION_TOKEN = "session_token"
|
||||
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||
private const val KEY_DEVICE_ID = "device_id"
|
||||
private const val KEY_API_KEY = "api_server_key"
|
||||
private const val KEY_PAIRED_META = "paired_session_meta_json"
|
||||
private const val PAIRING_CODE_LENGTH = 6
|
||||
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
|
||||
|
||||
/**
|
||||
* Sentinel [connectionId] meaning "bind this AuthManager to the legacy
|
||||
* single-connection EncryptedSharedPreferences file
|
||||
* ([Connection.LEGACY_TOKEN_STORE_KEY])". Used as the default ctor arg
|
||||
* so existing call sites don't need to change until Worker B threads
|
||||
* a real connection id through.
|
||||
*/
|
||||
const val CONNECTION_ID_LEGACY: String = "legacy"
|
||||
|
||||
internal fun shouldPreservePairedSessionOnAuthFail(
|
||||
currentState: AuthState,
|
||||
rawReason: String,
|
||||
): Boolean {
|
||||
val lower = rawReason.lowercase()
|
||||
return currentState is AuthState.Paired &&
|
||||
"timeout" in lower &&
|
||||
("auth" in lower || "authentication" in lower)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort read of a connection's stored device id without making
|
||||
* that connection active. Used by the connection removal path so it
|
||||
* can delete the per-device route list before deleting the token
|
||||
* store backing file.
|
||||
*/
|
||||
suspend fun readStoredDeviceId(context: Context, tokenStoreKey: String): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val appContext = context.applicationContext
|
||||
val primary = KeystoreTokenStore.tryCreate(appContext, tokenStoreKey)
|
||||
?: LegacyEncryptedPrefsTokenStore(appContext, tokenStoreKey)
|
||||
primary.getString(KEY_DEVICE_ID)
|
||||
?: if (tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
|
||||
runCatching {
|
||||
LegacyEncryptedPrefsTokenStore(appContext).getString(KEY_DEVICE_ID)
|
||||
}.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `profiles` array from an `auth.ok` payload into a list of
|
||||
* [Profile] entries. Extracted out of [handleAuthOk] so it's
|
||||
* exercisable from a pure JVM unit test without constructing an
|
||||
* Android [Context] / [kotlinx.coroutines.CoroutineScope].
|
||||
*
|
||||
* Defensive rules, in order:
|
||||
* - Non-[JsonObject] entries (stray strings, numbers) are dropped.
|
||||
* - An entry missing `name` is dropped — the picker has no label
|
||||
* to render for it.
|
||||
* - `model` defaults to `"unknown"` so a profile without a model
|
||||
* still renders as a selectable chip (server misconfiguration,
|
||||
* but we don't want to silently drop the only profile).
|
||||
* - `description` defaults to `""`.
|
||||
* - `system_message` is passed through as-is, including JSON `null`.
|
||||
* A null or missing value means "this profile has no SOUL.md on
|
||||
* disk — fall back to the personality/default system prompt at
|
||||
* send time". Kept separate from an empty string so ChatViewModel
|
||||
* can cleanly detect "no override" via `systemMessage?.isNotBlank()`.
|
||||
* - `gateway_running`, `has_soul`, `skill_count` (v0.7.0 runtime
|
||||
* metadata) are optional on the wire. Missing / malformed values
|
||||
* fall back to `false` / `false` / `0` so older relays stay
|
||||
* compatible and bad server data can't crash the pairing handshake.
|
||||
* - `api_server_*` metadata is optional. When present, it lets the
|
||||
* client route chat through a profile's isolated Hermes API
|
||||
* server without exposing that profile server's key.
|
||||
*/
|
||||
fun parseAgentProfiles(array: JsonArray): List<Profile> {
|
||||
return array.mapNotNull { entry ->
|
||||
val obj = entry as? JsonObject ?: return@mapNotNull null
|
||||
val name = obj["name"]?.jsonPrimitive?.contentOrNull
|
||||
?: return@mapNotNull null
|
||||
val model = obj["model"]?.jsonPrimitive?.contentOrNull
|
||||
?: "unknown"
|
||||
val description = obj["description"]?.jsonPrimitive?.contentOrNull
|
||||
?: ""
|
||||
val systemMessage = obj["system_message"]?.jsonPrimitive?.contentOrNull
|
||||
val gatewayRunning = obj["gateway_running"]
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
val hasSoul = obj["has_soul"]
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
val skillCount = obj["skill_count"]
|
||||
?.jsonPrimitive?.intOrNull ?: 0
|
||||
val apiServerEnabled = obj["api_server_enabled"]
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
val apiServerUrl = obj["api_server_url"]
|
||||
?.jsonPrimitive?.contentOrNull
|
||||
val apiServerHost = obj["api_server_host"]
|
||||
?.jsonPrimitive?.contentOrNull
|
||||
val apiServerPort = obj["api_server_port"]
|
||||
?.jsonPrimitive?.intOrNull
|
||||
val apiServerKeyPresent = obj["api_server_key_present"]
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
Profile(
|
||||
name = name,
|
||||
model = model,
|
||||
description = description,
|
||||
systemMessage = systemMessage,
|
||||
gatewayRunning = gatewayRunning,
|
||||
hasSoul = hasSoul,
|
||||
skillCount = skillCount,
|
||||
apiServerEnabled = apiServerEnabled,
|
||||
apiServerUrl = apiServerUrl,
|
||||
apiServerHost = apiServerHost,
|
||||
apiServerPort = apiServerPort,
|
||||
apiServerKeyPresent = apiServerKeyPresent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
@@ -92,8 +227,19 @@ class AuthManager(
|
||||
return storeMutex.withLock {
|
||||
_store?.let { return it }
|
||||
withContext(Dispatchers.IO) {
|
||||
// Multi-connection: pick the EncryptedSharedPreferences
|
||||
// filename based on the bound connection. The legacy sentinel
|
||||
// keeps the pre-multi-connection install on its original file
|
||||
// so the existing paired device keeps working with no
|
||||
// migration.
|
||||
val prefsName = tokenStoreKey ?: if (connectionId == CONNECTION_ID_LEGACY) {
|
||||
Connection.LEGACY_TOKEN_STORE_KEY
|
||||
} else {
|
||||
Connection.buildTokenStoreKey(connectionId)
|
||||
}
|
||||
val picked: SessionTokenStore =
|
||||
KeystoreTokenStore.tryCreate(context) ?: LegacyEncryptedPrefsTokenStore(context)
|
||||
KeystoreTokenStore.tryCreate(context, prefsName)
|
||||
?: LegacyEncryptedPrefsTokenStore(context, prefsName)
|
||||
migrateFromLegacyIfNeeded(picked)
|
||||
_store = picked
|
||||
picked
|
||||
@@ -109,13 +255,24 @@ class AuthManager(
|
||||
*/
|
||||
private fun migrateFromLegacyIfNeeded(picked: SessionTokenStore) {
|
||||
if (picked is LegacyEncryptedPrefsTokenStore) return
|
||||
// Multi-connection: only the legacy connection inherits from the pre-
|
||||
// multi-connection `hermes_companion_auth` file. A freshly-minted
|
||||
// per-connection store must NOT be seeded from the legacy file or
|
||||
// we'd copy connection 0's token into every new connection.
|
||||
if (connectionId != CONNECTION_ID_LEGACY) return
|
||||
val legacy = try {
|
||||
LegacyEncryptedPrefsTokenStore(context)
|
||||
} catch (_: Exception) {
|
||||
return
|
||||
}
|
||||
|
||||
val keysToMigrate = listOf(KEY_SESSION_TOKEN, KEY_DEVICE_ID, KEY_API_KEY, KEY_PAIRED_META)
|
||||
val keysToMigrate = listOf(
|
||||
KEY_SESSION_TOKEN,
|
||||
KEY_REFRESH_TOKEN,
|
||||
KEY_DEVICE_ID,
|
||||
KEY_API_KEY,
|
||||
KEY_PAIRED_META,
|
||||
)
|
||||
var migrated = false
|
||||
for (k in keysToMigrate) {
|
||||
val existing = legacy.getString(k) ?: continue
|
||||
@@ -207,8 +364,37 @@ class AuthManager(
|
||||
*/
|
||||
private var pendingGrants: Map<String, Long>? = null
|
||||
|
||||
private val _profiles = MutableStateFlow<List<String>>(emptyList())
|
||||
val profiles: StateFlow<List<String>> = _profiles.asStateFlow()
|
||||
/**
|
||||
* Optional endpoint-candidate list from the pairing QR (ADR 24, v3+).
|
||||
* Persisted via [PairingPreferences.setDeviceEndpoints] once the server
|
||||
* confirms the pair in `auth.ok` so the reachability probe + network-aware
|
||||
* switch (Kt-Probe) can read them on subsequent connects.
|
||||
*
|
||||
* `null` means "no endpoint list to persist on this pair" — either a v1/v2
|
||||
* QR hit the legacy path without going through [setPendingEndpoints], or
|
||||
* we're in a session-token refresh where the endpoint list doesn't change.
|
||||
* Either way, we leave the previously-persisted list untouched.
|
||||
*/
|
||||
private var pendingEndpoints: List<EndpointCandidate>? = null
|
||||
|
||||
/**
|
||||
* Server-advertised agent profiles from the `auth.ok` payload's
|
||||
* `profiles` field. Each entry corresponds to a named agent config in
|
||||
* the server's `~/.hermes/config.yaml` (see Hermes's `_load_profiles`).
|
||||
*
|
||||
* This replaces the old `_sessionLabels` field (2026-04-18, Pass 2).
|
||||
* The previous code parsed each entry as a raw [String] via
|
||||
* `it.jsonPrimitive.content`, which blew up silently on the real
|
||||
* object-shaped payload the server actually sends — so the list was
|
||||
* always empty in practice. [parseAgentProfiles] is the structured
|
||||
* replacement.
|
||||
*
|
||||
* Exposed via [com.hermesandroid.relay.viewmodel.ConnectionViewModel.agentProfiles]
|
||||
* to the profile picker UI. Empty when unpaired or when the server
|
||||
* returned no `profiles` entry.
|
||||
*/
|
||||
private val _agentProfiles = MutableStateFlow<List<Profile>>(emptyList())
|
||||
val agentProfiles: StateFlow<List<Profile>> = _agentProfiles.asStateFlow()
|
||||
|
||||
/**
|
||||
* Whether an API key is currently stored. Updated reactively by
|
||||
@@ -221,6 +407,12 @@ class AuthManager(
|
||||
init {
|
||||
// Register as system channel handler for auth messages
|
||||
multiplexer.registerHandler("system", this)
|
||||
// Also listen on the "pairing" channel for server-initiated
|
||||
// pushes — currently just profiles.updated (v0.7.1+ relay).
|
||||
// Routed through the same onMessage dispatcher which switches
|
||||
// by envelope type so adding new pairing.* events later is a
|
||||
// one-line change in [onMessage].
|
||||
multiplexer.registerHandler("pairing", this)
|
||||
|
||||
// Check for existing session token off main thread
|
||||
scope.launch {
|
||||
@@ -322,6 +514,9 @@ class AuthManager(
|
||||
*/
|
||||
suspend fun getOrCreateDeviceId(): String = getDeviceId()
|
||||
|
||||
/** Existing device ID without creating a new one. */
|
||||
suspend fun getExistingDeviceId(): String? = store().getString(KEY_DEVICE_ID)
|
||||
|
||||
/**
|
||||
* Set the TTL the user picked at [SessionTtlPickerDialog]. `0` → never,
|
||||
* `null` → defer to server default. Persisted across [authenticate]
|
||||
@@ -341,6 +536,21 @@ class AuthManager(
|
||||
pendingGrants = grants
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage the endpoint-candidate list parsed from the pairing QR (ADR 24).
|
||||
* Consumed in [handleAuthOk] — after the server confirms the pair we
|
||||
* persist the list under the current device id via
|
||||
* [PairingPreferences.setDeviceEndpoints].
|
||||
*
|
||||
* Safe to call with `null` or an empty list — either clears any staged
|
||||
* value without persisting. Pair-code re-sends from an existing
|
||||
* [AuthState.Paired] state never reach this setter, so session-token
|
||||
* refreshes don't clobber the persisted list.
|
||||
*/
|
||||
fun setPendingEndpoints(endpoints: List<EndpointCandidate>?) {
|
||||
pendingEndpoints = endpoints?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send auth envelope when connection is established.
|
||||
*
|
||||
@@ -363,12 +573,17 @@ class AuthManager(
|
||||
val deviceId = getDeviceId()
|
||||
val payload = when (currentState) {
|
||||
is AuthState.Paired -> {
|
||||
val refreshToken = store().getString(KEY_REFRESH_TOKEN)
|
||||
Log.i(
|
||||
TAG,
|
||||
"authenticate: sending session_token (state=Paired, token=${currentState.token.take(8)}…)"
|
||||
"authenticate: sending session_token (state=Paired, token=${currentState.token.take(8)}…, " +
|
||||
"refresh=${!refreshToken.isNullOrBlank()})"
|
||||
)
|
||||
buildJsonObject {
|
||||
put("session_token", currentState.token)
|
||||
if (!refreshToken.isNullOrBlank()) {
|
||||
put("refresh_token", refreshToken)
|
||||
}
|
||||
put("device_id", deviceId)
|
||||
put("device_name", android.os.Build.MODEL)
|
||||
}
|
||||
@@ -452,6 +667,7 @@ class AuthManager(
|
||||
scope.launch {
|
||||
val s = store()
|
||||
s.remove(KEY_SESSION_TOKEN)
|
||||
s.remove(KEY_REFRESH_TOKEN)
|
||||
s.remove(KEY_PAIRED_META)
|
||||
if (relayUrl != null) {
|
||||
certPinStore.removePinFor(relayUrl)
|
||||
@@ -464,9 +680,58 @@ class AuthManager(
|
||||
when (envelope.type) {
|
||||
"auth.ok" -> handleAuthOk(envelope)
|
||||
"auth.fail" -> handleAuthFail(envelope)
|
||||
// `profiles.updated` push — sent by the v0.7.1+ relay on
|
||||
// the "pairing" channel whenever its in-memory profile
|
||||
// snapshot changes (file-watcher, SIGHUP, or a manual
|
||||
// /api/profiles/refresh). The array shape mirrors the
|
||||
// `profiles` field of auth.ok, so we parse with the exact
|
||||
// same [parseAgentProfiles] helper.
|
||||
"profiles.updated" -> handleProfilesUpdated(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumer for the pairing-channel `profiles.updated` envelope.
|
||||
* Re-uses [parseAgentProfiles] because the wire shape of the
|
||||
* `profiles` array exactly matches the auth.ok embedded form —
|
||||
* the whole point of the push is that the app keeps a single
|
||||
* parser regardless of which entry point the list arrives on.
|
||||
*
|
||||
* Emits a one-shot [profilesUpdatedEvents] event so the UI layer
|
||||
* can show a brief "Profiles updated" snackbar. The event is only
|
||||
* fired when the list actually changed (different names or count)
|
||||
* — an idempotent push that matches the cached state is silent.
|
||||
*/
|
||||
private fun handleProfilesUpdated(envelope: Envelope) {
|
||||
val array = envelope.profiles ?: run {
|
||||
Log.w(TAG, "handleProfilesUpdated: envelope has no `profiles` array")
|
||||
return
|
||||
}
|
||||
val parsed = parseAgentProfiles(array)
|
||||
val prev = _agentProfiles.value
|
||||
_agentProfiles.value = parsed
|
||||
|
||||
// Did anything meaningful change? We treat "same names" as
|
||||
// "nothing to announce" — the dashboard can emit these pushes
|
||||
// liberally and we don't want to spam the user with toasts.
|
||||
val prevNames = prev.map { it.name }.toSet()
|
||||
val nextNames = parsed.map { it.name }.toSet()
|
||||
if (prevNames != nextNames || prev.size != parsed.size) {
|
||||
_profilesUpdatedEvents.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot signal emitted every time a `profiles.updated` envelope
|
||||
* materially changes the profile list. UI collects it via
|
||||
* [com.hermesandroid.relay.viewmodel.ConnectionViewModel.profilesUpdatedEvents]
|
||||
* to show a transient snackbar.
|
||||
*/
|
||||
private val _profilesUpdatedEvents =
|
||||
kotlinx.coroutines.flow.MutableSharedFlow<Unit>(extraBufferCapacity = 4)
|
||||
val profilesUpdatedEvents: kotlinx.coroutines.flow.SharedFlow<Unit> =
|
||||
_profilesUpdatedEvents.asSharedFlow()
|
||||
|
||||
fun regeneratePairingCode() {
|
||||
_pairingCode.value = generatePairingCode()
|
||||
}
|
||||
@@ -475,6 +740,7 @@ class AuthManager(
|
||||
scope.launch {
|
||||
val s = store()
|
||||
s.remove(KEY_SESSION_TOKEN)
|
||||
s.remove(KEY_REFRESH_TOKEN)
|
||||
s.remove(KEY_PAIRED_META)
|
||||
_authState.value = AuthState.Unpaired
|
||||
_currentPairedSession.value = null
|
||||
@@ -523,6 +789,14 @@ class AuthManager(
|
||||
if (token != null) {
|
||||
val s = store()
|
||||
s.putString(KEY_SESSION_TOKEN, token)
|
||||
val refreshToken = payload["refresh_token"]
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (refreshToken != null) {
|
||||
s.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
Log.i(TAG, "handleAuthOk: stored rotated refresh token")
|
||||
}
|
||||
_authState.value = AuthState.Paired(token)
|
||||
Log.i(TAG, "handleAuthOk: Paired(token=${token.take(8)}…)")
|
||||
// Server-issued code is one-shot — drop it once the
|
||||
@@ -565,19 +839,55 @@ class AuthManager(
|
||||
_currentPairedSession.value = paired
|
||||
persistPairedSession(paired)
|
||||
|
||||
// Pending TTL/grants are consumed — the server has
|
||||
// either honored or overridden them and the next
|
||||
// ADR 24 — persist the pairing's endpoint-candidate list
|
||||
// so the reachability probe + network-aware switch
|
||||
// (Kt-Probe) can load it on subsequent connects without
|
||||
// re-scanning the original QR. Keyed by the stable
|
||||
// device id so multiple paired devices coexist cleanly.
|
||||
//
|
||||
// Leave the previously-persisted list untouched when
|
||||
// nothing was staged (session-token refresh path, or
|
||||
// a legacy caller that didn't set endpoints).
|
||||
pendingEndpoints?.let { endpoints ->
|
||||
try {
|
||||
val deviceId = getDeviceId()
|
||||
PairingPreferences.setDeviceEndpoints(
|
||||
context,
|
||||
deviceId,
|
||||
endpoints,
|
||||
)
|
||||
Log.i(
|
||||
TAG,
|
||||
"handleAuthOk: persisted ${endpoints.size} endpoint(s) for device=$deviceId " +
|
||||
"roles=${endpoints.map { it.role }}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleAuthOk: endpoint persistence failed — continuing without; " +
|
||||
"reachability probe will fall back to the single active endpoint. " +
|
||||
"reason=${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pending TTL/grants/endpoints are consumed — the server
|
||||
// has either honored or overridden them and the next
|
||||
// auth round-trip should not resend stale values.
|
||||
pendingTtlSeconds = null
|
||||
pendingGrants = null
|
||||
pendingEndpoints = null
|
||||
}
|
||||
|
||||
val profilesArray = payload["profiles"]?.jsonArray
|
||||
if (profilesArray != null) {
|
||||
_profiles.value = profilesArray.map { it.jsonPrimitive.content }
|
||||
_agentProfiles.value = parseAgentProfiles(profilesArray)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
// Replaces a silent `e.printStackTrace()` — that stack-trace-only
|
||||
// handler is exactly why the broken `_sessionLabels` parser
|
||||
// (stringifying object entries) sat undetected for so long.
|
||||
Log.w(TAG, "auth.ok parse failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -588,13 +898,36 @@ class AuthManager(
|
||||
?: "Unknown error"
|
||||
val humanized = humanizeAuthFailReason(rawReason)
|
||||
Log.w(TAG, "handleAuthFail: raw=$rawReason humanized=$humanized")
|
||||
if (shouldPreservePairedSessionOnAuthFail(_authState.value, rawReason)) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleAuthFail: preserving paired session after transient auth timeout"
|
||||
)
|
||||
return
|
||||
}
|
||||
clearPendingPairContextAfterAuthFailure(rawReason)
|
||||
_authState.value = AuthState.Failed(humanized)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "handleAuthFail: exception parsing payload", e)
|
||||
clearPendingPairContextAfterAuthFailure("parse failure")
|
||||
_authState.value = AuthState.Failed("Authentication failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearPendingPairContextAfterAuthFailure(reason: String) {
|
||||
if (serverIssuedCode == null) return
|
||||
serverIssuedCode = null
|
||||
pendingTtlSeconds = null
|
||||
pendingGrants = null
|
||||
pendingEndpoints = null
|
||||
_pairingCode.value = generatePairingCode()
|
||||
Log.i(
|
||||
TAG,
|
||||
"auth.fail consumed server-issued pairing code; " +
|
||||
"cleared pending pair context so reconnects stop (reason=$reason)"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map common relay `auth.fail` reasons to user-friendly short strings.
|
||||
* The wizard VerifyStep surfaces the returned text directly, so this is
|
||||
|
||||
@@ -18,10 +18,11 @@ import kotlinx.serialization.Serializable
|
||||
* @property expiresAt epoch seconds at which the server says the session
|
||||
* expires — or `null` when the user chose "never expire" at the
|
||||
* TTL picker. `null` is a first-class value, not a missing field.
|
||||
* @property grants per-channel expiry map. Keys: `"chat"`, `"terminal"`,
|
||||
* `"bridge"` (and any future channel the server adds). Values are
|
||||
* epoch seconds or `null` for "never". Missing channels = server
|
||||
* didn't grant that channel to this device.
|
||||
* @property grants per-channel expiry map. Known keys include `"chat"`,
|
||||
* `"terminal"`, `"bridge"`, `"tui"`, `"voice:config"`,
|
||||
* `"voice:stt"`, and `"voice:tts"`; future server-defined keys are
|
||||
* tolerated. Values are epoch seconds or `null` for "never".
|
||||
* Missing channels = server didn't grant that channel to this device.
|
||||
* @property transportHint the transport the server advises the phone to
|
||||
* use, for UX labeling only. `"wss"` / `"ws"` / `null` when the
|
||||
* server didn't provide a hint.
|
||||
|
||||
@@ -67,7 +67,8 @@ interface SessionTokenStore {
|
||||
class KeystoreTokenStore private constructor(
|
||||
private val context: Context,
|
||||
private val wantsStrongBox: Boolean,
|
||||
override val hasHardwareBackedStorage: Boolean
|
||||
override val hasHardwareBackedStorage: Boolean,
|
||||
private val prefsName: String,
|
||||
) : SessionTokenStore {
|
||||
|
||||
// Mutable so [resetPrefs] can swap in a fresh instance after a corrupted
|
||||
@@ -89,7 +90,7 @@ class KeystoreTokenStore private constructor(
|
||||
val masterKey = builder.build()
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
prefsName,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
@@ -114,16 +115,24 @@ class KeystoreTokenStore private constructor(
|
||||
prefs.edit().clear().apply()
|
||||
} catch (_: Exception) { /* expected on a wedged file */ }
|
||||
try {
|
||||
context.deleteSharedPreferences(PREFS_NAME)
|
||||
context.deleteSharedPreferences(prefsName)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "deleteSharedPreferences($PREFS_NAME) failed: ${e.message}")
|
||||
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e.message}")
|
||||
}
|
||||
prefs = buildPrefs()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "KeystoreTokenStore"
|
||||
private const val PREFS_NAME = "hermes_companion_auth_hw"
|
||||
|
||||
/**
|
||||
* Default EncryptedSharedPreferences filename. Pre-multi-connection
|
||||
* installs have all their auth state in this single file —
|
||||
* connection 0 re-uses it as-is via
|
||||
* [Connection.LEGACY_TOKEN_STORE_KEY] so no user-visible migration is
|
||||
* needed.
|
||||
*/
|
||||
const val DEFAULT_PREFS_NAME = "hermes_companion_auth_hw"
|
||||
|
||||
/**
|
||||
* Try to build a [KeystoreTokenStore] on the current device. Returns
|
||||
@@ -131,13 +140,22 @@ class KeystoreTokenStore private constructor(
|
||||
* AndroidKeystore implementations — we don't want the app to brick
|
||||
* itself trying to create a master key).
|
||||
*
|
||||
* [prefsName] selects the EncryptedSharedPreferences file — defaults
|
||||
* to [DEFAULT_PREFS_NAME] for backwards compat with the
|
||||
* single-connection call sites. Multi-connection callers pass a
|
||||
* per-connection filename built via
|
||||
* [com.hermesandroid.relay.data.Connection.buildTokenStoreKey].
|
||||
*
|
||||
* Also fires a one-shot read probe so a pre-corrupted file from a
|
||||
* previous install gets healed during construction rather than on the
|
||||
* first user-driven read. The probe routes through the instance's
|
||||
* own [getString], so if it throws, [resetPrefs] runs and we end up
|
||||
* with a fresh empty prefs file — not a permanently broken store.
|
||||
*/
|
||||
fun tryCreate(context: Context): KeystoreTokenStore? {
|
||||
fun tryCreate(
|
||||
context: Context,
|
||||
prefsName: String = DEFAULT_PREFS_NAME,
|
||||
): KeystoreTokenStore? {
|
||||
return try {
|
||||
val wantsStrongBox = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
|
||||
context.packageManager.hasSystemFeature(
|
||||
@@ -147,6 +165,7 @@ class KeystoreTokenStore private constructor(
|
||||
context = context.applicationContext,
|
||||
wantsStrongBox = wantsStrongBox,
|
||||
hasHardwareBackedStorage = wantsStrongBox,
|
||||
prefsName = prefsName,
|
||||
)
|
||||
// Force a read so a wedged file from a prior install heals
|
||||
// here rather than at the first user-visible call.
|
||||
@@ -224,7 +243,10 @@ class KeystoreTokenStore private constructor(
|
||||
* (b) migration source for reading existing session tokens out of the legacy
|
||||
* prefs on first launch after the update.
|
||||
*/
|
||||
class LegacyEncryptedPrefsTokenStore(context: Context) : SessionTokenStore {
|
||||
class LegacyEncryptedPrefsTokenStore(
|
||||
context: Context,
|
||||
private val prefsName: String = LEGACY_PREFS_NAME,
|
||||
) : SessionTokenStore {
|
||||
|
||||
companion object {
|
||||
const val LEGACY_PREFS_NAME = "hermes_companion_auth"
|
||||
@@ -243,7 +265,7 @@ class LegacyEncryptedPrefsTokenStore(context: Context) : SessionTokenStore {
|
||||
.build()
|
||||
return EncryptedSharedPreferences.create(
|
||||
appContext,
|
||||
LEGACY_PREFS_NAME,
|
||||
prefsName,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
@@ -255,9 +277,9 @@ class LegacyEncryptedPrefsTokenStore(context: Context) : SessionTokenStore {
|
||||
prefs.edit().clear().apply()
|
||||
} catch (_: Exception) { /* expected on a wedged file */ }
|
||||
try {
|
||||
appContext.deleteSharedPreferences(LEGACY_PREFS_NAME)
|
||||
appContext.deleteSharedPreferences(prefsName)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "deleteSharedPreferences($LEGACY_PREFS_NAME) failed: ${e.message}")
|
||||
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e.message}")
|
||||
}
|
||||
prefs = buildPrefs()
|
||||
}
|
||||
|
||||
@@ -70,8 +70,10 @@ class AutoDisableWorker(private val context: Context) {
|
||||
// call is also wrapped in runCatching to swallow SecurityException as
|
||||
// a belt-and-braces. Suppress here rather than inlining the check —
|
||||
// the helper exists so the same gate can grow more conditions later
|
||||
// without each call site re-implementing it.
|
||||
@SuppressLint("MissingPermission")
|
||||
// without each call site re-implementing it. Both IDs are needed:
|
||||
// `NotificationPermission` is the notify()-specific check (POST_NOTIFICATIONS
|
||||
// on API 33+); `MissingPermission` is the generic fallback.
|
||||
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||
private fun postNotification() {
|
||||
ensureChannel()
|
||||
if (!hasPostNotificationsPermission()) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.bridge
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
@@ -300,6 +301,16 @@ class BridgeForegroundService : Service() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ForegroundServiceType: lint requires the manifest `<service>` to declare
|
||||
// `foregroundServiceType` for targetSdk >= 34. The SIDELOAD manifest does
|
||||
// (specialUse|mediaProjection) + declares the matching FOREGROUND_SERVICE_*
|
||||
// permissions. The GOOGLEPLAY flavor deliberately omits this service AND
|
||||
// those permissions (no device-control capability for Play-Store
|
||||
// compliance), so this code is unreachable there — the service can't be
|
||||
// started without a manifest declaration. Lint analyzes the merged
|
||||
// googlePlay manifest and can't see the sideload guarantee, so suppress
|
||||
// here rather than weaken googlePlay by granting it specialUse.
|
||||
@SuppressLint("ForegroundServiceType")
|
||||
private fun startForegroundNotification() {
|
||||
ensureChannel()
|
||||
val notification = buildNotification()
|
||||
|
||||
@@ -110,10 +110,26 @@ class BridgeSafetyManager(
|
||||
private val _settings = MutableStateFlow(BridgeSafetySettings())
|
||||
val settings: StateFlow<BridgeSafetySettings> = _settings.asStateFlow()
|
||||
|
||||
/**
|
||||
* "Don't ask again" set for destructive verbs. When a confirmation is
|
||||
* about to fire and the matched verb is in this set, we short-circuit
|
||||
* to Allow (and let the normal activity-log path record the action so
|
||||
* the user still has an audit trail). This does NOT bypass the master
|
||||
* blocklist or the master-disable toggle — both of those gates run in
|
||||
* [BridgeCommandHandler] before the code ever reaches [awaitConfirmation].
|
||||
*/
|
||||
private val _trustedDestructiveVerbs = MutableStateFlow<Set<String>>(emptySet())
|
||||
val trustedDestructiveVerbs: StateFlow<Set<String>> =
|
||||
_trustedDestructiveVerbs.asStateFlow()
|
||||
|
||||
/** True once the DataStore collector has ticked at least once. */
|
||||
@Volatile
|
||||
private var settingsHydrated: Boolean = false
|
||||
|
||||
/** True once the trusted-verbs collector has ticked at least once. */
|
||||
@Volatile
|
||||
private var trustedHydrated: Boolean = false
|
||||
|
||||
/**
|
||||
* Pending confirmation requests keyed by a monotonic id. The overlay's
|
||||
* Allow / Deny callbacks complete the deferred by looking up the id the
|
||||
@@ -145,6 +161,12 @@ class BridgeSafetyManager(
|
||||
settingsHydrated = true
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
prefsRepo.trustedDestructiveVerbs.collect { latest ->
|
||||
_trustedDestructiveVerbs.value = latest
|
||||
trustedHydrated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blocklist ────────────────────────────────────────────────────────
|
||||
@@ -197,12 +219,32 @@ class BridgeSafetyManager(
|
||||
val timeoutMs = snapshot.confirmationTimeoutSeconds * 1000L
|
||||
val requestId = nextRequestId.incrementAndGet()
|
||||
|
||||
val matchedVerb = text?.let { firstMatchedVerb(it, snapshot.destructiveVerbs) }.orEmpty()
|
||||
|
||||
// "Don't ask again" short-circuit. If the user has previously
|
||||
// allowed this verb with the trust checkbox ticked, skip the
|
||||
// modal entirely and return allow. We intentionally only short-
|
||||
// circuit when the matched verb is non-empty — routes like /call
|
||||
// and /send_sms whose confirm text doesn't match any user verb
|
||||
// (verb = "") always prompt so irreversible actions never bypass.
|
||||
//
|
||||
// This path is NOT consulted before the master blocklist or the
|
||||
// master-disable toggle — both of those live in BridgeCommandHandler
|
||||
// and fail earlier, so even a trusted verb can't slip through a
|
||||
// blocklisted app or a disabled bridge.
|
||||
if (matchedVerb.isNotBlank() &&
|
||||
currentTrustedVerbs().contains(matchedVerb.lowercase())
|
||||
) {
|
||||
Log.i(TAG, "awaitConfirmation: verb '$matchedVerb' is trusted — auto-allowing")
|
||||
return true
|
||||
}
|
||||
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
val pending = PendingConfirmation(
|
||||
id = requestId,
|
||||
method = method,
|
||||
text = text.orEmpty(),
|
||||
verb = text?.let { firstMatchedVerb(it, snapshot.destructiveVerbs) }.orEmpty(),
|
||||
verb = matchedVerb,
|
||||
deferred = deferred,
|
||||
)
|
||||
pendingConfirmations[requestId] = pending
|
||||
@@ -302,6 +344,46 @@ class BridgeSafetyManager(
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add [verb] to the trusted-verb set. Idempotent; no-op on blank input.
|
||||
* Called from the overlay host when the user ticks "Don't ask again"
|
||||
* and taps Allow. Verbs are persisted lowercase/trimmed by the
|
||||
* underlying repository.
|
||||
*/
|
||||
fun trustDestructiveVerb(verb: String) {
|
||||
val normalized = verb.trim().lowercase()
|
||||
if (normalized.isEmpty()) return
|
||||
scope.launch {
|
||||
runCatching { prefsRepo.addTrustedDestructiveVerb(normalized) }
|
||||
.onFailure { Log.w(TAG, "trustDestructiveVerb('$verb') failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe the trusted-verb set. Called from [BridgeScreen]'s "Reset"
|
||||
* affordance after the user confirms. After this, every destructive
|
||||
* verb prompts again.
|
||||
*/
|
||||
fun clearTrustedDestructiveVerbs() {
|
||||
scope.launch {
|
||||
runCatching { prefsRepo.clearTrustedDestructiveVerbs() }
|
||||
.onFailure { Log.w(TAG, "clearTrustedDestructiveVerbs failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun currentTrustedVerbs(): Set<String> {
|
||||
if (trustedHydrated) return _trustedDestructiveVerbs.value
|
||||
return try {
|
||||
val first = prefsRepo.trustedDestructiveVerbs.first()
|
||||
_trustedDestructiveVerbs.value = first
|
||||
trustedHydrated = true
|
||||
first
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "currentTrustedVerbs: DataStore read failed — using empty", t)
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun currentSettings(): BridgeSafetySettings {
|
||||
// Prefer the cached value once the DataStore collector has ticked
|
||||
// at least once. Before that, fall back to a one-shot read of
|
||||
|
||||
@@ -184,11 +184,22 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
|
||||
method = request.method,
|
||||
verb = request.verb,
|
||||
fullText = request.text,
|
||||
onAllow = {
|
||||
onAllow = { trustVerb ->
|
||||
// Persist the "don't ask again" choice BEFORE
|
||||
// dismissing so a slow write can't race a
|
||||
// follow-up command that arrives while we're
|
||||
// still tearing down the overlay. trustVerb is
|
||||
// already gated by the dialog on verb.isNotBlank,
|
||||
// so passing it through straight is safe.
|
||||
if (trustVerb && request.verb.isNotBlank()) {
|
||||
BridgeSafetyManager.peek()
|
||||
?.trustDestructiveVerb(request.verb)
|
||||
}
|
||||
onResult(true)
|
||||
dismissConfirmation(request.id)
|
||||
},
|
||||
onDeny = {
|
||||
// Deny never writes trust — denying isn't consent.
|
||||
onResult(false)
|
||||
dismissConfirmation(request.id)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Shared profile/personality display and request identity helpers.
|
||||
*
|
||||
* A null profile name is the app's explicit "Server default" state. The
|
||||
* relay also advertises the root Hermes config as a synthetic profile named
|
||||
* "default"; for request/session identity that row is an alias of server
|
||||
* default so it does not split chat, voice, or session scope.
|
||||
*/
|
||||
object AgentDisplay {
|
||||
const val SERVER_DEFAULT_PROFILE_KEY: String = "__server_default__"
|
||||
|
||||
fun effectiveProfile(
|
||||
selectedProfile: Profile?,
|
||||
profiles: List<Profile>,
|
||||
): Profile? = selectedProfile
|
||||
?: profiles.firstOrNull { it.name.equals("default", ignoreCase = true) }
|
||||
|
||||
fun profileDisplayName(profile: Profile?): String? {
|
||||
if (profile == null) return null
|
||||
return when {
|
||||
profile.description.isNotBlank() -> profile.description.trim()
|
||||
profile.name.isNotBlank() -> titleCase(profile.name.trim())
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun agentName(
|
||||
profile: Profile?,
|
||||
selectedPersonality: String,
|
||||
defaultPersonality: String,
|
||||
connectionLabel: String?,
|
||||
): String {
|
||||
profileDisplayName(profile)?.let { return it }
|
||||
|
||||
val personalityName = if (
|
||||
selectedPersonality == "default" &&
|
||||
defaultPersonality.isNotBlank()
|
||||
) {
|
||||
defaultPersonality
|
||||
} else {
|
||||
selectedPersonality
|
||||
}
|
||||
|
||||
return when {
|
||||
personalityName.isNotBlank() && personalityName != "default" ->
|
||||
titleCase(personalityName.trim())
|
||||
!connectionLabel.isNullOrBlank() -> connectionLabel.trim()
|
||||
else -> "Hermes"
|
||||
}
|
||||
}
|
||||
|
||||
fun personalityLabel(
|
||||
selectedPersonality: String,
|
||||
defaultPersonality: String,
|
||||
): String = when {
|
||||
selectedPersonality != "default" && selectedPersonality.isNotBlank() ->
|
||||
titleCase(selectedPersonality.trim())
|
||||
defaultPersonality.isNotBlank() -> titleCase(defaultPersonality.trim())
|
||||
else -> "Default"
|
||||
}
|
||||
|
||||
fun isServerDefaultAlias(profileName: String?): Boolean =
|
||||
profileName?.trim()?.equals("default", ignoreCase = true) == true
|
||||
|
||||
fun normalizeSelection(profile: Profile?): Profile? =
|
||||
if (isServerDefaultAlias(profile?.name)) null else profile
|
||||
|
||||
fun profileRequestName(profileName: String?): String? =
|
||||
profileName
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() && !isServerDefaultAlias(it) }
|
||||
|
||||
fun profileSessionKey(profileName: String?): String =
|
||||
profileRequestName(profileName) ?: SERVER_DEFAULT_PROFILE_KEY
|
||||
|
||||
fun profileContextKey(connectionId: String?, profileName: String?): String =
|
||||
"${connectionId.orEmpty()}::${profileSessionKey(profileName)}"
|
||||
|
||||
private fun titleCase(value: String): String =
|
||||
value.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
@@ -87,15 +88,17 @@ class BargeInPreferencesRepository(
|
||||
booleanPreferencesKey("barge_in_resume_after_interruption")
|
||||
}
|
||||
|
||||
val flow: Flow<BargeInPreferences> = dataStore.data.map { prefs ->
|
||||
BargeInPreferences(
|
||||
enabled = prefs[KEY_ENABLED] ?: DEFAULT_ENABLED,
|
||||
sensitivity = prefs[KEY_SENSITIVITY]?.let { decodeSensitivity(it) }
|
||||
?: DEFAULT_SENSITIVITY,
|
||||
resumeAfterInterruption = prefs[KEY_RESUME_AFTER_INTERRUPTION]
|
||||
?: DEFAULT_RESUME_AFTER_INTERRUPTION,
|
||||
)
|
||||
}
|
||||
val flow: Flow<BargeInPreferences> = dataStore.data
|
||||
.map { prefs ->
|
||||
BargeInPreferences(
|
||||
enabled = prefs[KEY_ENABLED] ?: DEFAULT_ENABLED,
|
||||
sensitivity = prefs[KEY_SENSITIVITY]?.let { decodeSensitivity(it) }
|
||||
?: DEFAULT_SENSITIVITY,
|
||||
resumeAfterInterruption = prefs[KEY_RESUME_AFTER_INTERRUPTION]
|
||||
?: DEFAULT_RESUME_AFTER_INTERRUPTION,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun setEnabled(value: Boolean) {
|
||||
dataStore.edit { it[KEY_ENABLED] = value }
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -164,6 +165,17 @@ class BridgeSafetyPreferencesRepository(private val context: Context) {
|
||||
booleanPreferencesKey("bridge_unattended_warning_seen")
|
||||
// === END v0.4.1 unattended-access keys ===
|
||||
|
||||
// === "Don't ask again" trusted destructive verbs ===
|
||||
// Set of normalized (lowercase, trimmed) destructive verbs the user
|
||||
// has chosen to stop being prompted about. Stored as a native
|
||||
// stringSet (not JSON) because there's no ordering/evolution concern
|
||||
// here — just membership. Empty set by default — every destructive
|
||||
// verb prompts until the user opts in via the confirmation dialog's
|
||||
// "Don't ask again" checkbox.
|
||||
private val KEY_TRUSTED_DESTRUCTIVE_VERBS =
|
||||
stringSetPreferencesKey("bridge_trusted_destructive_verbs")
|
||||
// === END trusted destructive verbs ===
|
||||
|
||||
/** Sentinel key we set the first time settings get written. Used to
|
||||
* tell "user cleared the blocklist" from "user has never touched it". */
|
||||
private val KEY_SAFETY_INITIALIZED = booleanPreferencesKey("bridge_safety_initialized")
|
||||
@@ -307,6 +319,52 @@ class BridgeSafetyPreferencesRepository(private val context: Context) {
|
||||
|
||||
// === END v0.4.1 unattended-access setters ===
|
||||
|
||||
// === "Don't ask again" trusted destructive verbs ===
|
||||
|
||||
/**
|
||||
* Live Flow of the verbs the user has marked "don't ask again" for.
|
||||
* Values are normalized (lowercase, trimmed) on write, so comparisons
|
||||
* against [BridgeSafetySettings.destructiveVerbs] and the incoming
|
||||
* modal `verb` field don't need any extra casing logic at the read
|
||||
* site. Master blocklist + master-disable still take precedence over
|
||||
* this set — this is only consulted AFTER the destructive-verb gate
|
||||
* decides a confirmation would otherwise fire.
|
||||
*/
|
||||
val trustedDestructiveVerbs: Flow<Set<String>> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS]?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
suspend fun setTrustedDestructiveVerbs(verbs: Set<String>) {
|
||||
val normalized = verbs
|
||||
.map { it.trim().lowercase() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] = normalized
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addTrustedDestructiveVerb(verb: String) {
|
||||
val normalized = verb.trim().lowercase()
|
||||
if (normalized.isEmpty()) return
|
||||
context.relayDataStore.edit { prefs ->
|
||||
val current = prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] ?: emptySet()
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] = current + normalized
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearTrustedDestructiveVerbs() {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_TRUSTED_DESTRUCTIVE_VERBS] = emptySet()
|
||||
prefs[KEY_SAFETY_INITIALIZED] = true
|
||||
}
|
||||
}
|
||||
|
||||
// === END trusted destructive verbs ===
|
||||
|
||||
private fun decodeSet(raw: String): Set<String> =
|
||||
runCatching { json.decodeFromString<List<String>>(raw).toSet() }
|
||||
.getOrDefault(emptySet())
|
||||
|
||||
@@ -39,8 +39,27 @@ data class ChatMessage(
|
||||
val estimatedCost: Double? = null,
|
||||
// Agent/personality name for display on assistant messages
|
||||
val agentName: String? = null,
|
||||
// Small provenance badges rendered on assistant bubbles.
|
||||
val badges: List<String> = emptyList(),
|
||||
// File attachments (images, documents, etc.)
|
||||
val attachments: List<Attachment> = emptyList(),
|
||||
/**
|
||||
* Rich content cards emitted by the agent via `CARD:{json}` line
|
||||
* markers in the text stream. Parsed in
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler.scanForCardMarkers]
|
||||
* and rendered inline by
|
||||
* [com.hermesandroid.relay.ui.components.HermesCardBubble]. Mirrors
|
||||
* [attachments]' lifecycle — the marker line is stripped from
|
||||
* [content] on match so the raw `CARD:{...}` never appears in the
|
||||
* bubble text, same as the `MEDIA:` parser.
|
||||
*/
|
||||
val cards: List<HermesCard> = emptyList(),
|
||||
/**
|
||||
* Tapped actions on any of [cards]. Checked by the renderer so a card
|
||||
* whose action has been dispatched collapses into a confirmation state
|
||||
* rather than re-offering the buttons.
|
||||
*/
|
||||
val cardDispatches: List<HermesCardDispatch> = emptyList(),
|
||||
/**
|
||||
* Structured trace of a phone-local voice intent dispatch. Populated only
|
||||
* for messages whose [id] starts with `voice-intent-` and that originated
|
||||
@@ -60,7 +79,17 @@ data class ChatMessage(
|
||||
* to true via [com.hermesandroid.relay.network.handlers.ChatHandler.markVoiceIntentsSynced]
|
||||
* so they're not re-sent on the next turn.
|
||||
*/
|
||||
val voiceIntent: VoiceIntentTrace? = null
|
||||
val voiceIntent: VoiceIntentTrace? = null,
|
||||
/**
|
||||
* Provider-native Realtime Agent turns can answer without calling Hermes.
|
||||
* Those local-only assistant turns need to be spliced into the next Hermes
|
||||
* chat/run payload so switching back to normal chat preserves context.
|
||||
*
|
||||
* Hermes-backed realtime turns leave this null because Hermes already owns
|
||||
* the durable session turn; the provider's spoken summary is UI/runtime
|
||||
* provenance, not another canonical assistant message.
|
||||
*/
|
||||
val realtimeTurn: RealtimeTurnTrace? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -112,6 +141,24 @@ data class VoiceIntentTrace(
|
||||
val syncedToServer: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Local provider-native realtime turn that has not necessarily been absorbed
|
||||
* into the Hermes session yet.
|
||||
*
|
||||
* Stored on the assistant message so the next normal chat send can emit a
|
||||
* compact OpenAI-format user/assistant pair before the live user message. This
|
||||
* keeps Realtime Agent and Hermes Chat + Voice Output as one conversation even
|
||||
* when the realtime provider answered directly.
|
||||
*/
|
||||
data class RealtimeTurnTrace(
|
||||
val userText: String,
|
||||
val assistantText: String,
|
||||
val provider: String? = null,
|
||||
val model: String? = null,
|
||||
val voice: String? = null,
|
||||
val syncedToServer: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* A file attachment sent with a message.
|
||||
*
|
||||
@@ -187,6 +234,8 @@ data class ToolCall(
|
||||
val success: Boolean?,
|
||||
val isComplete: Boolean = false,
|
||||
val error: String? = null,
|
||||
val runId: String? = null,
|
||||
val provenance: String? = null,
|
||||
// Duration tracking
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
val completedAt: Long? = null
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* A "connection" = a distinct Hermes server connection the app can switch between.
|
||||
*
|
||||
* Each connection has its own:
|
||||
* - API server URL + relay URL
|
||||
* - EncryptedSharedPreferences file (keyed by [tokenStoreKey]) holding the
|
||||
* session token, device ID, API key, and paired-session metadata.
|
||||
* - Cert pin (already host-keyed in [com.hermesandroid.relay.auth.CertPinStore]
|
||||
* so that store is intrinsically per-connection as long as hosts differ).
|
||||
* - Last-active session ID (to restore the open chat on connection switch).
|
||||
* - Transport hint + session expiry mirrored from the server's `auth.ok`
|
||||
* payload so the connection list can show "expires in 3d" without cracking
|
||||
* open the token store.
|
||||
*
|
||||
* Switching connection is a HEAVY context swap — caller is expected to tear down
|
||||
* the current [com.hermesandroid.relay.network.ConnectionManager],
|
||||
* [com.hermesandroid.relay.auth.AuthManager], and API client, then construct
|
||||
* fresh ones pointed at the new connection's `tokenStoreKey`.
|
||||
*
|
||||
* **Zero-disruption migration:** the legacy pre-multi-connection install kept
|
||||
* all of its auth state in a single EncryptedSharedPreferences file named
|
||||
* [LEGACY_TOKEN_STORE_KEY]. On first launch after the multi-connection upgrade,
|
||||
* [ConnectionStore.migrateLegacyConnectionIfNeeded] seeds connection 0 pointing
|
||||
* at that existing file — no token migration, no re-pair.
|
||||
*
|
||||
* **Terminology note (2026-04-18):** earlier drafts of this feature called the
|
||||
* concept "Profile". Renamed to [Connection] so that the term "Profile" is
|
||||
* free to mean what Hermes's server config means by it (agent profiles —
|
||||
* name + model + description defined under `agent.profiles` in config.yaml).
|
||||
* A follow-up pass will introduce the new `Profile` concept on top.
|
||||
*/
|
||||
@Serializable
|
||||
data class Connection(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val apiServerUrl: String,
|
||||
val relayUrl: String,
|
||||
val tokenStoreKey: String,
|
||||
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
|
||||
val pairedAt: Long? = null,
|
||||
val lastActiveSessionId: String? = null,
|
||||
val transportHint: String? = null,
|
||||
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
|
||||
val expiresAt: Long? = null,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* The pre-multi-connection EncryptedSharedPreferences filename. Matches
|
||||
* [com.hermesandroid.relay.auth.KeystoreTokenStore]'s original
|
||||
* hardcoded `PREFS_NAME`. Connection 0 re-uses this file as-is so the
|
||||
* existing paired device keeps working across the upgrade.
|
||||
*/
|
||||
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
|
||||
|
||||
/**
|
||||
* Derive a stable per-connection EncryptedSharedPreferences filename
|
||||
* from a connection UUID. Trimmed to the first 8 characters of the
|
||||
* UUID so the on-disk filename stays short and human-diffable, which
|
||||
* matters because [android.content.Context.deleteSharedPreferences]
|
||||
* only accepts a filename string.
|
||||
*/
|
||||
fun buildTokenStoreKey(id: String): String = "hermes_auth_${id.take(8)}"
|
||||
|
||||
/**
|
||||
* Human-friendly default label for a newly-added connection. Uses the
|
||||
* hostname of the API server URL so "http://192.168.1.10:8642" becomes
|
||||
* "192.168.1.10". Falls back to the raw URL if parsing fails (e.g.,
|
||||
* user typed a malformed value — better to show something recognizable
|
||||
* than to crash).
|
||||
*/
|
||||
fun extractDefaultLabel(apiServerUrl: String): String {
|
||||
return try {
|
||||
URI(apiServerUrl).host ?: apiServerUrl
|
||||
} catch (_: Exception) {
|
||||
apiServerUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Single source of truth for the list of Hermes server connections and which
|
||||
* one is currently active.
|
||||
*
|
||||
* Persistence lives on [Context.relayDataStore] — the same DataStore used by
|
||||
* [FeatureFlags] / [PairingPreferences] / etc. — under two keys:
|
||||
*
|
||||
* - [KEY_CONNECTIONS] — JSON array of [Connection] serialized via
|
||||
* kotlinx.serialization.
|
||||
* - [KEY_ACTIVE_CONNECTION_ID] — the UUID of the currently-active connection.
|
||||
* May be absent on a fresh install or after
|
||||
* the last connection is removed.
|
||||
*
|
||||
* The store exposes three [StateFlow]s:
|
||||
*
|
||||
* - [connections] — the full list, hot on the store's own scope.
|
||||
* - [activeConnectionId] — the active connection's UUID, or null.
|
||||
* - [activeConnection] — derived from the above two. Null when the active
|
||||
* ID is missing or refers to a connection that no
|
||||
* longer exists (stale ID after a delete, for
|
||||
* example).
|
||||
*
|
||||
* The store is **single-writer by convention** — all mutations go through its
|
||||
* suspend fns, each of which uses a [DataStore.edit] block under the hood so
|
||||
* concurrent writers serialize correctly. No external locking required.
|
||||
*
|
||||
* Tests instantiate it via the internal constructor that accepts a raw
|
||||
* [DataStore] so they can point it at a temp-folder preferences file without
|
||||
* needing an Android Context.
|
||||
*
|
||||
* **Terminology note (2026-04-18):** renamed from `ProfileStore` so that the
|
||||
* term "Profile" is free to mean what Hermes's server config means by it
|
||||
* (agent profiles). Legacy DataStore keys (`profiles_v1`, `active_profile_id`)
|
||||
* are migrated once on first launch — see the init block below.
|
||||
*/
|
||||
class ConnectionStore private constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val context: Context?,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Production constructor — wires the store to [Context.relayDataStore].
|
||||
* The context reference is kept so [removeConnection] can call
|
||||
* [Context.deleteSharedPreferences] on the connection's
|
||||
* EncryptedSharedPreferences file when it's removed.
|
||||
*/
|
||||
constructor(context: Context) : this(
|
||||
dataStore = context.relayDataStore,
|
||||
context = context.applicationContext,
|
||||
)
|
||||
|
||||
/**
|
||||
* Test constructor — accepts a raw [DataStore]. The context is null, so
|
||||
* [removeConnection] skips the file-deletion side effect (tests don't have
|
||||
* access to a real EncryptedSharedPreferences anyway).
|
||||
*/
|
||||
internal constructor(dataStore: DataStore<Preferences>) : this(
|
||||
dataStore = dataStore,
|
||||
context = null,
|
||||
)
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val connectionListSerializer = ListSerializer(Connection.serializer())
|
||||
|
||||
private val writeMutex = Mutex()
|
||||
|
||||
private val _connections = MutableStateFlow<List<Connection>>(emptyList())
|
||||
val connections: StateFlow<List<Connection>> = _connections.asStateFlow()
|
||||
|
||||
private val _activeConnectionId = MutableStateFlow<String?>(null)
|
||||
val activeConnectionId: StateFlow<String?> = _activeConnectionId.asStateFlow()
|
||||
|
||||
/**
|
||||
* Derived: the active connection, or null when the active ID is missing
|
||||
* or points to a deleted connection. Recomputes every time either
|
||||
* upstream emits — cheap list scan, no memoization needed.
|
||||
*/
|
||||
val activeConnection: StateFlow<Connection?> = combine(_connections, _activeConnectionId) { list, id ->
|
||||
if (id == null) null else list.firstOrNull { it.id == id }
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
try {
|
||||
val prefs = dataStore.data.first()
|
||||
val newJson = prefs[KEY_CONNECTIONS]
|
||||
val oldJson = prefs[KEY_LEGACY_PROFILES]
|
||||
val activeNew = prefs[KEY_ACTIVE_CONNECTION_ID]
|
||||
val activeOld = prefs[KEY_LEGACY_ACTIVE_PROFILE_ID]
|
||||
|
||||
// Prefer the new key. If absent and the old key has data,
|
||||
// migrate it once: write to the new key and clear the old ones
|
||||
// so we don't thrash every boot. JSON shape is identical
|
||||
// between the two names — `{"id": ..., "label": ..., ...}` —
|
||||
// so no per-record migration is needed.
|
||||
if (newJson == null && oldJson != null) {
|
||||
dataStore.edit { p ->
|
||||
p[KEY_CONNECTIONS] = oldJson
|
||||
p.remove(KEY_LEGACY_PROFILES)
|
||||
if (activeNew == null && activeOld != null) {
|
||||
p[KEY_ACTIVE_CONNECTION_ID] = activeOld
|
||||
p.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
}
|
||||
}
|
||||
_connections.value = decodeConnections(oldJson)
|
||||
_activeConnectionId.value = activeOld
|
||||
Log.i(
|
||||
TAG,
|
||||
"Migrated legacy DataStore keys (profiles_v1 → connections_v1)",
|
||||
)
|
||||
} else {
|
||||
_connections.value = decodeConnections(newJson)
|
||||
_activeConnectionId.value = activeNew
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Initial hydrate failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mutations ----------------------------------------------------------
|
||||
|
||||
suspend fun addConnection(connection: Connection) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
// If the same ID already exists, treat this as an upsert —
|
||||
// callers shouldn't rely on insertion order of a duplicate
|
||||
// add, and the alternative (throwing) makes migration code
|
||||
// more brittle than it needs to be.
|
||||
val next = current.filterNot { it.id == connection.id } + connection
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the connection with [connection]'s id. No-op if no connection
|
||||
* with that id exists — callers should use [addConnection] for inserts.
|
||||
*/
|
||||
suspend fun updateConnection(connection: Connection) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
if (current.none { it.id == connection.id }) {
|
||||
Log.w(TAG, "updateConnection: no connection with id=${connection.id} — ignored")
|
||||
return@edit
|
||||
}
|
||||
val next = current.map { if (it.id == connection.id) connection else it }
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the connection with [id] from the list and delete its backing
|
||||
* EncryptedSharedPreferences file. If [id] is currently active, the
|
||||
* active pointer is cleared — callers are responsible for picking a new
|
||||
* active connection.
|
||||
*
|
||||
* The EncryptedSharedPreferences file is deleted via
|
||||
* [Context.deleteSharedPreferences], which is documented as API 24+ and
|
||||
* is safe on our minSdk 26. The legacy connection's file
|
||||
* ([Connection.LEGACY_TOKEN_STORE_KEY]) is deleted the same way — there's
|
||||
* nothing structurally special about it once the user explicitly asks
|
||||
* to remove connection 0.
|
||||
*/
|
||||
suspend fun removeConnection(id: String) {
|
||||
writeMutex.withLock {
|
||||
var removed: Connection? = null
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
removed = current.firstOrNull { it.id == id }
|
||||
val next = current.filterNot { it.id == id }
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
|
||||
if (prefs[KEY_ACTIVE_CONNECTION_ID] == id) {
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
_activeConnectionId.value = null
|
||||
}
|
||||
}
|
||||
removed?.let { connection ->
|
||||
context?.let { ctx ->
|
||||
val storeKeys = buildSet {
|
||||
add(connection.tokenStoreKey)
|
||||
if (connection.tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
|
||||
// Pre-StrongBox fallback path used this file. If
|
||||
// connection 0 is removed, scrub it alongside the
|
||||
// hardware-backed legacy filename.
|
||||
add("hermes_companion_auth")
|
||||
}
|
||||
}
|
||||
for (storeKey in storeKeys) {
|
||||
try {
|
||||
ctx.deleteSharedPreferences(storeKey)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"deleteSharedPreferences($storeKey) failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setActiveConnection(id: String) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = id
|
||||
_activeConnectionId.value = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update just the `lastActiveSessionId` on the identified connection.
|
||||
* Called whenever the user picks a chat session so connection-switch can
|
||||
* restore the same session on re-selection. No-op if the connection
|
||||
* doesn't exist.
|
||||
*/
|
||||
suspend fun setLastActiveSessionId(connectionId: String, sessionId: String?) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
val target = current.firstOrNull { it.id == connectionId } ?: return@edit
|
||||
val next = current.map {
|
||||
if (it.id == connectionId) target.copy(lastActiveSessionId = sessionId) else it
|
||||
}
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the identified connection with pairing metadata pulled out of an
|
||||
* `auth.ok` payload. No-op if the connection doesn't exist — callers are
|
||||
* responsible for ordering this after [addConnection].
|
||||
*
|
||||
* Both [pairedAtMillis] and [expiresAtMillis] are epoch milliseconds —
|
||||
* pass `System.currentTimeMillis()` for pairedAt and `expires_at * 1000`
|
||||
* for the seconds-based auth.ok payload field. `ConnectionsSettingsScreen`
|
||||
* assumes millis when rendering relative time; pass seconds here and
|
||||
* cards will always read as "Paired decades ago".
|
||||
*/
|
||||
suspend fun markPaired(
|
||||
connectionId: String,
|
||||
pairedAtMillis: Long,
|
||||
transportHint: String?,
|
||||
expiresAtMillis: Long?,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
val target = current.firstOrNull { it.id == connectionId } ?: return@edit
|
||||
val next = current.map {
|
||||
if (it.id == connectionId) {
|
||||
target.copy(
|
||||
pairedAt = pairedAtMillis,
|
||||
transportHint = transportHint,
|
||||
expiresAt = expiresAtMillis,
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot legacy migration: if no connections are persisted yet, seed a
|
||||
* connection 0 pointing at [Connection.LEGACY_TOKEN_STORE_KEY] so the
|
||||
* existing paired install keeps working without a re-pair.
|
||||
*
|
||||
* Idempotent — subsequent calls after connection 0 is seeded (or after
|
||||
* the user has added real connections) are a no-op. Pass the legacy URL
|
||||
* / session values from whatever store currently holds them (e.g.,
|
||||
* [ConnectionViewModel]'s DataStore-backed URL preferences).
|
||||
*/
|
||||
suspend fun migrateLegacyConnectionIfNeeded(
|
||||
legacyApiServerUrl: String?,
|
||||
legacyRelayUrl: String?,
|
||||
legacyLastSessionId: String?,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val existing = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
if (existing.isNotEmpty()) {
|
||||
// Already migrated or already has user-created connections.
|
||||
return@edit
|
||||
}
|
||||
if (legacyApiServerUrl.isNullOrBlank() && legacyRelayUrl.isNullOrBlank()) {
|
||||
Log.i(TAG, "migrateLegacyConnectionIfNeeded: no legacy URLs to seed")
|
||||
return@edit
|
||||
}
|
||||
val apiUrl = legacyApiServerUrl?.takeIf { it.isNotBlank() } ?: DEFAULT_API_URL
|
||||
val relayUrl = legacyRelayUrl?.takeIf { it.isNotBlank() } ?: DEFAULT_RELAY_URL
|
||||
val id = java.util.UUID.randomUUID().toString()
|
||||
val seed = Connection(
|
||||
id = id,
|
||||
label = Connection.extractDefaultLabel(apiUrl),
|
||||
apiServerUrl = apiUrl,
|
||||
relayUrl = relayUrl,
|
||||
tokenStoreKey = Connection.LEGACY_TOKEN_STORE_KEY,
|
||||
pairedAt = null,
|
||||
lastActiveSessionId = legacyLastSessionId,
|
||||
transportHint = null,
|
||||
expiresAt = null,
|
||||
)
|
||||
val next = listOf(seed)
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = id
|
||||
_connections.value = next
|
||||
_activeConnectionId.value = id
|
||||
Log.i(TAG, "migrateLegacyConnectionIfNeeded: seeded connection 0 id=$id apiUrl=$apiUrl")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Encoding helpers ---------------------------------------------------
|
||||
|
||||
private fun encodeConnections(list: List<Connection>): String =
|
||||
json.encodeToString(connectionListSerializer, list)
|
||||
|
||||
private fun decodeConnections(raw: String?): List<Connection> {
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return try {
|
||||
json.decodeFromString(connectionListSerializer, raw)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "decodeConnections failed, returning empty list: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionStore"
|
||||
|
||||
private val KEY_CONNECTIONS = stringPreferencesKey("connections_v1")
|
||||
private val KEY_ACTIVE_CONNECTION_ID = stringPreferencesKey("active_connection_id")
|
||||
|
||||
// Pre-rename DataStore keys — read once in init on first launch after
|
||||
// the rename, then wiped. See the init block above.
|
||||
private val KEY_LEGACY_PROFILES = stringPreferencesKey("profiles_v1")
|
||||
private val KEY_LEGACY_ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id")
|
||||
|
||||
// Match the defaults used by ConnectionViewModel so a seeded connection
|
||||
// from migrateLegacyConnectionIfNeeded() resolves to the same endpoints
|
||||
// a fresh install would.
|
||||
private const val DEFAULT_API_URL = "http://localhost:8642"
|
||||
private const val DEFAULT_RELAY_URL = "ws://localhost:8767"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
|
||||
/**
|
||||
* Validation rules for user-editable connection fields. Kept as a standalone
|
||||
* object so the same rules fire in all save paths — inline dialog feedback,
|
||||
* post-pairing add-connection, and any future import path — without the VM,
|
||||
* the UI, and the data layer each re-implementing string checks.
|
||||
*
|
||||
* All validators return null on success or a short human-readable message
|
||||
* suitable for surfacing in an OutlinedTextField `supportingText` slot or a
|
||||
* Snackbar. Messages are intentionally concise — callers add context ("in
|
||||
* connection label", "in relay URL") if the surface needs it.
|
||||
*/
|
||||
object ConnectionValidation {
|
||||
|
||||
const val LABEL_MAX_LEN: Int = 40
|
||||
|
||||
/**
|
||||
* @return null when valid, else a message describing the problem.
|
||||
* Callers should `.trim()` before persisting — this does not
|
||||
* mutate the input.
|
||||
*/
|
||||
fun validateLabel(raw: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return "Label can't be blank"
|
||||
if (trimmed.length > LABEL_MAX_LEN) return "Label too long (max $LABEL_MAX_LEN)"
|
||||
// Control characters (newlines, tabs, nul, etc.) would render badly
|
||||
// in chips and are almost certainly a paste-accident.
|
||||
if (trimmed.any { it.isISOControl() }) return "Label can't contain control characters"
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* API server must be http:// or https:// with a host. The user pairs
|
||||
* before they can save a connection, so malformed URLs shouldn't reach
|
||||
* this path — but defense-in-depth against manual edits / restored
|
||||
* backups is worth the few lines.
|
||||
*/
|
||||
fun validateApiServerUrl(raw: String): String? = validateUrl(
|
||||
raw = raw,
|
||||
allowedSchemes = setOf("http", "https"),
|
||||
kind = "API server URL",
|
||||
)
|
||||
|
||||
/** Relay URL must be ws:// or wss:// with a host. */
|
||||
fun validateRelayUrl(raw: String): String? = validateUrl(
|
||||
raw = raw,
|
||||
allowedSchemes = setOf("ws", "wss"),
|
||||
kind = "relay URL",
|
||||
)
|
||||
|
||||
/**
|
||||
* Catches the "added the same server twice" mistake. Matches when the
|
||||
* candidate's api + relay URLs exactly match an existing connection
|
||||
* (case-insensitive on scheme + host, per RFC 3986). [excludeId] skips
|
||||
* a specific connection so renames don't trip over their own entry.
|
||||
*
|
||||
* Deliberately lenient: two connections may share either URL alone (dev
|
||||
* that points API at prod + relay at a test box, say). Full exact-match
|
||||
* on both is the only blocked case.
|
||||
*/
|
||||
fun findDuplicate(
|
||||
connections: List<Connection>,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
excludeId: String? = null,
|
||||
): Connection? = connections.firstOrNull { c ->
|
||||
c.id != excludeId &&
|
||||
c.apiServerUrl.equals(apiServerUrl, ignoreCase = true) &&
|
||||
c.relayUrl.equals(relayUrl, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun validateUrl(raw: String, allowedSchemes: Set<String>, kind: String): String? {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return "$kind can't be blank"
|
||||
val uri = try {
|
||||
URI(trimmed)
|
||||
} catch (_: URISyntaxException) {
|
||||
return "$kind is malformed"
|
||||
}
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
if (scheme == null || scheme !in allowedSchemes) {
|
||||
return "$kind must start with ${allowedSchemes.joinToString(" or ") { "$it://" }}"
|
||||
}
|
||||
if (uri.host.isNullOrBlank()) return "$kind has no host"
|
||||
val port = uri.port
|
||||
if (port != -1 && (port < 1 || port > 65535)) return "$kind has an invalid port"
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -21,7 +23,17 @@ import java.io.File
|
||||
* Backup format is a JSON file containing settings and connection info.
|
||||
* Tokens are NOT included in backups for security.
|
||||
*/
|
||||
class DataManager(private val context: Context) {
|
||||
class DataManager(
|
||||
private val context: Context,
|
||||
/**
|
||||
* Multi-connection: the [ConnectionStore] singleton whose snapshot gets
|
||||
* written into [AppBackup.connections] on export. Nullable for
|
||||
* legacy/compat call sites that construct a [DataManager] without
|
||||
* connection support; a null store just means "export an empty
|
||||
* connections list" (equivalent to v2 behavior).
|
||||
*/
|
||||
private val connectionStore: ConnectionStore? = null,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DataManager"
|
||||
@@ -41,39 +53,69 @@ class DataManager(private val context: Context) {
|
||||
/**
|
||||
* Backup data model -- only non-sensitive settings.
|
||||
* Tokens and device IDs are never included.
|
||||
*
|
||||
* **Schema history:**
|
||||
* - v1: `serverUrl` only (single endpoint, pre-API-split).
|
||||
* - v2: adds `apiServerUrl` + `relayUrl`; `profiles: List<String>` held
|
||||
* server-issued session labels from `auth.ok` (never actually populated
|
||||
* on export — the field was vestigial).
|
||||
* - v3 (2026-04-18): `profiles: List<Profile>` — carried the new
|
||||
* multi-connection definitions under the then-current "Profile" name.
|
||||
* - v4 (2026-04-18): `connections: List<Connection>` — same shape as v3
|
||||
* under the renamed concept. v3 imports get their `profiles` field
|
||||
* re-mapped to `connections` (see [importSettings]). v1/v2 imports
|
||||
* get `connections = emptyList()` since the old string list was not
|
||||
* structurally compatible.
|
||||
*/
|
||||
@Serializable
|
||||
data class AppBackup(
|
||||
val version: Int = 2,
|
||||
val version: Int = 4,
|
||||
val serverUrl: String? = null, // legacy (v1 compat)
|
||||
val apiServerUrl: String? = null,
|
||||
val relayUrl: String? = null,
|
||||
val theme: String = "auto",
|
||||
val onboardingCompleted: Boolean = false,
|
||||
val profiles: List<String> = emptyList(),
|
||||
val connections: List<Connection> = emptyList(),
|
||||
val exportedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* Export app settings to a JSON string.
|
||||
* Does NOT include session tokens or device IDs (security).
|
||||
*
|
||||
* The `sessionLabels` parameter is a legacy dead parameter — it was
|
||||
* previously sourced from `AuthManager.sessionLabels`, a field removed
|
||||
* in Pass 2 of the multi-connection rollout (2026-04-18) when it was
|
||||
* replaced by the structured `agentProfiles: StateFlow<List<Profile>>`.
|
||||
* Kept only for call-site signature stability; not written to the
|
||||
* backup. The backup's [AppBackup.connections] comes from the
|
||||
* injected [connectionStore] snapshot. Callers should pass
|
||||
* `emptyList()`. Will be removed in a later pass.
|
||||
*/
|
||||
suspend fun exportSettings(
|
||||
serverUrl: String?,
|
||||
theme: String,
|
||||
onboardingCompleted: Boolean,
|
||||
profiles: List<String>,
|
||||
@Suppress("UNUSED_PARAMETER") sessionLabels: List<String>,
|
||||
apiServerUrl: String? = null,
|
||||
relayUrl: String? = null
|
||||
): String {
|
||||
val connectionsSnapshot = connectionStore?.connections?.value
|
||||
if (connectionStore == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"exportSettings: no ConnectionStore wired — writing empty connections list " +
|
||||
"(caller constructed DataManager without the multi-connection ctor arg)",
|
||||
)
|
||||
}
|
||||
val backup = AppBackup(
|
||||
version = 2,
|
||||
version = 4,
|
||||
serverUrl = serverUrl, // legacy compat
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
theme = theme,
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
profiles = profiles,
|
||||
connections = connectionsSnapshot ?: emptyList(),
|
||||
exportedAt = System.currentTimeMillis()
|
||||
)
|
||||
return json.encodeToString(backup)
|
||||
@@ -82,10 +124,51 @@ class DataManager(private val context: Context) {
|
||||
/**
|
||||
* Import settings from a JSON string.
|
||||
* Returns the parsed backup, or null if invalid.
|
||||
*
|
||||
* For v1/v2 backups we deliberately drop the old `profiles: List<String>`
|
||||
* field on its own, since kotlinx.serialization's `ignoreUnknownKeys`
|
||||
* would throw if it found the old scalar-string entries where it now
|
||||
* expects [Connection] objects. We pre-parse as a [JsonElement] and
|
||||
* rebuild the object with `connections = []` on older schema versions.
|
||||
*
|
||||
* For v3 backups, the old `profiles: List<Profile>` field is re-mapped
|
||||
* to `connections: List<Connection>` — same wire shape, just renamed.
|
||||
*/
|
||||
fun importSettings(jsonString: String): AppBackup? {
|
||||
return try {
|
||||
json.decodeFromString<AppBackup>(jsonString)
|
||||
val element = json.parseToJsonElement(jsonString)
|
||||
val obj = element as? JsonObject ?: return null
|
||||
val version = obj["version"]?.let {
|
||||
(it as? JsonPrimitive)?.content?.toIntOrNull()
|
||||
} ?: 4
|
||||
val normalized = when {
|
||||
version < 3 -> {
|
||||
// Strip the incompatible v1/v2 `profiles` field so the
|
||||
// serializer doesn't try to decode List<String> into
|
||||
// List<Connection>. The feature never populated the list
|
||||
// in export anyway, so no user data is lost.
|
||||
Log.d(
|
||||
TAG,
|
||||
"importSettings: dropping legacy v$version profiles field " +
|
||||
"(schema was vestigial)",
|
||||
)
|
||||
JsonObject(obj - "profiles")
|
||||
}
|
||||
version == 3 -> {
|
||||
// v3 used `profiles: List<Profile>` with the same wire
|
||||
// shape as v4's `connections: List<Connection>`. Swap
|
||||
// the key name and decode as v4.
|
||||
val profilesField = obj["profiles"]
|
||||
val withoutProfiles = obj - "profiles"
|
||||
if (profilesField != null) {
|
||||
JsonObject(withoutProfiles + ("connections" to profilesField))
|
||||
} else {
|
||||
JsonObject(withoutProfiles)
|
||||
}
|
||||
}
|
||||
else -> obj
|
||||
}
|
||||
json.decodeFromJsonElement(AppBackup.serializer(), normalized)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse backup JSON", e)
|
||||
null
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One entry in a pairing payload's `endpoints` array (ADR 24 — multi-endpoint
|
||||
* pairing, 2026-04-19).
|
||||
*
|
||||
* Pairing QRs can now carry an ordered list of candidate endpoints so a single
|
||||
* pairing works across LAN / Tailscale / public-reverse-proxy networks. The
|
||||
* phone picks the highest-priority reachable candidate at connect time and
|
||||
* re-evaluates on network change.
|
||||
*
|
||||
* Wire contract (v3 pairing payload):
|
||||
* ```json
|
||||
* {
|
||||
* "role": "lan",
|
||||
* "priority": 0,
|
||||
* "api": { "host": "192.168.1.100", "port": 8642, "tls": false },
|
||||
* "relay": { "url": "ws://192.168.1.100:8767", "transport_hint": "ws" }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* **Semantics (locked by ADR 24):**
|
||||
* - [role] is an open string. Known values `lan` / `tailscale` / `public`
|
||||
* get styled labels; anything else renders generically (`Custom VPN (<role>)`).
|
||||
* No enum, no normalization — the raw role string must round-trip exactly
|
||||
* so HMAC canonicalization holds.
|
||||
* - [priority] is strict, `0 = highest`. Reachability never promotes a lower
|
||||
* priority over a higher one; it only breaks ties among equal priorities.
|
||||
* - The per-endpoint [RelayEndpoint] intentionally carries **only** the URL
|
||||
* and transport hint. The pairing `code`, `ttl_seconds`, and `grants` stay
|
||||
* at the top level of the pairing payload because they're per-pair
|
||||
* artifacts — not per-endpoint.
|
||||
*/
|
||||
@Serializable
|
||||
data class EndpointCandidate(
|
||||
val role: String,
|
||||
val priority: Int = 0,
|
||||
val api: ApiEndpoint,
|
||||
val relay: RelayEndpoint,
|
||||
)
|
||||
|
||||
/**
|
||||
* The API-server half of an [EndpointCandidate] — the HTTP/SSE target the
|
||||
* phone uses for `/v1/runs`, `/v1/chat/completions`, `/api/sessions/...`, etc.
|
||||
*
|
||||
* Note: [tls] defaults to false so a v2-synthesized candidate (built from a
|
||||
* legacy QR with no `endpoints` field and no top-level `tls`) still
|
||||
* deserializes cleanly.
|
||||
*/
|
||||
@Serializable
|
||||
data class ApiEndpoint(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val tls: Boolean = false,
|
||||
) {
|
||||
/** Build the full API server URL from host, port, and tls flag. */
|
||||
val url: String
|
||||
get() = "${if (tls) "https" else "http"}://$host:$port"
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay-server half of an [EndpointCandidate] — the WSS URL the phone
|
||||
* opens for the bridge + terminal channels.
|
||||
*
|
||||
* @property url full WebSocket URL, e.g. `ws://192.168.1.100:8767` (dev) or
|
||||
* `wss://hermes.example.com/relay` (fronted by a reverse proxy).
|
||||
* @property transportHint `"wss"` / `"ws"` / `null`. Drives the plaintext-ws
|
||||
* consent gate and the transport-security UI badge. Never gates
|
||||
* behavior on its own — the scheme of [url] is authoritative.
|
||||
*/
|
||||
@Serializable
|
||||
data class RelayEndpoint(
|
||||
val url: String,
|
||||
@SerialName("transport_hint")
|
||||
val transportHint: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns true when [EndpointCandidate.role] is one of the built-in, styled
|
||||
* roles: `lan`, `tailscale`, or `public`. Case-insensitive match — but the
|
||||
* role string itself is still preserved verbatim for HMAC canonicalization.
|
||||
*
|
||||
* Unknown roles (`"wireguard"`, `"zerotier"`, `"netbird-eu"`, operator-defined
|
||||
* labels) return false so the UI can fall back to [displayLabel]'s generic
|
||||
* "Custom VPN" treatment.
|
||||
*/
|
||||
fun EndpointCandidate.isKnownRole(): Boolean {
|
||||
return when (role.lowercase()) {
|
||||
"lan", "tailscale", "public" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for the UI. Known roles get fixed-case styled labels;
|
||||
* unknown roles render as `"Custom VPN (<role>)"` with the raw role preserved
|
||||
* so an operator can see exactly what they labeled it.
|
||||
*
|
||||
* The raw [role] on the [EndpointCandidate] is NOT modified — it stays in its
|
||||
* emitted form for HMAC canonicalization. This is a display-only transform.
|
||||
*/
|
||||
fun EndpointCandidate.displayLabel(): String {
|
||||
return when (role.lowercase()) {
|
||||
"lan" -> "LAN"
|
||||
"tailscale" -> "Tailscale"
|
||||
"public" -> "Public"
|
||||
else -> "Custom VPN ($role)"
|
||||
}
|
||||
}
|
||||
@@ -75,21 +75,18 @@ object FeatureFlags {
|
||||
/**
|
||||
* Compile-time gating based on the active Gradle product flavor.
|
||||
*
|
||||
* Phase 3 ships Bridge on two tracks with very different AccessibilityService
|
||||
* scope: the `googlePlay` flavor carries a conservative event-type subset and
|
||||
* a "notifications + confirmations" description for Play Store policy review,
|
||||
* and the `sideload` flavor carries the full agent-control surface. The tier
|
||||
* flags below let UI code hide tier 3/4/6 surfaces on the Play build without
|
||||
* a runtime check — Kotlin's `val … get() = current == SIDELOAD` resolves at
|
||||
* each call site, but because `current` is a compile-time string, R8 is able
|
||||
* to fold the check away in release builds.
|
||||
* Phase 3 keeps "Hermes Bridge" as the umbrella, but only the `sideload`
|
||||
* flavor ships AccessibilityService-backed Device Control. The `googlePlay`
|
||||
* flavor is Bridge Core: relay pairing, chat, voice, terminal, notification
|
||||
* companion, media, and session-grant surfaces without screen reading, taps,
|
||||
* typing, screenshots, overlays, or unattended control.
|
||||
*
|
||||
* Tier definitions (see `Phase 3 — Bridge Channel.md` in the vault):
|
||||
* 1. baseline — both tracks (app open, tap, navigate within app)
|
||||
* 2. notifications — both tracks (read notifications, summarize, reply)
|
||||
* Device Control tier definitions (see `Phase 3 — Bridge Channel.md`):
|
||||
* 1. baseline — sideload only (app open, tap, navigate within app)
|
||||
* 2. screen context — sideload only (Accessibility tree / screen reads)
|
||||
* 3. voice-first — sideload only (always-on voice capture)
|
||||
* 4. vision-first — sideload only (always-on screen reading)
|
||||
* 5. safety rails — both tracks (confirmation dialogs, action log)
|
||||
* 5. safety rails — sideload only (confirmation dialogs, action log)
|
||||
* 6. ambitious future — sideload only (cross-app macros, scheduling)
|
||||
*/
|
||||
object BuildFlavor {
|
||||
@@ -112,11 +109,11 @@ object BuildFlavor {
|
||||
*/
|
||||
val isSideload: Boolean get() = current == SIDELOAD
|
||||
|
||||
val bridgeTier1: Boolean = true // baseline — both tracks
|
||||
val bridgeTier2: Boolean = true // notifications, calendar — both tracks
|
||||
val bridgeTier1: Boolean get() = current == SIDELOAD // baseline device control
|
||||
val bridgeTier2: Boolean get() = current == SIDELOAD // screen context
|
||||
val bridgeTier3: Boolean get() = current == SIDELOAD // voice-first
|
||||
val bridgeTier4: Boolean get() = current == SIDELOAD // vision-first
|
||||
val bridgeTier5: Boolean = true // safety rails — always on
|
||||
val bridgeTier5: Boolean get() = current == SIDELOAD // safety rails
|
||||
val bridgeTier6: Boolean get() = current == SIDELOAD // future ambitious
|
||||
|
||||
/** Human-readable badge label for the Settings → About version row. */
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A rich content card emitted inline in an assistant message via the
|
||||
* `CARD:{json}` line marker. Parsed by
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler] and rendered by
|
||||
* [com.hermesandroid.relay.ui.components.HermesCardBubble].
|
||||
*
|
||||
* The marker lives in the text stream alongside `MEDIA:...` for the same
|
||||
* reason: it works unchanged across every streaming endpoint we support
|
||||
* (`/v1/runs`, `/api/sessions/{id}/chat/stream`, `/v1/chat/completions`)
|
||||
* without a server-side schema change. When upstream gains structured card
|
||||
* events, the parser can fan out — the [HermesCard] model stays.
|
||||
*
|
||||
* Unknown [type] values fall back to a generic title+fields render so the
|
||||
* surface doesn't break when the agent emits a card the phone build hasn't
|
||||
* seen. Unknown [accent] / action [style] values degrade to their defaults
|
||||
* the same way.
|
||||
*
|
||||
* Example (single-line in practice):
|
||||
* ```
|
||||
* CARD:{"type":"approval_request","title":"Run shell command?",
|
||||
* "body":"`rm -rf /tmp/cache`","accent":"warning",
|
||||
* "actions":[{"label":"Allow","value":"/approve","style":"primary"},
|
||||
* {"label":"Deny","value":"/deny","style":"danger"}]}
|
||||
* ```
|
||||
*/
|
||||
@Serializable
|
||||
data class HermesCard(
|
||||
/**
|
||||
* Card dispatcher key. Known built-ins are defined in [BuiltInTypes];
|
||||
* unknown values render via the generic fallback.
|
||||
*/
|
||||
val type: String,
|
||||
val title: String? = null,
|
||||
val subtitle: String? = null,
|
||||
/** Markdown-rendered body text. Appears between the header and fields. */
|
||||
val body: String? = null,
|
||||
/**
|
||||
* Semantic accent — maps to a colorScheme token in the renderer.
|
||||
* Valid: `info` (default), `success`, `warning`, `danger`.
|
||||
*/
|
||||
val accent: String? = null,
|
||||
val fields: List<HermesCardField> = emptyList(),
|
||||
val actions: List<HermesCardAction> = emptyList(),
|
||||
/** Small muted text at the bottom of the card. */
|
||||
val footer: String? = null,
|
||||
/**
|
||||
* Optional stable id from the agent. Used by the renderer to track
|
||||
* which action (if any) has been dispatched, so the same card reloaded
|
||||
* from session history doesn't re-prompt. Falls back to the card's
|
||||
* position in the message when null.
|
||||
*/
|
||||
val id: String? = null,
|
||||
) {
|
||||
object BuiltInTypes {
|
||||
const val SKILL_RESULT = "skill_result"
|
||||
const val APPROVAL_REQUEST = "approval_request"
|
||||
const val LINK_PREVIEW = "link_preview"
|
||||
const val CALENDAR_EVENT = "calendar_event"
|
||||
const val WEATHER = "weather"
|
||||
}
|
||||
|
||||
object Accents {
|
||||
const val INFO = "info"
|
||||
const val SUCCESS = "success"
|
||||
const val WARNING = "warning"
|
||||
const val DANGER = "danger"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A label/value row inside a card. [value] is rendered as markdown so the
|
||||
* agent can embed emphasis, inline code, or links.
|
||||
*/
|
||||
@Serializable
|
||||
data class HermesCardField(
|
||||
val label: String,
|
||||
val value: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* A tappable action on a card.
|
||||
*
|
||||
* When the user taps the button, the ViewModel reads [mode] to decide how
|
||||
* to dispatch [value]:
|
||||
* - [Modes.SEND_TEXT] (default): sends [value] as a new user message, so
|
||||
* the agent sees it in its next turn. This is how approval_request's
|
||||
* "Allow" / "Deny" replies flow back to the LLM.
|
||||
* - [Modes.SLASH_COMMAND]: runs [value] as a slash command (e.g.
|
||||
* `/approve` or `/clear`). The leading `/` is stripped if present.
|
||||
* - [Modes.OPEN_URL]: opens [value] in an external browser — used by
|
||||
* [HermesCard.BuiltInTypes.LINK_PREVIEW]'s "Open" button.
|
||||
*
|
||||
* [style] picks a button color from the colorScheme:
|
||||
* - `primary` — filled, colorScheme.primary
|
||||
* - `secondary` (default) — outlined, onSurfaceVariant
|
||||
* - `danger` — outlined, colorScheme.error
|
||||
*/
|
||||
@Serializable
|
||||
data class HermesCardAction(
|
||||
val label: String,
|
||||
val value: String,
|
||||
val style: String? = null,
|
||||
val mode: String? = null,
|
||||
) {
|
||||
object Styles {
|
||||
const val PRIMARY = "primary"
|
||||
const val SECONDARY = "secondary"
|
||||
const val DANGER = "danger"
|
||||
}
|
||||
|
||||
object Modes {
|
||||
const val SEND_TEXT = "send_text"
|
||||
const val SLASH_COMMAND = "slash_command"
|
||||
const val OPEN_URL = "open_url"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-only tracking of card action dispatch, stored alongside
|
||||
* [ChatMessage.cards] so a session reload from server history doesn't lose
|
||||
* "I already approved this" state. Keyed by the card's id (or its index
|
||||
* position as a fallback) + action value.
|
||||
*
|
||||
* Persisted to server-side session memory via
|
||||
* [com.hermesandroid.relay.viewmodel.CardDispatchSyncBuilder], modeled on
|
||||
* [com.hermesandroid.relay.voice.VoiceIntentSyncBuilder]: on the next
|
||||
* chat send, unsynced dispatches materialize as OpenAI-format `assistant`
|
||||
* (with structured `tool_calls`) + `tool` message pairs under a synthetic
|
||||
* `hermes_card_action` tool name, splicing them into the session history
|
||||
* the LLM sees. After the API client takes ownership of the request,
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler.markCardDispatchesSynced]
|
||||
* flips [syncedToServer] so subsequent turns don't re-send the same
|
||||
* trace.
|
||||
*/
|
||||
@Serializable
|
||||
data class HermesCardDispatch(
|
||||
val cardKey: String,
|
||||
val actionValue: String,
|
||||
val timestamp: Long,
|
||||
/**
|
||||
* Idempotency guard for the server-side session sync path.
|
||||
* Flipped to true by
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler.markCardDispatchesSynced]
|
||||
* once the API client has accepted the request that carried this
|
||||
* dispatch's synthetic message pair. Once true, the dispatch is
|
||||
* excluded from future
|
||||
* [com.hermesandroid.relay.viewmodel.CardDispatchSyncBuilder.buildSyntheticMessages]
|
||||
* passes.
|
||||
*/
|
||||
val syncedToServer: Boolean = false,
|
||||
)
|
||||
@@ -8,6 +8,8 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* DataStore-backed preferences for the pairing + security overhaul introduced
|
||||
@@ -42,6 +44,27 @@ object PairingPreferences {
|
||||
private val KEY_INSECURE_ACK_SEEN = booleanPreferencesKey("insecure_ack_seen")
|
||||
private val KEY_INSECURE_REASON = stringPreferencesKey("insecure_reason")
|
||||
private val KEY_TOFU_PINS = stringPreferencesKey("tofu_pins")
|
||||
private val KEY_ALL_INSECURE_PAIR_ACK_SEEN =
|
||||
booleanPreferencesKey("all_insecure_pair_ack_seen")
|
||||
|
||||
/**
|
||||
* Prefix for per-device endpoint-candidate keys. Full key is
|
||||
* `device_endpoints:<deviceId>`; the value is a JSON-encoded
|
||||
* `List<EndpointCandidate>` (ADR 24, 2026-04-19). One key per paired
|
||||
* device so the phone can store + retrieve multi-endpoint pairing
|
||||
* candidates for each host without multiplexing into a single blob.
|
||||
*/
|
||||
private const val KEY_DEVICE_ENDPOINTS_PREFIX = "device_endpoints:"
|
||||
|
||||
private val endpointJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val endpointListSerializer = ListSerializer(EndpointCandidate.serializer())
|
||||
|
||||
private fun deviceEndpointsKey(deviceId: String) =
|
||||
stringPreferencesKey("$KEY_DEVICE_ENDPOINTS_PREFIX$deviceId")
|
||||
|
||||
// --- Pair TTL -----------------------------------------------------------
|
||||
|
||||
@@ -75,6 +98,27 @@ object PairingPreferences {
|
||||
context.relayDataStore.edit { it[KEY_INSECURE_ACK_SEEN] = seen }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-install acknowledgment that the user understands the implications of
|
||||
* pairing to a QR where *every* endpoint candidate is plain text
|
||||
* (`ws://` / `http://` with no secure Tailscale/wss fallback — the
|
||||
* [TransportSecurityState.AllInsecure] case).
|
||||
*
|
||||
* Gates the Pair button on the wizard's Confirm step only for the absolute-
|
||||
* boundary AllInsecure scenario. Mixed pairings (any secure route present)
|
||||
* are NOT gated — the app auto-falls back to the secure one, so the
|
||||
* existing amber advisory is sufficient. Once the user has acknowledged
|
||||
* once on this install the gate is removed for all future AllInsecure
|
||||
* pairs — matches the precedent set by [insecureAckSeen] for the
|
||||
* per-install insecure-mode dialog.
|
||||
*/
|
||||
fun allInsecurePairAckSeen(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { it[KEY_ALL_INSECURE_PAIR_ACK_SEEN] ?: false }
|
||||
|
||||
suspend fun setAllInsecurePairAckSeen(context: Context, seen: Boolean) {
|
||||
context.relayDataStore.edit { it[KEY_ALL_INSECURE_PAIR_ACK_SEEN] = seen }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reason the user selected when they flipped insecure mode on.
|
||||
*
|
||||
@@ -139,4 +183,61 @@ object PairingPreferences {
|
||||
|
||||
private fun encodePins(pins: Map<String, String>): String =
|
||||
pins.entries.joinToString("|") { (host, pin) -> "$host=$pin" }
|
||||
|
||||
// --- Per-device endpoint candidates (ADR 24) ---------------------------
|
||||
//
|
||||
// Multi-endpoint pairing carries an ordered list of API+relay candidates
|
||||
// (LAN, Tailscale, public, custom-VPN, ...). The phone persists the list
|
||||
// per-device so the reachability-probe + network-aware switch (Kt-Probe)
|
||||
// can pick the best candidate on every connect without re-reading the
|
||||
// original QR.
|
||||
//
|
||||
// Storage shape: one DataStore string key per deviceId, value is the
|
||||
// JSON-encoded `List<EndpointCandidate>`. JSON keeps us flexible on
|
||||
// schema growth without touching the DataStore key layout (e.g. future
|
||||
// `weight`, `region`, `last_successful_at` per-candidate fields land as
|
||||
// new JSON properties, not new preference keys).
|
||||
|
||||
/**
|
||||
* Persist the ordered endpoint-candidate list for [deviceId]. Overwrites
|
||||
* any previously-stored list for that device. Encodes as JSON so future
|
||||
* schema fields land cleanly without a migration.
|
||||
*/
|
||||
suspend fun setDeviceEndpoints(
|
||||
context: Context,
|
||||
deviceId: String,
|
||||
endpoints: List<EndpointCandidate>,
|
||||
) {
|
||||
val encoded = endpointJson.encodeToString(endpointListSerializer, endpoints)
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[deviceEndpointsKey(deviceId)] = encoded
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe the stored endpoint-candidate list for [deviceId]. Emits an
|
||||
* empty list when none has been persisted yet (first-pair / pre-ADR-24
|
||||
* legacy devices) or when decoding fails (forward-compat safety net —
|
||||
* a bad blob shouldn't crash the reachability probe).
|
||||
*/
|
||||
fun getDeviceEndpoints(context: Context, deviceId: String): Flow<List<EndpointCandidate>> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
val raw = prefs[deviceEndpointsKey(deviceId)] ?: return@map emptyList()
|
||||
try {
|
||||
endpointJson.decodeFromString(endpointListSerializer, raw)
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the stored endpoint list for [deviceId]. Used when a device is
|
||||
* revoked / re-paired and its old candidate list is no longer trusted.
|
||||
* No-op if no record exists.
|
||||
*/
|
||||
suspend fun removeDeviceEndpoints(context: Context, deviceId: String) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs.remove(deviceEndpointsKey(deviceId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* An agent profile advertised by a Hermes server in its `auth.ok` payload.
|
||||
*
|
||||
* Historically (pre-R1) the server scanned a non-existent top-level
|
||||
* `profiles:` key in `~/.hermes/config.yaml` and effectively shipped an
|
||||
* empty list. Worker R1 rewrote the relay-side loader to scan the REAL
|
||||
* upstream layout (one directory per profile under `~/.hermes/profiles/`)
|
||||
* and added [systemMessage], sourced from each profile's `SOUL.md`.
|
||||
*
|
||||
* A Profile is a NAMED AGENT CONFIG within a Connection. Switching profile
|
||||
* changes the active agent identity for the Android chat surface:
|
||||
* - which profile API server the phone routes chat/session calls to when
|
||||
* the relay advertises [apiServerUrl];
|
||||
* - which profile name the phone sends to the server for new sessions and
|
||||
* chat turns when isolated routing is not available;
|
||||
* - which model and system message the phone can send as compatibility
|
||||
* fallback (via [model] and [systemMessage]);
|
||||
* - which profile-scoped session id Android resumes for local chat context.
|
||||
*
|
||||
* It does not mutate the server's configured default profile.
|
||||
*
|
||||
* Wire shape uses snake_case (`system_message`), this class uses camelCase
|
||||
* (`systemMessage`) — translated via [SerialName].
|
||||
*
|
||||
* **v0.7.0 runtime metadata.** Three optional fields — [gatewayRunning],
|
||||
* [hasSoul], [skillCount] — describe what the relay observes about each
|
||||
* profile directory at discovery time:
|
||||
* - [gatewayRunning] is a read-only probe (best-effort; can be stale or
|
||||
* wrong if the relay's last probe missed a restart). Drives the green/
|
||||
* grey status dot in the agent sheet.
|
||||
* - [hasSoul] is true when the profile directory has a non-empty
|
||||
* `SOUL.md` on disk. Decoupled from `systemMessage != null` so a SOUL
|
||||
* that fails to load (permissions, I/O) still reports its presence.
|
||||
* - [skillCount] is the count of skills visible under the profile
|
||||
* directory — drives the "N skills" chip.
|
||||
*
|
||||
* All three default to safe zero-values and are optional on the wire, so
|
||||
* older relays without the fields deserialize cleanly as
|
||||
* `gatewayRunning = false, hasSoul = false, skillCount = 0`.
|
||||
*
|
||||
* **Hermes profile API metadata.** A relay can advertise an isolated
|
||||
* profile API server without exposing its secret. When [apiServerUrl] is
|
||||
* present, Android routes chat/session traffic to that URL and reuses the
|
||||
* active connection's stored API key. Operators that use distinct API keys
|
||||
* per profile should pair those profile API servers as separate connections.
|
||||
*/
|
||||
@Serializable
|
||||
data class Profile(
|
||||
val name: String,
|
||||
val model: String,
|
||||
val description: String = "",
|
||||
@SerialName("system_message")
|
||||
val systemMessage: String? = null,
|
||||
@SerialName("gateway_running")
|
||||
val gatewayRunning: Boolean = false,
|
||||
@SerialName("has_soul")
|
||||
val hasSoul: Boolean = false,
|
||||
@SerialName("skill_count")
|
||||
val skillCount: Int = 0,
|
||||
@SerialName("api_server_enabled")
|
||||
val apiServerEnabled: Boolean = false,
|
||||
@SerialName("api_server_url")
|
||||
val apiServerUrl: String? = null,
|
||||
@SerialName("api_server_host")
|
||||
val apiServerHost: String? = null,
|
||||
@SerialName("api_server_port")
|
||||
val apiServerPort: Int? = null,
|
||||
@SerialName("api_server_key_present")
|
||||
val apiServerKeyPresent: Boolean = false,
|
||||
) {
|
||||
val hasIsolatedApi: Boolean
|
||||
get() = !apiServerUrl.isNullOrBlank()
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Wire contracts for the v0.7.0 Profile Inspector endpoints:
|
||||
*
|
||||
* - `GET /api/profiles/{name}/config`
|
||||
* - `GET /api/profiles/{name}/skills`
|
||||
* - `GET /api/profiles/{name}/soul`
|
||||
* - `GET /api/profiles/{name}/memory`
|
||||
*
|
||||
* All four endpoints are read-only introspection views served by the relay
|
||||
* directly off disk (no gateway round-trip). Field names mirror the Python
|
||||
* worker's contracts exactly — any rename here is a protocol break.
|
||||
*
|
||||
* Optional fields on the wire (`truncated`, `readonly`) default to safe
|
||||
* values so older relays that omit them deserialize without failing the
|
||||
* whole payload.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Response for `GET /api/profiles/{name}/config`.
|
||||
*
|
||||
* `config` is a raw [JsonObject] so the UI can render arbitrary nested YAML
|
||||
* loaded from the profile's `config.yaml`. We don't model every possible
|
||||
* config shape — that's upstream Hermes territory and churns frequently.
|
||||
*
|
||||
* @property readonly The relay always serves this view read-only; the flag
|
||||
* is advisory. Optional on the wire, defaults to false.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileConfigResponse(
|
||||
val profile: String,
|
||||
val path: String,
|
||||
val config: JsonObject,
|
||||
val readonly: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* One entry in the [ProfileSkillsResponse.skills] list.
|
||||
*
|
||||
* `enabled` is optional on the wire; defaults to true so a pre-v0.7 relay
|
||||
* that doesn't emit the field treats every skill as enabled.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileSkillEntry(
|
||||
val name: String,
|
||||
val category: String,
|
||||
val description: String,
|
||||
val path: String,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
/** Response for `GET /api/profiles/{name}/skills`. */
|
||||
@Serializable
|
||||
data class ProfileSkillsResponse(
|
||||
val profile: String,
|
||||
val skills: List<ProfileSkillEntry>,
|
||||
val total: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response for `GET /api/profiles/{name}/soul`.
|
||||
*
|
||||
* When `exists=false`, [content] is typically an empty string and the UI
|
||||
* should render an empty-state pointing at the expected [path].
|
||||
*
|
||||
* [truncated] is optional on the wire — Python may omit when false.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileSoulResponse(
|
||||
val profile: String,
|
||||
val path: String,
|
||||
val content: String,
|
||||
val exists: Boolean,
|
||||
@SerialName("size_bytes")
|
||||
val sizeBytes: Long,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* One entry in the [ProfileMemoryResponse.entries] list — a single memory
|
||||
* file found under the profile's memories directory.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileMemoryEntry(
|
||||
val name: String,
|
||||
val filename: String,
|
||||
val path: String,
|
||||
val content: String,
|
||||
@SerialName("size_bytes")
|
||||
val sizeBytes: Long,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/** Response for `GET /api/profiles/{name}/memory`. */
|
||||
@Serializable
|
||||
data class ProfileMemoryResponse(
|
||||
val profile: String,
|
||||
@SerialName("memories_dir")
|
||||
val memoriesDir: String,
|
||||
val entries: List<ProfileMemoryEntry>,
|
||||
val total: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response for `PUT /api/profiles/{name}/soul`. Server echoes back the
|
||||
* profile name, on-disk path, and bytes written so the UI can
|
||||
* optimistically confirm the write without an immediate re-fetch.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileSoulUpdateResponse(
|
||||
val ok: Boolean,
|
||||
val profile: String,
|
||||
val path: String,
|
||||
@SerialName("bytes_written")
|
||||
val bytesWritten: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response for `PUT /api/profiles/{name}/memory/{filename}`. Same shape
|
||||
* as [ProfileSoulUpdateResponse] plus the filename so a client can
|
||||
* confirm which entry it just wrote (relevant when creating a new file
|
||||
* — the request echo proves the server stored it under the requested
|
||||
* name rather than silently rewriting a collision).
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfileMemoryUpdateResponse(
|
||||
val ok: Boolean,
|
||||
val profile: String,
|
||||
val filename: String,
|
||||
val path: String,
|
||||
@SerialName("bytes_written")
|
||||
val bytesWritten: Long,
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Per-connection persisted selection of the active profile name.
|
||||
*
|
||||
* Why separate from [relayDataStore]: the main `relay_settings` store holds
|
||||
* a large pile of global settings that are expensive to iterate on every
|
||||
* profile-selection change, and we want these mappings to survive clean-up
|
||||
* passes over the main store without special-casing per-connection keys.
|
||||
* The dedicated `profile_selections` DataStore is small, scoped, and can be
|
||||
* cleared wholesale without collateral damage.
|
||||
*
|
||||
* Stored shape: one string-preference key per connection id, value is the
|
||||
* profile `name` string (NOT the serialized Profile object — the Profile
|
||||
* itself is advertised fresh by the server on every `auth.ok` and the
|
||||
* on-server set can drift between app launches, so we resolve name → Profile
|
||||
* at read time against the current [ConnectionViewModel.agentProfiles] list).
|
||||
*
|
||||
* A `null` value means "clear" — the key is removed from the store rather
|
||||
* than written as an empty string. That way the flow emits null cleanly
|
||||
* on fresh installs and on explicit clears.
|
||||
*
|
||||
* See Commit 3 of feature/profile-config-readonly for wiring. The caller
|
||||
* ([com.hermesandroid.relay.viewmodel.ConnectionViewModel]) handles the
|
||||
* name → Profile resolution and is the sole consumer.
|
||||
*/
|
||||
class ProfileSelectionStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.profileSelectionsDataStore)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Preference-key factory. Per-connection so every connection gets
|
||||
* its own slot — wholesale clearing of the store still works by
|
||||
* calling [clear] per connection id (or, in disaster-recovery,
|
||||
* dropping the file).
|
||||
*/
|
||||
private fun keyFor(connectionId: String) =
|
||||
stringPreferencesKey("selected_profile_$connectionId")
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the selected profile name for [connectionId]. Passing `null`
|
||||
* removes the key — fresh installs and explicit clears both converge
|
||||
* on the same "no key" state.
|
||||
*/
|
||||
suspend fun setSelectedProfile(connectionId: String, profileName: String?) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = keyFor(connectionId)
|
||||
if (profileName == null) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
prefs[key] = profileName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the persisted profile name for [connectionId], or `null` when
|
||||
* no selection has been stored. Callers must resolve the name against
|
||||
* the current server-advertised profile list — if the profile was
|
||||
* removed on the server, the caller should treat the resolution as
|
||||
* null (see ConnectionViewModel for the reference resolver).
|
||||
*/
|
||||
fun selectedProfileFlow(connectionId: String): Flow<String?> {
|
||||
val key = keyFor(connectionId)
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the persisted selection for [connectionId]. Called from the
|
||||
* Connection removal path AFTER the switch-away job completes so we
|
||||
* don't delete the selection the just-unmounted store is still writing.
|
||||
*/
|
||||
suspend fun clear(connectionId: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.remove(keyFor(connectionId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated DataStore for [ProfileSelectionStore]. Kept separate from
|
||||
* [relayDataStore] so the two stores can evolve independently and a nuke
|
||||
* of one doesn't take out the other.
|
||||
*/
|
||||
internal val Context.profileSelectionsDataStore: DataStore<Preferences>
|
||||
by preferencesDataStore(name = "profile_selections")
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Per-connection, per-Hermes-profile last active chat session.
|
||||
*
|
||||
* This is intentionally separate from [ProfileSelectionStore]. Selection says
|
||||
* which agent is active; this store says which chat session belongs to that
|
||||
* agent on that connection. Null profile name is the explicit Server default
|
||||
* context.
|
||||
*/
|
||||
class ProfileSessionStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.profileSessionsDataStore)
|
||||
|
||||
companion object {
|
||||
private const val PREFIX = "profile_session__"
|
||||
|
||||
private fun keyName(connectionId: String, profileName: String?): String =
|
||||
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}"
|
||||
|
||||
private fun keyFor(connectionId: String, profileName: String?) =
|
||||
stringPreferencesKey(keyName(connectionId, profileName))
|
||||
|
||||
private fun connectionPrefix(connectionId: String): String =
|
||||
"$PREFIX${connectionId}__"
|
||||
}
|
||||
|
||||
suspend fun setSessionId(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
sessionId: String?,
|
||||
) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = keyFor(connectionId, profileName)
|
||||
if (sessionId.isNullOrBlank()) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
prefs[key] = sessionId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sessionIdFlow(connectionId: String, profileName: String?): Flow<String?> {
|
||||
val key = keyFor(connectionId, profileName)
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
suspend fun clearConnection(connectionId: String) {
|
||||
val prefix = connectionPrefix(connectionId)
|
||||
dataStore.edit { prefs ->
|
||||
prefs.asMap().keys
|
||||
.filter { it.name.startsWith(prefix) }
|
||||
.forEach { prefs.remove(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.profileSessionsDataStore: DataStore<Preferences>
|
||||
by preferencesDataStore(name = "profile_sessions")
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Compact text context sent to the relay when opening a provider-native
|
||||
* Realtime Agent session.
|
||||
*/
|
||||
data class RealtimeConversationContextMessage(
|
||||
val role: MessageRole,
|
||||
val content: String,
|
||||
val source: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Friendly-name store for terminal tabs, keyed by the stable wire-side
|
||||
* `session_name` (e.g. `hermes-<deviceId>-tabN`). Cosmetic-only — the name
|
||||
* never crosses the wire; tmux reattach + server-side bookkeeping still use
|
||||
* the opaque session name.
|
||||
*
|
||||
* Keyed on `session_name` because that's also what tmux keys on — if the
|
||||
* user detaches, restarts the app, and reattaches, the same name re-binds
|
||||
* to the same tmux session. Kill explicitly clears the name (via
|
||||
* [clearName]) because the underlying session is destroyed; detach leaves
|
||||
* the name intact as a "come back to this later" hint.
|
||||
*/
|
||||
class TerminalTabNameStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
companion object {
|
||||
private val KEY_NAMES_JSON = stringPreferencesKey("terminal_tab_names_json")
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
}
|
||||
|
||||
val namesFlow: Flow<Map<String, String>> = dataStore.data.map { prefs ->
|
||||
val raw = prefs[KEY_NAMES_JSON] ?: return@map emptyMap()
|
||||
runCatching { JSON.decodeFromString<Map<String, String>>(raw) }
|
||||
.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
/** Set or clear (null / blank) the friendly name for [sessionName]. */
|
||||
suspend fun setName(sessionName: String, displayName: String?) {
|
||||
dataStore.edit { prefs ->
|
||||
val current = prefs[KEY_NAMES_JSON]
|
||||
?.let { runCatching { JSON.decodeFromString<Map<String, String>>(it) }.getOrNull() }
|
||||
?: emptyMap()
|
||||
val next = if (displayName.isNullOrBlank()) {
|
||||
current - sessionName
|
||||
} else {
|
||||
current + (sessionName to displayName.trim().take(MAX_NAME_LEN))
|
||||
}
|
||||
prefs[KEY_NAMES_JSON] = JSON.encodeToString(next)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the entry for a session that's being forcibly retired (e.g. kill). */
|
||||
suspend fun clearName(sessionName: String) = setName(sessionName, null)
|
||||
}
|
||||
|
||||
private const val MAX_NAME_LEN = 40
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Snapshot of a single tool-call invocation for the Stats-for-Nerds +
|
||||
* Timeline panels.
|
||||
*
|
||||
* Derived from [ToolCall] on the assistant messages but denormalized so
|
||||
* consumers don't need to walk the message list themselves. The ring
|
||||
* buffer that owns these is bounded — usually the last 10 tool calls
|
||||
* across all assistant messages in the active chat session.
|
||||
*
|
||||
* Status is split into two booleans so the UI can render three visual
|
||||
* states without introducing an enum dependency:
|
||||
* - `isComplete = false` → in-progress (spinner)
|
||||
* - `isComplete = true && success == true` → completed
|
||||
* - `isComplete = true && success == false` → failed
|
||||
*/
|
||||
data class ToolCallEvent(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val startedAtMs: Long,
|
||||
val completedAtMs: Long?,
|
||||
val isComplete: Boolean,
|
||||
val success: Boolean?,
|
||||
val resultSummary: String?,
|
||||
val errorSummary: String?,
|
||||
) {
|
||||
/** Elapsed wall-clock ms; null if the call hasn't completed. */
|
||||
val durationMs: Long?
|
||||
get() = completedAtMs?.let { it - startedAtMs }
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
@@ -20,48 +23,97 @@ import kotlinx.coroutines.flow.map
|
||||
* (V1 doesn't accept a language param).
|
||||
*/
|
||||
data class VoiceSettings(
|
||||
val engineMode: String = VoiceEngineMode.HermesVoiceOutput.storageValue,
|
||||
val interactionMode: String = "tap",
|
||||
val silenceThresholdMs: Long = 3000L,
|
||||
val autoTts: Boolean = false,
|
||||
val language: String = "",
|
||||
val realtimeTraceDetails: Boolean = false,
|
||||
/**
|
||||
* When true (default), Realtime Agent keeps one provider session/socket open
|
||||
* across turns (persistent conversation). When false, falls back to the
|
||||
* legacy one-session-per-utterance path. See
|
||||
* docs/plans/2026-05-24-realtime-persistent-session.md.
|
||||
*/
|
||||
val realtimePersistentSession: Boolean = true,
|
||||
)
|
||||
|
||||
class VoicePreferencesRepository(private val context: Context) {
|
||||
enum class VoiceEngineMode(val storageValue: String) {
|
||||
HermesVoiceOutput("hermes_voice_output"),
|
||||
RealtimeAgent("realtime_agent");
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String?): VoiceEngineMode =
|
||||
values().firstOrNull { it.storageValue == value } ?: HermesVoiceOutput
|
||||
}
|
||||
}
|
||||
|
||||
class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>) {
|
||||
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
companion object {
|
||||
private val KEY_ENGINE_MODE = stringPreferencesKey("voice_engine_mode")
|
||||
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
|
||||
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
|
||||
private val KEY_AUTO_TTS = booleanPreferencesKey("voice_auto_tts")
|
||||
private val KEY_LANGUAGE = stringPreferencesKey("voice_language")
|
||||
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
|
||||
private val KEY_REALTIME_PERSISTENT_SESSION =
|
||||
booleanPreferencesKey("voice_realtime_persistent_session")
|
||||
|
||||
const val DEFAULT_ENGINE_MODE = "hermes_voice_output"
|
||||
const val DEFAULT_INTERACTION_MODE = "tap"
|
||||
const val DEFAULT_SILENCE_THRESHOLD_MS = 3000L
|
||||
const val DEFAULT_AUTO_TTS = false
|
||||
const val DEFAULT_LANGUAGE = ""
|
||||
const val DEFAULT_REALTIME_TRACE_DETAILS = false
|
||||
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
|
||||
}
|
||||
|
||||
val settings: Flow<VoiceSettings> = context.relayDataStore.data.map { prefs ->
|
||||
VoiceSettings(
|
||||
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
||||
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
||||
autoTts = prefs[KEY_AUTO_TTS] ?: DEFAULT_AUTO_TTS,
|
||||
language = prefs[KEY_LANGUAGE] ?: DEFAULT_LANGUAGE,
|
||||
)
|
||||
val settings: Flow<VoiceSettings> = dataStore.data
|
||||
.map { prefs ->
|
||||
VoiceSettings(
|
||||
engineMode = VoiceEngineMode.fromStorage(
|
||||
prefs[KEY_ENGINE_MODE] ?: DEFAULT_ENGINE_MODE,
|
||||
).storageValue,
|
||||
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
||||
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
||||
autoTts = prefs[KEY_AUTO_TTS] ?: DEFAULT_AUTO_TTS,
|
||||
language = prefs[KEY_LANGUAGE] ?: DEFAULT_LANGUAGE,
|
||||
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
|
||||
?: DEFAULT_REALTIME_TRACE_DETAILS,
|
||||
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
|
||||
?: DEFAULT_REALTIME_PERSISTENT_SESSION,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun setEngineMode(mode: VoiceEngineMode) {
|
||||
dataStore.edit { it[KEY_ENGINE_MODE] = mode.storageValue }
|
||||
}
|
||||
|
||||
suspend fun setInteractionMode(mode: String) {
|
||||
context.relayDataStore.edit { it[KEY_INTERACTION_MODE] = mode }
|
||||
dataStore.edit { it[KEY_INTERACTION_MODE] = mode }
|
||||
}
|
||||
|
||||
suspend fun setSilenceThresholdMs(ms: Long) {
|
||||
context.relayDataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
|
||||
dataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
|
||||
}
|
||||
|
||||
suspend fun setAutoTts(enabled: Boolean) {
|
||||
context.relayDataStore.edit { it[KEY_AUTO_TTS] = enabled }
|
||||
dataStore.edit { it[KEY_AUTO_TTS] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setLanguage(language: String) {
|
||||
context.relayDataStore.edit { it[KEY_LANGUAGE] = language }
|
||||
dataStore.edit { it[KEY_LANGUAGE] = language }
|
||||
}
|
||||
|
||||
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setRealtimePersistentSession(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_REALTIME_PERSISTENT_SESSION] = enabled }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.hermesandroid.relay.diagnostics
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
enum class DiagnosticCategory(val label: String) {
|
||||
Api("API"),
|
||||
Relay("Relay"),
|
||||
Session("Session"),
|
||||
Voice("Voice"),
|
||||
Endpoint("Route"),
|
||||
Auth("Auth"),
|
||||
}
|
||||
|
||||
enum class DiagnosticSeverity {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
data class DiagnosticLogEntry(
|
||||
val timestampMs: Long,
|
||||
val category: DiagnosticCategory,
|
||||
val severity: DiagnosticSeverity,
|
||||
val title: String,
|
||||
val detail: String? = null,
|
||||
val endpointRole: String? = null,
|
||||
val url: String? = null,
|
||||
val elapsedMs: Long? = null,
|
||||
)
|
||||
|
||||
object DiagnosticsLog {
|
||||
private const val MAX_ENTRIES = 200
|
||||
private const val MAX_TEXT_LENGTH = 180
|
||||
|
||||
private val lock = Any()
|
||||
private val _entries = MutableStateFlow<List<DiagnosticLogEntry>>(emptyList())
|
||||
val entries: StateFlow<List<DiagnosticLogEntry>> = _entries.asStateFlow()
|
||||
|
||||
fun record(
|
||||
category: DiagnosticCategory,
|
||||
severity: DiagnosticSeverity = DiagnosticSeverity.Info,
|
||||
title: String,
|
||||
detail: String? = null,
|
||||
endpointRole: String? = null,
|
||||
url: String? = null,
|
||||
elapsedMs: Long? = null,
|
||||
) {
|
||||
val entry = DiagnosticLogEntry(
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
category = category,
|
||||
severity = severity,
|
||||
title = clean(title) ?: title.take(MAX_TEXT_LENGTH),
|
||||
detail = clean(detail),
|
||||
endpointRole = clean(endpointRole),
|
||||
url = sanitizeUrl(url),
|
||||
elapsedMs = elapsedMs,
|
||||
)
|
||||
synchronized(lock) {
|
||||
_entries.value = (_entries.value + entry).takeLast(MAX_ENTRIES)
|
||||
}
|
||||
}
|
||||
|
||||
fun recent(
|
||||
categories: Set<DiagnosticCategory>? = null,
|
||||
limit: Int = 30,
|
||||
): List<DiagnosticLogEntry> {
|
||||
val source = entries.value.asReversed()
|
||||
val filtered = if (categories == null) {
|
||||
source
|
||||
} else {
|
||||
source.filter { it.category in categories }
|
||||
}
|
||||
return filtered.take(limit.coerceAtLeast(0))
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(lock) {
|
||||
_entries.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun sanitizeUrl(value: String?): String? {
|
||||
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val noQuery = trimmed.substringBefore('?').substringBefore('#')
|
||||
val schemeEnd = noQuery.indexOf("://")
|
||||
val noUserInfo = if (schemeEnd >= 0) {
|
||||
val prefix = noQuery.substring(0, schemeEnd + 3)
|
||||
val rest = noQuery.substring(schemeEnd + 3)
|
||||
val slash = rest.indexOf('/').let { if (it < 0) rest.length else it }
|
||||
val authority = rest.substring(0, slash)
|
||||
val path = rest.substring(slash)
|
||||
val safeAuthority = authority.substringAfterLast('@')
|
||||
prefix + safeAuthority + path
|
||||
} else {
|
||||
noQuery
|
||||
}
|
||||
return noUserInfo.take(MAX_TEXT_LENGTH)
|
||||
}
|
||||
|
||||
private fun clean(value: String?): String? {
|
||||
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return trimmed
|
||||
.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
|
||||
"${it.groupValues[1]}=[hidden]"
|
||||
}
|
||||
.take(MAX_TEXT_LENGTH)
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,13 @@ class ChannelMultiplexer {
|
||||
// flavor or by the master enable toggle in the UI).
|
||||
"bridge" -> handlers["bridge"]?.onMessage(envelope)
|
||||
// === END PHASE3-accessibility ===
|
||||
// Pairing channel — host-originated pushes that concern the
|
||||
// paired session itself (e.g. `profiles.updated` when the
|
||||
// server rescans its ~/.hermes/profiles tree). Routed to
|
||||
// whatever handler registered for "pairing"; AuthManager
|
||||
// picks it up so the existing agentProfiles flow updates
|
||||
// without requiring a re-pair round-trip.
|
||||
"pairing" -> handlers["pairing"]?.onMessage(envelope)
|
||||
else -> {
|
||||
// Unknown channel — ignore
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.auth.CertPinStore
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -10,7 +20,9 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.CertificatePinner
|
||||
@@ -59,7 +71,29 @@ class ConnectionManager(
|
||||
* Defaults to always-allow for tests and legacy call sites. Production
|
||||
* wiring passes the AuthManager gate from [ConnectionViewModel].
|
||||
*/
|
||||
private val reconnectGate: () -> Boolean = { true }
|
||||
private val reconnectGate: () -> Boolean = { true },
|
||||
/**
|
||||
* Application context used to register the [ConnectivityManager
|
||||
* .NetworkCallback] that drives ADR 24's network-aware re-resolution.
|
||||
* Nullable for legacy call sites / tests — when null, the callback is
|
||||
* never registered and the manager degrades to single-URL behavior.
|
||||
*/
|
||||
private val context: Context? = null,
|
||||
/**
|
||||
* ADR 24 multi-endpoint resolver. When provided alongside [context] and
|
||||
* a non-null [deviceIdProvider], every call to [connect] first consults
|
||||
* the resolver before opening the WSS; on network changes the resolver
|
||||
* is re-run and we hot-swap to the new winner. When null the manager
|
||||
* uses the caller-supplied URL verbatim (pre-ADR-24 behavior).
|
||||
*/
|
||||
private val endpointResolver: EndpointResolver? = null,
|
||||
/**
|
||||
* Suspending supplier for the active device id. Used to key into
|
||||
* [PairingPreferences.getDeviceEndpoints] during resolution. `null`
|
||||
* disables multi-endpoint resolution even when [endpointResolver] is
|
||||
* non-null — the manager falls back to the single-URL path.
|
||||
*/
|
||||
private val deviceIdProvider: (suspend () -> String?)? = null,
|
||||
) {
|
||||
private val supervisorJob = SupervisorJob()
|
||||
private val scope = CoroutineScope(supervisorJob + Dispatchers.IO)
|
||||
@@ -90,10 +124,19 @@ class ConnectionManager(
|
||||
@Volatile
|
||||
private var client: OkHttpClient = buildClient()
|
||||
|
||||
@Volatile
|
||||
private var webSocket: WebSocket? = null
|
||||
|
||||
@Volatile
|
||||
private var serverUrl: String? = null
|
||||
private var reconnectAttempt = 0
|
||||
private var shouldReconnect = true
|
||||
// Last HTTP status seen during WSS upgrade, captured in onFailure.
|
||||
// Used by scheduleReconnect() to pick an appropriate backoff — notably
|
||||
// a much longer one when the server is rate-limiting us (HTTP 429) so
|
||||
// we don't re-fill the ban bucket and brick our own auth window.
|
||||
@Volatile
|
||||
private var lastUpgradeResponseCode: Int? = null
|
||||
|
||||
private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
@@ -106,27 +149,128 @@ class ConnectionManager(
|
||||
private val _isInsecureConnection = MutableStateFlow(false)
|
||||
val isInsecureConnection: StateFlow<Boolean> = _isInsecureConnection.asStateFlow()
|
||||
|
||||
// ADR 24 — currently-active endpoint candidate. Null when the manager is
|
||||
// running in legacy single-URL mode (no resolver wired, no candidates in
|
||||
// DataStore, or resolve() returned null and we fell back to the caller's
|
||||
// URL). Surfaced through [activeEndpoint] for the UI status chip + the
|
||||
// Endpoints card in Settings.
|
||||
private val _activeEndpoint = MutableStateFlow<EndpointCandidate?>(null)
|
||||
val activeEndpoint: StateFlow<EndpointCandidate?> = _activeEndpoint.asStateFlow()
|
||||
|
||||
/**
|
||||
* Manual role override. When non-null, the resolver's output is replaced
|
||||
* with whichever candidate in the stored list matches this role (case-
|
||||
* insensitive) — provided it's reachable. Reachability still gates: a
|
||||
* user-preferred endpoint that doesn't respond to HEAD /health falls
|
||||
* back through the normal priority chain.
|
||||
*
|
||||
* Cleared on [disconnect] per ADR 24's "clears on disconnect" semantics
|
||||
* from the UI card.
|
||||
*/
|
||||
@Volatile
|
||||
private var manualRoleOverride: String? = null
|
||||
|
||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionManager"
|
||||
private const val MAX_BACKOFF_MS = 30_000L
|
||||
private const val BASE_BACKOFF_MS = 1_000L
|
||||
// Matches plugin.relay.auth._BLOCK_SECONDS (5 min). If we see 429
|
||||
// on the WSS upgrade, we're IP-banned server-side — retrying at
|
||||
// our normal 1-30s cadence re-fills the ban bucket and keeps us
|
||||
// banned forever. Waiting at least as long as the server's block
|
||||
// duration lets the ban expire naturally.
|
||||
private const val RATE_LIMIT_BACKOFF_MS = 300_000L
|
||||
}
|
||||
|
||||
fun setInsecureMode(enabled: Boolean) {
|
||||
if (_insecureMode.value == enabled) {
|
||||
return
|
||||
}
|
||||
_insecureMode.value = enabled
|
||||
if (enabled) {
|
||||
Log.w(TAG, "⚠ INSECURE MODE ENABLED — ws:// connections allowed. Do NOT use in production.")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Insecure relay mode enabled",
|
||||
detail = "ws:// connections are allowed",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun connect(url: String) {
|
||||
// Register the network callback on the first connect attempt. We
|
||||
// only do this once per manager lifetime; [shutdown] tears it down.
|
||||
ensureNetworkCallbackRegistered()
|
||||
|
||||
// ADR 24: if we have a resolver + device id, try the multi-endpoint
|
||||
// path first. Fall back to the caller-supplied URL whenever the
|
||||
// resolver returns nothing — preserving pre-ADR-24 single-URL
|
||||
// behavior for freshly-upgraded installs and for v1/v2 QRs where
|
||||
// the synthesized list just collapses to the same URL anyway.
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val targetUrl = resolved?.relay?.url ?: url
|
||||
if (resolved != null) {
|
||||
_activeEndpoint.value = resolved
|
||||
Log.i(TAG, "connect: resolver picked role=${resolved.role} " +
|
||||
"relay=${resolved.relay.url} (fallback would have been $url)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay route selected",
|
||||
endpointRole = resolved.role,
|
||||
url = resolved.relay.url,
|
||||
)
|
||||
} else {
|
||||
_activeEndpoint.value = null
|
||||
Log.d(TAG, "connect: no resolver winner — using supplied url $url")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Using configured relay URL",
|
||||
detail = "No resolver winner",
|
||||
url = url,
|
||||
)
|
||||
}
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [connect] but bypasses the resolver — used by the network-
|
||||
* change callback when we've already picked a winner and just want to
|
||||
* reopen the socket against that URL. Keeping this separate prevents
|
||||
* the callback from re-running the resolve loop inside another
|
||||
* resolve loop.
|
||||
*/
|
||||
private fun connectToUrlOnMainPath(
|
||||
url: String,
|
||||
replaceReason: String = "Relay socket replaced",
|
||||
) {
|
||||
val isInsecure = url.startsWith("ws://") && !url.startsWith("wss://")
|
||||
if (isInsecure && !_insecureMode.value) {
|
||||
Log.e(TAG, "Blocked ws:// connection — insecure mode is disabled. Use wss:// or enable insecure mode in Settings.")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay socket blocked",
|
||||
detail = "ws:// is disabled",
|
||||
url = url,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
|
||||
Log.e(TAG, "Invalid URL scheme — must start with ws:// or wss://")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay socket URL invalid",
|
||||
detail = "URL must start with ws:// or wss://",
|
||||
url = url,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -135,16 +279,247 @@ class ConnectionManager(
|
||||
// hits the HTTP root and comes back as 404 Not Found during the
|
||||
// upgrade handshake. We still accept an explicit path if present.
|
||||
val normalized = normalizeRelayUrl(url)
|
||||
val existingState = _connectionState.value
|
||||
if (serverUrl == normalized &&
|
||||
(existingState == ConnectionState.Connecting ||
|
||||
existingState == ConnectionState.Connected ||
|
||||
existingState == ConnectionState.Reconnecting)
|
||||
) {
|
||||
Log.i(TAG, "connect: already ${existingState.name.lowercase()} to $normalized — skipping duplicate open")
|
||||
return
|
||||
}
|
||||
val previousSocket = webSocket
|
||||
|
||||
_isInsecureConnection.value = isInsecure
|
||||
if (isInsecure) {
|
||||
Log.w(TAG, "⚠ Connecting over INSECURE ws:// to: $normalized")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Opening insecure relay socket",
|
||||
url = normalized,
|
||||
)
|
||||
} else {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Opening relay socket",
|
||||
url = normalized,
|
||||
)
|
||||
}
|
||||
|
||||
serverUrl = normalized
|
||||
shouldReconnect = true
|
||||
reconnectAttempt = 0
|
||||
doConnect(normalized)
|
||||
doConnect(normalized, previousSocket, replaceReason)
|
||||
}
|
||||
|
||||
// ----- ADR 24 — multi-endpoint resolution --------------------------------
|
||||
|
||||
/**
|
||||
* Load the device's stored [EndpointCandidate] list and hand it to
|
||||
* [EndpointResolver.resolve]. Returns `null` when any precondition is
|
||||
* missing (no resolver wired, no context, no device id, empty list) OR
|
||||
* when no candidate was reachable — caller then falls back to the
|
||||
* legacy single-URL path.
|
||||
*
|
||||
* Wraps the DataStore read in a 1-second timeout; if DataStore stalls
|
||||
* for any reason we don't block the connect loop forever.
|
||||
*/
|
||||
suspend fun resolveBestEndpoint(): EndpointCandidate? = resolveBestEndpointSafe()
|
||||
|
||||
private suspend fun resolveBestEndpointSafe(): EndpointCandidate? {
|
||||
val resolver = endpointResolver ?: return null
|
||||
val ctx = context ?: return null
|
||||
val devicePull = deviceIdProvider ?: return null
|
||||
|
||||
val deviceId = try {
|
||||
withTimeoutOrNull(1_000L) { devicePull() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
val endpoints: List<EndpointCandidate> = try {
|
||||
withTimeoutOrNull(1_000L) {
|
||||
PairingPreferences.getDeviceEndpoints(ctx, deviceId).first()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: emptyList()
|
||||
|
||||
if (endpoints.isEmpty()) return null
|
||||
|
||||
// Manual override: if the user pinned a role in the Endpoints card,
|
||||
// try that one first; fall through to the strict-priority algorithm
|
||||
// if it isn't reachable.
|
||||
manualRoleOverride?.let { preferredRole ->
|
||||
val preferred = endpoints.firstOrNull {
|
||||
it.role.equals(preferredRole, ignoreCase = true)
|
||||
}
|
||||
if (preferred != null) {
|
||||
// Single-element list still respects the 2s probe gate.
|
||||
val winner = resolver.resolve(listOf(preferred))
|
||||
if (winner != null) return winner
|
||||
Log.i(TAG, "manualRoleOverride=$preferredRole not reachable — " +
|
||||
"falling through to strict-priority resolve")
|
||||
}
|
||||
}
|
||||
|
||||
return resolver.resolve(endpoints)
|
||||
}
|
||||
|
||||
/**
|
||||
* User-triggered re-probe. Forces a fresh resolve + reconnect regardless
|
||||
* of cache state. Backs the "Probe now" row action in the Endpoints card.
|
||||
*/
|
||||
fun probeAndReconnect() {
|
||||
endpointResolver?.clearCache()
|
||||
val current = serverUrl
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val targetUrl = resolved?.relay?.url ?: current ?: return@launch
|
||||
val normalizedTarget = normalizeRelayUrl(targetUrl)
|
||||
_activeEndpoint.value = resolved
|
||||
// Reconnect when the winner changed, and also when the socket is
|
||||
// stale/disconnected on the same winner. The latter makes the
|
||||
// "Use now" route action an actual recovery path after Wi-Fi drop
|
||||
// instead of a no-op that only updates preference state.
|
||||
if (current == null) {
|
||||
if (shouldReconnect && reconnectGate()) {
|
||||
Log.i(TAG, "probeAndReconnect: no current socket — connecting to $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
}
|
||||
} else if (normalizedTarget != current) {
|
||||
Log.i(TAG, "probeAndReconnect: swapping $current → $normalizedTarget")
|
||||
connectToUrlOnMainPath(targetUrl, "Endpoint re-probe")
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
shouldReconnect &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "probeAndReconnect: current route is stale — reconnecting $current")
|
||||
doConnect(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run endpoint resolution and publish the winner without forcing a
|
||||
* WSS reconnect. Used by HTTP-only surfaces (chat/voice/relay HTTP)
|
||||
* so they can follow LAN/Tailscale/VPN route changes even when the relay
|
||||
* socket is currently disconnected or intentionally not paired.
|
||||
*/
|
||||
suspend fun refreshActiveEndpoint(): EndpointCandidate? {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
_activeEndpoint.value = resolved
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a specific role as the preferred endpoint. Cleared on [disconnect]
|
||||
* per the Endpoints-card contract. No-op until the next connect / probe
|
||||
* cycle — call [probeAndReconnect] to apply immediately.
|
||||
*/
|
||||
fun setManualRoleOverride(role: String?) {
|
||||
manualRoleOverride = role?.takeIf { it.isNotBlank() }
|
||||
Log.i(TAG, "manualRoleOverride now=${manualRoleOverride ?: "(cleared)"}")
|
||||
}
|
||||
|
||||
fun getManualRoleOverride(): String? = manualRoleOverride
|
||||
|
||||
private fun markActiveEndpointUnreachable(reason: String) {
|
||||
val active = _activeEndpoint.value ?: return
|
||||
endpointResolver?.markUnreachable(active)
|
||||
Log.i(TAG, "marked endpoint role=${active.role} unreachable ($reason)")
|
||||
}
|
||||
|
||||
private fun resolveAndSwitchIfNeeded(closeReason: String) {
|
||||
if (endpointResolver == null) return
|
||||
val current = serverUrl ?: return
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == null) {
|
||||
_activeEndpoint.value = null
|
||||
return@launch
|
||||
}
|
||||
val newUrl = resolved.relay.url
|
||||
val normalizedNew = normalizeRelayUrl(newUrl)
|
||||
_activeEndpoint.value = resolved
|
||||
if (normalizedNew != current) {
|
||||
Log.i(TAG, "endpoint fallback: swapping $current → $normalizedNew")
|
||||
connectToUrlOnMainPath(newUrl, closeReason)
|
||||
} else if (_connectionState.value == ConnectionState.Disconnected &&
|
||||
shouldReconnect &&
|
||||
reconnectGate()
|
||||
) {
|
||||
Log.i(TAG, "endpoint fallback: same winner is disconnected — reconnecting $current")
|
||||
doConnect(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureNetworkCallbackRegistered() {
|
||||
val ctx = context ?: return
|
||||
if (networkCallback != null) return
|
||||
val cm = ctx.getSystemService(ConnectivityManager::class.java) ?: return
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
Log.i(TAG, "network onAvailable — re-evaluating endpoint")
|
||||
if (endpointResolver == null) return
|
||||
val url = serverUrl ?: return
|
||||
endpointResolver.clearCache()
|
||||
scope.launch {
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val newUrl = resolved?.relay?.url
|
||||
if (newUrl == null) {
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val normalizedNew = normalizeRelayUrl(newUrl)
|
||||
_activeEndpoint.value = resolved
|
||||
// Only swap if the winner actually differs from the
|
||||
// currently-connected URL. Avoids dropping a healthy
|
||||
// socket on a no-op network flap (Wi-Fi scan, cell
|
||||
// handover that ends up on the same route, etc.).
|
||||
if (normalizedNew != url) {
|
||||
Log.i(TAG, "network change: swapping $url → $normalizedNew")
|
||||
connectToUrlOnMainPath(newUrl, "Network change — switching endpoint")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
Log.i(TAG, "network onLost — marking active endpoint unreachable and resolving fallback")
|
||||
endpointResolver?.clearCache()
|
||||
markActiveEndpointUnreachable("network lost")
|
||||
resolveAndSwitchIfNeeded("Network lost — switching endpoint")
|
||||
}
|
||||
}
|
||||
try {
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
.build()
|
||||
cm.registerNetworkCallback(request, callback)
|
||||
networkCallback = callback
|
||||
Log.i(TAG, "registered NetworkCallback for ADR 24 re-resolution")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "registerNetworkCallback failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterNetworkCallback() {
|
||||
val ctx = context ?: return
|
||||
val cb = networkCallback ?: return
|
||||
try {
|
||||
val cm = ctx.getSystemService(ConnectivityManager::class.java)
|
||||
cm?.unregisterNetworkCallback(cb)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "unregisterNetworkCallback failed: ${e.message}")
|
||||
} finally {
|
||||
networkCallback = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeRelayUrl(url: String): String {
|
||||
@@ -165,14 +540,26 @@ class ConnectionManager(
|
||||
|
||||
fun disconnect() {
|
||||
shouldReconnect = false
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay socket disconnect requested",
|
||||
url = serverUrl,
|
||||
)
|
||||
webSocket?.close(1000, "Client disconnect")
|
||||
webSocket = null
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
_isInsecureConnection.value = false
|
||||
// ADR 24: clear manual override on explicit disconnect — the
|
||||
// Routes card's "Prefer this route" menu contract is that it lasts
|
||||
// until the user disconnects, then resets to resolver-picked.
|
||||
manualRoleOverride = null
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
disconnect()
|
||||
unregisterNetworkCallback()
|
||||
supervisorJob.cancel()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
@@ -183,17 +570,38 @@ class ConnectionManager(
|
||||
webSocket?.send(text)
|
||||
}
|
||||
|
||||
private fun doConnect(url: String) {
|
||||
private fun isActiveSocket(socket: WebSocket): Boolean = webSocket === socket
|
||||
|
||||
private fun doConnect(
|
||||
url: String,
|
||||
previousSocketToClose: WebSocket? = null,
|
||||
replaceReason: String = "Relay socket replaced",
|
||||
) {
|
||||
val existingState = _connectionState.value
|
||||
if (previousSocketToClose == null &&
|
||||
serverUrl == url &&
|
||||
(existingState == ConnectionState.Connecting ||
|
||||
existingState == ConnectionState.Connected ||
|
||||
existingState == ConnectionState.Reconnecting)
|
||||
) {
|
||||
Log.i(TAG, "doConnect: already ${existingState.name.lowercase()} to $url — skipping duplicate open")
|
||||
return
|
||||
}
|
||||
|
||||
_connectionState.value = if (reconnectAttempt > 0) {
|
||||
ConnectionState.Reconnecting
|
||||
} else {
|
||||
ConnectionState.Connecting
|
||||
}
|
||||
|
||||
scope.launch { doConnectInternal(url) }
|
||||
scope.launch { doConnectInternal(url, previousSocketToClose, replaceReason) }
|
||||
}
|
||||
|
||||
private fun doConnectInternal(url: String) {
|
||||
private fun doConnectInternal(
|
||||
url: String,
|
||||
previousSocketToClose: WebSocket? = null,
|
||||
replaceReason: String = "Relay socket replaced",
|
||||
) {
|
||||
// Rebuild the client so the CertificatePinner picks up the current
|
||||
// pin store snapshot — crucial right after applyServerIssuedCodeAndReset
|
||||
// wipes a pin for re-pair. buildClient() does a tiny DataStore read
|
||||
@@ -205,11 +613,24 @@ class ConnectionManager(
|
||||
.build()
|
||||
|
||||
Log.i(TAG, "doConnect: opening WSS to $url")
|
||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
val newSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
if (!isActiveSocket(webSocket)) {
|
||||
Log.i(TAG, "onOpen: stale WSS handshake ignored ($url)")
|
||||
runCatching { webSocket.close(1000, "Stale relay socket") }
|
||||
webSocket.cancel()
|
||||
return
|
||||
}
|
||||
reconnectAttempt = 0
|
||||
lastUpgradeResponseCode = null
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
Log.i(TAG, "onOpen: WSS handshake complete ($url)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay socket connected",
|
||||
url = url,
|
||||
)
|
||||
|
||||
// TOFU: record the peer cert fingerprint if we don't have one
|
||||
// yet. OkHttp populates response.handshake when the connection
|
||||
@@ -232,6 +653,10 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (!isActiveSocket(webSocket)) {
|
||||
Log.i(TAG, "onMessage: stale WSS envelope ignored ($url)")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val envelope = json.decodeFromString<Envelope>(text)
|
||||
multiplexer.route(envelope)
|
||||
@@ -246,17 +671,55 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (!isActiveSocket(webSocket)) {
|
||||
Log.i(TAG, "onClosed: stale WSS close ignored ($url code=$code reason=$reason)")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "onClosed: code=$code reason=$reason")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay socket closed",
|
||||
detail = "code=$code reason=$reason",
|
||||
url = url,
|
||||
)
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.w(TAG, "onFailure: ${t.javaClass.simpleName}: ${t.message} (responseCode=${response?.code})")
|
||||
if (!isActiveSocket(webSocket)) {
|
||||
Log.i(TAG, "onFailure: stale WSS failure ignored ($url ${t.javaClass.simpleName}: ${t.message})")
|
||||
return
|
||||
}
|
||||
val code = response?.code
|
||||
Log.w(TAG, "onFailure: ${t.javaClass.simpleName}: ${t.message} (responseCode=$code)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay socket failed",
|
||||
detail = listOfNotNull(
|
||||
t.javaClass.simpleName,
|
||||
t.message,
|
||||
code?.let { "HTTP $it" },
|
||||
).joinToString(": "),
|
||||
url = url,
|
||||
)
|
||||
lastUpgradeResponseCode = code
|
||||
if (response == null) {
|
||||
markActiveEndpointUnreachable("socket failure")
|
||||
}
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
webSocket = newSocket
|
||||
previousSocketToClose
|
||||
?.takeIf { it !== newSocket }
|
||||
?.let { staleSocket ->
|
||||
runCatching { staleSocket.close(1000, replaceReason) }
|
||||
staleSocket.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
@@ -269,6 +732,13 @@ class ConnectionManager(
|
||||
// the rate limiter and block ourselves.
|
||||
if (!reconnectGate()) {
|
||||
Log.i(TAG, "scheduleReconnect: gate says no pair context — aborting retry")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Session,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay reconnect skipped",
|
||||
detail = "No paired session or pending pair code",
|
||||
url = serverUrl,
|
||||
)
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
return
|
||||
}
|
||||
@@ -276,8 +746,33 @@ class ConnectionManager(
|
||||
val url = serverUrl ?: return
|
||||
reconnectAttempt++
|
||||
|
||||
val backoffMs = (BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
|
||||
.coerceAtMost(MAX_BACKOFF_MS)
|
||||
// Server-issued 429 means we're IP-banned — keep retrying at our
|
||||
// normal exponential cadence and we'll re-fill the ban bucket on
|
||||
// every attempt, extending the ban indefinitely. Wait out the
|
||||
// server's full block window instead.
|
||||
val backoffMs = if (lastUpgradeResponseCode == 429) {
|
||||
Log.i(TAG, "scheduleReconnect: rate-limited (429) — backing off ${RATE_LIMIT_BACKOFF_MS}ms")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay reconnect delayed",
|
||||
detail = "Rate limited; retrying in ${RATE_LIMIT_BACKOFF_MS / 1000}s",
|
||||
url = url,
|
||||
)
|
||||
RATE_LIMIT_BACKOFF_MS
|
||||
} else {
|
||||
(BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
|
||||
.coerceAtMost(MAX_BACKOFF_MS)
|
||||
}
|
||||
if (lastUpgradeResponseCode != 429) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay reconnect scheduled",
|
||||
detail = "Retrying in ${backoffMs / 1000}s",
|
||||
url = url,
|
||||
)
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
delay(backoffMs)
|
||||
@@ -285,7 +780,19 @@ class ConnectionManager(
|
||||
// expires, auth state may have changed (e.g., user hit Revoke
|
||||
// during the retry window).
|
||||
if (shouldReconnect && reconnectGate()) {
|
||||
doConnect(url)
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val targetUrl = resolved?.relay?.url
|
||||
if (resolved != null) {
|
||||
_activeEndpoint.value = resolved
|
||||
} else {
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
if (targetUrl != null && normalizeRelayUrl(targetUrl) != url) {
|
||||
Log.i(TAG, "scheduleReconnect: switching $url → ${normalizeRelayUrl(targetUrl)}")
|
||||
connectToUrlOnMainPath(targetUrl)
|
||||
} else {
|
||||
doConnect(url)
|
||||
}
|
||||
} else if (!reconnectGate()) {
|
||||
Log.i(TAG, "scheduleReconnect: gate turned false during backoff — aborting retry")
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
|
||||
@@ -19,32 +19,69 @@ class ConnectivityObserver(private val context: Context) {
|
||||
|
||||
fun observe(): Flow<Status> = callbackFlow {
|
||||
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||
if (connectivityManager == null) {
|
||||
trySend(Status.Unavailable)
|
||||
awaitClose { }
|
||||
return@callbackFlow
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun hasAnyInternetNetwork(): Boolean =
|
||||
connectivityManager.allNetworks.any { network ->
|
||||
hasInternetCapability(connectivityManager.getNetworkCapabilities(network))
|
||||
}
|
||||
|
||||
fun sendCurrentStatus(fallbackWhenNone: Status) {
|
||||
trySend(statusForInternetAvailability(hasAnyInternetNetwork(), fallbackWhenNone))
|
||||
}
|
||||
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(Status.Available)
|
||||
sendCurrentStatus(Status.Available)
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities,
|
||||
) {
|
||||
sendCurrentStatus(
|
||||
if (hasInternetCapability(networkCapabilities)) {
|
||||
Status.Available
|
||||
} else {
|
||||
Status.Lost
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(Status.Lost)
|
||||
sendCurrentStatus(Status.Lost)
|
||||
}
|
||||
|
||||
override fun onUnavailable() {
|
||||
trySend(Status.Unavailable)
|
||||
sendCurrentStatus(Status.Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
.build()
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
|
||||
// Emit current state
|
||||
val activeNetwork = connectivityManager.activeNetwork
|
||||
val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
|
||||
val isConnected = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
||||
trySend(if (isConnected) Status.Available else Status.Unavailable)
|
||||
sendCurrentStatus(Status.Unavailable)
|
||||
|
||||
awaitClose {
|
||||
connectivityManager.unregisterNetworkCallback(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun hasInternetCapability(caps: NetworkCapabilities?): Boolean =
|
||||
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
||||
|
||||
internal fun statusForInternetAvailability(
|
||||
hasAnyInternetNetwork: Boolean,
|
||||
fallbackWhenNone: ConnectivityObserver.Status,
|
||||
): ConnectivityObserver.Status =
|
||||
if (hasAnyInternetNetwork) ConnectivityObserver.Status.Available else fallbackWhenNone
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Picks the highest-priority **reachable** [EndpointCandidate] from a
|
||||
* per-device list, driven by ADR 24 "Multi-endpoint pairing + network-aware
|
||||
* switching" (2026-04-19).
|
||||
*
|
||||
* ### Semantics (locked by ADR 24)
|
||||
*
|
||||
* * **Strict priority.** `priority = 0` is highest. If a priority-0
|
||||
* candidate is reachable we use it; reachability never promotes a lower
|
||||
* priority over a higher one. Reachability is **only** the tiebreaker
|
||||
* among candidates that share the same priority.
|
||||
* * **Reachability probe.** `HEAD ${api.url}/health` with a 2-second
|
||||
* per-candidate timeout. Positive results are cached longer than negative
|
||||
* results so repeated `connect()` calls don't hammer healthy routes, while
|
||||
* transient handoff misses do not pin a good fallback offline.
|
||||
* * **Network-change re-evaluate.** `ConnectionManager`'s network callback
|
||||
* bumps the caller into `resolve()` again on `onAvailable`, and marks the
|
||||
* active endpoint unreachable on `onLost` via [markUnreachable].
|
||||
*
|
||||
* The resolver is pure: no Context, no DataStore, no coroutine scope of its
|
||||
* own. Callers pass the pre-loaded [EndpointCandidate] list (from
|
||||
* `PairingPreferences.getDeviceEndpoints`), we run the probes, we return the
|
||||
* winner. That keeps the resolver testable from plain JUnit with a
|
||||
* MockWebServer stand-in.
|
||||
*
|
||||
* The resolver is **thread-safe** — the probe cache is a
|
||||
* [ConcurrentHashMap] so parallel probes from a race group don't tear it.
|
||||
*/
|
||||
class EndpointResolver(
|
||||
/**
|
||||
* OkHttp client used for probes. Callers pass the shared relay-side
|
||||
* client so TLS trust + DNS cache + cert-pinner state is consistent with
|
||||
* the eventual WSS connect. Internally the resolver applies its own
|
||||
* 2-second timeouts per call via [OkHttpClient.newBuilder], so the input
|
||||
* client's timeouts don't leak into probe behavior.
|
||||
*/
|
||||
private val httpClient: OkHttpClient,
|
||||
/**
|
||||
* Swappable "now" for tests. Production uses [System.currentTimeMillis];
|
||||
* tests feed a mutable clock to exercise the 30-second TTL.
|
||||
*/
|
||||
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||
) {
|
||||
|
||||
/**
|
||||
* Cached probe result. [expiresAt] is `clock()` + [CACHE_TTL_MS] when the
|
||||
* entry was written; after expiry the entry is re-probed.
|
||||
*/
|
||||
private data class CacheEntry(val expiresAt: Long, val reachable: Boolean)
|
||||
|
||||
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "EndpointResolver"
|
||||
/**
|
||||
* Per-candidate HEAD probe timeout. ADR 24 speced 2s which was
|
||||
* tight — LTE hand-off and slow hotel Wi-Fi routinely blew past
|
||||
* 2s on the first packet and got candidates marked unreachable
|
||||
* spuriously. 4s preserves "fast-fail on real outage" while
|
||||
* surviving the flaky-network case.
|
||||
*/
|
||||
const val PROBE_TIMEOUT_MS = 4_000L
|
||||
/**
|
||||
* Successful probe-result cache TTL. Widened from ADR 24's 30s to 60s for
|
||||
* two reasons: (1) HEAD /health on every tab open was burning
|
||||
* battery unnecessarily on mobile, (2) NetworkCallback's
|
||||
* onAvailable / onLost invalidates the cache on real network
|
||||
* changes anyway, so a 60s idle cache is functionally
|
||||
* equivalent. Manual probes (EndpointsCard → "Probe now")
|
||||
* bypass the cache.
|
||||
*/
|
||||
const val CACHE_TTL_MS = 60_000L
|
||||
|
||||
/**
|
||||
* Failed probe-result cache TTL. Keep this intentionally short:
|
||||
* Android may report a new cellular/VPN network before Tailscale has
|
||||
* finished routing, so a single early ConnectException must not keep a
|
||||
* viable fallback route suppressed through the voice resume window.
|
||||
*/
|
||||
const val NEGATIVE_CACHE_TTL_MS = 2_000L
|
||||
|
||||
/**
|
||||
* Stable cache key for a candidate: `"<role>|<api.host>:<api.port>"`.
|
||||
* Roles are preserved case-verbatim (HMAC canonicalization contract)
|
||||
* but hostnames are lowercased — two roles pointing at the same
|
||||
* host:port share reachability state.
|
||||
*/
|
||||
internal fun cacheKey(candidate: EndpointCandidate): String =
|
||||
"${candidate.role}|${candidate.api.host.lowercase()}:${candidate.api.port}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the resolver against [candidates].
|
||||
*
|
||||
* 1. Group by `priority` ascending.
|
||||
* 2. For each priority group, race a HEAD /health probe against every
|
||||
* candidate in the group (2 s per candidate). First 2xx wins; ties
|
||||
* broken by whichever response lands first.
|
||||
* 3. If the entire group is unreachable, fall through to the next
|
||||
* priority group.
|
||||
* 4. If no candidate is reachable, return `null` — the caller falls back
|
||||
* to its legacy single-URL path.
|
||||
*
|
||||
* Candidates with an invalid api URL are skipped without affecting the
|
||||
* priority-group decision (a bad record shouldn't starve out the rest of
|
||||
* its tier). An empty [candidates] list returns null immediately without
|
||||
* touching the network.
|
||||
*/
|
||||
suspend fun resolve(candidates: List<EndpointCandidate>): EndpointCandidate? {
|
||||
if (candidates.isEmpty()) return null
|
||||
|
||||
// Strict priority: sort ascending so priority-0 lands first. Grouping
|
||||
// preserves emitted order within a priority class (DNS SRV parity).
|
||||
val groups = candidates.groupBy { it.priority }.toSortedMap()
|
||||
|
||||
for ((priority, group) in groups) {
|
||||
Log.d(TAG, "probing priority=$priority group (size=${group.size})")
|
||||
val winner = raceGroup(group)
|
||||
if (winner != null) {
|
||||
Log.i(TAG, "resolve winner: role=${winner.role} " +
|
||||
"api=${winner.api.host}:${winner.api.port} priority=$priority")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Endpoint selected",
|
||||
detail = "priority=$priority",
|
||||
endpointRole = winner.role,
|
||||
url = winner.relay.url,
|
||||
)
|
||||
return winner
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "resolve: no reachable candidate across ${candidates.size} record(s)")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "No reachable endpoint",
|
||||
detail = "${candidates.size} configured route(s) failed health probes",
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Race all candidates in [group] (same priority tier) in parallel. First
|
||||
* candidate that reports reachable — whether from cache or a fresh probe
|
||||
* — wins. Null when the entire group is unreachable.
|
||||
*
|
||||
* We **don't** await all probes before picking a winner: the spec calls
|
||||
* for "first 2xx wins" so latency matters. The losing probes' results
|
||||
* still land in the cache, though, so the next call benefits.
|
||||
*/
|
||||
private suspend fun raceGroup(group: List<EndpointCandidate>): EndpointCandidate? {
|
||||
if (group.isEmpty()) return null
|
||||
if (group.size == 1) {
|
||||
val only = group.first()
|
||||
return if (isReachable(only)) only else null
|
||||
}
|
||||
|
||||
// Fast-path: any cached-reachable candidate wins immediately without
|
||||
// touching the network.
|
||||
for (candidate in group) {
|
||||
val cached = probeCache[cacheKey(candidate)]
|
||||
if (cached != null && cached.expiresAt > clock() && cached.reachable) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return coroutineScope {
|
||||
val deferred = group.map { candidate ->
|
||||
async(Dispatchers.IO) {
|
||||
if (isReachable(candidate)) candidate else null
|
||||
}
|
||||
}
|
||||
// Collect results in arrival order: iterate through awaitAll +
|
||||
// pick the first non-null. awaitAll preserves input order, which
|
||||
// means a slow-but-reachable priority-0 candidate would block a
|
||||
// fast-and-reachable sibling. But HEAD /health against a healthy
|
||||
// API route replies in <100ms and the timeout caps stragglers at 2s,
|
||||
// so this is acceptable in practice. A true "first to arrive"
|
||||
// would need kotlinx.coroutines Channel plumbing that's not
|
||||
// worth the weight here.
|
||||
deferred.awaitAll().firstOrNull { it != null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-aware reachability check for a single candidate. Consults
|
||||
* [probeCache] first; on miss or expiry, runs a HEAD /health probe and
|
||||
* records the result.
|
||||
*/
|
||||
private suspend fun isReachable(candidate: EndpointCandidate): Boolean {
|
||||
val key = cacheKey(candidate)
|
||||
val now = clock()
|
||||
val cached = probeCache[key]
|
||||
if (cached != null && cached.expiresAt > now) {
|
||||
Log.d(TAG, "cache hit for $key reachable=${cached.reachable}")
|
||||
return cached.reachable
|
||||
}
|
||||
|
||||
val reachable = probe(candidate)
|
||||
val ttl = if (reachable) CACHE_TTL_MS else NEGATIVE_CACHE_TTL_MS
|
||||
probeCache[key] = CacheEntry(expiresAt = now + ttl, reachable = reachable)
|
||||
return reachable
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot HEAD /health probe against a candidate. 2-second timeout,
|
||||
* no retries — callers that need retry semantics can re-invoke after
|
||||
* the cache expires.
|
||||
*
|
||||
* Returns false on any failure (timeout, I/O, non-2xx, invalid URL).
|
||||
* We never raise: a bad record shouldn't crash the connect loop.
|
||||
*/
|
||||
private suspend fun probe(candidate: EndpointCandidate): Boolean {
|
||||
val startedAtMs = clock()
|
||||
val url = "${candidate.api.url}/health".toHttpUrlOrNull()
|
||||
?: run {
|
||||
Log.w(TAG, "probe: invalid url for role=${candidate.role}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Endpoint probe invalid",
|
||||
detail = "Invalid API URL",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
)
|
||||
return false
|
||||
}
|
||||
val fastClient = httpClient.newBuilder()
|
||||
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.head()
|
||||
.header("Accept", "*/*")
|
||||
.build()
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
|
||||
fastClient.newCall(request).execute().use { resp ->
|
||||
val ok = resp.isSuccessful
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = if (ok) DiagnosticSeverity.Info else DiagnosticSeverity.Warning,
|
||||
title = if (ok) "Endpoint probe ok" else "Endpoint probe failed",
|
||||
detail = if (ok) null else "HTTP ${resp.code}",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
ok
|
||||
}
|
||||
} ?: run {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Endpoint probe timeout",
|
||||
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
false
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Endpoint probe timeout",
|
||||
detail = "No /health response in ${PROBE_TIMEOUT_MS}ms",
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "probe failed role=${candidate.role} " +
|
||||
"host=${candidate.api.host}: ${e.javaClass.simpleName}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Endpoint probe failed",
|
||||
detail = e.javaClass.simpleName,
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.api.url,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark [candidate] unreachable without re-probing. Called from
|
||||
* `ConnectionManager`'s `NetworkCallback.onLost` so the next resolve()
|
||||
* skips the dead endpoint without waiting for its probe to time out.
|
||||
*
|
||||
* The entry is still TTL'd with the short negative TTL so a network-change
|
||||
* transition can skip the known-dead active route without suppressing a
|
||||
* valid fallback for the whole positive cache window.
|
||||
*/
|
||||
fun markUnreachable(candidate: EndpointCandidate) {
|
||||
val key = cacheKey(candidate)
|
||||
probeCache[key] = CacheEntry(
|
||||
expiresAt = clock() + NEGATIVE_CACHE_TTL_MS,
|
||||
reachable = false,
|
||||
)
|
||||
}
|
||||
|
||||
/** Test-only: wipe the probe cache so a fresh run starts clean. */
|
||||
internal fun clearCache() {
|
||||
probeCache.clear()
|
||||
}
|
||||
|
||||
/** Test-only: snapshot the current cache for assertion purposes. */
|
||||
internal fun cacheSnapshot(): Map<String, Pair<Long, Boolean>> =
|
||||
probeCache.mapValues { (_, v) -> v.expiresAt to v.reachable }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.network
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.network.models.CreateSessionRequest
|
||||
import com.hermesandroid.relay.network.models.HermesSseEvent
|
||||
@@ -21,10 +22,9 @@ import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -57,16 +57,16 @@ enum class ChatMode {
|
||||
* The Android client uses this to pick the best chat path automatically when
|
||||
* `streamingEndpoint = "auto"`. The bootstrap-injected vanilla-upstream case
|
||||
* is the interesting one: `sessionsApi=true` (we injected it) but
|
||||
* `sessionsChatStream=false` (we deliberately didn't inject the chat
|
||||
* handler — runs is better). The auto-resolver picks `runs` for chat in that
|
||||
* case while still using sessions endpoints for browse/rename/delete.
|
||||
* `sessionsChatStream=false` (the chat handler is absent). The auto-resolver
|
||||
* now picks OpenAI-compatible chat completions for that case because the route
|
||||
* returns an SSE stream, while `/v1/runs` may be an async JSON run-start API.
|
||||
*/
|
||||
data class ServerCapabilities(
|
||||
/** `/api/sessions` (CRUD) — true on fork, upstream-merged, OR bootstrap-injected. */
|
||||
val sessionsApi: Boolean,
|
||||
/** `/api/sessions/{id}/chat/stream` (SSE) — true ONLY on fork or upstream-merged. */
|
||||
val sessionsChatStream: Boolean,
|
||||
/** `/v1/runs` (structured-event SSE) — standard upstream chat path. */
|
||||
/** `/v1/runs` (structured-event SSE) — true only when explicitly advertised as SSE-compatible. */
|
||||
val runs: Boolean,
|
||||
/** `/v1/chat/completions` — OpenAI-compatible fallback. */
|
||||
val portable: Boolean,
|
||||
@@ -76,6 +76,7 @@ data class ServerCapabilities(
|
||||
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
|
||||
fun preferredChatEndpoint(): String = when {
|
||||
sessionsChatStream -> "sessions"
|
||||
portable -> "completions"
|
||||
runs -> "runs"
|
||||
else -> "sessions" // last-resort: try sessions, will surface a clear error
|
||||
}
|
||||
@@ -98,6 +99,24 @@ data class ServerCapabilities(
|
||||
}
|
||||
}
|
||||
|
||||
internal val HERMES_SKILL_ENDPOINTS = listOf("/v1/skills", "/api/skills")
|
||||
|
||||
internal fun parseSkillListBody(json: Json, body: String): List<SkillInfo>? {
|
||||
try {
|
||||
val parsed = json.decodeFromString<SkillListResponse>(body)
|
||||
val skills = parsed.skills ?: parsed.items ?: parsed.data
|
||||
if (skills != null) return skills
|
||||
} catch (_: Exception) {
|
||||
// Fall through to direct-array compatibility below.
|
||||
}
|
||||
|
||||
try {
|
||||
return json.decodeFromString<List<SkillInfo>>(body)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct HTTP/SSE client for the Hermes API Server.
|
||||
*
|
||||
@@ -193,43 +212,109 @@ class HermesApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Session CRUD ---
|
||||
|
||||
suspend fun listSessions(limit: Int = 50): List<SessionItem> = withContext(Dispatchers.IO) {
|
||||
suspend fun checkSessionsAuthDetailed(): HealthCheckResult = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions?limit=$limit").get().build()
|
||||
val request = authRequest("$baseUrl/api/sessions?limit=1").get().build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
parsed.items ?: parsed.sessions ?: emptyList()
|
||||
when {
|
||||
response.isSuccessful -> HealthCheckResult.Healthy
|
||||
response.code == 401 || response.code == 403 ->
|
||||
HealthCheckResult.Unhealthy("API reachable, but sessions auth failed - check your API key")
|
||||
response.code == 404 ->
|
||||
HealthCheckResult.Unhealthy("API reachable, but /api/sessions is unavailable")
|
||||
else ->
|
||||
HealthCheckResult.Unhealthy("Sessions check returned HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
} catch (e: javax.net.ssl.SSLException) {
|
||||
if (baseUrl.startsWith("https://", ignoreCase = true)) {
|
||||
HealthCheckResult.Unhealthy("TLS handshake failed - try http:// if your server doesn't use HTTPS")
|
||||
} else {
|
||||
HealthCheckResult.Unhealthy("SSL error: ${e.message}")
|
||||
}
|
||||
} catch (e: java.net.ConnectException) {
|
||||
HealthCheckResult.Unhealthy("Connection refused - check the URL and port")
|
||||
} catch (e: java.net.UnknownHostException) {
|
||||
HealthCheckResult.Unhealthy("Server not found - check the hostname")
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
HealthCheckResult.Unhealthy("Connection timed out - is the server running?")
|
||||
} catch (e: IOException) {
|
||||
HealthCheckResult.Unhealthy("Connection failed: ${e.message ?: "I/O error"}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to list sessions: ${e.message}")
|
||||
emptyList()
|
||||
HealthCheckResult.Unhealthy("Unexpected error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createSession(title: String? = null): SessionItem? = withContext(Dispatchers.IO) {
|
||||
// --- Session CRUD ---
|
||||
|
||||
suspend fun listSessionsResult(limit: Int = 50): Result<List<SessionItem>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val reqBody = json.encodeToString(CreateSessionRequest(title = title))
|
||||
val request = authRequest("$baseUrl/api/sessions?limit=$limit").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "List sessions"))
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("List sessions returned an empty response"))
|
||||
}
|
||||
val parsed = json.decodeFromString<SessionListResponse>(body)
|
||||
Result.success(parsed.items ?: parsed.sessions ?: emptyList())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to list sessions: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun listSessions(limit: Int = 50): List<SessionItem> =
|
||||
listSessionsResult(limit).getOrElse { emptyList() }
|
||||
|
||||
suspend fun createSessionResult(
|
||||
title: String? = null,
|
||||
profileName: String? = null,
|
||||
model: String? = null,
|
||||
): Result<SessionItem> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val reqBody = json.encodeToString(
|
||||
CreateSessionRequest(
|
||||
title = title,
|
||||
model = model,
|
||||
profile = AgentDisplay.profileRequestName(profileName),
|
||||
),
|
||||
)
|
||||
val request = authRequest("$baseUrl/api/sessions")
|
||||
.post(reqBody.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
val body = response.body?.string() ?: return@withContext null
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(apiFailure(response, "Create session"))
|
||||
}
|
||||
val body = response.body.string()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(IOException("Create session returned an empty response"))
|
||||
}
|
||||
val parsed = json.decodeFromString<SessionResponse>(body)
|
||||
parsed.session ?: parsed.id?.let {
|
||||
val session = parsed.session ?: parsed.id?.let {
|
||||
SessionItem(id = it, title = parsed.title, model = parsed.model)
|
||||
}
|
||||
session?.let { Result.success(it) }
|
||||
?: Result.failure(IOException("Create session response missing session id"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to create session: ${e.message}")
|
||||
null
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createSession(
|
||||
title: String? = null,
|
||||
profileName: String? = null,
|
||||
model: String? = null,
|
||||
): SessionItem? =
|
||||
createSessionResult(title, profileName, model).getOrNull()
|
||||
|
||||
suspend fun deleteSession(sessionId: String): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId")
|
||||
@@ -275,27 +360,21 @@ class HermesApiClient(
|
||||
// --- Skills ---
|
||||
|
||||
suspend fun getSkills(): List<SkillInfo> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/skills").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
// Try structured response: { "skills": [...] } or { "items": [...] }
|
||||
try {
|
||||
val parsed = json.decodeFromString<SkillListResponse>(body)
|
||||
val skills = parsed.skills ?: parsed.items
|
||||
for (endpoint in HERMES_SKILL_ENDPOINTS) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl$endpoint").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@use
|
||||
val body = response.body?.string() ?: return@use
|
||||
val skills = parseSkillListBody(json, body)
|
||||
if (skills != null) return@withContext skills
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
// Try direct array: [...]
|
||||
try {
|
||||
return@withContext json.decodeFromString<List<SkillInfo>>(body)
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch skills from $endpoint: ${e.message}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch skills: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// --- Server personalities ---
|
||||
@@ -335,10 +414,22 @@ class HermesApiClient(
|
||||
key to ((value as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "")
|
||||
} ?: emptyMap()
|
||||
|
||||
// Default personality: config.display.personality
|
||||
// Default display identity. Upstream Hermes currently uses
|
||||
// config.display.personality for the active persona and often
|
||||
// mirrors the same identity through skin. Accept name-style
|
||||
// aliases too so older or profile-specific configs don't make
|
||||
// Android fall back to the literal "Hermes" label.
|
||||
val display = config["display"] as? JsonObject
|
||||
val defaultName = (display?.get("personality") as? kotlinx.serialization.json.JsonPrimitive)
|
||||
?.content ?: ""
|
||||
val defaultPersonality = display.stringField("personality")
|
||||
val defaultName = firstNonBlank(
|
||||
defaultPersonality.takeUnless { it.equals("default", ignoreCase = true) },
|
||||
display.stringField("agent_name"),
|
||||
display.stringField("assistant_name"),
|
||||
display.stringField("display_name"),
|
||||
display.stringField("name"),
|
||||
display.stringField("skin"),
|
||||
defaultPersonality,
|
||||
)
|
||||
|
||||
PersonalityConfig(
|
||||
names = prompts.keys.toList(),
|
||||
@@ -355,6 +446,16 @@ class HermesApiClient(
|
||||
|
||||
// --- Chat streaming via /api/sessions/{id}/chat/stream ---
|
||||
|
||||
/**
|
||||
* Stream chat via the sessions endpoint.
|
||||
*
|
||||
* @param modelOverride When non-null and non-blank, injects `"model":
|
||||
* "<value>"` at the top level of the session-chat request body,
|
||||
* asking the server to use that model for this turn. When null or
|
||||
* blank the `model` field is omitted entirely and the server falls
|
||||
* back to its session default. Used by the agent-profile picker so
|
||||
* an explicit user choice wins over implicit session/server defaults.
|
||||
*/
|
||||
fun sendChatStream(
|
||||
sessionId: String,
|
||||
message: String,
|
||||
@@ -390,31 +491,24 @@ class HermesApiClient(
|
||||
onTurnComplete: () -> Unit,
|
||||
onComplete: () -> Unit,
|
||||
onUsage: (UsageInfo?) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): EventSource {
|
||||
val requestPayload = buildJsonObject {
|
||||
put("message", message)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
// Nest the synthesized OpenAI-format pairs under `messages`.
|
||||
// Stays additive — the upstream sessions handler reads
|
||||
// `message` for the live turn and treats `messages` as
|
||||
// history context to seed the LLM with.
|
||||
put("messages", voiceIntentMessages)
|
||||
}
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let {
|
||||
Log.d(TAG, "sendChatStream: profile=$it")
|
||||
}
|
||||
val requestPayload = buildSessionChatStreamPayload(
|
||||
message = message,
|
||||
systemMessage = systemMessage,
|
||||
attachments = attachments,
|
||||
voiceIntentMessages = voiceIntentMessages,
|
||||
modelOverride = modelOverride,
|
||||
profileName = profileName,
|
||||
)
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId/chat/stream")
|
||||
@@ -610,8 +704,196 @@ class HermesApiClient(
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
}
|
||||
|
||||
// --- OpenAI-compatible chat streaming via /v1/chat/completions ---
|
||||
|
||||
/**
|
||||
* Stream chat through the OpenAI-compatible chat completions endpoint.
|
||||
*
|
||||
* This is the portable SSE fallback for servers that expose
|
||||
* `/v1/chat/completions` but where `/v1/runs` is an async JSON run-start
|
||||
* API rather than an EventSource-compatible stream.
|
||||
*/
|
||||
fun sendChatCompletionsStream(
|
||||
message: String,
|
||||
model: String? = null,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<com.hermesandroid.relay.data.Attachment>? = null,
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
onSessionId: (String) -> Unit,
|
||||
onMessageStarted: (String) -> Unit,
|
||||
onTextDelta: (String) -> Unit,
|
||||
onThinkingDelta: (String) -> Unit,
|
||||
onToolCallStart: (String, String) -> Unit,
|
||||
onToolCallDone: (String, String?) -> Unit,
|
||||
onToolCallFailed: (String, String?) -> Unit,
|
||||
onTurnComplete: () -> Unit,
|
||||
onComplete: () -> Unit,
|
||||
onUsage: (UsageInfo?) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): EventSource {
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
Log.d(TAG, "sendChatCompletionsStream: modelOverride=$modelOverride (profile pick, was model=$model)")
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let {
|
||||
Log.d(TAG, "sendChatCompletionsStream: profile=$it")
|
||||
}
|
||||
val requestPayload = buildChatCompletionsStreamPayload(
|
||||
message = message,
|
||||
model = model,
|
||||
systemMessage = systemMessage,
|
||||
attachments = attachments,
|
||||
voiceIntentMessages = voiceIntentMessages,
|
||||
modelOverride = modelOverride,
|
||||
profileName = profileName,
|
||||
)
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
|
||||
val request = authRequest("$baseUrl/v1/chat/completions")
|
||||
.header("Accept", "text/event-stream")
|
||||
.post(requestBody.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val messageStarted = AtomicBoolean(false)
|
||||
|
||||
val listener = object : EventSourceListener() {
|
||||
override fun onEvent(
|
||||
eventSource: EventSource,
|
||||
id: String?,
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val event = json.decodeFromString<JsonObject>(data)
|
||||
openAiErrorMessage(event)?.let { msg ->
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
openAiUsage(event)?.let { usage ->
|
||||
mainHandler.post { onUsage(usage) }
|
||||
}
|
||||
|
||||
if (messageStarted.compareAndSet(false, true)) {
|
||||
openAiMessageId(event)?.let { messageId ->
|
||||
mainHandler.post { onMessageStarted(messageId) }
|
||||
}
|
||||
}
|
||||
|
||||
openAiReasoningDelta(event)?.let { reasoning ->
|
||||
if (reasoning.isNotEmpty()) {
|
||||
mainHandler.post { onThinkingDelta(reasoning) }
|
||||
}
|
||||
}
|
||||
|
||||
openAiTextDelta(event)?.let { delta ->
|
||||
if (delta.isNotEmpty()) {
|
||||
mainHandler.post { onTextDelta(delta) }
|
||||
}
|
||||
}
|
||||
|
||||
val finishReason = openAiFinishReason(event)
|
||||
if (!finishReason.isNullOrBlank() && completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unparseable chat completion SSE event ($type): ${e.message}\nRaw: $data")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
eventSource: EventSource,
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
t is IOException -> "Connection failed: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
}
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sseFactory.newEventSource(request, listener)
|
||||
}
|
||||
|
||||
private fun openAiChoice(event: JsonObject): JsonObject? =
|
||||
(event["choices"] as? JsonArray)
|
||||
?.firstOrNull()
|
||||
?.let { it as? JsonObject }
|
||||
|
||||
private fun openAiDelta(event: JsonObject): JsonObject? =
|
||||
openAiChoice(event)?.get("delta") as? JsonObject
|
||||
|
||||
private fun openAiTextDelta(event: JsonObject): String? =
|
||||
(openAiDelta(event)?.get("content") as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
private fun openAiReasoningDelta(event: JsonObject): String? {
|
||||
val delta = openAiDelta(event) ?: return null
|
||||
return (delta["reasoning_content"] as? JsonPrimitive)?.contentOrNull
|
||||
?: (delta["reasoning"] as? JsonPrimitive)?.contentOrNull
|
||||
?: (delta["thinking"] as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
|
||||
private fun openAiFinishReason(event: JsonObject): String? =
|
||||
(openAiChoice(event)?.get("finish_reason") as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
private fun openAiMessageId(event: JsonObject): String? =
|
||||
(event["id"] as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
private fun openAiErrorMessage(event: JsonObject): String? {
|
||||
val error = event["error"] ?: return null
|
||||
return when (error) {
|
||||
is JsonPrimitive -> error.contentOrNull
|
||||
is JsonObject -> (error["message"] as? JsonPrimitive)?.contentOrNull
|
||||
?: (error["error"] as? JsonPrimitive)?.contentOrNull
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAiUsage(event: JsonObject): UsageInfo? =
|
||||
(event["usage"] as? JsonObject)?.let { usage ->
|
||||
runCatching { json.decodeFromJsonElement<UsageInfo>(usage) }.getOrNull()
|
||||
}
|
||||
|
||||
// --- Run streaming via /v1/runs ---
|
||||
|
||||
/**
|
||||
* Stream a run via `/v1/runs`.
|
||||
*
|
||||
* @param model Caller's default model selection (nullable). When
|
||||
* [modelOverride] is null/blank this is used as the `model` field,
|
||||
* or `"default"` if both are null — preserving the pre-profile
|
||||
* behaviour exactly.
|
||||
* @param modelOverride When non-null and non-blank, wins over [model]
|
||||
* and is injected as the top-level `"model"` field in the run
|
||||
* request body. Used by the agent-profile picker so an explicit
|
||||
* user-selected profile model takes precedence over any implicit
|
||||
* caller default. When null/blank this parameter is ignored and
|
||||
* [model] drives selection as before.
|
||||
*/
|
||||
fun sendRunStream(
|
||||
message: String,
|
||||
model: String? = null,
|
||||
@@ -629,33 +911,25 @@ class HermesApiClient(
|
||||
onTurnComplete: () -> Unit,
|
||||
onComplete: () -> Unit,
|
||||
onUsage: (UsageInfo?) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): EventSource {
|
||||
val requestPayload = buildJsonObject {
|
||||
put("model", model ?: "default")
|
||||
put("input", message)
|
||||
put("stream", true)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
// /v1/runs is OpenAI Responses-shaped — accepts an
|
||||
// additional `messages` field for context-priming the
|
||||
// run. Mirror the chat-stream branch so both endpoints
|
||||
// ingest the synthetic voice-intent history identically.
|
||||
put("messages", voiceIntentMessages)
|
||||
}
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
Log.d(TAG, "sendRunStream: modelOverride=$modelOverride (profile pick, was model=$model)")
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let {
|
||||
Log.d(TAG, "sendRunStream: profile=$it")
|
||||
}
|
||||
val requestPayload = buildRunStreamPayload(
|
||||
message = message,
|
||||
model = model,
|
||||
systemMessage = systemMessage,
|
||||
attachments = attachments,
|
||||
voiceIntentMessages = voiceIntentMessages,
|
||||
modelOverride = modelOverride,
|
||||
profileName = profileName,
|
||||
)
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
|
||||
val request = authRequest("$baseUrl/v1/runs")
|
||||
@@ -873,8 +1147,9 @@ class HermesApiClient(
|
||||
* presence. The handler only accepts POST, so HEAD returns 405
|
||||
* (Method Not Allowed) when the route is registered. 404 means
|
||||
* the route doesn't exist at all.
|
||||
* 4. `HEAD /v1/runs` — runs endpoint presence (same 405-vs-404 logic).
|
||||
* 5. `HEAD /v1/models` — OpenAI-compat reachability.
|
||||
* 4. `HEAD /v1/chat/completions` — OpenAI-compatible SSE fallback.
|
||||
* 5. `HEAD /v1/runs` with `Accept: text/event-stream` — accepted only
|
||||
* when the response explicitly advertises event-stream compatibility.
|
||||
*
|
||||
* **Why HEAD instead of OPTIONS:** The hermes-agent gateway runs CORS
|
||||
* middleware (`security_headers_middleware`) that intercepts OPTIONS
|
||||
@@ -884,10 +1159,13 @@ class HermesApiClient(
|
||||
* for present, 404 for missing). Verified empirically against the
|
||||
* production hermes-agent gateway on 2026-04-12.
|
||||
*
|
||||
* **Success criterion:** any HTTP response code that isn't 404 means
|
||||
* the route is registered. We accept 200, 204, 401, 403, 405, 415,
|
||||
* etc. as positive — even quirky middleware responses count, because
|
||||
* the alternative (404) is the only signal that means "no such path."
|
||||
* **Route presence criterion:** for sessions and completions, any HTTP
|
||||
* response code that isn't 404 means the route is registered. We accept
|
||||
* 200, 204, 401, 403, 405, 415, etc. as positive because the alternative
|
||||
* (404) is the only signal that means "no such path." `/v1/runs` is
|
||||
* stricter: route presence alone is not enough because async runs can
|
||||
* return `202 application/json`; auto only uses it if event-stream support
|
||||
* is explicitly advertised.
|
||||
*
|
||||
* Network errors (connection refused, DNS failure, etc.) count as
|
||||
* "missing" since we can't differentiate from a server-down case.
|
||||
@@ -913,10 +1191,31 @@ class HermesApiClient(
|
||||
false
|
||||
}
|
||||
|
||||
fun Response.advertisesEventStream(): Boolean {
|
||||
val contentType = header("Content-Type").orEmpty()
|
||||
val streamMode = header("X-Hermes-Stream-Mode").orEmpty()
|
||||
val runStreaming = header("X-Hermes-Run-Streaming").orEmpty()
|
||||
return contentType.contains("text/event-stream", ignoreCase = true) ||
|
||||
streamMode.equals("sse", ignoreCase = true) ||
|
||||
runStreaming.equals("sse", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun routeExplicitlySupportsEventStream(path: String): Boolean = try {
|
||||
val req = authRequest("$baseUrl$path")
|
||||
.head()
|
||||
.header("Accept", "text/event-stream")
|
||||
.build()
|
||||
client.newCall(req).execute().use { response ->
|
||||
response.code != 404 && response.advertisesEventStream()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
val sessionsApi = routeExists("/api/sessions?limit=1")
|
||||
val sessionsChatStream = routeExists("/api/sessions/probe/chat/stream")
|
||||
val runs = routeExists("/v1/runs")
|
||||
val portable = routeExists("/v1/models")
|
||||
val portable = routeExists("/v1/chat/completions")
|
||||
val runs = routeExplicitlySupportsEventStream("/v1/runs")
|
||||
|
||||
ServerCapabilities(
|
||||
sessionsApi = sessionsApi,
|
||||
@@ -948,4 +1247,20 @@ class HermesApiClient(
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
private fun apiFailure(response: Response, operation: String): IOException {
|
||||
val detail = response.message.takeIf { it.isNotBlank() }?.let { ": $it" }.orEmpty()
|
||||
val message = when (response.code) {
|
||||
401, 403 -> "$operation unauthorized - check your API key"
|
||||
in 500..599 -> "$operation failed - server error HTTP ${response.code}"
|
||||
else -> "$operation failed - HTTP ${response.code}$detail"
|
||||
}
|
||||
return IOException(message)
|
||||
}
|
||||
|
||||
private fun JsonObject?.stringField(name: String): String =
|
||||
((this?.get(name) as? JsonPrimitive)?.contentOrNull ?: "").trim()
|
||||
|
||||
private fun firstNonBlank(vararg values: String?): String =
|
||||
values.firstOrNull { !it.isNullOrBlank() }.orEmpty()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
|
||||
internal fun buildSessionChatStreamPayload(
|
||||
message: String,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<Attachment>? = null,
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): JsonObject = buildJsonObject {
|
||||
put("message", message)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
put("model", modelOverride)
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
put("messages", voiceIntentMessages)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildRunStreamPayload(
|
||||
message: String,
|
||||
model: String? = null,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<Attachment>? = null,
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): JsonObject {
|
||||
val resolvedModel = when {
|
||||
!modelOverride.isNullOrBlank() -> modelOverride
|
||||
!model.isNullOrBlank() -> model
|
||||
else -> "default"
|
||||
}
|
||||
return buildJsonObject {
|
||||
put("model", resolvedModel)
|
||||
put("input", message)
|
||||
put("stream", true)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
put("messages", voiceIntentMessages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildChatCompletionsStreamPayload(
|
||||
message: String,
|
||||
model: String? = null,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<Attachment>? = null,
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): JsonObject {
|
||||
val resolvedModel = when {
|
||||
!modelOverride.isNullOrBlank() -> modelOverride
|
||||
!model.isNullOrBlank() -> model
|
||||
else -> "default"
|
||||
}
|
||||
return buildJsonObject {
|
||||
put("model", resolvedModel)
|
||||
put("stream", true)
|
||||
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
|
||||
putJsonArray("messages") {
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
addJsonObject {
|
||||
put("role", "system")
|
||||
put("content", systemMessage)
|
||||
}
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
voiceIntentMessages.forEach { add(it) }
|
||||
}
|
||||
addJsonObject {
|
||||
put("role", "user")
|
||||
if (!attachments.isNullOrEmpty() && attachments.any { it.isImage }) {
|
||||
put("content", buildJsonArray {
|
||||
addJsonObject {
|
||||
put("type", "text")
|
||||
put("text", message)
|
||||
}
|
||||
attachments.filter { it.isImage }.forEach { att ->
|
||||
addJsonObject {
|
||||
put("type", "image_url")
|
||||
putJsonObject("image_url") {
|
||||
put("url", "data:${att.contentType};base64,${att.content}")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
put("content", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!attachments.isNullOrEmpty() && attachments.any { !it.isImage }) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.filter { !it.isImage }.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Resolves profile-scoped Hermes API URLs for phone use.
|
||||
*
|
||||
* Relays and Hermes gateways often bind profile API servers to loopback or
|
||||
* 0.0.0.0 on the host machine. Those addresses are correct for the relay
|
||||
* process but wrong on Android, where 127.0.0.1 means the phone. When the
|
||||
* active connection uses a reachable LAN/Tailscale host, replace loopback
|
||||
* profile hosts with that same host while preserving the profile port.
|
||||
*/
|
||||
object ProfileApiUrlResolver {
|
||||
fun normalize(url: String?): String? =
|
||||
url?.trim()?.takeIf { it.isNotBlank() }?.trimEnd('/')
|
||||
|
||||
fun resolveForConnection(profileApiUrl: String?, baseApiUrl: String?): String? {
|
||||
val profile = normalize(profileApiUrl) ?: return null
|
||||
val base = normalize(baseApiUrl) ?: return profile
|
||||
|
||||
val profileUri = runCatching { URI(profile) }.getOrNull() ?: return profile
|
||||
val profileHost = profileUri.host?.takeIf { it.isNotBlank() } ?: return profile
|
||||
if (!isLocalBindHost(profileHost)) return profile
|
||||
|
||||
val baseUri = runCatching { URI(base) }.getOrNull() ?: return profile
|
||||
val baseHost = baseUri.host?.takeIf { it.isNotBlank() } ?: return profile
|
||||
if (isLocalBindHost(baseHost)) return profile
|
||||
|
||||
val scheme = baseUri.scheme?.takeIf { it.isNotBlank() }
|
||||
?: profileUri.scheme?.takeIf { it.isNotBlank() }
|
||||
?: return profile
|
||||
val hostPart = if (baseHost.contains(":") && !baseHost.startsWith("[")) {
|
||||
"[$baseHost]"
|
||||
} else {
|
||||
baseHost
|
||||
}
|
||||
val portPart = profileUri.port.takeIf { it != -1 }?.let { ":$it" }.orEmpty()
|
||||
val pathPart = profileUri.rawPath?.takeIf { it.isNotBlank() && it != "/" }.orEmpty()
|
||||
val queryPart = profileUri.rawQuery?.let { "?$it" }.orEmpty()
|
||||
val fragmentPart = profileUri.rawFragment?.let { "#$it" }.orEmpty()
|
||||
|
||||
return "$scheme://$hostPart$portPart$pathPart$queryPart$fragmentPart".trimEnd('/')
|
||||
}
|
||||
|
||||
private fun isLocalBindHost(host: String): Boolean {
|
||||
return when (host.lowercase().trim('[', ']')) {
|
||||
"localhost", "127.0.0.1", "0.0.0.0", "::1", "::" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package com.hermesandroid.relay.network
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.auth.PairedDeviceInfo
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
@@ -579,7 +582,10 @@ class RelayHttpClient(
|
||||
* human-readable message on any failure (network, non-200, bad body,
|
||||
* doesn't-look-like-hermes-relay).
|
||||
*/
|
||||
suspend fun probeHealth(relayUrl: String): Result<RelayHealth> = withContext(Dispatchers.IO) {
|
||||
suspend fun probeHealth(
|
||||
relayUrl: String,
|
||||
logSuccess: Boolean = true,
|
||||
): Result<RelayHealth> = withContext(Dispatchers.IO) {
|
||||
val trimmed = relayUrl.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
@@ -591,10 +597,18 @@ class RelayHttpClient(
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
val startedAtMs = System.currentTimeMillis()
|
||||
|
||||
val url = try {
|
||||
"$httpBase/health".toHttpUrl()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay URL invalid",
|
||||
detail = e.message,
|
||||
url = relayUrl,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Invalid relay URL: ${e.message}")
|
||||
)
|
||||
@@ -606,6 +620,7 @@ class RelayHttpClient(
|
||||
.connectTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.callTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
@@ -617,12 +632,28 @@ class RelayHttpClient(
|
||||
try {
|
||||
fastClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = "HTTP ${response.code}",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay responded HTTP ${response.code}")
|
||||
)
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (body.isBlank()) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = "Empty response",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned an empty response")
|
||||
)
|
||||
@@ -632,18 +663,42 @@ class RelayHttpClient(
|
||||
val parsed: Map<String, kotlinx.serialization.json.JsonElement> = try {
|
||||
sessionsJson.parseToJsonElement(body).jsonObject
|
||||
} catch (e: Exception) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = "Non-JSON response",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned non-JSON: ${e.message ?: "parse error"}")
|
||||
)
|
||||
}
|
||||
val status = (parsed["status"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
if (status != "ok") {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = "status=${status ?: "missing"}",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay reports status=${status ?: "missing"} (expected 'ok')")
|
||||
)
|
||||
}
|
||||
val version = (parsed["version"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
if (version.isNullOrBlank()) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = "Missing version field",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Response doesn't look like a hermes-relay — missing 'version' field")
|
||||
)
|
||||
@@ -652,19 +707,61 @@ class RelayHttpClient(
|
||||
?.content?.toIntOrNull() ?: 0
|
||||
val sessions = (parsed["sessions"] as? kotlinx.serialization.json.JsonPrimitive)
|
||||
?.content?.toIntOrNull() ?: 0
|
||||
if (logSuccess) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay health ok",
|
||||
detail = "version=$version clients=$clients sessions=$sessions",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
}
|
||||
Result.success(RelayHealth(version = version, clients = clients, sessions = sessions))
|
||||
}
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
Log.w(TAG, "probeHealth timeout: ${e.message}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health timeout",
|
||||
detail = "No HTTP response in 3s",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
Result.failure(IOException("Relay is not responding (3s timeout)"))
|
||||
} catch (e: java.net.ConnectException) {
|
||||
Log.w(TAG, "probeHealth connect refused: ${e.message}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay connection refused",
|
||||
detail = e.message,
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
Result.failure(IOException("Connection refused — is the relay running on this URL?"))
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "probeHealth IO error: ${e.message}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay health failed",
|
||||
detail = e.message ?: "Network error",
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
Result.failure(IOException("Network error: ${e.message ?: "unreachable"}"))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "probeHealth unexpected error: ${e.message}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay health failed",
|
||||
detail = e.message ?: e.javaClass.simpleName,
|
||||
url = httpBase,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.ProfileConfigResponse
|
||||
import com.hermesandroid.relay.data.ProfileMemoryResponse
|
||||
import com.hermesandroid.relay.data.ProfileSkillsResponse
|
||||
import com.hermesandroid.relay.data.ProfileSoulResponse
|
||||
import com.hermesandroid.relay.data.ProfileSoulUpdateResponse
|
||||
import com.hermesandroid.relay.data.ProfileMemoryUpdateResponse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* HTTP client for the read-only **Profile Inspector** endpoints added in
|
||||
* the v0.7.0 relay:
|
||||
*
|
||||
* - `GET /api/profiles/{name}/config` (already live)
|
||||
* - `GET /api/profiles/{name}/skills` (already live)
|
||||
* - `GET /api/profiles/{name}/soul` (Python worker)
|
||||
* - `GET /api/profiles/{name}/memory` (Python worker)
|
||||
*
|
||||
* Mirrors the constructor shape of [RelayHttpClient] — same OkHttpClient,
|
||||
* the same `wss://` → `https://` URL-flipping trick, and the same lazy
|
||||
* session-token provider so a paired bearer token from EncryptedSharedPrefs
|
||||
* is only read when actually needed.
|
||||
*
|
||||
* All IO hops over [Dispatchers.IO] — we had a `NetworkOnMainThreadException`
|
||||
* during v0.6.0 development when an earlier client went straight from a
|
||||
* Composable effect to OkHttp without a dispatcher hop, so every path here
|
||||
* starts with `withContext(Dispatchers.IO) { ... }`.
|
||||
*
|
||||
* Profile names are URL-encoded before being spliced into the path so
|
||||
* names containing spaces or non-ASCII characters don't produce malformed
|
||||
* URLs.
|
||||
*/
|
||||
class RelayProfileInspectorClient(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val relayUrlProvider: () -> String?,
|
||||
private val sessionTokenProvider: suspend () -> String?,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RelayProfileInspector"
|
||||
|
||||
/**
|
||||
* Server-side upload ceiling for SOUL.md and memory entries
|
||||
* (1 MiB). Kept as a named constant so the matching wire limit
|
||||
* in the Python worker can be moved in lockstep. We use it to
|
||||
* translate a generic 413 into a friendlier error message.
|
||||
*/
|
||||
private const val SOUL_MAX_BYTES: Long = 1024L * 1024L
|
||||
|
||||
/**
|
||||
* JSON media type used for all PUT requests. Hoisted to a
|
||||
* constant so we don't re-parse it on every write.
|
||||
*/
|
||||
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
// Lenient + ignore unknown keys so if the Python worker adds a
|
||||
// field later we don't fail to deserialize the whole payload.
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
coerceInputValues = true
|
||||
explicitNulls = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Fetch `GET /api/profiles/{name}/config`. */
|
||||
suspend fun fetchConfig(profileName: String): Result<ProfileConfigResponse> =
|
||||
get(profileName, "config", ProfileConfigResponse.serializer())
|
||||
|
||||
/** Fetch `GET /api/profiles/{name}/skills`. */
|
||||
suspend fun fetchSkills(profileName: String): Result<ProfileSkillsResponse> =
|
||||
get(profileName, "skills", ProfileSkillsResponse.serializer())
|
||||
|
||||
/** Fetch `GET /api/profiles/{name}/soul`. */
|
||||
suspend fun fetchSoul(profileName: String): Result<ProfileSoulResponse> =
|
||||
get(profileName, "soul", ProfileSoulResponse.serializer())
|
||||
|
||||
/** Fetch `GET /api/profiles/{name}/memory`. */
|
||||
suspend fun fetchMemory(profileName: String): Result<ProfileMemoryResponse> =
|
||||
get(profileName, "memory", ProfileMemoryResponse.serializer())
|
||||
|
||||
/**
|
||||
* `PUT /api/profiles/{name}/soul` with body `{"content": "..."}`.
|
||||
*
|
||||
* Server-side contract:
|
||||
* - 200: content written; returns [ProfileSoulUpdateResponse]
|
||||
* - 404: profile not found
|
||||
* - 413: body exceeds 1 MiB size limit
|
||||
* - 401/403: unauthorized — re-pair
|
||||
*
|
||||
* The [content] may be any UTF-8 string including empty (to blank the
|
||||
* file) — the server does not enforce non-emptiness. A `null` content
|
||||
* would be a protocol violation; we send empty-string for an empty
|
||||
* SOUL.
|
||||
*/
|
||||
suspend fun updateSoul(
|
||||
profileName: String,
|
||||
content: String,
|
||||
): Result<ProfileSoulUpdateResponse> = withContext(Dispatchers.IO) {
|
||||
val bodyPayload = buildJsonObject { put("content", content) }
|
||||
val bodyJson = json.encodeToString(
|
||||
kotlinx.serialization.json.JsonObject.serializer(),
|
||||
bodyPayload,
|
||||
)
|
||||
put(
|
||||
profileName = profileName,
|
||||
segment = "soul",
|
||||
body = bodyJson,
|
||||
deserializer = ProfileSoulUpdateResponse.serializer(),
|
||||
maxBytesHint = SOUL_MAX_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `PUT /api/profiles/{name}/memory/{filename}` with body
|
||||
* `{"content": "..."}`.
|
||||
*
|
||||
* Filenames are validated both locally (by the caller — we expect
|
||||
* `.md` suffix, no traversal) and server-side. A bad filename yields
|
||||
* a 400 from the relay.
|
||||
*
|
||||
* Used for both creating a new memory entry (the relay writes the
|
||||
* file if missing) and updating an existing entry.
|
||||
*/
|
||||
suspend fun updateMemoryEntry(
|
||||
profileName: String,
|
||||
filename: String,
|
||||
content: String,
|
||||
): Result<ProfileMemoryUpdateResponse> = withContext(Dispatchers.IO) {
|
||||
val bodyPayload = buildJsonObject { put("content", content) }
|
||||
val bodyJson = json.encodeToString(
|
||||
kotlinx.serialization.json.JsonObject.serializer(),
|
||||
bodyPayload,
|
||||
)
|
||||
val encodedFilename = URLEncoder.encode(filename, "UTF-8").replace("+", "%20")
|
||||
put(
|
||||
profileName = profileName,
|
||||
segment = "memory/$encodedFilename",
|
||||
body = bodyJson,
|
||||
deserializer = ProfileMemoryUpdateResponse.serializer(),
|
||||
// Memory entries share the same 1MiB ceiling server-side —
|
||||
// no documented difference, so we report the same soft hint
|
||||
// in the error message.
|
||||
maxBytesHint = SOUL_MAX_BYTES,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared PUT-body-and-parse path for the two update endpoints
|
||||
* (SOUL + memory). Centralized so we reuse the same URL builder,
|
||||
* session-token plumbing, and error mapping. Kept separate from
|
||||
* [get] rather than generalized over the HTTP method because the
|
||||
* body/response semantics (413, 400 invalid filename) are specific
|
||||
* to the update side.
|
||||
*/
|
||||
private suspend fun <T> put(
|
||||
profileName: String,
|
||||
segment: String,
|
||||
body: String,
|
||||
deserializer: kotlinx.serialization.DeserializationStrategy<T>,
|
||||
maxBytesHint: Long = SOUL_MAX_BYTES,
|
||||
): Result<T> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay URL not configured")
|
||||
)
|
||||
}
|
||||
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
)
|
||||
}
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
|
||||
val encodedName = URLEncoder.encode(profileName, "UTF-8").replace("+", "%20")
|
||||
|
||||
val url = try {
|
||||
"$httpBase/api/profiles/$encodedName/$segment".toHttpUrl()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Invalid relay URL: ${e.message}")
|
||||
)
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.put(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
val reason = when (response.code) {
|
||||
400 -> {
|
||||
// Relay emits 400 for invalid filename or
|
||||
// malformed JSON. Surface the response body
|
||||
// when present so the user sees the specific
|
||||
// validation error.
|
||||
val bodyText = response.body?.string().orEmpty()
|
||||
if (bodyText.isNotBlank()) {
|
||||
"Invalid request: ${extractErrorDetail(bodyText)}"
|
||||
} else {
|
||||
"Invalid request"
|
||||
}
|
||||
}
|
||||
401, 403 -> "Unauthorized — re-pair with the relay"
|
||||
404 -> "Profile '$profileName' not found on relay"
|
||||
413 -> "Content too large — max ${maxBytesHint / 1024} KiB"
|
||||
in 500..599 -> "Relay error (HTTP ${response.code})"
|
||||
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
|
||||
}
|
||||
return@withContext Result.failure(IOException(reason))
|
||||
}
|
||||
|
||||
val bodyText = response.body?.string().orEmpty()
|
||||
if (bodyText.isBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned an empty response")
|
||||
)
|
||||
}
|
||||
|
||||
val parsed = try {
|
||||
json.decodeFromString(deserializer, bodyText)
|
||||
} catch (e: SerializationException) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Malformed response from relay: ${e.message ?: "parse error"}")
|
||||
)
|
||||
}
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "$segment write failed for $profileName: ${e.message}")
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "$segment write unexpected error for $profileName: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `PUT /api/skills/toggle` with body `{"name": "...", "enabled": true/false}`.
|
||||
*
|
||||
* Current relay stubs this out — returns 501 with
|
||||
* `{"error": "skill_toggle_not_implemented", "detail": "..."}`. The
|
||||
* UI uses the distinctive 501 to show a "not supported on this
|
||||
* server" snackbar and ghost out the toggle. When the real
|
||||
* implementation lands server-side, this method needs no change.
|
||||
*/
|
||||
suspend fun updateSkillToggle(
|
||||
skillName: String,
|
||||
enabled: Boolean,
|
||||
): Result<SkillToggleResult> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay URL not configured")
|
||||
)
|
||||
}
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
)
|
||||
}
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
|
||||
val url = try {
|
||||
"$httpBase/api/skills/toggle".toHttpUrl()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Invalid relay URL: ${e.message}")
|
||||
)
|
||||
}
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("name", skillName)
|
||||
put("enabled", enabled)
|
||||
}
|
||||
val bodyJson = json.encodeToString(
|
||||
kotlinx.serialization.json.JsonObject.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.put(bodyJson.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
when (response.code) {
|
||||
in 200..299 -> Result.success(SkillToggleResult.Ok)
|
||||
501 -> Result.success(SkillToggleResult.NotImplemented)
|
||||
401, 403 -> Result.failure(
|
||||
IOException("Unauthorized — re-pair with the relay")
|
||||
)
|
||||
else -> Result.failure(
|
||||
IOException("Relay returned HTTP ${response.code}")
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "skill toggle failed for $skillName: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability probe for the skill-toggle endpoint — HEAD / OPTIONS
|
||||
* would be the ideal choice but we need to know specifically if the
|
||||
* server responds 501 vs 200, which is only visible on PUT. We
|
||||
* send a no-op PUT with `enabled = true` against a placeholder
|
||||
* skill name that the server treats as a probe ping — relays that
|
||||
* implement the endpoint accept it; stubbed relays return 501.
|
||||
*
|
||||
* In practice we don't want this probe to have side effects, so we
|
||||
* use the HTTP OPTIONS verb instead and treat a 501 response as
|
||||
* "not implemented" and any 2xx as "supported". The relay serves
|
||||
* OPTIONS via aiohttp's CORS handling by default.
|
||||
*/
|
||||
suspend fun probeSkillToggleSupported(): Boolean = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) return@withContext false
|
||||
val sessionToken = sessionTokenProvider() ?: return@withContext false
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
|
||||
val url = try {
|
||||
"$httpBase/api/skills/toggle".toHttpUrl()
|
||||
} catch (_: IllegalArgumentException) {
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
// OPTIONS probe. Relays that don't mount the handler return 404
|
||||
// or the default 405 method-not-allowed; stubbed-implementation
|
||||
// relays return 501 from the PUT handler but allow OPTIONS.
|
||||
// A 2xx/3xx OPTIONS does NOT confirm PUT works (the server
|
||||
// might still 501 on the real call), so a successful OPTIONS
|
||||
// here means "worth trying". A 501 response on OPTIONS (rare)
|
||||
// is definitive "not supported".
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.method("OPTIONS", null)
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
when (response.code) {
|
||||
501 -> false
|
||||
404, 405 -> false
|
||||
// 401/403 — we don't know; default to true (don't
|
||||
// ghost the toggle over an auth problem, let the
|
||||
// PUT fail and snackbar through the normal path).
|
||||
401, 403 -> true
|
||||
else -> response.isSuccessful
|
||||
}
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
// Network / offline — assume supported; PUT will surface
|
||||
// the real failure.
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a skill-toggle PUT. Kept as a small sealed class so
|
||||
* the caller can distinguish "server accepted it" from "server
|
||||
* answered 501 — not implemented yet" without inventing magic
|
||||
* error strings.
|
||||
*/
|
||||
sealed class SkillToggleResult {
|
||||
data object Ok : SkillToggleResult()
|
||||
data object NotImplemented : SkillToggleResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort pull of a `detail` or `error` string out of a relay
|
||||
* 400 body. Falls back to the first 120 chars of the payload when
|
||||
* the response isn't JSON-shaped.
|
||||
*/
|
||||
private fun extractErrorDetail(body: String): String {
|
||||
return try {
|
||||
val obj = json.parseToJsonElement(body) as? kotlinx.serialization.json.JsonObject
|
||||
?: return body.take(120)
|
||||
val detail = (obj["detail"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
val error = (obj["error"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
detail ?: error ?: body.take(120)
|
||||
} catch (_: Exception) {
|
||||
body.take(120)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared GET-and-parse path for all four endpoints. Centralizing here
|
||||
* keeps the error-mapping consistent (404 profile-not-found, 401
|
||||
* re-pair, 5xx server error, etc.) without four near-identical copies.
|
||||
*/
|
||||
private suspend fun <T> get(
|
||||
profileName: String,
|
||||
segment: String,
|
||||
deserializer: kotlinx.serialization.DeserializationStrategy<T>,
|
||||
): Result<T> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay URL not configured")
|
||||
)
|
||||
}
|
||||
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
)
|
||||
}
|
||||
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
|
||||
// Percent-encode the profile name for splicing into the path —
|
||||
// profile names are typically ASCII identifiers but nothing
|
||||
// structurally forbids spaces or non-ASCII.
|
||||
// URLEncoder encodes spaces as `+` which is wrong for paths; swap
|
||||
// back to `%20` after encoding.
|
||||
val encodedName = URLEncoder.encode(profileName, "UTF-8").replace("+", "%20")
|
||||
|
||||
val url = try {
|
||||
"$httpBase/api/profiles/$encodedName/$segment".toHttpUrl()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Invalid relay URL: ${e.message}")
|
||||
)
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
try {
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
val reason = when (response.code) {
|
||||
401, 403 -> "Unauthorized — re-pair with the relay"
|
||||
404 -> "Profile '$profileName' not found on relay"
|
||||
in 500..599 -> "Relay error (HTTP ${response.code})"
|
||||
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
|
||||
}
|
||||
return@withContext Result.failure(IOException(reason))
|
||||
}
|
||||
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned an empty response")
|
||||
)
|
||||
}
|
||||
|
||||
val parsed = try {
|
||||
json.decodeFromString(deserializer, body)
|
||||
} catch (e: SerializationException) {
|
||||
return@withContext Result.failure(
|
||||
IOException("Malformed response from relay: ${e.message ?: "parse error"}")
|
||||
)
|
||||
}
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "$segment fetch failed for $profileName: ${e.message}")
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "$segment fetch unexpected error for $profileName: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Derives the conventional Hermes-Relay WSS/WS URL from a Hermes API URL.
|
||||
*
|
||||
* Hermes API and Relay are separate processes, but normal installs expose
|
||||
* them on the same host with API on 8642 and Relay on 8767. Keeping this
|
||||
* logic centralized lets setup flows treat the Relay URL as "Auto" by
|
||||
* default while still allowing a manual override for custom reverse proxies.
|
||||
*/
|
||||
object RelayUrlDeriver {
|
||||
const val DEFAULT_RELAY_PORT: Int = 8767
|
||||
|
||||
fun deriveFromApiUrl(apiUrl: String, relayPort: Int = DEFAULT_RELAY_PORT): String? {
|
||||
val trimmed = apiUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val relayScheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "ws"
|
||||
"https" -> "wss"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$relayScheme://$hostPart:$relayPort"
|
||||
}
|
||||
|
||||
fun isAutoManagedRelayUrl(relayUrl: String, apiUrl: String): Boolean {
|
||||
val trimmed = relayUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return true
|
||||
if (isDefaultLocalRelayUrl(trimmed)) return true
|
||||
|
||||
val derived = deriveFromApiUrl(apiUrl) ?: return false
|
||||
return trimmed.equals(derived, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun isDefaultLocalRelayUrl(relayUrl: String): Boolean {
|
||||
val uri = runCatching { URI(relayUrl.trim()) }.getOrNull() ?: return false
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
if (scheme != "ws" && scheme != "wss") return false
|
||||
val host = uri.host?.lowercase() ?: return false
|
||||
val port = if (uri.port == -1) {
|
||||
when (scheme) {
|
||||
"ws" -> 80
|
||||
"wss" -> 443
|
||||
else -> -1
|
||||
}
|
||||
} else {
|
||||
uri.port
|
||||
}
|
||||
return port == DEFAULT_RELAY_PORT &&
|
||||
(host == "localhost" || host == "127.0.0.1" || host == "::1")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+446
-65
@@ -1,6 +1,9 @@
|
||||
package com.hermesandroid.relay.network.handlers
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.accessibility.ActionExecutor
|
||||
import com.hermesandroid.relay.accessibility.HermesAccessibilityService
|
||||
@@ -21,15 +24,20 @@ import kotlinx.serialization.json.booleanOrNull
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
// === END PHASE3-tier-C ===
|
||||
import com.hermesandroid.relay.network.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.RelayHttpClient
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
import com.hermesandroid.relay.util.MediaCacheWriter
|
||||
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
@@ -103,6 +111,10 @@ import kotlinx.serialization.json.contentOrNull
|
||||
* - `/setup` → 200 no-op (host-side helper, phone has no setup work)
|
||||
* - `/clipboard` (GET) → returns `{text: "..."}` (empty string = nothing copied)
|
||||
* - `/clipboard` (POST) body `{text}` → returns `{success: true}`
|
||||
* - `/share_media` body `{attachments?, media?, path?, text?, package?}` →
|
||||
* launches Android's native share UI with FileProvider `content://` URIs
|
||||
* - `/send_mms` body `{to, body?, attachments?, media?, path?, package?}` →
|
||||
* opens a user-mediated MMS compose/share handoff
|
||||
* - `/describe_node` body `{nodeId}` — A4: full property bag for a node
|
||||
*
|
||||
* # nodeId semantics (A4)
|
||||
@@ -115,18 +127,23 @@ import kotlinx.serialization.json.contentOrNull
|
||||
* contract documented on the Python `android_tap` / `android_scroll` tools.
|
||||
* A non-resolvable nodeId returns a 404-style error envelope.
|
||||
*
|
||||
* # Master enable gate
|
||||
* # Device Control gate
|
||||
*
|
||||
* Before dispatching any action we check
|
||||
* The Google Play flavor ships Bridge Core without AccessibilityService or
|
||||
* Device Control. It answers harmless bridge liveness/status probes above
|
||||
* the dispatch layer, but any command that reaches Device Control fails closed
|
||||
* before touching [HermesAccessibilityService]. The sideload flavor then checks
|
||||
* [HermesAccessibilityService.instance] — if the user hasn't enabled the
|
||||
* service in Android Settings, we fail fast with status 503. If the
|
||||
* service is running but the soft master toggle is off we fail with 403
|
||||
* and a body explaining that Bridge is disabled in the app.
|
||||
* service in Android Settings, we fail fast with status 503. If the service is
|
||||
* running but the soft master toggle is off we fail with 403 and a body
|
||||
* explaining that Bridge is disabled in the app.
|
||||
*/
|
||||
class BridgeCommandHandler(
|
||||
private val multiplexer: ChannelMultiplexer,
|
||||
private val scope: CoroutineScope,
|
||||
private val screenCapture: ScreenCapture? = null,
|
||||
private val relayHttpClient: RelayHttpClient? = null,
|
||||
private val mediaCacheWriter: MediaCacheWriter? = null,
|
||||
// === PHASE3-safety-rails: safety enforcement ===
|
||||
// Safety manager is optional so older tests that construct this handler
|
||||
// without the full DI graph still compile; in production ConnectionViewModel
|
||||
@@ -207,14 +224,15 @@ class BridgeCommandHandler(
|
||||
//
|
||||
// Intentionally narrow — only commands that are PRIMARILY about
|
||||
// launching / switching to another app. /send_sms on sideload uses
|
||||
// SmsManager and doesn't shift foreground; it does on googlePlay
|
||||
// where the tool falls back to opening Messages, but that's already
|
||||
// covered by android_send_sms's own `android_return_to_hermes`
|
||||
// prompting in plugin/android_tool.py. Keep the allowlist minimal
|
||||
// and extend only when a concrete need surfaces.
|
||||
// SmsManager and doesn't shift foreground. /share_media and /send_mms
|
||||
// intentionally open native Android share/compose surfaces, so they
|
||||
// participate in the same auto-return bookkeeping as /open_app and
|
||||
// /send_intent.
|
||||
private val foregroundShiftingPaths: Set<String> = setOf(
|
||||
"/open_app",
|
||||
"/send_intent",
|
||||
"/share_media",
|
||||
"/send_mms",
|
||||
)
|
||||
|
||||
private data class PendingActivity(
|
||||
@@ -224,6 +242,19 @@ class BridgeCommandHandler(
|
||||
val timestampMs: Long,
|
||||
)
|
||||
|
||||
private data class ShareAttachmentRef(
|
||||
val media: String? = null,
|
||||
val path: String? = null,
|
||||
val contentType: String? = null,
|
||||
val fileName: String? = null,
|
||||
)
|
||||
|
||||
private data class CachedShareAttachment(
|
||||
val uri: Uri,
|
||||
val contentType: String,
|
||||
val fileName: String?,
|
||||
)
|
||||
|
||||
private val pendingActivities =
|
||||
java.util.concurrent.ConcurrentHashMap<String, PendingActivity>()
|
||||
// === END v0.4.1 polish ===
|
||||
@@ -500,6 +531,28 @@ class BridgeCommandHandler(
|
||||
return
|
||||
}
|
||||
|
||||
if (!BuildFlavor.isSideload) {
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put(
|
||||
"error",
|
||||
"Device Control is not included in the Google Play build " +
|
||||
"of Hermes Relay. This build keeps Hermes Bridge Core " +
|
||||
"features such as chat, voice, terminal, media, " +
|
||||
"notifications, and relay status, but it does not " +
|
||||
"ship AccessibilityService, screen reading, taps, " +
|
||||
"typing, screenshots, SMS, calls, or unattended " +
|
||||
"phone control. Install the sideload build for " +
|
||||
"Device Control.",
|
||||
)
|
||||
put("error_code", "device_control_sideload_only")
|
||||
put("flavor", "googlePlay")
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val service = HermesAccessibilityService.instance
|
||||
?: return respond(
|
||||
requestId, 503,
|
||||
@@ -648,57 +701,6 @@ class BridgeCommandHandler(
|
||||
}
|
||||
// === END v0.4.1 unattended-access ===
|
||||
|
||||
// === Google Play flavor route gate ===
|
||||
// The googlePlay build's AccessibilityService config declares a
|
||||
// narrow use case ("read notifications, summarize messages") with
|
||||
// NO gesture dispatch (canPerformGestures is absent) and NO
|
||||
// flagRetrieveInteractiveWindows. Only READ-ONLY routes that
|
||||
// match this declared scope are whitelisted; everything else
|
||||
// returns a 403 so reviewers tracing the code see a capability
|
||||
// surface that matches the manifest declaration.
|
||||
//
|
||||
// The whitelist is FAIL-CLOSED: any new route we add to the when
|
||||
// block below defaults to sideload-only on the Play flavor unless
|
||||
// explicitly added here. This prevents future routes from
|
||||
// accidentally widening the Play APK's capability surface.
|
||||
//
|
||||
// Early-return routes (/ping, /events, /setup) are above this
|
||||
// point so they work on both flavors — they're harmless liveness
|
||||
// probes and don't need the a11y service. /return_to_hermes is
|
||||
// whitelisted because it only foregrounds our OWN app (not a
|
||||
// phone-control action). /clipboard is whitelisted for GET
|
||||
// (read-only); POST (write) is gated inside the /clipboard case.
|
||||
if (!BuildFlavor.isSideload) {
|
||||
val playAllowed = setOf(
|
||||
"/current_app",
|
||||
"/screen",
|
||||
"/get_apps",
|
||||
"/apps",
|
||||
"/clipboard",
|
||||
"/return_to_hermes",
|
||||
)
|
||||
if (path !in playAllowed) {
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put(
|
||||
"error",
|
||||
"This bridge route ($path) is only available on the " +
|
||||
"sideload flavor of Hermes Relay. The Google Play " +
|
||||
"build supports read-only bridge operations (screen " +
|
||||
"reading, app status, clipboard read) but not " +
|
||||
"phone-control actions (tap, type, swipe, SMS, call). " +
|
||||
"Install the sideload APK for full phone control.",
|
||||
)
|
||||
put("error_code", "sideload_only")
|
||||
put("flavor", "googlePlay")
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
// === END Google Play flavor route gate ===
|
||||
|
||||
val executor = service.actionExecutor
|
||||
|
||||
when (path) {
|
||||
@@ -815,9 +817,8 @@ class BridgeCommandHandler(
|
||||
// server-side agent as the final step of any multi-app task
|
||||
// (e.g. after driving Messages to send an SMS) so the user
|
||||
// sees the agent's reply in-context without manually switching
|
||||
// apps. The phone knows its own package name via service — no
|
||||
// parameter needed, works transparently on both sideload and
|
||||
// googlePlay flavors.
|
||||
// apps. The sideload phone knows its own package name via the
|
||||
// accessibility service, so no parameter is needed.
|
||||
//
|
||||
// Allowed even when the master toggle is off: returning focus
|
||||
// to our own app isn't a destructive action, and this tool
|
||||
@@ -1325,6 +1326,9 @@ class BridgeCommandHandler(
|
||||
requestId, 400,
|
||||
buildJsonObject {
|
||||
put("error", "missing 'to' or 'body' in body")
|
||||
put("status", "failed")
|
||||
put("reason", "invalid_schema")
|
||||
put("expected_schema", "{ \"to\": \"<phone>\", \"body\": \"<text>\" }")
|
||||
}
|
||||
)
|
||||
return
|
||||
@@ -1340,6 +1344,8 @@ class BridgeCommandHandler(
|
||||
requestId, 503,
|
||||
buildJsonObject {
|
||||
put("error", "safety manager not initialized — refusing destructive action")
|
||||
put("status", "failed")
|
||||
put("reason", "safety_manager_missing")
|
||||
}
|
||||
)
|
||||
return
|
||||
@@ -1358,6 +1364,70 @@ class BridgeCommandHandler(
|
||||
}
|
||||
respondFromResult(requestId, executor.sendSms(to, smsBody))
|
||||
}
|
||||
|
||||
"/share_media", "/send_mms" -> {
|
||||
if (!BuildFlavor.isSideload) {
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put("error", "$path is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
|
||||
put("error_code", "sideload_only")
|
||||
put("flavor", "googlePlay")
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
if (safetyManager == null) {
|
||||
respond(
|
||||
requestId, 503,
|
||||
buildJsonObject {
|
||||
put("error", "safety manager not initialized — refusing media share action")
|
||||
put("status", "failed")
|
||||
put("reason", "safety_manager_missing")
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
val targetPkg = body["package"]?.jsonPrimitive?.contentOrNull
|
||||
val targetAllowed = safetyManager.checkPackageAllowed(targetPkg)
|
||||
if (!targetAllowed) {
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put("error", "blocked package ${targetPkg ?: "unknown"}")
|
||||
put("status", "blocked")
|
||||
put("reason", "blocked_package")
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val attachmentCount = extractShareAttachmentRefs(body).size
|
||||
val to = body["to"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
val text = body["text"]?.jsonPrimitive?.contentOrNull
|
||||
?: body["body"]?.jsonPrimitive?.contentOrNull
|
||||
?: ""
|
||||
val confirmText = if (path == "/send_mms") {
|
||||
val target = if (to.isBlank()) "the selected recipient" else to
|
||||
"Send MMS compose to $target with $attachmentCount attachment(s)?"
|
||||
} else if (attachmentCount > 0) {
|
||||
"Share $attachmentCount attachment(s) from Hermes Relay?"
|
||||
} else {
|
||||
"Share text from Hermes Relay?"
|
||||
}
|
||||
val allowed = safetyManager.awaitConfirmation(path, confirmText)
|
||||
if (!allowed) {
|
||||
respond(
|
||||
requestId, 403,
|
||||
userDeniedResponse(
|
||||
"The user denied the media share action via the " +
|
||||
"on-device confirmation modal.",
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
respondFromResult(requestId, shareMediaFromBody(path, body, service))
|
||||
}
|
||||
// === END PHASE3-tier-C ===
|
||||
|
||||
"/screen" -> {
|
||||
@@ -1623,6 +1693,303 @@ class BridgeCommandHandler(
|
||||
return if (out.isEmpty()) null else out
|
||||
}
|
||||
|
||||
private fun extractShareAttachmentRefs(body: JsonObject): List<ShareAttachmentRef> {
|
||||
val refs = mutableListOf<ShareAttachmentRef>()
|
||||
|
||||
fun stringField(obj: JsonObject, key: String): String? =
|
||||
(obj[key] as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
fun stringValue(element: JsonElement): String? =
|
||||
(element as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
fun addRef(
|
||||
media: String? = null,
|
||||
path: String? = null,
|
||||
contentType: String? = null,
|
||||
fileName: String? = null,
|
||||
) {
|
||||
val normalizedMedia = media?.trim()?.takeIf { it.isNotBlank() }
|
||||
val normalizedPath = path?.trim()?.takeIf { it.isNotBlank() }
|
||||
if (normalizedMedia == null && normalizedPath == null) return
|
||||
refs.add(
|
||||
ShareAttachmentRef(
|
||||
media = normalizedMedia,
|
||||
path = normalizedPath,
|
||||
contentType = contentType?.trim()?.takeIf { it.isNotBlank() },
|
||||
fileName = fileName?.trim()?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val contentType = stringField(body, "content_type")
|
||||
val fileName = stringField(body, "file_name")
|
||||
addRef(path = stringField(body, "path"), contentType = contentType, fileName = fileName)
|
||||
addRef(media = stringField(body, "media"), contentType = contentType, fileName = fileName)
|
||||
addRef(media = stringField(body, "media_token"), contentType = contentType, fileName = fileName)
|
||||
|
||||
(body["paths"] as? JsonArray)?.forEach { element ->
|
||||
addRef(path = stringValue(element))
|
||||
}
|
||||
(body["media_tokens"] as? JsonArray)?.forEach { element ->
|
||||
addRef(media = stringValue(element))
|
||||
}
|
||||
(body["attachments"] as? JsonArray)?.forEach { element: JsonElement ->
|
||||
val obj = element as? JsonObject ?: return@forEach
|
||||
val refContentType = stringField(obj, "content_type")
|
||||
val refFileName = stringField(obj, "file_name")
|
||||
val media = stringField(obj, "media")
|
||||
?: stringField(obj, "media_token")
|
||||
?: stringField(obj, "token")
|
||||
addRef(
|
||||
media = media,
|
||||
path = stringField(obj, "path"),
|
||||
contentType = refContentType,
|
||||
fileName = refFileName,
|
||||
)
|
||||
}
|
||||
|
||||
return refs
|
||||
}
|
||||
|
||||
private suspend fun shareMediaFromBody(
|
||||
path: String,
|
||||
body: JsonObject,
|
||||
service: HermesAccessibilityService,
|
||||
): ActionExecutor.ActionResult {
|
||||
val isMms = path == "/send_mms"
|
||||
fun stringField(key: String): String? =
|
||||
(body[key] as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
val to = stringField("to").orEmpty()
|
||||
val text = stringField("text")
|
||||
?: stringField("body")
|
||||
?: ""
|
||||
val title = stringField("title")
|
||||
?: if (isMms) "Send MMS" else "Share with"
|
||||
val explicitTargetPkg = stringField("package")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
if (isMms && to.isBlank()) {
|
||||
return ActionExecutor.ActionResult.failure(
|
||||
"send_mms requires non-blank 'to'",
|
||||
mapOf("status" to "failed", "reason" to "invalid_schema"),
|
||||
)
|
||||
}
|
||||
|
||||
val attachmentRefs = extractShareAttachmentRefs(body)
|
||||
if (attachmentRefs.isEmpty() && text.isBlank()) {
|
||||
return ActionExecutor.ActionResult.failure(
|
||||
"$path requires at least one attachment or non-blank text",
|
||||
mapOf("status" to "failed", "reason" to "invalid_schema"),
|
||||
)
|
||||
}
|
||||
|
||||
val cached = mutableListOf<CachedShareAttachment>()
|
||||
if (attachmentRefs.isNotEmpty()) {
|
||||
val client = relayHttpClient
|
||||
?: return ActionExecutor.ActionResult.failure(
|
||||
"relay HTTP client not initialized — cannot fetch media",
|
||||
mapOf("status" to "failed", "reason" to "relay_http_client_missing"),
|
||||
)
|
||||
val writer = mediaCacheWriter
|
||||
?: return ActionExecutor.ActionResult.failure(
|
||||
"media cache writer not initialized — cannot prepare attachment",
|
||||
mapOf("status" to "failed", "reason" to "media_cache_writer_missing"),
|
||||
)
|
||||
|
||||
for (ref in attachmentRefs) {
|
||||
val fetchResult = fetchShareAttachment(client, ref)
|
||||
if (fetchResult.isFailure) {
|
||||
return ActionExecutor.ActionResult.failure(
|
||||
fetchResult.exceptionOrNull()?.message ?: "media fetch failed",
|
||||
mapOf("status" to "failed", "reason" to "media_fetch_failed"),
|
||||
)
|
||||
}
|
||||
val fetched = fetchResult.getOrThrow()
|
||||
val contentType = ref.contentType
|
||||
?: fetched.contentType.takeIf { it.isNotBlank() }
|
||||
?: "application/octet-stream"
|
||||
val fileName = ref.fileName ?: fetched.fileName
|
||||
val uri = runCatching {
|
||||
writer.cache(fetched.bytes, contentType, fileName)
|
||||
}.getOrElse { t ->
|
||||
return ActionExecutor.ActionResult.failure(
|
||||
"media cache failed: ${t.message}",
|
||||
mapOf("status" to "failed", "reason" to "media_cache_failed"),
|
||||
)
|
||||
}
|
||||
cached.add(
|
||||
CachedShareAttachment(
|
||||
uri = uri,
|
||||
contentType = contentType,
|
||||
fileName = fileName,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val targetPkg = explicitTargetPkg
|
||||
?: if (isMms) {
|
||||
runCatching {
|
||||
android.provider.Telephony.Sms.getDefaultSmsPackage(service)
|
||||
}.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val intent = buildShareIntent(
|
||||
isMms = isMms,
|
||||
to = to,
|
||||
text = text,
|
||||
attachments = cached,
|
||||
targetPkg = targetPkg,
|
||||
)
|
||||
|
||||
val launchIntent = if (targetPkg.isNullOrBlank()) {
|
||||
Intent.createChooser(intent, title).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
intent.clipData?.let { clipData = it }
|
||||
}
|
||||
} else {
|
||||
intent
|
||||
}
|
||||
|
||||
return try {
|
||||
withContext(Dispatchers.Main) {
|
||||
service.applicationContext.startActivity(launchIntent)
|
||||
}
|
||||
ActionExecutor.ActionResult.ok(
|
||||
mapOf(
|
||||
"ok" to true,
|
||||
"status" to if (isMms) "compose_opened" else "share_opened",
|
||||
"mode" to if (isMms) "user_confirmed_mms_handoff" else "user_confirmed_share",
|
||||
"to" to if (isMms) to else null,
|
||||
"attachments" to cached.size,
|
||||
"content_types" to cached.map { it.contentType },
|
||||
"package" to targetPkg,
|
||||
"summary" to if (isMms) {
|
||||
"Opened MMS compose for $to with ${cached.size} attachment(s)"
|
||||
} else {
|
||||
"Opened share UI with ${cached.size} attachment(s)"
|
||||
},
|
||||
)
|
||||
)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
ActionExecutor.ActionResult.failure(
|
||||
"No Android app can handle this ${if (isMms) "MMS" else "share"} request",
|
||||
mapOf("status" to "failed", "reason" to "activity_not_found"),
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
ActionExecutor.ActionResult.failure(
|
||||
"Android denied attachment URI grant: ${e.message}",
|
||||
mapOf("status" to "failed", "reason" to "uri_permission_denied"),
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
ActionExecutor.ActionResult.failure(
|
||||
"share launch failed: ${t.message}",
|
||||
mapOf("status" to "failed", "reason" to "android_exception"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchShareAttachment(
|
||||
client: RelayHttpClient,
|
||||
ref: ShareAttachmentRef,
|
||||
): Result<RelayHttpClient.FetchedMedia> {
|
||||
val media = ref.media?.trim().orEmpty()
|
||||
val path = ref.path?.trim().orEmpty()
|
||||
return when {
|
||||
media.startsWith("MEDIA:hermes-relay://") -> {
|
||||
client.fetchMedia(media.removePrefix("MEDIA:hermes-relay://"))
|
||||
}
|
||||
media.startsWith("hermes-relay://") -> {
|
||||
client.fetchMedia(media.removePrefix("hermes-relay://"))
|
||||
}
|
||||
media.startsWith("MEDIA:/") || media.startsWith("MEDIA:\\") -> {
|
||||
client.fetchMediaByPath(media.removePrefix("MEDIA:"), ref.contentType)
|
||||
}
|
||||
media.startsWith("MEDIA:") -> {
|
||||
client.fetchMedia(media.removePrefix("MEDIA:"))
|
||||
}
|
||||
media.isNotBlank() -> client.fetchMedia(media)
|
||||
path.isNotBlank() -> client.fetchMediaByPath(path, ref.contentType)
|
||||
else -> Result.failure(IllegalArgumentException("attachment missing media token or path"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildShareIntent(
|
||||
isMms: Boolean,
|
||||
to: String,
|
||||
text: String,
|
||||
attachments: List<CachedShareAttachment>,
|
||||
targetPkg: String?,
|
||||
): Intent {
|
||||
val intent = when {
|
||||
isMms && attachments.isEmpty() -> Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:$to"))
|
||||
attachments.size > 1 -> Intent(Intent.ACTION_SEND_MULTIPLE)
|
||||
else -> Intent(Intent.ACTION_SEND)
|
||||
}
|
||||
|
||||
if (!(isMms && attachments.isEmpty())) {
|
||||
intent.type = commonContentType(attachments).ifBlank { "text/plain" }
|
||||
}
|
||||
if (!targetPkg.isNullOrBlank()) {
|
||||
intent.setPackage(targetPkg)
|
||||
}
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
|
||||
if (text.isNotBlank()) {
|
||||
intent.putExtra(Intent.EXTRA_TEXT, text)
|
||||
if (isMms) {
|
||||
intent.putExtra("sms_body", text)
|
||||
}
|
||||
}
|
||||
if (isMms) {
|
||||
intent.putExtra("address", to)
|
||||
}
|
||||
|
||||
if (attachments.size == 1) {
|
||||
intent.putExtra(Intent.EXTRA_STREAM, attachments.first().uri)
|
||||
} else if (attachments.size > 1) {
|
||||
intent.putParcelableArrayListExtra(
|
||||
Intent.EXTRA_STREAM,
|
||||
ArrayList<Uri>(attachments.map { it.uri }),
|
||||
)
|
||||
}
|
||||
attachClipData(intent, attachments, serviceLabel = if (isMms) "mms attachment" else "attachment")
|
||||
return intent
|
||||
}
|
||||
|
||||
private fun commonContentType(attachments: List<CachedShareAttachment>): String {
|
||||
if (attachments.isEmpty()) return "text/plain"
|
||||
val normalized = attachments
|
||||
.map { it.contentType.substringBefore(';').trim().lowercase() }
|
||||
.filter { it.isNotBlank() }
|
||||
if (normalized.isEmpty()) return "application/octet-stream"
|
||||
val distinct = normalized.toSet()
|
||||
if (distinct.size == 1) return distinct.first()
|
||||
val majors = normalized.map { it.substringBefore('/') }.toSet()
|
||||
return if (majors.size == 1) "${majors.first()}/*" else "*/*"
|
||||
}
|
||||
|
||||
private fun attachClipData(
|
||||
intent: Intent,
|
||||
attachments: List<CachedShareAttachment>,
|
||||
serviceLabel: String,
|
||||
) {
|
||||
if (attachments.isEmpty()) return
|
||||
val first = attachments.first()
|
||||
val clip = ClipData.newRawUri(
|
||||
first.fileName ?: serviceLabel,
|
||||
first.uri,
|
||||
)
|
||||
attachments.drop(1).forEach { attachment ->
|
||||
clip.addItem(ClipData.Item(attachment.uri))
|
||||
}
|
||||
intent.clipData = clip
|
||||
}
|
||||
|
||||
private suspend fun respondFromResult(requestId: String, result: ActionExecutor.ActionResult) {
|
||||
val status = if (result.ok) 200 else 400
|
||||
val payload = buildJsonObject {
|
||||
@@ -1646,6 +2013,10 @@ class BridgeCommandHandler(
|
||||
} else {
|
||||
val err = result.error ?: "unknown error"
|
||||
put("error", err)
|
||||
for ((k, v) in result.data) {
|
||||
if (v == null) continue
|
||||
put(k, anyToJsonElement(v))
|
||||
}
|
||||
// M2: structured error code for the LLM tool-calling path.
|
||||
// ActionExecutor returns free-text errors like "Grant contacts
|
||||
// permission in Settings..." which LLMs CAN interpret, but
|
||||
@@ -1772,6 +2143,8 @@ class BridgeCommandHandler(
|
||||
"re-attempt.",
|
||||
)
|
||||
put("error_code", "user_denied")
|
||||
put("status", "blocked")
|
||||
put("android_result", "user_denied")
|
||||
put("reason", "confirmation_denied_or_timeout")
|
||||
put("final", true)
|
||||
put(
|
||||
@@ -1911,9 +2284,17 @@ class BridgeCommandHandler(
|
||||
?: ""
|
||||
"/press_key" -> body["key"]?.jsonPrimitive?.content ?: ""
|
||||
"/send_sms" -> {
|
||||
val to = body["number"]?.jsonPrimitive?.content ?: "?"
|
||||
val to = body["to"]?.jsonPrimitive?.content ?: "?"
|
||||
"-> $to"
|
||||
}
|
||||
"/send_mms" -> {
|
||||
val to = body["to"]?.jsonPrimitive?.content ?: "?"
|
||||
"→ $to"
|
||||
}
|
||||
"/share_media" -> {
|
||||
val count = extractShareAttachmentRefs(body).size
|
||||
if (count > 0) "$count attachment(s)" else "text"
|
||||
}
|
||||
"/call" -> body["number"]?.jsonPrimitive?.content?.let { "→ $it" } ?: ""
|
||||
"/search_contacts" -> body["query"]?.jsonPrimitive?.content?.let { "\"$it\"" } ?: ""
|
||||
"/screen", "/screenshot", "/return_to_hermes", "/get_apps",
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.hermesandroid.relay.network.handlers
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.network.models.MessageItem
|
||||
@@ -70,6 +72,22 @@ class ChatHandler {
|
||||
// placeholder instead of attempting a fetch.
|
||||
private val mediaRelayRegex = Regex("""MEDIA:hermes-relay://([A-Za-z0-9_-]+)""")
|
||||
private val mediaBarePathRegex = Regex("""^\s*MEDIA:(/\S+)\s*$""")
|
||||
// Rich card marker — single line, full JSON object payload.
|
||||
//
|
||||
// Agents emit:
|
||||
// CARD:{"type":"approval_request","title":"...","actions":[...]}
|
||||
//
|
||||
// Constraints (mirrors the `MEDIA:` marker contract in
|
||||
// hermes-agent's prompt_builder.py): the marker MUST live on its
|
||||
// own line, and the JSON must be single-line (escape newlines in
|
||||
// string fields as `\n`). This keeps the line-buffer parser
|
||||
// trivial — same strategy as MEDIA.
|
||||
//
|
||||
// The regex is intentionally greedy on the JSON body so nested
|
||||
// braces in fields/actions are captured correctly. Invalid JSON is
|
||||
// logged and the line is left in content untouched so the user
|
||||
// still sees _something_ rather than a silent drop.
|
||||
private val cardMarkerRegex = Regex("""^\s*CARD:(\{.*\})\s*$""")
|
||||
// Known completion/failure emojis — if these appear in backtick format, it's a completion
|
||||
private val completionEmojis = setOf("✅", "✓", "☑")
|
||||
private val failureEmojis = setOf("❌", "✗", "⚠")
|
||||
@@ -131,6 +149,27 @@ class ChatHandler {
|
||||
private var mediaLineBuffer = StringBuilder()
|
||||
private val dispatchedMediaMarkers = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* Separate line buffer + dedupe set for rich-card markers, mirroring
|
||||
* the media-marker pipeline above. Cards are a first-class feature
|
||||
* (not gated behind any flag), so they need their own buffer for the
|
||||
* same reason `MEDIA:` does — tool-annotation parsing can be toggled
|
||||
* off without silently dropping partial card lines.
|
||||
*/
|
||||
private var cardLineBuffer = StringBuilder()
|
||||
private val dispatchedCardMarkers = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
* Lenient JSON for card payloads. `ignoreUnknownKeys` means future
|
||||
* schema additions (new card types, new field shapes) won't crash
|
||||
* older phone builds — the renderer's unknown-type fallback handles
|
||||
* display.
|
||||
*/
|
||||
private val cardJson = kotlinx.serialization.json.Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which tool names currently have an active (in-progress) annotation-based
|
||||
* ToolCall, keyed by "messageId:toolName" → toolCallId. This lets us match a
|
||||
@@ -161,6 +200,18 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceMessageContent(messageId: String, content: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { message ->
|
||||
if (message.id == messageId) {
|
||||
message.copy(content = content)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a local-only voice-intent trace to the chat scroll. Used by
|
||||
* the sideload voice intent flow (`RealVoiceBridgeIntentHandler`) so
|
||||
@@ -303,6 +354,65 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Twin of [markVoiceIntentsSynced] for rich-card action dispatches.
|
||||
* Flips [com.hermesandroid.relay.data.HermesCardDispatch.syncedToServer]
|
||||
* to true on every dispatch whose flag is currently false, so the
|
||||
* [com.hermesandroid.relay.viewmodel.CardDispatchSyncBuilder] doesn't
|
||||
* re-emit them on the next chat send. Called from
|
||||
* [com.hermesandroid.relay.viewmodel.ChatViewModel.startStream] at the
|
||||
* same point — right after the API client accepts the request — so
|
||||
* the voice-intent and card-dispatch sync paths have identical
|
||||
* commit-timing semantics and a thrown request-building exception
|
||||
* falsely marks neither stream as synced.
|
||||
*/
|
||||
fun markCardDispatchesSynced() {
|
||||
_messages.update { messages ->
|
||||
var changed = false
|
||||
val mapped = messages.map { msg ->
|
||||
if (msg.cardDispatches.isEmpty()) return@map msg
|
||||
val anyUnsynced = msg.cardDispatches.any { !it.syncedToServer }
|
||||
if (!anyUnsynced) return@map msg
|
||||
changed = true
|
||||
msg.copy(
|
||||
cardDispatches = msg.cardDispatches.map {
|
||||
if (it.syncedToServer) it else it.copy(syncedToServer = true)
|
||||
}
|
||||
)
|
||||
}
|
||||
if (changed) mapped else messages
|
||||
}
|
||||
}
|
||||
|
||||
fun attachRealtimeTurnTrace(messageId: String, trace: RealtimeTurnTrace) {
|
||||
_messages.update { messages ->
|
||||
var changed = false
|
||||
val mapped = messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
changed = true
|
||||
msg.copy(realtimeTurn = trace)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
if (changed) mapped else messages
|
||||
}
|
||||
}
|
||||
|
||||
fun markRealtimeTurnsSynced() {
|
||||
_messages.update { messages ->
|
||||
var changed = false
|
||||
val mapped = messages.map { msg ->
|
||||
val trace = msg.realtimeTurn
|
||||
if (trace != null && !trace.syncedToServer) {
|
||||
changed = true
|
||||
msg.copy(realtimeTurn = trace.copy(syncedToServer = true))
|
||||
} else msg
|
||||
}
|
||||
if (changed) mapped else messages
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable lenient JSON parser for tool-result previews. [Json { ... }]
|
||||
* is cheap to construct but we share one instance so per-tool-completion
|
||||
@@ -482,6 +592,33 @@ class ChatHandler {
|
||||
dispatchedMediaMarkers.clear()
|
||||
annotationLineBuffer.clear()
|
||||
activeAnnotationTools.clear()
|
||||
cardLineBuffer.clear()
|
||||
dispatchedCardMarkers.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair assistant labels after late-arriving agent config. History can
|
||||
* load before GET /api/config returns, leaving default-profile messages
|
||||
* with the generic "Hermes" label. Keep local phone/voice action trace
|
||||
* labels intact because those bubbles do not represent the server agent.
|
||||
*/
|
||||
fun relabelGenericAssistantMessages(agentName: String?) {
|
||||
val trimmed = agentName?.trim()?.takeIf { it.isNotBlank() } ?: return
|
||||
_messages.update { list ->
|
||||
list.map { message ->
|
||||
if (
|
||||
message.role == MessageRole.ASSISTANT &&
|
||||
!message.id.startsWith("voice-intent-") &&
|
||||
message.agentName != "Voice action" &&
|
||||
message.agentName != "Phone action" &&
|
||||
(message.agentName.isNullOrBlank() || message.agentName == "Hermes")
|
||||
) {
|
||||
message.copy(agentName = trimmed)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -571,19 +708,33 @@ class ChatHandler {
|
||||
|
||||
// Run the media marker parser on assistant content; strip matched
|
||||
// lines and queue hits for post-assignment dispatch.
|
||||
val cleanedContent = if (role == MessageRole.ASSISTANT && rawContent.isNotEmpty()) {
|
||||
val afterMedia = if (role == MessageRole.ASSISTANT && rawContent.isNotEmpty()) {
|
||||
extractMediaMarkersFromContent(messageId, rawContent, pendingMediaHits)
|
||||
} else {
|
||||
rawContent
|
||||
}
|
||||
|
||||
// Cards are synchronous (no async fetch) so we attach them
|
||||
// straight onto the reconstructed ChatMessage and strip their
|
||||
// lines from the displayed content in the same pass. No
|
||||
// post-assignment dispatch needed.
|
||||
val (cleanedContent, extractedCards) = if (
|
||||
role == MessageRole.ASSISTANT && afterMedia.isNotEmpty()
|
||||
) {
|
||||
extractCardsFromContent(afterMedia)
|
||||
} else {
|
||||
afterMedia to emptyList()
|
||||
}
|
||||
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
role = role,
|
||||
content = cleanedContent,
|
||||
timestamp = timestampMs,
|
||||
isStreaming = false,
|
||||
toolCalls = toolCalls
|
||||
toolCalls = toolCalls,
|
||||
cards = extractedCards,
|
||||
agentName = if (role == MessageRole.ASSISTANT) activeAgentName else null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -691,6 +842,38 @@ class ChatHandler {
|
||||
return cleaned.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan loaded content for `CARD:{json}` lines, parse each to a
|
||||
* [HermesCard], return the cleaned content + the extracted cards. Pure
|
||||
* function — does not mutate [_messages] or mark anything dispatched.
|
||||
* Called from [loadMessageHistory]. Unparseable card lines are left in
|
||||
* the content (same policy as [tryDispatchCardMarker]) so the user
|
||||
* sees a visible artifact instead of a silent drop.
|
||||
*/
|
||||
private fun extractCardsFromContent(content: String): Pair<String, List<HermesCard>> {
|
||||
var cleaned = content
|
||||
val cards = mutableListOf<HermesCard>()
|
||||
for (rawLine in content.lines()) {
|
||||
val trimmed = rawLine.trim()
|
||||
if (trimmed.isEmpty()) continue
|
||||
val match = cardMarkerRegex.find(trimmed) ?: continue
|
||||
val payload = match.groupValues[1]
|
||||
val card = try {
|
||||
cardJson.decodeFromString(HermesCard.serializer(), payload)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Card reload parse failed: ${e.message}")
|
||||
continue
|
||||
}
|
||||
cards += card
|
||||
cleaned = cleaned
|
||||
.replace("\n$rawLine\n", "\n")
|
||||
.replace("\n$rawLine", "")
|
||||
.replace("$rawLine\n", "")
|
||||
.replace(rawLine, "")
|
||||
}
|
||||
return cleaned.trim() to cards
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the tool_calls JSON from an assistant message into ToolCall objects.
|
||||
* Format: array of objects with {id, type:"function", function: {name, arguments}}
|
||||
@@ -764,6 +947,10 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSessions() {
|
||||
_sessions.value = emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a session from the local list (optimistic delete).
|
||||
*/
|
||||
@@ -846,6 +1033,12 @@ class ChatHandler {
|
||||
// Always scan for media markers — inbound attachments are a first-class
|
||||
// feature and shouldn't be gated behind the tool-annotation flag.
|
||||
scanForMediaMarkers(messageId, processedDelta)
|
||||
|
||||
// Rich cards ride the same "always on" treatment as media — the
|
||||
// agent can emit `CARD:{json}` at any point in any endpoint and
|
||||
// the renderer should pick it up without the user having to
|
||||
// opt in to any parsing mode.
|
||||
scanForCardMarkers(messageId, processedDelta)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -984,6 +1177,116 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Card-marker line scanner — twin of [scanForMediaMarkers] for
|
||||
* `CARD:{json}` rows. Matched cards are appended to the message's
|
||||
* [ChatMessage.cards] list and the raw line is stripped from
|
||||
* [ChatMessage.content] so the user only sees the rendered
|
||||
* [com.hermesandroid.relay.ui.components.HermesCardBubble], never the
|
||||
* literal JSON.
|
||||
*/
|
||||
private fun scanForCardMarkers(messageId: String, delta: String) {
|
||||
cardLineBuffer.append(delta)
|
||||
|
||||
while (true) {
|
||||
val newlineIndex = cardLineBuffer.indexOf('\n')
|
||||
if (newlineIndex == -1) break
|
||||
|
||||
val line = cardLineBuffer.substring(0, newlineIndex)
|
||||
cardLineBuffer.delete(0, newlineIndex + 1)
|
||||
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.isEmpty()) continue
|
||||
|
||||
if (tryDispatchCardMarker(messageId, trimmed)) {
|
||||
stripLineFromContent(messageId, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single `CARD:{json}` line. On success, appends the card to
|
||||
* [ChatMessage.cards] for [messageId]. Dedupes by "messageId:cardKey"
|
||||
* where cardKey is the parsed [HermesCard.id] or a SHA-style hash
|
||||
* (content-based) fallback, so the same card re-appearing during
|
||||
* streaming + finalize reconciliation doesn't render twice.
|
||||
*
|
||||
* Invalid JSON is logged and returns false — the caller leaves the
|
||||
* line in the content, which at least gives the user a visible hint
|
||||
* that the agent tried to emit a card the phone couldn't parse.
|
||||
*/
|
||||
private fun tryDispatchCardMarker(messageId: String, line: String): Boolean {
|
||||
val match = cardMarkerRegex.find(line) ?: return false
|
||||
val payload = match.groupValues[1]
|
||||
|
||||
val card = try {
|
||||
cardJson.decodeFromString(HermesCard.serializer(), payload)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Card marker parse failed: ${e.message} | payload=${payload.take(200)}")
|
||||
return false
|
||||
}
|
||||
|
||||
// Content-based fallback key when the agent didn't supply an id —
|
||||
// good enough for dedupe of the exact same card within a single
|
||||
// message turn.
|
||||
val cardKey = card.id ?: "anon:${payload.hashCode()}"
|
||||
val dedupeKey = "$messageId:$cardKey"
|
||||
if (!dispatchedCardMarkers.add(dedupeKey)) {
|
||||
Log.d(TAG, "Card marker duplicate, skipping: $cardKey")
|
||||
return true // Still strip the line — it's a valid card, just a repeat.
|
||||
}
|
||||
|
||||
Log.d(TAG, "Card marker: type=${card.type} key=$cardKey")
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
msg.copy(cards = msg.cards + card)
|
||||
} else msg
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the card line buffer + post-stream reconcile, twin of
|
||||
* [finalizeMediaMarkers]. Guards against the race where a CARD: line
|
||||
* arrived as the last delta with no trailing newline, and also
|
||||
* re-sweeps the finalized content for any card lines that survived
|
||||
* real-time stripping (update-ordering race against [stripLineFromContent]).
|
||||
*/
|
||||
private fun finalizeCardMarkers(messageId: String) {
|
||||
if (cardLineBuffer.isNotEmpty()) {
|
||||
val remaining = cardLineBuffer.toString().trim()
|
||||
cardLineBuffer.clear()
|
||||
if (remaining.isNotEmpty() && tryDispatchCardMarker(messageId, remaining)) {
|
||||
stripLineFromContent(messageId, remaining)
|
||||
}
|
||||
}
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
var cleaned = msg.content
|
||||
var changed = false
|
||||
for (rawLine in msg.content.lines()) {
|
||||
val trimmed = rawLine.trim()
|
||||
if (trimmed.isEmpty()) continue
|
||||
if (cardMarkerRegex.containsMatchIn(trimmed)) {
|
||||
tryDispatchCardMarker(messageId, trimmed)
|
||||
cleaned = cleaned
|
||||
.replace("\n$rawLine\n", "\n")
|
||||
.replace("\n$rawLine", "")
|
||||
.replace("$rawLine\n", "")
|
||||
.replace(rawLine, "")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) msg.copy(content = cleaned.trim()) else msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect [line] for a media marker and dispatch the appropriate callback.
|
||||
*
|
||||
@@ -1296,7 +1599,30 @@ class ChatHandler {
|
||||
return null
|
||||
}
|
||||
|
||||
fun onToolCallStart(messageId: String, toolCallId: String, toolName: String) {
|
||||
fun setMessageBadges(messageId: String, badges: List<String>) {
|
||||
val cleaned = badges
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.take(4)
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
msg.copy(badges = cleaned)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onToolCallStart(
|
||||
messageId: String,
|
||||
toolCallId: String,
|
||||
toolName: String,
|
||||
runId: String? = null,
|
||||
provenance: String? = null,
|
||||
) {
|
||||
_isStreaming.value = true
|
||||
|
||||
val toolCall = ToolCall(
|
||||
@@ -1305,7 +1631,9 @@ class ChatHandler {
|
||||
args = null,
|
||||
result = null,
|
||||
success = null,
|
||||
isComplete = false
|
||||
isComplete = false,
|
||||
runId = runId,
|
||||
provenance = provenance,
|
||||
)
|
||||
|
||||
_messages.update { messages ->
|
||||
@@ -1334,7 +1662,12 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fun onToolCallComplete(messageId: String, toolCallId: String, resultPreview: String? = null) {
|
||||
fun onToolCallComplete(
|
||||
messageId: String,
|
||||
toolCallId: String,
|
||||
resultPreview: String? = null,
|
||||
provenance: String? = null,
|
||||
) {
|
||||
// Snapshot the matching tool call's name BEFORE mutating — we need it
|
||||
// to decide whether to emit a phone-action result bubble below.
|
||||
val toolName = _messages.value
|
||||
@@ -1352,6 +1685,7 @@ class ChatHandler {
|
||||
success = true,
|
||||
isComplete = true,
|
||||
result = resultPreview ?: call.result,
|
||||
provenance = provenance ?: call.provenance,
|
||||
completedAt = System.currentTimeMillis()
|
||||
)
|
||||
} else {
|
||||
@@ -1446,6 +1780,9 @@ class ChatHandler {
|
||||
|
||||
// Finalize media markers unconditionally (not gated by parseToolAnnotations)
|
||||
finalizeMediaMarkers(messageId)
|
||||
// Rich cards get the same unconditional treatment — a partial
|
||||
// card line without a trailing newline should still render.
|
||||
finalizeCardMarkers(messageId)
|
||||
// Note: do NOT set _isStreaming to false — the run is still active
|
||||
}
|
||||
|
||||
@@ -1480,6 +1817,7 @@ class ChatHandler {
|
||||
|
||||
// Finalize media markers unconditionally
|
||||
finalizeMediaMarkers(messageId)
|
||||
finalizeCardMarkers(messageId)
|
||||
}
|
||||
|
||||
fun onStreamError(message: String) {
|
||||
@@ -1538,6 +1876,37 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Card action dispatch ---
|
||||
|
||||
/**
|
||||
* Record that the user tapped an action on a rich card. Appends a
|
||||
* [com.hermesandroid.relay.data.HermesCardDispatch] to the owning
|
||||
* message's dispatch list, which
|
||||
* [com.hermesandroid.relay.ui.components.HermesCardBubble] reads to
|
||||
* collapse the action row into a "you chose X" confirmation.
|
||||
*
|
||||
* Idempotent — taps on the same (cardKey, actionValue) pair are
|
||||
* silently coalesced, so tapping twice during a slow send doesn't
|
||||
* double-stamp the history.
|
||||
*/
|
||||
fun recordCardDispatch(messageId: String, cardKey: String, actionValue: String) {
|
||||
val stamp = com.hermesandroid.relay.data.HermesCardDispatch(
|
||||
cardKey = cardKey,
|
||||
actionValue = actionValue,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
val alreadyDispatched = msg.cardDispatches.any {
|
||||
it.cardKey == cardKey && it.actionValue == actionValue
|
||||
}
|
||||
if (alreadyDispatched) msg
|
||||
else msg.copy(cardDispatches = msg.cardDispatches + stamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Retry support ---
|
||||
|
||||
private val _lastSentMessage = MutableStateFlow<String?>(null)
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
package com.hermesandroid.relay.network.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Standard relay envelope. Most channel traffic nests data in [payload]
|
||||
* so a single decoder handles every message shape.
|
||||
*
|
||||
* **`profiles` escape hatch.** The `pairing`-channel `profiles.updated`
|
||||
* push envelope (see `AuthManager.handleProfilesUpdated`) hoists its
|
||||
* profiles array to the top level of the JSON rather than nesting it
|
||||
* inside payload. Rather than duplicating the kotlinx.serialization
|
||||
* pipeline for one message type, we widen [Envelope] with an optional
|
||||
* top-level [profiles] array. Every other envelope type leaves it null
|
||||
* — kotlinx.serialization tolerates an absent field because of the
|
||||
* default.
|
||||
*/
|
||||
@Serializable
|
||||
data class Envelope(
|
||||
val channel: String,
|
||||
val type: String,
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val payload: JsonObject = buildJsonObject {}
|
||||
val payload: JsonObject = buildJsonObject {},
|
||||
val profiles: JsonArray? = null,
|
||||
)
|
||||
|
||||
@@ -112,7 +112,8 @@ data class SessionItem(
|
||||
@Serializable
|
||||
data class CreateSessionRequest(
|
||||
val title: String? = null,
|
||||
val model: String? = null
|
||||
val model: String? = null,
|
||||
val profile: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -299,5 +300,6 @@ data class SkillInfo(
|
||||
@Serializable
|
||||
data class SkillListResponse(
|
||||
val skills: List<SkillInfo>? = null,
|
||||
val items: List<SkillInfo>? = null
|
||||
val items: List<SkillInfo>? = null,
|
||||
val data: List<SkillInfo>? = null
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+950
@@ -0,0 +1,950 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.ClipData
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.network.ConnectionState
|
||||
import com.hermesandroid.relay.network.RelayUrlDeriver
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
import com.hermesandroid.relay.util.classifyError
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.asBadgeState
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* ──────────────────────────────────────────────────────────────────────
|
||||
* Active-connection card body sections — the "what was Settings →
|
||||
* Connection" feature set, now living inline on the active `ConnectionCard`
|
||||
* inside [com.hermesandroid.relay.ui.screens.ConnectionsSettingsScreen].
|
||||
*
|
||||
* The per-card action row (Reconnect/Rename/Re-pair/Revoke/Remove) stays
|
||||
* on `ConnectionCard` itself. Everything below the first divider — status
|
||||
* rows, endpoints expander, advanced expander (manual URL, insecure
|
||||
* toggle, manual pairing code), and the security-posture strip — is
|
||||
* extracted here so `ConnectionsSettingsScreen` stays focused on the list
|
||||
* layout and this file owns the active-card deep content.
|
||||
*
|
||||
* All composables in this file assume they render INSIDE an active
|
||||
* `ConnectionCard`'s Column (16dp padding, 8dp vertical spacing). None of
|
||||
* them introduce a new Card wrapper or scroll container.
|
||||
*
|
||||
* Call sites pass a non-null `connectionViewModel` — these sections are
|
||||
* never rendered for non-active cards, so the VM guard happens at the
|
||||
* call site.
|
||||
* ──────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
/**
|
||||
* Three tappable status rows (API / Relay / Session), always visible on
|
||||
* the active card. Replaces the old "Active Connection" quick-look card
|
||||
* that used to live at the top of `SettingsScreen` — same information
|
||||
* density, same tap-for-info-sheet behavior.
|
||||
*
|
||||
* Tap on the Relay row while it's [RelayUiState.Stale] fires an immediate
|
||||
* reconnect + toast; every other row falls through to the info sheet
|
||||
* target via [onOpenApiInfo] / [onOpenRelayInfo] / [onOpenSessionInfo].
|
||||
*/
|
||||
@Composable
|
||||
fun ActiveCardStatusSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
onOpenSessionInfo: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayRowState by connectionViewModel.relayRowState.collectAsState()
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "API Server",
|
||||
isConnected = apiReachable,
|
||||
isProbing = apiHealth == ConnectionViewModel.HealthStatus.Probing,
|
||||
statusText = when {
|
||||
apiHealth == ConnectionViewModel.HealthStatus.Probing -> "Checking…"
|
||||
apiReachable -> "Reachable"
|
||||
else -> "Unreachable"
|
||||
},
|
||||
onClick = onOpenApiInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (relayEnabled) {
|
||||
// ADR 24: relayRowState carries both the phase and the active
|
||||
// endpoint role. statusText appends " · <Role>" when the
|
||||
// resolver has picked one, so the chip reads "Connected · LAN"
|
||||
// etc. without any extra wiring here.
|
||||
ConnectionStatusRow(
|
||||
label = "Relay",
|
||||
state = relayRowState.asBadgeState(),
|
||||
statusText = relayRowState.statusText(connectedLabel = "Connected"),
|
||||
onClick = {
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
connectionViewModel.connectRelay()
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Reconnecting to relay…",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
} else {
|
||||
onOpenRelayInfo()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
ConnectionStatusRow(
|
||||
label = "Session",
|
||||
isConnected = authState is AuthState.Paired,
|
||||
isConnecting = authState is AuthState.Pairing,
|
||||
statusText = when (authState) {
|
||||
is AuthState.Paired -> "Paired"
|
||||
is AuthState.Pairing -> "Pairing..."
|
||||
is AuthState.Unpaired -> "Unpaired"
|
||||
is AuthState.Failed -> "Failed: ${(authState as AuthState.Failed).reason}"
|
||||
},
|
||||
onClick = onOpenSessionInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced expandable section — three subsections:
|
||||
* - Manual URL configuration (API URL + key + Save & Test,
|
||||
* Relay URL + Save & Test + Disconnect)
|
||||
* - Allow-insecure-connections toggle (with first-enable Ack dialog)
|
||||
* - Manual pairing code fallback (3-step flow with in-flight Connect
|
||||
* watcher + snackbar feedback)
|
||||
*
|
||||
* Wrapped in a single `SettingsExpandableCard` so the user can collapse
|
||||
* the entire block — none of it is needed for the common paired-via-QR
|
||||
* flow. Expanded-state is `rememberSaveable` so rotation / process death
|
||||
* preserves user intent.
|
||||
*
|
||||
* [onInsecureAckRequested] opens the `InsecureConnectionAckDialog` at
|
||||
* screen scope; this composable never owns it directly so the dialog
|
||||
* can persist through card recomposition in a LazyColumn.
|
||||
*/
|
||||
@Composable
|
||||
fun ActiveCardAdvancedSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
onInsecureAckRequested: () -> Unit,
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
SettingsExpandableCard(
|
||||
title = "Advanced",
|
||||
expanded = expanded,
|
||||
onToggle = { expanded = !expanded },
|
||||
isDarkTheme = isDarkTheme,
|
||||
) {
|
||||
ManualUrlSubsection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
relayEnabled = relayEnabled,
|
||||
)
|
||||
|
||||
if (relayEnabled) {
|
||||
HorizontalDivider()
|
||||
InsecureToggleSubsection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onInsecureAckRequested = onInsecureAckRequested,
|
||||
)
|
||||
HorizontalDivider()
|
||||
ManualPairingCodeSubsection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual URL configuration subsection. Power-user only — the canonical
|
||||
* path is QR pair via the Re-pair button on the card row above.
|
||||
*
|
||||
* Kept internal rather than split further because the API + relay
|
||||
* fields share the `isTesting` + `Save & Test` idiom and the two test
|
||||
* paths talk to the same VM.
|
||||
*/
|
||||
@Composable
|
||||
private fun ManualUrlSubsection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val apiServerUrl by connectionViewModel.apiServerUrl.collectAsState()
|
||||
val relayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
val apiKeyPresent by connectionViewModel.authManager.apiKeyPresent.collectAsState()
|
||||
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
|
||||
|
||||
// Keyed on the backing URL so a connection switch refreshes the input.
|
||||
var apiUrlInput by remember(apiServerUrl) { mutableStateOf(apiServerUrl) }
|
||||
var apiKeyInput by remember { mutableStateOf("") }
|
||||
var apiKeyVisible by remember { mutableStateOf(false) }
|
||||
var relayUrlInput by remember(relayUrl) { mutableStateOf(relayUrl) }
|
||||
var isTestingApi by remember { mutableStateOf(false) }
|
||||
var apiVoiceSetupResult by remember {
|
||||
mutableStateOf<ConnectionViewModel.ApiVoiceSetupResult?>(null)
|
||||
}
|
||||
var relayOverrideVisible by rememberSaveable(apiServerUrl) {
|
||||
mutableStateOf(!RelayUrlDeriver.isAutoManagedRelayUrl(relayUrl, apiServerUrl))
|
||||
}
|
||||
val autoRelayUrl = RelayUrlDeriver.deriveFromApiUrl(apiUrlInput)
|
||||
|
||||
LaunchedEffect(apiUrlInput, relayOverrideVisible, autoRelayUrl) {
|
||||
if (!relayOverrideVisible && autoRelayUrl != null && relayUrlInput != autoRelayUrl) {
|
||||
relayUrlInput = autoRelayUrl
|
||||
connectionViewModel.clearRelayReachableResult()
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = apiUrlInput,
|
||||
onValueChange = { apiUrlInput = it },
|
||||
label = { Text("API Server URL") },
|
||||
placeholder = { Text("http://your-server:8642") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = apiKeyInput,
|
||||
onValueChange = { apiKeyInput = it },
|
||||
label = { Text("API Key (optional)") },
|
||||
placeholder = {
|
||||
Text(
|
||||
if (apiKeyPresent) "•••• already set (leave blank to keep)"
|
||||
else "Leave empty if not configured",
|
||||
)
|
||||
},
|
||||
supportingText = {
|
||||
Text(
|
||||
if (apiKeyPresent && apiKeyInput.isBlank()) {
|
||||
"A key is already stored — leave blank to keep it, or type to replace"
|
||||
} else {
|
||||
"Only needed if Hermes is configured with API_SERVER_KEY"
|
||||
},
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
visualTransformation = if (apiKeyVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { apiKeyVisible = !apiKeyVisible }) {
|
||||
Icon(
|
||||
imageVector = if (apiKeyVisible) Icons.Filled.VisibilityOff
|
||||
else Icons.Filled.Visibility,
|
||||
contentDescription = if (apiKeyVisible) "Hide" else "Show",
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
isTestingApi = true
|
||||
apiVoiceSetupResult = null
|
||||
val relayOverride = if (relayOverrideVisible) relayUrlInput else null
|
||||
connectionViewModel.saveApiAndProbeVoice(
|
||||
apiUrl = apiUrlInput,
|
||||
apiKey = apiKeyInput,
|
||||
manualRelayUrlOverride = relayOverride,
|
||||
) { result ->
|
||||
isTestingApi = false
|
||||
apiVoiceSetupResult = result
|
||||
if (!result.voiceConfigReachable && result.relayAutoDerived) {
|
||||
relayOverrideVisible = true
|
||||
result.relayUrl?.let { relayUrlInput = it }
|
||||
}
|
||||
Toast.makeText(
|
||||
context,
|
||||
when {
|
||||
result.apiReachable && result.voiceConfigReachable ->
|
||||
"API and voice relay reachable"
|
||||
result.apiReachable ->
|
||||
"API reachable; relay URL needs review"
|
||||
else -> "Cannot reach API server"
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
enabled = apiUrlInput.isNotBlank() && !isTestingApi,
|
||||
) {
|
||||
Text("Save & Test")
|
||||
}
|
||||
if (isTestingApi) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (relayEnabled) {
|
||||
HorizontalDivider()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = "Relay URL",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = if (autoRelayUrl != null && !relayOverrideVisible) {
|
||||
"Auto: $autoRelayUrl"
|
||||
} else {
|
||||
"Manual override"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "Voice uses the relay's /voice routes. The app derives this from the API host unless a custom route is needed.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
relayOverrideVisible = !relayOverrideVisible
|
||||
if (!relayOverrideVisible) {
|
||||
relayUrlInput = autoRelayUrl.orEmpty()
|
||||
}
|
||||
connectionViewModel.clearRelayReachableResult()
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
) {
|
||||
Text(if (relayOverrideVisible) "Use auto relay URL" else "Use custom relay URL")
|
||||
}
|
||||
}
|
||||
|
||||
if (relayOverrideVisible) {
|
||||
OutlinedTextField(
|
||||
value = relayUrlInput,
|
||||
onValueChange = {
|
||||
relayUrlInput = it
|
||||
// Stale reachability results belong to the prior URL.
|
||||
connectionViewModel.clearRelayReachableResult()
|
||||
},
|
||||
label = { Text("Relay URL override") },
|
||||
placeholder = { Text("wss://your-server:8767") },
|
||||
singleLine = true,
|
||||
supportingText = {
|
||||
Text("Only needed when Auto cannot reach /voice/config")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
apiVoiceSetupResult?.let { result ->
|
||||
val color = if (result.voiceConfigReachable) {
|
||||
Color(0xFF4CAF50)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
}
|
||||
Text(
|
||||
text = if (result.voiceConfigReachable) {
|
||||
"Voice ready via ${result.relayUrl ?: "relay"}"
|
||||
} else {
|
||||
"Relay URL required: ${result.voiceConfigError ?: "voice config probe failed"}"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
|
||||
// Save & Test + Disconnect. Connect button intentionally absent
|
||||
// — it used to cause an unpaired-auth-then-rate-limited trap.
|
||||
// /health probe with no WSS handshake is the safe surface.
|
||||
val relayReachable by connectionViewModel.relayReachableResult.collectAsState()
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
connectionViewModel.testRelayReachable(
|
||||
if (relayOverrideVisible) relayUrlInput else autoRelayUrl.orEmpty(),
|
||||
)
|
||||
},
|
||||
enabled = (if (relayOverrideVisible) relayUrlInput else autoRelayUrl.orEmpty()).isNotBlank() &&
|
||||
relayReachable !is ConnectionViewModel.RelayReachable.Probing,
|
||||
) {
|
||||
Text("Test Relay")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { connectionViewModel.disconnectRelay() },
|
||||
enabled = relayConnectionState != ConnectionState.Disconnected,
|
||||
) {
|
||||
Text("Disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
when (val r = relayReachable) {
|
||||
is ConnectionViewModel.RelayReachable.Probing -> {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Text(
|
||||
text = "Probing /health…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
is ConnectionViewModel.RelayReachable.Ok -> {
|
||||
Text(
|
||||
text = "✓ Reachable — hermes-relay v${r.version} (${r.clients} client, ${r.sessions} session${if (r.sessions == 1) "" else "s"})",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFF4CAF50),
|
||||
)
|
||||
}
|
||||
is ConnectionViewModel.RelayReachable.Fail -> {
|
||||
val humanErr = classifyError(
|
||||
Exception(r.message),
|
||||
context = "save_and_test",
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = "✗ ${humanErr.title}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
text = humanErr.body,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
null -> { /* idle */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insecure-mode toggle subsection. First enable triggers the
|
||||
* [InsecureConnectionAckDialog] at screen scope via
|
||||
* [onInsecureAckRequested]; subsequent toggles fire through the VM
|
||||
* directly.
|
||||
*/
|
||||
@Composable
|
||||
private fun InsecureToggleSubsection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onInsecureAckRequested: () -> Unit,
|
||||
) {
|
||||
val insecureMode by connectionViewModel.insecureMode.collectAsState()
|
||||
val insecureAckSeen by connectionViewModel.insecureAckSeen.collectAsState()
|
||||
val isInsecureConnection by connectionViewModel.isInsecureConnection.collectAsState()
|
||||
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
|
||||
|
||||
if (isInsecureConnection && relayConnectionState == ConnectionState.Connected) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Plain connection — traffic is not encrypted",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Allow plain (unencrypted) connections",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Enable ws:// and http:// for local dev/testing only",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = insecureMode,
|
||||
onCheckedChange = { enabled ->
|
||||
if (enabled && !insecureAckSeen) {
|
||||
// First enable → open threat-model Ack dialog at
|
||||
// screen scope. VM is written only on confirm.
|
||||
onInsecureAckRequested()
|
||||
} else {
|
||||
connectionViewModel.setInsecureMode(enabled)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual pairing code subsection — the 3-step fallback flow for when
|
||||
* the QR scanner isn't usable (no camera, headless host, bad lighting).
|
||||
*
|
||||
* 1. Copy the phone-generated code (with Refresh to regenerate)
|
||||
* 2. Run `hermes-pair --register-code <code>` on the host
|
||||
* 3. Tap Connect — with a 15s auth watcher that surfaces success /
|
||||
* failure through the global snackbar host
|
||||
*
|
||||
* Mirrors the legacy `ConnectionSettingsScreen` Card 3 flow verbatim
|
||||
* since it's already battle-tested. The top-level "Connect" button on
|
||||
* this card requires a relay URL — if the user hasn't set one in the
|
||||
* Manual URL subsection above, the button is disabled and an inline
|
||||
* hint points them there.
|
||||
*/
|
||||
@Composable
|
||||
private fun ManualPairingCodeSubsection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
) {
|
||||
val pairingCode by connectionViewModel.pairingCode.collectAsState()
|
||||
val relayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHost = LocalSnackbarHost.current
|
||||
|
||||
// Connect state + auth-watcher attempt counter. Keyed counter so
|
||||
// retrying cancels any in-flight watcher and restarts with a fresh
|
||||
// 15s budget — matches the legacy Card 3 behavior byte-for-byte.
|
||||
var connectInProgress by remember { mutableStateOf(false) }
|
||||
var connectAttempt by remember { mutableStateOf(0) }
|
||||
var explainerExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(connectAttempt) {
|
||||
if (connectAttempt == 0) return@LaunchedEffect
|
||||
try {
|
||||
val terminal = kotlinx.coroutines.withTimeout(15_000) {
|
||||
connectionViewModel.authState
|
||||
.first { it is AuthState.Paired || it is AuthState.Failed }
|
||||
}
|
||||
connectInProgress = false
|
||||
when (terminal) {
|
||||
is AuthState.Paired -> snackbarHost.showSnackbar("Paired successfully")
|
||||
is AuthState.Failed -> {
|
||||
val human = classifyError(
|
||||
IllegalStateException(terminal.reason),
|
||||
context = "pair",
|
||||
)
|
||||
snackbarHost.showHumanError(human)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
connectInProgress = false
|
||||
val human = classifyError(
|
||||
java.io.IOException("No response from relay"),
|
||||
context = "pair",
|
||||
)
|
||||
snackbarHost.showHumanError(human)
|
||||
} catch (e: Exception) {
|
||||
connectInProgress = false
|
||||
snackbarHost.showHumanError(classifyError(e, context = "pair"))
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Manual pairing code (fallback)",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = "Use this when you can't scan the pairing QR. " +
|
||||
"Follow the three steps — they're meant to be done in order on " +
|
||||
"whatever machine you have shell access to.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// Step 1 — display + copy + regenerate
|
||||
ManualPairStep(number = 1, title = "Copy the code below") {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = pairingCode,
|
||||
style = MaterialTheme.typography.headlineMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
letterSpacing = MaterialTheme.typography.headlineMedium.fontSize * 0.15,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText("Pairing code", pairingCode)),
|
||||
)
|
||||
snackbarHost.showSnackbar("Pairing code copied")
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentCopy,
|
||||
contentDescription = "Copy pairing code",
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { connectionViewModel.regeneratePairingCode() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = "Generate new code",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2 — host command
|
||||
ManualPairStep(number = 2, title = "On the host running Hermes-Relay, run:") {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "hermes-pair --register-code $pairingCode",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
val cmd = "hermes-pair --register-code $pairingCode"
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText("hermes-pair command", cmd)),
|
||||
)
|
||||
snackbarHost.showSnackbar("Command copied")
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentCopy,
|
||||
contentDescription = "Copy hermes-pair command",
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 — Connect button + relay-URL prerequisite check
|
||||
ManualPairStep(number = 3, title = "Come back here and tap Connect") {
|
||||
val canConnect = !connectInProgress &&
|
||||
relayUrl.isNotBlank() &&
|
||||
pairingCode.isNotBlank()
|
||||
Button(
|
||||
onClick = {
|
||||
connectInProgress = true
|
||||
connectAttempt += 1
|
||||
// Atomic apply-code-and-reset avoids races between the
|
||||
// stale session's code-regeneration and this fresh
|
||||
// authenticate()'s mirror-write. Then kick a disconnect
|
||||
// + connect so the WSS handshake uses the new code.
|
||||
connectionViewModel.authManager
|
||||
.applyServerIssuedCodeAndReset(pairingCode)
|
||||
connectionViewModel.disconnectRelay()
|
||||
connectionViewModel.connectRelay(relayUrl)
|
||||
},
|
||||
enabled = canConnect,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (connectInProgress) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text("Connecting…")
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
if (relayUrl.isBlank()) {
|
||||
Text(
|
||||
text = "Relay URL not set — open the Manual URL section above to set it first.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
TextButton(
|
||||
onClick = { explainerExpanded = !explainerExpanded },
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
) {
|
||||
Text(
|
||||
text = if (explainerExpanded) "Hide explanation" else "How does this work?",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (explainerExpanded) {
|
||||
Text(
|
||||
text = "This is a fallback for when you can't scan the pairing QR " +
|
||||
"— for example, no camera, the host can't render a QR, or you " +
|
||||
"only have SSH access from a single device. The canonical flow " +
|
||||
"is the QR scan from `/hermes-relay-pair` or `hermes-pair`.\n\n" +
|
||||
"How it works: the phone generates a 6-character code locally. " +
|
||||
"You paste that code into the host's `hermes-pair --register-code` " +
|
||||
"command, which pre-registers it with the relay. When you tap " +
|
||||
"Connect here, the phone presents the same code to the relay " +
|
||||
"and gets a long-lived session token in return.\n\n" +
|
||||
"Bridge / device-control is gated by the master toggle on the " +
|
||||
"Bridge tab, NOT by this pairing code. Pairing only authorizes " +
|
||||
"the relay session.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Security posture strip — always visible on the active card, directly
|
||||
* below the Advanced expander. Renders, in order:
|
||||
* - Transport security badge (wss:// vs ws://)
|
||||
* - Tailscale detected chip (conditional)
|
||||
* - Hardware keystore badge (conditional)
|
||||
* - Relay sessions row (always — tap to navigate)
|
||||
*
|
||||
* These are the "what's my pairing posture?" facts. Short + info-dense,
|
||||
* so they don't live behind an expander.
|
||||
*/
|
||||
@Composable
|
||||
fun ActiveCardSecurityPosture(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onNavigateToPairedDevices: () -> Unit,
|
||||
) {
|
||||
val relayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
val insecureReason by connectionViewModel.insecureReason.collectAsState()
|
||||
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
|
||||
val currentPairedSession by connectionViewModel.currentPairedSession.collectAsState()
|
||||
val pairedDevices by connectionViewModel.pairedDevices.collectAsState()
|
||||
// ADR 24 — surface the live endpoint role so the insecure badge can
|
||||
// say "Plain (on LAN)" instead of "Insecure (network unknown)" when
|
||||
// the resolver already knows which candidate we're on.
|
||||
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
|
||||
|
||||
TransportSecurityBadge(
|
||||
isSecure = isUrlSecure(relayUrl),
|
||||
reason = insecureReason.ifBlank { null },
|
||||
size = TransportSecuritySize.Row,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
activeRole = activeEndpoint?.role,
|
||||
)
|
||||
|
||||
if (isTailscaleDetected) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Shield,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF2E7D32),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Tailscale detected",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color(0xFF2E7D32),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPairedSession?.hasHardwareStorage == true) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Shield,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Session token stored in hardware keystore",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onNavigateToPairedDevices() }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Relay sessions",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = if (pairedDevices.isNotEmpty()) {
|
||||
"${pairedDevices.size} active sessions on this server"
|
||||
} else {
|
||||
"Manage which phones can connect"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbered step row for the Manual pairing code fallback. Tightly
|
||||
* coupled to its Card 3 layout — step badge sizing + content shape —
|
||||
* so it stays private-ish here rather than promoted to a shared
|
||||
* component. Lift to `ui.components` if a second caller appears.
|
||||
*/
|
||||
@Composable
|
||||
private fun ManualPairStep(
|
||||
number: Int,
|
||||
title: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(percent = 50),
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Text(
|
||||
text = number.toString(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Compact top-bar chip showing the currently-active Hermes connection.
|
||||
* Tapping opens [ConnectionSwitcherSheet]. Kept deliberately minimal — a
|
||||
* single row of label + dropdown caret — so it fits into the Chat top bar
|
||||
* without hogging horizontal space.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionChip(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AssistChip(
|
||||
onClick = onClick,
|
||||
label = {
|
||||
Row {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDropDown,
|
||||
contentDescription = "Switch connection",
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionHandoffStatus
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionStatusSnapshot
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionStatusTone
|
||||
import com.hermesandroid.relay.viewmodel.asConnectionStatusSnapshot
|
||||
|
||||
@Composable
|
||||
fun ConnectionHandoffBanner(
|
||||
status: ConnectionHandoffStatus?,
|
||||
modifier: Modifier = Modifier,
|
||||
includeStatusBarPadding: Boolean = false,
|
||||
) {
|
||||
ConnectionStatusBanner(
|
||||
status = status?.asConnectionStatusSnapshot(),
|
||||
modifier = modifier,
|
||||
includeStatusBarPadding = includeStatusBarPadding,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectionStatusBanner(
|
||||
status: ConnectionStatusSnapshot?,
|
||||
modifier: Modifier = Modifier,
|
||||
includeStatusBarPadding: Boolean = false,
|
||||
) {
|
||||
val current = status ?: return
|
||||
val containerColor = when {
|
||||
current.tone == ConnectionStatusTone.Error -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.86f)
|
||||
current.tone == ConnectionStatusTone.Warning -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.62f)
|
||||
current.success -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.58f)
|
||||
current.active -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.74f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.86f)
|
||||
}
|
||||
val contentColor = when {
|
||||
current.tone == ConnectionStatusTone.Error ||
|
||||
current.tone == ConnectionStatusTone.Warning -> MaterialTheme.colorScheme.onErrorContainer
|
||||
current.success -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
current.active -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val insetModifier = if (includeStatusBarPadding) {
|
||||
Modifier.windowInsetsPadding(WindowInsets.statusBars)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.88f))
|
||||
.then(insetModifier)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Surface(
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize(animationSpec = tween(durationMillis = 180)),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 34.dp)
|
||||
.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when {
|
||||
current.active -> PulsingSyncIcon(contentColor)
|
||||
current.success -> Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
current.tone == ConnectionStatusTone.Warning ||
|
||||
current.tone == ConnectionStatusTone.Error -> Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
else -> Icon(
|
||||
imageVector = Icons.Filled.Sync,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = current.title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
current.route?.takeIf { it.isNotBlank() }?.let { route ->
|
||||
Text(
|
||||
text = route,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.76f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
val outputLines = current.entries
|
||||
.takeLast(2)
|
||||
.mapNotNull { entry ->
|
||||
val label = entry.label.trim().takeIf { it.isNotBlank() }
|
||||
val detail = entry.detail?.trim()?.takeIf { it.isNotBlank() }
|
||||
when {
|
||||
label != null && detail != null -> "$label: $detail"
|
||||
label != null -> label
|
||||
detail != null -> detail
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
outputLines.forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.72f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current.active) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 2.dp, max = 2.dp),
|
||||
color = contentColor.copy(alpha = 0.76f),
|
||||
trackColor = contentColor.copy(alpha = 0.16f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PulsingSyncIcon(color: androidx.compose.ui.graphics.Color) {
|
||||
val infinite = rememberInfiniteTransition(label = "connection-handoff-pulse")
|
||||
val alpha by infinite.animateFloat(
|
||||
initialValue = 0.45f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 900),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "connection-handoff-alpha",
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Sync,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.alpha(alpha),
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -238,17 +238,29 @@ fun ConnectionStatusRow(
|
||||
}
|
||||
Row(
|
||||
modifier = modifier.then(interactiveModifier),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ConnectionStatusBadge(state = state)
|
||||
// Small top padding on the badge so it sits visually centered with
|
||||
// a single-line label+status, but stays aligned to the top for
|
||||
// multi-line errors (where CenterVertically would drop it into the
|
||||
// middle of a three-line block).
|
||||
Box(modifier = Modifier.padding(top = 4.dp)) {
|
||||
ConnectionStatusBadge(state = state)
|
||||
}
|
||||
|
||||
// Label keeps its intrinsic width (no weight). Previously carried
|
||||
// `weight(1f, fill = false)`, which collapsed it to 1 char wide
|
||||
// when an unweighted statusText greedily claimed the whole row.
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
)
|
||||
|
||||
// Status text gets the weight so long errors wrap inside their own
|
||||
// allocation instead of squeezing the label. `fill = false` lets
|
||||
// short statuses (e.g. "Reachable") sit naturally without forcing
|
||||
// the row to span full width.
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -257,7 +269,8 @@ fun ConnectionStatusRow(
|
||||
BadgeState.Connecting -> Color(0xFFFFA726)
|
||||
BadgeState.Probing -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
BadgeState.Disconnected -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
|
||||
if (onTest != null) {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
|
||||
/**
|
||||
* Bottom sheet chooser for switching between Hermes connections. Driven by
|
||||
* the top-bar [ConnectionChip] tap and the Settings → Connections row.
|
||||
* Each row is a radio selection — tapping commits immediately and dismisses
|
||||
* the sheet so the swap kicks off before the user's finger is off the screen.
|
||||
*
|
||||
* The "Manage connections…" footer button navigates to
|
||||
* [ConnectionsSettingsScreen] for rename / re-pair / revoke / remove —
|
||||
* anything beyond plain switching.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConnectionSwitcherSheet(
|
||||
connections: List<Connection>,
|
||||
activeConnectionId: String?,
|
||||
onSelectConnection: (String) -> Unit,
|
||||
onManageConnections: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
.navigationBarsPadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Switch connection",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
if (connections.isEmpty()) {
|
||||
// Defensive: the legacy migration should always seed connection 0,
|
||||
// but fall back to a Manage-only state if the list is empty.
|
||||
Text(
|
||||
text = "No connections yet",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onManageConnections,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Manage connections…")
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
items(connections, key = { it.id }) { connection ->
|
||||
ConnectionRow(
|
||||
connection = connection,
|
||||
isActive = connection.id == activeConnectionId,
|
||||
onClick = {
|
||||
onSelectConnection(connection.id)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onManageConnections,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Manage connections…")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionRow(
|
||||
connection: Connection,
|
||||
isActive: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val statusLine = if (connection.pairedAt == null) {
|
||||
"$hostname • Not paired"
|
||||
} else {
|
||||
"$hostname • Paired"
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = isActive,
|
||||
onClick = onClick,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = connection.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = statusLine,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
@@ -36,10 +37,14 @@ import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.PhonelinkLock
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -61,6 +66,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
@@ -70,7 +76,10 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -127,6 +136,16 @@ fun ConnectionWizard(
|
||||
onCancel: () -> Unit,
|
||||
showSkip: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* Deep-link into a specific pair method on first composition.
|
||||
* Currently only `"scan"` is honored — jumps directly into camera-
|
||||
* permission-request → scanner, skipping the Method chooser. Null
|
||||
* keeps the default Method step so users can still pick Scan / Enter
|
||||
* code / Show code manually. The "Add connection" FAB sets this to
|
||||
* `"scan"` because the single-purpose entry deserves a single-purpose
|
||||
* flow; re-pair surfaces leave it null so the chooser stays available.
|
||||
*/
|
||||
autoStart: String? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -146,6 +165,24 @@ fun ConnectionWizard(
|
||||
var verifyError by remember { mutableStateOf<String?>(null) }
|
||||
var verifyAttempt by remember { mutableStateOf(0) }
|
||||
|
||||
// Pre-pair duplicate detection. When the user is about to pair to an
|
||||
// API URL that already has a connection in the store, we stop the
|
||||
// wizard at this prompt instead of silently creating a second entry
|
||||
// (which would just get merged away by the post-pair dedupe in
|
||||
// ConnectionViewModel — confusing UX, since the user's custom label
|
||||
// on the existing entry is what "wins"). The prompt lets them
|
||||
// explicitly opt into the re-pair, or cancel out.
|
||||
//
|
||||
// Held as a Connection to preserve label/id for the dialog copy;
|
||||
// null means "no prompt active, proceed normally".
|
||||
var duplicatePrompt by remember { mutableStateOf<Connection?>(null) }
|
||||
// When the manual flow triggers the duplicate prompt, remember the
|
||||
// code the user typed / was issued so the confirm branch can finish
|
||||
// the pair without the user re-entering anything. Null for scan
|
||||
// path (which uses [pendingPayload] instead).
|
||||
var pendingManualCode by remember { mutableStateOf<String?>(null) }
|
||||
val wizardScope = rememberCoroutineScope()
|
||||
|
||||
// Manual-path field state. Pre-fill from whatever the VM already knows
|
||||
// so re-pair from Settings keeps the previously-configured URLs.
|
||||
var manualApiUrl by remember(currentApiUrl) { mutableStateOf(currentApiUrl) }
|
||||
@@ -168,6 +205,24 @@ fun ConnectionWizard(
|
||||
}
|
||||
}
|
||||
|
||||
// Deep-link: when the caller passed autoStart="scan" (currently only the
|
||||
// "Add connection" FAB does), fire the permission launcher on first
|
||||
// composition. Equivalent to the user tapping the Scan tile in the
|
||||
// Method step — sets chosenMethod and bounces through the camera
|
||||
// permission gate into the scanner. Only runs once per wizard mount
|
||||
// (keyed on Unit); unrecognized autoStart values fall through silently
|
||||
// so a future build that adds more deep-link targets can ignore old
|
||||
// args without crashing.
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
when (autoStart) {
|
||||
"scan" -> {
|
||||
chosenMethod = PairMethod.Scan
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
// Watch the auth state once verify starts. Resolves Paired → onComplete,
|
||||
// Failed → surface error + let user retry, timeout → same. Cancelled
|
||||
// automatically when verifyAttempt changes (re-tries are a fresh attempt).
|
||||
@@ -230,18 +285,42 @@ fun ConnectionWizard(
|
||||
}
|
||||
}
|
||||
|
||||
// Look up an existing connection pointing at [serverUrl] — excluding
|
||||
// the active one, which during the Add-connection flow is the blank
|
||||
// placeholder we pre-created in [ConnectionViewModel.beginAddConnection].
|
||||
// Re-pair flows from Settings → Connections switch to the target BEFORE
|
||||
// entering the wizard, so during those the active id IS the target
|
||||
// and the filter correctly returns null (no pointless self-prompt).
|
||||
val findDuplicateFor: (String) -> Connection? = { serverUrl ->
|
||||
val activeId = connectionViewModel.activeConnectionId.value
|
||||
connectionViewModel.connectionStore.connections.value.firstOrNull { c ->
|
||||
c.id != activeId &&
|
||||
c.apiServerUrl.isNotBlank() &&
|
||||
c.apiServerUrl == serverUrl
|
||||
}
|
||||
}
|
||||
|
||||
// Shared launcher for the manual paths — persists URLs, applies the
|
||||
// server-issued code, drops any stale session, and reconnects. Used by
|
||||
// both ManualEntry (typed code) and ShowCode (phone-generated code).
|
||||
//
|
||||
// Runs the pre-pair duplicate check first: if another connection
|
||||
// already has this API URL, surface the prompt and stall the wizard
|
||||
// on Method/ManualEntry/ShowCode until the user confirms or cancels.
|
||||
// Dialog confirm re-invokes via [applyManualPair] (below) which
|
||||
// bypasses the check so we don't loop.
|
||||
val launchManualPair: (String) -> Unit = { code ->
|
||||
connectionViewModel.updateApiServerUrl(manualApiUrl.trim())
|
||||
connectionViewModel.updateRelayUrl(manualRelayUrl.trim())
|
||||
connectionViewModel.authManager
|
||||
.applyServerIssuedCodeAndReset(code.trim().uppercase())
|
||||
connectionViewModel.disconnectRelay()
|
||||
connectionViewModel.connectRelay(manualRelayUrl.trim())
|
||||
step = WizardStep.Verify
|
||||
verifyAttempt += 1
|
||||
val trimmedApi = manualApiUrl.trim()
|
||||
val existing = findDuplicateFor(trimmedApi)
|
||||
if (existing != null) {
|
||||
// Remember what to re-run when the user confirms.
|
||||
pendingManualCode = code
|
||||
duplicatePrompt = existing
|
||||
} else {
|
||||
applyManualPair(connectionViewModel, trimmedApi, manualRelayUrl.trim(), code)
|
||||
step = WizardStep.Verify
|
||||
verifyAttempt += 1
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -294,10 +373,33 @@ fun ConnectionWizard(
|
||||
pendingPayload = null
|
||||
step = WizardStep.Method
|
||||
},
|
||||
onConfirm = {
|
||||
connectionViewModel.applyPairingPayload(payload, ttlSeconds)
|
||||
step = WizardStep.Verify
|
||||
verifyAttempt += 1
|
||||
onConfirm = { reorderedPayload ->
|
||||
// Persist the reordered payload so a retry
|
||||
// from VerifyStep reuses the chosen preferred
|
||||
// role — otherwise Retry would drop the user's
|
||||
// "Prefer" choice on every failure.
|
||||
pendingPayload = reorderedPayload
|
||||
// Pre-pair duplicate check — stops the flow
|
||||
// on the Confirm screen if the scanned
|
||||
// serverUrl already matches an existing
|
||||
// connection. User can then re-pair that
|
||||
// existing entry in place (see the
|
||||
// DuplicateConnectionDialog at the bottom
|
||||
// of this composable) or cancel out. If no
|
||||
// duplicate, proceed to Verify as before.
|
||||
val existing = findDuplicateFor(
|
||||
reorderedPayload.serverUrl,
|
||||
)
|
||||
if (existing != null) {
|
||||
duplicatePrompt = existing
|
||||
} else {
|
||||
connectionViewModel.applyPairingPayload(
|
||||
reorderedPayload,
|
||||
ttlSeconds,
|
||||
)
|
||||
step = WizardStep.Verify
|
||||
verifyAttempt += 1
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -368,6 +470,167 @@ fun ConnectionWizard(
|
||||
onDismiss = { showQrScanner = false },
|
||||
)
|
||||
}
|
||||
|
||||
// Pre-pair duplicate prompt. Renders over whichever wizard step is
|
||||
// currently visible. Confirm = switch to the existing connection,
|
||||
// discard the placeholder [ConnectionViewModel.beginAddConnection]
|
||||
// pre-created for this flow (if any), then re-run the pair against
|
||||
// the existing connection so the new session replaces the old one.
|
||||
// Dismiss = clear the prompt and return the user to the step they
|
||||
// came from (Confirm for scan, ManualEntry/ShowCode for manual), so
|
||||
// they can re-read the URL they just entered or scan a different QR.
|
||||
duplicatePrompt?.let { existing ->
|
||||
DuplicateConnectionDialog(
|
||||
existing = existing,
|
||||
onUpdate = {
|
||||
val prompt = existing
|
||||
duplicatePrompt = null
|
||||
wizardScope.launch {
|
||||
// Snapshot the placeholder id before we switch away —
|
||||
// after switchConnection returns, activeConnectionId
|
||||
// points at the EXISTING connection and we'd lose the
|
||||
// reference to the blank placeholder we need to delete.
|
||||
val placeholderId = connectionViewModel.activeConnectionId.value
|
||||
?.takeIf { it != prompt.id }
|
||||
|
||||
// 1. Switch to the existing connection so all subsequent
|
||||
// applyPairingPayload / applyServerIssuedCodeAndReset
|
||||
// calls land in ITS auth store, not the placeholder's.
|
||||
// join() ensures the AuthManager swap has finished
|
||||
// before we apply the payload.
|
||||
connectionViewModel.switchConnection(prompt.id).join()
|
||||
|
||||
// 2. Remove the placeholder we pre-created. Safe no-op
|
||||
// if it was never a placeholder (pairedAt != null)
|
||||
// thanks to discardPlaceholderConnection's own
|
||||
// guard. Also safe if placeholderId is null (which
|
||||
// would mean the user entered the wizard from a
|
||||
// re-pair flow on the target itself — no cleanup
|
||||
// needed).
|
||||
if (placeholderId != null) {
|
||||
connectionViewModel.discardPlaceholderConnection(placeholderId)
|
||||
}
|
||||
|
||||
// 3. Apply the pair, now targeting the existing
|
||||
// connection's auth store. Scan path uses
|
||||
// pendingPayload; manual paths replay through the
|
||||
// existing `applyManualPair` sequence.
|
||||
when (chosenMethod) {
|
||||
PairMethod.Scan -> {
|
||||
val payload = pendingPayload
|
||||
if (payload != null) {
|
||||
connectionViewModel.applyPairingPayload(
|
||||
payload,
|
||||
ttlSeconds,
|
||||
)
|
||||
step = WizardStep.Verify
|
||||
verifyAttempt += 1
|
||||
}
|
||||
}
|
||||
PairMethod.EnterCode, PairMethod.ShowCode -> {
|
||||
val code = pendingManualCode
|
||||
if (code != null) {
|
||||
applyManualPair(
|
||||
connectionViewModel,
|
||||
manualApiUrl.trim(),
|
||||
manualRelayUrl.trim(),
|
||||
code,
|
||||
)
|
||||
pendingManualCode = null
|
||||
step = WizardStep.Verify
|
||||
verifyAttempt += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
duplicatePrompt = null
|
||||
pendingManualCode = null
|
||||
// Scan path: kick back to the Confirm step so the user
|
||||
// can either re-confirm (which will re-trigger the prompt)
|
||||
// or hit Back to scan a different QR. Manual paths: the
|
||||
// user is still on ManualEntry/ShowCode, the step hasn't
|
||||
// advanced, so no navigation change needed.
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bypass version of the manual pair launcher — does NOT run the duplicate
|
||||
* check. Called from two sites:
|
||||
* - [launchManualPair] inside [ConnectionWizard] when no duplicate is
|
||||
* found (the common happy path).
|
||||
* - The duplicate-prompt confirm handler, which has already switched to
|
||||
* the existing connection and explicitly wants to apply the pairing
|
||||
* there.
|
||||
*/
|
||||
private fun applyManualPair(
|
||||
vm: ConnectionViewModel,
|
||||
apiUrl: String,
|
||||
relayUrl: String,
|
||||
code: String,
|
||||
) {
|
||||
vm.updateApiServerUrl(apiUrl)
|
||||
vm.updateRelayUrl(relayUrl)
|
||||
vm.authManager.applyServerIssuedCodeAndReset(code.trim().uppercase())
|
||||
vm.disconnectRelay()
|
||||
vm.connectRelay(relayUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-button confirmation dialog shown by [ConnectionWizard] when the user
|
||||
* is about to pair against an API URL that already matches an existing
|
||||
* [Connection] in the store. Prevents the "two cards to the same server"
|
||||
* class of bug at the wizard layer — the post-pair dedupe in
|
||||
* [ConnectionViewModel] is still there as a safety net, but this prompt
|
||||
* lets the user understand what's about to happen and carry their custom
|
||||
* label forward without a silent merge.
|
||||
*/
|
||||
@Composable
|
||||
private fun DuplicateConnectionDialog(
|
||||
existing: Connection,
|
||||
onUpdate: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Update existing connection?") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "You already have a connection to this server:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = existing.label,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = existing.apiServerUrl,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Pair with it again to refresh the session. " +
|
||||
"Your existing label and any saved preferences " +
|
||||
"will be kept.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = onUpdate) { Text("Update") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private val PairingPreferencesDefault: Long =
|
||||
@@ -592,6 +855,35 @@ private fun MethodTile(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an error message when [url] looks like a relay URL (ws/wss) in an
|
||||
* API-server field, or null when the scheme is fine or the field is empty.
|
||||
* The API server is HTTP/SSE; the relay is WSS — mixing them silently used
|
||||
* to land in the Confirm preview as a mislabeled line.
|
||||
*/
|
||||
private fun apiUrlSchemeError(url: String): String? {
|
||||
val trimmed = url.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
return when {
|
||||
trimmed.startsWith("ws://", ignoreCase = true) ||
|
||||
trimmed.startsWith("wss://", ignoreCase = true) ->
|
||||
"Looks like a relay URL — API server expects http:// or https://"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirror of [apiUrlSchemeError] for the relay field. */
|
||||
private fun relayUrlSchemeError(url: String): String? {
|
||||
val trimmed = url.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
return when {
|
||||
trimmed.startsWith("http://", ignoreCase = true) ||
|
||||
trimmed.startsWith("https://", ignoreCase = true) ->
|
||||
"Looks like an API URL — relay expects wss:// (or ws:// for local)"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManualEntryStep(
|
||||
apiUrl: String,
|
||||
@@ -605,7 +897,11 @@ private fun ManualEntryStep(
|
||||
) {
|
||||
val trimmedCode = code.trim().uppercase()
|
||||
val codeValid = trimmedCode.length in 4..12 && trimmedCode.all { it.isLetterOrDigit() }
|
||||
val canSubmit = codeValid && relayUrl.isNotBlank() && apiUrl.isNotBlank()
|
||||
val apiError = apiUrlSchemeError(apiUrl)
|
||||
val relayError = relayUrlSchemeError(relayUrl)
|
||||
val canSubmit = codeValid &&
|
||||
relayUrl.isNotBlank() && apiUrl.isNotBlank() &&
|
||||
apiError == null && relayError == null
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
@@ -628,6 +924,10 @@ private fun ManualEntryStep(
|
||||
label = { Text("API server URL") },
|
||||
placeholder = { Text("http://your-server:8642") },
|
||||
singleLine = true,
|
||||
isError = apiError != null,
|
||||
supportingText = {
|
||||
Text(apiError ?: "Hermes API — chat and sessions (default port 8642)")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
@@ -637,6 +937,10 @@ private fun ManualEntryStep(
|
||||
label = { Text("Relay URL") },
|
||||
placeholder = { Text("wss://your-server:8767") },
|
||||
singleLine = true,
|
||||
isError = relayError != null,
|
||||
supportingText = {
|
||||
Text(relayError ?: "Hermes Relay — bridge, voice, terminal (default port 8767)")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
@@ -693,9 +997,11 @@ private fun ShowCodeStep(
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val apiError = apiUrlSchemeError(apiUrl)
|
||||
val relayError = relayUrlSchemeError(relayUrl)
|
||||
val canConnect = pairingCode.isNotBlank() &&
|
||||
relayUrl.isNotBlank() &&
|
||||
apiUrl.isNotBlank()
|
||||
relayUrl.isNotBlank() && apiUrl.isNotBlank() &&
|
||||
apiError == null && relayError == null
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
@@ -718,6 +1024,10 @@ private fun ShowCodeStep(
|
||||
label = { Text("API server URL") },
|
||||
placeholder = { Text("http://your-server:8642") },
|
||||
singleLine = true,
|
||||
isError = apiError != null,
|
||||
supportingText = {
|
||||
Text(apiError ?: "Hermes API — chat and sessions (default port 8642)")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
@@ -727,6 +1037,10 @@ private fun ShowCodeStep(
|
||||
label = { Text("Relay URL") },
|
||||
placeholder = { Text("wss://your-server:8767") },
|
||||
singleLine = true,
|
||||
isError = relayError != null,
|
||||
supportingText = {
|
||||
Text(relayError ?: "Hermes Relay — bridge, voice, terminal (default port 8767)")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
@@ -864,12 +1178,68 @@ private fun ConfirmStep(
|
||||
onTtlChange: (Long) -> Unit,
|
||||
isTailscaleDetected: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
onConfirm: (HermesPairingPayload) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val confirmScope = rememberCoroutineScope()
|
||||
// Per-install acknowledgment for the AllInsecure pair gate
|
||||
// ([PairingPreferences.allInsecurePairAckSeen]). Once true, the
|
||||
// checkbox below is not rendered at all on subsequent pairings —
|
||||
// the user has consented to plain-text pairs and shouldn't keep
|
||||
// seeing friction for an already-made choice.
|
||||
val allInsecureAckSeen by com.hermesandroid.relay.data.PairingPreferences
|
||||
.allInsecurePairAckSeen(context)
|
||||
.collectAsState(initial = false)
|
||||
// Transient, per-pair tick. Resets every time ConfirmStep is composed
|
||||
// for a fresh payload — we don't want a stale tick from a previous
|
||||
// scan to carry forward.
|
||||
var ackThisPair by remember(payload) { mutableStateOf(false) }
|
||||
|
||||
val transportHint = payload.relay?.transportHint
|
||||
val relayUrl = payload.relay?.url
|
||||
val isInsecureRelay = relayUrl?.startsWith("ws://") == true
|
||||
|
||||
// Endpoints preview + prefer-role control (ADR 24). `endpoints` is
|
||||
// always non-null + non-empty after parseHermesPairingQr, but we still
|
||||
// null-safe on the orEmpty() path for defense in depth.
|
||||
val endpoints = payload.endpoints.orEmpty()
|
||||
|
||||
// Tri-state security posture across the full endpoint set. A multi-
|
||||
// endpoint QR with LAN (ws://) + Tailscale (wss://) is *Mixed* — the
|
||||
// app auto-falls back to the secure one, so a blanket "Insecure (dev)"
|
||||
// badge from endpoint[0] alone would lie to the user.
|
||||
val anySecure = endpoints.any { c ->
|
||||
c.relay.url.startsWith("wss://") || c.api.tls ||
|
||||
c.relay.transportHint.equals("wss", ignoreCase = true)
|
||||
}
|
||||
val anyInsecure = endpoints.any { c ->
|
||||
c.relay.url.startsWith("ws://") || !c.api.tls ||
|
||||
c.relay.transportHint.equals("ws", ignoreCase = true)
|
||||
}
|
||||
val securityState = when {
|
||||
endpoints.isEmpty() ->
|
||||
if (isInsecureRelay) TransportSecurityState.AllInsecure
|
||||
else TransportSecurityState.AllSecure
|
||||
anySecure && anyInsecure -> TransportSecurityState.Mixed
|
||||
anySecure -> TransportSecurityState.AllSecure
|
||||
else -> TransportSecurityState.AllInsecure
|
||||
}
|
||||
// Pick the first secure endpoint's label for user-facing copy in the
|
||||
// Mixed case ("Tailscale is encrypted..." vs "Public is encrypted...").
|
||||
val firstSecureLabel = endpoints
|
||||
.firstOrNull { c ->
|
||||
c.relay.url.startsWith("wss://") || c.api.tls ||
|
||||
c.relay.transportHint.equals("wss", ignoreCase = true)
|
||||
}?.displayLabel()
|
||||
val firstInsecureLabel = endpoints
|
||||
.firstOrNull { c ->
|
||||
c.relay.url.startsWith("ws://") ||
|
||||
c.relay.transportHint.equals("ws", ignoreCase = true)
|
||||
}?.displayLabel()
|
||||
val distinctRoles = endpoints.map { it.role }.distinct()
|
||||
var preferRole by remember(payload) { mutableStateOf<String?>(null) }
|
||||
var preferMenuOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -895,9 +1265,17 @@ private fun ConfirmStep(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
LabeledLine("API server", payload.serverUrl)
|
||||
LabeledLine(
|
||||
label = "API server",
|
||||
value = payload.serverUrl,
|
||||
hint = "chat & sessions",
|
||||
)
|
||||
if (relayUrl != null) {
|
||||
LabeledLine("Relay", relayUrl)
|
||||
LabeledLine(
|
||||
label = "Relay",
|
||||
value = relayUrl,
|
||||
hint = "bridge, voice, terminal",
|
||||
)
|
||||
}
|
||||
if (payload.relay?.grants?.isNotEmpty() == true) {
|
||||
LabeledLine(
|
||||
@@ -907,14 +1285,103 @@ private fun ConfirmStep(
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
TransportSecurityBadge(
|
||||
isSecure = !isInsecureRelay,
|
||||
reason = if (isInsecureRelay) "local_dev" else null,
|
||||
state = securityState,
|
||||
size = TransportSecuritySize.Row,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Endpoints preview (ADR 24) — always shown so v1/v2 pairings that
|
||||
// synthesize a single candidate still get a "what will connect"
|
||||
// summary row. v3 QRs with multiple candidates expose the full list
|
||||
// + a Prefer dropdown that promotes the chosen role to priority 0.
|
||||
if (endpoints.isNotEmpty()) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Routes (${endpoints.size})",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = "Your phone tries these routes in order and uses " +
|
||||
"the first one it can reach. It switches automatically " +
|
||||
"as you change networks.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
endpoints.forEachIndexed { index, candidate ->
|
||||
if (index > 0) HorizontalDivider()
|
||||
EndpointPreviewRow(
|
||||
candidate = candidate,
|
||||
index = index,
|
||||
isPreferred = preferRole?.equals(
|
||||
candidate.role,
|
||||
ignoreCase = true,
|
||||
) == true,
|
||||
)
|
||||
}
|
||||
// Only expose the Prefer control when the QR carried
|
||||
// more than one distinct role — single-endpoint payloads
|
||||
// have nothing to reorder.
|
||||
if (distinctRoles.size > 1) {
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "Prefer:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
Box {
|
||||
TextButton(onClick = { preferMenuOpen = true }) {
|
||||
Text(
|
||||
text = preferRole?.let { roleLabel(it) }
|
||||
?: "Natural order",
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = preferMenuOpen,
|
||||
onDismissRequest = { preferMenuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Natural order") },
|
||||
onClick = {
|
||||
preferRole = null
|
||||
preferMenuOpen = false
|
||||
},
|
||||
)
|
||||
distinctRoles.forEach { role ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(roleLabel(role)) },
|
||||
onClick = {
|
||||
preferRole = role
|
||||
preferMenuOpen = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TTL picker — flat radio list, no nested dialog
|
||||
Text(
|
||||
text = "Keep this pairing for…",
|
||||
@@ -961,26 +1428,102 @@ private fun ConfirmStep(
|
||||
}
|
||||
}
|
||||
|
||||
if (isInsecureRelay) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.4f),
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = "This relay uses plain ws:// — traffic is not encrypted.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = "Only continue if you trust the network (LAN, Tailscale, VPN).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
when (securityState) {
|
||||
TransportSecurityState.AllInsecure -> {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
.copy(alpha = 0.4f),
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = "This relay uses plain ws:// \u2014 traffic is " +
|
||||
"not encrypted.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = "Only continue if you trust the network on any " +
|
||||
"connection (LAN, Tailscale, VPN).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Per-install Tier-1 gate: only render the checkbox when the
|
||||
// user has never acknowledged an AllInsecure pair on this
|
||||
// install. Once they have, we never show it again — the
|
||||
// warning card above stays, but the gate is lifted.
|
||||
if (!allInsecureAckSeen) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = ackThisPair,
|
||||
onCheckedChange = { ackThisPair = it },
|
||||
)
|
||||
Text(
|
||||
text = "I understand this pairing sends traffic in " +
|
||||
"plain text — visible to anyone on the network.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TransportSecurityState.Mixed -> {
|
||||
// Amber-tinted informational card. The secure route is the
|
||||
// safety net — spell that out explicitly so users stop
|
||||
// reading "some plain" as "all plain".
|
||||
val plainLabel = firstInsecureLabel ?: "LAN"
|
||||
val secureLabel = firstSecureLabel ?: "Tailscale"
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color(0xFFF9A825).copy(alpha = 0.12f),
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "$plainLabel is plain ws:// \u2014 fine at home or " +
|
||||
"the office, not on public Wi-Fi.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "$secureLabel is encrypted (wss://) and the app " +
|
||||
"uses it automatically when $plainLabel is unreachable.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "You're safe on any network.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TransportSecurityState.AllSecure -> {
|
||||
// No warning block — every route is TLS.
|
||||
}
|
||||
}
|
||||
|
||||
// Gate for the absolute-boundary AllInsecure case only. Mixed and
|
||||
// AllSecure stay one-tap. Satisfied when either (a) the user has
|
||||
// previously ack'd an AllInsecure pair on this install (per-install,
|
||||
// never expires), or (b) they've ticked the checkbox for this pair.
|
||||
val gateIsSatisfied = when (securityState) {
|
||||
TransportSecurityState.AllInsecure -> allInsecureAckSeen || ackThisPair
|
||||
else -> true
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -994,7 +1537,25 @@ private fun ConfirmStep(
|
||||
Text("Back")
|
||||
}
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
onClick = {
|
||||
// Persist the per-install ack the first time an
|
||||
// AllInsecure pair goes through via the checkbox path.
|
||||
// Future AllInsecure pairs skip the checkbox entirely.
|
||||
if (securityState == TransportSecurityState.AllInsecure &&
|
||||
ackThisPair &&
|
||||
!allInsecureAckSeen
|
||||
) {
|
||||
confirmScope.launch {
|
||||
com.hermesandroid.relay.data.PairingPreferences
|
||||
.setAllInsecurePairAckSeen(context, true)
|
||||
}
|
||||
}
|
||||
val effective = preferRole
|
||||
?.let { reorderByPreferredRole(payload, it) }
|
||||
?: payload
|
||||
onConfirm(effective)
|
||||
},
|
||||
enabled = gateIsSatisfied,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Pair")
|
||||
@@ -1072,13 +1633,22 @@ private fun VerifyStep(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabeledLine(label: String, value: String) {
|
||||
private fun LabeledLine(label: String, value: String, hint: String? = null) {
|
||||
Column {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (hint != null) {
|
||||
Text(
|
||||
text = " · $hint",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -1087,3 +1657,125 @@ private fun LabeledLine(label: String, value: String) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the ConfirmStep's Endpoints preview — role label, host:port,
|
||||
* priority, optional "Preferred" chip when the user promoted it.
|
||||
*
|
||||
* Intentionally NOT the shared `EndpointsCard` component: that one carries
|
||||
* probe status, 3-dot actions, and TOFU pin viewer — none of which apply
|
||||
* pre-pair. Here we only need a lightweight visual summary.
|
||||
*/
|
||||
@Composable
|
||||
private fun EndpointPreviewRow(
|
||||
candidate: EndpointCandidate,
|
||||
index: Int,
|
||||
isPreferred: Boolean,
|
||||
) {
|
||||
// Per-row security derived from the same three signals as the overall
|
||||
// securityState computation — scheme, tls flag, transportHint.
|
||||
val isSecure = candidate.relay.url.startsWith("wss://") ||
|
||||
candidate.api.tls ||
|
||||
candidate.relay.transportHint.equals("wss", ignoreCase = true)
|
||||
val ordinalLabel = when (index) {
|
||||
0 -> "1st choice"
|
||||
1 -> "Fallback"
|
||||
else -> "Fallback $index"
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = candidate.displayLabel(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
SoftPill(
|
||||
text = if (isSecure) "Secure" else "Plain",
|
||||
fg = if (isSecure) Color(0xFF2E7D32) else Color(0xFFF9A825),
|
||||
)
|
||||
if (isPreferred) {
|
||||
SoftPill(
|
||||
text = "Preferred",
|
||||
fg = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "${candidate.api.host}:${candidate.api.port}" +
|
||||
(candidate.relay.transportHint?.let { " \u00b7 $it" } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
// Ordinal chip pinned to the right — "1st choice" / "Fallback" /
|
||||
// "Fallback N" replaces the raw `p0/p1/p2` from the old UI.
|
||||
SoftPill(
|
||||
text = ordinalLabel,
|
||||
fg = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact pill used by [EndpointPreviewRow] — matches the "Preferred" soft
|
||||
* chip style so the row reads as a row of related chips rather than a mix
|
||||
* of primary + secondary visual weights.
|
||||
*/
|
||||
@Composable
|
||||
private fun SoftPill(
|
||||
text: String,
|
||||
fg: Color,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = fg.copy(alpha = 0.14f),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = fg,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder the endpoints array so the chosen role lands at priority 0.
|
||||
* Priority values are renumbered to match the new order — this matters at
|
||||
* persist time because `setDeviceEndpoints` stores the list verbatim and
|
||||
* downstream [com.hermesandroid.relay.network.EndpointResolver] trusts the
|
||||
* `priority` field (see ADR 24 "strict priority").
|
||||
*
|
||||
* No-op when the preferred role is already at index 0, or when the role
|
||||
* isn't present in the candidates list.
|
||||
*/
|
||||
private fun reorderByPreferredRole(
|
||||
payload: HermesPairingPayload,
|
||||
preferRole: String,
|
||||
): HermesPairingPayload {
|
||||
val list = payload.endpoints.orEmpty().toMutableList()
|
||||
val idx = list.indexOfFirst { it.role.equals(preferRole, ignoreCase = true) }
|
||||
if (idx <= 0) return payload
|
||||
val promoted = list.removeAt(idx)
|
||||
list.add(0, promoted)
|
||||
val renumbered = list.mapIndexed { i, c -> c.copy(priority = i) }
|
||||
return payload.copy(endpoints = renumbered)
|
||||
}
|
||||
|
||||
/** Role → user-facing label used inside the Prefer dropdown menu. */
|
||||
private fun roleLabel(role: String): String = when (role.lowercase()) {
|
||||
"lan" -> "LAN"
|
||||
"tailscale" -> "Tailscale"
|
||||
"public" -> "Public"
|
||||
else -> role
|
||||
}
|
||||
|
||||
+48
-4
@@ -12,17 +12,23 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -53,9 +59,20 @@ fun DestructiveVerbConfirmDialog(
|
||||
method: String,
|
||||
verb: String,
|
||||
fullText: String,
|
||||
onAllow: () -> Unit,
|
||||
onAllow: (trustVerb: Boolean) -> Unit,
|
||||
onDeny: () -> Unit,
|
||||
) {
|
||||
// Checkbox is always OFF when the dialog opens — the user must
|
||||
// actively opt in to bypass future prompts. Kept local-only: we only
|
||||
// persist the verb to the trusted set when the user then taps Allow.
|
||||
// Tapping Deny with the box checked does NOT add the verb (denying is
|
||||
// not consent to anything).
|
||||
var trustVerb by remember { mutableStateOf(false) }
|
||||
// Only offer the "Don't ask again" escape hatch when we actually have
|
||||
// a specific verb to key the trust on. Routes like /call and
|
||||
// /send_sms come through with verb="" and must always prompt — there's
|
||||
// nothing to trust.
|
||||
val canTrust = verb.isNotBlank()
|
||||
// Root-fills-overlay-with-center-alignment. The overlay already applies
|
||||
// FLAG_DIM_BEHIND so we only need to draw the card itself.
|
||||
Box(
|
||||
@@ -131,6 +148,30 @@ fun DestructiveVerbConfirmDialog(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (canTrust) {
|
||||
// "Don't ask again" row — whole row is clickable so the
|
||||
// label is a tappable target (accessibility + fat-fingers).
|
||||
// Off by default on every open; see the @Composable KDoc.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { trustVerb = !trustVerb }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = trustVerb,
|
||||
onCheckedChange = { trustVerb = it },
|
||||
)
|
||||
Text(
|
||||
text = "Don't ask again for \"$verb\"",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
@@ -139,13 +180,16 @@ fun DestructiveVerbConfirmDialog(
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
// Deny never writes trust — denying a command isn't
|
||||
// consent to anything, even if the user happened to
|
||||
// tick the checkbox before changing their mind.
|
||||
onClick = onDeny,
|
||||
) {
|
||||
Text("Deny")
|
||||
}
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onAllow,
|
||||
onClick = { onAllow(trustVerb && canTrust) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFE53935),
|
||||
contentColor = Color.White,
|
||||
@@ -238,7 +282,7 @@ private fun DestructiveVerbConfirmDialogPreview_TapText() {
|
||||
method = "/tap_text",
|
||||
verb = "Send",
|
||||
fullText = "Send $500 to Alice",
|
||||
onAllow = {},
|
||||
onAllow = { _ -> },
|
||||
onDeny = {},
|
||||
)
|
||||
}
|
||||
@@ -252,7 +296,7 @@ private fun DestructiveVerbConfirmDialogPreview_Type() {
|
||||
method = "/type",
|
||||
verb = "delete",
|
||||
fullText = "delete all messages in #general",
|
||||
onAllow = {},
|
||||
onAllow = { _ -> },
|
||||
onDeny = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
|
||||
@Composable
|
||||
fun DiagnosticsLogPanel(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Recent activity",
|
||||
categories: Set<DiagnosticCategory>? = null,
|
||||
limit: Int = 8,
|
||||
showCategory: Boolean = false,
|
||||
showClear: Boolean = false,
|
||||
) {
|
||||
val entries by DiagnosticsLog.entries.collectAsState()
|
||||
val visible = entries
|
||||
.asReversed()
|
||||
.filter { categories == null || it.category in categories }
|
||||
.take(limit.coerceAtLeast(0))
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (showClear && entries.isNotEmpty()) {
|
||||
TextButton(onClick = { DiagnosticsLog.clear() }) {
|
||||
Text("Clear")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visible.isEmpty()) {
|
||||
Text(
|
||||
text = "No recent activity",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
visible.forEachIndexed { index, entry ->
|
||||
DiagnosticLogRow(
|
||||
entry = entry,
|
||||
showCategory = showCategory,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
)
|
||||
if (index != visible.lastIndex) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiagnosticLogRow(
|
||||
entry: DiagnosticLogEntry,
|
||||
showCategory: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(severityColor(entry.severity)),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = if (showCategory) {
|
||||
"${entry.category.label} - ${entry.title}"
|
||||
} else {
|
||||
entry.title
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = DateFormat.format("HH:mm:ss", entry.timestampMs).toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
|
||||
val detail = entry.detailLine()
|
||||
if (detail != null) {
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(1.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun severityColor(severity: DiagnosticSeverity): Color = when (severity) {
|
||||
DiagnosticSeverity.Info -> MaterialTheme.colorScheme.primary
|
||||
DiagnosticSeverity.Warning -> MaterialTheme.colorScheme.tertiary
|
||||
DiagnosticSeverity.Error -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
private fun DiagnosticLogEntry.detailLine(): String? {
|
||||
val pieces = listOfNotNull(
|
||||
detail,
|
||||
endpointRole?.let { "route=$it" },
|
||||
url,
|
||||
elapsedMs?.let { "${it}ms" },
|
||||
)
|
||||
return pieces.joinToString(" - ").takeIf { it.isNotBlank() }
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lan
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.filled.VpnKey
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.isKnownRole
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* ADR 24 — per-endpoint visibility + override card for the Connection
|
||||
* settings screen. Renders one row per [EndpointCandidate] stored for the
|
||||
* active device, with a health chip, a 3-dot menu (Prefer / Probe now /
|
||||
* View pin), and a bottom "Clear manual override" action when a preferred
|
||||
* role is set.
|
||||
*
|
||||
* Verbose by design — Bailey explicitly asked for per-row visibility, so we
|
||||
* do NOT collapse into a master row. The card itself is wrapped in a
|
||||
* [SettingsExpandableCard] by the caller for page layout hygiene.
|
||||
*
|
||||
* Not visible on legacy installs: when [endpoints] is empty we render a
|
||||
* helpful one-liner instead of an empty card, so freshly-upgraded users
|
||||
* who haven't re-paired yet understand why they can't see anything.
|
||||
*/
|
||||
@Composable
|
||||
fun EndpointsCard(
|
||||
endpoints: List<EndpointCandidate>,
|
||||
activeEndpoint: EndpointCandidate?,
|
||||
preferredRole: String?,
|
||||
onPreferEndpoint: (EndpointCandidate) -> Unit,
|
||||
onClearOverride: () -> Unit,
|
||||
onProbeNow: () -> Unit,
|
||||
onViewPin: suspend (EndpointCandidate) -> String?,
|
||||
) {
|
||||
if (endpoints.isEmpty()) {
|
||||
Text(
|
||||
text = "No route candidates stored for this device yet. " +
|
||||
"Scan a v3 pairing QR (Hermes 0.4.2+) to enable multi-route " +
|
||||
"switching — LAN + Tailscale + public URLs.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Current: ${activeEndpoint?.displayLabel() ?: "Resolving"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
endpoints.forEachIndexed { index, candidate ->
|
||||
if (index > 0) HorizontalDivider()
|
||||
EndpointRow(
|
||||
candidate = candidate,
|
||||
isActive = activeEndpoint != null &&
|
||||
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
|
||||
activeEndpoint.api.host.equals(candidate.api.host, ignoreCase = true) &&
|
||||
activeEndpoint.api.port == candidate.api.port,
|
||||
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
|
||||
onPrefer = { onPreferEndpoint(candidate) },
|
||||
onProbeNow = onProbeNow,
|
||||
onViewPin = onViewPin,
|
||||
)
|
||||
}
|
||||
|
||||
if (preferredRole != null) {
|
||||
HorizontalDivider()
|
||||
TextButton(onClick = onClearOverride, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Clear manual override (preferring $preferredRole)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row: role chip + host:port + transport hint + health chip + 3-dot menu.
|
||||
*/
|
||||
@Composable
|
||||
private fun EndpointRow(
|
||||
candidate: EndpointCandidate,
|
||||
isActive: Boolean,
|
||||
isPreferred: Boolean,
|
||||
onPrefer: () -> Unit,
|
||||
onProbeNow: () -> Unit,
|
||||
onViewPin: suspend (EndpointCandidate) -> String?,
|
||||
) {
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
var pinDialogText by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = roleIcon(candidate.role),
|
||||
contentDescription = null,
|
||||
tint = if (isActive) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = candidate.displayLabel(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
if (isActive) {
|
||||
ActiveChip()
|
||||
} else if (isPreferred) {
|
||||
PreferredChip()
|
||||
} else {
|
||||
FallbackChip()
|
||||
}
|
||||
if (!candidate.isKnownRole()) {
|
||||
// Show the raw role for custom-VPN entries so users
|
||||
// can tell "netbird-eu" from "wireguard-home" at a
|
||||
// glance without poking into the menu.
|
||||
Text(
|
||||
text = "(${candidate.role})",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "${candidate.api.host}:${candidate.api.port}" +
|
||||
(candidate.relay.transportHint?.let { " · $it" } ?: ""),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
|
||||
// 3-dot overflow menu — actions per-row so the card stays flat
|
||||
// without needing to expand into a detail sheet. "View pin"
|
||||
// suspends to read CertPinStore, so we resolve it into a dialog
|
||||
// when the user taps it.
|
||||
if (!isActive) {
|
||||
TextButton(onClick = onPrefer) {
|
||||
Text("Use now")
|
||||
}
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { menuOpen = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = "Endpoint actions",
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuOpen,
|
||||
onDismissRequest = { menuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Prefer this route") },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onPrefer()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Probe now") },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onProbeNow()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("View pin") },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
scope.launch {
|
||||
pinDialogText = onViewPin(candidate)
|
||||
?: "No pin recorded yet — the phone will " +
|
||||
"record one on first TOFU connect."
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pinDialogText?.let { body ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pinDialogText = null },
|
||||
title = { Text("TOFU pin · ${candidate.api.host}") },
|
||||
text = {
|
||||
Text(
|
||||
text = body,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { pinDialogText = null }) { Text("Close") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveChip() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.14f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Shield,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.height(10.dp),
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text(
|
||||
text = "Active",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreferredChip() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFFFFA726).copy(alpha = 0.18f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Preferred",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color(0xFFB26A00),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Outlined neutral chip rendered on non-active, non-preferred routes so
|
||||
* every row states its standing explicitly (mirror of [ActiveChip] /
|
||||
* [PreferredChip]). No background fill — just a 1dp border so it reads
|
||||
* as "available fallback" not "something is happening here".
|
||||
*/
|
||||
@Composable
|
||||
private fun FallbackChip() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Fallback",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Role → Material icon. Known roles get their canonical glyph; anything
|
||||
* else falls through to [Icons.Filled.Shield] (generic "Custom VPN").
|
||||
*/
|
||||
private fun roleIcon(role: String): ImageVector = when (role.lowercase()) {
|
||||
"lan" -> Icons.Filled.Lan
|
||||
"tailscale" -> Icons.Filled.VpnKey
|
||||
"public" -> Icons.Filled.Public
|
||||
else -> Icons.Filled.Shield
|
||||
}
|
||||
@@ -47,7 +47,10 @@ fun ExtraKeysToolbar(
|
||||
onCtrlToggle: () -> Unit,
|
||||
onAltToggle: () -> Unit,
|
||||
onArrow: (SpecialKey) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
onScrollUp: (() -> Unit)? = null,
|
||||
onScrollDown: (() -> Unit)? = null,
|
||||
onScrollToBottom: (() -> Unit)? = null,
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
@@ -135,6 +138,51 @@ fun ExtraKeysToolbar(
|
||||
onArrow(SpecialKey.ARROW_RIGHT)
|
||||
}
|
||||
)
|
||||
|
||||
// Scrollback controls — target xterm.js's viewport, NOT the remote
|
||||
// PTY. Unlike the arrow keys above (which send ANSI escapes into the
|
||||
// running shell), these just move the local scrollback window, so
|
||||
// the user can look at older output without disturbing whatever the
|
||||
// shell thinks the cursor position is.
|
||||
if (onScrollUp != null || onScrollDown != null) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
onScrollUp?.let { scrollUp ->
|
||||
ToolbarKey(
|
||||
label = "\u21D1", // upwards double arrow — distinct from ARROW_UP
|
||||
active = false,
|
||||
weight = 1f,
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
scrollUp()
|
||||
}
|
||||
)
|
||||
}
|
||||
onScrollDown?.let { scrollDown ->
|
||||
ToolbarKey(
|
||||
label = "\u21D3", // downwards double arrow
|
||||
active = false,
|
||||
weight = 1f,
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
scrollDown()
|
||||
}
|
||||
)
|
||||
}
|
||||
// "Jump to bottom" — small and always present when any scroll
|
||||
// callback is. Covers the case where the user has scrolled way up
|
||||
// and wants to snap back without swiping endlessly.
|
||||
onScrollToBottom?.let { scrollToBottom ->
|
||||
ToolbarKey(
|
||||
label = "\u21F2", // south-east double arrow; reads as "end"
|
||||
active = false,
|
||||
weight = 1f,
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
scrollToBottom()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.CalendarToday
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.filled.WbSunny
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.HermesCardDispatch
|
||||
import com.hermesandroid.relay.data.HermesCardField
|
||||
|
||||
/**
|
||||
* Inline rich-card render for a [HermesCard] extracted from an assistant
|
||||
* message via the `CARD:{json}` marker pipeline in
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler].
|
||||
*
|
||||
* Layout (top → bottom):
|
||||
* - Accent stripe (leading 3dp bar, colored by [HermesCard.accent])
|
||||
* - Header row: type icon + title + optional subtitle
|
||||
* - Body (markdown) if present
|
||||
* - Fields table (label : value rows)
|
||||
* - Actions row (AssistChip / Button per [HermesCardAction])
|
||||
* - Footer (muted labelSmall) if present
|
||||
*
|
||||
* Unknown [HermesCard.type] renders via the generic path — title + body +
|
||||
* fields + actions — so a newer agent emitting a type the phone build
|
||||
* doesn't recognize still gets a coherent card, not an empty bubble.
|
||||
*
|
||||
* Action dispatch is fully delegated to [onActionTap]. The bubble is
|
||||
* stateless w.r.t. dispatch tracking — it reads [dispatches] (from the
|
||||
* owning [com.hermesandroid.relay.data.ChatMessage.cardDispatches]) and
|
||||
* renders a confirmation row instead of the action buttons once the user
|
||||
* has chosen.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun HermesCardBubble(
|
||||
card: HermesCard,
|
||||
cardKey: String,
|
||||
dispatches: List<HermesCardDispatch>,
|
||||
onActionTap: (cardKey: String, action: HermesCardAction) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
maxWidth: Dp = 280.dp,
|
||||
) {
|
||||
val accentColor = accentToColor(card.accent)
|
||||
val typeIcon = iconForType(card.type)
|
||||
val alreadyChosen = dispatches.firstOrNull { it.cardKey == cardKey }
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.fillMaxWidth()
|
||||
.semantics {
|
||||
contentDescription = "Card: ${card.title ?: card.type}"
|
||||
},
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
border = BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min),
|
||||
) {
|
||||
// Accent stripe — runs full card height so tall cards keep the
|
||||
// color tie. Using the SAME tertiary accent strategy as the
|
||||
// voice/phone-action bubble marker in MessageBubble.kt so the
|
||||
// visual language stays consistent.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(3.dp)
|
||||
.fillMaxHeight()
|
||||
.background(accentColor),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (typeIcon != null) {
|
||||
Icon(
|
||||
imageVector = typeIcon,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (!card.title.isNullOrBlank()) {
|
||||
Text(
|
||||
text = card.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
if (!card.subtitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = card.subtitle,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Body — markdown, so the agent can embed inline code /
|
||||
// emphasis / links. Uses the existing MarkdownContent
|
||||
// renderer from MessageBubble's stack.
|
||||
if (!card.body.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
MarkdownContent(
|
||||
content = card.body,
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
// Fields table
|
||||
if (card.fields.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
card.fields.forEach { field ->
|
||||
FieldRow(field)
|
||||
}
|
||||
}
|
||||
|
||||
// Actions OR dispatch confirmation
|
||||
if (card.actions.isNotEmpty()) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
if (alreadyChosen != null) {
|
||||
val chosen = card.actions.firstOrNull {
|
||||
it.value == alreadyChosen.actionValue
|
||||
}
|
||||
ChoseRow(chosen?.label ?: alreadyChosen.actionValue)
|
||||
} else {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
card.actions.forEach { action ->
|
||||
ActionButton(
|
||||
action = action,
|
||||
onClick = { onActionTap(cardKey, action) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
if (!card.footer.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = card.footer,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FieldRow(field: HermesCardField) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
text = field.label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.width(88.dp),
|
||||
)
|
||||
Text(
|
||||
text = field.value,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontFamily = if (looksMonospaceWorthy(field.value)) FontFamily.Monospace else null,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Heuristic — values that look like paths, commands, or code get a mono font. */
|
||||
private fun looksMonospaceWorthy(value: String): Boolean {
|
||||
val trimmed = value.trim()
|
||||
return trimmed.startsWith("/") ||
|
||||
trimmed.startsWith("$") ||
|
||||
trimmed.startsWith("`") ||
|
||||
trimmed.contains("://") ||
|
||||
trimmed.matches(Regex("""^\S+\s+--\S.*$""")) // flags-style
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoseRow(label: String) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.4f))
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Chose: $label",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionButton(
|
||||
action: HermesCardAction,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
when (action.style) {
|
||||
HermesCardAction.Styles.PRIMARY -> Button(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) { Text(action.label, style = MaterialTheme.typography.labelMedium) }
|
||||
HermesCardAction.Styles.DANGER -> OutlinedButton(
|
||||
onClick = onClick,
|
||||
border = BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) { Text(action.label, style = MaterialTheme.typography.labelMedium) }
|
||||
else -> OutlinedButton(onClick = onClick) {
|
||||
Text(action.label, style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic accent → ColorScheme token. Unknown values fall back to the
|
||||
* neutral `info` tint (primary) so the stripe still shows up.
|
||||
*/
|
||||
@Composable
|
||||
private fun accentToColor(accent: String?): Color = when (accent) {
|
||||
HermesCard.Accents.SUCCESS -> MaterialTheme.colorScheme.primary
|
||||
HermesCard.Accents.WARNING -> MaterialTheme.colorScheme.tertiary
|
||||
HermesCard.Accents.DANGER -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.primary // info + unknown
|
||||
}
|
||||
|
||||
/** Built-in type → header icon. Unknown types show a neutral "auto" spark. */
|
||||
private fun iconForType(type: String): ImageVector? = when (type) {
|
||||
HermesCard.BuiltInTypes.APPROVAL_REQUEST -> Icons.Filled.Shield
|
||||
HermesCard.BuiltInTypes.LINK_PREVIEW -> Icons.Filled.Language
|
||||
HermesCard.BuiltInTypes.CALENDAR_EVENT -> Icons.Filled.CalendarToday
|
||||
HermesCard.BuiltInTypes.WEATHER -> Icons.Filled.WbSunny
|
||||
HermesCard.BuiltInTypes.SKILL_RESULT -> Icons.Filled.AutoAwesome
|
||||
else -> Icons.Filled.AutoAwesome
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a [HermesCardAction] tap to a side-effecting action on the
|
||||
* current Android context. Kept as a plain top-level helper so both the
|
||||
* ChatViewModel and any future preview harness can reuse it without
|
||||
* dragging in ViewModel dependencies.
|
||||
*
|
||||
* - [HermesCardAction.Modes.OPEN_URL] → `ACTION_VIEW` intent.
|
||||
* - Other modes return false — the caller is expected to route them
|
||||
* (send as a new user message, or interpret as a slash command in
|
||||
* [com.hermesandroid.relay.viewmodel.ChatViewModel]).
|
||||
*/
|
||||
fun handleCardActionExternally(
|
||||
context: android.content.Context,
|
||||
action: HermesCardAction,
|
||||
): Boolean {
|
||||
if (action.mode != HermesCardAction.Modes.OPEN_URL) return false
|
||||
return runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(action.value))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
@@ -34,10 +34,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.ui.theme.leftEdgeGlow
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -64,7 +66,16 @@ fun MessageBubble(
|
||||
* Invoked when the user taps a LOADING+"Tap to download" placeholder
|
||||
* (the cellular deferral case).
|
||||
*/
|
||||
onAttachmentManualFetch: (messageId: String, attachmentIndex: Int) -> Unit = { _, _ -> }
|
||||
onAttachmentManualFetch: (messageId: String, attachmentIndex: Int) -> Unit = { _, _ -> },
|
||||
/**
|
||||
* Invoked when the user taps an action button on an inline
|
||||
* [com.hermesandroid.relay.data.HermesCard]. Routed through
|
||||
* [com.hermesandroid.relay.viewmodel.ChatViewModel.dispatchCardAction]
|
||||
* by the owning screen — that path records the dispatch stamp (so the
|
||||
* card collapses) and forwards the action value per its mode.
|
||||
* Defaults to no-op so legacy callers / tests don't have to wire it.
|
||||
*/
|
||||
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit = { _, _, _ -> }
|
||||
) {
|
||||
val isUser = message.role == MessageRole.USER
|
||||
val isSystem = message.role == MessageRole.SYSTEM
|
||||
@@ -134,6 +145,20 @@ fun MessageBubble(
|
||||
)
|
||||
}
|
||||
|
||||
if (!isUser && !isSystem && message.badges.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.padding(bottom = 4.dp, start = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
message.badges.take(4).forEach { badge ->
|
||||
MessagePathBadge(text = badge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thinking block (above the bubble, only for assistant messages)
|
||||
if (!isUser && showThinking && message.thinkingContent.isNotEmpty()) {
|
||||
ThinkingBlock(
|
||||
@@ -205,6 +230,29 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
// Rich cards — rendered between the markdown body and
|
||||
// attachments so the reading order stays: narration → card
|
||||
// → attached file. Each card gets a stable key built from
|
||||
// its optional id or falling back to its positional index,
|
||||
// so a reload-from-history doesn't lose "I already chose X"
|
||||
// state tracked in [ChatMessage.cardDispatches].
|
||||
if (!isUser && !isSystem && message.cards.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
message.cards.forEachIndexed { index, card ->
|
||||
val cardKey = card.id ?: "idx:$index"
|
||||
HermesCardBubble(
|
||||
card = card,
|
||||
cardKey = cardKey,
|
||||
dispatches = message.cardDispatches,
|
||||
onActionTap = { key, action ->
|
||||
onCardAction(message.id, key, action)
|
||||
},
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments — dispatched through the unified InboundAttachmentCard
|
||||
// so outbound and inbound attachments share the same render pipeline.
|
||||
// Outbound attachments (user-authored) always have state=LOADED so
|
||||
@@ -254,6 +302,23 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessagePathBadge(text: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.75f),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Three dots that animate opacity in sequence to indicate streaming is in progress.
|
||||
*/
|
||||
|
||||
@@ -13,150 +13,33 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* ASCII morphing sphere — the visual embodiment of the AI agent.
|
||||
*
|
||||
* Inspired by Amp Code's Supernova orb. Renders a sphere from monospace
|
||||
* characters with layered procedural effects driven by [SphereState]:
|
||||
* characters with layered procedural effects. Algorithm lives in
|
||||
* [forEachSphereCell] (see `MorphingSphereCore.kt`) so the same math powers
|
||||
* Android here, the JS browser preview in `preview/web/`, and any future
|
||||
* renderer (Compose Desktop, terminal TUI).
|
||||
*
|
||||
* - **Hybrid brightness**: concentric distance-based zones + orbiting directional
|
||||
* light that shifts the highlight across the surface ("the eye")
|
||||
* - **Dual noise**: structural FBM (slow undulation) + turbulence (fast shimmer)
|
||||
* - **Breathing radius**: slow expand/contract
|
||||
* - **Core heartbeat**: brightness throb near center
|
||||
* - **Radial flow**: outward energy drift
|
||||
* - **Ripple waves**: concentric brightness rings radiating outward
|
||||
* - **State-driven colors**: palette shifts per agent state
|
||||
*
|
||||
* Pure Compose Canvas — no OpenGL, no external libraries.
|
||||
* This file is the Android/Compose renderer only — it owns animation state
|
||||
* (`animateFloatAsState`, `rememberInfiniteTransition`) and text drawing.
|
||||
*/
|
||||
|
||||
/** Agent visual state — controls animation parameters and color palette. */
|
||||
enum class SphereState {
|
||||
/** Calm breathing, slow wandering eye, gentle ripples. Present, waiting. */
|
||||
Idle,
|
||||
/** Faster pulse, tighter core, rapid eye scanning. Processing. */
|
||||
Thinking,
|
||||
/** Energy radiates outward, strong ripples, focused eye. Speaking. */
|
||||
Streaming,
|
||||
/** Voice mode — listening to user. Cool palette, subtle amplitude-driven motion. */
|
||||
Listening,
|
||||
/** Voice mode — speaking to user. Warm core, dramatic amplitude-driven motion. */
|
||||
Speaking,
|
||||
/** Red shift, erratic motion. Something wrong. */
|
||||
Error
|
||||
}
|
||||
|
||||
// ── State parameter system ───────────────────────────────────────────
|
||||
|
||||
private data class SphereParams(
|
||||
val breatheSpeed: Float,
|
||||
val breatheAmp: Float,
|
||||
val lightSpeedX: Float,
|
||||
val lightSpeedY: Float,
|
||||
val lightInfluence: Float,
|
||||
val coreTightness: Float,
|
||||
val turbulenceAmp: Float,
|
||||
val rippleScale: Float,
|
||||
val heartbeatSpeed: Float,
|
||||
val radialFlowSpeed: Float
|
||||
)
|
||||
|
||||
private data class SphereColors(
|
||||
val r1: Float, val g1: Float, val b1: Float, // color pole 1
|
||||
val r2: Float, val g2: Float, val b2: Float // color pole 2
|
||||
)
|
||||
|
||||
private fun paramsFor(state: SphereState) = when (state) {
|
||||
SphereState.Idle -> SphereParams(
|
||||
breatheSpeed = 0.5f, breatheAmp = 0.04f,
|
||||
lightSpeedX = 0.25f, lightSpeedY = 0.18f, lightInfluence = 0.35f,
|
||||
coreTightness = 0.75f, turbulenceAmp = 0.06f,
|
||||
rippleScale = 1.0f, heartbeatSpeed = 1.0f, radialFlowSpeed = 0.2f
|
||||
)
|
||||
SphereState.Thinking -> SphereParams(
|
||||
breatheSpeed = 0.8f, breatheAmp = 0.02f,
|
||||
lightSpeedX = 0.5f, lightSpeedY = 0.35f, lightInfluence = 0.30f,
|
||||
coreTightness = 0.90f, turbulenceAmp = 0.12f,
|
||||
rippleScale = 1.5f, heartbeatSpeed = 4.0f, radialFlowSpeed = 0.1f
|
||||
)
|
||||
SphereState.Streaming -> SphereParams(
|
||||
breatheSpeed = 0.3f, breatheAmp = 0.06f,
|
||||
lightSpeedX = 0.15f, lightSpeedY = 0.10f, lightInfluence = 0.25f,
|
||||
coreTightness = 0.60f, turbulenceAmp = 0.08f,
|
||||
rippleScale = 2.0f, heartbeatSpeed = 1.5f, radialFlowSpeed = 0.5f
|
||||
)
|
||||
SphereState.Listening -> SphereParams(
|
||||
// Calm base — voiceAmplitude modulates on top (see render loop).
|
||||
breatheSpeed = 0.55f, breatheAmp = 0.035f,
|
||||
lightSpeedX = 0.22f, lightSpeedY = 0.16f, lightInfluence = 0.38f,
|
||||
coreTightness = 0.78f, turbulenceAmp = 0.05f,
|
||||
rippleScale = 0.9f, heartbeatSpeed = 1.2f, radialFlowSpeed = 0.18f
|
||||
)
|
||||
SphereState.Speaking -> SphereParams(
|
||||
// Assertive base — amplitude pushes it dramatically further.
|
||||
breatheSpeed = 0.45f, breatheAmp = 0.05f,
|
||||
lightSpeedX = 0.20f, lightSpeedY = 0.14f, lightInfluence = 0.30f,
|
||||
coreTightness = 0.55f, turbulenceAmp = 0.07f,
|
||||
rippleScale = 1.8f, heartbeatSpeed = 1.8f, radialFlowSpeed = 0.45f
|
||||
)
|
||||
SphereState.Error -> SphereParams(
|
||||
breatheSpeed = 1.2f, breatheAmp = 0.03f,
|
||||
lightSpeedX = 0.7f, lightSpeedY = 0.6f, lightInfluence = 0.40f,
|
||||
coreTightness = 0.80f, turbulenceAmp = 0.15f,
|
||||
rippleScale = 0.5f, heartbeatSpeed = 6.0f, radialFlowSpeed = 0.3f
|
||||
)
|
||||
}
|
||||
|
||||
private fun colorsFor(state: SphereState) = when (state) {
|
||||
SphereState.Idle -> SphereColors(
|
||||
0.25f, 0.85f, 0.40f, // green
|
||||
0.61f, 0.42f, 0.94f // purple
|
||||
)
|
||||
SphereState.Thinking -> SphereColors(
|
||||
0.30f, 0.55f, 0.95f, // blue
|
||||
0.55f, 0.35f, 0.90f // purple
|
||||
)
|
||||
SphereState.Streaming -> SphereColors(
|
||||
0.20f, 0.90f, 0.50f, // green
|
||||
0.25f, 0.80f, 0.85f // teal
|
||||
)
|
||||
SphereState.Listening -> SphereColors(
|
||||
// Cool soft blue/purple — cooler than Idle's green/purple.
|
||||
0.35f, 0.55f, 0.95f, // #597EF2 soft blue
|
||||
0.65f, 0.45f, 0.95f // #A573F2 soft purple
|
||||
)
|
||||
SphereState.Speaking -> SphereColors(
|
||||
// Vibrant green/teal — same family as Streaming but punchier.
|
||||
// The render loop pushes core toward white as amplitude peaks.
|
||||
0.25f, 0.92f, 0.55f, // #40EB8C vivid green
|
||||
0.30f, 0.85f, 0.88f // #4DD9E0 teal
|
||||
)
|
||||
SphereState.Error -> SphereColors(
|
||||
0.90f, 0.30f, 0.25f, // red
|
||||
0.85f, 0.50f, 0.20f // orange
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main composable ──────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun MorphingSphere(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -168,66 +51,28 @@ fun MorphingSphere(
|
||||
fixedTime: Float? = null,
|
||||
fixedColorPhase: Float? = null
|
||||
) {
|
||||
// Clamp amplitude once — downstream math assumes 0..1.
|
||||
val amp = voiceAmplitude.coerceIn(0f, 1f)
|
||||
|
||||
// ── Animated state parameters (smooth 800ms transitions) ─────
|
||||
val targetP = remember(state) { paramsFor(state) }
|
||||
val targetC = remember(state) { colorsFor(state) }
|
||||
val spec = tween<Float>(800, easing = FastOutSlowInEasing)
|
||||
|
||||
// ── voiceMode expansion scalar ───────────────────────────────
|
||||
// 1.0 = normal, ~1.08 = expanded (bounded to avoid data-ring overflow).
|
||||
val voiceRadiusScale by animateFloatAsState(
|
||||
targetValue = if (voiceMode) 1.08f else 1.0f,
|
||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||
label = "voiceExpand"
|
||||
)
|
||||
|
||||
val baseBreatheSpeed by animateFloatAsState(targetP.breatheSpeed, spec, label = "bSpd")
|
||||
val breatheSpeed by animateFloatAsState(targetP.breatheSpeed, spec, label = "bSpd")
|
||||
val breatheAmp by animateFloatAsState(targetP.breatheAmp, spec, label = "bAmp")
|
||||
val lightSpeedX by animateFloatAsState(targetP.lightSpeedX, spec, label = "lsX")
|
||||
val lightSpeedY by animateFloatAsState(targetP.lightSpeedY, spec, label = "lsY")
|
||||
val lightInfluence by animateFloatAsState(targetP.lightInfluence, spec, label = "lInf")
|
||||
val coreTightness by animateFloatAsState(targetP.coreTightness, spec, label = "core")
|
||||
val baseTurbulence by animateFloatAsState(targetP.turbulenceAmp, spec, label = "turb")
|
||||
val turbulenceAmp by animateFloatAsState(targetP.turbulenceAmp, spec, label = "turb")
|
||||
val rippleScale by animateFloatAsState(targetP.rippleScale, spec, label = "rip")
|
||||
val heartbeatSpeed by animateFloatAsState(targetP.heartbeatSpeed, spec, label = "hb")
|
||||
val radialFlowSpeed by animateFloatAsState(targetP.radialFlowSpeed, spec, label = "rf")
|
||||
|
||||
// ── Voice amplitude modulation ────────────────────────────────
|
||||
// Listening = subtle (≤30% boost); Speaking = dramatic (up to 3×).
|
||||
// Idle/Thinking/Streaming/Error ignore amplitude — existing behavior preserved.
|
||||
val breatheSpeed = when (state) {
|
||||
SphereState.Listening -> lerp(baseBreatheSpeed, baseBreatheSpeed * 1.3f, amp * 0.5f)
|
||||
SphereState.Speaking -> lerp(baseBreatheSpeed, baseBreatheSpeed * 2.0f, amp)
|
||||
else -> baseBreatheSpeed
|
||||
}
|
||||
val turbulenceAmp = when (state) {
|
||||
SphereState.Listening -> baseTurbulence + amp * 0.15f
|
||||
SphereState.Speaking -> baseTurbulence + amp * 0.5f
|
||||
else -> baseTurbulence
|
||||
}
|
||||
// Core warmth: 0.30 is the existing constant baked into the render loop's
|
||||
// warmth term (see line where `warmth = (1f - normDist^2) * 0.12f` is mixed).
|
||||
// Speaking pushes this multiplier from 0.3 → 1.0 as amplitude rises, driving
|
||||
// the core bright→white. Listening holds at 0.3 (no change vs. other states).
|
||||
val coreWarmth = when (state) {
|
||||
SphereState.Speaking -> lerp(0.30f, 1.0f, amp)
|
||||
else -> 0.30f
|
||||
}
|
||||
// Perimeter wobble — existing code uses a fixed 0.06 multiplier.
|
||||
val wobbleAmplitude = when (state) {
|
||||
SphereState.Listening -> 0.06f * (1f + amp * 0.3f)
|
||||
SphereState.Speaking -> 0.06f * (1f + amp * 0.8f)
|
||||
else -> 0.06f
|
||||
}
|
||||
// Data ring orbit speed — existing code uses `t * 0.4f`.
|
||||
val dataRingSpeed = when (state) {
|
||||
SphereState.Speaking -> 0.4f * (1f + amp * 3f)
|
||||
else -> 0.4f
|
||||
}
|
||||
|
||||
val cr1 by animateFloatAsState(targetC.r1, spec, label = "cr1")
|
||||
val cg1 by animateFloatAsState(targetC.g1, spec, label = "cg1")
|
||||
val cb1 by animateFloatAsState(targetC.b1, spec, label = "cb1")
|
||||
@@ -235,7 +80,6 @@ fun MorphingSphere(
|
||||
val cg2 by animateFloatAsState(targetC.g2, spec, label = "cg2")
|
||||
val cb2 by animateFloatAsState(targetC.b2, spec, label = "cb2")
|
||||
|
||||
// ── Continuous time animations ──────────────────────────────
|
||||
val transition = rememberInfiniteTransition(label = "sphere")
|
||||
val animatedTime by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
@@ -259,25 +103,11 @@ fun MorphingSphere(
|
||||
val time = fixedTime ?: animatedTime
|
||||
val colorPhase = fixedColorPhase ?: animatedColorPhase
|
||||
|
||||
// Multiple character sets that rotate over time for surface "activity"
|
||||
val charSets = arrayOf(
|
||||
" ·:;=+*#%@", // technical dots
|
||||
" .:;=+*#%@", // classic with semicolons
|
||||
" ·;:+=*%#@", // shuffled mid-range
|
||||
" .:;+=*#@%" // variant ordering
|
||||
)
|
||||
// Data ring characters (orbit the sphere like processing data)
|
||||
val dataChars = "01<>[]{}|/\\~^"
|
||||
|
||||
val cols = 58
|
||||
val rows = 34
|
||||
|
||||
val paint = remember {
|
||||
Paint().apply {
|
||||
typeface = Typeface.MONOSPACE
|
||||
isAntiAlias = true
|
||||
}
|
||||
}
|
||||
// Cache covers the ~25 distinct glyphs across charSets/dataChars/debrisChars.
|
||||
val textMeasurer = rememberTextMeasurer(cacheSize = 64)
|
||||
|
||||
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
|
||||
val canvasW = size.width
|
||||
@@ -285,277 +115,43 @@ fun MorphingSphere(
|
||||
val cellW = canvasW / cols
|
||||
val cellH = canvasH / rows
|
||||
val charSize = (cellW * 1.3f).coerceAtMost(cellH * 1.1f)
|
||||
paint.textSize = charSize
|
||||
|
||||
val cx = cols / 2f
|
||||
val cy = rows / 2f
|
||||
val charAspect = cellW / cellH
|
||||
|
||||
// Reduced from 0.72 so data ring (1.55x) fits within grid.
|
||||
// voiceRadiusScale is ~1.08 in voiceMode, 1.0 otherwise — bounded so the
|
||||
// data ring outer edge (1.55x) still stays within the drawable region.
|
||||
val maxRadiusFromRows = (rows / 2f) * 0.60f
|
||||
val maxRadiusFromCols = (cols / 2f) * charAspect * 0.60f
|
||||
val baseRadius = minOf(maxRadiusFromRows, maxRadiusFromCols) * voiceRadiusScale
|
||||
val t = time
|
||||
val style = TextStyle(
|
||||
fontSize = charSize.toSp(),
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
|
||||
// ── Breathing ────────────────────────────────────────────
|
||||
val breathe = sin(t * breatheSpeed) * breatheAmp
|
||||
val breathingRadius = baseRadius * (1f + breathe)
|
||||
val frame = SphereFrame(
|
||||
cols = cols, rows = rows, charAspect = charAspect,
|
||||
state = state, time = time, colorPhase = colorPhase,
|
||||
breatheSpeed = breatheSpeed, breatheAmp = breatheAmp,
|
||||
lightSpeedX = lightSpeedX, lightSpeedY = lightSpeedY,
|
||||
lightInfluence = lightInfluence, coreTightness = coreTightness,
|
||||
turbulenceAmp = turbulenceAmp, rippleScale = rippleScale,
|
||||
heartbeatSpeed = heartbeatSpeed, radialFlowSpeed = radialFlowSpeed,
|
||||
cr1 = cr1, cg1 = cg1, cb1 = cb1,
|
||||
cr2 = cr2, cg2 = cg2, cb2 = cb2,
|
||||
intensity = intensity, toolCallBurst = toolCallBurst,
|
||||
voiceAmplitude = amp, voiceMode = voiceMode,
|
||||
voiceRadiusScale = voiceRadiusScale
|
||||
)
|
||||
|
||||
// ── Orbiting directional light ("the eye") ──────────────
|
||||
// Lissajous orbit (different X/Y speeds) + noise jitter
|
||||
// for organic, non-repeating path. lx/ly at ±0.65 creates
|
||||
// strong enough asymmetry that the highlight visibly shifts.
|
||||
val noiseJitter1 = fbm(t * 0.05f + 7.3f, 1.7f) * 0.5f
|
||||
val noiseJitter2 = fbm(3.1f, t * 0.04f + 13.7f) * 0.5f
|
||||
val lightAngle1 = t * lightSpeedX + noiseJitter1
|
||||
val lightAngle2 = t * lightSpeedY + noiseJitter2
|
||||
val lx = sin(lightAngle1) * 0.65f
|
||||
val ly = cos(lightAngle2) * 0.65f
|
||||
val lz = sqrt((1f - lx * lx - ly * ly).coerceAtLeast(0.01f))
|
||||
|
||||
// ── Core heartbeat ──────────────────────────────────────
|
||||
val heartbeat = sin(t * heartbeatSpeed) * 0.5f + 0.5f
|
||||
|
||||
// ── Color palette (animated poles + phase oscillation) ───
|
||||
val pulse = sin(colorPhase) * 0.5f + 0.5f
|
||||
val colR = lerp(cr1, cr2, pulse)
|
||||
val colG = lerp(cg1, cg2, pulse)
|
||||
val colB = lerp(cb1, cb2, pulse)
|
||||
|
||||
val distWeight = 1f - lightInfluence
|
||||
|
||||
// Intensity/tool call modulation of state params
|
||||
val effTurbulence = turbulenceAmp + intensity * 0.04f + toolCallBurst * 0.15f
|
||||
val effRadialFlow = radialFlowSpeed + intensity * 0.3f
|
||||
val effRipple = rippleScale + intensity * 0.5f + toolCallBurst * 1.0f
|
||||
|
||||
for (row in 0 until rows) {
|
||||
for (col in 0 until cols) {
|
||||
val dx = (col - cx) * charAspect
|
||||
val dy = (row - cy)
|
||||
val dist = sqrt(dx * dx + dy * dy)
|
||||
val angle = atan2(dy, dx)
|
||||
|
||||
// ── Perimeter (subtle 6% wobble — amplified by voice) ───
|
||||
val perimeterNoise = fbm(
|
||||
angle * 1.8f + t * 0.08f,
|
||||
angle * 0.7f + t * 0.12f
|
||||
) * 2f - 1f
|
||||
val distortedRadius = breathingRadius * (1f + perimeterNoise * wobbleAmplitude)
|
||||
val glowRadius = distortedRadius * 1.35f
|
||||
val dataRingInner = distortedRadius * 1.40f
|
||||
val dataRingOuter = distortedRadius * 1.55f
|
||||
val normDist = dist / distortedRadius
|
||||
|
||||
if (dist > dataRingOuter) continue
|
||||
|
||||
val px = col * cellW
|
||||
val py = row * cellH + cellH * 0.8f
|
||||
|
||||
if (normDist <= 1f) {
|
||||
// ── INSIDE SPHERE ────────────────────────────
|
||||
|
||||
// Surface normal
|
||||
val nx = dx / distortedRadius
|
||||
val ny2 = dy / distortedRadius
|
||||
val nzSq = (1f - nx * nx - ny2 * ny2).coerceAtLeast(0f)
|
||||
val nz = sqrt(nzSq)
|
||||
|
||||
// Distance-based brightness (concentric zones)
|
||||
val distBrightness = (1f - normDist * normDist * coreTightness)
|
||||
.coerceAtLeast(0.15f)
|
||||
|
||||
// Directional light (shifts highlight across surface)
|
||||
val directionalLight = (nx * lx + ny2 * ly + nz * lz)
|
||||
.coerceIn(0f, 1f)
|
||||
|
||||
// Structural noise (slow undulation)
|
||||
val structural = fbm(
|
||||
col * 0.25f + t * 0.18f,
|
||||
row * 0.25f + t * 0.13f,
|
||||
octaves = 2
|
||||
) * 0.15f - 0.075f
|
||||
|
||||
// Turbulence (fast shimmer, boosted by intensity + tool calls)
|
||||
val turbulence = fbm(
|
||||
col * 0.8f + t * 0.6f,
|
||||
row * 0.8f + t * 0.45f,
|
||||
octaves = 2
|
||||
) * effTurbulence - effTurbulence * 0.5f
|
||||
|
||||
// Radial flow (outward energy drift, faster when streaming)
|
||||
val radialFlow = fbm(
|
||||
angle * 2f + t * 0.15f,
|
||||
dist * 0.3f - t * effRadialFlow,
|
||||
octaves = 2
|
||||
) * 0.06f - 0.03f
|
||||
|
||||
// Ripple waves (stronger during streaming/tool calls)
|
||||
val ripple = (
|
||||
sin(normDist * 8f - t * 1.2f) * 0.04f * (1f - normDist) +
|
||||
sin(normDist * 5f - t * 0.7f + 2f) * 0.03f * (1f - normDist)
|
||||
) * effRipple
|
||||
|
||||
// Core heartbeat (subtle glow, concentrated at center)
|
||||
val heartbeatFx = heartbeat * 0.05f * (1f - normDist * normDist)
|
||||
|
||||
// ── Hybrid brightness ────────────────────────
|
||||
val brightness = distWeight * distBrightness +
|
||||
lightInfluence * directionalLight +
|
||||
heartbeatFx
|
||||
val charNoise = structural + turbulence + radialFlow + ripple
|
||||
|
||||
// Character rotation: cycle through char sets over time
|
||||
// Each cell picks a set based on position + time, creating
|
||||
// surface "activity" where characters shift independently
|
||||
val rotationPhase = (t * 0.3f + col * 0.17f + row * 0.13f).toInt()
|
||||
val chars = charSets[rotationPhase.and(3)] // mod 4 via bitmask
|
||||
|
||||
val charIdx = ((brightness + charNoise) * (chars.length - 1))
|
||||
.toInt().coerceIn(1, chars.length - 1)
|
||||
val ch = chars[charIdx]
|
||||
|
||||
// Edge fade (quadratic, starts at 0.80)
|
||||
val edgeFade = when {
|
||||
normDist > 0.80f -> {
|
||||
val ef = (normDist - 0.80f) / 0.20f
|
||||
1f - ef * ef
|
||||
}
|
||||
else -> 1f
|
||||
}
|
||||
|
||||
// Scanline: dimming on odd rows (CRT/holographic feel)
|
||||
val scanline = if (row % 2 == 1) 0.82f else 1f
|
||||
|
||||
val alpha = ((brightness * 0.4f + 0.6f) * edgeFade * scanline)
|
||||
.coerceIn(0.1f, 1f)
|
||||
|
||||
// Core warmth (center bleeds towards white).
|
||||
// coreWarmth is 0.30 for all non-voice states (→ 0.12 multiplier,
|
||||
// the historical value) and scales up to 1.0 when Speaking peaks.
|
||||
val warmth = (1f - normDist * normDist) * (coreWarmth * 0.40f)
|
||||
val lightBoost = directionalLight * 0.08f
|
||||
|
||||
paint.color = android.graphics.Color.argb(
|
||||
(alpha * 255).toInt().coerceIn(0, 255),
|
||||
((colR + lightBoost + warmth) * 255).toInt().coerceIn(0, 255),
|
||||
((colG + lightBoost * 0.5f + warmth) * 255).toInt().coerceIn(0, 255),
|
||||
((colB + lightBoost + warmth) * 255).toInt().coerceIn(0, 255)
|
||||
)
|
||||
|
||||
drawContext.canvas.nativeCanvas.drawText(ch.toString(), px, py, paint)
|
||||
|
||||
} else if (dist <= glowRadius) {
|
||||
// ── GLOW / DEBRIS ZONE ───────────────────────
|
||||
|
||||
val glowT = (dist - distortedRadius) / (glowRadius - distortedRadius)
|
||||
val glowFalloff = (1f - glowT).coerceIn(0f, 1f)
|
||||
|
||||
val sparsityNoise = fbm(
|
||||
angle * 3.5f + t * 0.25f,
|
||||
dist * 0.4f + t * 0.08f,
|
||||
octaves = 2
|
||||
)
|
||||
val sparsityThreshold = 0.35f + glowT * 0.25f
|
||||
if (sparsityNoise < sparsityThreshold) continue
|
||||
|
||||
val debrisChars = "·:;- "
|
||||
val debrisIdx = ((1f - glowFalloff) * (debrisChars.length - 1))
|
||||
.toInt().coerceIn(0, debrisChars.length - 1)
|
||||
val ch = debrisChars[debrisIdx]
|
||||
if (ch == ' ') continue
|
||||
|
||||
val alpha = glowFalloff * 0.85f
|
||||
|
||||
paint.color = android.graphics.Color.argb(
|
||||
(alpha * 255).toInt().coerceIn(0, 255),
|
||||
(colR * 255).toInt().coerceIn(0, 255),
|
||||
(colG * 255).toInt().coerceIn(0, 255),
|
||||
(colB * 255).toInt().coerceIn(0, 255)
|
||||
)
|
||||
|
||||
drawContext.canvas.nativeCanvas.drawText(ch.toString(), px, py, paint)
|
||||
|
||||
} else if (dist >= dataRingInner) {
|
||||
// ── DATA RING ────────────────────────────────
|
||||
// Sparse orbiting characters like processing data.
|
||||
// Angle offset by time = characters appear to orbit.
|
||||
|
||||
val ringT = (dist - dataRingInner) / (dataRingOuter - dataRingInner)
|
||||
|
||||
// Orbiting: offset angle by time (different layers at different speeds).
|
||||
// dataRingSpeed is 0.4 default, spun up to ~1.6 at Speaking peak.
|
||||
val orbitAngle = angle - t * dataRingSpeed + ringT * 1.5f
|
||||
// Sparsity: only render ~15% of ring positions
|
||||
val ringNoise = fbm(
|
||||
orbitAngle * 4f + t * 0.3f,
|
||||
ringT * 3f + t * 0.15f,
|
||||
octaves = 2
|
||||
)
|
||||
if (ringNoise < 0.55f) continue
|
||||
|
||||
// Pick character from data set, cycling with orbit
|
||||
val dataIdx = ((orbitAngle * 2f + t * 0.5f) * dataChars.length)
|
||||
.toInt().mod(dataChars.length)
|
||||
val ch = dataChars[dataIdx]
|
||||
|
||||
// Fade: bright at inner edge, fading outward
|
||||
val ringFade = (1f - ringT).coerceIn(0f, 1f)
|
||||
val alpha = ringFade * 0.65f
|
||||
|
||||
paint.color = android.graphics.Color.argb(
|
||||
(alpha * 255).toInt().coerceIn(0, 255),
|
||||
(colR * 0.85f * 255).toInt().coerceIn(0, 255),
|
||||
(colG * 0.85f * 255).toInt().coerceIn(0, 255),
|
||||
(colB * 0.85f * 255).toInt().coerceIn(0, 255)
|
||||
)
|
||||
|
||||
drawContext.canvas.nativeCanvas.drawText(ch.toString(), px, py, paint)
|
||||
}
|
||||
}
|
||||
forEachSphereCell(frame) { cell ->
|
||||
val layout = textMeasurer.measure(cell.char.toString(), style)
|
||||
// Legacy Paint used y as baseline (`row*cellH + cellH*0.8f`).
|
||||
// Compose `drawText` uses top-left — offset by firstBaseline to match.
|
||||
val px = cell.col * cellW
|
||||
val py = cell.row * cellH + cellH * 0.8f - layout.firstBaseline
|
||||
drawText(
|
||||
textLayoutResult = layout,
|
||||
color = Color(cell.r, cell.g, cell.b, cell.alpha),
|
||||
topLeft = Offset(px, py)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Procedural noise ─────────────────────────────────────────────────
|
||||
|
||||
private fun hash(x: Int, y: Int): Float {
|
||||
var h = x * 374761393 + y * 668265263
|
||||
h = (h xor (h ushr 13)) * 1274126177
|
||||
h = h xor (h ushr 16)
|
||||
return (h and 0x7fffffff) / 2147483647f
|
||||
}
|
||||
|
||||
private fun smoothNoise(x: Float, y: Float): Float {
|
||||
val xi = floor(x).toInt()
|
||||
val yi = floor(y).toInt()
|
||||
val xf = x - xi
|
||||
val yf = y - yi
|
||||
val u = xf * xf * (3f - 2f * xf)
|
||||
val v = yf * yf * (3f - 2f * yf)
|
||||
val n00 = hash(xi, yi)
|
||||
val n10 = hash(xi + 1, yi)
|
||||
val n01 = hash(xi, yi + 1)
|
||||
val n11 = hash(xi + 1, yi + 1)
|
||||
return lerp(lerp(n00, n10, u), lerp(n01, n11, u), v)
|
||||
}
|
||||
|
||||
private fun fbm(x: Float, y: Float, octaves: Int = 3): Float {
|
||||
var value = 0f
|
||||
var amplitude = 0.5f
|
||||
var frequency = 1f
|
||||
for (i in 0 until octaves) {
|
||||
value += amplitude * smoothNoise(x * frequency, y * frequency)
|
||||
amplitude *= 0.5f
|
||||
frequency *= 2f
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t
|
||||
|
||||
// ── Previews ─────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "Idle", showBackground = true, backgroundColor = 0xFF0D0D0D, widthDp = 360, heightDp = 640)
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Pure, platform-agnostic core of the ASCII morphing sphere.
|
||||
*
|
||||
* No Android, no Compose — only `kotlin.math`. Intended as the single source of
|
||||
* truth for the sphere algorithm. Android renders via Compose Canvas; a JS port
|
||||
* in `preview/web/` mirrors this file for browser iteration; future renderers
|
||||
* (Compose Desktop, terminal TUI) can call this same core.
|
||||
*/
|
||||
|
||||
/** Agent visual state — controls animation parameters and color palette. */
|
||||
enum class SphereState {
|
||||
Idle,
|
||||
Thinking,
|
||||
Streaming,
|
||||
Listening,
|
||||
Speaking,
|
||||
Error
|
||||
}
|
||||
|
||||
/** Animated parameter bundle — interpolated by the caller for smooth state transitions. */
|
||||
data class SphereParams(
|
||||
val breatheSpeed: Float,
|
||||
val breatheAmp: Float,
|
||||
val lightSpeedX: Float,
|
||||
val lightSpeedY: Float,
|
||||
val lightInfluence: Float,
|
||||
val coreTightness: Float,
|
||||
val turbulenceAmp: Float,
|
||||
val rippleScale: Float,
|
||||
val heartbeatSpeed: Float,
|
||||
val radialFlowSpeed: Float
|
||||
)
|
||||
|
||||
/** Two color poles mixed by a time-varying phase. */
|
||||
data class SphereColors(
|
||||
val r1: Float, val g1: Float, val b1: Float,
|
||||
val r2: Float, val g2: Float, val b2: Float
|
||||
)
|
||||
|
||||
fun paramsFor(state: SphereState): SphereParams = when (state) {
|
||||
SphereState.Idle -> SphereParams(
|
||||
breatheSpeed = 0.5f, breatheAmp = 0.04f,
|
||||
lightSpeedX = 0.25f, lightSpeedY = 0.18f, lightInfluence = 0.35f,
|
||||
coreTightness = 0.75f, turbulenceAmp = 0.06f,
|
||||
rippleScale = 1.0f, heartbeatSpeed = 1.0f, radialFlowSpeed = 0.2f
|
||||
)
|
||||
SphereState.Thinking -> SphereParams(
|
||||
breatheSpeed = 0.8f, breatheAmp = 0.02f,
|
||||
lightSpeedX = 0.5f, lightSpeedY = 0.35f, lightInfluence = 0.30f,
|
||||
coreTightness = 0.90f, turbulenceAmp = 0.12f,
|
||||
rippleScale = 1.5f, heartbeatSpeed = 4.0f, radialFlowSpeed = 0.1f
|
||||
)
|
||||
SphereState.Streaming -> SphereParams(
|
||||
breatheSpeed = 0.3f, breatheAmp = 0.06f,
|
||||
lightSpeedX = 0.15f, lightSpeedY = 0.10f, lightInfluence = 0.25f,
|
||||
coreTightness = 0.60f, turbulenceAmp = 0.08f,
|
||||
rippleScale = 2.0f, heartbeatSpeed = 1.5f, radialFlowSpeed = 0.5f
|
||||
)
|
||||
SphereState.Listening -> SphereParams(
|
||||
breatheSpeed = 0.55f, breatheAmp = 0.035f,
|
||||
lightSpeedX = 0.22f, lightSpeedY = 0.16f, lightInfluence = 0.38f,
|
||||
coreTightness = 0.78f, turbulenceAmp = 0.05f,
|
||||
rippleScale = 0.9f, heartbeatSpeed = 1.2f, radialFlowSpeed = 0.18f
|
||||
)
|
||||
SphereState.Speaking -> SphereParams(
|
||||
breatheSpeed = 0.45f, breatheAmp = 0.05f,
|
||||
lightSpeedX = 0.20f, lightSpeedY = 0.14f, lightInfluence = 0.30f,
|
||||
coreTightness = 0.55f, turbulenceAmp = 0.07f,
|
||||
rippleScale = 1.8f, heartbeatSpeed = 1.8f, radialFlowSpeed = 0.45f
|
||||
)
|
||||
SphereState.Error -> SphereParams(
|
||||
breatheSpeed = 1.2f, breatheAmp = 0.03f,
|
||||
lightSpeedX = 0.7f, lightSpeedY = 0.6f, lightInfluence = 0.40f,
|
||||
coreTightness = 0.80f, turbulenceAmp = 0.15f,
|
||||
rippleScale = 0.5f, heartbeatSpeed = 6.0f, radialFlowSpeed = 0.3f
|
||||
)
|
||||
}
|
||||
|
||||
fun colorsFor(state: SphereState): SphereColors = when (state) {
|
||||
SphereState.Idle -> SphereColors(
|
||||
0.25f, 0.85f, 0.40f,
|
||||
0.61f, 0.42f, 0.94f
|
||||
)
|
||||
SphereState.Thinking -> SphereColors(
|
||||
0.30f, 0.55f, 0.95f,
|
||||
0.55f, 0.35f, 0.90f
|
||||
)
|
||||
SphereState.Streaming -> SphereColors(
|
||||
0.20f, 0.90f, 0.50f,
|
||||
0.25f, 0.80f, 0.85f
|
||||
)
|
||||
SphereState.Listening -> SphereColors(
|
||||
0.35f, 0.55f, 0.95f,
|
||||
0.65f, 0.45f, 0.95f
|
||||
)
|
||||
SphereState.Speaking -> SphereColors(
|
||||
0.25f, 0.92f, 0.55f,
|
||||
0.30f, 0.85f, 0.88f
|
||||
)
|
||||
SphereState.Error -> SphereColors(
|
||||
0.90f, 0.30f, 0.25f,
|
||||
0.85f, 0.50f, 0.20f
|
||||
)
|
||||
}
|
||||
|
||||
/** All inputs needed to render one frame — populated by the caller from animation state. */
|
||||
data class SphereFrame(
|
||||
val cols: Int,
|
||||
val rows: Int,
|
||||
val charAspect: Float,
|
||||
val state: SphereState,
|
||||
val time: Float,
|
||||
val colorPhase: Float,
|
||||
// Animated base params (caller smooths transitions)
|
||||
val breatheSpeed: Float,
|
||||
val breatheAmp: Float,
|
||||
val lightSpeedX: Float,
|
||||
val lightSpeedY: Float,
|
||||
val lightInfluence: Float,
|
||||
val coreTightness: Float,
|
||||
val turbulenceAmp: Float,
|
||||
val rippleScale: Float,
|
||||
val heartbeatSpeed: Float,
|
||||
val radialFlowSpeed: Float,
|
||||
// Animated colors
|
||||
val cr1: Float, val cg1: Float, val cb1: Float,
|
||||
val cr2: Float, val cg2: Float, val cb2: Float,
|
||||
// Intensity + modulation
|
||||
val intensity: Float,
|
||||
val toolCallBurst: Float,
|
||||
val voiceAmplitude: Float,
|
||||
val voiceMode: Boolean,
|
||||
val voiceRadiusScale: Float,
|
||||
// Gaze bias — lets callers aim the sphere's "eye" (bright spot) at a
|
||||
// specific direction without moving the sphere body. `lightAngleBlend`
|
||||
// blends between natural rotation (0) and the bias override (1).
|
||||
// Defaults keep the original behavior for every existing caller.
|
||||
val lightAngleBiasX: Float = 0f,
|
||||
val lightAngleBiasY: Float = 0f,
|
||||
val lightAngleBlend: Float = 0f,
|
||||
// Shadow contrast — darkens distBrightness on the hemisphere facing away
|
||||
// from the light, making the bright spot ("eye") clearly distinguishable
|
||||
// from the shadow side. 0 = uniform pearl shading (legacy behavior, full
|
||||
// parity preserved); 1 = shadow side fully uses directionalLight to scale
|
||||
// the distBrightness (strong Lambertian contrast).
|
||||
val shadowStrength: Float = 0f
|
||||
)
|
||||
|
||||
/** What to draw at one grid cell. RGB + alpha in 0..1. */
|
||||
data class SphereCell(
|
||||
val col: Int,
|
||||
val row: Int,
|
||||
val char: Char,
|
||||
val r: Float,
|
||||
val g: Float,
|
||||
val b: Float,
|
||||
val alpha: Float
|
||||
)
|
||||
|
||||
private val charSets = arrayOf(
|
||||
" ·:;=+*#%@",
|
||||
" .:;=+*#%@",
|
||||
" ·;:+=*%#@",
|
||||
" .:;+=*#@%"
|
||||
)
|
||||
private const val dataChars = "01<>[]{}|/\\~^"
|
||||
|
||||
/**
|
||||
* Iterates the `rows × cols` grid for one frame and invokes `onCell` for every
|
||||
* cell that should be drawn. Cells outside the drawable region (or excluded by
|
||||
* sparsity) are silently skipped — the callback sees only visible glyphs.
|
||||
*/
|
||||
fun forEachSphereCell(frame: SphereFrame, onCell: (SphereCell) -> Unit) {
|
||||
val amp = frame.voiceAmplitude.coerceIn(0f, 1f)
|
||||
|
||||
// ── Voice modulation of animated base params ─────────────────
|
||||
val breatheSpeed = when (frame.state) {
|
||||
SphereState.Listening -> lerp(frame.breatheSpeed, frame.breatheSpeed * 1.3f, amp * 0.5f)
|
||||
SphereState.Speaking -> lerp(frame.breatheSpeed, frame.breatheSpeed * 2.0f, amp)
|
||||
else -> frame.breatheSpeed
|
||||
}
|
||||
val turbulenceAmp = when (frame.state) {
|
||||
SphereState.Listening -> frame.turbulenceAmp + amp * 0.15f
|
||||
SphereState.Speaking -> frame.turbulenceAmp + amp * 0.5f
|
||||
else -> frame.turbulenceAmp
|
||||
}
|
||||
val coreWarmth = when (frame.state) {
|
||||
SphereState.Speaking -> lerp(0.30f, 1.0f, amp)
|
||||
else -> 0.30f
|
||||
}
|
||||
val wobbleAmplitude = when (frame.state) {
|
||||
SphereState.Listening -> 0.06f * (1f + amp * 0.3f)
|
||||
SphereState.Speaking -> 0.06f * (1f + amp * 0.8f)
|
||||
else -> 0.06f
|
||||
}
|
||||
val dataRingSpeed = when (frame.state) {
|
||||
SphereState.Speaking -> 0.4f * (1f + amp * 3f)
|
||||
else -> 0.4f
|
||||
}
|
||||
|
||||
val cx = frame.cols / 2f
|
||||
val cy = frame.rows / 2f
|
||||
val charAspect = frame.charAspect
|
||||
|
||||
// Matches legacy 0.60 envelope so the data ring (1.55×) fits the grid.
|
||||
val maxRadiusFromRows = (frame.rows / 2f) * 0.60f
|
||||
val maxRadiusFromCols = (frame.cols / 2f) * charAspect * 0.60f
|
||||
val baseRadius = minOf(maxRadiusFromRows, maxRadiusFromCols) * frame.voiceRadiusScale
|
||||
val t = frame.time
|
||||
|
||||
val breathe = sin(t * breatheSpeed) * frame.breatheAmp
|
||||
val breathingRadius = baseRadius * (1f + breathe)
|
||||
|
||||
val noiseJitter1 = fbm(t * 0.05f + 7.3f, 1.7f) * 0.5f
|
||||
val noiseJitter2 = fbm(3.1f, t * 0.04f + 13.7f) * 0.5f
|
||||
val naturalAngle1 = t * frame.lightSpeedX + noiseJitter1
|
||||
val naturalAngle2 = t * frame.lightSpeedY + noiseJitter2
|
||||
val blend = frame.lightAngleBlend.coerceIn(0f, 1f)
|
||||
val lightAngle1 = naturalAngle1 * (1f - blend) + frame.lightAngleBiasX * blend
|
||||
val lightAngle2 = naturalAngle2 * (1f - blend) + frame.lightAngleBiasY * blend
|
||||
val lx = sin(lightAngle1) * 0.65f
|
||||
val ly = cos(lightAngle2) * 0.65f
|
||||
val lz = sqrt((1f - lx * lx - ly * ly).coerceAtLeast(0.01f))
|
||||
|
||||
val heartbeat = sin(t * frame.heartbeatSpeed) * 0.5f + 0.5f
|
||||
|
||||
val pulse = sin(frame.colorPhase) * 0.5f + 0.5f
|
||||
val colR = lerp(frame.cr1, frame.cr2, pulse)
|
||||
val colG = lerp(frame.cg1, frame.cg2, pulse)
|
||||
val colB = lerp(frame.cb1, frame.cb2, pulse)
|
||||
|
||||
val distWeight = 1f - frame.lightInfluence
|
||||
|
||||
val effTurbulence = turbulenceAmp + frame.intensity * 0.04f + frame.toolCallBurst * 0.15f
|
||||
val effRadialFlow = frame.radialFlowSpeed + frame.intensity * 0.3f
|
||||
val effRipple = frame.rippleScale + frame.intensity * 0.5f + frame.toolCallBurst * 1.0f
|
||||
|
||||
for (row in 0 until frame.rows) {
|
||||
for (col in 0 until frame.cols) {
|
||||
val dx = (col - cx) * charAspect
|
||||
val dy = (row - cy)
|
||||
val dist = sqrt(dx * dx + dy * dy)
|
||||
val angle = atan2(dy, dx)
|
||||
|
||||
val perimeterNoise = fbm(
|
||||
angle * 1.8f + t * 0.08f,
|
||||
angle * 0.7f + t * 0.12f
|
||||
) * 2f - 1f
|
||||
val distortedRadius = breathingRadius * (1f + perimeterNoise * wobbleAmplitude)
|
||||
val glowRadius = distortedRadius * 1.35f
|
||||
val dataRingInner = distortedRadius * 1.40f
|
||||
val dataRingOuter = distortedRadius * 1.55f
|
||||
val normDist = dist / distortedRadius
|
||||
|
||||
if (dist > dataRingOuter) continue
|
||||
|
||||
if (normDist <= 1f) {
|
||||
// ── INSIDE SPHERE ────────────────────────────
|
||||
val nx = dx / distortedRadius
|
||||
val ny2 = dy / distortedRadius
|
||||
val nzSq = (1f - nx * nx - ny2 * ny2).coerceAtLeast(0f)
|
||||
val nz = sqrt(nzSq)
|
||||
|
||||
val distBrightness = (1f - normDist * normDist * frame.coreTightness)
|
||||
.coerceAtLeast(0.15f)
|
||||
|
||||
val directionalLight = (nx * lx + ny2 * ly + nz * lz)
|
||||
.coerceIn(0f, 1f)
|
||||
|
||||
val structural = fbm(
|
||||
col * 0.25f + t * 0.18f,
|
||||
row * 0.25f + t * 0.13f,
|
||||
octaves = 2
|
||||
) * 0.15f - 0.075f
|
||||
|
||||
val turbulence = fbm(
|
||||
col * 0.8f + t * 0.6f,
|
||||
row * 0.8f + t * 0.45f,
|
||||
octaves = 2
|
||||
) * effTurbulence - effTurbulence * 0.5f
|
||||
|
||||
val radialFlow = fbm(
|
||||
angle * 2f + t * 0.15f,
|
||||
dist * 0.3f - t * effRadialFlow,
|
||||
octaves = 2
|
||||
) * 0.06f - 0.03f
|
||||
|
||||
val ripple = (
|
||||
sin(normDist * 8f - t * 1.2f) * 0.04f * (1f - normDist) +
|
||||
sin(normDist * 5f - t * 0.7f + 2f) * 0.03f * (1f - normDist)
|
||||
) * effRipple
|
||||
|
||||
val heartbeatFx = heartbeat * 0.05f * (1f - normDist * normDist)
|
||||
|
||||
// Shadow modulation — leave distBrightness alone on the lit
|
||||
// side (directionalLight=1 → factor=1), dim it on the shadow
|
||||
// side (directionalLight=0 → factor = 1 - shadowStrength).
|
||||
val shadowFactor = 1f - frame.shadowStrength * (1f - directionalLight)
|
||||
val brightness = distWeight * distBrightness * shadowFactor +
|
||||
frame.lightInfluence * directionalLight +
|
||||
heartbeatFx
|
||||
val charNoise = structural + turbulence + radialFlow + ripple
|
||||
|
||||
val rotationPhase = (t * 0.3f + col * 0.17f + row * 0.13f).toInt()
|
||||
val chars = charSets[rotationPhase.and(3)]
|
||||
|
||||
val charIdx = ((brightness + charNoise) * (chars.length - 1))
|
||||
.toInt().coerceIn(1, chars.length - 1)
|
||||
val ch = chars[charIdx]
|
||||
|
||||
val edgeFade = when {
|
||||
normDist > 0.80f -> {
|
||||
val ef = (normDist - 0.80f) / 0.20f
|
||||
1f - ef * ef
|
||||
}
|
||||
else -> 1f
|
||||
}
|
||||
|
||||
val scanline = if (row % 2 == 1) 0.82f else 1f
|
||||
val alpha = ((brightness * 0.4f + 0.6f) * edgeFade * scanline)
|
||||
.coerceIn(0.1f, 1f)
|
||||
|
||||
val warmth = (1f - normDist * normDist) * (coreWarmth * 0.40f)
|
||||
val lightBoost = directionalLight * 0.08f
|
||||
|
||||
onCell(SphereCell(
|
||||
col = col, row = row, char = ch,
|
||||
r = (colR + lightBoost + warmth).coerceIn(0f, 1f),
|
||||
g = (colG + lightBoost * 0.5f + warmth).coerceIn(0f, 1f),
|
||||
b = (colB + lightBoost + warmth).coerceIn(0f, 1f),
|
||||
alpha = alpha
|
||||
))
|
||||
} else if (dist <= glowRadius) {
|
||||
// ── GLOW / DEBRIS ZONE ───────────────────────
|
||||
val glowT = (dist - distortedRadius) / (glowRadius - distortedRadius)
|
||||
val glowFalloff = (1f - glowT).coerceIn(0f, 1f)
|
||||
|
||||
val sparsityNoise = fbm(
|
||||
angle * 3.5f + t * 0.25f,
|
||||
dist * 0.4f + t * 0.08f,
|
||||
octaves = 2
|
||||
)
|
||||
val sparsityThreshold = 0.35f + glowT * 0.25f
|
||||
if (sparsityNoise < sparsityThreshold) continue
|
||||
|
||||
val debrisChars = "·:;- "
|
||||
val debrisIdx = ((1f - glowFalloff) * (debrisChars.length - 1))
|
||||
.toInt().coerceIn(0, debrisChars.length - 1)
|
||||
val ch = debrisChars[debrisIdx]
|
||||
if (ch == ' ') continue
|
||||
|
||||
val alpha = glowFalloff * 0.85f
|
||||
onCell(SphereCell(
|
||||
col = col, row = row, char = ch,
|
||||
r = colR.coerceIn(0f, 1f),
|
||||
g = colG.coerceIn(0f, 1f),
|
||||
b = colB.coerceIn(0f, 1f),
|
||||
alpha = alpha
|
||||
))
|
||||
} else if (dist >= dataRingInner) {
|
||||
// ── DATA RING ────────────────────────────────
|
||||
val ringT = (dist - dataRingInner) / (dataRingOuter - dataRingInner)
|
||||
val orbitAngle = angle - t * dataRingSpeed + ringT * 1.5f
|
||||
val ringNoise = fbm(
|
||||
orbitAngle * 4f + t * 0.3f,
|
||||
ringT * 3f + t * 0.15f,
|
||||
octaves = 2
|
||||
)
|
||||
if (ringNoise < 0.55f) continue
|
||||
|
||||
val dataIdx = ((orbitAngle * 2f + t * 0.5f) * dataChars.length)
|
||||
.toInt().mod(dataChars.length)
|
||||
val ch = dataChars[dataIdx]
|
||||
|
||||
val ringFade = (1f - ringT).coerceIn(0f, 1f)
|
||||
val alpha = ringFade * 0.65f
|
||||
onCell(SphereCell(
|
||||
col = col, row = row, char = ch,
|
||||
r = (colR * 0.85f).coerceIn(0f, 1f),
|
||||
g = (colG * 0.85f).coerceIn(0f, 1f),
|
||||
b = (colB * 0.85f).coerceIn(0f, 1f),
|
||||
alpha = alpha
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Procedural noise (public so renderers can share / verify behavior) ──
|
||||
|
||||
fun hash(x: Int, y: Int): Float {
|
||||
var h = x * 374761393 + y * 668265263
|
||||
h = (h xor (h ushr 13)) * 1274126177
|
||||
h = h xor (h ushr 16)
|
||||
return (h and 0x7fffffff) / 2147483647f
|
||||
}
|
||||
|
||||
fun smoothNoise(x: Float, y: Float): Float {
|
||||
val xi = floor(x).toInt()
|
||||
val yi = floor(y).toInt()
|
||||
val xf = x - xi
|
||||
val yf = y - yi
|
||||
val u = xf * xf * (3f - 2f * xf)
|
||||
val v = yf * yf * (3f - 2f * yf)
|
||||
val n00 = hash(xi, yi)
|
||||
val n10 = hash(xi + 1, yi)
|
||||
val n01 = hash(xi, yi + 1)
|
||||
val n11 = hash(xi + 1, yi + 1)
|
||||
return lerp(lerp(n00, n10, u), lerp(n01, n11, u), v)
|
||||
}
|
||||
|
||||
fun fbm(x: Float, y: Float, octaves: Int = 3): Float {
|
||||
var value = 0f
|
||||
var amplitude = 0.5f
|
||||
var frequency = 1f
|
||||
for (i in 0 until octaves) {
|
||||
value += amplitude * smoothNoise(x * frequency, y * frequency)
|
||||
amplitude *= 0.5f
|
||||
frequency *= 2f
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t
|
||||
@@ -1,95 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
/**
|
||||
* Personality picker — shows server-configured personalities from GET /api/config.
|
||||
* "Default" uses the server's active personality (config.display.personality).
|
||||
* Other entries are from config.agent.personalities.
|
||||
*/
|
||||
@Composable
|
||||
fun PersonalityPicker(
|
||||
selected: String,
|
||||
personalities: List<String>,
|
||||
defaultName: String,
|
||||
onSelect: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val displayName = if (selected == "default") {
|
||||
if (defaultName.isNotBlank()) {
|
||||
defaultName.replaceFirstChar { it.uppercase() }
|
||||
} else "Default"
|
||||
} else {
|
||||
selected.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
TextButton(onClick = { expanded = true }) {
|
||||
Text(
|
||||
text = displayName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDropDown,
|
||||
contentDescription = "Select personality"
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
// Default (server's active personality)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = if (defaultName.isNotBlank()) {
|
||||
"${defaultName.replaceFirstChar { it.uppercase() }} (default)"
|
||||
} else "Default",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
onSelect("default")
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
|
||||
if (personalities.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
|
||||
personalities.filter { it != defaultName }.forEach { personality ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = personality.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
onSelect(personality)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.ManageSearch
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.ui.theme.gradientBorder
|
||||
|
||||
/**
|
||||
* Settings card that opens the Profile Inspector full-screen viewer for
|
||||
* the currently-active agent profile.
|
||||
*
|
||||
* Placement-wise this lives immediately under `ActiveAgentCard` in
|
||||
* [com.hermesandroid.relay.ui.screens.SettingsScreen] so the card lines
|
||||
* up visually with the active-agent pick the user just saw above.
|
||||
*
|
||||
* When no profile is selected, the card renders at 50% alpha with a
|
||||
* "No active agent" subtitle and its onClick is a no-op — matching the
|
||||
* existing disabled-row convention used elsewhere in Settings. This
|
||||
* keeps the card visible (discoverable) rather than hidden, so users
|
||||
* understand the feature exists even before they've picked a profile.
|
||||
*/
|
||||
@Composable
|
||||
fun ProfileInspectorCard(
|
||||
activeProfile: Profile?,
|
||||
onClick: (profileName: String) -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val enabled = activeProfile != null
|
||||
val profileName = activeProfile?.name
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
.alpha(if (enabled) 1f else 0.5f),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (enabled && profileName != null) {
|
||||
Modifier.clickable { onClick(profileName) }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ManageSearch,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Inspect Agent",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = if (enabled) {
|
||||
"View config, SOUL, memory, skills"
|
||||
} else {
|
||||
"No active agent"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,9 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import com.hermesandroid.relay.data.ApiEndpoint
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.RelayEndpoint
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -73,7 +76,7 @@ import kotlin.math.max
|
||||
/**
|
||||
* Parsed result from a Hermes pairing QR code.
|
||||
*
|
||||
* **Supported versions:** v1 and v2.
|
||||
* **Supported versions:** v1, v2, and v3.
|
||||
*
|
||||
* v1 (legacy, pre-2026-04-11):
|
||||
* ```json
|
||||
@@ -106,19 +109,47 @@ import kotlin.math.max
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* v3 (multi-endpoint pairing, 2026-04-19 — ADR 24):
|
||||
* ```json
|
||||
* {
|
||||
* "hermes": 3,
|
||||
* "host": "192.168.1.100",
|
||||
* "port": 8642,
|
||||
* "key": "optional-api-key",
|
||||
* "tls": false,
|
||||
* "relay": { "url": "ws://192.168.1.100:8767", "code": "ABC123",
|
||||
* "ttl_seconds": 2592000, "grants": {...},
|
||||
* "transport_hint": "ws" },
|
||||
* "endpoints": [
|
||||
* { "role": "lan", "priority": 0,
|
||||
* "api": { "host": "192.168.1.100", "port": 8642, "tls": false },
|
||||
* "relay": { "url": "ws://192.168.1.100:8767", "transport_hint": "ws" } },
|
||||
* { "role": "tailscale", "priority": 1,
|
||||
* "api": { "host": "hermes.tail-scale.ts.net", "port": 8642, "tls": true },
|
||||
* "relay": { "url": "wss://hermes.tail-scale.ts.net:8767", "transport_hint": "wss" } }
|
||||
* ],
|
||||
* "sig": "base64-hmac-sha256"
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The top-level fields configure the direct-chat Hermes API server. The
|
||||
* optional [relay] block configures the Hermes-Relay WSS connection used by
|
||||
* the terminal and bridge channels.
|
||||
* the terminal and bridge channels. The [endpoints] list (v3+) carries an
|
||||
* ordered array of candidate endpoints; the phone picks the highest-priority
|
||||
* reachable candidate at connect time — see ADR 24.
|
||||
*
|
||||
* **Forward/backward compatibility:**
|
||||
* - `hermes` now has a default of `1` so v1 QRs without the field parse.
|
||||
* - `sig` is captured but **not verified** — we don't have the server's
|
||||
* HMAC secret. Stored for future verification and for operator audit.
|
||||
* TODO: once the server exposes a pairing public key, verify.
|
||||
* - Unknown fields are tolerated via `ignoreUnknownKeys = true`. v3+ QRs
|
||||
* - Unknown fields are tolerated via `ignoreUnknownKeys = true`. v4+ QRs
|
||||
* will still parse on this phone.
|
||||
* - The `ttl_seconds`, `grants`, and `transport_hint` fields on [RelayPairing]
|
||||
* are nullable so v1 QRs with only `url` + `code` still deserialize.
|
||||
* - [endpoints] is nullable — v1/v2 QRs without the field still parse, and
|
||||
* [parseHermesPairingQr] synthesizes a single priority-0 candidate from
|
||||
* the top-level fields so downstream code always has at least one entry.
|
||||
*
|
||||
* Old QRs without the relay block still parse cleanly because the field is
|
||||
* nullable.
|
||||
@@ -132,6 +163,13 @@ data class HermesPairingPayload(
|
||||
val tls: Boolean = false,
|
||||
val relay: RelayPairing? = null,
|
||||
val sig: String? = null,
|
||||
/**
|
||||
* Optional ordered list of endpoint candidates (v3+). Present verbatim
|
||||
* when the payload carried one; synthesized by [parseHermesPairingQr]
|
||||
* from the top-level fields for v1/v2 payloads so callers can always
|
||||
* assume this is non-null + non-empty after parse.
|
||||
*/
|
||||
val endpoints: List<EndpointCandidate>? = null,
|
||||
) {
|
||||
/** Build the full API server URL from host, port, and tls flag. */
|
||||
val serverUrl: String
|
||||
@@ -181,14 +219,23 @@ private val json = Json {
|
||||
/**
|
||||
* Try to parse a scanned string as a Hermes pairing QR payload.
|
||||
*
|
||||
* Accepts both v1 and v2 (or anything without a `hermes` field — we default
|
||||
* Accepts v1, v2, and v3 (or anything without a `hermes` field — we default
|
||||
* to `1`). Returns null when the payload is not valid JSON, has no `host`
|
||||
* field, or fails strict decoding.
|
||||
*
|
||||
* **Endpoint synthesis (ADR 24):** when the payload has no `endpoints`
|
||||
* array (v1/v2 QRs), a single priority-0 [EndpointCandidate] is materialized
|
||||
* from the top-level fields so downstream code can always iterate
|
||||
* `payload.endpoints`. The synthesized `role` is `"tailscale"` when the
|
||||
* top-level [HermesPairingPayload.host] matches the Tailscale CGNAT /
|
||||
* MagicDNS heuristic (`.ts.net` suffix or `100.` prefix), `"lan"` otherwise.
|
||||
* A v3+ payload with an explicit `endpoints` array round-trips verbatim —
|
||||
* role case, priority order, and unknown roles are all preserved.
|
||||
*/
|
||||
fun parseHermesPairingQr(raw: String): HermesPairingPayload? {
|
||||
return try {
|
||||
// Quick check: must contain a `host` field and be valid JSON. We no
|
||||
// longer reject based on the `hermes` version int — future v3+ QRs
|
||||
// longer reject based on the `hermes` version int — future v4+ QRs
|
||||
// should still parse on this phone so Bailey doesn't have to ship a
|
||||
// whole release to keep up with wire-format growth.
|
||||
val obj = json.decodeFromString<JsonObject>(raw)
|
||||
@@ -203,12 +250,52 @@ fun parseHermesPairingQr(raw: String): HermesPairingPayload? {
|
||||
// unsigned payloads — the phone has no way to fetch the server's
|
||||
// secret in-band.
|
||||
|
||||
decoded
|
||||
// Synthesize a single priority-0 candidate from the top-level fields
|
||||
// when the wire payload didn't carry an explicit `endpoints` array.
|
||||
// v3+ payloads with an explicit array pass through untouched.
|
||||
if (decoded.endpoints.isNullOrEmpty()) {
|
||||
decoded.copy(endpoints = listOf(synthesizeLegacyEndpoint(decoded)))
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single priority-0 [EndpointCandidate] from a v1/v2 pairing payload
|
||||
* that lacked an `endpoints` array. Preserves the top-level API coordinates
|
||||
* + the optional relay block (the synthesized candidate's [RelayEndpoint]
|
||||
* intentionally drops `code` / `grants` / `ttl_seconds` — those stay on the
|
||||
* top-level [RelayPairing]).
|
||||
*
|
||||
* Role detection: matches [TailscaleDetector]'s heuristic — `.ts.net` suffix
|
||||
* or `100.`-prefixed IPv4 (CGNAT range 100.64.0.0/10 is the canonical one,
|
||||
* but the broader `100.*` check keeps us tolerant of operator labeling).
|
||||
* Inlined here so this pure-parse code has no Android Context dependency
|
||||
* and stays unit-testable on the JVM.
|
||||
*/
|
||||
private fun synthesizeLegacyEndpoint(payload: HermesPairingPayload): EndpointCandidate {
|
||||
val host = payload.host
|
||||
val isTailscale = host.endsWith(".ts.net", ignoreCase = true) ||
|
||||
host.startsWith("100.")
|
||||
val role = if (isTailscale) "tailscale" else "lan"
|
||||
return EndpointCandidate(
|
||||
role = role,
|
||||
priority = 0,
|
||||
api = ApiEndpoint(
|
||||
host = payload.host,
|
||||
port = payload.port,
|
||||
tls = payload.tls,
|
||||
),
|
||||
relay = RelayEndpoint(
|
||||
url = payload.relay?.url ?: "",
|
||||
transportHint = payload.relay?.transportHint,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A bounding rect in *viewport* pixel coordinates (top-left origin), produced
|
||||
* by mapping a barcode's image-space bounding box through the camera rotation
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user