Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40bb0a4ef8 | ||
|
|
d96898a6aa | ||
|
|
b4e595e320 | ||
|
|
31c41fb2ff | ||
|
|
ab0f7b726a | ||
|
|
f72904ab53 | ||
|
|
4fc5f668de | ||
|
|
cedc340091 | ||
|
|
97cb30c927 | ||
|
|
ed41be3390 | ||
|
|
08816cfe63 | ||
|
|
01a0cde589 | ||
|
|
ed6742afe4 | ||
|
|
a6264df910 | ||
|
|
64e2e2eca6 | ||
|
|
1ccaf2c4f1 | ||
|
|
7686bb41e7 | ||
|
|
a940b4b8ea | ||
|
|
46afdeab59 | ||
|
|
d4a8aad050 | ||
|
|
0a6e95ae74 | ||
|
|
a6fc53e5cf | ||
|
|
c013daacda | ||
|
|
f5b1d377a4 | ||
|
|
bb1e406f3f | ||
|
|
10213ca8ed | ||
|
|
cbfccd8ccf | ||
|
|
c3c98caa31 | ||
|
|
ed60abd57c | ||
|
|
cab0d90530 | ||
|
|
d977600f9d | ||
|
|
c902c00101 | ||
|
|
aa6b48a068 | ||
|
|
50297d1496 | ||
|
|
d80f36a087 | ||
|
|
b6117c2d41 | ||
|
|
b0ee6935fe | ||
|
|
33538fde0c | ||
|
|
603919c8ff | ||
|
|
52df3adbf6 | ||
|
|
3eab11c639 | ||
|
|
2673f228bb | ||
|
|
53b8f6a418 | ||
|
|
87cd9e7b9d | ||
|
|
51a020bd22 | ||
|
|
34ff4d0629 | ||
|
|
c452c25148 | ||
|
|
0db5c02722 | ||
|
|
4630695c17 | ||
|
|
f4ee440015 |
@@ -4,7 +4,7 @@ contact_links:
|
||||
url: https://github.com/Codename-11/hermes-relay/security/advisories/new
|
||||
about: Report privately via GitHub Security Advisories — do not open a public issue. See SECURITY.md for the full policy.
|
||||
- name: User documentation
|
||||
url: https://codename-11.github.io/hermes-relay/
|
||||
url: https://hermes-relay.dev/docs/
|
||||
about: Read setup, pairing, remote access, and troubleshooting docs.
|
||||
- name: Contributing guide
|
||||
url: https://github.com/Codename-11/hermes-relay/blob/main/CONTRIBUTING.md
|
||||
|
||||
@@ -25,7 +25,7 @@ otherwise use verified Co-authored-by trailers. Write "N/A" for original work.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Target branch is `dev` unless this is a release PR
|
||||
- [ ] Target branch is `dev`, unless this is a `dev` → `main` release PR or a focused production-tag hotfix PR to `main`
|
||||
- [ ] Android changes: lint and focused unit tests ran, or rationale is listed above
|
||||
- [ ] Translation changes: locale status/review references are accurate, `python scripts/check-android-locales.py` ran, and device/emulator review is documented, or N/A
|
||||
- [ ] Server changes: focused `python -m unittest ...` checks ran, or rationale is listed above
|
||||
|
||||
@@ -33,6 +33,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Test path classifier
|
||||
run: node .github/scripts/classify-ci-paths.test.cjs
|
||||
@@ -42,13 +44,11 @@ jobs:
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const paths = files.map((file) => file.filename);
|
||||
const { stdout } = await exec.getExecOutput(
|
||||
'git',
|
||||
['diff', '--name-only', 'HEAD^1', 'HEAD^2'],
|
||||
);
|
||||
const paths = stdout.split(/\r?\n/).filter(Boolean);
|
||||
const { classifyCiPaths } = require(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/classify-ci-paths.cjs`,
|
||||
);
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# Hermes-Relay — Docs Deployment
|
||||
#
|
||||
# Builds VitePress docs and deploys to GitHub Pages.
|
||||
# Triggers on pushes to main that change user-docs/ content,
|
||||
# or manually via workflow_dispatch.
|
||||
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'user-docs/**'
|
||||
- '.github/workflows/docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Allow only one concurrent deployment
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
# Sets permissions for GITHUB_TOKEN to enable Pages deployment
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # Full history for lastUpdated timestamps
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
# Node 24 ships npm 11, matching the npm that generates
|
||||
# user-docs/package-lock.json. On npm 10 (Node 20), `npm ci` rejects
|
||||
# the lock over the optional `search-insights` peer dep of bundled
|
||||
# docsearch. Keep this aligned with the npm used to write the lock.
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: user-docs/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Build VitePress site
|
||||
run: npm run build
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: user-docs/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -0,0 +1,89 @@
|
||||
name: Deploy legacy docs redirects
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "legacy-pages-redirect/**"
|
||||
- "website/public/privacy.html"
|
||||
- ".github/workflows/legacy-docs-redirect.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "legacy-pages-redirect/**"
|
||||
- "website/public/privacy.html"
|
||||
- ".github/workflows/legacy-docs-redirect.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: legacy-docs-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build redirect artifact
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Build redirect-only site
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_file="legacy-pages-redirect/redirect.html"
|
||||
privacy_file="website/public/privacy.html"
|
||||
output_dir="legacy-pages-redirect/_site"
|
||||
rm -rf "$output_dir"
|
||||
mkdir -p \
|
||||
"$output_dir/guide/getting-started" \
|
||||
"$output_dir/privacy" \
|
||||
"$output_dir/reference/relay-server" \
|
||||
"$output_dir/architecture"
|
||||
for target in \
|
||||
index.html \
|
||||
404.html \
|
||||
guide/getting-started.html \
|
||||
guide/getting-started/index.html \
|
||||
reference/relay-server.html \
|
||||
reference/relay-server/index.html \
|
||||
architecture/connection-security.html; do
|
||||
cp "$source_file" "$output_dir/$target"
|
||||
done
|
||||
cp "$privacy_file" "$output_dir/privacy.html"
|
||||
cp "$privacy_file" "$output_dir/privacy/index.html"
|
||||
touch "$output_dir/.nojekyll"
|
||||
test "$(find "$output_dir" -type f | wc -l)" -eq 10
|
||||
grep -Fq '<h1>Privacy Policy</h1>' "$output_dir/privacy.html"
|
||||
grep -Fq 'https://hermes-relay.dev/privacy.html' "$output_dir/privacy.html"
|
||||
if grep -R -E '<title>VitePress|<div id="app">' "$output_dir"; then
|
||||
echo "Full documentation content must not be deployed by this workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Configure Pages
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload redirect artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: legacy-pages-redirect/_site
|
||||
|
||||
deploy:
|
||||
name: Deploy redirect shim
|
||||
if: github.event_name != 'pull_request'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -81,6 +81,7 @@ jobs:
|
||||
- name: Validate release metadata and source compatibility
|
||||
run: |
|
||||
python3 scripts/check-version-tracks.py
|
||||
python3 scripts/check-privacy-policy.py --live
|
||||
python3 scripts/check-android-locales.py
|
||||
python3 scripts/check-android-collection-apis.py
|
||||
python3 -m json.tool app/src/main/assets/changelog.json >/dev/null
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Triggered when an Android release tag (android-v*) is pushed.
|
||||
# Validates the tag matches the app version in libs.versions.toml,
|
||||
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
|
||||
# GitHub Release. Plugin/Python package releases use plugin-v* tags.
|
||||
# GitHub Release. Server/Python package releases use server-v* tags.
|
||||
|
||||
name: Release Android
|
||||
|
||||
@@ -35,6 +35,8 @@ jobs:
|
||||
version_code: ${{ steps.version.outputs.version_code }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
@@ -65,9 +67,26 @@ jobs:
|
||||
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no release heading for $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Version validated: $TAG_VERSION"
|
||||
|
||||
- name: Verify public privacy policy URLs
|
||||
run: python3 scripts/check-privacy-policy.py --live
|
||||
|
||||
- name: Verify tagged commit belongs to main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin main --no-tags
|
||||
tag_commit="$(git rev-parse HEAD)"
|
||||
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
|
||||
echo "Android releases must be tagged from main; $tag_commit is not in origin/main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Require successful Play preflight for this exact release tree
|
||||
if: ${{ !contains(steps.version.outputs.version, '-') }}
|
||||
env:
|
||||
@@ -107,6 +126,7 @@ jobs:
|
||||
- name: Validate release metadata and Android API compatibility
|
||||
run: |
|
||||
python3 scripts/check-version-tracks.py
|
||||
python3 scripts/check-privacy-policy.py
|
||||
python3 scripts/check-android-locales.py
|
||||
python3 scripts/check-android-collection-apis.py
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: Release CLI
|
||||
name: Release Desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['cli-v*']
|
||||
tags: ['desktop-v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -36,13 +36,17 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#cli-v}"
|
||||
version="${GITHUB_REF_NAME#desktop-v}"
|
||||
if [[ -z "$version" || "$version" == "$GITHUB_REF_NAME" ]]; then
|
||||
echo "Expected a cli-v* tag, got $GITHUB_REF_NAME" >&2
|
||||
echo "Expected a desktop-v* tag, got $GITHUB_REF_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
npm run check:version-sync -- --expect "$version"
|
||||
if ! grep -Fq "## [$version]" ../CHANGELOG.md; then
|
||||
echo "CHANGELOG.md has no release heading for $version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify tagged commit belongs to main
|
||||
shell: bash
|
||||
@@ -52,7 +56,7 @@ jobs:
|
||||
git fetch origin main --no-tags
|
||||
tag_commit="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")"
|
||||
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
|
||||
echo "CLI releases must be tagged from main; $tag_commit is not in origin/main" >&2
|
||||
echo "Desktop releases must be tagged from main; $tag_commit is not in origin/main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -234,9 +238,9 @@ jobs:
|
||||
# (the other publish-release steps only consume downloaded build artifacts).
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract CLI version
|
||||
- name: Extract Desktop version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#cli-v}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -253,7 +257,7 @@ jobs:
|
||||
|
||||
# Render CLI_RELEASE_NOTES.md (hand-written per release) into the GitHub
|
||||
# Release body. __VERSION__ = bare version (0.3.0), __TAG__ = full tag
|
||||
# (cli-v0.3.0) so the install/pin commands stay accurate without manual edits.
|
||||
# (desktop-v0.3.0) so install/pin commands stay accurate without manual edits.
|
||||
- name: Render release notes
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
@@ -266,7 +270,7 @@ jobs:
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay-CLI v${{ steps.version.outputs.version }}
|
||||
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') }}
|
||||
|
||||
@@ -1,32 +1,49 @@
|
||||
name: Release Plugin
|
||||
name: Release Server
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "plugin-v*"
|
||||
- "server-v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Plugin release
|
||||
name: Validate Server release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/plugin-v}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "version=${GITHUB_REF#refs/tags/server-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify Plugin version sync
|
||||
run: python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
|
||||
- name: Verify Server version sync and changelog
|
||||
run: |
|
||||
python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
|
||||
if ! grep -Fq "## [$TAG_VERSION]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no release heading for $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
TAG_VERSION: ${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Verify tagged commit belongs to main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin main --no-tags
|
||||
tag_commit="$(git rev-parse HEAD)"
|
||||
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
|
||||
echo "Server releases must be tagged from main; $tag_commit is not in origin/main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test:
|
||||
name: Test Plugin package
|
||||
needs: validate
|
||||
@@ -100,8 +117,8 @@ jobs:
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay-Plugin v${{ needs.validate.outputs.version }}
|
||||
tag_name: plugin-v${{ needs.validate.outputs.version }}
|
||||
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_path: release_notes_rendered.md
|
||||
|
||||
@@ -93,3 +93,5 @@ keystore.properties
|
||||
|
||||
# Legacy generated desktop tray assets may remain after upgrading a worktree.
|
||||
desktop/tray/ui/vendor/
|
||||
# Generated from assets/screenshots/02_chat.png before docs dev/build.
|
||||
/user-docs/public/chat-demo.png
|
||||
|
||||
@@ -5,16 +5,35 @@ coding agent (Claude Code, Codex, Cursor, etc.).
|
||||
|
||||
## Read this first
|
||||
|
||||
The detailed, authoritative context lives in **[CLAUDE.md](CLAUDE.md)** —
|
||||
architecture, the upstream Hermes API reference, repository layout, per-language
|
||||
code style, the dev loop, and the Key Files map. Read it before touching code,
|
||||
then `docs/spec.md` and `docs/decisions.md`.
|
||||
This file is the provider-neutral canonical agent context. Read it before
|
||||
touching code, then `docs/spec.md` and `docs/decisions.md`. Provider adapters
|
||||
such as **[CLAUDE.md](CLAUDE.md)** may add tool-specific guidance, but they do
|
||||
not redefine the branch, release, or hotfix policy here and in `RELEASE.md`.
|
||||
|
||||
- Release process → **[RELEASE.md](RELEASE.md)**
|
||||
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
|
||||
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.md)**
|
||||
- Follow-ups / deferred work / known gaps → **[TODO.md](TODO.md)** (the single home for "what's next" — never DEVLOG, never scattered code comments)
|
||||
|
||||
## Branch contract
|
||||
|
||||
| Contract item | Canonical source or target |
|
||||
|---|---|
|
||||
| Integration branch | `dev`; normal feature, fix, docs, and chore PRs target `dev` |
|
||||
| Release branch | `main`; release history and hotfix integration only |
|
||||
| Tag source | The new `main` tip after an approved `dev` → `main` release PR, or after an approved hotfix PR to `main` |
|
||||
| Staging source | An exact tested `dev` SHA or release-candidate tag; staging is an environment, never a branch |
|
||||
| Production source | Immutable `android-v*`, `server-v*`, or `desktop-v*` tags, selected by surface |
|
||||
| Hotfix base | The immutable production tag for the affected surface |
|
||||
| Back-merge target | `dev`; merge `main` back immediately after every hotfix |
|
||||
|
||||
Feature completion means merged and verified on `dev`; it does not mean
|
||||
released. A release train is separate work owned by a Forge release
|
||||
issue/session: reconcile only the affected surface version and notes on `dev`,
|
||||
open the `dev` → `main` release PR, tag the resulting `main` tip, publish the
|
||||
surface artifacts, deploy or roll out, and verify the live result. Never create
|
||||
a staging branch.
|
||||
|
||||
## Non-negotiables (the short list)
|
||||
|
||||
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection —
|
||||
@@ -23,8 +42,10 @@ then `docs/spec.md` and `docs/decisions.md`.
|
||||
PRs or the optional relay plugin, never fork patches.
|
||||
- **Verify endpoints against upstream** (`gateway/platforms/api_server.py` /
|
||||
`tui_gateway/server.py` in hermes-agent) before assuming a route exists.
|
||||
- **Conventional Commits + `main`/`dev` branching.** Feature branches off `dev`,
|
||||
`--no-ff` merges, version bumps at release-prep on `dev`, tags cut from `main`.
|
||||
- **Conventional Commits + `main`/`dev` branching.** Normal branches start at
|
||||
`dev` and PR back to `dev`; merge commits/no-ff are the repository policy.
|
||||
Version bumps happen only during release preparation on `dev`, and production
|
||||
tags are cut only from `main`.
|
||||
- **Android:** Jetpack Compose only (no XML), kotlinx.serialization (no Gson),
|
||||
OkHttp (no Ktor), `wss://` only. Run `./gradlew lint` before pushing Kotlin.
|
||||
- **Plugin (Python 3.11+):** aiohttp + asyncio (no threading), type hints
|
||||
|
||||
@@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Relay trust boundaries are enforced across privileged interfaces.** Pairing policy is host-authorized, Android bridge and terminal dispatch require active grants, ordinary sessions can only reduce their own policy, remote profile config is restricted to a public schema, and voice callers cannot redirect host provider credentials.
|
||||
|
||||
## [1.4.8] - 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The Google Play privacy-policy URL is permanently available.** The canonical policy now lives on hermes-relay.dev, the historical GitHub Pages URL serves the complete policy for compatibility, and Android release automation blocks publication if either public page is unavailable.
|
||||
- **Android opens the hosted privacy policy directly.** The About screen no longer sends users to a repository source file.
|
||||
|
||||
## [1.4.7] - 2026-07-18
|
||||
|
||||
### Added
|
||||
|
||||
- **Android adds German, Brazilian Portuguese, and Japanese.** Complete AI-assisted catalogs cover both product flavors, with language-picker integration and freshness validation against the canonical English resources.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Long streamed replies grow smoothly and remain at the latest text.** Android frame-paces bursty token delivery, expands the active bubble within clipped bounds, preserves bottom-following through completion, and avoids replacing the visible live transcript while readers who intentionally scroll up remain undisturbed.
|
||||
|
||||
## [Android 1.4.6] - 2026-07-15
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Hermes-Relay — Claude Code Context
|
||||
# Hermes-Relay — Claude Code Adapter
|
||||
|
||||
> Read this before touching code. Then read docs/spec.md and docs/decisions.md.
|
||||
> Read [AGENTS.md](AGENTS.md) first. It is the provider-neutral canonical agent
|
||||
> context. Branch, release, staging, and hotfix rules live in `AGENTS.md` and
|
||||
> [RELEASE.md](RELEASE.md); this file only adds Claude-specific project and tool
|
||||
> guidance. Then read `docs/spec.md` and `docs/decisions.md`.
|
||||
|
||||
## What This Is
|
||||
|
||||
@@ -183,18 +186,16 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
### Git
|
||||
|
||||
- **Conventional Commits:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
|
||||
- **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-plugin-version.sh` for `plugin-vX.Y.Z`, and `desktop/package.json` for `cli-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.
|
||||
- **Branch/release policy:** follow the branch-contract table in `AGENTS.md` and
|
||||
the executable release and hotfix procedures in `RELEASE.md`. Do not maintain
|
||||
a Claude-specific parallel policy here.
|
||||
|
||||
### 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 is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-plugin.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.
|
||||
- **CI and release gates:** follow the repository-wide requirements in
|
||||
`AGENTS.md` and `RELEASE.md`; Claude-specific guidance does not redefine them.
|
||||
|
||||
## Key Files
|
||||
|
||||
@@ -406,7 +407,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 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. Android CI runs lint alongside build/test for faster feedback, but a local lint run still surfaces issues before the workflow spends runner time compiling and packaging.
|
||||
5. **Commit + push** — feature branch off `dev`, merged back to `dev` via PR. `main` is reserved for release merges.
|
||||
5. **Commit + push** — follow `AGENTS.md` and `RELEASE.md`; normal work PRs to `dev`.
|
||||
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`.
|
||||
|
||||
@@ -455,15 +456,10 @@ must not depend on this hook.
|
||||
|
||||
### Release Process
|
||||
|
||||
See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
|
||||
- **Android version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`); bump with `scripts/bump-android-version.sh`
|
||||
- **Relay plugin version source:** `pyproject.toml`; keep plugin/dashboard metadata synced with `scripts/check-plugin-version-sync.py`; bump with `scripts/bump-plugin-version.sh`
|
||||
- **Desktop CLI version source:** `desktop/package.json`; regenerate `desktop/src/version.ts` with `npm run gen:version`
|
||||
- **Track audit:** `python scripts/check-version-tracks.py` reports Android, plugin, and CLI versions without forcing them to match
|
||||
- `**appVersionCode` is monotonic** — always increment across Android prereleases
|
||||
- **Cut a release:** bump the target surface → commit → merge `dev` to `main` → tag with `android-v*`, `plugin-v*`, or `cli-v*` → push tag → CI builds + GitHub Release
|
||||
- **Required secrets:** `HERMES_KEYSTORE_BASE64`, `HERMES_KEYSTORE_PASSWORD`, `HERMES_KEY_ALIAS`, `HERMES_KEY_PASSWORD`
|
||||
See [AGENTS.md](AGENTS.md) for the canonical branch contract and
|
||||
[RELEASE.md](RELEASE.md) for version sources, release trains, surface tags,
|
||||
hotfixes, secrets, publishing, and verification. Claude-specific automation
|
||||
must not infer release authority from feature completion.
|
||||
|
||||
## Integration Points
|
||||
|
||||
|
||||
@@ -60,4 +60,4 @@ hermes-relay daemon status
|
||||
|
||||
On Windows, open **Hermes Relay Systray** from the Start menu and right-click its notification-area icon. No separate desktop window is installed.
|
||||
|
||||
See the [CLI and systray guide](https://codename-11.github.io/hermes-relay/desktop/) for installation, commands, desktop-use safety, and troubleshooting.
|
||||
See the [CLI and systray guide](https://hermes-relay.dev/docs/desktop/) for installation, commands, desktop-use safety, and troubleshooting.
|
||||
|
||||
+12
-2
@@ -92,9 +92,19 @@ 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:`.
|
||||
|
||||
**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`.
|
||||
**Branching model: `main` + `dev`.** Feature branches — `feature/<name>`,
|
||||
`fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back
|
||||
into `dev` via merge-commit/no-ff PRs. This includes small documentation fixes.
|
||||
`main` is release history, not the normal contribution target; it receives
|
||||
approved release PRs from `dev` and focused hotfix PRs based on production tags.
|
||||
|
||||
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`, `plugin-vX.Y.Z`, or `cli-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
|
||||
Feature completion means merged and verified on `dev`; it does not mean the
|
||||
change has been released. A separate Forge release issue/session owns release
|
||||
preparation, the `dev` → `main` release PR, tagging, artifacts, rollout or
|
||||
deployment, and live verification. Release-prep commits land on `dev`; tags are
|
||||
cut from the resulting `main` tip as `android-vX.Y.Z`, `server-vX.Y.Z`, or
|
||||
`desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release and hotfix
|
||||
procedures.
|
||||
|
||||
## Stale PR salvage and contributor credit
|
||||
|
||||
|
||||
@@ -1,5 +1,170 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-07-18 — Android privacy-policy URL hotfix
|
||||
|
||||
The canonical Android privacy policy moved to a stable public page on
|
||||
hermes-relay.dev. The historical GitHub Pages path now serves the complete
|
||||
policy for compatibility with existing store metadata instead of depending on
|
||||
a client-side redirect. Android's About screen, public privacy references, and
|
||||
Play submission documentation use the canonical site URL.
|
||||
|
||||
The legacy Pages workflow publishes both file and directory policy paths from
|
||||
the same canonical HTML source. Repository validation checks the policy's
|
||||
required disclosures and URL wiring, while Play preflight and stable tag release
|
||||
jobs additionally require both deployed policy URLs to return complete policy
|
||||
content before an artifact can be uploaded or promoted. Android advanced to
|
||||
1.4.8 with versionCode 31 for the replacement Play submission.
|
||||
|
||||
## 2026-07-18 — Android 1.4.7 release preparation
|
||||
|
||||
Android advanced to 1.4.7 with versionCode 30 after the localization, streaming,
|
||||
release-history, and branch-contract reconciliation landed on `dev`. The public
|
||||
changelog, GitHub release body, in-app What's New surfaces, Play metadata, and
|
||||
store-listing copy now describe the Android-only patch while the unreleased Relay
|
||||
security work remains assigned to its independent server release track.
|
||||
|
||||
## 2026-07-17 — Smooth streamed-reply rendering and finalization
|
||||
|
||||
Uninterrupted Gateway turns treat their structured live assistant, reasoning, and
|
||||
tool events as authoritative instead of immediately republishing the transcript
|
||||
through a full history read. Rejoined sockets, Sessions SSE, detached turns,
|
||||
profile-aware resume, errors, and missing-data recovery retain their required
|
||||
authoritative reconciliation paths.
|
||||
|
||||
Bursty provider deltas now enter a main-thread frame pacer that publishes adaptive
|
||||
UTF-16-safe slices at a display-sized cadence. The visible live tail uses one stable
|
||||
plain-text node, ignores leading blank transport lines, and expands inside a short
|
||||
clipped size animation. Tool, thinking, completion, cancellation, and error
|
||||
boundaries still flush buffered content immediately and preserve event order.
|
||||
|
||||
Bottom-following is driven by stable row identity, real drag interactions, measured
|
||||
positive tail growth, and structural anchors. The active response retains its live
|
||||
renderer through completion, settles the exact footer for two frames, and releases
|
||||
to full Markdown after another row becomes the tail or the session is revisited.
|
||||
This prevents both the completion-time top snap and the transient scroll-to-bottom
|
||||
button without interrupting readers who intentionally move into history.
|
||||
|
||||
Focused stream-pacing, Unicode-boundary, leading-whitespace, Gateway reconnect,
|
||||
completion-policy, and scroll regressions passed. Repeated sideload builds and live
|
||||
phone tests verified smooth following, stable completion, exact-bottom settling,
|
||||
frame-paced text insertion, and clipped bubble growth.
|
||||
|
||||
## 2026-07-17 — Stable chat rows across post-turn history reconciliation
|
||||
|
||||
Android chat now separates the stable Compose identity of a visible message row
|
||||
from its authoritative server message ID. The post-turn history reconcile can
|
||||
adopt persisted IDs and rebuild message boundaries without making LazyColumn
|
||||
remove and reinsert the long answer currently anchoring the viewport.
|
||||
|
||||
Regression coverage exercises both the same-count user/assistant ID adoption
|
||||
and a list-expansion reconcile that inserts persisted rows around a matched live
|
||||
tail. The focused ChatHandler and scroll-snapshot unit tests passed.
|
||||
|
||||
## 2026-07-16 — Stable chat position after stream completion
|
||||
|
||||
Android chat now observes the assistant message identity and the streaming-to-final
|
||||
transition as conversation-tail changes. When a reader is already following the
|
||||
response, completion performs an instant multi-frame bottom settle after the
|
||||
streaming renderer is replaced by the final Markdown layout. The existing
|
||||
user-scroll gate remains authoritative, so reading older messages is not
|
||||
interrupted.
|
||||
|
||||
Focused snapshot regression coverage verifies completion detection, ordinary
|
||||
stream growth, stream startup, and server message-ID reconciliation.
|
||||
|
||||
## 2026-07-16 — Critical Relay authorization hardening
|
||||
|
||||
Relay privileged interfaces now enforce host-authorized policy at every shared
|
||||
dispatch boundary. Anonymous pairing-code minting was removed; pairing clients
|
||||
can no longer choose session lifetime or grants; Android bridge HTTP routes and
|
||||
terminal messages require live sessions with active route grants; ordinary
|
||||
session bearers can only reduce their own lifetime and existing grants; remote
|
||||
profile config reads expose an explicit public schema without host paths; and
|
||||
voice requests cannot override credential-bearing provider origins.
|
||||
|
||||
Six bounded exploit harnesses stopped at the restored boundaries. The combined
|
||||
security regression set passed 96 tests, Python compilation passed, Ruff passed
|
||||
for changed modules and tests apart from the pre-existing unused `signal`
|
||||
import in `server.py`, and `git diff --check` passed. The broad plugin
|
||||
discovery run progressed through unrelated suites but was interrupted by the
|
||||
existing Windows async-suite `KeyboardInterrupt` behavior, so the focused
|
||||
security and neighboring route suites remain the authoritative local result.
|
||||
The required-check path classifier now reads changed paths from the checked-out
|
||||
PR merge commit instead of GitHub's PR-files API, so an API outage cannot skip
|
||||
every surface check.
|
||||
|
||||
## 2026-07-16 — Fix production docs asset context
|
||||
|
||||
Updated the production docs Docker stage to build from the same full repository
|
||||
checkout used by CI rather than a hand-maintained file allowlist. This supplies
|
||||
the canonical screenshot manifest, localization registry and validators, route
|
||||
contract source, version metadata, and preview renderer without creating
|
||||
Docker-only `ENOENT` failures when those build inputs grow. Both Node build
|
||||
stages now install Python 3 so the localized website and docs validators run
|
||||
inside the production image build as they do in CI.
|
||||
|
||||
## 2026-07-16 — Localized marketing site
|
||||
|
||||
The Astro product site now publishes German, Spanish, Japanese, Brazilian
|
||||
Portuguese, and Simplified Chinese routes from one typed copy contract. Each
|
||||
route localizes marketing copy, navigation, accessibility labels, metadata, and
|
||||
links into the matching first-run documentation while retaining canonical
|
||||
screenshots, command examples, and UI recreations as shipped-product evidence.
|
||||
|
||||
Locale-aware canonical URLs, alternate-language links, Open Graph locale data,
|
||||
structured-data language, sitemap entries, and a responsive language selector
|
||||
were added. The localization registry records English-source freshness, and the
|
||||
website development and build commands reject missing or stale translations.
|
||||
Astro diagnostics, deterministic asset checks, the six-page production build,
|
||||
built-site validation, desktop/mobile browser checks, and `git diff --check`
|
||||
passed.
|
||||
|
||||
## 2026-07-16 — Temporary redirect shim for pre-migration Android builds
|
||||
|
||||
Restored GitHub Pages only as a redirect-only compatibility endpoint for app
|
||||
versions that still open `https://codename-11.github.io/hermes-relay/`. The
|
||||
shim preserves known paths, query strings, and fragments while forwarding to
|
||||
`https://hermes-relay.dev/docs/`; it does not publish the VitePress site.
|
||||
The production Nginx configuration resolves VitePress clean URLs to their
|
||||
`.html` artifacts so both legacy and corrected in-app links reach real pages.
|
||||
Removal criteria and the operator review date are tracked in `TODO.md`.
|
||||
|
||||
## 2026-07-15 — German, Brazilian Portuguese, and Japanese localization
|
||||
|
||||
Android now includes complete German, Brazilian Portuguese, and Japanese
|
||||
catalogs across the Google Play and sideload flavors. German and Brazilian
|
||||
Portuguese were recovered from unfinished translation drafts and refreshed
|
||||
against the current English resource contract; Japanese was generated through
|
||||
the same deterministic translation harness. The in-app picker, Android locale
|
||||
configuration, localization registry, contributor references, and user-facing
|
||||
language lists now describe the expanded set consistently.
|
||||
|
||||
The catalogs remain marked as AI-translated until fluent review is recorded.
|
||||
Structural validation covers resource parity, placeholders, plurals, arrays,
|
||||
formatting flags, XML parsing, and canonical source hashes. The 10-catalog
|
||||
validator, five focused `AppLanguageTest` cases, both flavor Kotlin/resource
|
||||
compilations, sideload debug lint, and `git diff --check` passed.
|
||||
|
||||
## 2026-07-15 — Retire GitHub Pages and move docs to hermes-relay.dev
|
||||
|
||||
Disabled and removed the GitHub Pages deployment path, changed the repository
|
||||
homepage to `https://hermes-relay.dev`, and moved the existing VitePress guide
|
||||
to `https://hermes-relay.dev/docs/` inside the production Coolify image. Active
|
||||
README, website, Android, release-note, and pet-schema links now target the new
|
||||
docs origin while historical DEVLOG entries remain unchanged.
|
||||
|
||||
## 2026-07-15 — Coolify root-context website deployment hotfix
|
||||
|
||||
Added a repository-owned multi-stage Dockerfile for the Astro marketing site
|
||||
and corrected its Coolify instructions. Production builds now keep the
|
||||
repository root as Docker context, run the existing `build:production` gate
|
||||
from `website/`, and serve the generated static output with Nginx. This keeps
|
||||
the site's canonical screenshot comparison against `docs/media/` intact while
|
||||
avoiding Nixpacks' incorrect Android/Gradle provider selection at monorepo root.
|
||||
|
||||
Verification: local website checks, production build, link validation, Docker
|
||||
image build, and Nginx-served smoke checks passed before deployment.
|
||||
|
||||
## 2026-07-15 — Android 1.4.6 and Plugin 1.4.2 release preparation
|
||||
|
||||
The profile-continuity and profile-image work was prepared as a two-surface
|
||||
@@ -52,6 +217,60 @@ Verification: seven profile-avatar endpoint tests and the focused Android host
|
||||
avatar client plus profile-controller suites passed. Android lint and final diff
|
||||
checks are recorded with the completed work.
|
||||
|
||||
## 2026-07-15 — Repository branch, release, and hotfix contract reconciliation
|
||||
|
||||
Repository guidance now has one provider-neutral branch contract in `AGENTS.md`:
|
||||
normal work, including documentation, branches from and returns to `dev`; release
|
||||
preparation happens on `dev`; approved release PRs merge `dev` to `main`; and
|
||||
immutable surface tags are cut from the new `main` tip. Staging is an environment
|
||||
sourced from an exact tested SHA or release-candidate tag. Production uses
|
||||
`android-v*`, `server-v*`, or `desktop-v*`. Hotfixes branch from the affected
|
||||
production tag, change and patch-bump only that surface, merge to `main`, tag,
|
||||
verify, and immediately merge `main` back into `dev`.
|
||||
|
||||
Stable release workflows now require the tagged commit to be contained in
|
||||
`main`, require the tag to match the authoritative surface version source, and
|
||||
require a matching `CHANGELOG.md` release heading before any build or publish
|
||||
job. Server and Desktop use the canonical `server-v*` and `desktop-v*` prefixes;
|
||||
runtime update discovery retains fallback support for immutable historical
|
||||
`plugin-v*` and `cli-v*` releases. No historical tag was moved or rewritten.
|
||||
|
||||
Audit inventory:
|
||||
|
||||
- Canonical/root guidance: `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`,
|
||||
`RELEASE.md`, `README.md`, `DEVLOG.md`, `PLUGIN_RELEASE_NOTES.md`, and
|
||||
`CLI_RELEASE_NOTES.md`.
|
||||
- Contributor metadata: `.github/PULL_REQUEST_TEMPLATE.md`; every file in
|
||||
`.github/ISSUE_TEMPLATE/` (`bug_report.yml`, `config.yml`, `docs.yml`,
|
||||
`feature_request.yml`, and `translation.yml`); `.github/copilot-instructions.md`;
|
||||
and `.github/dependabot.yml`.
|
||||
- GitHub workflows: `approve-release-android.yml`, `ci-android.yml`,
|
||||
`ci-contract.yml`, `ci-dashboard.yml`, `ci-desktop.yml`, `ci-plugin.yml`,
|
||||
`ci-required.yml`, `dependabot-auto-merge.yml`, `docs.yml`, `issue-triage.yml`,
|
||||
`play-listing.yml`, `play-preflight-android.yml`, `release-android.yml`,
|
||||
`release-cli.yml`, and `release-plugin.yml`.
|
||||
- Developer documentation: every Markdown file directly under `docs/`, plus
|
||||
`docs/audits/`, `docs/diagrams/`, and `docs/mockups/`. Historical files under
|
||||
`docs/plans/` were inspected for classification but not rewritten as current
|
||||
instructions. Current contradictions were corrected in `docs/decisions.md`
|
||||
and `docs/worktree-workflow.md`.
|
||||
- Public documentation: every Markdown file under `user-docs/`, including the
|
||||
architecture, desktop, features, guide, reference, and `zh-CN` trees. Current
|
||||
tag guidance was corrected in `user-docs/desktop/index.md` and
|
||||
`user-docs/desktop/installation.md`.
|
||||
- Release/runtime seams: all three release workflows; `scripts/bump-version.sh`,
|
||||
`scripts/bump-plugin-version.sh`, `scripts/check-version-tracks.py`, and
|
||||
`scripts/check-plugin-version-sync.py`; Desktop install/update sources under
|
||||
`desktop/scripts/` and `desktop/src/`; and Server update-discovery sources and
|
||||
focused tests under `plugin/`.
|
||||
|
||||
The repository audit also confirmed that GitHub-owned settings cannot be
|
||||
reconciled through repository files. The default branch correctly remained
|
||||
`main`, the release-history branch. At audit time, `dev` was unprotected, squash
|
||||
and rebase merges were enabled, and `main` protection did not apply to
|
||||
administrators. An operator must align those remaining settings with the
|
||||
documented contract.
|
||||
|
||||
## 2026-07-15 — Android 1.4.5 release and automated Play gate
|
||||
|
||||
Android 1.4.5 shipped as versionCode 28 after the signed release build, final
|
||||
|
||||
@@ -33,4 +33,4 @@ Pairs with Hermes-Relay-Android v1.4.6 for profile image import. Standard chat a
|
||||
|
||||
---
|
||||
|
||||
Tag prefixes: Android releases use android-v*, plugin releases use plugin-v*, and CLI releases use cli-v*.
|
||||
Tag prefixes: Android releases use android-v*, Server releases use server-v*, and Desktop releases use desktop-v*.
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a><br>
|
||||
<a href="https://codename-11.github.io/hermes-relay/">Documentation</a> ·
|
||||
<a href="https://hermes-relay.dev/docs/">Documentation</a> ·
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases">Releases</a> ·
|
||||
<a href="CHANGELOG.md">Changelog</a> ·
|
||||
<a href="https://hermes-agent.nousresearch.com">Hermes Agent</a>
|
||||
@@ -50,13 +50,13 @@ Install → connect → talk, in about two minutes.
|
||||
### 1 · Install the app
|
||||
|
||||
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, Manage, terminal/TUI, media, notifications, and relay sessions.
|
||||
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://hermes-relay.dev/docs/guide/getting-started.html#sideload-apk).
|
||||
|
||||
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
|
||||
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks) for the capability matrix.
|
||||
|
||||
### 2 · Have Hermes running
|
||||
|
||||
The app needs your Hermes **API server enabled and reachable from your phone**, plus an **API key** — the token the app sends to authenticate Chat (pick any value you like). Installing Hermes and choosing a provider is vanilla Hermes setup; the [full walkthrough](https://codename-11.github.io/hermes-relay/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
|
||||
The app needs your Hermes **API server enabled and reachable from your phone**, plus an **API key** — the token the app sends to authenticate Chat (pick any value you like). Installing Hermes and choosing a provider is vanilla Hermes setup; the [full walkthrough](https://hermes-relay.dev/docs/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
|
||||
|
||||
```bash
|
||||
hermes setup --portal # install / log in / pick a provider — skip if already done
|
||||
@@ -77,7 +77,7 @@ hermes gateway
|
||||
|
||||
`API_SERVER_ENABLED` turns the API server on; `API_SERVER_HOST=0.0.0.0` makes it reachable on your LAN (the default is localhost-only); `API_SERVER_KEY` is the bearer token the app sends — **your choice of value**.
|
||||
|
||||
> **Heads up on `0.0.0.0`:** that exposes the API to every device on your network — fine on a trusted home LAN, but off it keep the key set and front it with Tailscale or an HTTPS reverse proxy ([Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access)) rather than exposing it directly. You don't have to type the key on your phone — **Scan for Hermes on LAN**, or have your agent make a setup QR (below). For **Manage** (skills, models, keys), also run the Hermes dashboard — see [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
|
||||
> **Heads up on `0.0.0.0`:** that exposes the API to every device on your network — fine on a trusted home LAN, but off it keep the key set and front it with Tailscale or an HTTPS reverse proxy ([Remote access](https://hermes-relay.dev/docs/guide/remote-access)) rather than exposing it directly. You don't have to type the key on your phone — **Scan for Hermes on LAN**, or have your agent make a setup QR (below). For **Manage** (skills, models, keys), also run the Hermes dashboard — see [Getting Started](https://hermes-relay.dev/docs/guide/getting-started).
|
||||
|
||||
### 3 · Connect and talk
|
||||
|
||||
@@ -99,7 +99,7 @@ The wizard probes everything and finishes with a capability card:
|
||||
|
||||
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session unlocks voice. That's the whole Vanilla Hermes setup.
|
||||
|
||||
> **Going places?** Put your server's Tailscale URL in the setup form's *Remote access* field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access).
|
||||
> **Going places?** Put your server's Tailscale URL in the setup form's *Remote access* field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://hermes-relay.dev/docs/guide/remote-access).
|
||||
|
||||
### 4 · Optional: install Relay for power tools
|
||||
|
||||
@@ -162,12 +162,12 @@ Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-s
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
The Android app also ships a complete AI-assisted Spanish catalog. Choose
|
||||
**Español** from **Settings → Appearance → Language**; translation status and
|
||||
fluent review are tracked independently so community corrections remain easy
|
||||
to contribute.
|
||||
The Android app ships complete AI-assisted catalogs for **Deutsch**, **Español**,
|
||||
**日本語**, **Português (Brasil)**, and **简体中文**. Choose a language from
|
||||
**Settings → Appearance → Language**; translation status and fluent review are
|
||||
tracked independently so community corrections remain easy to contribute.
|
||||
|
||||
<p align="center"><sub>▶ <a href="https://codename-11.github.io/hermes-relay/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
|
||||
<p align="center"><sub>▶ <a href="https://hermes-relay.dev/docs/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
|
||||
|
||||
## Features
|
||||
|
||||
@@ -183,7 +183,7 @@ to contribute.
|
||||
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL.
|
||||
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts.
|
||||
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks).
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://hermes-relay.dev/docs/guide/release-tracks).
|
||||
|
||||
## Hands on any machine — the Hermes-Relay CLI <sub>(alpha)</sub>
|
||||
|
||||
@@ -201,11 +201,11 @@ hermes-relay daemon start # background tool router — agen
|
||||
hermes-relay update # self-update via GitHub Releases
|
||||
```
|
||||
|
||||
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on a separate `cli-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=cli), with old alpha prereleases still visible under `desktop-v*`.
|
||||
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on the `desktop-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=desktop), with historical releases still visible under `cli-v*`.
|
||||
|
||||
On Windows, the default installer adds the optional right-click-only systray: no dashboard or app window, just TUI launch, User/Administrator-aware daemon controls, pairing, local grant review, audit, diagnostics, logs, desktop-use status/cancellation, sign-in startup, and emergency stop.
|
||||
|
||||
- **Docs:** [CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **Docs:** [CLI guide](https://hermes-relay.dev/docs/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
|
||||
|
||||
## How It Works
|
||||
@@ -229,14 +229,14 @@ configure API, dashboard, and relay routes without merging their auth models.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Quick start, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
|
||||
| [Hermes-Relay CLI](https://codename-11.github.io/hermes-relay/desktop/) | 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 |
|
||||
| **[User Guide](https://hermes-relay.dev/docs/)** | **Quick start, features, configuration — start here** |
|
||||
| [Android](https://hermes-relay.dev/docs/guide/) | Android install + setup + features |
|
||||
| [Hermes-Relay CLI](https://hermes-relay.dev/docs/desktop/) | Pairing, subcommands, local tool routing |
|
||||
| [Architecture](https://hermes-relay.dev/docs/architecture/) | How the system works under the hood |
|
||||
| [API Reference](https://hermes-relay.dev/docs/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 (`android-v*`, `plugin-v*`, `cli-v*`) |
|
||||
| [Changelog](CHANGELOG.md) | Release history (`android-v*`, `server-v*`, `desktop-v*`; historical prefixes remain immutable) |
|
||||
|
||||
<details>
|
||||
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>简体中文</strong> · <a href="README.md">English</a><br>
|
||||
<a href="https://codename-11.github.io/hermes-relay/zh-CN/">中文文档</a> ·
|
||||
<a href="https://hermes-relay.dev/docs/zh-CN/">中文文档</a> ·
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases">版本下载</a> ·
|
||||
<a href="CHANGELOG.md">更新日志</a>
|
||||
</p>
|
||||
@@ -76,7 +76,7 @@ hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
完整说明请阅读[中文快速开始](https://codename-11.github.io/hermes-relay/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
|
||||
完整说明请阅读[中文快速开始](https://hermes-relay.dev/docs/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
|
||||
|
||||
## 中文界面
|
||||
|
||||
|
||||
+124
-65
@@ -13,23 +13,24 @@ 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. Public GitHub
|
||||
Release titles use product names (`Hermes-Relay-Android`,
|
||||
`Hermes-Relay-Plugin`, `Hermes-Relay-CLI`); tag prefixes stay short and stable
|
||||
for automation.
|
||||
Hermes-Relay ships three independently versioned production surfaces. Public
|
||||
GitHub Release titles use product names (`Hermes-Relay-Android`,
|
||||
`Hermes-Relay-Server`, `Hermes-Relay-Desktop`); immutable tag prefixes select
|
||||
the corresponding build and deployment lane.
|
||||
|
||||
| Surface | Tag prefix | Version source | Bump script | Release workflow |
|
||||
|---|---|---|---|---|
|
||||
| Hermes-Relay-Android | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
|
||||
| Hermes-Relay-Plugin | `plugin-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-plugin-version.sh` | `.github/workflows/release-plugin.yml` |
|
||||
| Hermes-Relay-CLI | `cli-v*` | `desktop/package.json` | `cd desktop && npm version --no-git-tag-version <version>` | `.github/workflows/release-cli.yml` |
|
||||
| Hermes-Relay-Server | `server-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-plugin-version.sh` | `.github/workflows/release-plugin.yml` |
|
||||
| Hermes-Relay-Desktop | `desktop-v*` | `desktop/package.json` | `cd desktop && npm version --no-git-tag-version <version>` | `.github/workflows/release-cli.yml` |
|
||||
|
||||
This split is intentional. The plugin carries relay features for both Android
|
||||
and CLI clients, so plugin fixes can ship without forcing an Android app
|
||||
`versionCode` bump, and CLI alphas can continue on their own cadence. Historical
|
||||
Android releases before this naming split used bare `v*` tags. Historical
|
||||
plugin/server releases used `relay-v*` tags, and historical CLI prereleases used
|
||||
`desktop-v*` tags. New releases use the explicit tag prefixes above.
|
||||
plugin/server releases used `relay-v*` and `plugin-v*` tags. Historical
|
||||
desktop/CLI releases also include `cli-v*` tags. Those tags remain immutable;
|
||||
new releases use the canonical prefixes above.
|
||||
|
||||
### Android app versioning
|
||||
|
||||
@@ -87,7 +88,7 @@ lockstep:
|
||||
| `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata |
|
||||
| `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata |
|
||||
|
||||
Always bump Plugin releases via:
|
||||
Always bump Server releases via:
|
||||
|
||||
```bash
|
||||
bash scripts/bump-plugin-version.sh 0.6.2
|
||||
@@ -105,23 +106,23 @@ Check all release tracks at once with:
|
||||
python scripts/check-version-tracks.py
|
||||
```
|
||||
|
||||
This aggregate check reports Android, plugin, and CLI versions
|
||||
This aggregate check reports Android, Server, and Desktop versions
|
||||
side by side and validates that each track's own source files are internally
|
||||
consistent. It deliberately does not require all three tracks to share the same
|
||||
SemVer.
|
||||
|
||||
The `plugin-v*` release workflow validates the tag against the same metadata,
|
||||
The `server-v*` release workflow validates the tag against the same metadata,
|
||||
runs plugin tests, builds a wheel and sdist, generates checksums, and
|
||||
publishes a `Hermes-Relay-Plugin vX.Y.Z` GitHub Release with the package
|
||||
publishes a `Hermes-Relay-Server vX.Y.Z` GitHub Release with the package
|
||||
artifacts.
|
||||
|
||||
### CLI / tray versioning
|
||||
|
||||
`desktop/package.json` is the CLI release track's source of truth. Its version
|
||||
`desktop/package.json` is the Desktop/CLI release track's source of truth. Its version
|
||||
must match the generated CLI and native Windows systray metadata. The systray is
|
||||
a menu-only controller for the installed CLI; it has no application window,
|
||||
WebView, embedded terminal, or separate desktop product surface. The public
|
||||
release remains one `Hermes-Relay-CLI` track containing CLI binaries plus the
|
||||
release remains one `Hermes-Relay-Desktop` track containing CLI binaries plus the
|
||||
optional Windows installer.
|
||||
|
||||
| File | Purpose |
|
||||
@@ -166,12 +167,28 @@ 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`.
|
||||
then tagging `main`. Feature completion means merged and verified on `dev`; it
|
||||
does not mean released.
|
||||
|
||||
**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.
|
||||
**Staging is an environment, not a branch.** Deploy an exact tested `dev` SHA or
|
||||
an immutable release-candidate tag to staging. Record that source in the Forge
|
||||
release issue/session. Never deploy a moving branch name as the source of record
|
||||
and never create a staging branch. Production deploys only immutable
|
||||
`android-v*`, `server-v*`, or `desktop-v*` tags cut from `main`.
|
||||
|
||||
### Normal contribution and release flow
|
||||
|
||||
1. Branch `feature/*`, `fix/*`, `docs/*`, or `chore/*` from `dev`.
|
||||
2. Open the PR into `dev` and require CI to pass.
|
||||
3. Merge with a merge commit/no-ff according to repository policy.
|
||||
4. Accumulate user-facing work under `CHANGELOG.md` `[Unreleased]`.
|
||||
5. Treat the feature as complete when it is merged and verified on `dev`.
|
||||
6. Start a separate Forge release issue/session when a release train is approved.
|
||||
7. Prepare the affected surface release on `dev`, including its version and notes.
|
||||
8. Open and approve the release PR from `dev` into `main`.
|
||||
9. Tag the new `main` tip with the affected surface prefix.
|
||||
10. Build and publish that surface's artifacts, roll out or deploy from the
|
||||
immutable tag, and verify the release and live environment.
|
||||
|
||||
### Branch names
|
||||
|
||||
@@ -211,23 +228,35 @@ version files and, for Android, on `appVersionCode` (which must be
|
||||
monotonic).
|
||||
|
||||
Version-bump commits live on `dev` as the last commit of release-prep
|
||||
work. Android commits use `release(android): android-vX.Y.Z`; plugin commits
|
||||
use `release(plugin): plugin-vX.Y.Z`; CLI commits use
|
||||
`release(cli): cli-vX.Y.Z`. A release PR then merges `dev` →
|
||||
work. Android commits use `release(android): android-vX.Y.Z`; server commits
|
||||
use `release(server): server-vX.Y.Z`; desktop commits use
|
||||
`release(desktop): desktop-vX.Y.Z`. A release PR then merges `dev` →
|
||||
`main` with `--no-ff`, and the matching tag is cut from the resulting
|
||||
`main` tip.
|
||||
|
||||
### Branch protection
|
||||
|
||||
Light branch protection is enabled:
|
||||
Repository files define the contract and CI, but GitHub owns the default branch,
|
||||
branch protection, rulesets, allowed merge methods, and required-check settings.
|
||||
Those settings require an operator or infrastructure automation.
|
||||
|
||||
- **`main`** — direct pushes blocked; only release PRs from `dev` merge
|
||||
here. PR must pass CI (Android + Plugin) 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).
|
||||
The intended settings are:
|
||||
|
||||
- **`main`** — PRs required; `Required checks` required and current; force push
|
||||
and deletion blocked. Normal work does not target this branch.
|
||||
- **`dev`** — PRs and `Required checks` required; force push and deletion
|
||||
blocked. This is the normal contribution target.
|
||||
- **Merge policy** — merge commits allowed; squash and rebase merges disabled so
|
||||
the no-ff contract cannot be bypassed in the GitHub UI.
|
||||
- **Default branch** — `main`, which remains the release-history branch and the
|
||||
repository's canonical landing page. Normal contribution PRs must explicitly
|
||||
target `dev`.
|
||||
|
||||
As of the 2026-07-15 repository audit, the default branch was correctly `main`.
|
||||
The remaining GitHub-owned gaps were that `dev` had no protection, squash and
|
||||
rebase merges were enabled, and `main` protection did not apply to
|
||||
administrators. Those settings must be reconciled separately; this documentation
|
||||
PR does not mutate them.
|
||||
|
||||
## One-time Setup
|
||||
|
||||
@@ -388,6 +417,15 @@ 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 train ownership
|
||||
|
||||
Every release train gets its own Forge release issue/session. That owner records
|
||||
the exact tested staging source, reconciles the affected surface version and
|
||||
notes on `dev`, owns the `dev` → `main` PR, tags the new `main` tip, observes the
|
||||
artifact workflow, performs the rollout or deployment, and captures live
|
||||
verification. Feature implementation sessions stop at merged and verified on
|
||||
`dev`; they do not inherit release authority.
|
||||
|
||||
## Release Process
|
||||
|
||||
### 1. Bump the Android app version
|
||||
@@ -430,7 +468,7 @@ the new app version and a higher `appVersionCode`.
|
||||
three* surfaces (Android + CLI + plugin), but releases are
|
||||
per-surface. Move only the entries for the surface you're cutting into
|
||||
the new versioned block, and leave the other surfaces' entries under
|
||||
the fresh `[Unreleased]` for their own `cli-v*` / `plugin-v*` cut.
|
||||
the fresh `[Unreleased]` for their own `desktop-v*` / `server-v*` cut.
|
||||
(Those tracks' GitHub-Release bodies come from `CLI_RELEASE_NOTES.md` /
|
||||
`PLUGIN_RELEASE_NOTES.md`, so the split here only governs this file's
|
||||
historical record.)
|
||||
@@ -585,14 +623,14 @@ publication.
|
||||
Plugin/Python version files are intentionally not part of an Android app
|
||||
release unless the plugin package itself is also being released.
|
||||
|
||||
### Plugin / Python package release
|
||||
### Server / Python package release
|
||||
|
||||
Use this when plugin or relay behavior changes independently of Android app
|
||||
delivery, for example CLI channel support, bridge routes, pairing server fixes,
|
||||
voice auth, dashboard plugin UI, or packaging changes.
|
||||
|
||||
First **rewrite `PLUGIN_RELEASE_NOTES.md`** — it is the GitHub Release body for
|
||||
`plugin-v*` tags (the same role `RELEASE_NOTES.md` plays for Android). Fill the
|
||||
`server-v*` tags (the same role `RELEASE_NOTES.md` plays for Android). Fill the
|
||||
Summary and the Added/Changed/Fixed groups from the plugin-relevant bullets in the
|
||||
promoted `CHANGELOG.md` block, keep the `__VERSION__` token in the Install command
|
||||
(the workflow substitutes it), and apply the same public-distribution scrub as §2.
|
||||
@@ -603,31 +641,31 @@ git pull --ff-only origin dev
|
||||
|
||||
bash scripts/bump-plugin-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 PLUGIN_RELEASE_NOTES.md
|
||||
git commit -m "release(plugin): plugin-v0.6.2"
|
||||
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 plugin-v0.6.2
|
||||
git push origin plugin-v0.6.2
|
||||
git tag server-v0.6.2
|
||||
git push origin server-v0.6.2
|
||||
```
|
||||
|
||||
Pushing `plugin-v*` triggers `.github/workflows/release-plugin.yml`, which
|
||||
Pushing `server-v*` triggers `.github/workflows/release-plugin.yml`, which
|
||||
validates all plugin-owned version metadata with
|
||||
`scripts/check-plugin-version-sync.py`. Run
|
||||
`python scripts/check-version-tracks.py` locally before tagging when a change
|
||||
touches more than one release surface. The workflow also runs plugin tests,
|
||||
builds a wheel and sdist, generates `SHA256SUMS.txt`, and creates a GitHub
|
||||
Release named `Hermes-Relay-Plugin v<version>` for the plugin package.
|
||||
Release named `Hermes-Relay-Server v<version>` for the server/plugin package.
|
||||
|
||||
### CLI / Windows systray release
|
||||
|
||||
Use this when the standalone CLI, daemon, desktop tools, or Windows tray changes.
|
||||
Android and plugin versions do not need to move with it.
|
||||
|
||||
First rewrite `CLI_RELEASE_NOTES.md` for the new CLI release and promote only
|
||||
First rewrite `CLI_RELEASE_NOTES.md` for the new Desktop release and promote only
|
||||
CLI/tray-relevant changelog bullets into the release block. Then:
|
||||
|
||||
```powershell
|
||||
@@ -641,7 +679,7 @@ cd ..
|
||||
|
||||
git add desktop/package.json desktop/package-lock.json desktop/src/version.ts `
|
||||
desktop/tray/Cargo.toml desktop/tray/Cargo.lock CHANGELOG.md CLI_RELEASE_NOTES.md
|
||||
git commit -m "release(cli): cli-v0.4.0-alpha.2"
|
||||
git commit -m "release(desktop): desktop-v0.4.0-alpha.2"
|
||||
git push origin dev
|
||||
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
@@ -651,8 +689,8 @@ git pull --ff-only origin main
|
||||
cd desktop
|
||||
npm run check:version-sync -- --expect 0.4.0-alpha.2
|
||||
cd ..
|
||||
git tag cli-v0.4.0-alpha.2
|
||||
git push origin cli-v0.4.0-alpha.2
|
||||
git tag desktop-v0.4.0-alpha.2
|
||||
git push origin desktop-v0.4.0-alpha.2
|
||||
```
|
||||
|
||||
The tag workflow rejects version drift and tags whose commit is not in
|
||||
@@ -768,7 +806,8 @@ plugin 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
|
||||
1. Verifies the stable tag resolves to a commit contained in `main` and that the
|
||||
tag matches `appVersionName` in
|
||||
`gradle/libs.versions.toml` (mismatches fail the workflow).
|
||||
2. Runs the Android debug build and the stable sideload pairing/connection
|
||||
regression slice with explicit timeouts.
|
||||
@@ -778,30 +817,35 @@ On every push of a tag matching `android-v*`, `.github/workflows/release-android
|
||||
(`./gradlew bundleRelease assembleRelease`); only the sideload APK and
|
||||
googlePlay AAB are attached (see §Release assets).
|
||||
5. Generates `SHA256SUMS.txt` covering the two attached files.
|
||||
6. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
|
||||
6. Promotes the exact preflighted Production draft to `completed`; a missing
|
||||
credential or rejected Play edit fails before public GitHub publication.
|
||||
7. 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. `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.
|
||||
8. Prints a `$GITHUB_STEP_SUMMARY` with the release and Play result.
|
||||
|
||||
On every push of a tag matching `plugin-v*`,
|
||||
On every push of a tag matching `server-v*`,
|
||||
`.github/workflows/release-plugin.yml`:
|
||||
|
||||
1. Validates the tag matches all plugin-owned version metadata checked by
|
||||
`scripts/check-plugin-version-sync.py`.
|
||||
1. Verifies the tag commit is contained in `main`, validates the tag against
|
||||
all server/plugin-owned version metadata checked by
|
||||
`scripts/check-plugin-version-sync.py`, and requires the matching release
|
||||
heading in `CHANGELOG.md`.
|
||||
2. Runs plugin 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-Plugin v<version>` with the wheel,
|
||||
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 `cli-v*`,
|
||||
On every push of a tag matching `desktop-v*`,
|
||||
`.github/workflows/release-cli.yml` builds and publishes the CLI binaries and
|
||||
Windows tray installer. Its GitHub Release body comes from `CLI_RELEASE_NOTES.md`
|
||||
(rewritten per release — the CLI counterpart of `RELEASE_NOTES.md`); the workflow
|
||||
substitutes `__VERSION__` (bare, e.g. `0.3.0`) and `__TAG__` (full, e.g.
|
||||
`cli-v0.3.0`) so the install/pin commands stay accurate. Fill its Summary and
|
||||
`desktop-v0.3.0`) so the install/pin commands stay accurate. It rejects tags
|
||||
whose commit is not contained in `main`, whose version differs from
|
||||
`desktop/package.json`, or whose version has no `CHANGELOG.md` release heading.
|
||||
Fill its Summary and
|
||||
Added/Changed/Fixed groups at CLI release-prep and apply the §2 public scrub.
|
||||
Dashboard-only changes are covered by
|
||||
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
|
||||
@@ -816,19 +860,28 @@ in the built bundle.
|
||||
| `HERMES_KEYSTORE_PASSWORD` | Store password | Password set during `keytool -genkey` |
|
||||
| `HERMES_KEY_ALIAS` | Key alias | Alias set during `keytool -genkey` |
|
||||
| `HERMES_KEY_PASSWORD` | Key password | Usually the same as the store password |
|
||||
| `PLAY_SERVICE_ACCOUNT_JSON` | **Optional** — Play auto-upload | Paste the full Play Developer API service-account JSON (step 3) |
|
||||
| `PLAY_SERVICE_ACCOUNT_JSON` | Stable Play submission | Paste the full Play Developer API service-account JSON (step 3) |
|
||||
|
||||
If `PLAY_SERVICE_ACCOUNT_JSON` is set, the `android-v*` release workflow uploads
|
||||
the `googlePlay` AAB to the **Production track as a DRAFT** automatically (stable
|
||||
tags only — prereleases are skipped). CI does the upload; you still click **Start
|
||||
rollout** in Play Console. If the secret is unset, the workflow skips the upload
|
||||
and you upload manually (§5) — nothing else changes.
|
||||
Stable Android releases require `PLAY_SERVICE_ACCOUNT_JSON`. Preflight uploads
|
||||
the Production draft and the tag workflow promotes that exact version code to
|
||||
`completed`. The workflow does not fall back to manual upload or publish GitHub
|
||||
first. With Play Managed Publishing off, an approved release publishes
|
||||
automatically; with it on, Play holds the approved change for an operator action
|
||||
that the Developer API does not expose.
|
||||
|
||||
## Hotfix Recipe
|
||||
|
||||
When production has a bug and you need to ship a fix without picking up
|
||||
unreleased work from `dev`, branch from the affected release tag and only
|
||||
bump the version source for the surface you are shipping.
|
||||
When production has a bug, use the same invariant for every surface:
|
||||
|
||||
1. Branch from the affected immutable `android-v*`, `server-v*`, or `desktop-v*`
|
||||
production tag, never from the moving `main` or `dev` branch.
|
||||
2. Make the smallest safe fix and add focused verification.
|
||||
3. Bump only the affected surface's patch version and release notes.
|
||||
4. Open the focused hotfix PR into `main` and merge with a merge commit/no-ff.
|
||||
5. Tag the new `main` tip with the affected surface's patch tag.
|
||||
6. Verify the artifacts and production rollout or deployment.
|
||||
7. Merge `main` back into `dev` immediately so integration inherits the fix and
|
||||
version history.
|
||||
|
||||
For an Android app hotfix:
|
||||
|
||||
@@ -842,17 +895,23 @@ For an Android app hotfix:
|
||||
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.
|
||||
7. Verify the automated Play submission, GitHub artifacts, and rollout.
|
||||
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 Plugin hotfix, branch from the affected `plugin-v*` tag, apply
|
||||
For a Server hotfix, branch from the affected `server-v*` tag, apply
|
||||
the fix, run `bash scripts/bump-plugin-version.sh <next-version>`, merge to
|
||||
`main`, and tag `plugin-v<next-version>`. Do not touch
|
||||
`main`, tag `server-v<next-version>`, verify the package/deployment, and merge
|
||||
`main` back to `dev`. Do not touch
|
||||
`gradle/libs.versions.toml` unless an Android app release is also shipping.
|
||||
|
||||
For a Desktop hotfix, branch from the affected `desktop-v*` tag, update only
|
||||
`desktop/package.json` and its generated lock/runtime/tray metadata, merge to
|
||||
`main`, tag `desktop-v<next-version>`, verify all binaries and the installer,
|
||||
then merge `main` back to `dev`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`Tag version (X) does not match appVersionName (Y)` in CI validate step**
|
||||
|
||||
+9
-13
@@ -1,30 +1,26 @@
|
||||
# Hermes-Relay-Android v1.4.6
|
||||
# Hermes-Relay-Android v1.4.8
|
||||
|
||||
**Release Date:** July 15, 2026
|
||||
**Release Date:** July 18, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.4.6-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.4.8-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://codename-11.github.io/hermes-relay/guide/sideload) for installation help.
|
||||
Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://hermes-relay.dev/docs/guide/sideload) for installation help.
|
||||
|
||||
## Summary
|
||||
|
||||
This patch keeps Server default aligned with Hermes' active profile, adds per-profile icon import and organization controls, and prevents the session drawer from mixing profile databases.
|
||||
|
||||
## Added
|
||||
|
||||
- Reorder or hide profiles per connection without changing server configuration.
|
||||
- Choose a profile icon from the Android file picker or import `avatar.png`/`profile.jpg` from a paired Relay host.
|
||||
This patch restores the public privacy-policy URL required by Google Play and moves the canonical policy to hermes-relay.dev.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Server default resolves Hermes' sticky active profile before Gateway session create/resume and dashboard session operations, keeping the agent, drawer, transcript, and writes in one profile database.
|
||||
- Host image import distinguishes an outdated Relay from a genuinely missing avatar and presents **Choose file** as a reliable fallback.
|
||||
- The historical GitHub Pages privacy URL now serves the complete policy instead of returning 404.
|
||||
- The About screen opens the canonical policy on hermes-relay.dev.
|
||||
- Android release automation verifies both public policy URLs before uploading or publishing to Google Play.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.4.6** (versionCode **29**).
|
||||
- App version: **1.4.8** (versionCode **31**).
|
||||
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
|
||||
@@ -6,6 +6,36 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Active — Remove temporary GitHub Pages docs redirects
|
||||
|
||||
PR #210 moved current source and production documentation to
|
||||
`https://hermes-relay.dev/docs/`, but Android 1.4.0 and earlier releases still
|
||||
contain hardcoded `https://codename-11.github.io/hermes-relay/` links. GitHub
|
||||
Pages therefore serves a redirect-only compatibility shim from
|
||||
`legacy-pages-redirect/`; it must never regain full documentation content.
|
||||
|
||||
Retire the shim only after the first Android release containing merge commit
|
||||
`52df3adbf6d61d0ddbfb69671546f7c4953f956a` has been available for at least
|
||||
90 days **and** at least two Android releases containing the corrected links
|
||||
have shipped. If either condition is unmet at review time, retain it and set a
|
||||
new review date.
|
||||
|
||||
Removal checklist:
|
||||
|
||||
- Remove `.github/workflows/legacy-docs-redirect.yml` and
|
||||
`legacy-pages-redirect/` through a reviewed PR.
|
||||
- Delete/disable the repository Pages site after that PR merges.
|
||||
- Verify `https://codename-11.github.io/hermes-relay/` no longer serves the
|
||||
shim and `https://hermes-relay.dev/docs/` plus representative deep links
|
||||
still return HTTP 200.
|
||||
- Update `DEVLOG.md` and the canonical Obsidian Hermes-Relay project note.
|
||||
|
||||
A one-shot operator reminder is scheduled for **2026-10-15 at 09:00 ET** to
|
||||
review these gates; it is a review trigger, not authorization for automatic
|
||||
removal.
|
||||
|
||||
---
|
||||
|
||||
## Multi-profile Phone/Threads routing — deferred (2026-07-12)
|
||||
|
||||
Android profile hot-swap and concurrent Gateway turns are separate from proactive
|
||||
@@ -615,13 +645,12 @@ every bubble) + grouping breaks on a >5min gap (`GROUP_GAP_MS`) so a resumed
|
||||
conversation gets its own beat; long-press haptic on the action menu; streaming dots
|
||||
gated to pre-first-token. Deferred:
|
||||
|
||||
- **Streaming↔final render parity — conservative 1.4.1 slice implemented; live
|
||||
reflow check remains.** Blank-terminated, unambiguous top-level prose/headings use
|
||||
the final Markdown renderer during generation while the active tail stays raw.
|
||||
Lists, quotes, tables, HTML, and fences intentionally remain lightweight until the
|
||||
final parse because partial CommonMark containers can re-parent earlier blocks.
|
||||
Verify that the chosen boundary removes the common heading/prose pop without
|
||||
introducing partial-fence or list flicker.
|
||||
- **Streaming↔final render parity — live reflow check remains.** Blank-terminated,
|
||||
unambiguous top-level prose/headings use the final Markdown renderer during
|
||||
generation while the active tail stays lightweight. Completion intentionally
|
||||
parses one full CommonMark document so global link references, indentation, and
|
||||
nested containers remain correct; the viewport now anchors that same remeasure.
|
||||
Verify lists, tables, quotes, HTML, nested fences, and reference links on-device.
|
||||
- **Bubble body 14sp → 15sp/21.** 14sp is the smallest body of the five reference
|
||||
apps. Bump markdown paragraph/text/list + the two plain `Text` sites
|
||||
(`MessageBubble.kt` user/system) together; keep ~1.4 leading so the ~272dp measure
|
||||
@@ -643,13 +672,12 @@ gated to pre-first-token. Deferred:
|
||||
kebab). Needs on-device confirmation of the current conflict first.
|
||||
- **Drop the no-op tap ripple on bubbles.** The 1.4.1 jump-to-bottom unread badge is
|
||||
code-complete; `combinedClickable(onClick={})` still ripples on a normal bubble tap.
|
||||
- **Sessions-transport `animateItem` flash.** Stream-complete rebuilds the list with
|
||||
new ids → every visible bubble replays its enter animation (gateway transport,
|
||||
stable id, is unaffected). Reuse the streaming bubble's id for the final message.
|
||||
- **Viewport re-pin on the `isStreaming` true→false height growth** (gateway
|
||||
transport): `ChatScreen` early-returns on `onlyStreamingFlagChanged`; issue one
|
||||
`withFrameNanos{}` + instant `scrollToItem(last)` when the flag flips and the user
|
||||
isn't scrolled away. Largely neutralized once render parity removes the height delta.
|
||||
- **Sessions per-turn reconciliation.** Current upstream includes assistant/tool
|
||||
rows in `run.completed.messages`, but Android still uses a full profile-aware
|
||||
history read for successful Sessions turns so older servers and persisted message
|
||||
boundaries remain safe. Replace it only with a bounded partial-turn merge that
|
||||
preserves the prior transcript and client-only fields, with a full-history fallback
|
||||
when the completion payload is absent or incomplete.
|
||||
- **Full 15-role `Typography` + metadata contrast.** Type.kt declares only 7 roles at
|
||||
0 tracking; the rest inherit M3 defaults with 0.1–0.5sp tracking (ChatScreen uses
|
||||
several) — declare all 15 for one coherent scale. Separately, floor muted-metadata
|
||||
|
||||
@@ -1 +1 @@
|
||||
Server default now keeps its agent, chats, drawer, and transcript in the active Hermes profile. Reorder or hide profiles per connection, choose an icon from your phone, or import avatar.png/profile.jpg from an updated paired Relay. Image import now clearly distinguishes an outdated Relay from a missing avatar.
|
||||
The privacy policy now lives at hermes-relay.dev, the About screen opens it directly, and release automation verifies the public policy before publishing.
|
||||
|
||||
@@ -1 +1 @@
|
||||
“服务器默认”现在会让代理、聊天、会话抽屉和记录保持在 Hermes 当前活动配置文件中。可按连接重新排序或隐藏配置文件,从手机选择图标,或从已更新且配对的 Relay 导入 avatar.png/profile.jpg。图像导入也会明确区分 Relay 版本过旧与头像文件缺失。
|
||||
隐私政策现已迁移到 hermes-relay.dev,“关于”页面可直接打开该政策;发布流程会在上线前验证政策页面是否可公开访问。
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.4.8",
|
||||
"title": "Privacy policy restored",
|
||||
"date": "2026-07-18",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Google Play compliance",
|
||||
"bullets": [
|
||||
"The privacy policy now lives at hermes-relay.dev and the historical store URL remains valid for compatibility.",
|
||||
"The About screen opens the hosted policy directly, and releases verify it is publicly available before publishing."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.7",
|
||||
"title": "Smoother replies, more languages",
|
||||
"date": "2026-07-18",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Smooth streaming",
|
||||
"bullets": [
|
||||
"Long replies grow at a display-paced cadence and stay anchored at the newest text through completion.",
|
||||
"Scrolling into history preserves your reading position instead of forcing the conversation back to the bottom."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "More languages",
|
||||
"bullets": [
|
||||
"Use German, Brazilian Portuguese, or Japanese throughout both Android product flavors.",
|
||||
"Catalog freshness validation keeps every shipped translation aligned with the canonical English resources."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.6",
|
||||
"title": "Profiles stay together",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
v1.4.6 - Profiles stay together
|
||||
v1.4.8 - Privacy policy link restored
|
||||
|
||||
Profile continuity
|
||||
* Server default now keeps its agent, chats, drawer, and transcript in the active Hermes profile.
|
||||
* Reorder or hide profiles per connection without changing the server.
|
||||
|
||||
Profile icons
|
||||
* Choose an image file on your phone or import avatar.png/profile.jpg from an updated paired Relay.
|
||||
* The privacy policy now lives at hermes-relay.dev.
|
||||
* The About screen opens the hosted policy directly.
|
||||
* Release automation verifies the policy is publicly available before publishing.
|
||||
|
||||
@@ -7,6 +7,9 @@ import java.util.Locale
|
||||
enum class AppLanguage(val languageTag: String) {
|
||||
SYSTEM_DEFAULT(""),
|
||||
ENGLISH("en"),
|
||||
GERMAN("de"),
|
||||
BRAZILIAN_PORTUGUESE("pt-BR"),
|
||||
JAPANESE("ja"),
|
||||
SIMPLIFIED_CHINESE("zh-Hans"),
|
||||
SPANISH("es"),
|
||||
;
|
||||
@@ -27,8 +30,11 @@ enum class AppLanguage(val languageTag: String) {
|
||||
val locale = Locale.forLanguageTag(primaryTag)
|
||||
|
||||
return when (locale.language.lowercase(Locale.ROOT)) {
|
||||
"de" -> GERMAN
|
||||
"en" -> ENGLISH
|
||||
"es" -> SPANISH
|
||||
"ja" -> JAPANESE
|
||||
"pt" -> BRAZILIAN_PORTUGUESE
|
||||
"zh" -> {
|
||||
val simplified = locale.script.equals("Hans", ignoreCase = true) ||
|
||||
locale.script.isEmpty() ||
|
||||
|
||||
@@ -129,6 +129,19 @@ data class ChatMessage(
|
||||
* the live message can be matched to its server row.
|
||||
*/
|
||||
val backgroundTask: BackgroundTaskState? = null,
|
||||
/**
|
||||
* Stable identity for Compose list rendering.
|
||||
*
|
||||
* Gateway/user rows start with client UUIDs, then post-turn history
|
||||
* reconciliation adopts the server message id into [id]. That server-id
|
||||
* adoption must not make a visible bubble look removed and reinserted to
|
||||
* LazyColumn: doing so discards its scroll anchor, which is especially
|
||||
* disruptive when the row is a long answer occupying the viewport.
|
||||
*
|
||||
* New rows default to their current [id]. Reconciled rows retain this key
|
||||
* through `copy`, while [id] remains the authoritative lookup/wire id.
|
||||
*/
|
||||
val uiKey: String = id,
|
||||
)
|
||||
|
||||
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
|
||||
|
||||
@@ -1315,7 +1315,9 @@ class ChatHandler {
|
||||
// `id = messageId` adopts the server id: for an id-matched (SSE)
|
||||
// row it's a no-op, but for a positionally reconciled (gateway /
|
||||
// user) row whose `prior` still carries a client UUID it swaps in
|
||||
// the server id so EVERY future reload matches by id.
|
||||
// the server id so EVERY future reload matches by id. `uiKey` is
|
||||
// deliberately not overwritten: Compose must continue treating
|
||||
// this as the same visible row across the post-turn reload.
|
||||
prior.copy(
|
||||
id = messageId,
|
||||
role = role,
|
||||
|
||||
@@ -2103,9 +2103,19 @@ class GatewayChatClient(
|
||||
|
||||
private val rejoinAttempts = java.util.concurrent.atomic.AtomicInteger(0)
|
||||
|
||||
/** True if this socket loss should be answered with a rejoin attempt. */
|
||||
fun beginRejoin(): Boolean =
|
||||
!ended && rejoinAttempts.incrementAndGet() <= MAX_TURN_REJOINS
|
||||
@Volatile
|
||||
private var reconcileRequired = false
|
||||
|
||||
/**
|
||||
* True if this socket loss should be answered with a rejoin attempt.
|
||||
* Mark reconciliation before reconnecting so a terminal event arriving
|
||||
* immediately after `gateway.ready` cannot race ahead of the signal.
|
||||
*/
|
||||
fun beginRejoin(): Boolean {
|
||||
val shouldRejoin = !ended && rejoinAttempts.incrementAndGet() <= MAX_TURN_REJOINS
|
||||
if (shouldRejoin) reconcileRequired = true
|
||||
return shouldRejoin
|
||||
}
|
||||
|
||||
private var watchdog: Job? = null
|
||||
|
||||
@@ -2132,6 +2142,12 @@ class GatewayChatClient(
|
||||
// Ask requests block with no further events, so they arm with
|
||||
// their own (longer) duration via watchdogTimeoutFor.
|
||||
armWatchdog(watchdogTimeoutFor(type))
|
||||
// Queue this immediately before the terminal callbacks. Both are
|
||||
// marshalled through the same dispatcher, preserving callback order
|
||||
// even when the WebSocket reader and reconnect coroutine differ.
|
||||
if (type == "message.complete" && reconcileRequired) {
|
||||
callbacks.onReconcileRequired()
|
||||
}
|
||||
mapper.onEvent(type, payload)
|
||||
if (mapper.turnEnded) {
|
||||
disarmWatchdog()
|
||||
@@ -2305,6 +2321,7 @@ class GatewayChatClient(
|
||||
onToolCallFailed = { a, b -> callbackDispatcher { callbacks.onToolCallFailed(a, b) } },
|
||||
onToolOutputRisk = { v -> callbackDispatcher { callbacks.onToolOutputRisk(v) } },
|
||||
onTurnComplete = { callbackDispatcher { callbacks.onTurnComplete() } },
|
||||
onReconcileRequired = { callbackDispatcher { callbacks.onReconcileRequired() } },
|
||||
onComplete = { callbackDispatcher { callbacks.onComplete() } },
|
||||
onUsage = { v -> callbackDispatcher { callbacks.onUsage(v) } },
|
||||
onError = { v -> callbackDispatcher { callbacks.onError(v) } },
|
||||
|
||||
@@ -335,6 +335,12 @@ class GatewayTurnCallbacks(
|
||||
/** Attach deterministic output-risk metadata to the matching tool card. */
|
||||
val onToolOutputRisk: (GatewayToolOutputRisk) -> Unit = { _ -> },
|
||||
val onTurnComplete: () -> Unit,
|
||||
/**
|
||||
* Fired before [onComplete] when this turn rejoined after a socket gap.
|
||||
* Events emitted while the socket was unavailable are not replayed, so
|
||||
* the caller must reconcile the durable transcript after completion.
|
||||
*/
|
||||
val onReconcileRequired: () -> Unit,
|
||||
val onComplete: () -> Unit,
|
||||
val onUsage: (UsageInfo?) -> Unit,
|
||||
val onError: (String) -> Unit,
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ import com.hermesandroid.relay.data.ConnectionSecurityLevel
|
||||
import com.hermesandroid.relay.data.SurfaceSecurity
|
||||
|
||||
private const val LEARN_MORE_URL =
|
||||
"https://codename-11.github.io/hermes-relay/architecture/connection-security.html"
|
||||
"https://hermes-relay.dev/docs/architecture/connection-security.html"
|
||||
|
||||
/**
|
||||
* Per-surface "Connection security" detail sheet — the tap target for the
|
||||
|
||||
@@ -882,8 +882,8 @@ private data class StandardConnectionDraft(
|
||||
val routeCandidates: List<EndpointCandidate>? = null,
|
||||
)
|
||||
|
||||
private const val SetupGuideUrl = "https://codename-11.github.io/hermes-relay/guide/getting-started"
|
||||
private const val RelaySetupDocsUrl = "https://codename-11.github.io/hermes-relay/reference/relay-server"
|
||||
private const val SetupGuideUrl = "https://hermes-relay.dev/docs/guide/getting-started"
|
||||
private const val RelaySetupDocsUrl = "https://hermes-relay.dev/docs/reference/relay-server"
|
||||
private const val HermesApiDocsUrl = "https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server"
|
||||
|
||||
private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||
|
||||
@@ -11,34 +11,21 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.semantics.CollectionInfo
|
||||
import androidx.compose.ui.semantics.collectionInfo
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -69,7 +56,6 @@ import com.mikepenz.markdown.model.markdownExtendedSpans
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.SyntaxThemes
|
||||
import kotlinx.coroutines.delay
|
||||
import org.intellij.markdown.ast.findChildOfType
|
||||
import org.intellij.markdown.flavours.gfm.GFMElementTypes.HEADER
|
||||
import org.intellij.markdown.flavours.gfm.GFMElementTypes.ROW
|
||||
@@ -303,285 +289,42 @@ fun StreamingMarkdownContent(
|
||||
isStreaming: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val blocks = remember(content, isStreaming) {
|
||||
if (isStreaming) {
|
||||
parseStreamingMarkdownBlocks(content)
|
||||
} else {
|
||||
listOf(StreamingMarkdownBlock.Markdown(content))
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
// Match the final renderer's block spacer so moving the active tail
|
||||
// into the settled Markdown prefix does not add a second layout jump.
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
blocks.forEach { block ->
|
||||
when (block) {
|
||||
is StreamingMarkdownBlock.Markdown -> MarkdownContent(
|
||||
content = block.content,
|
||||
textColor = textColor,
|
||||
)
|
||||
|
||||
is StreamingMarkdownBlock.Text -> Text(
|
||||
text = block.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = textColor,
|
||||
)
|
||||
|
||||
is StreamingMarkdownBlock.Code -> StreamingCodeBlock(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamingCodeBlock(block: StreamingMarkdownBlock.Code) {
|
||||
// Discord-like fenced block: a contrasting inset surface with a thin header
|
||||
// (language label + copy), and a horizontally-scrollable monospace body.
|
||||
// Header is shown whenever there's a language to label or code to copy, so
|
||||
// even a bare ``` fence gets the copy affordance once it has content.
|
||||
val hasHeader = block.language.isNotBlank() || block.code.isNotBlank()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
if (hasHeader) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 4.dp, top = 2.dp, bottom = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = block.language.ifBlank { "code" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
CodeCopyButton(code = block.code)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = block.code.ifEmpty { " " },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small copy affordance for a code block — copies [code] to the clipboard and
|
||||
* briefly flips to a check for feedback. No-op while [code] is blank.
|
||||
*/
|
||||
@Composable
|
||||
private fun CodeCopyButton(code: String) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(1500)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (code.isNotBlank()) {
|
||||
clipboard.setText(AnnotatedString(code))
|
||||
copied = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||
contentDescription = if (copied) "Copied" else "Copy code",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp),
|
||||
if (isStreaming) {
|
||||
// Keep one stable layout node for the entire live turn. Promoting each
|
||||
// blank-terminated paragraph into Markdown replaced the Text subtree
|
||||
// repeatedly; LazyColumn then exposed its fallback anchor for a frame,
|
||||
// which looked like the whole chat reloaded. Updating this Text value
|
||||
// only remeasures the growing bubble. Full Markdown is parsed once the
|
||||
// owning row releases its stable live-tail layout.
|
||||
Text(
|
||||
// CommonMark ignores blank lines before the first block. The live
|
||||
// Text renderer must do the same or a response whose transport
|
||||
// prefix contains newlines appears to start several lines down.
|
||||
// Preserve indentation on the first non-blank line so indented
|
||||
// code and deliberately spaced prose are not altered.
|
||||
text = content.withoutLeadingBlankLines(),
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface StreamingMarkdownBlock {
|
||||
data class Markdown(val content: String) : StreamingMarkdownBlock
|
||||
data class Text(val text: String) : StreamingMarkdownBlock
|
||||
data class Code(val language: String, val code: String) : StreamingMarkdownBlock
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits an in-flight response into stable Markdown and one structurally
|
||||
* incomplete tail.
|
||||
*
|
||||
* Only conservative, blank-terminated top-level prose/heading blocks promote
|
||||
* to the real renderer. Lists, quotes, tables, indented blocks, HTML, and code
|
||||
* remain on the lightweight streaming surface until the message settles; those
|
||||
* containers can legally absorb later lines, so promoting them early causes a
|
||||
* visible re-parenting jump when the final CommonMark tree is parsed.
|
||||
*/
|
||||
internal fun parseStreamingMarkdownBlocks(content: String): List<StreamingMarkdownBlock> {
|
||||
if (content.isBlank()) return emptyList()
|
||||
|
||||
val normalized = content
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
val blocks = mutableListOf<StreamingMarkdownBlock>()
|
||||
val settledEnd = findStableMarkdownBoundary(normalized).coerceAtLeast(0)
|
||||
|
||||
if (settledEnd > 0) {
|
||||
normalized.substring(0, settledEnd).trimEnd().let { stable ->
|
||||
if (stable.isNotBlank()) blocks += StreamingMarkdownBlock.Markdown(stable)
|
||||
}
|
||||
}
|
||||
|
||||
val activeTail = normalized.substring(settledEnd)
|
||||
if (activeTail.isNotBlank()) {
|
||||
blocks += parseActiveStreamingTail(activeTail)
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
/** Last unambiguous blank-line boundary in a contiguous simple-markdown prefix. */
|
||||
private fun findStableMarkdownBoundary(content: String): Int {
|
||||
var offset = 0
|
||||
var blockStart = 0
|
||||
var activeFence: StreamingFence? = null
|
||||
var lastStableBoundary = 0
|
||||
|
||||
while (offset < content.length) {
|
||||
val newline = content.indexOf('\n', offset)
|
||||
val lineEnd = if (newline >= 0) newline else content.length
|
||||
val line = content.substring(offset, lineEnd)
|
||||
|
||||
activeFence = when (val current = activeFence) {
|
||||
null -> streamingFence(line)
|
||||
else -> if (isClosingFence(line, current)) null else current
|
||||
}
|
||||
|
||||
// Whitespace-only lines can be meaningful indentation inside a list.
|
||||
// Require a truly empty delimiter and stop at the first ambiguous
|
||||
// container so every promoted prefix remains structurally final.
|
||||
if (activeFence == null && newline >= 0 && line.isEmpty()) {
|
||||
val candidate = content.substring(blockStart, offset).trimEnd()
|
||||
if (candidate.isNotBlank() && !isConservativeStableBlock(candidate)) break
|
||||
lastStableBoundary = newline + 1
|
||||
blockStart = lastStableBoundary
|
||||
}
|
||||
|
||||
if (newline < 0) break
|
||||
offset = newline + 1
|
||||
}
|
||||
|
||||
return lastStableBoundary
|
||||
}
|
||||
|
||||
private fun isConservativeStableBlock(block: String): Boolean = block
|
||||
.lineSequence()
|
||||
.filter { it.isNotEmpty() }
|
||||
.none { line ->
|
||||
line.firstOrNull()?.isWhitespace() == true ||
|
||||
AMBIGUOUS_STREAMING_BLOCK.matches(line)
|
||||
}
|
||||
|
||||
private fun parseActiveStreamingTail(content: String): List<StreamingMarkdownBlock> {
|
||||
val blocks = mutableListOf<StreamingMarkdownBlock>()
|
||||
val paragraph = StringBuilder()
|
||||
val code = StringBuilder()
|
||||
var activeFence: StreamingFence? = null
|
||||
var language = ""
|
||||
|
||||
fun flushParagraph() {
|
||||
val text = paragraph.toString().trim('\n').trimEnd()
|
||||
if (text.isNotBlank()) {
|
||||
blocks += StreamingMarkdownBlock.Text(text)
|
||||
}
|
||||
paragraph.clear()
|
||||
}
|
||||
|
||||
fun flushCode() {
|
||||
blocks += StreamingMarkdownBlock.Code(
|
||||
language = language,
|
||||
code = code.toString().trimEnd('\n'),
|
||||
)
|
||||
code.clear()
|
||||
}
|
||||
|
||||
val lines = content.split('\n')
|
||||
|
||||
lines.forEachIndexed { index, line ->
|
||||
val lineWithBreak = if (index == lines.lastIndex) line else "$line\n"
|
||||
|
||||
if (activeFence == null) {
|
||||
val fence = streamingFence(line)
|
||||
if (fence != null) {
|
||||
flushParagraph()
|
||||
activeFence = fence
|
||||
language = streamingFenceLanguage(line, fence)
|
||||
} else {
|
||||
paragraph.append(lineWithBreak)
|
||||
}
|
||||
} else if (isClosingFence(line, activeFence)) {
|
||||
flushCode()
|
||||
activeFence = null
|
||||
language = ""
|
||||
} else {
|
||||
code.append(lineWithBreak)
|
||||
}
|
||||
}
|
||||
|
||||
if (activeFence != null) {
|
||||
flushCode()
|
||||
} else {
|
||||
flushParagraph()
|
||||
MarkdownContent(
|
||||
content = content,
|
||||
textColor = textColor,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
private data class StreamingFence(
|
||||
val marker: Char,
|
||||
val length: Int,
|
||||
)
|
||||
internal fun String.withoutLeadingBlankLines(): String {
|
||||
var contentStart = 0
|
||||
while (contentStart < length) {
|
||||
val lineEnd = indexOf('\n', startIndex = contentStart)
|
||||
if (lineEnd < 0) break
|
||||
|
||||
private fun streamingFence(line: String): StreamingFence? {
|
||||
val trimmed = line.trimStart()
|
||||
val marker = trimmed.firstOrNull()?.takeIf { it == '`' || it == '~' } ?: return null
|
||||
val length = trimmed.takeWhile { it == marker }.length
|
||||
return length.takeIf { it >= 3 }?.let { StreamingFence(marker, it) }
|
||||
val line = substring(contentStart, lineEnd).removeSuffix("\r")
|
||||
if (line.isNotBlank()) break
|
||||
contentStart = lineEnd + 1
|
||||
}
|
||||
return substring(contentStart)
|
||||
}
|
||||
|
||||
private fun isClosingFence(line: String, activeFence: StreamingFence): Boolean {
|
||||
val trimmed = line.trimStart()
|
||||
if (trimmed.firstOrNull() != activeFence.marker) return false
|
||||
val markerLength = trimmed.takeWhile { it == activeFence.marker }.length
|
||||
return markerLength >= activeFence.length && trimmed.drop(markerLength).isBlank()
|
||||
}
|
||||
|
||||
private fun streamingFenceLanguage(line: String, fence: StreamingFence): String {
|
||||
val tail = line.trimStart().drop(fence.length).trim()
|
||||
return tail
|
||||
.takeWhile { !it.isWhitespace() && it != fence.marker }
|
||||
.take(32)
|
||||
}
|
||||
|
||||
private val AMBIGUOUS_STREAMING_BLOCK = Regex(
|
||||
"""^(?:[-+*]\s|\d{1,9}[.)]\s|>|```|~~~|<|\||(?:-{3,}|={3,})\s*$).*""",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
@@ -83,6 +85,12 @@ fun MessageBubble(
|
||||
showThinking: Boolean = true,
|
||||
isFirstInGroup: Boolean = true,
|
||||
isLastInGroup: Boolean = true,
|
||||
/**
|
||||
* Keeps a just-completed live tail on its stable Text layout. The owning
|
||||
* list releases this once another row becomes the tail, allowing full
|
||||
* Markdown to render without disturbing the visible bottom anchor.
|
||||
*/
|
||||
retainStreamingLayout: Boolean = false,
|
||||
onCopyMessage: (String) -> Unit = {},
|
||||
/**
|
||||
* Quote this message into the input field. Null hides the Quote entry in
|
||||
@@ -361,6 +369,26 @@ fun MessageBubble(
|
||||
shape = bubbleShape,
|
||||
color = backgroundColor,
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (!isUser && !isSystem &&
|
||||
(message.isStreaming || retainStreamingLayout)
|
||||
) {
|
||||
// The frame-paced text node is already measured at its
|
||||
// new size. Animate and clip the owning surface so a
|
||||
// newly wrapped line is revealed inside the expanding
|
||||
// bubble instead of drawing below the previous bounds
|
||||
// for one frame. TopStart keeps existing prose fixed.
|
||||
Modifier.animateContentSize(
|
||||
animationSpec = tween(
|
||||
durationMillis = 72,
|
||||
easing = LinearOutSlowInEasing,
|
||||
),
|
||||
alignment = Alignment.TopStart,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.then(
|
||||
if (!isUser && !isSystem && isDarkTheme) {
|
||||
Modifier.leftEdgeGlow(
|
||||
@@ -396,15 +424,16 @@ fun MessageBubble(
|
||||
color = textColor
|
||||
)
|
||||
} else {
|
||||
// Settled blocks keep the real Markdown renderer while
|
||||
// only the structurally incomplete tail stays raw. The
|
||||
// same composable settles the final tail so retained
|
||||
// parser state survives the streaming -> final handoff.
|
||||
// Keep one plain Text node stable while content grows
|
||||
// and while this completed response remains the visible
|
||||
// tail. Full Markdown renders once another row becomes
|
||||
// the tail (or the session is revisited), where its
|
||||
// remeasure cannot reset the active viewport.
|
||||
if (markdownBody.isNotEmpty()) {
|
||||
StreamingMarkdownContent(
|
||||
content = markdownBody,
|
||||
textColor = textColor,
|
||||
isStreaming = message.isStreaming,
|
||||
isStreaming = message.isStreaming || retainStreamingLayout,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -538,7 +567,11 @@ fun MessageBubble(
|
||||
// burst of fragments doesn't stack three near-touching time labels.
|
||||
// Grouping breaks on a >5min gap (ChatScreen), so every pause still
|
||||
// surfaces its own time. Alpha floored at 0.6 for 11sp contrast.
|
||||
if (isLastInGroup) {
|
||||
// Keep the live bubble's footer structurally quiet. Showing a
|
||||
// timestamp while text is still growing makes it chase every
|
||||
// token and exaggerates any single-frame layout lag. Reveal it
|
||||
// once the message settles into its final layout.
|
||||
if (isLastInGroup && !message.isStreaming) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
|
||||
@@ -359,7 +359,7 @@ private fun WelcomePage() {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/guide/getting-started"))
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/docs/guide/getting-started"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
|
||||
@@ -394,7 +394,7 @@ fun AboutScreen(
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://codename-11.github.io/hermes-relay/"))
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/docs/"))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
@@ -432,7 +432,7 @@ fun AboutScreen(
|
||||
// Privacy policy link
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Codename-11/hermes-relay/blob/main/docs/privacy.md"))
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://hermes-relay.dev/privacy.html"))
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
|
||||
@@ -244,6 +244,9 @@ fun AppearanceSettingsScreen(
|
||||
val languageLabels = mapOf(
|
||||
AppLanguage.SYSTEM_DEFAULT to stringResource(R.string.appearance_language_system),
|
||||
AppLanguage.ENGLISH to stringResource(R.string.appearance_language_english),
|
||||
AppLanguage.GERMAN to stringResource(R.string.appearance_language_german),
|
||||
AppLanguage.BRAZILIAN_PORTUGUESE to stringResource(R.string.appearance_language_brazilian_portuguese),
|
||||
AppLanguage.JAPANESE to stringResource(R.string.appearance_language_japanese),
|
||||
AppLanguage.SIMPLIFIED_CHINESE to stringResource(R.string.appearance_language_simplified_chinese),
|
||||
AppLanguage.SPANISH to stringResource(R.string.appearance_language_spanish),
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -37,6 +38,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.DragInteraction
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -76,6 +78,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -85,7 +88,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -218,7 +220,6 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val DEFAULT_CHAR_LIMIT = 4096
|
||||
|
||||
/**
|
||||
* A same-author run breaks into a new visual group once the gap to the
|
||||
* neighboring message exceeds this — so a conversation resumed after a pause
|
||||
@@ -233,19 +234,38 @@ private const val GROUP_GAP_MS = 5 * 60_000L
|
||||
*
|
||||
* Captured inside a `snapshotFlow { ... }` so distinctUntilChanged can
|
||||
* detect any meaningful change (new message, longer text, longer reasoning,
|
||||
* new tool card, streaming on/off) and re-trigger an auto-follow scroll.
|
||||
* new tool card, message-id reconciliation, streaming on/off) and re-trigger
|
||||
* an auto-follow scroll.
|
||||
*
|
||||
* `equals` is auto-generated by `data class`, which gives field-wise
|
||||
* comparison — exactly the behavior distinctUntilChanged needs.
|
||||
*/
|
||||
private data class ChatScrollSnapshot(
|
||||
internal data class ChatScrollSnapshot(
|
||||
val messageCount: Int,
|
||||
val lastMessageId: String?,
|
||||
val lastMessageUiKey: String?,
|
||||
val lastContentLength: Int,
|
||||
val lastThinkingLength: Int,
|
||||
val lastToolCallCount: Int,
|
||||
val isStreaming: Boolean
|
||||
)
|
||||
|
||||
internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?): Boolean =
|
||||
previous?.isStreaming == true &&
|
||||
!isStreaming &&
|
||||
previous.messageCount == messageCount &&
|
||||
previous.lastMessageUiKey == lastMessageUiKey
|
||||
|
||||
private class ChatTailTransitionRef(
|
||||
var snapshot: ChatScrollSnapshot? = null,
|
||||
)
|
||||
|
||||
private data class ChatTailLayoutSnapshot(
|
||||
val uiKey: String?,
|
||||
val measuredSizePx: Int?,
|
||||
val shouldFollowGrowth: Boolean,
|
||||
)
|
||||
|
||||
private fun LazyListState.isAtConversationBottom(slopPx: Int): Boolean {
|
||||
val layout = layoutInfo
|
||||
if (layout.totalItemsCount == 0) return true
|
||||
@@ -267,9 +287,9 @@ private suspend fun LazyListState.scrollToConversationBottom(
|
||||
if (attempt == 0 || !isAtConversationBottom(slopPx)) {
|
||||
if (animateNext) {
|
||||
animateNext = false
|
||||
animateScrollToItem(lastIndex, Int.MAX_VALUE)
|
||||
animateScrollToItem(lastIndex)
|
||||
} else {
|
||||
scrollToItem(lastIndex, Int.MAX_VALUE)
|
||||
scrollToItem(lastIndex)
|
||||
}
|
||||
}
|
||||
withFrameNanos { }
|
||||
@@ -991,7 +1011,10 @@ fun ChatScreen(
|
||||
// user back to the latest token while they are reading history.
|
||||
// Reset to false the moment the user returns to the bottom.
|
||||
var userScrolledAway by remember(currentSessionId) { mutableStateOf(false) }
|
||||
var isUserDragging by remember(currentSessionId) { mutableStateOf(false) }
|
||||
var programmaticBottomScroll by remember { mutableStateOf(false) }
|
||||
var retainedLiveTailUiKey by remember(currentSessionId) { mutableStateOf<String?>(null) }
|
||||
var completionSettlingUiKey by remember(currentSessionId) { mutableStateOf<String?>(null) }
|
||||
val currentUnreadSnapshot = remember(messages) { messages.toUnreadSnapshot() }
|
||||
var lastReadSnapshot by remember(currentSessionId) {
|
||||
mutableStateOf(currentUnreadSnapshot)
|
||||
@@ -1024,24 +1047,27 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Decide "is the user reading history" from GENUINE scroll gestures only.
|
||||
// A bare isAtBottom flip — the streaming bubble grew and pushed content
|
||||
// below the fold — must NOT count as the user scrolling away. That false
|
||||
// signal was the bounce: it popped the scroll-to-bottom FAB and (worse)
|
||||
// aborted auto-follow even though the user never touched the screen.
|
||||
// Content growth never sets isScrollInProgress, so keying off the scroll's
|
||||
// falling edge ignores it; only a real drag/fling that ENDS above the
|
||||
// bottom flips userScrolledAway. (Programmatic animated scrolls also set
|
||||
// isScrollInProgress, so they're excluded via programmaticBottomScroll.)
|
||||
// Decide "is the user reading history" from actual touch drags only.
|
||||
// isScrollInProgress also becomes true for our own animated bottom scroll;
|
||||
// if that animation is cancelled by the next stream batch, its falling
|
||||
// edge can race the programmatic flag and falsely disable auto-follow for
|
||||
// the rest of the turn. LazyListState's interaction source emits only real
|
||||
// drag gestures, so it cleanly separates user intent from app scrolling.
|
||||
// Pause follow at drag start so a new token cannot fight the finger, but do
|
||||
// not classify the user as reading history until the gesture actually ends
|
||||
// above the bottom. A tiny/cancelled touch must not poison the next turn.
|
||||
LaunchedEffect(listState) {
|
||||
var wasScrolling = false
|
||||
snapshotFlow { listState.isScrollInProgress }
|
||||
.collect { scrolling ->
|
||||
if (wasScrolling && !scrolling && !programmaticBottomScroll) {
|
||||
userScrolledAway = !isAtBottom
|
||||
listState.interactionSource.interactions.collect { interaction ->
|
||||
when (interaction) {
|
||||
is DragInteraction.Start -> {
|
||||
isUserDragging = true
|
||||
}
|
||||
is DragInteraction.Stop, is DragInteraction.Cancel -> {
|
||||
isUserDragging = false
|
||||
userScrolledAway = !listState.isAtConversationBottom(atBottomSlopPx)
|
||||
}
|
||||
wasScrolling = scrolling
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reaching the bottom by any means (user, follow-pin, content shrank)
|
||||
// always re-arms auto-follow.
|
||||
@@ -1061,10 +1087,14 @@ fun ChatScreen(
|
||||
// suppression lifts and the button appears.
|
||||
val showScrollToBottom by remember {
|
||||
derivedStateOf {
|
||||
val retainingVisibleTail = retainedLiveTailUiKey != null &&
|
||||
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
|
||||
messages.isNotEmpty() &&
|
||||
!isAtBottom &&
|
||||
!programmaticBottomScroll &&
|
||||
!(isStreaming && smoothAutoScroll && !userScrolledAway)
|
||||
!((isStreaming || retainingVisibleTail) &&
|
||||
smoothAutoScroll &&
|
||||
!userScrolledAway)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1196,124 +1226,148 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom while streaming.
|
||||
//
|
||||
// Bugs the previous versions had:
|
||||
// 1. Keys only watched messages.size and content.length — so growth of
|
||||
// the thinking block (thinkingContent) and tool-card additions
|
||||
// (toolCalls) silently froze the auto-follow during long reasoning
|
||||
// and tool execution phases.
|
||||
// 2. animateScrollToItem(lastIndex) defaults scrollOffset = 0, which
|
||||
// aligns the TOP of the item with the top of the viewport. For a
|
||||
// tall streaming bubble (reasoning + tool cards + text) that means
|
||||
// the user gets snapped back to the start of the message instead
|
||||
// of staying at the latest token. Fix: scrollOffset = Int.MAX_VALUE
|
||||
// pins the bottom of the item to the bottom of the viewport.
|
||||
// 3. There was no userScrolledAway gate, so any delta would yank a
|
||||
// user reading history back to the bottom.
|
||||
// 4. The isStreaming flag was a snapshot key, so the stream-complete
|
||||
// transition (true → false) re-triggered animateScrollToItem even
|
||||
// when no content actually changed — producing a visible jiggle.
|
||||
// 5. Sessions endpoint reloads the entire message list on stream
|
||||
// complete via loadMessageHistory(), and the resulting animateItem()
|
||||
// placement animations on every bubble fought with our concurrent
|
||||
// animateScrollToItem — producing a flash where the viewport
|
||||
// visibly settled twice.
|
||||
//
|
||||
// The fix below uses snapshotFlow on a snapshot of every meaningful
|
||||
// streaming-state field. distinctUntilChanged debounces identical
|
||||
// emissions; collectLatest cancels any in-flight scroll animation when
|
||||
// a newer delta arrives, preventing animation pile-ups during rapid
|
||||
// SSE bursts. The pref `smoothAutoScroll` (default true) gates the
|
||||
// entire effect — when off, only manual scrolling occurs.
|
||||
//
|
||||
// The previous-snapshot var inside the LaunchedEffect coroutine lets us
|
||||
// distinguish "content arrived" from "state flipped" and "single
|
||||
// append" from "list rebuild", and pick the right scroll strategy.
|
||||
LaunchedEffect(listState, smoothAutoScroll) {
|
||||
if (!smoothAutoScroll) return@LaunchedEffect
|
||||
var previousSnapshot: ChatScrollSnapshot? = null
|
||||
val tailMessage = messages.lastOrNull()
|
||||
val tailTransition = ChatScrollSnapshot(
|
||||
messageCount = messages.size,
|
||||
lastMessageId = tailMessage?.id,
|
||||
lastMessageUiKey = tailMessage?.uiKey,
|
||||
lastContentLength = tailMessage?.content?.length ?: 0,
|
||||
lastThinkingLength = tailMessage?.thinkingContent?.length ?: 0,
|
||||
lastToolCallCount = tailMessage?.toolCalls?.size ?: 0,
|
||||
isStreaming = tailMessage?.isStreaming == true,
|
||||
)
|
||||
val tailTransitionRef = remember(currentSessionId) { ChatTailTransitionRef() }
|
||||
|
||||
// New rows and streaming -> final Markdown are structural transitions.
|
||||
// Anchor their trailing spacer in SideEffect so the request participates in
|
||||
// the very next remeasure instead of correcting an already-drawn frame.
|
||||
SideEffect {
|
||||
val previous = tailTransitionRef.snapshot
|
||||
val streamStarted = tailTransition.isStreaming && previous?.isStreaming != true
|
||||
val completed = tailTransition.isCompletionAfter(previous)
|
||||
val tailStructureChanged = tailTransition.lastMessageUiKey != null &&
|
||||
(previous == null ||
|
||||
previous.messageCount != tailTransition.messageCount ||
|
||||
previous.lastMessageUiKey != tailTransition.lastMessageUiKey)
|
||||
|
||||
if (streamStarted) {
|
||||
// Sending a turn means "follow my new answer" even if the idle
|
||||
// transcript had previously been left above the bottom. Do not
|
||||
// clear isUserDragging: a real finger keeps priority until release.
|
||||
userScrolledAway = false
|
||||
retainedLiveTailUiKey = tailTransition.lastMessageUiKey
|
||||
} else if (completed) {
|
||||
completionSettlingUiKey = tailTransition.lastMessageUiKey
|
||||
} else if (
|
||||
tailStructureChanged &&
|
||||
retainedLiveTailUiKey != null &&
|
||||
retainedLiveTailUiKey != tailTransition.lastMessageUiKey
|
||||
) {
|
||||
retainedLiveTailUiKey = null
|
||||
}
|
||||
|
||||
val shouldAnchor = smoothAutoScroll &&
|
||||
!isUserDragging &&
|
||||
(!userScrolledAway || streamStarted) &&
|
||||
(streamStarted || completed || tailStructureChanged)
|
||||
if (shouldAnchor) {
|
||||
listState.requestScrollToItem(tailTransition.messageCount + 1)
|
||||
}
|
||||
|
||||
tailTransitionRef.snapshot = tailTransition
|
||||
}
|
||||
|
||||
// Completion adds the timestamp/footer after the final token. Keep the
|
||||
// stable live renderer, then consume any small remaining forward range for
|
||||
// two settled frames. scrollBy preserves the current item anchor and is
|
||||
// visually inert when already at the exact bottom; unlike scrollToItem it
|
||||
// cannot align the top of a tall response with the viewport.
|
||||
LaunchedEffect(
|
||||
completionSettlingUiKey,
|
||||
smoothAutoScroll,
|
||||
userScrolledAway,
|
||||
isUserDragging,
|
||||
) {
|
||||
val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect
|
||||
if (!smoothAutoScroll || userScrolledAway || isUserDragging) {
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
var settledFrames = 0
|
||||
repeat(6) {
|
||||
withFrameNanos { }
|
||||
if (messages.lastOrNull()?.uiKey != settlingKey) {
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (listState.canScrollForward) {
|
||||
settledFrames = 0
|
||||
val viewportHeight = listState.layoutInfo.viewportSize.height
|
||||
if (viewportHeight > 0) {
|
||||
listState.scroll(MutatePriority.Default) {
|
||||
scrollBy(viewportHeight.toFloat())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settledFrames += 1
|
||||
if (settledFrames >= 2) {
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
}
|
||||
completionSettlingUiKey = null
|
||||
}
|
||||
|
||||
// Ordinary streaming growth keeps the same row and Text node. Advance the
|
||||
// existing scroll position by exactly the measured positive height delta;
|
||||
// never replace the logical anchor with scrollToItem(). User input has a
|
||||
// higher mutation priority and cancels this work naturally.
|
||||
LaunchedEffect(listState, smoothAutoScroll, userScrolledAway, isUserDragging) {
|
||||
if (!smoothAutoScroll || userScrolledAway || isUserDragging) return@LaunchedEffect
|
||||
|
||||
var previousLayout: ChatTailLayoutSnapshot? = null
|
||||
snapshotFlow {
|
||||
val last = messages.lastOrNull()
|
||||
// Snapshot every field that can grow during a single turn.
|
||||
// Any change here means "more content arrived, try to follow".
|
||||
ChatScrollSnapshot(
|
||||
messageCount = messages.size,
|
||||
lastContentLength = last?.content?.length ?: 0,
|
||||
lastThinkingLength = last?.thinkingContent?.length ?: 0,
|
||||
lastToolCallCount = last?.toolCalls?.size ?: 0,
|
||||
isStreaming = last?.isStreaming == true
|
||||
val tail = messages.lastOrNull()
|
||||
val tailSize = tail?.uiKey?.let { uiKey ->
|
||||
listState.layoutInfo.visibleItemsInfo
|
||||
.firstOrNull { item -> item.key == uiKey }
|
||||
?.size
|
||||
}
|
||||
ChatTailLayoutSnapshot(
|
||||
uiKey = tail?.uiKey,
|
||||
measuredSizePx = tailSize,
|
||||
shouldFollowGrowth = tail?.isStreaming == true ||
|
||||
(retainedLiveTailUiKey != null && tail?.uiKey == retainedLiveTailUiKey),
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { snapshot ->
|
||||
val prev = previousSnapshot
|
||||
previousSnapshot = snapshot
|
||||
.collect { current ->
|
||||
val previous = previousLayout
|
||||
previousLayout = current
|
||||
val previousSize = previous?.measuredSizePx ?: return@collect
|
||||
val currentSize = current.measuredSizePx ?: return@collect
|
||||
if (!current.shouldFollowGrowth || previous.uiKey != current.uiKey) return@collect
|
||||
|
||||
if (messages.isEmpty()) return@collectLatest
|
||||
if (userScrolledAway) return@collectLatest
|
||||
|
||||
// Skip "state-only" snapshot deltas where the only thing
|
||||
// that changed is the isStreaming flag. The viewport is
|
||||
// already at the right position from the last content
|
||||
// delta — animating again on the state flip causes a
|
||||
// visible flash, especially in sessions mode where the
|
||||
// StreamingDots row vanishes when isStreaming flips false.
|
||||
val onlyStreamingFlagChanged = prev != null
|
||||
&& prev.messageCount == snapshot.messageCount
|
||||
&& prev.lastContentLength == snapshot.lastContentLength
|
||||
&& prev.lastThinkingLength == snapshot.lastThinkingLength
|
||||
&& prev.lastToolCallCount == snapshot.lastToolCallCount
|
||||
&& prev.isStreaming != snapshot.isStreaming
|
||||
if (onlyStreamingFlagChanged) return@collectLatest
|
||||
|
||||
// Sessions endpoint reloads the entire message list on
|
||||
// stream complete (one streaming message → multiple final
|
||||
// messages with proper boundaries + tool call cards).
|
||||
// animateScrollToItem during a list rebuild conflicts with
|
||||
// the items' animateItem() placement animations and produces
|
||||
// a visible flash. Use the instant scrollToItem path so the
|
||||
// viewport snaps to the new bottom while the items animate
|
||||
// into their final positions independently.
|
||||
val isListRebuild = prev != null
|
||||
&& snapshot.messageCount - prev.messageCount > 1
|
||||
|
||||
// Growth of the bubble we're already following (thinking /
|
||||
// content / tool cards on the same message) arrives at token
|
||||
// frequency on the gateway transport. animateScrollToItem
|
||||
// per delta is a cancel/restart storm — each collectLatest
|
||||
// cancellation strands the viewport mid-animation (showing
|
||||
// earlier content) before the next one yanks it back:
|
||||
// visible stutter when parked at the bottom during long
|
||||
// reasoning. Pin instantly instead; reserve the animation
|
||||
// for the discrete new-bubble event.
|
||||
val isSameTurnGrowth = prev != null
|
||||
&& snapshot.messageCount == prev.messageCount
|
||||
|
||||
if (isSameTurnGrowth) {
|
||||
// Tail-follow: a single atomic pin to the clamped bottom.
|
||||
// The helper's multi-frame settle loop gets cancelled by
|
||||
// collectLatest on the very next token (streaming arrives
|
||||
// ~every frame), stranding the viewport mid-settle → the
|
||||
// bounce. One withFrameNanos to let the grown content lay
|
||||
// out, then one scrollToItem — instant and cancellation-safe.
|
||||
withFrameNanos { }
|
||||
val lastIndex = listState.layoutInfo.totalItemsCount - 1
|
||||
if (lastIndex >= 0) listState.scrollToItem(lastIndex, Int.MAX_VALUE)
|
||||
} else {
|
||||
// Discrete events (new bubble, list rebuild, history load):
|
||||
// scrollOffset = Int.MAX_VALUE clamps to the deepest offset;
|
||||
// the helper retries across frames so late markdown/code
|
||||
// measurement can't leave us anchored above the real bottom.
|
||||
// Instant for a rebuild (avoids fighting animateItem()).
|
||||
scrollConversationToBottom(animated = !isListRebuild)
|
||||
val growthPx = currentSize - previousSize
|
||||
if (growthPx > 0) {
|
||||
listState.scroll(MutatePriority.Default) {
|
||||
scrollBy(growthPx.toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Haptic on stream complete
|
||||
// Completion haptic only; scroll ownership remains with the transition and
|
||||
// measured-growth paths above.
|
||||
var observedActiveStream by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(isStreaming) {
|
||||
if (!isStreaming && messages.isNotEmpty()) {
|
||||
if (isStreaming) {
|
||||
observedActiveStream = true
|
||||
} else if (observedActiveStream) {
|
||||
observedActiveStream = false
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
}
|
||||
}
|
||||
@@ -2102,8 +2156,15 @@ fun ChatScreen(
|
||||
) {
|
||||
item { Spacer(modifier = Modifier.height(8.dp).animateItem()) }
|
||||
|
||||
items(messages.size, key = { messages[it].id }) { index ->
|
||||
// `id` can legitimately change once after a Gateway turn:
|
||||
// the history reconcile adopts the persisted server id.
|
||||
// Keep Compose identity stable across that data update so
|
||||
// LazyColumn retains the visible row and its scroll anchor.
|
||||
items(messages.size, key = { messages[it].uiKey }) { index ->
|
||||
val message = messages[index]
|
||||
val retainLiveLayout =
|
||||
index == messages.lastIndex &&
|
||||
message.uiKey == retainedLiveTailUiKey
|
||||
val processNotification = message.hermesProcessNotificationOrNull()
|
||||
|
||||
// Skip empty bubbles (content stripped by annotation parser, no tool calls,
|
||||
@@ -2142,112 +2203,112 @@ fun ChatScreen(
|
||||
message.cards.isNotEmpty()
|
||||
|
||||
message.backgroundTask?.let { task ->
|
||||
val taskModifier = Modifier.padding(
|
||||
top = if (isFirstInGroup) 6.dp else 2.dp,
|
||||
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
|
||||
)
|
||||
BackgroundTaskCard(
|
||||
task = task,
|
||||
toolCalls = message.toolCalls,
|
||||
showTimeline = toolDisplay != "off",
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = if (isFirstInGroup) 6.dp else 2.dp,
|
||||
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
|
||||
)
|
||||
.animateItem(),
|
||||
modifier = taskModifier,
|
||||
)
|
||||
}
|
||||
|
||||
if (processNotification != null) {
|
||||
val notificationModifier = Modifier.padding(
|
||||
top = if (isFirstInGroup) 6.dp else 2.dp,
|
||||
)
|
||||
SyntheticProcessNotificationNotice(
|
||||
notification = processNotification,
|
||||
modifier = Modifier
|
||||
.padding(top = if (isFirstInGroup) 6.dp else 2.dp)
|
||||
.animateItem(),
|
||||
modifier = notificationModifier,
|
||||
)
|
||||
} else if (shouldRenderBubble) MessageBubble(
|
||||
message = message,
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = if (hasBackgroundTask) 1.dp
|
||||
} else if (shouldRenderBubble) {
|
||||
val bubbleModifier = Modifier.padding(
|
||||
top = if (hasBackgroundTask) 1.dp
|
||||
else if (isFirstInGroup) 6.dp
|
||||
else 1.dp,
|
||||
)
|
||||
.animateItem(),
|
||||
maxBubbleWidth = maxBubbleWidth,
|
||||
showThinking = showThinking,
|
||||
isFirstInGroup = isFirstInGroup,
|
||||
isLastInGroup = isLastInGroup,
|
||||
recoveringAnswer = recoveringAnswer,
|
||||
onAttachmentRetry = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
onAttachmentManualFetch = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
onCardAction = { msgId, cardKey, action ->
|
||||
// OPEN_URL is resolved at the UI layer
|
||||
// because launching ACTION_VIEW needs a
|
||||
// Context. We record the dispatch FIRST
|
||||
// via the ViewModel so the card collapses
|
||||
// even if the browser launch throws.
|
||||
if (action.mode == com.hermesandroid.relay.data.HermesCardAction.Modes.OPEN_URL) {
|
||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
||||
com.hermesandroid.relay.ui.components.handleCardActionExternally(
|
||||
context, action
|
||||
)
|
||||
)
|
||||
MessageBubble(
|
||||
message = message,
|
||||
modifier = bubbleModifier,
|
||||
maxBubbleWidth = maxBubbleWidth,
|
||||
showThinking = showThinking,
|
||||
isFirstInGroup = isFirstInGroup,
|
||||
isLastInGroup = isLastInGroup,
|
||||
retainStreamingLayout = retainLiveLayout,
|
||||
recoveringAnswer = recoveringAnswer,
|
||||
onAttachmentRetry = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
onAttachmentManualFetch = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
onCardAction = { msgId, cardKey, action ->
|
||||
// OPEN_URL is resolved at the UI layer
|
||||
// because launching ACTION_VIEW needs a
|
||||
// Context. Record the dispatch first so
|
||||
// the card collapses even if launch fails.
|
||||
if (action.mode == com.hermesandroid.relay.data.HermesCardAction.Modes.OPEN_URL) {
|
||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
||||
com.hermesandroid.relay.ui.components.handleCardActionExternally(
|
||||
context,
|
||||
action,
|
||||
)
|
||||
} else {
|
||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
||||
}
|
||||
},
|
||||
onCardInput = { msgId, cardKey, value ->
|
||||
chatViewModel.answerAsk(msgId, cardKey, value)
|
||||
},
|
||||
onEditMessage = if (
|
||||
isGatewayTransport &&
|
||||
!isStreaming &&
|
||||
message.role == MessageRole.USER &&
|
||||
!message.id.startsWith("voice-intent-") &&
|
||||
!message.id.startsWith("steer-")
|
||||
) {
|
||||
{ msg ->
|
||||
editingMessage = msg
|
||||
inputText = msg.content.take(charLimit)
|
||||
}
|
||||
} else {
|
||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
||||
}
|
||||
},
|
||||
onCardInput = { msgId, cardKey, value ->
|
||||
chatViewModel.answerAsk(msgId, cardKey, value)
|
||||
},
|
||||
onEditMessage = if (
|
||||
isGatewayTransport &&
|
||||
!isStreaming &&
|
||||
message.role == MessageRole.USER &&
|
||||
!message.id.startsWith("voice-intent-") &&
|
||||
!message.id.startsWith("steer-")
|
||||
) {
|
||||
{ msg ->
|
||||
editingMessage = msg
|
||||
inputText = msg.content.take(charLimit)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onQuoteMessage = { text ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
val quoted = text.take(600)
|
||||
.trim()
|
||||
.lines()
|
||||
.joinToString("\n") { line -> "> $line" }
|
||||
inputText = if (inputText.isBlank()) {
|
||||
"$quoted\n\n"
|
||||
} else {
|
||||
"$inputText\n$quoted\n\n"
|
||||
}
|
||||
},
|
||||
onCopyMessage = { text ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
// The new Clipboard API is suspend-based, so the
|
||||
// setClipEntry call has to live inside a coroutine.
|
||||
// We piggyback on the same scope.launch that posts
|
||||
// the snackbar — they are sequential anyway.
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(
|
||||
ClipData.newPlainText(
|
||||
hermesMessageLabel,
|
||||
text
|
||||
null
|
||||
},
|
||||
onQuoteMessage = { text ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
val quoted = text.take(600)
|
||||
.trim()
|
||||
.lines()
|
||||
.joinToString("\n") { line -> "> $line" }
|
||||
inputText = if (inputText.isBlank()) {
|
||||
"$quoted\n\n"
|
||||
} else {
|
||||
"$inputText\n$quoted\n\n"
|
||||
}
|
||||
},
|
||||
onCopyMessage = { text ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
// The new Clipboard API is suspend-based, so the
|
||||
// setClipEntry call has to live inside a coroutine.
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(
|
||||
ClipData.newPlainText(
|
||||
hermesMessageLabel,
|
||||
text,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
snackbarHostState.showSnackbar(
|
||||
message = copiedToClipboardMsg,
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
snackbarHostState.showSnackbar(
|
||||
message = copiedToClipboardMsg,
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Steered sends live inside a server-side tool
|
||||
// result, not a user message — flag the local
|
||||
|
||||
@@ -120,6 +120,21 @@ data class ContextWindowUsage(
|
||||
get() = if (maxTokens > 0) (usedTokens.toFloat() / maxTokens).coerceIn(0f, 1f) else 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* A successful Sessions SSE turn still needs the server-authoritative transcript
|
||||
* because that transport does not stream every persisted message boundary.
|
||||
* Gateway turns already deliver the assistant, reasoning, and tool lifecycle
|
||||
* directly; reloading their full transcript only republishes a healthy live turn.
|
||||
* A successful socket rejoin is the exception because events emitted during the
|
||||
* gap cannot be replayed.
|
||||
*/
|
||||
internal fun shouldReloadHistoryAfterSuccessfulTurn(
|
||||
actualTransport: String,
|
||||
gatewayReconcileRequired: Boolean,
|
||||
): Boolean =
|
||||
actualTransport == "sessions" ||
|
||||
(actualTransport == "gateway" && gatewayReconcileRequired)
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
|
||||
private var apiClient: HermesApiClient? = null
|
||||
@@ -132,6 +147,9 @@ class ChatViewModel : ViewModel() {
|
||||
*/
|
||||
private var activeStream: ActiveTurnHandle? = null
|
||||
|
||||
/** Token bursts awaiting their next UI-sized publication window. */
|
||||
private var activeStreamDeltas: StreamDeltaCoalescer? = null
|
||||
|
||||
/**
|
||||
* True when [activeStream] is a GATEWAY turn (vs an SSE EventSource). A
|
||||
* gateway turn runs on the gateway client, which survives a same-connection
|
||||
@@ -1254,6 +1272,9 @@ class ChatViewModel : ViewModel() {
|
||||
onTurnComplete = {
|
||||
if (acceptsEvent()) handler.onTurnComplete(messageId)
|
||||
},
|
||||
// Server-initiated turns already take the bounded durable-history
|
||||
// reconcile below on every completion.
|
||||
onReconcileRequired = { },
|
||||
onComplete = {
|
||||
val canWriteTranscript = acceptsEvent()
|
||||
val expectedText = handler.messages.value
|
||||
@@ -2255,6 +2276,10 @@ class ChatViewModel : ViewModel() {
|
||||
// same-connection route blip and reconnects its own socket, keeping the
|
||||
// live session), so cancelling here would needlessly kill a recoverable
|
||||
// turn. Leave it running; it completes on the gateway client.
|
||||
if (!activeStreamIsGateway) {
|
||||
activeStreamDeltas?.flushNow()
|
||||
activeStreamDeltas = null
|
||||
}
|
||||
val droppedSseCheckpoint = if (!activeStreamIsGateway) buildTurnCheckpoint() else null
|
||||
if (!activeStreamIsGateway) {
|
||||
activeStream?.cancel()
|
||||
@@ -2293,6 +2318,8 @@ class ChatViewModel : ViewModel() {
|
||||
historyLoadGeneration.incrementAndGet()
|
||||
sessionRefreshGeneration.incrementAndGet()
|
||||
intentionallyCancelled = true
|
||||
activeStreamDeltas?.discard()
|
||||
activeStreamDeltas = null
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
@@ -3339,7 +3366,8 @@ class ChatViewModel : ViewModel() {
|
||||
* message among role==USER messages (excluding phone-local traces the
|
||||
* server never saw), truncates the local list from it, and dispatches a
|
||||
* gateway turn carrying `truncate_before_user_ordinal`. Local/server
|
||||
* divergence self-heals via the post-turn history reload.
|
||||
* divergence self-heals through the gateway's authoritative truncate and
|
||||
* live turn events; recovery paths still perform a full history reconcile.
|
||||
*
|
||||
* @return false when the edit could not be dispatched (turn in flight,
|
||||
* non-gateway endpoint, missing client, ordinal failure) — the caller
|
||||
@@ -3827,6 +3855,8 @@ class ChatViewModel : ViewModel() {
|
||||
val gateway = gatewayClient
|
||||
val canBackground = streamingEndpoint == "gateway" &&
|
||||
activeStreamIsGateway && activeStream != null && gateway != null
|
||||
activeStreamDeltas?.flushNow()
|
||||
activeStreamDeltas = null
|
||||
val checkpoint = if (canBackground) buildTurnCheckpoint() else null
|
||||
if (canBackground && gateway.backgroundActiveTurn()) {
|
||||
if (checkpoint != null) {
|
||||
@@ -4036,6 +4066,8 @@ class ChatViewModel : ViewModel() {
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
},
|
||||
// Recovered turns already reload their authoritative history below.
|
||||
onReconcileRequired = { },
|
||||
onComplete = {
|
||||
if (owns()) {
|
||||
finalizeTurnSideEffects(handler, messageId)
|
||||
@@ -5394,6 +5426,8 @@ class ChatViewModel : ViewModel() {
|
||||
// finalizes the previous turn's leftover streaming placeholder so it
|
||||
// can't pulse forever next to this turn's fresh one.
|
||||
cancelAnswerRecovery()
|
||||
activeStreamDeltas?.flushNow()
|
||||
activeStreamDeltas = null
|
||||
|
||||
// A new turn is starting: clear any leftover cancellation flag so a
|
||||
// stale `true` from a PRIOR cancelled turn (the flag is sticky — a
|
||||
@@ -5414,6 +5448,7 @@ class ChatViewModel : ViewModel() {
|
||||
// stream answer recovery (issue #166) on "sessions": the other
|
||||
// endpoints keep their existing error behavior.
|
||||
var dispatchedSseEndpoint: String? = null
|
||||
var gatewayHistoryReconcileRequired = false
|
||||
|
||||
val assistantTimestamp = System.currentTimeMillis()
|
||||
beginTurnCheckpoint(
|
||||
@@ -5440,13 +5475,26 @@ class ChatViewModel : ViewModel() {
|
||||
// interfaceContextPrompt today, so it marks this turn as spoken.
|
||||
// Tag the reply with a "Voice" chip (parity with "Realtime
|
||||
// Agent"); ChatHandler.loadMessageHistory preserves it across
|
||||
// the post-turn history reload.
|
||||
// history and recovery reconciliation.
|
||||
badges = if (interfaceContextPrompt != null) listOf("Voice") else emptyList(),
|
||||
)
|
||||
)
|
||||
|
||||
val streamDeltas = StreamDeltaCoalescer(
|
||||
scope = viewModelScope,
|
||||
onTextDelta = { delta -> handler.onTextDelta(currentMessageId, delta) },
|
||||
onThinkingDelta = { delta -> handler.onThinkingDelta(currentMessageId, delta) },
|
||||
)
|
||||
activeStreamDeltas = streamDeltas
|
||||
|
||||
fun flushAndReleaseStreamDeltas() {
|
||||
streamDeltas.flushNow()
|
||||
if (activeStreamDeltas === streamDeltas) activeStreamDeltas = null
|
||||
}
|
||||
|
||||
// Shared callbacks for both endpoints
|
||||
val onMessageStartedCb = { serverMsgId: String ->
|
||||
streamDeltas.flushNow()
|
||||
// Replace the placeholder's ID so subsequent deltas/tool calls attach
|
||||
// to it instead of creating a duplicate orphan bubble with streaming dots.
|
||||
// Only replaces empty+streaming messages (the placeholder), not completed turns.
|
||||
@@ -5459,34 +5507,41 @@ class ChatViewModel : ViewModel() {
|
||||
firstTokenNotified = true
|
||||
AppAnalytics.onFirstTokenReceived()
|
||||
}
|
||||
handler.onTextDelta(currentMessageId, delta)
|
||||
streamDeltas.appendText(delta)
|
||||
}
|
||||
val onThinkingDeltaCb = { delta: String ->
|
||||
handler.onThinkingDelta(currentMessageId, delta)
|
||||
streamDeltas.appendThinking(delta)
|
||||
}
|
||||
val onToolCallStartCb = { toolCallId: String, toolName: String ->
|
||||
streamDeltas.flushNow()
|
||||
handler.onToolCallStart(currentMessageId, toolCallId, toolName)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
val onToolCallDoneCb = { toolCallId: String, resultPreview: String? ->
|
||||
streamDeltas.flushNow()
|
||||
handler.onToolCallComplete(currentMessageId, toolCallId, resultPreview)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
val onToolCallFailedCb = { toolCallId: String, errorMsg: String? ->
|
||||
streamDeltas.flushNow()
|
||||
handler.onToolCallFailed(currentMessageId, toolCallId, errorMsg)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
// Turn complete — one assistant message finished, but the run may continue
|
||||
val onTurnCompleteCb = {
|
||||
streamDeltas.flushNow()
|
||||
handler.onTurnComplete(currentMessageId)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
val onCompleteCb = {
|
||||
flushAndReleaseStreamDeltas()
|
||||
// Double-finalize guard: if a straggler completion arrives while
|
||||
// the answer-recovery poller is running, the normal completion
|
||||
// wins — stop the poller before finalizing so the turn can't
|
||||
// finish twice.
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
val completedTransport = dispatchedSseEndpoint
|
||||
?: if (activeStreamIsGateway) "gateway" else streamingEndpoint
|
||||
finalizeTurnSideEffects(handler, currentMessageId)
|
||||
AppAnalytics.onStreamComplete(lastInputTokens, lastOutputTokens)
|
||||
|
||||
@@ -5504,15 +5559,13 @@ class ChatViewModel : ViewModel() {
|
||||
refreshReasoningSettings()
|
||||
}
|
||||
|
||||
// Sessions endpoint doesn't emit structured tool events during streaming —
|
||||
// tool calls are only available as JSON on the stored messages. Reload the
|
||||
// server-authoritative history to get proper message boundaries + tool_calls.
|
||||
// Gateway turns reconcile the same way: live tool events are gated by the
|
||||
// server's display.tool_progress config, so a turn that ran tools silently
|
||||
// (config off, or events lost in a mid-turn rejoin gap) still gets its tool
|
||||
// cards + persisted reasoning right after the turn — not on the next app
|
||||
// restart. By message.complete the server has persisted the turn, so the
|
||||
// REST read is authoritative.
|
||||
// Sessions SSE does not stream every persisted message boundary, so it
|
||||
// still needs the server-authoritative transcript after success. A healthy
|
||||
// Gateway turn is already authoritative in memory through its structured
|
||||
// assistant/reasoning/tool events; reloading the full transcript here would
|
||||
// republish the entire visible list and cause a completion flash. A Gateway
|
||||
// socket rejoin explicitly flags this completion for the same profile-aware
|
||||
// history reconcile because events emitted during the gap may be missing.
|
||||
val sid = handler.currentSessionId.value
|
||||
// A turn that ended in an error (gateway ❌ lifecycle → "Error" badge)
|
||||
// has NO assistant message persisted server-side, so reconciling the
|
||||
@@ -5523,9 +5576,13 @@ class ChatViewModel : ViewModel() {
|
||||
val turnErrored = handler.messages.value
|
||||
.lastOrNull { it.id == currentMessageId }
|
||||
?.badges?.contains("Error") == true
|
||||
if (sid != null && (streamingEndpoint == "sessions" || streamingEndpoint == "gateway")) {
|
||||
if (sid != null && (completedTransport == "sessions" || completedTransport == "gateway")) {
|
||||
viewModelScope.launch {
|
||||
if (!turnErrored) {
|
||||
if (!turnErrored && shouldReloadHistoryAfterSuccessfulTurn(
|
||||
completedTransport,
|
||||
gatewayHistoryReconcileRequired,
|
||||
)
|
||||
) {
|
||||
// Profile-aware read: a gateway turn on a non-default profile
|
||||
// persists into THAT profile's own state.db, so the bare
|
||||
// api_server `/api/sessions/{id}/messages` 404s → emptyList()
|
||||
@@ -5587,6 +5644,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
val onErrorCb = { errorMsg: String ->
|
||||
flushAndReleaseStreamDeltas()
|
||||
val errorSessionId = handler.currentSessionId.value
|
||||
if (intentionallyCancelled) {
|
||||
intentionallyCancelled = false
|
||||
@@ -5943,18 +6001,24 @@ class ChatViewModel : ViewModel() {
|
||||
onToolCallDone = onToolCallDoneCb,
|
||||
onToolCallFailed = onToolCallFailedCb,
|
||||
onToolOutputRisk = { risk ->
|
||||
streamDeltas.flushNow()
|
||||
handler.onToolOutputRisk(currentMessageId, risk)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
},
|
||||
onTurnComplete = onTurnCompleteCb,
|
||||
onReconcileRequired = {
|
||||
gatewayHistoryReconcileRequired = true
|
||||
},
|
||||
onComplete = onCompleteCb,
|
||||
onUsage = onUsageCb,
|
||||
onError = onErrorCb,
|
||||
onToolGenerating = { name ->
|
||||
streamDeltas.flushNow()
|
||||
handler.onToolGenerating(currentMessageId, name)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
},
|
||||
onSubagentEvent = { event ->
|
||||
streamDeltas.flushNow()
|
||||
handler.onSubagentEvent(currentMessageId, event)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
},
|
||||
@@ -6076,6 +6140,8 @@ class ChatViewModel : ViewModel() {
|
||||
// false: the Stopped-badge block below finalizes the placeholder
|
||||
// itself (completing it here first would hide it from findLast).
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
activeStreamDeltas?.flushNow()
|
||||
activeStreamDeltas = null
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
_queuedMessages.value = emptyList()
|
||||
@@ -6610,6 +6676,8 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
activeStreamDeltas?.flushNow()
|
||||
activeStreamDeltas = null
|
||||
flushTurnCheckpointForTeardown()
|
||||
gatewayClient?.setUnsolicitedTurnProvider(null)
|
||||
gatewayClient?.setColdPrewarmSessionReadyListener(null)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Frame-paced publication cadence for streaming text. */
|
||||
internal const val STREAM_DELTA_FRAME_MS = 16L
|
||||
|
||||
private const val STREAM_DELTA_MIN_CHARS_PER_FRAME = 8
|
||||
private const val STREAM_DELTA_MAX_CHARS_PER_FRAME = 48
|
||||
private const val STREAM_DELTA_TARGET_DRAIN_FRAMES = 8
|
||||
|
||||
private enum class StreamDeltaKind {
|
||||
TEXT,
|
||||
THINKING,
|
||||
}
|
||||
|
||||
private data class PendingStreamDelta(
|
||||
val kind: StreamDeltaKind,
|
||||
val content: StringBuilder,
|
||||
)
|
||||
|
||||
/**
|
||||
* Main-thread-confined stream-delta frame pacer.
|
||||
*
|
||||
* Providers often deliver many token events in one scheduler burst, followed
|
||||
* by a network gap. Publishing that burst as one Compose state update makes
|
||||
* text appear in large steps even when rendering itself is fast. This buffer
|
||||
* drains a bounded slice every display-sized interval instead. The slice grows
|
||||
* with backlog, keeping presentation close to real time without returning to
|
||||
* hundreds of full-message republishes per second.
|
||||
*
|
||||
* Adjacent deltas of the same kind retain their bytes and their ordering
|
||||
* relative to thinking/text transitions. Lifecycle boundaries call [flushNow]
|
||||
* so no buffered content can arrive after a tool event, completion,
|
||||
* cancellation, or error has settled the message.
|
||||
*/
|
||||
internal class StreamDeltaCoalescer(
|
||||
private val scope: CoroutineScope,
|
||||
private val onTextDelta: (String) -> Unit,
|
||||
private val onThinkingDelta: (String) -> Unit,
|
||||
private val frameMs: Long = STREAM_DELTA_FRAME_MS,
|
||||
) {
|
||||
private val pending = mutableListOf<PendingStreamDelta>()
|
||||
private var flushJob: Job? = null
|
||||
|
||||
init {
|
||||
require(frameMs >= 0L) { "frameMs must be non-negative" }
|
||||
}
|
||||
|
||||
fun appendText(delta: String) {
|
||||
enqueue(StreamDeltaKind.TEXT, delta)
|
||||
}
|
||||
|
||||
fun appendThinking(delta: String) {
|
||||
enqueue(StreamDeltaKind.THINKING, delta)
|
||||
}
|
||||
|
||||
fun flushNow() {
|
||||
flushJob?.cancel()
|
||||
flushJob = null
|
||||
flushPending()
|
||||
}
|
||||
|
||||
fun discard() {
|
||||
flushJob?.cancel()
|
||||
flushJob = null
|
||||
pending.clear()
|
||||
}
|
||||
|
||||
private fun enqueue(kind: StreamDeltaKind, delta: String) {
|
||||
if (delta.isEmpty()) return
|
||||
|
||||
val tail = pending.lastOrNull()
|
||||
if (tail?.kind == kind) {
|
||||
tail.content.append(delta)
|
||||
} else {
|
||||
pending += PendingStreamDelta(kind, StringBuilder(delta))
|
||||
}
|
||||
|
||||
scheduleFrame()
|
||||
}
|
||||
|
||||
private fun scheduleFrame() {
|
||||
if (flushJob != null || pending.isEmpty()) return
|
||||
|
||||
flushJob = scope.launch {
|
||||
delay(frameMs)
|
||||
flushJob = null
|
||||
publishFrame()
|
||||
scheduleFrame()
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishFrame() {
|
||||
if (pending.isEmpty()) return
|
||||
|
||||
var remainingBudget = streamDeltaFrameBudget(
|
||||
pending.sumOf { it.content.length },
|
||||
)
|
||||
val frame = mutableListOf<Pair<StreamDeltaKind, String>>()
|
||||
|
||||
while (remainingBudget > 0 && pending.isNotEmpty()) {
|
||||
val head = pending.first()
|
||||
val requestedLength = minOf(remainingBudget, head.content.length)
|
||||
val safeLength = head.content.codePointSafePrefixLength(requestedLength)
|
||||
if (safeLength == 0) break
|
||||
|
||||
val content = head.content.substring(0, safeLength)
|
||||
head.content.delete(0, safeLength)
|
||||
remainingBudget -= safeLength
|
||||
if (head.content.isEmpty()) pending.removeAt(0)
|
||||
|
||||
val previous = frame.lastOrNull()
|
||||
if (previous?.first == head.kind) {
|
||||
frame[frame.lastIndex] = head.kind to (previous.second + content)
|
||||
} else {
|
||||
frame += head.kind to content
|
||||
}
|
||||
}
|
||||
|
||||
publish(frame)
|
||||
}
|
||||
|
||||
private fun flushPending() {
|
||||
if (pending.isEmpty()) return
|
||||
|
||||
// Copy before invoking callbacks so a callback that indirectly queues
|
||||
// more work starts a fresh window instead of mutating this drain.
|
||||
val batch = pending.map { it.kind to it.content.toString() }
|
||||
pending.clear()
|
||||
publish(batch)
|
||||
}
|
||||
|
||||
private fun publish(batch: List<Pair<StreamDeltaKind, String>>) {
|
||||
batch.forEach { (kind, content) ->
|
||||
when (kind) {
|
||||
StreamDeltaKind.TEXT -> onTextDelta(content)
|
||||
StreamDeltaKind.THINKING -> onThinkingDelta(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun streamDeltaFrameBudget(pendingChars: Int): Int {
|
||||
if (pendingChars <= 0) return 0
|
||||
val adaptiveBudget =
|
||||
(pendingChars + STREAM_DELTA_TARGET_DRAIN_FRAMES - 1) /
|
||||
STREAM_DELTA_TARGET_DRAIN_FRAMES
|
||||
return adaptiveBudget
|
||||
.coerceIn(STREAM_DELTA_MIN_CHARS_PER_FRAME, STREAM_DELTA_MAX_CHARS_PER_FRAME)
|
||||
.coerceAtMost(pendingChars)
|
||||
}
|
||||
|
||||
private fun StringBuilder.codePointSafePrefixLength(requestedLength: Int): Int {
|
||||
if (requestedLength <= 0) return 0
|
||||
if (requestedLength >= length) return length
|
||||
|
||||
return if (
|
||||
Character.isHighSurrogate(this[requestedLength - 1]) &&
|
||||
Character.isLowSurrogate(this[requestedLength])
|
||||
) {
|
||||
requestedLength - 1
|
||||
} else {
|
||||
requestedLength
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1054,6 +1054,9 @@
|
||||
<string name="appearance_language_desc">选择 Hermes-Relay 使用的语言。跟随系统会使用设备的语言设置。</string>
|
||||
<string name="appearance_language_system">跟随系统</string>
|
||||
<string name="appearance_language_english">English</string>
|
||||
<string name="appearance_language_german">Deutsch</string>
|
||||
<string name="appearance_language_brazilian_portuguese">Português (Brasil)</string>
|
||||
<string name="appearance_language_japanese">日本語</string>
|
||||
<string name="appearance_language_simplified_chinese">简体中文</string>
|
||||
<string name="appearance_language_spanish">Español</string>
|
||||
<string name="appearance_appearance">外观</string>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2812,6 +2812,9 @@
|
||||
<string name="appearance_language_desc">Elige el idioma de Hermes-Relay. La opción predeterminada del sistema sigue la configuración del dispositivo.</string>
|
||||
<string name="appearance_language_system">Predeterminado del sistema</string>
|
||||
<string name="appearance_language_english">English</string>
|
||||
<string name="appearance_language_german">Deutsch</string>
|
||||
<string name="appearance_language_brazilian_portuguese">Português (Brasil)</string>
|
||||
<string name="appearance_language_japanese">日本語</string>
|
||||
<string name="appearance_language_simplified_chinese">简体中文</string>
|
||||
<string name="appearance_language_spanish">Español</string>
|
||||
<string name="chat_profile_history_unavailable">No se pudo acceder al historial de conversaciones del perfil activo. Vuelve a conectarte e inténtalo de nuevo.</string>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1057,6 +1057,9 @@
|
||||
<string name="appearance_language_desc">Choose the language used by Hermes-Relay. System default follows your device setting.</string>
|
||||
<string name="appearance_language_system">System default</string>
|
||||
<string name="appearance_language_english">English</string>
|
||||
<string name="appearance_language_german">Deutsch</string>
|
||||
<string name="appearance_language_brazilian_portuguese">Português (Brasil)</string>
|
||||
<string name="appearance_language_japanese">日本語</string>
|
||||
<string name="appearance_language_simplified_chinese">简体中文</string>
|
||||
<string name="appearance_language_spanish">Español</string>
|
||||
<string name="appearance_appearance">Appearance</string>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="zh-Hans" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="es" />
|
||||
<locale android:name="ja" />
|
||||
<locale android:name="pt-BR" />
|
||||
<locale android:name="zh-Hans" />
|
||||
</locale-config>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
<string name="a11y_service_label">Hermes-Bridge Dev</string>
|
||||
<string name="notification_companion_label">assistente de notificações do Hermes Dev</string>
|
||||
<string name="a11y_description_sideload">O Hermes Bridge concede ao agente acesso completo de leitura e gravação no celular para controle sem usar as mãos por voz e visão. Todas as ações são registradas no Registro de atividades, e as ações destrutivas exigem sua confirmação.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Sideload flavor strings.
|
||||
|
||||
`a11y_description_sideload` is the full-surface description users see when
|
||||
enabling Hermes Bridge on the direct-install track. Unlike the Google Play
|
||||
flavor we can be explicit about voice + vision + full device control here:
|
||||
the sideload user is assumed to be a power user who installed an APK by
|
||||
hand, not a Play Store customer.
|
||||
|
||||
Do NOT reuse these strings in the googlePlay flavor — Play reviewers may
|
||||
flag any mention of "full read/write access" or "hands-free control" as
|
||||
outside the declared use case.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
<string name="a11y_service_label">Hermes-Bridge Dev</string>
|
||||
<string name="notification_companion_label">Hermes Dev-Benachrichtigungsassistent</string>
|
||||
<string name="a11y_description_sideload">Hermes Bridge gewährt dem Agenten vollständigen Lese- und Schreibzugriff auf das Smartphone zur freihändigen Steuerung per Sprache und Bilderkennung. Alle Aktionen werden im Aktivitätsprotokoll erfasst; destruktive Aktionen erfordern deine Bestätigung.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Sideload flavor strings.
|
||||
|
||||
`a11y_description_sideload` is the full-surface description users see when
|
||||
enabling Hermes Bridge on the direct-install track. Unlike the Google Play
|
||||
flavor we can be explicit about voice + vision + full device control here:
|
||||
the sideload user is assumed to be a power user who installed an APK by
|
||||
hand, not a Play Store customer.
|
||||
|
||||
Do NOT reuse these strings in the googlePlay flavor — Play reviewers may
|
||||
flag any mention of "full read/write access" or "hands-free control" as
|
||||
outside the declared use case.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
<string name="a11y_service_label">Hermes-Bridge Dev</string>
|
||||
<string name="notification_companion_label">Hermes Dev 通知コンパニオン</string>
|
||||
<string name="a11y_description_sideload">Hermes Bridge は、エージェントに電話機への完全な読み取り/書き込みアクセス権を与え、音声と視覚によるハンズフリー制御を可能にします。すべてのアクションはアクティビティ ログに記録され、破壊的なアクションにはユーザーの確認が必要です。</string>
|
||||
</resources>
|
||||
@@ -26,6 +26,10 @@ class AppLanguageTest {
|
||||
|
||||
@Test
|
||||
fun addedLocaleTagsResolveToTheirPickerOptions() {
|
||||
assertEquals(AppLanguage.GERMAN, AppLanguage.fromLanguageTags("de-DE"))
|
||||
assertEquals(AppLanguage.BRAZILIAN_PORTUGUESE, AppLanguage.fromLanguageTags("pt-BR"))
|
||||
assertEquals(AppLanguage.BRAZILIAN_PORTUGUESE, AppLanguage.fromLanguageTags("pt-PT"))
|
||||
assertEquals(AppLanguage.JAPANESE, AppLanguage.fromLanguageTags("ja-JP"))
|
||||
assertEquals(AppLanguage.SPANISH, AppLanguage.fromLanguageTags("es-MX"))
|
||||
}
|
||||
|
||||
@@ -33,6 +37,9 @@ class AppLanguageTest {
|
||||
fun languageOptionsProduceExpectedLocaleLists() {
|
||||
assertTrue(AppLanguage.SYSTEM_DEFAULT.toLocaleList().isEmpty)
|
||||
assertEquals("en", AppLanguage.ENGLISH.toLocaleList().toLanguageTags())
|
||||
assertEquals("de", AppLanguage.GERMAN.toLocaleList().toLanguageTags())
|
||||
assertEquals("pt-BR", AppLanguage.BRAZILIAN_PORTUGUESE.toLocaleList().toLanguageTags())
|
||||
assertEquals("ja", AppLanguage.JAPANESE.toLocaleList().toLanguageTags())
|
||||
assertEquals("zh-Hans", AppLanguage.SIMPLIFIED_CHINESE.languageTag)
|
||||
assertEquals("es", AppLanguage.SPANISH.toLocaleList().toLanguageTags())
|
||||
assertEquals(
|
||||
|
||||
@@ -1046,6 +1046,11 @@ class ChatHandlerTest {
|
||||
// Server ids adopted onto the live rows.
|
||||
assertEquals("srv-1", user.id)
|
||||
assertEquals("srv-2", assistant.id)
|
||||
// Compose identity stays on the live rows. A same-count post-turn
|
||||
// reload must not remove/reinsert the two bubbles just because their
|
||||
// authoritative ids arrived.
|
||||
assertEquals("uuid-user", user.uiKey)
|
||||
assertEquals("uuid-assistant", assistant.uiKey)
|
||||
// State carried by id, in place.
|
||||
assertEquals(1, user.attachments.size)
|
||||
assertEquals("outb64", user.attachments[0].content)
|
||||
@@ -1056,6 +1061,39 @@ class ChatHandlerTest {
|
||||
assertFalse(assistant.isStreaming)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_listRebuildPreservesMatchedTailUiKey() {
|
||||
// Some persisted turns rebuild one live streaming bubble into several
|
||||
// server rows (for example, restored message boundaries/tool output).
|
||||
// The reconciled tail must retain its UI identity even while new rows
|
||||
// are inserted around it, otherwise LazyColumn loses the viewport
|
||||
// anchor on a long answer.
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "uuid-live-tail",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "final chunk",
|
||||
timestamp = 3L,
|
||||
isStreaming = false,
|
||||
)
|
||||
)
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(id = "srv-user", role = "user", content = JsonPrimitive("question"), timestamp = 1.0),
|
||||
MessageItem(id = "srv-prefix", role = "assistant", content = JsonPrimitive("earlier chunk"), timestamp = 2.0),
|
||||
MessageItem(id = "srv-tail", role = "assistant", content = JsonPrimitive("final chunk"), timestamp = 3.0),
|
||||
)
|
||||
)
|
||||
|
||||
val messages = handler.messages.value
|
||||
assertEquals(3, messages.size)
|
||||
assertEquals("uuid-live-tail", messages.single { it.id == "srv-tail" }.uiKey)
|
||||
assertEquals("srv-user", messages.single { it.id == "srv-user" }.uiKey)
|
||||
assertEquals("srv-prefix", messages.single { it.id == "srv-prefix" }.uiKey)
|
||||
assertEquals(messages.size, messages.map { it.uiKey }.distinct().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_secondReloadMatchesByIdAfterReconciliation() {
|
||||
// Once the first reload adopts the server id, subsequent reloads match by
|
||||
|
||||
@@ -382,6 +382,7 @@ class GatewayChatClientTest {
|
||||
val toolGenerating = ConcurrentLinkedQueue<String>()
|
||||
val subagentEvents = ConcurrentLinkedQueue<GatewaySubagentEvent>()
|
||||
val usages = ConcurrentLinkedQueue<UsageInfo>()
|
||||
val reconcileRequests = AtomicInteger(0)
|
||||
val completeLatch = CountDownLatch(1)
|
||||
val preflightFailures = ConcurrentLinkedQueue<String>()
|
||||
|
||||
@@ -394,6 +395,7 @@ class GatewayChatClientTest {
|
||||
onToolCallDone = { id, result -> toolDone += id to result },
|
||||
onToolCallFailed = { _, _ -> },
|
||||
onTurnComplete = { },
|
||||
onReconcileRequired = { reconcileRequests.incrementAndGet() },
|
||||
onComplete = { completeLatch.countDown() },
|
||||
onUsage = { it?.let(usages::add) },
|
||||
onError = { errors += it; completeLatch.countDown() },
|
||||
@@ -503,6 +505,7 @@ class GatewayChatClientTest {
|
||||
assertEquals(listOf("Hi!"), r.textDeltas.toList())
|
||||
assertEquals(listOf("20260612_120000_abc123"), r.sessionIds.toList())
|
||||
assertEquals(5, r.usages.firstOrNull()?.resolvedInputTokens)
|
||||
assertEquals(0, r.reconcileRequests.get())
|
||||
assertTrue(r.errors.isEmpty())
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
}
|
||||
@@ -978,6 +981,7 @@ class GatewayChatClientTest {
|
||||
|
||||
assertTrue("turn never completed after rejoin", r.completeLatch.await(10, TimeUnit.SECONDS))
|
||||
assertEquals(listOf("after rejoin"), r.textDeltas.toList())
|
||||
assertEquals(1, r.reconcileRequests.get())
|
||||
assertTrue("rejoined turn must not error, got ${r.errors}", r.errors.isEmpty())
|
||||
// The fix's core invariant: a mid-turn rejoin must NEVER session.resume.
|
||||
assertTrue(
|
||||
|
||||
@@ -32,6 +32,7 @@ class GatewayEventMapperTest {
|
||||
val sessionIds = mutableListOf<String>()
|
||||
var starts = 0
|
||||
var turnCompletes = 0
|
||||
var reconcileRequests = 0
|
||||
var completes = 0
|
||||
var usage: UsageInfo? = null
|
||||
var usageCalls = 0
|
||||
@@ -47,6 +48,7 @@ class GatewayEventMapperTest {
|
||||
onToolCallFailed = { id, err -> toolFails += id to err },
|
||||
onToolOutputRisk = { toolOutputRisks += it },
|
||||
onTurnComplete = { turnCompletes++ },
|
||||
onReconcileRequired = { reconcileRequests++ },
|
||||
onComplete = { completes++ },
|
||||
onUsage = { usage = it; usageCalls++ },
|
||||
onError = { errors += it },
|
||||
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MarkdownStreamingParserTest {
|
||||
|
||||
@Test
|
||||
fun activeParagraph_remainsRawUntilAStableBlockBoundary() {
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text("A paragraph still arriving")),
|
||||
parseStreamingMarkdownBlocks("A paragraph still arriving"),
|
||||
)
|
||||
|
||||
// A single newline is a Markdown soft break, not a stable block split.
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text("line one\nline two")),
|
||||
parseStreamingMarkdownBlocks("line one\nline two"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankLine_promotesSettledPrefixToRealMarkdown() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
StreamingMarkdownBlock.Markdown("## Stable heading"),
|
||||
StreamingMarkdownBlock.Text("- one\n- two\n\nTail still arriving"),
|
||||
),
|
||||
parseStreamingMarkdownBlocks(
|
||||
"## Stable heading\n\n- one\n- two\n\nTail still arriving",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openFence_keepsBlankLinesInsideTheActiveCodeBlock() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
StreamingMarkdownBlock.Markdown("Intro"),
|
||||
StreamingMarkdownBlock.Code(
|
||||
language = "kotlin",
|
||||
code = "val first = 1\n\nval second = 2",
|
||||
),
|
||||
),
|
||||
parseStreamingMarkdownBlocks(
|
||||
"Intro\n\n```kotlin\nval first = 1\n\nval second = 2",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closedFence_staysOnTheStreamingCodeSurfaceUntilFinal() {
|
||||
val blocks = parseStreamingMarkdownBlocks(
|
||||
"```kotlin\nval answer = 42\n```\n\nNext paragraph",
|
||||
)
|
||||
|
||||
assertEquals(2, blocks.size)
|
||||
assertEquals(
|
||||
StreamingMarkdownBlock.Code(
|
||||
language = "kotlin",
|
||||
code = "val answer = 42",
|
||||
),
|
||||
blocks[0],
|
||||
)
|
||||
assertEquals(StreamingMarkdownBlock.Text("Next paragraph"), blocks[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longerFence_isNotClosedByShorterFenceInsideCode() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
StreamingMarkdownBlock.Code(
|
||||
language = "markdown",
|
||||
code = "```\ninside\n```",
|
||||
),
|
||||
),
|
||||
parseStreamingMarkdownBlocks(
|
||||
"````markdown\n```\ninside\n```\n````",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun table_staysRawUntilTheFinalCommonMarkParse() {
|
||||
val content = "| Name | Value |\n| --- | --- |\n| Alpha | 1 |\n| Beta |"
|
||||
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text(content)),
|
||||
parseStreamingMarkdownBlocks(content),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun incompleteTableDelimiter_doesNotPrematurelyPromoteTheTable() {
|
||||
val content = "| Name | Value |\n| --- | --"
|
||||
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text(content)),
|
||||
parseStreamingMarkdownBlocks(content),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun escapedHeaderPipe_doesNotMakeTheTableLookSettled() {
|
||||
val content = "| Name \\| alias | Value |\n| --- | --- |\n| Alpha | 1 |\n| Beta |"
|
||||
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text(content)),
|
||||
parseStreamingMarkdownBlocks(content),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listAndContinuation_stayTogetherUntilFinal() {
|
||||
val content = "- first paragraph\n\n continuation\n- second"
|
||||
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text(content)),
|
||||
parseStreamingMarkdownBlocks(content),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lazyBlockQuoteContinuation_isNeverSplitIntoASettledPrefix() {
|
||||
val content = "> quoted line\n\nlazy continuation"
|
||||
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text(content)),
|
||||
parseStreamingMarkdownBlocks(content),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crlfInput_isNormalizedWithoutLeakingCarriageReturns() {
|
||||
val blocks = parseStreamingMarkdownBlocks("First\r\n\r\nSecond")
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
StreamingMarkdownBlock.Markdown("First"),
|
||||
StreamingMarkdownBlock.Text("Second"),
|
||||
),
|
||||
blocks,
|
||||
)
|
||||
assertTrue(blocks.none { it.toString().contains('\r') })
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class StreamingMarkdownContentTest {
|
||||
@Test
|
||||
fun liveText_discardsOnlyLeadingBlankLines() {
|
||||
assertEquals(
|
||||
"First line\n\nSecond line",
|
||||
"\n\r\n \t\nFirst line\n\nSecond line".withoutLeadingBlankLines(),
|
||||
)
|
||||
assertEquals(
|
||||
" indented code",
|
||||
"\n\n indented code".withoutLeadingBlankLines(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatScrollSnapshotTest {
|
||||
@Test
|
||||
fun `same-tail stream completion requests an atomic bottom anchor`() {
|
||||
val streaming = snapshot(isStreaming = true)
|
||||
val complete = snapshot(isStreaming = false)
|
||||
|
||||
assertTrue(complete.isCompletionAfter(streaming))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tail replacement is not mistaken for stream completion`() {
|
||||
val streaming = snapshot(isStreaming = true)
|
||||
val replaced = snapshot(isStreaming = false, lastMessageUiKey = "replacement-tail")
|
||||
|
||||
assertFalse(replaced.isCompletionAfter(streaming))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message list rebuild is not mistaken for stream completion`() {
|
||||
val streaming = snapshot(isStreaming = true)
|
||||
val rebuilt = snapshot(isStreaming = false, messageCount = 10)
|
||||
|
||||
assertFalse(rebuilt.isCompletionAfter(streaming))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ordinary streaming growth is not a completion`() {
|
||||
val before = snapshot(contentLength = 4_000, isStreaming = true)
|
||||
val after = snapshot(contentLength = 4_500, isStreaming = true)
|
||||
|
||||
assertFalse(after.isCompletionAfter(before))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `starting a stream is not a completion`() {
|
||||
val idle = snapshot(isStreaming = false)
|
||||
val streaming = snapshot(isStreaming = true)
|
||||
|
||||
assertFalse(streaming.isCompletionAfter(idle))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `server id adoption remains an observable tail change`() {
|
||||
val local = snapshot(isStreaming = false)
|
||||
val reconciled = local.copy(lastMessageId = "assistant-server-id")
|
||||
|
||||
assertNotEquals(local, reconciled)
|
||||
}
|
||||
|
||||
private fun snapshot(
|
||||
contentLength: Int = 12_000,
|
||||
isStreaming: Boolean,
|
||||
messageCount: Int = 8,
|
||||
lastMessageUiKey: String = "assistant-ui-key",
|
||||
) = ChatScrollSnapshot(
|
||||
messageCount = messageCount,
|
||||
lastMessageId = "assistant-live-id",
|
||||
lastMessageUiKey = lastMessageUiKey,
|
||||
lastContentLength = contentLength,
|
||||
lastThinkingLength = 1_200,
|
||||
lastToolCallCount = 2,
|
||||
isStreaming = isStreaming,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatTurnCompletionPolicyTest {
|
||||
@Test
|
||||
fun `successful Sessions turns reload persisted message boundaries`() {
|
||||
assertTrue(shouldReloadHistoryAfterSuccessfulTurn("sessions", gatewayReconcileRequired = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `healthy uninterrupted Gateway turns keep their live transcript`() {
|
||||
assertFalse(shouldReloadHistoryAfterSuccessfulTurn("gateway", gatewayReconcileRequired = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Gateway turns with a socket gap reload potentially missed events`() {
|
||||
assertTrue(shouldReloadHistoryAfterSuccessfulTurn("gateway", gatewayReconcileRequired = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stateless structured transports do not reload session history`() {
|
||||
assertFalse(shouldReloadHistoryAfterSuccessfulTurn("runs", gatewayReconcileRequired = false))
|
||||
assertFalse(shouldReloadHistoryAfterSuccessfulTurn("completions", gatewayReconcileRequired = false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class StreamDeltaCoalescerTest {
|
||||
@Test
|
||||
fun `token burst drains across display paced frames`() = runTest {
|
||||
val textBatches = mutableListOf<String>()
|
||||
val coalescer = StreamDeltaCoalescer(
|
||||
scope = this,
|
||||
onTextDelta = textBatches::add,
|
||||
onThinkingDelta = {},
|
||||
)
|
||||
|
||||
repeat(100) { coalescer.appendText("x") }
|
||||
runCurrent()
|
||||
|
||||
assertTrue(textBatches.isEmpty())
|
||||
advanceTimeBy(STREAM_DELTA_FRAME_MS - 1)
|
||||
runCurrent()
|
||||
assertTrue(textBatches.isEmpty())
|
||||
|
||||
advanceTimeBy(1)
|
||||
runCurrent()
|
||||
assertEquals(listOf("x".repeat(streamDeltaFrameBudget(100))), textBatches)
|
||||
assertTrue(textBatches.joinToString("").length < 100)
|
||||
|
||||
advanceUntilIdle()
|
||||
assertEquals("x".repeat(100), textBatches.joinToString(""))
|
||||
assertTrue(textBatches.size > 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text and thinking transitions preserve stream order`() = runTest {
|
||||
val events = mutableListOf<String>()
|
||||
val coalescer = StreamDeltaCoalescer(
|
||||
scope = this,
|
||||
onTextDelta = { events += "text:$it" },
|
||||
onThinkingDelta = { events += "thinking:$it" },
|
||||
)
|
||||
|
||||
coalescer.appendThinking("plan ")
|
||||
coalescer.appendThinking("first")
|
||||
coalescer.appendText("answer ")
|
||||
coalescer.appendText("next")
|
||||
coalescer.appendThinking("tail")
|
||||
coalescer.flushNow()
|
||||
|
||||
assertEquals(
|
||||
listOf("thinking:plan first", "text:answer next", "thinking:tail"),
|
||||
events,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal flush cancels the scheduled duplicate`() = runTest {
|
||||
val textBatches = mutableListOf<String>()
|
||||
val coalescer = StreamDeltaCoalescer(
|
||||
scope = this,
|
||||
onTextDelta = textBatches::add,
|
||||
onThinkingDelta = {},
|
||||
)
|
||||
|
||||
coalescer.appendText("complete before timer")
|
||||
coalescer.flushNow()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("complete before timer"), textBatches)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `frame pacing never separates a surrogate pair`() = runTest {
|
||||
val textBatches = mutableListOf<String>()
|
||||
val coalescer = StreamDeltaCoalescer(
|
||||
scope = this,
|
||||
onTextDelta = textBatches::add,
|
||||
onThinkingDelta = {},
|
||||
)
|
||||
|
||||
coalescer.appendText("1234567🚀tail")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("1234567🚀tail", textBatches.joinToString(""))
|
||||
assertTrue(textBatches.none { it.endsWith('\uD83D') })
|
||||
assertTrue(textBatches.none { it.startsWith('\uDE80') })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard drops pending content`() = runTest {
|
||||
val textBatches = mutableListOf<String>()
|
||||
val coalescer = StreamDeltaCoalescer(
|
||||
scope = this,
|
||||
onTextDelta = textBatches::add,
|
||||
onThinkingDelta = {},
|
||||
)
|
||||
|
||||
coalescer.appendText("old connection")
|
||||
coalescer.discard()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(textBatches.isEmpty())
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -63,7 +63,7 @@ Windows downloads and verifies `hermes-relay-windows-x64-setup.exe`. The install
|
||||
places the CLI and systray together, adds `~/.hermes/bin` to the user PATH, and
|
||||
lets the systray start at sign-in. CLI-only installs download the same prebuilt
|
||||
single-file CLI binary without the systray. Pin a release with
|
||||
`HERMES_RELAY_VERSION=cli-v0.3.0-alpha.18`; CLI-only installs can override the
|
||||
`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.18`; CLI-only installs can override the
|
||||
install directory with `HERMES_RELAY_INSTALL_DIR=...`.
|
||||
|
||||
After install, use `hermes-relay <prompt>`. The shorter `hermes <prompt>` alias is optional because it can shadow a real local hermes-agent install. Enable it only when you want hermes-relay to be the `hermes` command for tools like Orca:
|
||||
@@ -550,7 +550,7 @@ Precedence for credentials: `--token` → `HERMES_RELAY_TOKEN` → `--code` →
|
||||
|
||||
## Roadmap
|
||||
|
||||
What's shipped on the `cli-v*` track: remote chat + tool-event rendering,
|
||||
What's shipped on the `desktop-v*` track: remote chat + tool-event rendering,
|
||||
one-time pairing, the interactive PTY/TUI shell, client-side tool routing,
|
||||
auto-reconnect with TOFU cert pinning, server-side session management, the
|
||||
headless daemon, local diagnostics, and the optional menu-only Windows systray.
|
||||
|
||||
+12
-12
@@ -6,7 +6,7 @@
|
||||
# Install only the CLI binary instead:
|
||||
# $env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm ... | iex
|
||||
# Pin a specific release:
|
||||
# $env:HERMES_RELAY_VERSION='cli-v0.3.0-alpha.18'; irm ... | iex
|
||||
# $env:HERMES_RELAY_VERSION='desktop-v0.3.0-alpha.18'; irm ... | iex
|
||||
# Override CLI install dir:
|
||||
# $env:HERMES_RELAY_INSTALL_DIR='C:\tools\hermes\bin'; irm ... | iex
|
||||
# Optional `hermes` alias for Orca/upstream-style workflows:
|
||||
@@ -54,7 +54,7 @@ function Read-InstalledVersion {
|
||||
}
|
||||
}
|
||||
|
||||
# Strip the `cli-v` tag prefix to get the bare semver (KEEPS the
|
||||
# Strip the release tag prefix to get the bare semver (KEEPS the
|
||||
# prerelease suffix — `0.3.0-alpha.11`, not `0.3.0`). The binary's
|
||||
# `--version` output has reported the full semver since alpha.4 (when
|
||||
# `gen:version` started embedding the full string from package.json), so
|
||||
@@ -66,8 +66,8 @@ function Get-NormalizedPin {
|
||||
param([string]$Pin)
|
||||
if (-not $Pin -or $Pin -eq 'latest') { return '' }
|
||||
$v = $Pin
|
||||
if ($v.StartsWith('cli-v')) { $v = $v.Substring('cli-v'.Length) }
|
||||
elseif ($v.StartsWith('desktop-v')) { $v = $v.Substring('desktop-v'.Length) }
|
||||
if ($v.StartsWith('desktop-v')) { $v = $v.Substring('desktop-v'.Length) }
|
||||
elseif ($v.StartsWith('cli-v')) { $v = $v.Substring('cli-v'.Length) }
|
||||
elseif ($v.StartsWith('v')) { $v = $v.Substring(1) }
|
||||
return $v
|
||||
}
|
||||
@@ -110,12 +110,12 @@ $asset = if ($surface -eq 'tray') { "hermes-relay-windows-$arch-setup.exe" } els
|
||||
|
||||
# Resolve "latest" to a concrete tag. GitHub's /releases/latest/download/ URL
|
||||
# always skips prereleases, which breaks install during any all-alpha window.
|
||||
# Walk the releases API and prefer the SemVer-max `cli-v*` tag. Historical
|
||||
# public prereleases used `desktop-v*`, so fall back to that track when no new
|
||||
# CLI tag exists. Pinned versions skip this and use the tag directly.
|
||||
# Walk the releases API and prefer the SemVer-max `desktop-v*` tag. Historical
|
||||
# public releases used `cli-v*`, so fall back to that track. Pinned versions
|
||||
# skip this and use the tag directly.
|
||||
$resolvedVersion = $version
|
||||
if ($version -eq 'latest') {
|
||||
Say "-> resolving latest cli-v* release..."
|
||||
Say "-> resolving latest desktop-v* release..."
|
||||
try {
|
||||
$releases = Invoke-RestMethod -UseBasicParsing "https://api.github.com/repos/$repo/releases"
|
||||
# Don't trust the API's first-element ordering — GitHub orders by the
|
||||
@@ -126,12 +126,12 @@ if ($version -eq 'latest') {
|
||||
# PRERANK is 1=alpha, 2=beta, 3=rc, 999=stable (semver §11: stable >
|
||||
# any prerelease) and PRENUM is the prerelease number (so alpha.10 >
|
||||
# alpha.9).
|
||||
$candidates = $releases | Where-Object { $_.tag_name -like 'cli-v*' }
|
||||
$candidates = $releases | Where-Object { $_.tag_name -like 'desktop-v*' }
|
||||
if (-not $candidates) {
|
||||
Say ' no cli-v* releases yet; checking historical desktop-v* prereleases...'
|
||||
$candidates = $releases | Where-Object { $_.tag_name -like 'desktop-v*' }
|
||||
Say ' no desktop-v* releases yet; checking historical cli-v* releases...'
|
||||
$candidates = $releases | Where-Object { $_.tag_name -like 'cli-v*' }
|
||||
}
|
||||
if (-not $candidates) { Die "no cli-v* or historical desktop-v* releases found on $repo" }
|
||||
if (-not $candidates) { Die "no desktop-v* or historical cli-v* releases found on $repo" }
|
||||
$pick = $candidates | Sort-Object @{Expression = {
|
||||
$v = $_.tag_name -replace '^cli-v', ''
|
||||
$v = $v -replace '^desktop-v', ''
|
||||
|
||||
+12
-12
@@ -5,7 +5,7 @@
|
||||
#
|
||||
# Downloads a prebuilt binary from GitHub Releases — no Node.js required.
|
||||
# Pin a specific release:
|
||||
# HERMES_RELAY_VERSION=cli-v0.3.0-alpha.18 curl -fsSL ... | sh
|
||||
# HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.18 curl -fsSL ... | sh
|
||||
# Override install dir:
|
||||
# HERMES_RELAY_INSTALL_DIR=/opt/hermes curl -fsSL ... | sh
|
||||
# Optional `hermes` alias for Orca/upstream-style workflows:
|
||||
@@ -44,7 +44,7 @@ read_installed_version() {
|
||||
printf '%s' "$line" | awk '{print $2}'
|
||||
}
|
||||
|
||||
# Strip the `cli-v` tag prefix to get the bare semver (KEEPS the
|
||||
# Strip the release tag prefix to get the bare semver (KEEPS the
|
||||
# prerelease suffix — `0.3.0-alpha.11`, not `0.3.0`). The binary's
|
||||
# `--version` output has reported the full semver since alpha.4 (when
|
||||
# `gen:version` started embedding the full string from package.json), so
|
||||
@@ -56,9 +56,9 @@ normalize_pinned_version() {
|
||||
local v="$1"
|
||||
# Empty or "latest" → unknown; caller decides.
|
||||
[ -z "$v" ] || [ "$v" = "latest" ] && { printf ''; return 0; }
|
||||
# Strip leading `cli-v` (current convention) or historical `desktop-v`.
|
||||
v="${v#cli-v}"
|
||||
# Strip leading `desktop-v` (current convention) or historical `cli-v`.
|
||||
v="${v#desktop-v}"
|
||||
v="${v#cli-v}"
|
||||
# Strip leading `v` just in case someone pinned `v0.3.0`.
|
||||
v="${v#v}"
|
||||
printf '%s' "$v"
|
||||
@@ -99,12 +99,12 @@ esac
|
||||
|
||||
# Resolve "latest" to a concrete tag. GitHub's /releases/latest/download/ URL
|
||||
# always skips prereleases, which breaks install during any all-alpha window.
|
||||
# Walk the releases API and prefer the SemVer-max `cli-v*` tag. Historical
|
||||
# public prereleases used `desktop-v*`, so fall back to that track when no new
|
||||
# CLI tag exists. Pinned versions skip this and use the tag directly.
|
||||
# Walk the releases API and prefer the SemVer-max `desktop-v*` tag. Historical
|
||||
# public releases used `cli-v*`, so fall back to that track. Pinned versions
|
||||
# skip this and use the tag directly.
|
||||
resolved_version="$VERSION"
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
say "-> resolving latest cli-v* release..."
|
||||
say "-> resolving latest desktop-v* release..."
|
||||
api_body=$(curl -fsSL "https://api.github.com/repos/$REPO/releases" 2>/dev/null) \
|
||||
|| die "could not query GitHub Releases API"
|
||||
# Extract every CLI-track tag_name and pick the SemVer-max. Don't trust the
|
||||
@@ -116,19 +116,19 @@ if [ "$VERSION" = "latest" ]; then
|
||||
| grep -E '"tag_name": *"(cli-v|desktop-v)' \
|
||||
| sed -E 's/.*"tag_name": *"([^"]+)".*/\1/' || true)
|
||||
resolved_version=$(printf '%s\n' "$release_tags" \
|
||||
| awk '/^cli-v/ { v=$0; sub(/^cli-v/, "", v); print v "\t" $0 }' \
|
||||
| awk '/^desktop-v/ { v=$0; sub(/^desktop-v/, "", v); print v "\t" $0 }' \
|
||||
| sort -V \
|
||||
| tail -1 \
|
||||
| cut -f2)
|
||||
if [ -z "$resolved_version" ]; then
|
||||
say " no cli-v* releases yet; checking historical desktop-v* prereleases..."
|
||||
say " no desktop-v* releases yet; checking historical cli-v* releases..."
|
||||
resolved_version=$(printf '%s\n' "$release_tags" \
|
||||
| awk '/^desktop-v/ { v=$0; sub(/^desktop-v/, "", v); print v "\t" $0 }' \
|
||||
| awk '/^cli-v/ { v=$0; sub(/^cli-v/, "", v); print v "\t" $0 }' \
|
||||
| sort -V \
|
||||
| tail -1 \
|
||||
| cut -f2)
|
||||
fi
|
||||
[ -n "$resolved_version" ] || die "no cli-v* or historical desktop-v* releases found on $REPO"
|
||||
[ -n "$resolved_version" ] || die "no desktop-v* or historical cli-v* releases found on $REPO"
|
||||
say " $resolved_version"
|
||||
fi
|
||||
base="https://github.com/$REPO/releases/download/$resolved_version"
|
||||
|
||||
+1
-1
@@ -174,7 +174,7 @@ Usage:
|
||||
hermes-relay daemon [start|stop|restart|status] Headless tool router — 'start' runs it in the background
|
||||
hermes-relay doctor Diagnostic report: version, paths, sessions, daemon status
|
||||
hermes-relay grants Review pending local computer-use grants
|
||||
hermes-relay update Check for and install the latest cli-v* release
|
||||
hermes-relay update Check for and install the latest desktop-v* release
|
||||
hermes-relay voice Show native Hermes voice config (STT/TTS/realtime providers)
|
||||
hermes-relay voice mode Push-to-talk in a browser tab (proxied through this CLI)
|
||||
hermes-relay workspace Print local workspace context (cwd, git, editor, shell) — --json for scripting
|
||||
|
||||
@@ -149,7 +149,7 @@ export async function updateCommand(args: ParsedArgs): Promise<number> {
|
||||
return 0
|
||||
}
|
||||
process.stdout.write(`Current version: ${VERSION}\n`)
|
||||
process.stdout.write(`No cli-v* or historical desktop-v* releases found on the upstream repo.\n`)
|
||||
process.stdout.write(`No desktop-v* or historical cli-v* releases found on the upstream repo.\n`)
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Query the GitHub Releases API for this repo (default Codename-11/hermes-relay),
|
||||
// prefer tags starting with `cli-v`, and fall back to historical
|
||||
// `desktop-v` prereleases. Zero deps.
|
||||
// prefer tags starting with `desktop-v`, and fall back to historical
|
||||
// `cli-v` releases. Zero deps.
|
||||
// 2. Pick the asset for this platform (`process.platform` + `process.arch`).
|
||||
// 3. Download to `<target>.download`, verify against SHA256SUMS.txt, then
|
||||
// atomically rename — POSIX rename keeps the running process's inode
|
||||
@@ -85,7 +85,7 @@ interface ParsedVersion {
|
||||
}
|
||||
|
||||
function parseVersion(raw: string): ParsedVersion | null {
|
||||
// Strip leading `v` / `cli-v` / historical `desktop-v`.
|
||||
// Strip leading `v` / `desktop-v` / historical `cli-v`.
|
||||
let v = raw.trim()
|
||||
if (v.startsWith('cli-v')) v = v.slice('cli-v'.length)
|
||||
else if (v.startsWith('desktop-v')) v = v.slice('desktop-v'.length)
|
||||
@@ -196,16 +196,16 @@ export async function checkForUpdate(opts: { repo?: string } = {}): Promise<Upda
|
||||
const repo = opts.repo ?? DEFAULT_REPO
|
||||
const releases = await fetchReleases(repo)
|
||||
|
||||
// Prefer cli-v* tags. Historical public prereleases used desktop-v*, so keep
|
||||
// a fallback while the alpha channel migrates. NOTE: GitHub orders the
|
||||
// Prefer desktop-v* tags. Historical public releases used cli-v*, so keep
|
||||
// a fallback while the stable surface names migrate. NOTE: GitHub orders the
|
||||
// response by the release row's created_at, NOT by SemVer of the tag — and
|
||||
// "created_at" can shift when the row is touched (re-tag, manual edit).
|
||||
// Don't trust [0]; pick the SemVer-maximum tag explicitly so a touched alpha
|
||||
// row can't outrank a freshly-tagged release.
|
||||
const cli = releases.filter((r) => r.tag_name.startsWith('cli-v'))
|
||||
const candidates = cli.length > 0
|
||||
? cli
|
||||
: releases.filter((r) => r.tag_name.startsWith('desktop-v'))
|
||||
const desktop = releases.filter((r) => r.tag_name.startsWith('desktop-v'))
|
||||
const candidates = desktop.length > 0
|
||||
? desktop
|
||||
: releases.filter((r) => r.tag_name.startsWith('cli-v'))
|
||||
if (candidates.length === 0) return null
|
||||
const pick = candidates.reduce((max, r) =>
|
||||
compareVersions(r.tag_name, max.tag_name) > 0 ? r : max
|
||||
|
||||
+7
-5
@@ -369,7 +369,7 @@ Key data classes: `MessageEvent` (inbound), `SendResult` (outbound), `SessionSou
|
||||
- **Upstream doesn't solve this on the HTTP API surface.** Verification against `~/AppData/Local/Temp/hermes-agent/gateway/platforms/api_server.py` shows `APIServerAdapter.send()` is an explicit no-op with the comment `"API server uses HTTP request/response, not send()"`. `_write_sse_chat_completion` (api_server.py:651-757) streams raw `stream_q` deltas straight into SSE `content` chunks — it never invokes `extract_media()` and never routes deltas through `GatewayStreamConsumer` (which would at least strip `MEDIA:` tags via `_MEDIA_RE` at `stream_consumer.py:188`). The upstream `extract_media()` / `send_document()` calls at `gateway/run.py:4570`, `4747`, `4349` are only reachable from **non-streaming** paths (background tasks, cron, batch) and push-style platform adapters (Telegram, Feishu, WeChat, Slack), all of which override `send_document` with real platform APIs. The pull-based HTTP adapter inherits the base class default, which falls back to `self.send(chat_id, f"📎 File: {file_path}")` — which is the no-op. So `MEDIA:/tmp/...` has always passed through our chat stream as literal text.
|
||||
- **Inline base64 in tool output was the obvious alternative but blows up LLM context.** A 1280×720 JPEG is ~135 KB base64, and every subsequent turn's context window has to re-ingest the bytes. Scales badly for video or multiple attachments per turn. Opaque tokens are ~25 chars and add essentially zero context cost.
|
||||
- **No upstream PR in scope.** Fixing this properly upstream would mean implementing `send_document` on `APIServerAdapter` (likely via a side-channel SSE event or a new attachment field on the chat-completion chunk shape). That's a community-scoped API change, and user explicitly wanted an in-plugin workaround, not a fork.
|
||||
- **Our relay is already the right place.** The plugin's relay server (`plugin/relay/server.py`) is a service we already own, already has HTTP routes (`/health`, `/pairing`, `/pairing/register`), already uses `SessionManager` for bearer-auth'd channels, and already lives on the phone's trust boundary (paired via the same QR). Adding file-serving doesn't create a new security surface or a new credential store — it reuses both.
|
||||
- **Our relay is already the right place.** The plugin's relay server (`plugin/relay/server.py`) is a service we already own, already has HTTP routes (`/health`, `/pairing/register`), already uses `SessionManager` for bearer-auth'd channels, and already lives on the phone's trust boundary (paired via the same QR). Adding file-serving doesn't create a new security surface or a new credential store — it reuses both.
|
||||
|
||||
**How it works:**
|
||||
|
||||
@@ -465,7 +465,8 @@ The bare-path fetch is therefore safe as long as operators treat the allowed-roo
|
||||
|
||||
- **Grants on a single token (not multiple tokens)** — one WSS connection, one auth envelope, one session lookup. Per-channel expiry is checked at channel message dispatch time via `Session.channel_is_expired(name)`. Simpler to reason about than multiple parallel tokens, and the phone only needs one storage slot.
|
||||
- **`math.inf` for never-expire** — represents "truly unbounded" in code, serializes to `null` on the wire (JSON doesn't have an infinity literal, and null maps cleanly to Kotlin's nullable `Long?`). `canonicalize()` uses `allow_nan=False` so accidentally trying to sign a payload with a raw `math.inf` crashes loudly — callers must explicitly emit `None`/`0`. Prevents silent serialization bugs.
|
||||
- **Metadata on pairing entries, host wins over phone** — when the host operator runs `hermes pair --ttl 7d` and the phone sends `ttl_seconds=30d` in the auth envelope (because the user picked a different value on the TTL dialog), the host value wins. Operator policy is authoritative. If the host didn't specify anything, the phone's value applies.
|
||||
- **Metadata on pairing entries is host-authoritative** — when the host operator runs `hermes pair --ttl 7d` and the phone sends `ttl_seconds=30d` in the auth envelope (because the user picked a different value on the TTL dialog), the host value wins. If host metadata is absent, the relay uses bounded server defaults; network clients never author session lifetime or grants. The legacy anonymous `POST /pairing` code-mint route is intentionally not registered, so every accepted code originates from a loopback-only operator flow.
|
||||
- **Bearer session-policy changes are monotonic and self-only** — a Relay bearer may use `PATCH /sessions/{token_prefix}` only for its own token and only to shorten its session or grants. It cannot add grant names, lengthen a grant, switch to never-expire, or modify another session. Those authority-increasing changes require a fresh operator-approved pairing flow.
|
||||
- **Token prefix (not full token) in `/sessions` responses** — a caller already holds their own full token; they should never see another session's full token. First 8 chars are enough to identify devices in a practical deployment (one operator, 1-3 phones) and enough entropy to avoid collisions. Collisions return 409 with the match count.
|
||||
- **Always open the TTL picker (no skip)** — even when the QR carries an operator-chosen TTL, the dialog opens with that value preselected. The user is always in the loop for the trust decision. A future "don't ask again if QR specifies a TTL" toggle is a plausible refinement but not in this cut.
|
||||
|
||||
@@ -846,9 +847,9 @@ First attempt parsed a top-level `profiles:` / `agents:` list from one YAML. Tha
|
||||
|
||||
The hermes-agent dashboard exposes `/api/config` and `/api/skills` via `hermes_cli/web_server.py` — a separate loopback-only web server from the chat API at `:8642`. Two problems if we proxied through the dashboard:
|
||||
1. **No profile scoping.** The dashboard's `/api/config` operates on the active profile only; there's no way to read another profile's config without switching first. Our relay already has the layout knowledge (`_load_profiles` scans the tree); duplicating that as "switch profile, read, switch back" is fragile and racy.
|
||||
2. **Secrets leakage risk.** `config.yaml` never holds credentials (those live in `~/.hermes/.env` + `~/.hermes/auth.json`), but proxying a general-purpose config endpoint invites future callers to pick up sensitive fields. A purpose-built read route keeps the attack surface small and the shape explicit — the response is `{profile, path, config, readonly: true}`, with the `readonly` flag part of the contract so clients can't silently assume write support.
|
||||
2. **Secrets leakage risk.** Credentials normally live in `~/.hermes/.env` or `~/.hermes/auth.json`, but Hermes also supports some credentials in `config.yaml` and extensions may add their own sensitive fields. A purpose-built read route therefore keeps the remote shape explicit: paired remote clients receive only `description` and `model.default`, while loopback operator callers can inspect the complete parsed file. The response remains `{profile, path, config, readonly: true}`, with `path: "config.yaml"` remotely so host layout is not disclosed.
|
||||
|
||||
Both endpoints trust the same boundary as every other phone-facing relay route: bearer-auth for remote callers, loopback for in-process dashboard proxy calls. `.env` and `auth.json` are **never** read or returned by these routes.
|
||||
Both endpoints trust the same boundary as every other phone-facing relay route: bearer-auth for remote callers, loopback for in-process dashboard proxy calls. The config endpoint additionally enforces an explicit remote response schema rather than key-name redaction, so new or nested extension sections cannot silently become public. `.env` and `auth.json` are **never** read or returned by these routes.
|
||||
|
||||
**Why read-only in v0.7:**
|
||||
|
||||
@@ -2011,7 +2012,8 @@ settings, an overlay, and its own daemon ownership. That duplicated the CLI/TUI
|
||||
contract, obscured which surface was authoritative, and made a background tray
|
||||
helper carry a full WebView application architecture.
|
||||
|
||||
**Decision.** Hermes-Relay desktop has exactly two deliverables on the `cli-v*`
|
||||
**Decision.** Hermes-Relay desktop has exactly two deliverables on the
|
||||
`desktop-v*` production track (historical `cli-v*` tags remain immutable):
|
||||
track:
|
||||
|
||||
1. `hermes-relay`, the primary cross-platform CLI and terminal TUI. Interactive
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"schema_version": 3,
|
||||
"canonical_locale": "en",
|
||||
"verification_definitions": {
|
||||
"canonical": "English source text that defines product meaning.",
|
||||
@@ -8,6 +8,30 @@
|
||||
"verified": "Comprehensively reviewed in context by a fluent contributor across the shipped surface."
|
||||
},
|
||||
"locales": {
|
||||
"de": {
|
||||
"native_name": "Deutsch",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0b9cdf734d26aea8c37502e8e3109d305985e3493375fbf1ccd4ce93a1bd44d3",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
"android": "complete",
|
||||
"readme": "english-fallback",
|
||||
"user_docs": "core-pages",
|
||||
"website": "complete"
|
||||
},
|
||||
"docs_locale": "de",
|
||||
"docs_source_sha256": {
|
||||
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
|
||||
"guide/quick-start.md": "3c59c6b4fe188db19fb38eb026a25036a5ad588f04f91936ad6e30a6582024f9",
|
||||
"guide/getting-started.md": "c7119552d0ff8c46e5f081947db7f90098f54b94341bb41ee501fc15cd825410",
|
||||
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
|
||||
"guide/troubleshooting.md": "9285efecd02b8a8bf3ee7bb421299e32ae436f5e33cd351ee20c8e0bd4c55152"
|
||||
},
|
||||
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
|
||||
},
|
||||
"en": {
|
||||
"native_name": "English",
|
||||
"verification": "canonical",
|
||||
@@ -15,7 +39,8 @@
|
||||
"surfaces": {
|
||||
"android": "canonical",
|
||||
"readme": "canonical",
|
||||
"user_docs": "canonical"
|
||||
"user_docs": "canonical",
|
||||
"website": "canonical"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
@@ -23,28 +48,96 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "fe2f099227cc377e298aac67ae402099e252e5474eeab230ecde119e117149ae",
|
||||
"main": "0b9cdf734d26aea8c37502e8e3109d305985e3493375fbf1ccd4ce93a1bd44d3",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
"android": "complete",
|
||||
"readme": "english-fallback",
|
||||
"user_docs": "english-fallback"
|
||||
}
|
||||
"user_docs": "core-pages",
|
||||
"website": "complete"
|
||||
},
|
||||
"docs_locale": "es",
|
||||
"docs_source_sha256": {
|
||||
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
|
||||
"guide/quick-start.md": "3c59c6b4fe188db19fb38eb026a25036a5ad588f04f91936ad6e30a6582024f9",
|
||||
"guide/getting-started.md": "c7119552d0ff8c46e5f081947db7f90098f54b94341bb41ee501fc15cd825410",
|
||||
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
|
||||
"guide/troubleshooting.md": "9285efecd02b8a8bf3ee7bb421299e32ae436f5e33cd351ee20c8e0bd4c55152"
|
||||
},
|
||||
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
|
||||
},
|
||||
"ja": {
|
||||
"native_name": "日本語",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0b9cdf734d26aea8c37502e8e3109d305985e3493375fbf1ccd4ce93a1bd44d3",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
"android": "complete",
|
||||
"readme": "english-fallback",
|
||||
"user_docs": "core-pages",
|
||||
"website": "complete"
|
||||
},
|
||||
"docs_locale": "ja",
|
||||
"docs_source_sha256": {
|
||||
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
|
||||
"guide/quick-start.md": "3c59c6b4fe188db19fb38eb026a25036a5ad588f04f91936ad6e30a6582024f9",
|
||||
"guide/getting-started.md": "c7119552d0ff8c46e5f081947db7f90098f54b94341bb41ee501fc15cd825410",
|
||||
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
|
||||
"guide/troubleshooting.md": "9285efecd02b8a8bf3ee7bb421299e32ae436f5e33cd351ee20c8e0bd4c55152"
|
||||
},
|
||||
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
|
||||
},
|
||||
"pt-BR": {
|
||||
"native_name": "Português (Brasil)",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0b9cdf734d26aea8c37502e8e3109d305985e3493375fbf1ccd4ce93a1bd44d3",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
"android": "complete",
|
||||
"readme": "english-fallback",
|
||||
"user_docs": "core-pages",
|
||||
"website": "complete"
|
||||
},
|
||||
"docs_locale": "pt-BR",
|
||||
"docs_source_sha256": {
|
||||
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
|
||||
"guide/quick-start.md": "3c59c6b4fe188db19fb38eb026a25036a5ad588f04f91936ad6e30a6582024f9",
|
||||
"guide/getting-started.md": "c7119552d0ff8c46e5f081947db7f90098f54b94341bb41ee501fc15cd825410",
|
||||
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
|
||||
"guide/troubleshooting.md": "9285efecd02b8a8bf3ee7bb421299e32ae436f5e33cd351ee20c8e0bd4c55152"
|
||||
},
|
||||
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
|
||||
},
|
||||
"zh-Hans": {
|
||||
"native_name": "简体中文",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "fe2f099227cc377e298aac67ae402099e252e5474eeab230ecde119e117149ae",
|
||||
"main": "0b9cdf734d26aea8c37502e8e3109d305985e3493375fbf1ccd4ce93a1bd44d3",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
"android": "complete",
|
||||
"readme": "maintained-summary",
|
||||
"user_docs": "core-pages"
|
||||
}
|
||||
"user_docs": "core-pages",
|
||||
"website": "complete"
|
||||
},
|
||||
"docs_locale": "zh-CN",
|
||||
"docs_source_sha256": {
|
||||
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
|
||||
"guide/quick-start.md": "3c59c6b4fe188db19fb38eb026a25036a5ad588f04f91936ad6e30a6582024f9",
|
||||
"guide/getting-started.md": "c7119552d0ff8c46e5f081947db7f90098f54b94341bb41ee501fc15cd825410",
|
||||
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
|
||||
"guide/troubleshooting.md": "9285efecd02b8a8bf3ee7bb421299e32ae436f5e33cd351ee20c8e0bd4c55152"
|
||||
},
|
||||
"website_source_sha256": "fa34022613a537b752a54b940cc166e4aa83bc7cb888a601c461780eca76c342"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+63
-7
@@ -1,8 +1,8 @@
|
||||
# Localization
|
||||
|
||||
English is the canonical product language. Android ships Simplified Chinese and
|
||||
Spanish catalogs; additional languages can be added without changing the runtime
|
||||
architecture.
|
||||
English is the canonical product language. Android also ships Brazilian
|
||||
Portuguese, German, Japanese, Simplified Chinese, and Spanish catalogs;
|
||||
additional languages can be added without changing the runtime architecture.
|
||||
|
||||
Translation coverage and linguistic verification are separate. Shipped locale
|
||||
status is recorded in `docs/localization-status.json` as `ai-translated`,
|
||||
@@ -11,10 +11,10 @@ technical gates pass; the status must not imply human review that did not occur.
|
||||
See `docs/translation-playbook.md` for the required translation and critique
|
||||
workflow.
|
||||
|
||||
Users can switch between System default, English, Spanish, and Simplified Chinese from
|
||||
Settings → Appearance → Language. The picker stays synchronized with Android's
|
||||
per-app language setting; Android 12 and lower use AppCompat's automatic locale
|
||||
storage.
|
||||
Users can switch between System default, English, Brazilian Portuguese, German,
|
||||
Japanese, Spanish, and Simplified Chinese from Settings → Appearance → Language.
|
||||
The picker stays synchronized with Android's per-app language setting; Android
|
||||
12 and lower use AppCompat's automatic locale storage.
|
||||
|
||||
## Android resource contract
|
||||
|
||||
@@ -92,6 +92,62 @@ should link to the canonical English page rather than copying stale content.
|
||||
status. README and user-documentation translations may follow app translation;
|
||||
maintainer `docs/` and ADRs remain canonical English.
|
||||
|
||||
The public documentation currently localizes a deliberately bounded first-run
|
||||
set for every Android locale:
|
||||
|
||||
- documentation home;
|
||||
- Quick Start;
|
||||
- condensed Installation & Setup;
|
||||
- release-track choice;
|
||||
- symptom-first Troubleshooting.
|
||||
|
||||
Fast-moving API, architecture, security, CLI, and operator references remain
|
||||
canonical English and are linked from localized pages instead of copied. Each
|
||||
localized page declares `translation_status` and `canonical_source` in its
|
||||
frontmatter. `docs_source_sha256` in the status registry records the exact
|
||||
English page revision used for every locale.
|
||||
|
||||
Validate localized documentation and links with:
|
||||
|
||||
```bash
|
||||
python scripts/check-user-docs-locales.py
|
||||
```
|
||||
|
||||
After intentionally refreshing all five locale versions of a changed English
|
||||
core page, record the new canonical hashes with:
|
||||
|
||||
```bash
|
||||
python scripts/check-user-docs-locales.py --refresh
|
||||
```
|
||||
|
||||
The validator rejects stale source hashes, missing pages, broken internal
|
||||
links, unbalanced code fences, and translated or invented executable lines.
|
||||
VitePress runs this gate automatically before development and production builds.
|
||||
|
||||
## Marketing website
|
||||
|
||||
The product site ships the same locale set under `/de/`, `/es/`, `/ja/`,
|
||||
`/pt-BR/`, and `/zh-CN/`. Marketing copy, navigation, accessibility labels, and
|
||||
page metadata are localized. Product screenshots, command examples, and live UI
|
||||
recreations remain unchanged so they continue to represent the shipped product.
|
||||
|
||||
Validate the typed copy dictionaries and their English-source freshness with:
|
||||
|
||||
```bash
|
||||
python scripts/check-website-locales.py
|
||||
```
|
||||
|
||||
After reviewing every marketing translation against an intentional English copy
|
||||
change, record the new source hash with:
|
||||
|
||||
```bash
|
||||
python scripts/check-website-locales.py --refresh
|
||||
```
|
||||
|
||||
The Astro development, check, and production-build commands run this gate
|
||||
automatically. Locale routes publish their own canonical URL, language metadata,
|
||||
alternate-language links, and sitemap entry.
|
||||
|
||||
## Translation corrections and pull requests
|
||||
|
||||
Keep translation PRs scoped to one locale or one clearly described catalog
|
||||
|
||||
+2
-2
@@ -127,13 +127,13 @@ cell "identical").
|
||||
### Editor validation (JSON Schema)
|
||||
|
||||
A JSON Schema for this manifest is published at
|
||||
`https://codename-11.github.io/hermes-relay/pet.schema.json` (source of truth:
|
||||
`https://hermes-relay.dev/docs/pet.schema.json` (source of truth:
|
||||
`user-docs/public/pet.schema.json`). Add it as the first key of a `pet.json` for
|
||||
editor autocomplete and inline validation — and for an AI agent to lint its own
|
||||
output against:
|
||||
|
||||
```json
|
||||
{ "$schema": "https://codename-11.github.io/hermes-relay/pet.schema.json", "id": "blob", "states": { "idle": { "frames": ["idle.png"], "fps": 6 } } }
|
||||
{ "$schema": "https://hermes-relay.dev/docs/pet.schema.json", "id": "blob", "states": { "idle": { "frames": ["idle.png"], "fps": 6 } } }
|
||||
```
|
||||
|
||||
The `$schema` key is an unknown field to the loader and is silently ignored
|
||||
|
||||
@@ -85,14 +85,11 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
v1.4.6 - Profiles stay together
|
||||
v1.4.8 - Privacy policy link restored
|
||||
|
||||
Profile continuity
|
||||
* Server default keeps its agent, chats, drawer, and transcript in the active Hermes profile.
|
||||
* Reorder or hide profiles per connection.
|
||||
|
||||
Profile icons
|
||||
* Choose a phone image or import avatar.png/profile.jpg from an updated paired Relay.
|
||||
* The privacy policy now lives at hermes-relay.dev.
|
||||
* The About screen opens the hosted policy directly.
|
||||
* Releases verify the public policy before publishing.
|
||||
```
|
||||
|
||||
## Category
|
||||
@@ -134,6 +131,15 @@ path-filtered Play Store Listing workflow or publish locally with:
|
||||
|
||||
Submission-time declarations the Play Console requires — keep in sync with the merged `googlePlay` manifest.
|
||||
|
||||
### Privacy policy
|
||||
|
||||
Use `https://hermes-relay.dev/privacy.html` as the Play Console privacy-policy
|
||||
URL. The Android preflight and release workflows require both that canonical
|
||||
page and the historical GitHub Pages compatibility URL to return the complete
|
||||
policy before they can publish. The standard Android Publisher listing API does
|
||||
not expose this Play policy-declaration field, so the legacy URL remains a
|
||||
permanent compatibility page for existing Console metadata.
|
||||
|
||||
### App access
|
||||
|
||||
Hermes-Relay is a client for a **user-run Hermes server**. A fresh install with no server configured has no content of its own — which is what a reviewer hits first, and what triggered the v1.2.4 *App access* rejection. The core experience is reviewable **offline via Demo mode**, with **no test server, account, or credentials required**.
|
||||
|
||||
@@ -291,13 +291,12 @@ See [`docs/spec.md` §3.3](spec.md) for the full auth flow and the QR wire forma
|
||||
|-------|--------|---------|
|
||||
| `/ws`, `/` | GET (upgrade) | Main WebSocket endpoint. Phone connects, sends `system/auth`, then multiplexes `chat`/`terminal`/`bridge` envelopes. |
|
||||
| `/health` | GET | Returns `{status, version, clients, sessions}` JSON. |
|
||||
| `/pairing` | POST | Generate a new relay-side pairing code. Returns `{"code": "ABC123"}`. Unrestricted (intended for host-local callers). |
|
||||
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can appear in a QR payload before the phone scans it. Request body: `{"code": "ABCD12", "ttl_seconds": 2592000, "grants": {"terminal": 604800, "bridge": 86400}, "transport_hint": "wss"}` — `ttl_seconds` / `grants` / `transport_hint` are all optional; if omitted the phone's chosen values (or the SessionManager defaults) are used. Response: `{"ok": true, "code": "ABCD12"}`. Returns HTTP 403 for any `request.remote` other than `127.0.0.1` / `::1`. **As of ADR 15 this endpoint clears all rate-limit blocks on success** — the operator is explicitly re-pairing, stale blocks should not prevent the new code from being consumed. Used by `hermes pair` / `/hermes-relay-pair`; `hermes-pair` remains a compatibility shim. |
|
||||
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can appear in a QR payload before the phone scans it. Request body: `{"code": "ABCD12", "ttl_seconds": 2592000, "grants": {"terminal": 604800, "bridge": 86400}, "transport_hint": "wss"}` — `ttl_seconds` / `grants` / `transport_hint` are all optional; if omitted the SessionManager's bounded defaults are used. Client-supplied policy in the WebSocket auth envelope is never authoritative. Response: `{"ok": true, "code": "ABCD12"}`. Returns HTTP 403 for any `request.remote` other than `127.0.0.1` / `::1`. **As of ADR 15 this endpoint clears all rate-limit blocks on success** — the operator is explicitly re-pairing, stale blocks should not prevent the new code from being consumed. Used by `hermes pair` / `/hermes-relay-pair`; `hermes-pair` remains a compatibility shim. |
|
||||
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and return the signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) used by dashboard and desktop pair/repair flows. Reads `API_SERVER_KEY` from the host-local config chain when the dashboard does not pass `api_key` explicitly. Optional request field `dashboard_url` is mirrored into the QR payload and response. |
|
||||
| `/pairing/approve` | POST | **Loopback only, Phase 3 stub.** Same wire shape and loopback gate as `/pairing/register` — present so the Android client can target the route today. The semantic difference (operator reviewing a phone-initiated pending code before approval) still needs the pending-codes store + approval UX, marked `# TODO(Phase 3)` in the handler. |
|
||||
| `/sessions` | GET | Bearer-auth'd. Returns `{"sessions": [ {token_prefix, device_name, device_id, created_at, last_seen, expires_at, grants, transport_hint, is_current}, ... ]}` for all currently-active paired devices. `token_prefix` is the first 8 characters of the session token — full tokens are NEVER included, so a caller holding one session token can't extract another. `expires_at` and grant values that are `math.inf` serialize as `null` (never expire). `is_current` is true for the session matching the caller's bearer. 401 on missing/invalid bearer. Used by the Android Paired Devices screen. **Loopback branch (2026-04-18):** callers on `127.0.0.1` / `::1` may skip the bearer and receive the same `{sessions: [...]}` payload without the `is_current` flag (no caller context). Added so the dashboard plugin proxy can list paired devices without needing to mint its own bearer. Non-loopback callers still require the bearer and retain `is_current`. |
|
||||
| `/sessions/{token_prefix}` | DELETE | Bearer-auth'd. Revoke a paired device by first-N-char token prefix (N ≥ 4). Returns 200 `{"ok": true, "revoked_self": bool}` on exact match; 404 on zero matches; 409 on ambiguous (2+) matches with the count in the body. Self-revoke is allowed and flagged via `revoked_self: true` so the caller knows to wipe local state. Any paired device can revoke any other — see ADR 15 for the trade-off rationale. |
|
||||
| `/sessions/{token_prefix}` | PATCH | Bearer-auth'd. Update a paired device's session TTL and/or per-channel grants in place. Body: `{"ttl_seconds": 2592000}` (extend only) or `{"grants": {"terminal": 604800}}` (grants only) or both. `ttl_seconds = 0` means never-expire. **Semantics: TTL restarts the clock from now** — "extend by 30 days" = "30 days from now", not "old expiry + 30 days". If `grants` is omitted but `ttl_seconds` is provided and shorter than the existing expiry, existing grants are automatically clamped to the new session lifetime (no grant outlives its session). Returns 200 with the updated `{expires_at, grants}`; 400 on missing/invalid body; 404 on prefix miss; 409 on ambiguous prefix. Backs the Android Paired Devices "Extend" button. |
|
||||
| `/sessions/{token_prefix}` | PATCH | Bearer-auth'd, self-targeted, and reduction-only. Body `{"ttl_seconds": 3600}`, `{"grants": {"terminal": 600}}`, or both may shorten the caller's current session policy. A bearer cannot target another session, extend its lifetime, add or lengthen grants, or change a finite expiry to never-expire; authority-increasing changes require a fresh operator-approved pairing flow. Omitted grants retain their existing absolute ceilings and are clamped if the parent session is shortened. Returns 200 with the reduced `{expires_at, grants}`; 400 on missing/invalid or unknown grants; 403 on cross-session targets or policy expansion; 404 on prefix miss; 409 on ambiguous prefix. |
|
||||
| `/clipboard/inbox` | POST | Bearer-auth'd clipboard rendezvous used by remote clients before native platform clipboard fallback. |
|
||||
| `/media/register` | POST | **Loopback only.** Register a file path with the in-memory `MediaRegistry` and receive an opaque token. Used by host-local tools (`android_screenshot` etc.) to make a file fetchable by the paired phone without leaking the filesystem path. Request body: `{"path": "/abs/path", "content_type": "image/jpeg", "file_name": "screenshot.jpg"}`. Response: `{"ok": true, "token": "<url-safe-16>", "expires_at": <unix>}`. Returns 403 for non-loopback callers, 400 on validation failure (relative path, missing file, oversized, outside allowed roots, etc). Path sandboxing is enforced server-side — see ADR 14. |
|
||||
| `/media/upload` | POST | Bearer-auth'd small upload endpoint for phone-originated media. Accepts JSON `{file_name, content_type, content}` where `content` is base64 and registers the decoded bytes with the media registry. |
|
||||
@@ -322,6 +321,7 @@ See [`docs/spec.md` §3.3](spec.md) for the full auth flow and the QR wire forma
|
||||
| `/voice/realtime-agent/session` | POST | Creates a brokered Realtime Agent session bound to the active profile, optional Hermes chat session id, provider/model/voice/sample-rate, auth principal, and event log path. |
|
||||
| `/voice/realtime-agent/{session_id}` | GET websocket | Experimental broker websocket. For provider-native xAI or OpenAI sessions, Android sends `session.start`, `input_audio.append`, `input_audio.commit` without transcript text, `playback.drained`, `response.cancel`, `hermes.confirm`, and `session.close`. The relay streams PCM to the provider, normalizes transcript/audio/function-call events, brokers only the approved Hermes functions, and sends input transcript events, Hermes session/tool/confirmation state, provider PCM as `voice.output_audio.delta`, and final `voice.response.done`. Hermes remains the owner of tools, memory, transcript persistence, Android bridge safety, confirmations, and cancellation. |
|
||||
| `/bridge/activity` | GET | **Loopback only.** Returns the `BridgeHandler.recent_commands` ring buffer (max 100 entries) as `{"activity": [ {request_id, method, path, params, sent_at, response_status, result_summary, error, decision}, ... ]}` — newest first. Query param: `?limit=N` (1–500, default 100) caps the response size. `params` is redacted for any key in `{password, token, secret, otp, bearer}`; `decision` is one of `pending` / `executed` / `blocked` / `confirmed` / `timeout` / `error`. 403 for non-loopback callers. Consumed by the dashboard plugin's Bridge Activity tab. |
|
||||
| Device Control routes (`/screen`, `/tap`, `/type`, and peers) | GET/POST | Require `Authorization: Bearer <session_token>` and an active `bridge` grant before any request data is forwarded to a connected Android client. Host tools supply the token through `ANDROID_BRIDGE_TOKEN`; loopback callers do not bypass this gate. |
|
||||
| `/media/inspect` | GET | **Loopback only.** Returns `{"media": [ {token, file_name, content_type, size, created_at, expires_at, last_accessed, is_expired}, ... ]}` — `MediaRegistry.list_all()` snapshot, newest first. Absolute file paths are **never** included — only `file_name` (basename). Query param: `?include_expired=true` includes evicted entries (default false, hides them). 403 for non-loopback callers. Consumed by the dashboard plugin's Media Inspector tab. |
|
||||
| `/relay/info` | GET | Aggregate status and capability contract. Loopback dashboard calls may omit auth; remote callers require a paired-device bearer. Returns backward-compatible `version` plus `plugin_version`, `protocol_version`, stable `capabilities`, per-profile `relay_state`, counters, and `health`. |
|
||||
| `/relay/security` | GET/PATCH | **Loopback only.** Runtime security toggles for local operators, `hermes relay insecure-api-key`, and `hermes-relay insecure-api-key`. `GET` returns `{"allow_insecure_api_bearer": false, "trust_proxy_headers": false, "scope": "runtime"}`. `PATCH {"allow_insecure_api_bearer": true}` enables plain-LAN API-key voice auth immediately for the running relay; `false` disables it. This is not persisted across restarts. |
|
||||
|
||||
+5
-5
@@ -88,7 +88,7 @@ Connection lifecycle, auth, keepalive.
|
||||
|
||||
| Type | Direction | Payload |
|
||||
|------|-----------|---------|
|
||||
| `auth` (pairing mode) | App → Server | `{ pairing_code, ttl_seconds?, grants?, device_name, device_id }` — `ttl_seconds` / `grants` come from the phone's TTL picker dialog; host metadata wins over phone metadata when both are present |
|
||||
| `auth` (pairing mode) | App → Server | `{ pairing_code, ttl_seconds?, grants?, device_name, device_id }` — `ttl_seconds` / `grants` remain in the wire shape for client compatibility, but only policy attached by a loopback-only host flow is authoritative; missing host metadata uses bounded server defaults |
|
||||
| `auth` (session mode) | App → Server | `{ session_token, device_name, device_id }` — ttl/grants are not re-sent; server keeps the grant table keyed on the original pair |
|
||||
| `auth.ok` | Server → App | `{ session_token, server_version, profiles[], expires_at, grants, transport_hint }` — see below |
|
||||
| `auth.fail` | Server → App | `{ reason }` |
|
||||
@@ -300,6 +300,7 @@ Implementation references:
|
||||
| Tailscale helper (first-class) | `plugin/relay/tailscale.py` + `hermes-relay-tailscale` CLI (ADR 25). Publishes the loopback relay over the tailnet via `tailscale serve --bg --https=<port>`; managed TLS + tailnet ACL identity. Optional, graceful-absent when the binary isn't installed. Auto-retires when upstream PR #9295 lands. See [`docs/remote-access.md`](remote-access.md). |
|
||||
| Multi-endpoint pairing | Single QR carries an ordered list of `role: lan/tailscale/public/...` candidates with strict-priority selection (ADR 24). Phone re-probes reachability on every network change. Per-candidate `transport_hint` drives the plaintext-`ws://` consent dialog. |
|
||||
| Device revocation | Paired Devices screen → `GET /sessions` (tokens masked to 8-char prefix) / `DELETE /sessions/{token_prefix}` (self-revoke allowed, wipes local state + redirects to pair flow). Any paired device can revoke any other — trade-off documented in ADR 15. |
|
||||
| Session policy updates | `PATCH /sessions/{token_prefix}` is self-targeted and reduction-only for normal Relay bearers. Extending a lifetime, adding or lengthening grants, or changing another session requires a fresh operator-approved pairing flow. |
|
||||
| Terminal gate | Biometric/PIN required before terminal access (planned). |
|
||||
|
||||
---
|
||||
@@ -432,10 +433,9 @@ HTTP routes registered by `create_app()` in `plugin/relay/server.py`:
|
||||
|-------|--------|---------|
|
||||
| `/ws`, `/` | GET (upgrade) | WebSocket handler — main multiplexed channel |
|
||||
| `/health` | GET | Health check — returns `{status, version, clients, sessions}` |
|
||||
| `/pairing` | POST | Generate a new relay-side pairing code |
|
||||
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code. Used by the pair command (`hermes pair`, `/hermes-relay-pair`, or compatibility `hermes-pair`) to inject codes that will appear in QR payloads. Request: `{"code": "ABCD12"}`. Rejects non-loopback peers with HTTP 403. |
|
||||
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) for dashboard and CLI/tray pair/repair flows. Optional request field `dashboard_url` is copied into the QR payload for custom dashboard routes. |
|
||||
| `/api/profiles/{name}/config` | GET | Profile-scoped read-only config. Returns `{profile, path, config, readonly: true}` — `config` is the parsed `config.yaml` for `~/.hermes/` (when `name == "default"`) or `~/.hermes/profiles/<name>/`. Loopback callers skip bearer; remote callers require the relay session bearer. 404 on missing profile / missing config.yaml; 500 on yaml parse error. See §22 in decisions.md. |
|
||||
| `/api/profiles/{name}/config` | GET | Profile-scoped read-only config. Returns `{profile, path, config, readonly: true}`. Loopback callers receive the parsed `config.yaml` and absolute path. Remote callers require a relay session bearer and receive only the explicitly public `description` and `model.default` fields with `path: "config.yaml"`; arbitrary provider, platform, integration, and extension sections never cross the remote boundary. 404 on missing profile / missing config.yaml; 500 on yaml parse error. See §22 in decisions.md. |
|
||||
| `/api/profiles/{name}/avatar` | GET | Profile-scoped avatar discovery and image delivery. Searches direct children of the profile home for conventional names, preferring `avatar.*` then `profile.*` (`png`, `jpg`, `jpeg`, `webp`, `gif`; additional `profile-image`, `agent`, and `icon` stems are accepted). Synthetic `default` follows a valid sticky `active_profile` marker, matching its advertised identity. The resolved file must remain inside the profile home and satisfy the Relay media-size policy. Same loopback-or-session-bearer auth as the other profile reads. 404 when the profile or an image is absent. Android copies returned bytes into its existing device-local per-profile icon store. |
|
||||
| `/api/profiles/{name}/skills` | GET | Profile-scoped skill enumeration. Walks `<profile>/skills/<category>/<skill>/SKILL.md` recursively; returns `{profile, skills: [{name, category, description, path, enabled: true}], total}`. Same auth model as `/config`. `name`/`description` come from YAML frontmatter when present, else directory basename. All skills report `enabled: true` today — see §22 for the toggle stub. |
|
||||
| `/api/profiles/{name}/soul` | GET | Profile-scoped raw `SOUL.md` read. Returns `{profile, path, content, exists, size_bytes}` with optional `truncated: true` when content exceeds the 200KB inline cap. Absent SOUL.md returns 200 with `exists: false` and an empty content string so the Inspector can distinguish "no soul" from transport failure. Same auth model as `/config`. 404 on unknown profile; 500 `{error: "soul_read_failed"}` on decode error. See §22 in decisions.md. |
|
||||
@@ -629,7 +629,7 @@ Wraps the existing relay protocol. When the agent calls `android_*` tools, the t
|
||||
|
||||
#### 6.4.1 `android_*` tool surface
|
||||
|
||||
Tools register against the Hermes plugin API in `plugin/tools/android_tool.py` (plus `plugin/tools/android_notifications.py`, `plugin/tools/android_navigate.py`). The Python-side Device Control tools issue HTTP requests to the relay on loopback; the relay forwards them to the phone over WSS; the sideload phone executes them via the accessibility service and returns structured responses. Google Play phones report `bridge.device_control_supported=false` from `/bridge/status`, so these tools are hidden from the agent and direct command probes fail closed with `error_code: device_control_sideload_only`.
|
||||
Tools register against the Hermes plugin API in `plugin/tools/android_tool.py` (plus `plugin/tools/android_notifications.py`, `plugin/tools/android_navigate.py`). The Python-side Device Control tools issue bearer-authenticated HTTP requests to the relay on loopback using `ANDROID_BRIDGE_TOKEN`; the relay requires that session's active `bridge` grant before forwarding to the phone over WSS. The sideload phone executes commands via the accessibility service and returns structured responses. Google Play phones report `bridge.device_control_supported=false` from `/bridge/status`, so these tools are hidden from the agent and direct command probes fail closed with `error_code: device_control_sideload_only`.
|
||||
|
||||
**Baseline (pre-v0.4 — shipped in Phase 3 Wave 1):**
|
||||
|
||||
@@ -773,7 +773,7 @@ The `ActionResult.data` field indicates which tier succeeded (`"direct"` / `"par
|
||||
- [x] Android Keystore session token storage (`SessionTokenStore` — `KeystoreTokenStore` with StrongBox-preferred via `setRequestStrongBoxBacked`, `LegacyEncryptedPrefsTokenStore` TEE-backed fallback, one-shot lossless migration on first launch)
|
||||
- [x] User-chosen session TTL at pair time (`SessionTtlPickerDialog` — 1d / 7d / 30d / 90d / 1y / Never)
|
||||
- [x] Per-channel grants on one session token (`Session.grants` — chat / terminal / bridge / TUI / split voice grants (`voice:config`, `voice:stt`, `voice:tts`), clamped to session lifetime)
|
||||
- [x] Paired Devices screen (`PairedDevicesScreen` + `GET /sessions` + `DELETE /sessions/{prefix}` + `PATCH /sessions/{prefix}` for extend)
|
||||
- [x] Paired Devices screen (`PairedDevicesScreen` + `GET /sessions` + `DELETE /sessions/{prefix}`; bearer-authenticated `PATCH /sessions/{prefix}` is self-targeted and reduction-only)
|
||||
- [x] Transport security badge (`TransportSecurityBadge` — three states: secure / insecure-with-reason / insecure-unknown)
|
||||
- [x] First-time insecure-mode ack dialog with reason picker (`InsecureConnectionAckDialog`)
|
||||
- [x] Tailscale detection (`TailscaleDetector` — informational only)
|
||||
|
||||
@@ -53,6 +53,20 @@ After review:
|
||||
- add recurring terminology corrections to this glossary;
|
||||
- preserve translator credit and stale-PR lineage under `CONTRIBUTING.md`.
|
||||
|
||||
## Public documentation
|
||||
|
||||
Translate the first-run documentation as complete pages, preserving frontmatter,
|
||||
internal links, component tags, command names, route paths, configuration keys,
|
||||
and code samples exactly. Fast-moving API, relay-route, configuration, and
|
||||
architecture reference remains canonical English until a locale has a durable
|
||||
maintenance owner. Localized core pages should link to that reference rather
|
||||
than copying it.
|
||||
|
||||
Run `python scripts/check-user-docs-locales.py` before previewing or building the
|
||||
documentation. When a canonical English core page changes intentionally, review
|
||||
every localized counterpart, then refresh its recorded source hash with
|
||||
`python scripts/check-user-docs-locales.py --refresh`.
|
||||
|
||||
## Technical release gate
|
||||
|
||||
Run:
|
||||
|
||||
@@ -90,5 +90,5 @@ git worktree remove <path> # delete a worktree (must be clean, or pass --force)
|
||||
|
||||
Worktrees change *nothing* about the release contract. Feature worktrees merge to
|
||||
`dev`; releases are still cut by merging `dev` → `main` with `--no-ff` and tagging
|
||||
from `main` (Android `android-v*`, plugin `plugin-v*`, CLI `cli-v*`). Version bumps
|
||||
from `main` (Android `android-v*`, server `server-v*`, desktop `desktop-v*`). Version bumps
|
||||
happen on `dev` at release-prep, never on a feature branch — see RELEASE.md.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.4.6"
|
||||
appVersionCode = "29"
|
||||
appVersionName = "1.4.8"
|
||||
appVersionCode = "31"
|
||||
agp = "9.3.0"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.06.01"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Legacy documentation redirect
|
||||
|
||||
This directory is a temporary compatibility shim for Android releases that
|
||||
hardcoded `https://codename-11.github.io/hermes-relay/` before PR #210.
|
||||
Documentation is hosted only at `https://hermes-relay.dev/docs/`; GitHub Pages
|
||||
serves redirect HTML for old documentation paths. The historical
|
||||
`privacy.html` URL serves the complete policy from the canonical
|
||||
`website/public/privacy.html` source so store-review crawlers and installed
|
||||
clients never depend on JavaScript redirects.
|
||||
|
||||
The deployment workflow copies `redirect.html` to the project root, the Pages
|
||||
404 fallback, and the exact deep-link paths embedded in released clients. The
|
||||
JavaScript preserves the path, query string, and fragment while moving the
|
||||
request under `/docs/`. The meta refresh and visible link provide a no-script
|
||||
fallback to the guide root. Privacy compatibility pages identify
|
||||
`https://hermes-relay.dev/privacy.html` as canonical.
|
||||
|
||||
Removal is tracked in the repository root `TODO.md`. Do not delete this shim
|
||||
solely because the first fixed release has shipped; honor the documented
|
||||
compatibility window for older Play and sideload installations.
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta http-equiv="refresh" content="2;url=https://hermes-relay.dev/docs/" />
|
||||
<link rel="canonical" href="https://hermes-relay.dev/docs/" />
|
||||
<title>Hermes Relay documentation moved</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Hermes Relay documentation moved</h1>
|
||||
<p>
|
||||
Redirecting to
|
||||
<a id="destination" href="https://hermes-relay.dev/docs/">hermes-relay.dev/docs</a>.
|
||||
</p>
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const legacyBase = "/hermes-relay";
|
||||
const pathname = window.location.pathname;
|
||||
const suffix = pathname === legacyBase || pathname === `${legacyBase}/`
|
||||
? "/"
|
||||
: pathname.startsWith(`${legacyBase}/`)
|
||||
? pathname.slice(legacyBase.length)
|
||||
: "/";
|
||||
const destination = new URL("https://hermes-relay.dev/docs/");
|
||||
destination.pathname = `/docs/${suffix.replace(/^\/+/, "")}`;
|
||||
destination.search = window.location.search;
|
||||
destination.hash = window.location.hash;
|
||||
document.getElementById("destination").href = destination.href;
|
||||
window.location.replace(destination.href);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -269,7 +269,7 @@ async def get_update_check(refresh: Optional[bool] = Query(default=False)) -> di
|
||||
"""Report whether a newer hermes-relay plugin release is available.
|
||||
|
||||
Compares the installed ``plugin.relay.__version__`` against the latest
|
||||
``plugin-v*`` GitHub release. The GitHub fetch is cached for an hour
|
||||
``server-v*`` GitHub release (with historical ``plugin-v*`` fallback). The GitHub fetch is cached for an hour
|
||||
(``?refresh=true`` forces it) so a polling dashboard doesn't hammer the
|
||||
releases API. Network failures degrade to ``update_available=false`` with
|
||||
an ``error`` string — never a 5xx — so the card can show "couldn't check".
|
||||
|
||||
@@ -371,7 +371,7 @@ class UpdateCheckTests(PluginApiTestCase):
|
||||
|
||||
def test_update_available(self) -> None:
|
||||
def handler(_req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=[{"tag_name": "plugin-v99.0.0"}])
|
||||
return httpx.Response(200, json=[{"tag_name": "server-v99.0.0"}])
|
||||
|
||||
_install_mock_transport(self, handler)
|
||||
body = self.client.get("/update-check").json()
|
||||
@@ -385,7 +385,7 @@ class UpdateCheckTests(PluginApiTestCase):
|
||||
cur = update_check.current_version()
|
||||
|
||||
def handler(_req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=[{"tag_name": f"plugin-v{cur}"}])
|
||||
return httpx.Response(200, json=[{"tag_name": f"server-v{cur}"}])
|
||||
|
||||
_install_mock_transport(self, handler)
|
||||
body = self.client.get("/update-check").json()
|
||||
|
||||
@@ -14,9 +14,9 @@ See ``plugin/relay/server.py`` for the aiohttp server,
|
||||
# avoids a circular-import crash during package initialization.
|
||||
#
|
||||
# Canonical plugin version source is pyproject.toml's [project].version.
|
||||
# Keep this runtime constant in sync with pyproject.toml for plugin-v*
|
||||
# Keep this runtime constant in sync with pyproject.toml for server-v*
|
||||
# releases. Android releases use gradle/libs.versions.toml and android-v* tags;
|
||||
# CLI releases use desktop/package.json and cli-v* tags. The /health endpoint
|
||||
# Desktop releases use desktop/package.json and desktop-v* tags. The /health endpoint
|
||||
# reports this plugin version, and stale values make live diagnosis harder than
|
||||
# it should be.
|
||||
__version__ = "1.4.2"
|
||||
|
||||
@@ -1204,6 +1204,71 @@ class SessionManager:
|
||||
self._save_to_disk()
|
||||
return session
|
||||
|
||||
def reduce_session_policy(
|
||||
self,
|
||||
token: str,
|
||||
ttl_seconds: int | None = None,
|
||||
grants: dict[str, float] | None = None,
|
||||
) -> Session | None:
|
||||
"""Atomically reduce a live session's lifetime and grant ceilings.
|
||||
|
||||
Unlike :meth:`update_session`, this operation never rebuilds omitted
|
||||
grants from defaults and never permits a policy expansion. It is the
|
||||
safe self-service primitive for an ordinary session bearer; broader
|
||||
updates require a separately authorized operator flow.
|
||||
|
||||
Raises :class:`ValueError` for unknown or malformed grants and
|
||||
:class:`PermissionError` for any requested policy expansion.
|
||||
"""
|
||||
self._cleanup()
|
||||
session = self._sessions.get(token)
|
||||
if session is None or session.is_expired:
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
requested_session_expiry = session.expires_at
|
||||
if ttl_seconds is not None:
|
||||
requested_session_expiry = (
|
||||
math.inf if ttl_seconds == 0 else now + float(ttl_seconds)
|
||||
)
|
||||
if requested_session_expiry > session.expires_at:
|
||||
raise PermissionError(
|
||||
"operator approval required to extend session"
|
||||
)
|
||||
|
||||
requested_grant_expiries: dict[str, float] = {}
|
||||
for channel, seconds in (grants or {}).items():
|
||||
if channel not in session.grants:
|
||||
raise ValueError(f"unknown grant {channel!r}")
|
||||
if not isinstance(seconds, (int, float)) or not math.isfinite(
|
||||
seconds
|
||||
):
|
||||
raise ValueError(f"invalid grant duration for {channel!r}")
|
||||
if seconds < 0:
|
||||
raise ValueError(f"invalid grant duration for {channel!r}")
|
||||
requested_expiry = (
|
||||
math.inf if seconds == 0 else now + float(seconds)
|
||||
)
|
||||
if requested_expiry > session.grants[channel]:
|
||||
raise PermissionError(
|
||||
f"operator approval required to expand grant {channel!r}"
|
||||
)
|
||||
requested_grant_expiries[channel] = requested_expiry
|
||||
|
||||
session.expires_at = requested_session_expiry
|
||||
for channel, current_expiry in tuple(session.grants.items()):
|
||||
requested_expiry = requested_grant_expiries.get(
|
||||
channel,
|
||||
current_expiry,
|
||||
)
|
||||
session.grants[channel] = _clamp_grant_to_session(
|
||||
requested_expiry,
|
||||
session.expires_at,
|
||||
)
|
||||
session.last_seen = now
|
||||
self._save_to_disk()
|
||||
return session
|
||||
|
||||
def active_count(self) -> int:
|
||||
"""Return the number of non-expired sessions."""
|
||||
self._cleanup()
|
||||
|
||||
+94
-69
@@ -27,6 +27,7 @@ import logging
|
||||
import math
|
||||
import mimetypes
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
import ssl
|
||||
import stat
|
||||
@@ -186,16 +187,6 @@ async def handle_health(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def handle_pairing(request: web.Request) -> web.Response:
|
||||
"""Generate a new pairing code (for use by the Hermes agent/CLI).
|
||||
|
||||
POST /pairing → {"code": "ABC123"}
|
||||
"""
|
||||
server: RelayServer = request.app["server"]
|
||||
code = server.pairing.generate_code()
|
||||
return web.json_response({"code": code})
|
||||
|
||||
|
||||
async def handle_pairing_register(request: web.Request) -> web.Response:
|
||||
"""Pre-register an externally-provided pairing code.
|
||||
|
||||
@@ -810,22 +801,22 @@ async def handle_sessions_revoke(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
async def handle_sessions_extend(request: web.Request) -> web.Response:
|
||||
"""Update a paired device's session TTL and/or per-channel grants.
|
||||
"""Reduce the calling device's session TTL and/or channel grants.
|
||||
|
||||
This is the "extend" action exposed on the phone's Paired Devices
|
||||
screen, but also handles arbitrary TTL updates — passing
|
||||
``ttl_seconds`` shorter than the current expiry clips the session
|
||||
(equivalent to shortening, though the button is labeled "Extend"
|
||||
for the common case). ``ttl_seconds == 0`` maps to never-expire.
|
||||
A normal Relay bearer is session identity, not session-management
|
||||
authority. It may reduce only its own live policy. Extending a
|
||||
lifetime, adding or lengthening a grant, or changing another session
|
||||
requires a fresh operator-approved pairing flow.
|
||||
|
||||
PATCH /sessions/{token_prefix}
|
||||
Body: {"ttl_seconds": 2592000} # extend only
|
||||
| {"grants": {"terminal": 604800}} # grants only
|
||||
| {"ttl_seconds": 0, "grants": {...}} # both
|
||||
Body: {"ttl_seconds": 300} # shorten only
|
||||
| {"grants": {"terminal": 60}} # reduce grants
|
||||
| {"ttl_seconds": 300, "grants": {...}} # both
|
||||
→ 200 {"ok": true, "expires_at": ..., "grants": {...}}
|
||||
→ 400 missing/invalid body or no fields provided
|
||||
→ 401 missing/invalid bearer
|
||||
→ 404 prefix doesn't match any active session
|
||||
→ 403 cross-session target or policy expansion
|
||||
→ 409 prefix matches multiple sessions
|
||||
"""
|
||||
server, current_session = _require_bearer_session(request)
|
||||
@@ -873,7 +864,12 @@ async def handle_sessions_extend(request: web.Request) -> web.Response:
|
||||
{"ok": False, "error": "'grants' must be an object"}, status=400
|
||||
)
|
||||
for k, v in grants.items():
|
||||
if not isinstance(k, str) or not isinstance(v, (int, float)) or v < 0:
|
||||
if (
|
||||
not isinstance(k, str)
|
||||
or not isinstance(v, (int, float))
|
||||
or not math.isfinite(v)
|
||||
or v < 0
|
||||
):
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
@@ -901,20 +897,27 @@ async def handle_sessions_extend(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
target = matches[0]
|
||||
updated = server.sessions.update_session(
|
||||
target.token,
|
||||
ttl_seconds=ttl_seconds,
|
||||
grants=grants,
|
||||
)
|
||||
if not secrets.compare_digest(target.token, current_session.token):
|
||||
raise web.HTTPForbidden(text="cannot modify another session")
|
||||
|
||||
try:
|
||||
updated = server.sessions.reduce_session_policy(
|
||||
target.token,
|
||||
ttl_seconds=ttl_seconds,
|
||||
grants=grants,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"ok": False, "error": str(exc)}, status=400)
|
||||
except PermissionError as exc:
|
||||
raise web.HTTPForbidden(text=str(exc)) from exc
|
||||
if updated is None:
|
||||
# Raced with expiry or revocation between find_by_prefix and update.
|
||||
raise web.HTTPNotFound(text="session vanished mid-update, retry")
|
||||
|
||||
logger.info(
|
||||
"Extended session %s... (%s)%s",
|
||||
"Reduced session policy %s... (%s) [self]",
|
||||
target.token[:8],
|
||||
target.device_name,
|
||||
" [self]" if target.token == current_session.token else "",
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -1704,17 +1707,10 @@ async def handle_media_by_path(request: web.Request) -> web.StreamResponse:
|
||||
# channel to the connected phone.
|
||||
#
|
||||
# Auth model:
|
||||
# * These routes are **unauthenticated** at the HTTP layer on purpose —
|
||||
# the legacy relay was unauthenticated too, and the trust boundary
|
||||
# is the same: only tools running on the same host as the relay can
|
||||
# reach localhost:8767. The relay's default bind is 0.0.0.0 for the
|
||||
# WebSocket side, but tools should always point at ``localhost``, so
|
||||
# an attacker reaching port 8767 from the LAN would need the phone
|
||||
# to also have auth'd with a valid pairing code — without a paired
|
||||
# phone, every bridge HTTP call just returns 503.
|
||||
# * If tightening is needed later, wrap these handlers with the same
|
||||
# ``_require_bearer_session`` pattern used by ``/media/*`` — the
|
||||
# bridge grant is already tracked per-session in ``Session.grants``.
|
||||
# * Every route requires a live Relay bearer with an active ``bridge``
|
||||
# grant. The same listener accepts external WebSocket connections, so
|
||||
# callers are never trusted merely because host tools normally use
|
||||
# ``localhost``.
|
||||
#
|
||||
# A paired but disconnected phone still drops bridge calls with 503 —
|
||||
# the tool caller should retry or tell the user to reconnect the app.
|
||||
@@ -1746,7 +1742,9 @@ async def _bridge_dispatch(
|
||||
path: str,
|
||||
) -> web.Response:
|
||||
"""Forward an HTTP request to an Android bridge device."""
|
||||
server: RelayServer = request.app["server"]
|
||||
server, session = _require_bearer_session(request)
|
||||
if session.channel_is_expired("bridge"):
|
||||
raise web.HTTPForbidden(text="active bridge grant required")
|
||||
method = request.method # GET or POST
|
||||
|
||||
params: dict[str, Any] = dict(request.query)
|
||||
@@ -2455,8 +2453,33 @@ def _parse_skill_frontmatter(text: str) -> dict[str, Any]:
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _public_profile_config(parsed: object) -> dict[str, Any]:
|
||||
"""Build the explicitly public subset of a Hermes profile config.
|
||||
|
||||
Remote profile inspection must not serialize arbitrary configuration
|
||||
sections: provider and extension fields may contain reusable credentials.
|
||||
Keep this schema deliberately small and add fields only after classifying
|
||||
them as safe for every paired Relay client.
|
||||
"""
|
||||
if not isinstance(parsed, dict):
|
||||
return {}
|
||||
|
||||
public: dict[str, Any] = {}
|
||||
description = parsed.get("description")
|
||||
if isinstance(description, str):
|
||||
public["description"] = description
|
||||
|
||||
model = parsed.get("model")
|
||||
if isinstance(model, dict):
|
||||
default_model = model.get("default")
|
||||
if isinstance(default_model, str):
|
||||
public["model"] = {"default": default_model}
|
||||
|
||||
return public
|
||||
|
||||
|
||||
async def handle_profile_config(request: web.Request) -> web.Response:
|
||||
"""Return the parsed ``config.yaml`` for a named profile.
|
||||
"""Return a safe view of ``config.yaml`` for a named profile.
|
||||
|
||||
GET /api/profiles/{name}/config
|
||||
→ 200 {"profile", "path", "config": {...}, "readonly": true}
|
||||
@@ -2464,9 +2487,10 @@ async def handle_profile_config(request: web.Request) -> web.Response:
|
||||
→ 404 profile dir missing or no config.yaml
|
||||
→ 500 yaml parse error
|
||||
|
||||
Loopback callers may skip bearer auth (matches
|
||||
``/notifications/recent``); remote callers must present a valid
|
||||
relay session token.
|
||||
Loopback callers may skip bearer auth and receive the complete parsed file.
|
||||
Remote callers must present a valid relay session token and receive only
|
||||
the explicitly public profile schema, never arbitrary config sections or
|
||||
the host filesystem path.
|
||||
"""
|
||||
is_loopback = request.remote in ("127.0.0.1", "::1")
|
||||
if is_loopback:
|
||||
@@ -2521,11 +2545,14 @@ async def handle_profile_config(request: web.Request) -> web.Response:
|
||||
if parsed is None:
|
||||
parsed = {}
|
||||
|
||||
response_config = parsed if is_loopback else _public_profile_config(parsed)
|
||||
response_path = str(config_path) if is_loopback else config_path.name
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"profile": name,
|
||||
"path": str(config_path),
|
||||
"config": parsed,
|
||||
"path": response_path,
|
||||
"config": response_config,
|
||||
"readonly": True,
|
||||
}
|
||||
)
|
||||
@@ -3518,7 +3545,7 @@ async def handle_relay_update_check(request: web.Request) -> web.Response:
|
||||
The dashboard has its own (loopback) update-check; this is the **app-facing**
|
||||
twin on the relay port so the phone can surface a soft "your relay is behind"
|
||||
nudge. Compares the installed ``plugin.relay.__version__`` against the latest
|
||||
``plugin-v*`` GitHub release and names the right update command for the host.
|
||||
``server-v*`` GitHub release and names the right update command for the host.
|
||||
|
||||
Loopback callers skip bearer auth (diagnostics); the paired phone presents
|
||||
its relay session bearer (same gate as ``/phone/threads``). The blocking
|
||||
@@ -3764,14 +3791,10 @@ async def _authenticate(
|
||||
client_surface = str(payload.get("client_surface", "unknown") or "unknown")
|
||||
device_form_factor = str(payload.get("device_form_factor", "unknown") or "unknown")
|
||||
|
||||
# The phone MAY send ttl_seconds / grants in its auth envelope, but
|
||||
# if the operator pre-registered the code with metadata on the host
|
||||
# side, those host-provided values win — the host has the authority
|
||||
# to decide "how long and for which channels". Only fall back to the
|
||||
# phone-sent fields when the code had no attached metadata (e.g. old
|
||||
# phones predating the v2 auth envelope).
|
||||
phone_ttl = payload.get("ttl_seconds")
|
||||
phone_grants = payload.get("grants")
|
||||
# Pairing policy is attached by a loopback-only operator flow. Clients
|
||||
# may still send ttl_seconds / grants for wire compatibility, but those
|
||||
# fields are not an authority boundary and must not influence sessions.
|
||||
# Missing host metadata therefore resolves to SessionManager defaults.
|
||||
detected_transport = _detect_transport_hint(request)
|
||||
|
||||
# Try session token first (reconnection)
|
||||
@@ -3815,24 +3838,13 @@ async def _authenticate(
|
||||
if pairing_code:
|
||||
metadata = server.pairing.consume_code(pairing_code)
|
||||
if metadata is not None:
|
||||
# Thread host-side metadata (from /pairing/register) through
|
||||
# to the session. Fall back to phone-sent values, then to
|
||||
# library defaults.
|
||||
# Thread host-side metadata (from a loopback-only pairing flow)
|
||||
# through to the session. Missing metadata uses bounded library
|
||||
# defaults; the network client cannot author session policy.
|
||||
ttl_seconds: float | None = metadata.ttl_seconds
|
||||
grants: dict[str, float] | None = metadata.grants
|
||||
transport_hint = metadata.transport_hint or detected_transport
|
||||
|
||||
if ttl_seconds is None and isinstance(phone_ttl, (int, float)) and not isinstance(phone_ttl, bool):
|
||||
if phone_ttl >= 0:
|
||||
ttl_seconds = float(phone_ttl)
|
||||
if grants is None and isinstance(phone_grants, dict):
|
||||
cleaned: dict[str, float] = {}
|
||||
for channel, value in phone_grants.items():
|
||||
if isinstance(channel, str) and isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0:
|
||||
cleaned[channel] = float(value)
|
||||
if cleaned:
|
||||
grants = cleaned
|
||||
|
||||
session = server.sessions.create_session(
|
||||
device_name,
|
||||
device_id,
|
||||
@@ -3924,6 +3936,20 @@ async def _on_message(
|
||||
)
|
||||
_track_task(server, ws, task)
|
||||
elif channel == "terminal":
|
||||
token = server._clients.get(ws)
|
||||
session = server.sessions.get_session(token) if token else None
|
||||
if session is None or session.channel_is_expired("terminal"):
|
||||
logger.warning(
|
||||
"Rejected terminal message from device=%s: terminal grant expired",
|
||||
session.device_id if session is not None else "unknown",
|
||||
)
|
||||
await _send_system(
|
||||
ws,
|
||||
"error",
|
||||
{"message": "Terminal grant expired for this device"},
|
||||
msg_id=msg_id,
|
||||
)
|
||||
return
|
||||
task = asyncio.create_task(server.terminal.handle(ws, envelope))
|
||||
_track_task(server, ws, task)
|
||||
elif channel == "bridge":
|
||||
@@ -4110,7 +4136,6 @@ def create_app(config: RelayConfig) -> web.Application:
|
||||
app.router.add_get("/ws", handle_ws)
|
||||
app.router.add_get("/", handle_ws)
|
||||
app.router.add_get("/health", handle_health)
|
||||
app.router.add_post("/pairing", handle_pairing)
|
||||
app.router.add_post("/pairing/register", handle_pairing_register)
|
||||
app.router.add_post("/pairing/mint", handle_pairing_mint)
|
||||
app.router.add_post("/pairing/approve", handle_pairing_approve)
|
||||
|
||||
@@ -121,7 +121,9 @@ def _synthesize_gemini(
|
||||
|
||||
raw = config.get("gemini")
|
||||
gemini = dict(raw) if isinstance(raw, dict) else {}
|
||||
for key in ("model", "voice", "base_url"):
|
||||
# Never merge a request-supplied endpoint into credential-bearing host
|
||||
# configuration. ``base_url`` is deliberately operator-controlled.
|
||||
for key in ("model", "voice"):
|
||||
value = overrides.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
gemini[key] = value.strip()
|
||||
|
||||
@@ -506,7 +506,9 @@ def _extract_voice_overrides(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
nested = payload.get("enhanced") or payload.get("gemini")
|
||||
source = nested if isinstance(nested, dict) else payload
|
||||
overrides: dict[str, Any] = {}
|
||||
for key in ("provider", "voice", "model", "base_url", "language"):
|
||||
# Network destinations remain operator-controlled because provider
|
||||
# adapters attach host-owned credentials to their configured endpoints.
|
||||
for key in ("provider", "voice", "model", "language"):
|
||||
value = source.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
overrides[key] = value.strip()
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Security regression tests for Relay HTTP-to-Android bridge authorization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from unittest import mock
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from plugin.relay.config import RelayConfig
|
||||
from plugin.relay.server import create_app
|
||||
|
||||
|
||||
class BridgeHttpAuthorizationTests(AioHTTPTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
self._hermes_home = tempfile.TemporaryDirectory()
|
||||
self._env_patch = mock.patch.dict(
|
||||
os.environ,
|
||||
{"HERMES_HOME": self._hermes_home.name},
|
||||
)
|
||||
self._env_patch.start()
|
||||
await super().asyncSetUp()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
await super().asyncTearDown()
|
||||
self._env_patch.stop()
|
||||
self._hermes_home.cleanup()
|
||||
|
||||
async def get_application(self) -> web.Application:
|
||||
return create_app(RelayConfig(profile_discovery_enabled=False))
|
||||
|
||||
def _session(self) -> object:
|
||||
return self.app["server"].sessions.create_session(
|
||||
"bridge-security-test",
|
||||
"bridge-security-test",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _bearer(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async def test_anonymous_and_invalid_callers_are_rejected_before_dispatch(self) -> None:
|
||||
dispatch = mock.AsyncMock(return_value={"status": 200, "result": {"ok": True}})
|
||||
self.app["server"].bridge.handle_command = dispatch
|
||||
|
||||
anonymous = await self.client.get("/screen?include_bounds=true")
|
||||
invalid = await self.client.post(
|
||||
"/tap",
|
||||
json={"x": 1, "y": 2},
|
||||
headers=self._bearer("invalid-token"),
|
||||
)
|
||||
|
||||
self.assertEqual(anonymous.status, 401)
|
||||
self.assertEqual(invalid.status, 401)
|
||||
dispatch.assert_not_awaited()
|
||||
|
||||
async def test_missing_and_expired_bridge_grants_are_rejected(self) -> None:
|
||||
dispatch = mock.AsyncMock(return_value={"status": 200, "result": {"ok": True}})
|
||||
self.app["server"].bridge.handle_command = dispatch
|
||||
|
||||
missing = self._session()
|
||||
missing.grants.pop("bridge")
|
||||
expired = self._session()
|
||||
expired.grants["bridge"] = time.time() - 1
|
||||
|
||||
missing_response = await self.client.get(
|
||||
"/screen", headers=self._bearer(missing.token)
|
||||
)
|
||||
expired_response = await self.client.post(
|
||||
"/tap", json={"x": 1, "y": 2}, headers=self._bearer(expired.token)
|
||||
)
|
||||
|
||||
self.assertEqual(missing_response.status, 403)
|
||||
self.assertEqual(expired_response.status, 403)
|
||||
dispatch.assert_not_awaited()
|
||||
|
||||
async def test_active_bridge_grant_preserves_request_and_device_selector(self) -> None:
|
||||
dispatch = mock.AsyncMock(
|
||||
return_value={"status": 200, "result": {"ok": True}}
|
||||
)
|
||||
self.app["server"].bridge.handle_command = dispatch
|
||||
session = self._session()
|
||||
|
||||
response = await self.client.post(
|
||||
"/tap?device=phone",
|
||||
json={"x": 10, "y": 20},
|
||||
headers=self._bearer(session.token),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(await response.json(), {"ok": True})
|
||||
dispatch.assert_awaited_once_with(
|
||||
method="POST",
|
||||
path="/tap",
|
||||
params={},
|
||||
body={"x": 10, "y": 20},
|
||||
device="phone",
|
||||
)
|
||||
@@ -53,6 +53,12 @@ class _FakeSession:
|
||||
class _FakeServer:
|
||||
def __init__(self, bridge: BridgeHandler) -> None:
|
||||
self.bridge = bridge
|
||||
session = _FakeSession("bridge-test-token", "test", "test")
|
||||
self.sessions = type(
|
||||
"FakeSessions",
|
||||
(),
|
||||
{"get_session": lambda _self, token: session if token == session.token else None},
|
||||
)()
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
@@ -70,6 +76,7 @@ class _FakeRequest:
|
||||
self.query = query or {}
|
||||
self._body = body
|
||||
self.remote = remote
|
||||
self.headers = {"Authorization": "Bearer bridge-test-token"}
|
||||
|
||||
@property
|
||||
def body_exists(self) -> bool:
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Security regression tests for operator-authorized Relay pairing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from plugin.relay.auth import Session
|
||||
from plugin.relay.config import RelayConfig
|
||||
from plugin.relay.server import create_app
|
||||
|
||||
|
||||
class PairingAuthorityTests(AioHTTPTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
self._hermes_home = tempfile.TemporaryDirectory()
|
||||
self._env_patch = mock.patch.dict(
|
||||
os.environ,
|
||||
{"HERMES_HOME": self._hermes_home.name},
|
||||
)
|
||||
self._env_patch.start()
|
||||
await super().asyncSetUp()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
await super().asyncTearDown()
|
||||
self._env_patch.stop()
|
||||
self._hermes_home.cleanup()
|
||||
|
||||
async def get_application(self) -> web.Application:
|
||||
return create_app(RelayConfig(profile_discovery_enabled=False))
|
||||
|
||||
async def _authenticate(
|
||||
self,
|
||||
code: str,
|
||||
*,
|
||||
ttl_seconds: int,
|
||||
grants: dict[str, int],
|
||||
) -> Session:
|
||||
ws = await self.client.ws_connect("/ws")
|
||||
await ws.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "auth",
|
||||
"payload": {
|
||||
"pairing_code": code,
|
||||
"device_name": "security-regression",
|
||||
"device_id": f"security-regression-{code}",
|
||||
"ttl_seconds": ttl_seconds,
|
||||
"grants": grants,
|
||||
},
|
||||
}
|
||||
)
|
||||
message = await ws.receive_json()
|
||||
await ws.close()
|
||||
self.assertEqual(message["type"], "auth.ok")
|
||||
token = message["payload"]["session_token"]
|
||||
session = self.app["server"].sessions.get_session(token)
|
||||
self.assertIsNotNone(session)
|
||||
assert session is not None
|
||||
return session
|
||||
|
||||
async def test_anonymous_pairing_code_mint_is_not_exposed(self) -> None:
|
||||
server = self.app["server"]
|
||||
|
||||
response = await self.client.post("/pairing")
|
||||
|
||||
self.assertEqual(response.status, 404)
|
||||
self.assertEqual(server.pairing._codes, {})
|
||||
self.assertEqual(server.sessions.active_count(), 0)
|
||||
self.assertEqual(server.sessions._trusted_devices, {})
|
||||
|
||||
async def test_client_policy_is_ignored_when_host_metadata_is_absent(
|
||||
self,
|
||||
) -> None:
|
||||
response = await self.client.post(
|
||||
"/pairing/register",
|
||||
json={"code": "SAFE01"},
|
||||
)
|
||||
self.assertEqual(response.status, 200, await response.text())
|
||||
|
||||
session = await self._authenticate(
|
||||
"SAFE01",
|
||||
ttl_seconds=0,
|
||||
grants={"terminal": 0, "bridge": 0},
|
||||
)
|
||||
|
||||
self.assertFalse(math.isinf(session.expires_at))
|
||||
self.assertFalse(math.isinf(session.grants["terminal"]))
|
||||
self.assertFalse(math.isinf(session.grants["bridge"]))
|
||||
|
||||
async def test_explicit_host_policy_remains_authoritative(self) -> None:
|
||||
response = await self.client.post(
|
||||
"/pairing/register",
|
||||
json={
|
||||
"code": "SAFE02",
|
||||
"ttl_seconds": 0,
|
||||
"grants": {"terminal": 0},
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status, 200, await response.text())
|
||||
|
||||
session = await self._authenticate(
|
||||
"SAFE02",
|
||||
ttl_seconds=60,
|
||||
grants={"terminal": 60},
|
||||
)
|
||||
|
||||
self.assertTrue(math.isinf(session.expires_at))
|
||||
self.assertTrue(math.isinf(session.grants["terminal"]))
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Security tests for ``GET /api/profiles/{name}/config``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from plugin.relay.config import RelayConfig
|
||||
from plugin.relay.server import create_app
|
||||
|
||||
|
||||
class ProfileConfigEndpointTests(AioHTTPTestCase):
|
||||
async def get_application(self) -> web.Application:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.hermes_dir = Path(self._tmp.name)
|
||||
config_path = self.hermes_dir / "config.yaml"
|
||||
config_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"description: Safe profile description",
|
||||
"model:",
|
||||
" default: safe-model",
|
||||
"platforms:",
|
||||
" api_server:",
|
||||
" api_key: POC_API_SERVER_KEY_DO_NOT_USE",
|
||||
"providers:",
|
||||
" synthetic:",
|
||||
" api_key: POC_PROVIDER_KEY_DO_NOT_USE",
|
||||
"extensions:",
|
||||
" future:",
|
||||
" nested:",
|
||||
" credential: POC_UNKNOWN_SECRET_DO_NOT_USE",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
app = create_app(RelayConfig(hermes_config_path=str(config_path)))
|
||||
|
||||
@web.middleware
|
||||
async def force_remote_client(
|
||||
request: web.Request,
|
||||
handler: web.RequestHandler,
|
||||
) -> web.StreamResponse:
|
||||
return await handler(request.clone(remote="198.51.100.27"))
|
||||
|
||||
app.middlewares.append(force_remote_client)
|
||||
self.session = app["server"].sessions.create_session(
|
||||
"profile-reader", "test-device", ttl_seconds=3600
|
||||
)
|
||||
self.session.grants = {"chat": time.time() + 3600}
|
||||
return app
|
||||
|
||||
async def test_remote_profile_config_returns_only_public_schema(self) -> None:
|
||||
response = await self.client.get(
|
||||
"/api/profiles/default/config",
|
||||
headers={"Authorization": f"Bearer {self.session.token}"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
body = await response.json()
|
||||
self.assertEqual(body["profile"], "default")
|
||||
self.assertEqual(body["path"], "config.yaml")
|
||||
self.assertEqual(
|
||||
body["config"],
|
||||
{
|
||||
"description": "Safe profile description",
|
||||
"model": {"default": "safe-model"},
|
||||
},
|
||||
)
|
||||
serialized = await response.text()
|
||||
self.assertNotIn("POC_API_SERVER_KEY_DO_NOT_USE", serialized)
|
||||
self.assertNotIn("POC_PROVIDER_KEY_DO_NOT_USE", serialized)
|
||||
self.assertNotIn("POC_UNKNOWN_SECRET_DO_NOT_USE", serialized)
|
||||
self.assertNotIn(str(self.hermes_dir), serialized)
|
||||
|
||||
async def test_remote_profile_config_still_requires_bearer(self) -> None:
|
||||
response = await self.client.get("/api/profiles/default/config")
|
||||
|
||||
self.assertEqual(response.status, 401)
|
||||
|
||||
|
||||
class ProfileConfigLoopbackEndpointTests(AioHTTPTestCase):
|
||||
async def get_application(self) -> web.Application:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.config_path = Path(self._tmp.name) / "config.yaml"
|
||||
self.config_path.write_text(
|
||||
"model:\n default: local-model\ncustom:\n enabled: true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return create_app(
|
||||
RelayConfig(hermes_config_path=str(self.config_path))
|
||||
)
|
||||
|
||||
async def test_loopback_operator_retains_full_config_view(self) -> None:
|
||||
response = await self.client.get("/api/profiles/default/config")
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
body = await response.json()
|
||||
self.assertEqual(body["path"], str(self.config_path))
|
||||
self.assertEqual(body["config"]["custom"], {"enabled": True})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Security regressions for Relay session-policy authorization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from plugin.relay.config import RelayConfig
|
||||
from plugin.relay.server import create_app
|
||||
|
||||
|
||||
class SessionPolicyAuthorizationTests(AioHTTPTestCase):
|
||||
async def get_application(self) -> web.Application:
|
||||
return create_app(RelayConfig(profile_discovery_enabled=False))
|
||||
|
||||
def _create_session(self, name: str, *, ttl_seconds: int = 600):
|
||||
return self.app["server"].sessions.create_session(
|
||||
name,
|
||||
f"{name}-id",
|
||||
ttl_seconds=ttl_seconds,
|
||||
client_surface="phone",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _headers(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
async def test_bounded_bearer_cannot_upgrade_its_policy(self) -> None:
|
||||
session = self._create_session("bounded")
|
||||
original_expiry = session.expires_at
|
||||
original_grants = dict(session.grants)
|
||||
|
||||
response = await self.client.patch(
|
||||
f"/sessions/{session.token[:8]}",
|
||||
headers=self._headers(session.token),
|
||||
json={
|
||||
"ttl_seconds": 0,
|
||||
"grants": {
|
||||
"terminal": 0,
|
||||
"bridge": 0,
|
||||
"tui": 0,
|
||||
"voice:tts": 0,
|
||||
"attacker:custom": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 403, await response.text())
|
||||
self.assertEqual(session.expires_at, original_expiry)
|
||||
self.assertEqual(session.grants, original_grants)
|
||||
self.assertFalse(math.isinf(session.expires_at))
|
||||
|
||||
async def test_bearer_cannot_modify_another_session(self) -> None:
|
||||
caller = self._create_session("caller")
|
||||
target = self._create_session("target")
|
||||
original_expiry = target.expires_at
|
||||
|
||||
response = await self.client.patch(
|
||||
f"/sessions/{target.token[:8]}",
|
||||
headers=self._headers(caller.token),
|
||||
json={"ttl_seconds": 60},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 403, await response.text())
|
||||
self.assertEqual(target.expires_at, original_expiry)
|
||||
|
||||
async def test_bearer_cannot_add_or_lengthen_grants(self) -> None:
|
||||
session = self._create_session("bounded")
|
||||
|
||||
add_response = await self.client.patch(
|
||||
f"/sessions/{session.token[:8]}",
|
||||
headers=self._headers(session.token),
|
||||
json={"grants": {"attacker:custom": 30}},
|
||||
)
|
||||
extend_response = await self.client.patch(
|
||||
f"/sessions/{session.token[:8]}",
|
||||
headers=self._headers(session.token),
|
||||
json={"grants": {"terminal": 601}},
|
||||
)
|
||||
|
||||
self.assertEqual(add_response.status, 400, await add_response.text())
|
||||
self.assertEqual(extend_response.status, 403, await extend_response.text())
|
||||
self.assertNotIn("attacker:custom", session.grants)
|
||||
|
||||
async def test_nan_grant_is_rejected(self) -> None:
|
||||
session = self._create_session("bounded")
|
||||
|
||||
response = await self.client.patch(
|
||||
f"/sessions/{session.token[:8]}",
|
||||
headers=self._headers(session.token),
|
||||
json={"grants": {"terminal": float("nan")}},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 400, await response.text())
|
||||
self.assertFalse(math.isnan(session.grants["terminal"]))
|
||||
|
||||
async def test_self_service_can_only_reduce_policy(self) -> None:
|
||||
session = self._create_session("bounded")
|
||||
original_expiry = session.expires_at
|
||||
original_bridge_expiry = session.grants["bridge"]
|
||||
|
||||
response = await self.client.patch(
|
||||
f"/sessions/{session.token[:8]}",
|
||||
headers=self._headers(session.token),
|
||||
json={"ttl_seconds": 300, "grants": {"terminal": 60}},
|
||||
)
|
||||
body = await response.json()
|
||||
|
||||
self.assertEqual(response.status, 200, body)
|
||||
self.assertLess(session.expires_at, original_expiry)
|
||||
self.assertLessEqual(session.expires_at, time.time() + 301)
|
||||
self.assertLessEqual(session.grants["terminal"], time.time() + 61)
|
||||
self.assertLessEqual(session.grants["bridge"], original_bridge_expiry)
|
||||
self.assertLessEqual(session.grants["bridge"], session.expires_at)
|
||||
|
||||
async def test_grant_only_reduction_preserves_omitted_ceilings(self) -> None:
|
||||
session = self._create_session("bounded")
|
||||
original_expiry = session.expires_at
|
||||
original_grants = dict(session.grants)
|
||||
|
||||
response = await self.client.patch(
|
||||
f"/sessions/{session.token[:8]}",
|
||||
headers=self._headers(session.token),
|
||||
json={"grants": {"terminal": 60}},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 200, await response.text())
|
||||
self.assertEqual(session.expires_at, original_expiry)
|
||||
self.assertLess(session.grants["terminal"], original_grants["terminal"])
|
||||
for channel, expiry in original_grants.items():
|
||||
if channel != "terminal":
|
||||
self.assertEqual(session.grants[channel], expiry)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import unittest
|
||||
|
||||
unittest.main()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Authorization tests for terminal WebSocket dispatch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from plugin.relay.config import RelayConfig
|
||||
from plugin.relay.server import RelayServer, _on_message
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
self.sent: list[dict[str, object]] = []
|
||||
|
||||
async def send_str(self, raw: str) -> None:
|
||||
self.sent.append(json.loads(raw))
|
||||
|
||||
async def close(self, **_kwargs: object) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class TerminalAuthorizationTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
self.server = RelayServer(RelayConfig())
|
||||
self.server.terminal.handle = AsyncMock()
|
||||
self.ws = _FakeWebSocket()
|
||||
self.server._client_tasks[self.ws] = set()
|
||||
self.session = self.server.sessions.create_session(
|
||||
"terminal-test",
|
||||
"terminal-test-id",
|
||||
grants={"terminal": 3600},
|
||||
)
|
||||
self.server._clients[self.ws] = self.session.token
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
for task in self.server._client_tasks.get(self.ws, set()):
|
||||
task.cancel()
|
||||
await asyncio.gather(
|
||||
*self.server._client_tasks.get(self.ws, set()),
|
||||
return_exceptions=True,
|
||||
)
|
||||
await self.server.close()
|
||||
|
||||
async def _dispatch(self, msg_type: str = "terminal.attach") -> None:
|
||||
await _on_message(
|
||||
self.ws,
|
||||
self.server,
|
||||
json.dumps(
|
||||
{
|
||||
"channel": "terminal",
|
||||
"type": msg_type,
|
||||
"id": "terminal-request",
|
||||
"payload": {},
|
||||
}
|
||||
),
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
def _assert_authorization_error(self) -> None:
|
||||
self.server.terminal.handle.assert_not_awaited()
|
||||
self.assertEqual(len(self.ws.sent), 1)
|
||||
self.assertEqual(self.ws.sent[0]["channel"], "system")
|
||||
self.assertEqual(self.ws.sent[0]["type"], "error")
|
||||
self.assertEqual(self.ws.sent[0]["id"], "terminal-request")
|
||||
self.assertIn("Terminal grant", self.ws.sent[0]["payload"]["message"])
|
||||
|
||||
async def test_missing_terminal_grant_rejects_every_terminal_action(self) -> None:
|
||||
self.session.grants.pop("terminal")
|
||||
|
||||
for msg_type in (
|
||||
"terminal.attach",
|
||||
"terminal.input",
|
||||
"terminal.resize",
|
||||
"terminal.list",
|
||||
"terminal.detach",
|
||||
"terminal.kill",
|
||||
):
|
||||
with self.subTest(msg_type=msg_type):
|
||||
self.ws.sent.clear()
|
||||
self.server.terminal.handle.reset_mock()
|
||||
await self._dispatch(msg_type)
|
||||
self._assert_authorization_error()
|
||||
|
||||
async def test_expired_terminal_grant_is_rejected(self) -> None:
|
||||
self.session.grants["terminal"] = time.time() - 1
|
||||
|
||||
await self._dispatch()
|
||||
|
||||
self._assert_authorization_error()
|
||||
|
||||
async def test_revoked_session_on_connected_socket_is_rejected(self) -> None:
|
||||
self.server.sessions.revoke_session(self.session.token)
|
||||
|
||||
await self._dispatch()
|
||||
|
||||
self._assert_authorization_error()
|
||||
|
||||
async def test_current_terminal_grant_dispatches_to_handler(self) -> None:
|
||||
await self._dispatch()
|
||||
|
||||
self.server.terminal.handle.assert_awaited_once()
|
||||
self.assertEqual(self.ws.sent, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -17,7 +17,7 @@ from plugin import update_check as uc
|
||||
class SemverTests(unittest.TestCase):
|
||||
def test_parse(self) -> None:
|
||||
self.assertEqual(uc.parse_semver("1.2.3"), (1, 2, 3))
|
||||
self.assertEqual(uc.parse_semver("plugin-v1.2.0"), (1, 2, 0))
|
||||
self.assertEqual(uc.parse_semver("server-v1.2.0"), (1, 2, 0))
|
||||
self.assertEqual(uc.parse_semver("v2.0.1-rc1"), (2, 0, 1))
|
||||
self.assertIsNone(uc.parse_semver("nope"))
|
||||
self.assertIsNone(uc.parse_semver(""))
|
||||
@@ -31,16 +31,23 @@ class SemverTests(unittest.TestCase):
|
||||
|
||||
|
||||
class TagPickTests(unittest.TestCase):
|
||||
def test_picks_highest_plugin_tag(self) -> None:
|
||||
def test_picks_highest_server_tag(self) -> None:
|
||||
releases = [
|
||||
{"tag_name": "plugin-v1.2.0"},
|
||||
{"tag_name": "android-v1.5.0"}, # different track — ignored
|
||||
{"tag_name": "plugin-v1.3.0"},
|
||||
{"tag_name": "cli-v0.4.0"},
|
||||
{"tag_name": "plugin-v1.9.0", "draft": True}, # draft — ignored
|
||||
{"tag_name": "server-v1.3.0"},
|
||||
{"tag_name": "desktop-v0.4.0"},
|
||||
{"tag_name": "server-v1.9.0", "draft": True}, # draft — ignored
|
||||
]
|
||||
self.assertEqual(uc.pick_latest_plugin_tag(releases), "1.3.0")
|
||||
|
||||
def test_falls_back_to_historical_plugin_tags(self) -> None:
|
||||
releases = [
|
||||
{"tag_name": "plugin-v1.2.0"},
|
||||
{"tag_name": "plugin-v1.4.0"},
|
||||
]
|
||||
self.assertEqual(uc.pick_latest_plugin_tag(releases), "1.4.0")
|
||||
|
||||
def test_no_plugin_releases(self) -> None:
|
||||
self.assertIsNone(uc.pick_latest_plugin_tag([{"tag_name": "android-v1.0.0"}]))
|
||||
self.assertIsNone(uc.pick_latest_plugin_tag("not-a-list"))
|
||||
|
||||
@@ -810,6 +810,25 @@ class EnhancedVoiceHelpersTests(unittest.TestCase):
|
||||
self.assertEqual(out["audio_tags"], False)
|
||||
self.assertEqual(out["language"], "en")
|
||||
|
||||
def test_extract_overrides_drops_untrusted_base_urls(self) -> None:
|
||||
from plugin.relay.voice import _extract_voice_overrides
|
||||
|
||||
top_level = _extract_voice_overrides(
|
||||
{"text": "hi", "voice": "Puck", "base_url": "https://attacker.invalid"}
|
||||
)
|
||||
nested = _extract_voice_overrides(
|
||||
{
|
||||
"text": "hi",
|
||||
"gemini": {
|
||||
"voice": "Puck",
|
||||
"base_url": "https://attacker.invalid",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(top_level, {"voice": "Puck"})
|
||||
self.assertEqual(nested, {"voice": "Puck"})
|
||||
|
||||
def test_extract_overrides_empty_for_plain_body(self) -> None:
|
||||
from plugin.relay.voice import _extract_voice_overrides
|
||||
|
||||
@@ -855,6 +874,42 @@ class EnhancedVoiceHelpersTests(unittest.TestCase):
|
||||
self.assertEqual(block["voices"], []) # free-text on the client
|
||||
self.assertIn("language", block["overrides"])
|
||||
|
||||
def test_gemini_adapter_keeps_operator_configured_base_url(self) -> None:
|
||||
from plugin.relay import upstream_voice
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
tts_mod = sys.modules["tools.tts_tool"]
|
||||
original = getattr(tts_mod, "_generate_gemini_tts", None)
|
||||
|
||||
def _capture(text: str, output_path: str, config: dict[str, object]) -> str:
|
||||
captured["config"] = config
|
||||
return output_path
|
||||
|
||||
try:
|
||||
tts_mod._generate_gemini_tts = _capture
|
||||
result = upstream_voice._synthesize_gemini(
|
||||
{
|
||||
"provider": "gemini",
|
||||
"gemini": {"base_url": "https://operator.example", "voice": "Kore"},
|
||||
},
|
||||
"hello",
|
||||
"voice.mp3",
|
||||
{"base_url": "https://attacker.invalid", "voice": "Puck"},
|
||||
)
|
||||
finally:
|
||||
if original is None:
|
||||
delattr(tts_mod, "_generate_gemini_tts")
|
||||
else:
|
||||
tts_mod._generate_gemini_tts = original
|
||||
|
||||
self.assertEqual(result, {"success": True, "file_path": "voice.mp3"})
|
||||
config = captured["config"]
|
||||
assert isinstance(config, dict)
|
||||
gemini = config["gemini"]
|
||||
assert isinstance(gemini, dict)
|
||||
self.assertEqual(gemini["base_url"], "https://operator.example")
|
||||
self.assertEqual(gemini["voice"], "Puck")
|
||||
|
||||
def test_apply_xai_speech_tags_calls_through_and_fails_soft(self) -> None:
|
||||
# Used by the streaming /voice/output renderer to match the synthesize
|
||||
# path's xAI tone behavior; must call upstream when present, fail soft
|
||||
|
||||
+18
-7
@@ -4,7 +4,7 @@ Hermes already ships the update *mechanism* — ``hermes plugins update
|
||||
hermes-relay`` for native plugin installs, ``hermes-relay-update`` for the
|
||||
full-relay installer. What was missing is *discovery*: telling the operator a
|
||||
newer release exists. This module compares the installed version against the
|
||||
latest ``plugin-v*`` GitHub release and picks the right update command for how
|
||||
latest ``server-v*`` GitHub release and picks the right update command for how
|
||||
this host was installed.
|
||||
|
||||
Pure helpers (version parsing/compare, tag selection, command detection) carry
|
||||
@@ -23,7 +23,8 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
GITHUB_RELEASES_URL = "https://api.github.com/repos/Codename-11/hermes-relay/releases"
|
||||
PLUGIN_TAG_PREFIX = "plugin-v"
|
||||
SERVER_TAG_PREFIX = "server-v"
|
||||
LEGACY_PLUGIN_TAG_PREFIX = "plugin-v"
|
||||
|
||||
# Native-plugin update command vs. full-relay installer shim. Detected per host.
|
||||
_NATIVE_UPDATE_CMD = "hermes plugins update hermes-relay"
|
||||
@@ -46,7 +47,7 @@ _SEMVER_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
|
||||
def parse_semver(value: str) -> Optional[Tuple[int, int, int]]:
|
||||
"""Extract ``(major, minor, patch)`` from a version-ish string.
|
||||
|
||||
Tolerates a leading ``v`` / ``plugin-v`` prefix and any pre-release suffix
|
||||
Tolerates release-tag prefixes and any pre-release suffix
|
||||
(``1.2.0-rc1`` → ``(1, 2, 0)``). Returns ``None`` when no ``X.Y.Z`` core is
|
||||
present.
|
||||
"""
|
||||
@@ -76,22 +77,32 @@ def compare_versions(current: str, latest: str) -> int:
|
||||
|
||||
|
||||
def pick_latest_plugin_tag(releases: Any) -> Optional[str]:
|
||||
"""Pick the highest ``plugin-v*`` version from a GitHub releases payload.
|
||||
"""Pick the highest Server version from a GitHub releases payload.
|
||||
|
||||
``releases`` is the decoded JSON list from the GitHub releases API. Drafts
|
||||
are ignored; pre-releases are considered (the plugin ships ``-alpha``/``-rc``
|
||||
tags). Returns the bare version (``"1.3.0"``), or ``None`` when no plugin
|
||||
release is present.
|
||||
release is present. Canonical ``server-v*`` releases take precedence over
|
||||
historical ``plugin-v*`` releases.
|
||||
"""
|
||||
if not isinstance(releases, list):
|
||||
return None
|
||||
canonical = [
|
||||
rel for rel in releases
|
||||
if isinstance(rel, dict)
|
||||
and not rel.get("draft")
|
||||
and isinstance(rel.get("tag_name"), str)
|
||||
and rel["tag_name"].startswith(SERVER_TAG_PREFIX)
|
||||
]
|
||||
candidates = canonical or releases
|
||||
accepted_prefix = SERVER_TAG_PREFIX if canonical else LEGACY_PLUGIN_TAG_PREFIX
|
||||
best: Optional[Tuple[int, int, int]] = None
|
||||
best_name: Optional[str] = None
|
||||
for rel in releases:
|
||||
for rel in candidates:
|
||||
if not isinstance(rel, dict) or rel.get("draft"):
|
||||
continue
|
||||
tag = rel.get("tag_name") or ""
|
||||
if not isinstance(tag, str) or not tag.startswith(PLUGIN_TAG_PREFIX):
|
||||
if not isinstance(tag, str) or not tag.startswith(accepted_prefix):
|
||||
continue
|
||||
ver = parse_semver(tag)
|
||||
if ver is None:
|
||||
|
||||
@@ -25,7 +25,8 @@ def qualifier(tag: str) -> str:
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
normalized = path.read_text(encoding="utf-8").replace("\r\n", "\n")
|
||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def placeholders(value: str) -> list[tuple[int, str]]:
|
||||
@@ -115,6 +116,10 @@ def install(args: argparse.Namespace) -> None:
|
||||
"native_name": manifest["native_name"],
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
source_set: digest(APP_SRC / source_set / "res" / "values" / "strings.xml")
|
||||
for source_set in SOURCE_SETS
|
||||
},
|
||||
"surfaces": {
|
||||
"android": "complete",
|
||||
"readme": "english-fallback",
|
||||
|
||||
@@ -110,7 +110,7 @@ echo " Next steps:"
|
||||
echo " 1. Update CHANGELOG.md / plugin release notes if needed"
|
||||
echo " 2. Commit on dev:"
|
||||
echo " git add $PYPROJECT $INITPY $PLUGIN_YAML $DASH_MANIFEST $DASH_PACKAGE $DASH_LOCK CHANGELOG.md"
|
||||
echo " git commit -m \"release(plugin): plugin-v$NEW_VERSION\""
|
||||
echo " git commit -m \"release(server): server-v$NEW_VERSION\""
|
||||
echo " 3. Merge dev -> main, then tag main:"
|
||||
echo " git tag plugin-v$NEW_VERSION"
|
||||
echo " git push origin plugin-v$NEW_VERSION"
|
||||
echo " git tag server-v$NEW_VERSION"
|
||||
echo " git push origin server-v$NEW_VERSION"
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
#
|
||||
# Hermes-Relay now has split release tracks:
|
||||
# - Android app: android-vX.Y.Z, version in gradle/libs.versions.toml
|
||||
# - Plugin/Python package: plugin-vX.Y.Z, version in pyproject.toml
|
||||
# - Server/Python package: server-vX.Y.Z, version in pyproject.toml
|
||||
# and plugin/relay/__init__.py
|
||||
# - CLI: cli-vX.Y.Z, version in desktop/package.json
|
||||
# - Desktop: desktop-vX.Y.Z, version in desktop/package.json
|
||||
#
|
||||
# Keep this legacy script as an alias for the Android app bump so older release
|
||||
# notes and muscle memory still work, but prefer the explicit script names:
|
||||
@@ -16,5 +16,5 @@ set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
echo "NOTE: scripts/bump-version.sh is now an Android app alias."
|
||||
echo " Use scripts/bump-plugin-version.sh for plugin-v* releases."
|
||||
echo " Use scripts/bump-plugin-version.sh for server-v* releases."
|
||||
exec bash "$REPO_ROOT/scripts/bump-android-version.sh" "$@"
|
||||
|
||||
@@ -189,8 +189,8 @@ def validate_status_registry(errors: list[str]) -> None:
|
||||
fail(f"{STATUS_PATH.relative_to(REPO_ROOT)}: cannot read status registry: {exc}", errors)
|
||||
return
|
||||
|
||||
if data.get("schema_version") != 2:
|
||||
fail("localization status schema_version must be 2", errors)
|
||||
if data.get("schema_version") != 3:
|
||||
fail("localization status schema_version must be 3", errors)
|
||||
if data.get("canonical_locale") != "en":
|
||||
fail("localization status canonical_locale must be 'en'", errors)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify plugin-owned version metadata stays in sync.
|
||||
|
||||
Plugin releases use the `plugin-v*` track. The canonical version is
|
||||
Server releases use the `server-v*` track. The canonical version is
|
||||
`pyproject.toml`'s `[project].version`; plugin and dashboard metadata should
|
||||
match because they ship as one Hermes plugin package.
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the canonical and legacy Hermes-Relay privacy policy surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CANONICAL_URL = "https://hermes-relay.dev/privacy.html"
|
||||
LEGACY_URL = "https://codename-11.github.io/hermes-relay/privacy.html"
|
||||
REQUIRED_MARKERS = (
|
||||
"<h1>Privacy Policy</h1>",
|
||||
"Google Play build",
|
||||
"Data storage",
|
||||
"Network connections",
|
||||
"Data export and deletion",
|
||||
"Children's privacy",
|
||||
"Hermes-Relay issue tracker",
|
||||
)
|
||||
|
||||
|
||||
def validate_content(label: str, content: str) -> None:
|
||||
missing = [marker for marker in REQUIRED_MARKERS if marker not in content]
|
||||
if missing:
|
||||
raise ValueError(f"{label} is missing required markers: {', '.join(missing)}")
|
||||
|
||||
|
||||
def validate_repository() -> None:
|
||||
policy = (ROOT / "website/public/privacy.html").read_text(encoding="utf-8")
|
||||
validate_content("website/public/privacy.html", policy)
|
||||
if f'<link rel="canonical" href="{CANONICAL_URL}"' not in policy:
|
||||
raise ValueError("canonical privacy URL is missing from website/public/privacy.html")
|
||||
|
||||
about = (ROOT / "app/src/main/kotlin/com/hermesandroid/relay/ui/screens/AboutScreen.kt").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
if CANONICAL_URL not in about:
|
||||
raise ValueError("Android About screen does not use the canonical privacy URL")
|
||||
|
||||
workflow = (ROOT / ".github/workflows/legacy-docs-redirect.yml").read_text(encoding="utf-8")
|
||||
for marker in ("website/public/privacy.html", "privacy.html", "privacy/index.html"):
|
||||
if marker not in workflow:
|
||||
raise ValueError(f"legacy Pages workflow is missing {marker}")
|
||||
|
||||
|
||||
def fetch(url: str) -> str:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "Hermes-Relay-release-check/1"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
if response.status != 200:
|
||||
raise ValueError(f"{url} returned HTTP {response.status}")
|
||||
return response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as error:
|
||||
raise ValueError(f"{url} returned HTTP {error.code}") from error
|
||||
except urllib.error.URLError as error:
|
||||
raise ValueError(f"{url} could not be loaded: {error.reason}") from error
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--live", action="store_true", help="also validate deployed policy URLs")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
validate_repository()
|
||||
if args.live:
|
||||
for url in (CANONICAL_URL, LEGACY_URL):
|
||||
validate_content(url, fetch(url))
|
||||
except ValueError as error:
|
||||
print(f"privacy policy validation failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
suffix = " and live URLs" if args.live else ""
|
||||
print(f"privacy policy repository contract{suffix} validated")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user