Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a5649d016 |
@@ -1 +0,0 @@
|
||||
*.sh text eol=lf
|
||||
@@ -1,90 +0,0 @@
|
||||
name: Bug report
|
||||
description: Report a reproducible problem in Hermes-Relay.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before submitting, remove secrets, access tokens, real hostnames/IPs, private deployment names, and personal names. Public example IPs such as `192.168.1.100` are fine.
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Affected area
|
||||
description: Pick the closest surface.
|
||||
options:
|
||||
- Android app
|
||||
- Standard Hermes chat or voice
|
||||
- Relay plugin or server
|
||||
- Desktop CLI or tray
|
||||
- Dashboard plugin
|
||||
- Docs or installer
|
||||
- CI, release, or packaging
|
||||
- Unsure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: State the behavior you saw and what you expected instead.
|
||||
placeholder: |
|
||||
Observed:
|
||||
|
||||
Expected:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Reproduction steps
|
||||
description: Include the smallest sequence that reproduces the issue.
|
||||
placeholder: |
|
||||
1. Pair or configure...
|
||||
2. Open...
|
||||
3. Tap or run...
|
||||
4. See...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Include only the fields that apply.
|
||||
value: |
|
||||
- Hermes-Relay version/tag:
|
||||
- Install surface: Google Play / sideload APK / local build / plugin / desktop CLI
|
||||
- Android device and OS:
|
||||
- hermes-agent version or commit:
|
||||
- Connection mode: LAN / Tailscale / public TLS / other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Sanitized logs, screenshots, or traces
|
||||
description: Paste the smallest useful log excerpt. Remove tokens, private URLs, hostnames, IPs, and user-identifying data.
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: upstream
|
||||
attributes:
|
||||
label: Upstream or standard-path notes
|
||||
description: If relevant, note whether this reproduces against unmodified upstream hermes-agent or only with the relay plugin enabled.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I searched existing issues first.
|
||||
required: true
|
||||
- label: I removed secrets, tokens, private infrastructure, and personal names.
|
||||
required: true
|
||||
- label: I included the affected version or install surface where known.
|
||||
required: true
|
||||
@@ -1,11 +0,0 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Report a security vulnerability (private)
|
||||
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://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
|
||||
about: Review local setup, branch, commit, changelog, and test conventions.
|
||||
@@ -1,64 +0,0 @@
|
||||
name: Documentation or setup issue
|
||||
description: Report unclear, stale, or missing docs and setup guidance.
|
||||
title: "[Docs]: "
|
||||
labels: ["documentation"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this for docs, installer, setup, release-note, or contribution-guide problems. Remove private hostnames/IPs, tokens, and personal names before posting.
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Documentation area
|
||||
options:
|
||||
- README
|
||||
- User docs site
|
||||
- Android setup
|
||||
- Relay plugin setup
|
||||
- Desktop CLI or tray setup
|
||||
- Release notes or changelog
|
||||
- Contributor docs
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: location
|
||||
attributes:
|
||||
label: Page, file, or section
|
||||
description: Link the page or name the file and heading.
|
||||
placeholder: user-docs/guide/getting-started.md, README install section, etc.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: issue
|
||||
attributes:
|
||||
label: What is wrong or missing?
|
||||
description: Explain what was unclear, outdated, misleading, or absent.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Suggested correction
|
||||
description: Optional. Include the wording, command, screenshot need, or structure that would help.
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Context
|
||||
description: Optional. Include the version, install path, device, or command you were following.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I checked that this is not already covered in current docs.
|
||||
required: true
|
||||
- label: I removed secrets, private hostnames/IPs, internal deployment names, and personal names.
|
||||
required: true
|
||||
@@ -1,78 +0,0 @@
|
||||
name: Feature request
|
||||
description: Propose a product, workflow, or platform improvement.
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Keep requests focused on user-visible outcomes. Do not include private infrastructure, secrets, personal names, or branch/workspace plumbing.
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Affected area
|
||||
options:
|
||||
- Android app
|
||||
- Standard Hermes chat or voice
|
||||
- Relay plugin or server
|
||||
- Desktop CLI or tray
|
||||
- Dashboard plugin
|
||||
- Docs or installer
|
||||
- CI, release, or packaging
|
||||
- Unsure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem or workflow
|
||||
description: What is hard, missing, slow, confusing, or unsafe today?
|
||||
placeholder: Describe the concrete user workflow this would improve.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed behavior
|
||||
description: Describe the outcome, not just an implementation detail.
|
||||
placeholder: After this change, a user should be able to...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: standard_path
|
||||
attributes:
|
||||
label: Standard upstream compatibility
|
||||
description: If this touches chat, voice, dashboard, API routes, or server behavior, note whether it can work against unmodified upstream hermes-agent.
|
||||
placeholder: This should work on vanilla upstream because... / This requires the relay plugin because...
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Optional. Mention current workarounds or related approaches.
|
||||
|
||||
- type: textarea
|
||||
id: acceptance
|
||||
attributes:
|
||||
label: Acceptance criteria
|
||||
description: What would make the request complete?
|
||||
placeholder: |
|
||||
- Users can...
|
||||
- The app/server handles...
|
||||
- Documentation covers...
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I searched existing issues first.
|
||||
required: true
|
||||
- label: I described the user outcome and affected surface.
|
||||
required: true
|
||||
- label: I removed private infrastructure details and personal names.
|
||||
required: true
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Translation correction
|
||||
description: Report or propose a clearer translation for one locale.
|
||||
title: "[Translation]: "
|
||||
labels: ["translation"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
English defines the product meaning. Translation corrections are applied to the canonical locale catalog and credited through Git history.
|
||||
- type: input
|
||||
id: locale
|
||||
attributes:
|
||||
label: Language and locale
|
||||
placeholder: Spanish (es), Simplified Chinese (zh-Hans), etc.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: location
|
||||
attributes:
|
||||
label: Screen and current text
|
||||
description: Name the screen, resource key if known, and current translated wording.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: correction
|
||||
attributes:
|
||||
label: Suggested correction
|
||||
description: Include the corrected text and what the English source means in this context.
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: proficiency
|
||||
attributes:
|
||||
label: Language familiarity
|
||||
options:
|
||||
- Native speaker
|
||||
- Fluent speaker
|
||||
- Professional translator
|
||||
- Learner or machine-assisted report
|
||||
- Prefer not to say
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: sensitive
|
||||
attributes:
|
||||
label: Sensitive meaning
|
||||
options:
|
||||
- label: This affects permissions, privacy, security, destructive actions, payments, or recovery instructions.
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Optional screenshot, regional preference, or explanation of why the existing wording is misleading.
|
||||
@@ -6,33 +6,11 @@
|
||||
|
||||
-
|
||||
|
||||
## Verification
|
||||
|
||||
<!-- List the checks you ran, or explain why a check is not applicable. -->
|
||||
|
||||
-
|
||||
|
||||
## Lineage / contributor credit
|
||||
|
||||
<!--
|
||||
If this PR salvages or supersedes earlier work, link every source PR and name
|
||||
the original contributor(s). Preserve original commit authors where practical;
|
||||
otherwise use verified Co-authored-by trailers. Write "N/A" for original work.
|
||||
-->
|
||||
|
||||
- Source PR(s): N/A
|
||||
- Attribution preserved by: N/A
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] 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
|
||||
- [ ] Desktop changes: `npm run build` or a narrower documented check ran, or rationale is listed above
|
||||
- [ ] Docs/site changes: docs build or link check ran, or rationale is listed above
|
||||
- [ ] UI changes were tested on emulator/device or desktop surface when applicable
|
||||
- [ ] `./gradlew assembleDebug` succeeds
|
||||
- [ ] `./gradlew test` passes
|
||||
- [ ] Tested on emulator or device (if UI change)
|
||||
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
- [ ] CHANGELOG.md updated (if user-facing)
|
||||
- [ ] Public writing hygiene checked: no secrets, private infrastructure, personal names, or AI/process narration
|
||||
- [ ] Salvaged work links the source PR and preserves contributor authorship, or N/A
|
||||
- [ ] No credentials or secrets in committed files
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# GitHub Copilot instructions — Hermes-Relay
|
||||
|
||||
This file exists so GitHub Copilot (which reads `.github/copilot-instructions.md`,
|
||||
not `AGENTS.md`) picks up the project's agent guidance.
|
||||
|
||||
**Read [AGENTS.md](../AGENTS.md) first — it is the single source of truth**
|
||||
for agent guidance: the entry point, the non-negotiables, and the public-repo
|
||||
writing hygiene. It links on to `CLAUDE.md` for the deep reference
|
||||
(architecture, upstream Hermes API, repository layout, per-language code style,
|
||||
the dev loop, and the Key Files map). Follow those; don't restate them here.
|
||||
|
||||
Quick non-negotiables (the full list and rationale are in `AGENTS.md`):
|
||||
|
||||
- **Standard path = vanilla upstream only.** The default no-plugin connection
|
||||
must work against unmodified upstream hermes-agent; server-side needs go
|
||||
through upstream PRs or the optional relay plugin, never fork patches.
|
||||
- **Conventional Commits**, `main`/`dev` branching — feature branches off
|
||||
`dev`, `--no-ff` merges, tags cut from `main`.
|
||||
- **Android:** Jetpack Compose (no XML), kotlinx.serialization (no Gson),
|
||||
OkHttp (no Ktor), `wss://` only; run `./gradlew lint` before pushing Kotlin.
|
||||
- **Public repo:** no personal names, no private infrastructure, no
|
||||
AI/assistant self-narration in committed prose.
|
||||
@@ -3,7 +3,6 @@ updates:
|
||||
# Gradle dependencies
|
||||
- package-ecosystem: "gradle"
|
||||
directory: "/"
|
||||
target-branch: "dev"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
@@ -25,12 +24,10 @@ updates:
|
||||
patterns:
|
||||
- "junit*"
|
||||
- "androidx.compose.ui:ui-test*"
|
||||
- "io.github.takahirom.roborazzi*"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
target-branch: "dev"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
function classifyCiPaths(paths) {
|
||||
const forceAll = paths.some((path) => [
|
||||
'.github/workflows/ci-required.yml',
|
||||
'.github/scripts/classify-ci-paths.cjs',
|
||||
'.github/scripts/classify-ci-paths.test.cjs',
|
||||
].includes(path));
|
||||
const exact = (values) => paths.some((path) => values.includes(path));
|
||||
const under = (prefixes) => paths.some((path) => prefixes.some((prefix) => path.startsWith(prefix)));
|
||||
|
||||
return {
|
||||
android: forceAll || under(['app/', 'relay-core/', 'relay-ui/', 'ui-preview/', 'quest/', 'gradle/']) || exact([
|
||||
'build.gradle.kts', 'settings.gradle.kts', 'gradle.properties', 'gradlew', 'gradlew.bat',
|
||||
'scripts/check-android-locales.py', 'scripts/android-locale-harness.py',
|
||||
'scripts/check-android-collection-apis.py', '.github/workflows/ci-android.yml',
|
||||
'.github/workflows/play-preflight-android.yml',
|
||||
'.github/workflows/approve-release-android.yml',
|
||||
'.github/workflows/release-android.yml',
|
||||
]),
|
||||
desktop: forceAll || under(['desktop/']) || exact([
|
||||
'.github/workflows/ci-desktop.yml',
|
||||
]),
|
||||
plugin: forceAll || paths.some((path) => /^plugin\/[^/]+\.py$/.test(path)) ||
|
||||
under(['plugin/relay/', 'plugin/tools/', 'plugin/tests/', 'relay_server/', 'hermes_relay_bootstrap/']) || exact([
|
||||
'plugin/plugin.yaml', 'pyproject.toml', 'scripts/check-plugin-version-sync.py',
|
||||
'scripts/check-server-version-sync.py', 'scripts/bump-plugin-version.sh',
|
||||
'scripts/bump-server-version.sh', '.github/workflows/ci-plugin.yml',
|
||||
]),
|
||||
dashboard: forceAll || under(['plugin/dashboard/']) || exact([
|
||||
'.github/workflows/ci-dashboard.yml',
|
||||
]),
|
||||
contract: forceAll ||
|
||||
under(['app/src/main/kotlin/com/hermesandroid/relay/network/upstream/']) || exact([
|
||||
'scripts/check-upstream-route-contract.py', '.github/workflows/ci-contract.yml',
|
||||
]),
|
||||
docs: forceAll || under(['user-docs/']) || exact([
|
||||
'.github/workflows/docs.yml',
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { classifyCiPaths };
|
||||
@@ -1,34 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { classifyCiPaths } = require('./classify-ci-paths.cjs');
|
||||
|
||||
const none = {
|
||||
android: false,
|
||||
desktop: false,
|
||||
plugin: false,
|
||||
dashboard: false,
|
||||
contract: false,
|
||||
docs: false,
|
||||
};
|
||||
|
||||
assert.deepEqual(classifyCiPaths(['README.md']), none);
|
||||
assert.deepEqual(classifyCiPaths(['desktop/src/cli.ts']), { ...none, desktop: true });
|
||||
assert.deepEqual(classifyCiPaths(['relay-core/src/main/kotlin/Wire.kt']), { ...none, android: true });
|
||||
assert.deepEqual(classifyCiPaths(['plugin/relay/server.py']), { ...none, plugin: true });
|
||||
assert.deepEqual(classifyCiPaths(['plugin/dashboard/src/App.tsx']), { ...none, dashboard: true });
|
||||
assert.deepEqual(classifyCiPaths(['user-docs/index.md']), { ...none, docs: true });
|
||||
assert.deepEqual(
|
||||
classifyCiPaths(['app/src/main/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClient.kt']),
|
||||
{ ...none, android: true, contract: true },
|
||||
);
|
||||
assert.deepEqual(classifyCiPaths(['.github/workflows/ci-required.yml']), {
|
||||
android: true,
|
||||
desktop: true,
|
||||
plugin: true,
|
||||
dashboard: true,
|
||||
contract: true,
|
||||
docs: true,
|
||||
});
|
||||
|
||||
console.log('CI path classification tests passed.');
|
||||
@@ -1,173 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const COMMENT_MARKER = '<!-- hermes-relay-review-candidate -->';
|
||||
const ARTIFACT_NAME_RE = /^hermes-relay-review-pr-(\d+)-([0-9a-f]{12})$/;
|
||||
|
||||
function formatExpiry(value) {
|
||||
if (!value) return 'the artifact retention window';
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function buildReviewComment({ conclusion, prNumber, headSha, runUrl, artifact }) {
|
||||
const shortSha = headSha.slice(0, 12);
|
||||
|
||||
if (conclusion === 'success' && artifact) {
|
||||
const artifactUrl = `${runUrl}/artifacts/${artifact.id}`;
|
||||
return `${COMMENT_MARKER}
|
||||
## Review candidate ready
|
||||
|
||||
Built from PR #${prNumber} head \`${shortSha}\`.
|
||||
|
||||
[Download \`${artifact.name}\`](${artifactUrl}) — expires **${formatExpiry(artifact.expires_at)}**.
|
||||
|
||||
1. Unzip the bundle and verify its files against \`SHA256SUMS.txt\`.
|
||||
2. Install the APK under \`android/\`. It appears as **HR Candidate**, leaves stable installs untouched, and must be paired separately.
|
||||
3. Test the Relay package only in a disposable/staging Hermes instance or with an explicit snapshot and rollback plan. Confirm the source SHA in \`REVIEW_MANIFEST.json\`.
|
||||
|
||||
[View workflow run](${runUrl})`;
|
||||
}
|
||||
|
||||
if (conclusion === 'action_required') {
|
||||
return `${COMMENT_MARKER}
|
||||
## Review candidate awaiting approval
|
||||
|
||||
GitHub held the build for PR #${prNumber} head \`${shortSha}\` at the first-time fork approval gate. A maintainer must approve the run before any candidate can be published.
|
||||
|
||||
[Review and approve the workflow run](${runUrl})`;
|
||||
}
|
||||
|
||||
const result = conclusion || 'unknown';
|
||||
return `${COMMENT_MARKER}
|
||||
## Review candidate unavailable
|
||||
|
||||
The build for PR #${prNumber} head \`${shortSha}\` completed with **${result}** and did not publish a candidate bundle.
|
||||
|
||||
[View workflow run](${runUrl})`;
|
||||
}
|
||||
|
||||
function artifactPrNumber(artifacts, headSha) {
|
||||
const shortSha = headSha.slice(0, 12);
|
||||
for (const artifact of artifacts) {
|
||||
const match = ARTIFACT_NAME_RE.exec(artifact.name);
|
||||
if (match && match[2] === shortSha) return Number(match[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolvePrNumber({ github, owner, repo, run, artifacts }) {
|
||||
const payloadPr = run.pull_requests?.[0]?.number;
|
||||
if (payloadPr) return payloadPr;
|
||||
|
||||
const artifactPr = artifactPrNumber(artifacts, run.head_sha);
|
||||
if (artifactPr) return artifactPr;
|
||||
|
||||
const headOwner = run.head_repository?.owner?.login;
|
||||
if (!headOwner || !run.head_branch) return null;
|
||||
|
||||
const { data: pulls } = await github.rest.pulls.list({
|
||||
owner,
|
||||
repo,
|
||||
head: `${headOwner}:${run.head_branch}`,
|
||||
state: 'all',
|
||||
per_page: 100,
|
||||
});
|
||||
const exact = pulls.find((pull) =>
|
||||
pull.head.sha === run.head_sha && pull.base.ref === 'dev'
|
||||
);
|
||||
return exact?.number ?? null;
|
||||
}
|
||||
|
||||
async function resolveWorkflowRun({ github, context, core }) {
|
||||
const completedRun = context.payload.workflow_run;
|
||||
if (completedRun) return completedRun;
|
||||
|
||||
const requested = context.payload.inputs?.run_id;
|
||||
const runId = Number(requested);
|
||||
if (!Number.isSafeInteger(runId) || runId <= 0) {
|
||||
core.setFailed(`Invalid Build Review Bundle run ID: ${requested ?? ''}`);
|
||||
return null;
|
||||
}
|
||||
const { owner, repo } = context.repo;
|
||||
const { data: run } = await github.rest.actions.getWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: runId,
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
async function reportReviewBundle({ github, context, core }) {
|
||||
const run = await resolveWorkflowRun({ github, context, core });
|
||||
const { owner, repo } = context.repo;
|
||||
if (!run) return;
|
||||
if (run.name !== 'Build Review Bundle' || run.event !== 'pull_request') {
|
||||
core.info('Ignoring a review-bundle run that was not triggered by a pull request.');
|
||||
return;
|
||||
}
|
||||
if (run.conclusion === 'skipped') {
|
||||
core.info(`Ignoring skipped review-bundle run ${run.id}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const artifacts = await github.paginate(
|
||||
github.rest.actions.listWorkflowRunArtifacts,
|
||||
{ owner, repo, run_id: run.id, per_page: 100 },
|
||||
);
|
||||
const prNumber = await resolvePrNumber({ github, owner, repo, run, artifacts });
|
||||
if (!prNumber) {
|
||||
core.warning(`Could not resolve a pull request for review-bundle run ${run.id}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedName = `hermes-relay-review-pr-${prNumber}-${run.head_sha.slice(0, 12)}`;
|
||||
const artifact = artifacts.find((item) => item.name === expectedName && !item.expired);
|
||||
const body = buildReviewComment({
|
||||
conclusion: run.conclusion,
|
||||
prNumber,
|
||||
headSha: run.head_sha,
|
||||
runUrl: run.html_url,
|
||||
artifact,
|
||||
});
|
||||
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ owner, repo, issue_number: prNumber, per_page: 100 },
|
||||
);
|
||||
const existing = comments.find((comment) =>
|
||||
comment.user?.login === 'github-actions[bot]' &&
|
||||
comment.body?.includes(COMMENT_MARKER)
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
core.info(`Updated review-candidate comment on PR #${prNumber}.`);
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
core.info(`Created review-candidate comment on PR #${prNumber}.`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ARTIFACT_NAME_RE,
|
||||
COMMENT_MARKER,
|
||||
artifactPrNumber,
|
||||
buildReviewComment,
|
||||
reportReviewBundle,
|
||||
resolvePrNumber,
|
||||
resolveWorkflowRun,
|
||||
};
|
||||
@@ -1,184 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
artifactPrNumber,
|
||||
buildReviewComment,
|
||||
reportReviewBundle,
|
||||
} = require('./review-bundle-report.cjs');
|
||||
|
||||
const run = {
|
||||
id: 32729383426,
|
||||
name: 'Build Review Bundle',
|
||||
event: 'pull_request',
|
||||
conclusion: 'success',
|
||||
head_sha: '90ab705a883ca963035f4f8ccda815619dbd4f3b',
|
||||
head_branch: 'fix/gateway-history-attachments',
|
||||
head_repository: { owner: { login: 'JackHunzicker' } },
|
||||
html_url: 'https://github.com/Codename-11/hermes-relay/actions/runs/32729383426',
|
||||
pull_requests: [],
|
||||
};
|
||||
const artifact = {
|
||||
id: 9521126010,
|
||||
name: 'hermes-relay-review-pr-398-90ab705a883c',
|
||||
expired: false,
|
||||
expires_at: '2026-08-31T12:52:24Z',
|
||||
};
|
||||
|
||||
assert.equal(artifactPrNumber([artifact], run.head_sha), 398);
|
||||
|
||||
const successBody = buildReviewComment({
|
||||
conclusion: 'success',
|
||||
prNumber: 398,
|
||||
headSha: run.head_sha,
|
||||
runUrl: run.html_url,
|
||||
artifact,
|
||||
});
|
||||
assert.match(successBody, /## Review candidate ready/);
|
||||
assert.match(successBody, /hermes-relay-review-pr-398-90ab705a883c/);
|
||||
assert.match(successBody, /expires \*\*August 31, 2026\*\*/);
|
||||
assert.match(successBody, /HR Candidate/);
|
||||
assert.ok(!successBody.includes(['Hermes', 'Candidate'].join(' ')));
|
||||
assert.match(successBody, /REVIEW_MANIFEST\.json/);
|
||||
|
||||
const blockedBody = buildReviewComment({
|
||||
conclusion: 'action_required',
|
||||
prNumber: 398,
|
||||
headSha: run.head_sha,
|
||||
runUrl: run.html_url,
|
||||
});
|
||||
assert.match(blockedBody, /## Review candidate awaiting approval/);
|
||||
assert.doesNotMatch(blockedBody, /Download/);
|
||||
|
||||
async function testExistingCommentIsUpdated() {
|
||||
const calls = { create: [], update: [] };
|
||||
const github = {
|
||||
rest: {
|
||||
actions: { listWorkflowRunArtifacts() {} },
|
||||
issues: {
|
||||
listComments() {},
|
||||
createComment: async (args) => calls.create.push(args),
|
||||
updateComment: async (args) => calls.update.push(args),
|
||||
},
|
||||
pulls: { list: async () => ({ data: [] }) },
|
||||
},
|
||||
paginate: async (method) => {
|
||||
if (method === github.rest.actions.listWorkflowRunArtifacts) return [artifact];
|
||||
if (method === github.rest.issues.listComments) {
|
||||
return [{
|
||||
id: 77,
|
||||
user: { login: 'github-actions[bot]' },
|
||||
body: '<!-- hermes-relay-review-candidate -->\nold',
|
||||
}];
|
||||
}
|
||||
throw new Error('Unexpected pagination method');
|
||||
},
|
||||
};
|
||||
const messages = [];
|
||||
await reportReviewBundle({
|
||||
github,
|
||||
context: {
|
||||
repo: { owner: 'Codename-11', repo: 'hermes-relay' },
|
||||
payload: { workflow_run: run },
|
||||
},
|
||||
core: {
|
||||
info: (message) => messages.push(message),
|
||||
warning: (message) => messages.push(message),
|
||||
},
|
||||
});
|
||||
assert.equal(calls.create.length, 0);
|
||||
assert.equal(calls.update.length, 1);
|
||||
assert.equal(calls.update[0].comment_id, 77);
|
||||
assert.match(calls.update[0].body, /## Review candidate ready/);
|
||||
assert.deepEqual(messages, ['Updated review-candidate comment on PR #398.']);
|
||||
}
|
||||
|
||||
async function testManualRunSelectionCreatesComment() {
|
||||
const calls = { create: [], update: [] };
|
||||
const github = {
|
||||
rest: {
|
||||
actions: {
|
||||
getWorkflowRun: async ({ run_id: runId }) => {
|
||||
assert.equal(runId, run.id);
|
||||
return { data: run };
|
||||
},
|
||||
listWorkflowRunArtifacts() {},
|
||||
},
|
||||
issues: {
|
||||
listComments() {},
|
||||
createComment: async (args) => calls.create.push(args),
|
||||
updateComment: async (args) => calls.update.push(args),
|
||||
},
|
||||
pulls: { list: async () => ({ data: [] }) },
|
||||
},
|
||||
paginate: async (method) => {
|
||||
if (method === github.rest.actions.listWorkflowRunArtifacts) return [artifact];
|
||||
if (method === github.rest.issues.listComments) return [];
|
||||
throw new Error('Unexpected pagination method');
|
||||
},
|
||||
};
|
||||
await reportReviewBundle({
|
||||
github,
|
||||
context: {
|
||||
repo: { owner: 'Codename-11', repo: 'hermes-relay' },
|
||||
payload: { inputs: { run_id: String(run.id) } },
|
||||
},
|
||||
core: {
|
||||
info() {},
|
||||
warning() {},
|
||||
setFailed: (message) => assert.fail(message),
|
||||
},
|
||||
});
|
||||
assert.equal(calls.update.length, 0);
|
||||
assert.equal(calls.create.length, 1);
|
||||
assert.equal(calls.create[0].issue_number, 398);
|
||||
assert.match(calls.create[0].body, /## Review candidate ready/);
|
||||
}
|
||||
|
||||
async function testSkippedRunIsIgnored() {
|
||||
let apiCalled = false;
|
||||
const messages = [];
|
||||
const github = {
|
||||
rest: {
|
||||
actions: {
|
||||
listWorkflowRunArtifacts() {},
|
||||
},
|
||||
},
|
||||
paginate: async () => {
|
||||
apiCalled = true;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
await reportReviewBundle({
|
||||
github,
|
||||
context: {
|
||||
repo: { owner: 'Codename-11', repo: 'hermes-relay' },
|
||||
payload: {
|
||||
workflow_run: {
|
||||
...run,
|
||||
id: 32736508535,
|
||||
conclusion: 'skipped',
|
||||
head_sha: 'a38849ff1680a1993230773a5d602b781367c789',
|
||||
},
|
||||
},
|
||||
},
|
||||
core: {
|
||||
info: (message) => messages.push(message),
|
||||
warning: (message) => messages.push(message),
|
||||
setFailed: (message) => assert.fail(message),
|
||||
},
|
||||
});
|
||||
assert.equal(apiCalled, false);
|
||||
assert.deepEqual(messages, ['Ignoring skipped review-bundle run 32736508535.']);
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
testExistingCommentIsUpdated(),
|
||||
testManualRunSelectionCreatesComment(),
|
||||
testSkippedRunIsIgnored(),
|
||||
])
|
||||
.then(() => console.log('Review-bundle report tests passed.'))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
# Hermes-Relay Android — explicit public release approval
|
||||
#
|
||||
# Run from main only after the automated Play preflight passes and the release
|
||||
# PR has merged. Starting this workflow is the release approval. Creating the
|
||||
# stable tag triggers Play submission first, then GitHub publication.
|
||||
|
||||
name: Hermes-Relay Android Release Approval
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Approved Android version (for example 1.4.3)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: approve-android-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: Verify preflight and create release tag
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate approval request
|
||||
id: metadata
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ "$GITHUB_REF" != "refs/heads/main" ]; then
|
||||
echo "::error::Hermes-Relay Android Release Approval must run from main, not $GITHUB_REF"
|
||||
exit 1
|
||||
fi
|
||||
TOML_VERSION=$(grep -oP 'appVersionName\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
if [ "$REQUESTED_VERSION" != "$TOML_VERSION" ]; then
|
||||
echo "::error::Requested version $REQUESTED_VERSION does not match appVersionName $TOML_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$TOML_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify this exact release tree passed Play preflight
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
RELEASE_TREE: ${{ steps.metadata.outputs.tree }}
|
||||
run: |
|
||||
ARTIFACT_NAME="play-preflight-${VERSION}-${RELEASE_TREE}"
|
||||
COUNT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
|
||||
--jq '[.artifacts[] | select(.expired == false)] | length')
|
||||
if [ "$COUNT" -lt 1 ]; then
|
||||
echo "::error::No successful Play preflight found for version $VERSION with tree $RELEASE_TREE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified Play preflight proof: $ARTIFACT_NAME"
|
||||
|
||||
- name: Ensure release tag does not already exist
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
run: |
|
||||
if gh api "/repos/${GITHUB_REPOSITORY}/git/ref/tags/android-v${VERSION}" >/dev/null 2>&1; then
|
||||
echo "::error::Tag android-v${VERSION} already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create approved Android release tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
run: |
|
||||
gh api --method POST "/repos/${GITHUB_REPOSITORY}/git/refs" \
|
||||
-f ref="refs/tags/android-v${VERSION}" \
|
||||
-f sha="$GITHUB_SHA"
|
||||
|
||||
- name: Start the tag release workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.metadata.outputs.version }}
|
||||
run: |
|
||||
gh workflow run release-android.yml \
|
||||
--ref=main \
|
||||
-f version="$VERSION"
|
||||
|
||||
- name: Approval summary
|
||||
run: |
|
||||
echo "## Android release approved" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Created \`android-v${{ steps.metadata.outputs.version }}\` from main at \`$GITHUB_SHA\`." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The current release workflow was dispatched from main and will check out that immutable tag. It will submit the preflighted Play draft before creating the public GitHub Release." >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,60 +1,51 @@
|
||||
# Hermes-Relay — Android CI Pipeline
|
||||
#
|
||||
# Runs directly on Android-affecting pushes to main/dev and is called by the
|
||||
# path-aware required-check workflow for relevant pull requests.
|
||||
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
|
||||
# Android-affecting paths so Python-only changes don't spin up the JVM.
|
||||
#
|
||||
# Pipeline: lint, build, and focused tests run concurrently. PRs build debug
|
||||
# APKs before merge; dev pushes keep lint/tests only to avoid duplicate
|
||||
# post-merge packaging. Main pushes keep APK artifacts.
|
||||
#
|
||||
# A release-build smoke (bundleRelease assembleRelease) runs on dev/main pushes
|
||||
# and on the dev→main release PR so release-only breakage (R8/minify rules,
|
||||
# resource shrinking, bundletool OOM) is caught BEFORE the android-v* tag,
|
||||
# instead of mid-release. It is debug-signed, so it needs no signing secrets.
|
||||
# Pipeline: lint -> build + test (parallel) -> upload artifacts
|
||||
|
||||
name: CI — Android
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "relay-core/**"
|
||||
- "relay-ui/**"
|
||||
- "ui-preview/**"
|
||||
- "quest/**"
|
||||
- "gradle/**"
|
||||
- "build.gradle.kts"
|
||||
- "settings.gradle.kts"
|
||||
- "gradle.properties"
|
||||
- "gradlew"
|
||||
- "gradlew.bat"
|
||||
- "scripts/check-android-locales.py"
|
||||
- "scripts/android-locale-harness.py"
|
||||
- "scripts/check-android-collection-apis.py"
|
||||
- ".github/workflows/ci-android.yml"
|
||||
- ".github/workflows/play-preflight-android.yml"
|
||||
- ".github/workflows/approve-release-android.yml"
|
||||
- ".github/workflows/release-android.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "gradle/**"
|
||||
- "build.gradle.kts"
|
||||
- "settings.gradle.kts"
|
||||
- "gradle.properties"
|
||||
- "gradlew"
|
||||
- "gradlew.bat"
|
||||
- ".github/workflows/ci-android.yml"
|
||||
|
||||
# Cancel superseded PR and dev runs. Never cancel main: every release-branch
|
||||
# commit must finish its independent validation.
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
concurrency:
|
||||
group: ci-android-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
# Android Lint
|
||||
# Android Lint — gate for build and test jobs
|
||||
# ──────────────────────────────────────────────
|
||||
lint:
|
||||
name: Lint (Android)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -63,30 +54,29 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Validate translation catalogs
|
||||
run: python3 scripts/check-android-locales.py
|
||||
|
||||
- name: Reject unsafe Android collection APIs
|
||||
run: python3 scripts/check-android-collection-apis.py
|
||||
|
||||
- name: Run Android lint
|
||||
run: ./gradlew lint --console=plain
|
||||
# Prefer ktlintCheck if configured; fall back to Android lint
|
||||
- name: Run lint checks
|
||||
run: |
|
||||
if ./gradlew tasks --all 2>/dev/null | grep -q "ktlintCheck"; then
|
||||
echo "Running ktlintCheck..."
|
||||
./gradlew ktlintCheck
|
||||
else
|
||||
echo "ktlintCheck not found, falling back to Android lint..."
|
||||
./gradlew lint
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Android Build — assembleDebug for PRs and main pushes
|
||||
# Android Build — assembleDebug + upload APK
|
||||
# ──────────────────────────────────────────────
|
||||
build:
|
||||
name: Build (Android)
|
||||
if: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }}
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -95,16 +85,13 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug --console=plain
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Upload debug APK
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
with:
|
||||
name: debug-apk
|
||||
# Product flavors (googlePlay, sideload) nest APKs under
|
||||
@@ -122,6 +109,7 @@ jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
test:
|
||||
name: Test (Android)
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Advisory on dev, strict on main. Evaluates to false (= strict) for
|
||||
@@ -130,7 +118,7 @@ jobs:
|
||||
continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -139,82 +127,24 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
# The broad Gradle `test` aggregate currently hangs in deferred JVM test
|
||||
# suites tracked by issue #32. Keep CI release-relevant until that suite is
|
||||
# split: run the stable connection slice plus focused Chat/Voice state,
|
||||
# parser, layout, and accessibility regressions for the active release.
|
||||
# split: pairing URL derivation plus connection switching are the stable
|
||||
# Android regression slice for the active release work.
|
||||
- name: Run focused Android unit tests
|
||||
run: |
|
||||
./gradlew :app:testSideloadDebugUnitTest \
|
||||
--tests com.hermesandroid.relay.network.ArchitectureBoundaryTest \
|
||||
--tests com.hermesandroid.relay.network.relay.RelayUrlDeriverTest \
|
||||
--tests com.hermesandroid.relay.network.RelayUrlDeriverTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
|
||||
--tests com.hermesandroid.relay.util.ServerAddressTest \
|
||||
--tests com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest \
|
||||
--tests com.hermesandroid.relay.data.AppLanguageTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ChatStreamRecoveryTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ChatViewModelRealtimeTurnTest \
|
||||
--tests com.hermesandroid.relay.network.relay.RealtimeVoiceEventParsingTest \
|
||||
--tests com.hermesandroid.relay.voice.VoiceCommandInterpreterTest \
|
||||
--tests com.hermesandroid.relay.data.VoiceModePresetTest \
|
||||
--tests com.hermesandroid.relay.ui.components.BackgroundTaskCardTest \
|
||||
--tests com.hermesandroid.relay.ui.components.DotMatrixIndicatorTest \
|
||||
--tests com.hermesandroid.relay.ui.components.AttachmentGalleryLayoutTest \
|
||||
--tests com.hermesandroid.relay.ui.components.MarkdownStreamingParserTest \
|
||||
--tests com.hermesandroid.relay.ui.screens.ChatUnreadStateTest \
|
||||
--console=plain
|
||||
|
||||
# Upload reports only for failures. Successful PR report uploads add
|
||||
# noticeable latency and are rarely inspected.
|
||||
# Upload test reports even if tests fail, for debugging
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
if: always()
|
||||
with:
|
||||
name: test-reports
|
||||
path: app/build/reports/tests/
|
||||
retention-days: 7
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Release build smoke — exercises the release variant the android-v* tag
|
||||
# build runs (./gradlew bundleRelease assembleRelease, both flavors), so
|
||||
# release-only breakage (R8/minify, resource shrinking, bundletool OOM) is
|
||||
# caught BEFORE the tag instead of mid-release. Debug-signed — no secrets,
|
||||
# so it also runs on fork PRs. Runs on dev/main pushes (early signal after
|
||||
# each merge) and on the dev→main release PR (hard pre-tag gate); skipped on
|
||||
# dev-targeted feature PRs to avoid re-running a ~12-min build per iteration.
|
||||
# ──────────────────────────────────────────────
|
||||
release-smoke:
|
||||
name: Release build smoke (Android)
|
||||
if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.base_ref == 'main') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
# Mirrors release-android.yml's build step. No keystore is provided here,
|
||||
# so app/build.gradle.kts falls back to debug signing — fine for a build
|
||||
# smoke; the goal is to exercise the build, not to produce a shippable AAB.
|
||||
- name: Build release bundles + APKs (both flavors, debug-signed)
|
||||
run: ./gradlew bundleRelease assembleRelease --console=plain
|
||||
|
||||
- name: Scan release DEX for unsupported collection APIs
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
--apk app/build/outputs/apk/googlePlay/release/*.apk \
|
||||
--apk app/build/outputs/apk/sideload/release/*.apk
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
# Hermes-Relay — Vanilla-Upstream Route Contract (ADR 34)
|
||||
#
|
||||
# Proves the Android *standard path* (no-plugin) route surface exists on
|
||||
# UNMODIFIED NousResearch/hermes-agent — the invariant CLAUDE.md asserts but
|
||||
# that was never tested. Source-parses upstream's declared routes (no server
|
||||
# boot, no pip install, no model keys); see scripts/check-upstream-route-contract.py
|
||||
# for the design + tradeoff (catches renamed/removed routes; not runtime auth).
|
||||
#
|
||||
# Required-PR and direct push runs check a pinned ref (non-flaky); the weekly
|
||||
# schedule tracks upstream `main` as a drift siren.
|
||||
|
||||
name: CI — Upstream Contract
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "scripts/check-upstream-route-contract.py"
|
||||
- ".github/workflows/ci-contract.yml"
|
||||
- "app/src/main/kotlin/com/hermesandroid/relay/network/upstream/**"
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Mondays 06:00 UTC — upstream-drift siren (tracks main)
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upstream_ref:
|
||||
description: "NousResearch/hermes-agent ref to check (branch, tag, or SHA)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: ci-contract-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
route-contract:
|
||||
name: Vanilla-upstream route contract
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout hermes-relay
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Resolve upstream ref
|
||||
id: ref
|
||||
run: |
|
||||
# PR/push runs use a known-good NousResearch/hermes-agent commit so
|
||||
# normal CI is stable. The weekly schedule below intentionally tracks
|
||||
# main as the upstream-drift siren.
|
||||
DEFAULT_REF="ef4b897a1843cd32c4f141f55db60f0f0602cc98"
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
REF="main" # weekly drift siren
|
||||
elif [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
|
||||
REF="${{ github.event.inputs.upstream_ref }}" # manual override
|
||||
else
|
||||
REF="$DEFAULT_REF"
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
echo "Checking standard-path route contract against upstream ref: $REF"
|
||||
|
||||
- name: Checkout vanilla upstream (no plugin, no bootstrap)
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: NousResearch/hermes-agent
|
||||
ref: ${{ steps.ref.outputs.ref }}
|
||||
path: _upstream
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Assert upstream checkout is vanilla (no relay bootstrap/plugin)
|
||||
run: |
|
||||
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
|
||||
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
|
||||
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
|
||||
echo "FAIL: upstream checkout contains a relay bootstrap — not vanilla."; exit 1
|
||||
fi
|
||||
echo "OK: upstream checkout carries no relay plugin/bootstrap."
|
||||
|
||||
- name: Run route-surface contract
|
||||
run: python scripts/check-upstream-route-contract.py "_upstream"
|
||||
@@ -1,11 +1,17 @@
|
||||
name: CI dashboard plugin
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
|
||||
permissions:
|
||||
@@ -21,10 +27,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
@@ -39,19 +45,15 @@ jobs:
|
||||
run: npm run build
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v7
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Verify plugin-owned version metadata
|
||||
run: python scripts/check-plugin-version-sync.py
|
||||
- name: Verify server-owned version metadata
|
||||
run: python scripts/check-server-version-sync.py
|
||||
|
||||
- name: Install dashboard API test deps
|
||||
# The suite imports the `plugin` package transitively: __init__ loads
|
||||
# android_tool/desktop_tool (`import requests`), and one test imports
|
||||
# `plugin.relay`, whose server.py needs `aiohttp` (+ pyyaml) from
|
||||
# relay_server/requirements.txt. fastapi+httpx cover plugin_api itself.
|
||||
run: pip install -r relay_server/requirements.txt fastapi httpx requests
|
||||
run: pip install -r relay_server/requirements.txt fastapi httpx pytest requests
|
||||
|
||||
- name: Run dashboard API tests
|
||||
run: python -m unittest plugin.dashboard.test_plugin_api
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# Hermes-Relay - Desktop Vanilla-Upstream Baseline
|
||||
#
|
||||
# Manual/scheduled confidence gate for HRUI-055. This keeps the first CI shape
|
||||
# intentionally small: check out a clean upstream hermes-agent beside Relay and
|
||||
# run the desktop typed-stream/renderer tests that protect the gateway event
|
||||
# contract. A later expansion can boot the upstream gateway with a mock provider
|
||||
# once that harness is stable enough for CI.
|
||||
|
||||
name: CI - Desktop Upstream Baseline
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upstream_ref:
|
||||
description: "NousResearch/hermes-agent ref to check"
|
||||
required: false
|
||||
default: "main"
|
||||
schedule:
|
||||
- cron: "30 6 * * 1"
|
||||
|
||||
concurrency:
|
||||
group: ci-desktop-upstream-baseline-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
desktop-baseline:
|
||||
name: Desktop typed gateway baseline
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout hermes-relay
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Resolve upstream ref
|
||||
id: ref
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
|
||||
REF="${{ github.event.inputs.upstream_ref }}"
|
||||
else
|
||||
REF="main"
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout vanilla upstream
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: NousResearch/hermes-agent
|
||||
ref: ${{ steps.ref.outputs.ref }}
|
||||
path: _upstream
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Assert upstream checkout is vanilla
|
||||
run: |
|
||||
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
|
||||
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
|
||||
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
|
||||
echo "FAIL: upstream checkout contains a relay bootstrap."; exit 1
|
||||
fi
|
||||
git -C _upstream status --short --untracked-files=no
|
||||
|
||||
- name: Run desktop gateway baseline contract
|
||||
run: python scripts/check-desktop-upstream-baseline.py "_upstream"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop
|
||||
run: npm ci
|
||||
|
||||
- name: Run desktop gateway baseline tests
|
||||
working-directory: desktop
|
||||
env:
|
||||
HERMES_UPSTREAM_BASELINE: ${{ github.workspace }}/_upstream
|
||||
run: npx tsx --test tests/gatewayTypes.test.ts tests/renderer.test.ts tests/typedStreamRenderer.test.ts
|
||||
@@ -1,20 +1,19 @@
|
||||
name: CI desktop
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- '.github/workflows/ci-desktop.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- '.github/workflows/ci-desktop.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-desktop-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
typecheck-and-build:
|
||||
name: Type-check + build
|
||||
@@ -23,10 +22,10 @@ jobs:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -35,15 +34,9 @@ jobs:
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Verify CLI and tray versions are synchronized
|
||||
run: npm run check:version-sync
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Test typed stream rendering
|
||||
run: npm test
|
||||
|
||||
- name: Build (tsc → dist/)
|
||||
run: npm run build
|
||||
|
||||
@@ -54,8 +47,16 @@ jobs:
|
||||
# prebuilt dist/ that references a source file that moved.
|
||||
run: node bin/hermes-relay.js --version
|
||||
|
||||
- name: Upload dist/
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-dist
|
||||
path: desktop/dist
|
||||
retention-days: 7
|
||||
|
||||
smoke-help:
|
||||
name: Smoke — --help + --version work on every target OS
|
||||
needs: typecheck-and-build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -65,10 +66,10 @@ jobs:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -93,28 +94,20 @@ jobs:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
desktop/package-lock.json
|
||||
desktop/tray/package-lock.json
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci && npm --prefix tray ci
|
||||
|
||||
- name: Check tray formatting
|
||||
run: npm run tray:fmt
|
||||
|
||||
- name: Lint tray shell
|
||||
run: npm run tray:lint
|
||||
run: npm ci
|
||||
|
||||
- name: Cargo check tray shell
|
||||
run: npm run tray:check
|
||||
|
||||
@@ -1,137 +1,49 @@
|
||||
# Path-aware required CI for pull requests targeting main or dev.
|
||||
# Required-checks sentinel — always runs on every PR + push to main/dev so
|
||||
# branch protection on `main` has a check name it can rely on, regardless
|
||||
# of which paths the PR touches.
|
||||
#
|
||||
# The change detector selects the existing surface workflows, which are exposed
|
||||
# through workflow_call. The final job keeps one stable branch-protection check
|
||||
# while ensuring that every relevant build or test actually completed.
|
||||
# Why this exists. The other CI workflows (`ci-android.yml`, `ci-server.yml`,
|
||||
# `ci-desktop.yml`) are scoped via `paths:` filters so a docs-only or
|
||||
# desktop-only PR doesn't spin up the Android toolchain. Branch protection's
|
||||
# "required status checks" treat a check that doesn't run as failing — so
|
||||
# any PR that didn't touch the protected paths was blocked from merging,
|
||||
# even with all the relevant gates green. We were admin-overriding every
|
||||
# desktop-only PR. Same for relay-touching PRs (the protection rule named
|
||||
# `Relay Check (Python)` didn't even match any actual job — broken since
|
||||
# day one).
|
||||
#
|
||||
# This sentinel + claude-review become the only required checks. The
|
||||
# path-filtered workflows still run when relevant and surface their
|
||||
# results on the PR — visible, clickable, but advisory rather than
|
||||
# blocking. Reviewers (human + claude-review) eyeball them. This is the
|
||||
# standard pattern for monorepos with path-filtered CI.
|
||||
#
|
||||
# Trade-off acknowledged: a broken Android build on an Android-touching
|
||||
# PR could merge if the reviewer ignores the failing CI badge. Mitigation:
|
||||
# claude-review reads CI conclusions in its review prompt + the project's
|
||||
# release-merge cadence catches issues before they reach a tag. If a
|
||||
# stricter gate is later wanted, fold it into this workflow as a job that
|
||||
# fans out to the path-filtered work — but the simplest version (just an
|
||||
# `echo`) is what's needed to make branch protection useful again today.
|
||||
|
||||
name: Required checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR. Doesn't matter much for
|
||||
# a 5-second job, but matches every other workflow's concurrency shape.
|
||||
concurrency:
|
||||
group: ci-required-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect affected surfaces
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
android: ${{ steps.filter.outputs.android }}
|
||||
desktop: ${{ steps.filter.outputs.desktop }}
|
||||
plugin: ${{ steps.filter.outputs.plugin }}
|
||||
dashboard: ${{ steps.filter.outputs.dashboard }}
|
||||
contract: ${{ steps.filter.outputs.contract }}
|
||||
docs: ${{ steps.filter.outputs.docs }}
|
||||
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
|
||||
|
||||
- name: Classify changed files
|
||||
id: filter
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
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`,
|
||||
);
|
||||
const outputs = classifyCiPaths(paths);
|
||||
|
||||
for (const [surface, affected] of Object.entries(outputs)) {
|
||||
core.setOutput(surface, affected ? 'true' : 'false');
|
||||
}
|
||||
core.notice(`Changed paths: ${paths.join(', ')}`);
|
||||
core.notice(`Selected checks: ${Object.entries(outputs).filter(([, value]) => value).map(([key]) => key).join(', ') || 'none'}`);
|
||||
|
||||
android:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.android == 'true'
|
||||
uses: ./.github/workflows/ci-android.yml
|
||||
|
||||
desktop:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.desktop == 'true'
|
||||
uses: ./.github/workflows/ci-desktop.yml
|
||||
|
||||
plugin:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.plugin == 'true'
|
||||
uses: ./.github/workflows/ci-plugin.yml
|
||||
|
||||
dashboard:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.dashboard == 'true'
|
||||
uses: ./.github/workflows/ci-dashboard.yml
|
||||
|
||||
contract:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.contract == 'true'
|
||||
uses: ./.github/workflows/ci-contract.yml
|
||||
|
||||
docs:
|
||||
name: Build public docs
|
||||
needs: changes
|
||||
if: needs.changes.outputs.docs == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: user-docs
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: user-docs/package-lock.json
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
|
||||
guard:
|
||||
name: Required checks
|
||||
if: always()
|
||||
needs: [changes, android, desktop, plugin, dashboard, contract, docs]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
ANDROID_RESULT: ${{ needs.android.result }}
|
||||
DESKTOP_RESULT: ${{ needs.desktop.result }}
|
||||
PLUGIN_RESULT: ${{ needs.plugin.result }}
|
||||
DASHBOARD_RESULT: ${{ needs.dashboard.result }}
|
||||
CONTRACT_RESULT: ${{ needs.contract.result }}
|
||||
DOCS_RESULT: ${{ needs.docs.result }}
|
||||
steps:
|
||||
- name: Require every selected check to pass
|
||||
shell: bash
|
||||
run: |
|
||||
failed=0
|
||||
for check in CHANGES ANDROID DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
|
||||
result_var="${check}_RESULT"
|
||||
result="${!result_var}"
|
||||
echo "$check: $result"
|
||||
case "$result" in
|
||||
success|skipped) ;;
|
||||
*) failed=1 ;;
|
||||
esac
|
||||
done
|
||||
exit "$failed"
|
||||
- name: OK
|
||||
run: echo "Required-checks sentinel — see ci-required.yml header for context."
|
||||
|
||||
@@ -1,39 +1,63 @@
|
||||
# Hermes-Relay — Plugin CI Pipeline
|
||||
# Hermes-Relay — Python Server CI Pipeline
|
||||
#
|
||||
# Runs directly on plugin-affecting pushes to main/dev and is called by the
|
||||
# path-aware required-check workflow for relevant pull requests.
|
||||
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
|
||||
# server-affecting paths so Android-only changes don't spin up the
|
||||
# Python toolchain.
|
||||
#
|
||||
# Pipeline: syntax-check and focused plugin tests run concurrently.
|
||||
# Pipeline: syntax-check -> focused server tests
|
||||
|
||||
name: CI — Plugin
|
||||
name: CI — Server
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/*.py"
|
||||
- "plugin/__init__.py"
|
||||
- "plugin/android_tool.py"
|
||||
- "plugin/cli.py"
|
||||
- "plugin/pair.py"
|
||||
- "plugin/plugin.yaml"
|
||||
- "plugin/dashboard/manifest.json"
|
||||
- "plugin/dashboard/package.json"
|
||||
- "plugin/dashboard/package-lock.json"
|
||||
- "plugin/relay/**"
|
||||
- "plugin/tools/**"
|
||||
- "plugin/tests/**"
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- "scripts/check-plugin-version-sync.py"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- "scripts/bump-plugin-version.sh"
|
||||
- "scripts/bump-server-version.sh"
|
||||
- ".github/workflows/ci-plugin.yml"
|
||||
- ".github/workflows/ci-server.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/__init__.py"
|
||||
- "plugin/android_tool.py"
|
||||
- "plugin/cli.py"
|
||||
- "plugin/pair.py"
|
||||
- "plugin/plugin.yaml"
|
||||
- "plugin/dashboard/manifest.json"
|
||||
- "plugin/dashboard/package.json"
|
||||
- "plugin/dashboard/package-lock.json"
|
||||
- "plugin/relay/**"
|
||||
- "plugin/tools/**"
|
||||
- "plugin/tests/**"
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- "scripts/check-server-version-sync.py"
|
||||
- "scripts/bump-server-version.sh"
|
||||
- ".github/workflows/ci-server.yml"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
concurrency:
|
||||
group: ci-plugin-${{ github.ref }}
|
||||
group: ci-server-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Plugin — py_compile syntax sanity
|
||||
# Python Server — py_compile syntax sanity
|
||||
# ──────────────────────────────────────────────
|
||||
syntax-check:
|
||||
name: Syntax check (Python)
|
||||
@@ -41,16 +65,18 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v7
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Syntax check (plugin relay — canonical location)
|
||||
- name: Install dependencies
|
||||
run: pip install -r relay_server/requirements.txt
|
||||
|
||||
- name: Syntax check (server/plugin.relay — canonical location)
|
||||
run: |
|
||||
python -m py_compile plugin/relay/config.py
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/channels/terminal.py
|
||||
python -m py_compile plugin/relay/channels/chat.py
|
||||
@@ -61,18 +87,19 @@ jobs:
|
||||
- name: Syntax check (relay_server shim)
|
||||
run: python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
- name: Validate Plugin version metadata
|
||||
run: python scripts/check-plugin-version-sync.py
|
||||
- name: Validate Server version metadata
|
||||
run: python scripts/check-server-version-sync.py
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Plugin — focused route/auth/session tests
|
||||
# Python Server — focused route/auth/session tests
|
||||
#
|
||||
# Tests are ADVISORY on dev (push or PR) so WIP commits don't block the
|
||||
# merge queue. Strict on main — the dev → main release-merge PR surfaces
|
||||
# any real failures before release.
|
||||
# ──────────────────────────────────────────────
|
||||
unit-tests:
|
||||
name: Focused Plugin tests (Python)
|
||||
name: Focused Server tests (Python)
|
||||
needs: syntax-check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Advisory on dev, strict on main. Evaluates to false (= strict) for
|
||||
@@ -81,29 +108,21 @@ jobs:
|
||||
continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v7
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
# Editable install pulls the full runtime dependency set from
|
||||
# pyproject.toml (requests, aiohttp, segno, httpx, websocket-client,
|
||||
# pyyaml). test_native_layout_imports imports the whole relay module
|
||||
# chain in a clean subprocess, so the minimal relay_server/requirements
|
||||
# set is not enough on its own.
|
||||
pip install -e .
|
||||
pip install -r relay_server/requirements.txt
|
||||
pip install pytest responses
|
||||
|
||||
- name: Run focused Plugin tests
|
||||
- name: Run focused Server tests
|
||||
run: |
|
||||
python -m pytest \
|
||||
plugin/tests/test_relay_security.py \
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.py \
|
||||
plugin/tests/test_native_layout_imports.py \
|
||||
plugin/tests/test_profile_discovery.py \
|
||||
plugin/tests/test_profiles_updated_broadcast.py
|
||||
plugin/tests/test_session_grants.py
|
||||
@@ -1,39 +0,0 @@
|
||||
name: Website CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "website/**"
|
||||
- "assets/screenshots/02_chat.png"
|
||||
- "assets/screenshots/03_voice.png"
|
||||
- "assets/screenshots/06_manage.png"
|
||||
- "docs/media/screenshots.json"
|
||||
- ".github/workflows/ci-website.yml"
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "website/**"
|
||||
- "assets/screenshots/02_chat.png"
|
||||
- "assets/screenshots/03_voice.png"
|
||||
- "assets/screenshots/06_manage.png"
|
||||
- "docs/media/screenshots.json"
|
||||
- ".github/workflows/ci-website.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: website
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
env:
|
||||
IS_RELEASE_PR: ${{ github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev' && startsWith(github.event.pull_request.title, 'release:') }}
|
||||
|
||||
steps:
|
||||
- name: Skip aggregate release PR review
|
||||
if: env.IS_RELEASE_PR == 'true'
|
||||
run: |
|
||||
echo "Skipping Claude Code Review for aggregate dev -> main release PR."
|
||||
echo "Feature work is reviewed before it lands on dev; release PRs are gated by CI and release metadata checks."
|
||||
|
||||
- name: Checkout repository
|
||||
if: env.IS_RELEASE_PR != 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
if: env.IS_RELEASE_PR != 'true'
|
||||
timeout-minutes: 15
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr *)'
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
steps:
|
||||
- name: Fetch Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v3
|
||||
uses: dependabot/fetch-metadata@v2
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# 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@v6
|
||||
with:
|
||||
fetch-depth: 0 # Full history for lastUpdated timestamps
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: user-docs/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Build VitePress site
|
||||
run: npm run build
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
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@v4
|
||||
@@ -1,89 +0,0 @@
|
||||
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
|
||||
@@ -1,104 +0,0 @@
|
||||
name: Play Store Listing
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "assets/screenshots/**"
|
||||
- "assets/play-store-icon-512.png"
|
||||
- "assets/play-store-feature-1024x500.png"
|
||||
- "docs/media/screenshots.json"
|
||||
- "app/src/googlePlay/play/*.txt"
|
||||
- "app/src/googlePlay/play/listings/**"
|
||||
- "scripts/screenshots.py"
|
||||
- ".github/workflows/play-listing.yml"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- "assets/screenshots/**"
|
||||
- "assets/play-store-icon-512.png"
|
||||
- "assets/play-store-feature-1024x500.png"
|
||||
- "docs/media/screenshots.json"
|
||||
- "app/src/googlePlay/play/*.txt"
|
||||
- "app/src/googlePlay/play/listings/**"
|
||||
- "scripts/screenshots.py"
|
||||
- ".github/workflows/play-listing.yml"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_listing:
|
||||
description: "Publish Play Store listing metadata after validation"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Listing Assets
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install image tooling
|
||||
run: python -m pip install --upgrade rich Pillow
|
||||
|
||||
- name: Validate screenshots and listing metadata
|
||||
run: python scripts/screenshots.py validate
|
||||
|
||||
publish-listing:
|
||||
name: Publish Listing Metadata
|
||||
needs: validate
|
||||
# Auto-publish the listing when its assets change on `main` (the release
|
||||
# branch; the path filters above already scope this to screenshot/graphic/
|
||||
# text changes). `dev` pushes and PRs validate only. A manual dispatch with
|
||||
# `publish_listing` still works as an on-demand republish.
|
||||
if: >-
|
||||
${{ (github.event_name == 'workflow_dispatch' && inputs.publish_listing)
|
||||
|| (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
- name: Write Play service account
|
||||
id: sa
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
if [ -z "$PLAY_SERVICE_ACCOUNT_JSON" ]; then
|
||||
# Skip gracefully (no red CI) when the secret isn't configured — e.g.
|
||||
# an auto-publish push to main before the service account is set up.
|
||||
echo "::notice::PLAY_SERVICE_ACCOUNT_JSON not configured — skipping listing publish."
|
||||
echo "configured=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
||||
echo "configured=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish Play Store listing
|
||||
if: ${{ steps.sa.outputs.configured == 'true' }}
|
||||
run: ./gradlew publishGooglePlayReleaseListing
|
||||
|
||||
- name: Remove Play service account
|
||||
if: always()
|
||||
run: rm -f play-service-account.json
|
||||
@@ -1,155 +0,0 @@
|
||||
# Hermes-Relay Android — private Google Play preflight
|
||||
#
|
||||
# Run manually from the final dev or untagged main tree before creating
|
||||
# android-v*. The job
|
||||
# builds the same signed release artifacts, scans final DEX, and uploads the
|
||||
# Google Play bundle as a production DRAFT. A successful upload is the automated
|
||||
# Play gate while no public GitHub Release or sideload APK exists. Console-only
|
||||
# pre-review and pre-launch reports are informational and do not block release.
|
||||
|
||||
name: Hermes-Relay Android Play Preflight
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Android version to preflight (for example 1.4.3)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: play-preflight-android
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
name: Build and upload private Play draft
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Require final release branch and matching version
|
||||
id: metadata
|
||||
env:
|
||||
REQUESTED_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ "$GITHUB_REF" != "refs/heads/dev" ] && [ "$GITHUB_REF" != "refs/heads/main" ]; then
|
||||
echo "::error::Run Play preflight from dev or untagged main, not $GITHUB_REF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOML_VERSION=$(grep -oP 'appVersionName\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
if [ "$REQUESTED_VERSION" != "$TOML_VERSION" ]; then
|
||||
echo "::error::Requested version $REQUESTED_VERSION does not match appVersionName $TOML_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$TOML_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Require Play and release-signing secrets
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -z "$PLAY_SERVICE_ACCOUNT_JSON" ]; then
|
||||
echo "::error::PLAY_SERVICE_ACCOUNT_JSON is required for Play preflight"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$HERMES_KEYSTORE_BASE64" ]; then
|
||||
echo "::error::HERMES_KEYSTORE_BASE64 is required for Play preflight"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
- 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
|
||||
|
||||
- name: Decode release keystore
|
||||
env:
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
echo "$HERMES_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
|
||||
echo "HERMES_KEYSTORE_PATH=$RUNNER_TEMP/release.keystore" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build final release artifacts
|
||||
env:
|
||||
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
|
||||
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
|
||||
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
|
||||
run: ./gradlew bundleRelease assembleRelease --console=plain
|
||||
|
||||
- name: Scan final release DEX
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
--apk app/build/outputs/apk/googlePlay/release/*.apk \
|
||||
--apk app/build/outputs/apk/sideload/release/*.apk
|
||||
|
||||
- name: Upload private production draft to Play
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
|
||||
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
|
||||
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
|
||||
run: |
|
||||
trap 'rm -f play-service-account.json' EXIT
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
||||
./gradlew publishGooglePlayReleaseBundle \
|
||||
--track=production \
|
||||
--release-status=draft \
|
||||
--resolution-strategy=ignore \
|
||||
--release-name="Hermes-Relay Android v${{ steps.metadata.outputs.version }}"
|
||||
|
||||
- name: Record successful preflight for the exact commit
|
||||
run: |
|
||||
mkdir -p app/build/reports
|
||||
cat > app/build/reports/play-preflight.json <<EOF
|
||||
{
|
||||
"version": "${{ steps.metadata.outputs.version }}",
|
||||
"versionCode": "${{ steps.metadata.outputs.version_code }}",
|
||||
"commit": "$GITHUB_SHA",
|
||||
"tree": "${{ steps.metadata.outputs.tree }}",
|
||||
"track": "production",
|
||||
"status": "draft"
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Upload preflight proof
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: play-preflight-${{ steps.metadata.outputs.version }}-${{ steps.metadata.outputs.tree }}
|
||||
path: app/build/reports/play-preflight.json
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Preflight summary
|
||||
run: |
|
||||
echo "## Play preflight ready" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Version: **${{ steps.metadata.outputs.version }}** (code ${{ steps.metadata.outputs.version_code }})" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Commit: \`$GITHUB_SHA\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Release tree: \`${{ steps.metadata.outputs.tree }}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Play track/status: **Production draft**" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The signed build, DEX scan, and Play draft upload passed. Ensure this exact release tree is on main, then run **Hermes-Relay Android Release Approval** from main. Console-only reports are informational and non-blocking." >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,31 +1,19 @@
|
||||
# Hermes-Relay Android — Release Pipeline
|
||||
# Hermes-Relay-Android — Release Pipeline
|
||||
#
|
||||
# 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 server-v* tags.
|
||||
# GitHub Release. Server/Python package releases use server-v* tags.
|
||||
|
||||
name: Hermes-Relay Android Release
|
||||
name: Release Android
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "android-v*"
|
||||
# Hermes-Relay Android Release Approval creates its tag with GITHUB_TOKEN,
|
||||
# whose tag event
|
||||
# does not recursively start workflows. It dispatches the current workflow
|
||||
# definition from main, while every job checks out the immutable tag. Manual
|
||||
# tag pushes continue to use the push trigger.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Approved Android version"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
@@ -34,38 +22,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
version_code: ${{ steps.version.outputs.version_code }}
|
||||
prerelease: ${{ steps.version.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
env:
|
||||
DISPATCHED_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [ -n "$DISPATCHED_VERSION" ]; then
|
||||
REF_VERSION="$DISPATCHED_VERSION"
|
||||
TAG_COMMIT=$(git rev-list -n 1 "android-v${REF_VERSION}")
|
||||
if [ -z "$TAG_COMMIT" ] || [ "$TAG_COMMIT" != "$(git rev-parse HEAD)" ]; then
|
||||
echo "::error::Checked-out commit does not match immutable tag android-v${REF_VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
REF_VERSION="${GITHUB_REF#refs/tags/android-v}"
|
||||
fi
|
||||
VERSION_CODE=$(grep -oP 'appVersionCode\s*=\s*"\K[^"]+' gradle/libs.versions.toml)
|
||||
if [[ "$REF_VERSION" == *-* ]]; then
|
||||
PRERELEASE=true
|
||||
else
|
||||
PRERELEASE=false
|
||||
fi
|
||||
echo "version=$REF_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
|
||||
run: echo "version=${GITHUB_REF#refs/tags/android-v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify version sync
|
||||
run: |
|
||||
@@ -79,62 +41,16 @@ jobs:
|
||||
echo "::error::Tag version ($TAG_VERSION) does not match appVersionName ($TOML_VERSION) in gradle/libs.versions.toml"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Eq "^## \\[(Android )?${TAG_VERSION}\\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no Android 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 tag belongs to the correct integration branch
|
||||
env:
|
||||
PRERELEASE: ${{ steps.version.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag_commit="$(git rev-parse HEAD)"
|
||||
if [ "$PRERELEASE" = "true" ]; then
|
||||
git fetch origin dev --no-tags
|
||||
if ! git merge-base --is-ancestor "$tag_commit" origin/dev; then
|
||||
echo "Android prereleases must be tagged from dev; $tag_commit is not in origin/dev" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
git fetch origin main --no-tags
|
||||
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
|
||||
echo "Stable Android releases must be tagged from main; $tag_commit is not in origin/main" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Require successful Play preflight for this exact release tree
|
||||
if: ${{ !contains(steps.version.outputs.version, '-') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
RELEASE_TREE=$(git rev-parse 'HEAD^{tree}')
|
||||
ARTIFACT_NAME="play-preflight-${VERSION}-${RELEASE_TREE}"
|
||||
COUNT=$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${ARTIFACT_NAME}" \
|
||||
--jq '[.artifacts[] | select(.expired == false)] | length')
|
||||
if [ "$COUNT" -lt 1 ]; then
|
||||
echo "::error::No successful Play preflight found for version $VERSION with tree $RELEASE_TREE"
|
||||
echo "Run Hermes-Relay Android Play Preflight from the final dev tree, merge that unchanged tree to main, then approve the release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Play preflight proof found: $ARTIFACT_NAME"
|
||||
|
||||
ci:
|
||||
name: CI Checks
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -143,16 +59,10 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
with:
|
||||
cache-read-only: false
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- 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
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
# Keep the tag release gate aligned with CI — Android's broad Gradle
|
||||
# `test` aggregate currently hangs in deferred JVM suites tracked by
|
||||
@@ -170,9 +80,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('android-v{0}', inputs.version) || github.ref }}
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
@@ -181,9 +89,7 @@ jobs:
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
with:
|
||||
cache-read-only: false
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Decode release keystore
|
||||
env:
|
||||
@@ -193,8 +99,7 @@ jobs:
|
||||
echo "$HERMES_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.keystore"
|
||||
echo "HERMES_KEYSTORE_PATH=$RUNNER_TEMP/release.keystore" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build stable release artifacts (APK + AAB)
|
||||
if: ${{ needs.validate.outputs.prerelease != 'true' }}
|
||||
- name: Build release artifacts (APK + AAB)
|
||||
env:
|
||||
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
|
||||
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
|
||||
@@ -210,58 +115,6 @@ jobs:
|
||||
# app/build/outputs/bundle/sideloadRelease/hermes-relay-<version>-sideload-release.aab
|
||||
run: ./gradlew bundleRelease assembleRelease
|
||||
|
||||
- name: Build side-by-side release candidate APK
|
||||
if: ${{ needs.validate.outputs.prerelease == 'true' }}
|
||||
env:
|
||||
HERMES_KEYSTORE_PASSWORD: ${{ secrets.HERMES_KEYSTORE_PASSWORD }}
|
||||
HERMES_KEY_ALIAS: ${{ secrets.HERMES_KEY_ALIAS }}
|
||||
HERMES_KEY_PASSWORD: ${{ secrets.HERMES_KEY_PASSWORD }}
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
SOURCE_SHA="$(git rev-parse HEAD)"
|
||||
./gradlew :app:assembleSideloadCandidate \
|
||||
-Pcandidate.kind=rc \
|
||||
-Pcandidate.label="Hermes-Relay Android v${VERSION}" \
|
||||
-Pcandidate.sourceRef="android-v${VERSION}" \
|
||||
-Pcandidate.sourceSha="$SOURCE_SHA" \
|
||||
--console=plain
|
||||
|
||||
# The Play AAB carries its mapping for Play Console deobfuscation, but
|
||||
# sideload issue reports need the exact mapping from this immutable build.
|
||||
# Keep both variants as a workflow artifact (not a public release asset).
|
||||
- name: Retain R8 mappings for retrace
|
||||
if: ${{ needs.validate.outputs.prerelease != 'true' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: android-r8-mappings-${{ needs.validate.outputs.version }}-${{ github.sha }}
|
||||
path: |
|
||||
app/build/outputs/mapping/googlePlayRelease/mapping.txt
|
||||
app/build/outputs/mapping/sideloadRelease/mapping.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
- name: Retain candidate R8 mapping for retrace
|
||||
if: ${{ needs.validate.outputs.prerelease == 'true' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: android-rc-r8-mapping-${{ needs.validate.outputs.version }}-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/sideloadCandidate/mapping.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
- name: Scan stable release DEX for unsupported collection APIs
|
||||
if: ${{ needs.validate.outputs.prerelease != 'true' }}
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
--apk app/build/outputs/apk/googlePlay/release/*.apk \
|
||||
--apk app/build/outputs/apk/sideload/release/*.apk
|
||||
|
||||
- name: Scan candidate DEX for unsupported collection APIs
|
||||
if: ${{ needs.validate.outputs.prerelease == 'true' }}
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
--apk app/build/outputs/apk/sideload/candidate/*.apk
|
||||
|
||||
- name: List produced artifacts (debug aid)
|
||||
run: |
|
||||
echo "=== APK outputs ==="
|
||||
@@ -269,92 +122,42 @@ jobs:
|
||||
echo "=== AAB outputs ==="
|
||||
find app/build/outputs/bundle -name '*.aab' -print 2>/dev/null || true
|
||||
|
||||
- name: Generate stable checksums
|
||||
if: ${{ needs.validate.outputs.prerelease != 'true' }}
|
||||
- name: Generate checksums
|
||||
# Flavor dimension adds an extra path segment to the AGP output layout.
|
||||
# APKs live under `apk/<flavor>/release/`, AABs under `bundle/<flavor>Release/`
|
||||
# (note the concatenated camelCase — AGP path quirk, documented but
|
||||
# different between APK and AAB). Checksums cover EXACTLY the files
|
||||
# attached to the GitHub Release (see the 2-asset policy on the
|
||||
# release step below) so SHA256SUMS.txt matches the assets 1:1.
|
||||
# different between APK and AAB). The globs below match both flavors.
|
||||
run: |
|
||||
cd app/build/outputs
|
||||
sha256sum apk/sideload/release/*.apk bundle/googlePlayRelease/*.aab > SHA256SUMS.txt
|
||||
sha256sum apk/*/release/*.apk bundle/*Release/*.aab > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Generate candidate checksums
|
||||
if: ${{ needs.validate.outputs.prerelease == 'true' }}
|
||||
run: |
|
||||
cd app/build/outputs
|
||||
sha256sum apk/sideload/candidate/*.apk > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Require Play credentials for stable release
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
if: ${{ !contains(needs.validate.outputs.version, '-') }}
|
||||
run: |
|
||||
if [ -z "$PLAY_SERVICE_ACCOUNT_JSON" ]; then
|
||||
echo "::error::PLAY_SERVICE_ACCOUNT_JSON is required for stable Android releases"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Submit preflighted Play draft to production review
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
if: ${{ !contains(needs.validate.outputs.version, '-') }}
|
||||
run: |
|
||||
trap 'rm -f play-service-account.json' EXIT
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
||||
./gradlew promoteGooglePlayReleaseArtifact \
|
||||
--update=production \
|
||||
--version-code=${{ needs.validate.outputs.version_code }} \
|
||||
--release-status=completed \
|
||||
--release-name="Hermes-Relay Android v${{ needs.validate.outputs.version }}"
|
||||
|
||||
# Public distribution happens only after Play accepts the production
|
||||
# submission above. This keeps a Play-detected release blocker from
|
||||
# appearing after the sideload APK is already public.
|
||||
- name: Create stable GitHub Release
|
||||
if: ${{ needs.validate.outputs.prerelease != 'true' }}
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay Android v${{ needs.validate.outputs.version }}
|
||||
name: Hermes-Relay-Android v${{ needs.validate.outputs.version }}
|
||||
tag_name: android-v${{ needs.validate.outputs.version }}
|
||||
body_path: RELEASE_NOTES.md
|
||||
prerelease: false
|
||||
# Deliberate 2-asset policy (#144): attach ONLY the installable
|
||||
# sideload APK and Play AAB, plus checksums covering those files.
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
# Attach all four flavored artifacts — users sideload the
|
||||
# `hermes-relay-<version>-sideload-release.apk` for the full
|
||||
# Phase 3 / Tier 3/4/6 feature set; the
|
||||
# `hermes-relay-<version>-googlePlay-release.aab` is what gets
|
||||
# uploaded to Play Console. APK twin of the googlePlay flavor
|
||||
# and AAB twin of the sideload flavor are included for parity
|
||||
# (useful for diff tooling, not primary downloads).
|
||||
files: |
|
||||
app/build/outputs/apk/sideload/release/*.apk
|
||||
app/build/outputs/bundle/googlePlayRelease/*.aab
|
||||
app/build/outputs/SHA256SUMS.txt
|
||||
|
||||
- name: Create candidate GitHub prerelease
|
||||
if: ${{ needs.validate.outputs.prerelease == 'true' }}
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay Android v${{ needs.validate.outputs.version }}
|
||||
tag_name: android-v${{ needs.validate.outputs.version }}
|
||||
body_path: RELEASE_NOTES.md
|
||||
prerelease: true
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
app/build/outputs/apk/sideload/candidate/*.apk
|
||||
app/build/outputs/apk/*/release/*.apk
|
||||
app/build/outputs/bundle/*Release/*.aab
|
||||
app/build/outputs/SHA256SUMS.txt
|
||||
|
||||
- name: Release summary
|
||||
env:
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
PRERELEASE: ${{ needs.validate.outputs.prerelease }}
|
||||
run: |
|
||||
echo "## Hermes-Relay Android v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## Hermes-Relay-Android v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ "$PRERELEASE" = "true" ] && [ -n "$HERMES_KEYSTORE_BASE64" ]; then
|
||||
echo "✅ **Release-signed HR Candidate app** — separate package ID; never uploaded to Play" >> "$GITHUB_STEP_SUMMARY"
|
||||
elif [ "$PRERELEASE" = "true" ]; then
|
||||
echo "⚠️ **Debug-signed HR Candidate app** — separate package ID; never uploaded to Play" >> "$GITHUB_STEP_SUMMARY"
|
||||
elif [ -n "$HERMES_KEYSTORE_BASE64" ]; then
|
||||
if [ -n "$HERMES_KEYSTORE_BASE64" ]; then
|
||||
echo "✅ **Signed with release keystore** — suitable for Play Store upload" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "⚠️ **Debug-signed** (no \`HERMES_KEYSTORE_BASE64\` secret) — NOT suitable for Play Store. Add the secret in repo settings to enable release signing." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
name: Hermes-Relay CLI+UI Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['desktop-v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
validate-release:
|
||||
name: Validate tag, branch, and version metadata
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Extract and validate tag version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#desktop-v}"
|
||||
if [[ -z "$version" || "$version" == "$GITHUB_REF_NAME" ]]; then
|
||||
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 tag belongs to the correct integration branch
|
||||
shell: bash
|
||||
working-directory: .
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#desktop-v}"
|
||||
tag_commit="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")"
|
||||
if [[ "$version" == *-* ]]; then
|
||||
git fetch origin dev --no-tags
|
||||
if ! git merge-base --is-ancestor "$tag_commit" origin/dev; then
|
||||
echo "CLI+UI prereleases must be tagged from dev; $tag_commit is not in origin/dev" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
git fetch origin main --no-tags
|
||||
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
|
||||
echo "Stable CLI+UI releases must be tagged from main; $tag_commit is not in origin/main" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
build-cli-binaries:
|
||||
name: Build cross-platform CLI binaries via Bun compile
|
||||
runs-on: ubuntu-latest
|
||||
needs: validate-release
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js (for npm ci + tsc)
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version-file: 'desktop/.bun-version'
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Test CLI
|
||||
run: npm test
|
||||
|
||||
- name: Build dist/ (tsc)
|
||||
run: npm run build
|
||||
|
||||
- name: Print Bun version (diagnostics)
|
||||
run: bun --version
|
||||
|
||||
- name: Prepare binary output dir
|
||||
run: mkdir -p dist/bin
|
||||
|
||||
# Keep the package.json scripts as the single source of truth for Bun
|
||||
# compile flags so release and local smoke builds cannot diverge.
|
||||
- name: Build Windows x64
|
||||
run: npm run build:bin:win
|
||||
|
||||
- name: Build Linux x64
|
||||
run: npm run build:bin:linux
|
||||
|
||||
- name: Build Linux arm64
|
||||
run: npm run build:bin:linux-arm
|
||||
|
||||
- name: Build macOS x64
|
||||
run: npm run build:bin:mac-x64
|
||||
|
||||
- name: Build macOS arm64
|
||||
run: npm run build:bin:mac-arm
|
||||
|
||||
- name: Size guard (<150 MB each)
|
||||
run: |
|
||||
set -e
|
||||
for f in dist/bin/hermes-relay-*; do
|
||||
sz=$(stat -c%s "$f")
|
||||
mb=$(( sz / 1024 / 1024 ))
|
||||
echo " $f - ${mb} MB"
|
||||
if [ "$sz" -gt 157286400 ]; then
|
||||
echo "FAIL: $f exceeds 150 MB - Bun likely shipped a debug build or we added a large dep."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Smoke-test Linux binary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x dist/bin/hermes-relay-linux-x64
|
||||
for cmd in --version --help doctor; do
|
||||
set +e
|
||||
out=$(./dist/bin/hermes-relay-linux-x64 "$cmd" 2>&1)
|
||||
exit_code=$?
|
||||
set -e
|
||||
if [ "$exit_code" -ne 0 ] || [ -z "$out" ] || [ ${#out} -lt 10 ]; then
|
||||
echo "SMOKE FAIL: './hermes-relay-linux-x64 $cmd' failed or produced no output (exit=$exit_code)"
|
||||
echo "Raw output was: [$out]"
|
||||
exit 1
|
||||
fi
|
||||
echo " smoke OK: $cmd -> $(echo "$out" | head -1)"
|
||||
done
|
||||
|
||||
- name: Verify Linux arm64 artifact architecture
|
||||
run: |
|
||||
set -euo pipefail
|
||||
file dist/bin/hermes-relay-linux-arm64 | tee /tmp/hermes-relay-linux-arm64.file
|
||||
grep -Eq 'ELF 64-bit.*(ARM aarch64|ARM64)' /tmp/hermes-relay-linux-arm64.file
|
||||
|
||||
- name: Upload CLI release assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli-binaries
|
||||
path: |
|
||||
desktop/dist/bin/hermes-relay-win-x64.exe
|
||||
desktop/dist/bin/hermes-relay-linux-x64
|
||||
desktop/dist/bin/hermes-relay-linux-arm64
|
||||
desktop/dist/bin/hermes-relay-darwin-x64
|
||||
desktop/dist/bin/hermes-relay-darwin-arm64
|
||||
retention-days: 7
|
||||
|
||||
smoke-windows-cli-release-asset:
|
||||
name: Smoke exact Windows CLI release asset
|
||||
runs-on: windows-latest
|
||||
needs:
|
||||
- validate-release
|
||||
- build-cli-binaries
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: cli-binaries
|
||||
path: release-assets
|
||||
|
||||
- name: Repeated launch and process cleanup gate
|
||||
shell: pwsh
|
||||
env:
|
||||
EXPECTED_DESKTOP_VERSION: ${{ needs.validate-release.outputs.version }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$exe = (Resolve-Path 'release-assets/hermes-relay-win-x64.exe').Path
|
||||
1..20 | ForEach-Object {
|
||||
$output = & $exe --version
|
||||
if ($LASTEXITCODE -ne 0) { throw "Windows CLI smoke failed with exit $LASTEXITCODE" }
|
||||
if ($output -ne "hermes-relay $env:EXPECTED_DESKTOP_VERSION") {
|
||||
throw "Unexpected Windows CLI version output: $output"
|
||||
}
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
$leftovers = Get-CimInstance Win32_Process | Where-Object {
|
||||
$_.ExecutablePath -eq $exe
|
||||
}
|
||||
if ($leftovers) {
|
||||
throw "Windows CLI smoke left $(@($leftovers).Count) process(es) behind"
|
||||
}
|
||||
|
||||
smoke-macos-cli-release-asset:
|
||||
name: Smoke exact macOS CLI release asset
|
||||
runs-on: macos-latest
|
||||
needs:
|
||||
- validate-release
|
||||
- build-cli-binaries
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: cli-binaries
|
||||
path: release-assets
|
||||
|
||||
- name: Launch native release asset and inspect both architectures
|
||||
env:
|
||||
EXPECTED_DESKTOP_VERSION: ${{ needs.validate-release.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$(uname -m)" in
|
||||
x86_64) native_asset=hermes-relay-darwin-x64 ;;
|
||||
arm64) native_asset=hermes-relay-darwin-arm64 ;;
|
||||
*) echo "Unsupported macOS runner architecture: $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
chmod +x "release-assets/$native_asset"
|
||||
version_output=$("release-assets/$native_asset" --version)
|
||||
test "$version_output" = "hermes-relay $EXPECTED_DESKTOP_VERSION"
|
||||
"release-assets/$native_asset" --help | grep -Fq 'Usage:'
|
||||
file release-assets/hermes-relay-darwin-x64 | grep -Fq 'x86_64'
|
||||
file release-assets/hermes-relay-darwin-arm64 | grep -Eq '(arm64|arm64e)'
|
||||
|
||||
smoke-linux-arm64-cli-release-asset:
|
||||
name: Smoke exact Linux arm64 CLI release asset
|
||||
runs-on: ubuntu-24.04-arm
|
||||
needs:
|
||||
- validate-release
|
||||
- build-cli-binaries
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: cli-binaries
|
||||
path: release-assets
|
||||
|
||||
- name: Launch native arm64 release asset
|
||||
env:
|
||||
EXPECTED_DESKTOP_VERSION: ${{ needs.validate-release.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
asset=release-assets/hermes-relay-linux-arm64
|
||||
test "$(uname -m)" = "aarch64"
|
||||
chmod +x "$asset"
|
||||
version_output=$("$asset" --version)
|
||||
test "$version_output" = "hermes-relay $EXPECTED_DESKTOP_VERSION"
|
||||
"$asset" --help | grep -Fq 'Usage:'
|
||||
file "$asset" | grep -Eq 'ELF 64-bit.*(ARM aarch64|ARM64)'
|
||||
|
||||
build-windows-tray-installer:
|
||||
name: Build Windows tray installer
|
||||
runs-on: windows-latest
|
||||
needs: validate-release
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
desktop/package-lock.json
|
||||
desktop/tray/package-lock.json
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version-file: 'desktop/.bun-version'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci && npm --prefix tray ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build dist/ (tsc)
|
||||
run: npm run build
|
||||
|
||||
- name: Check and lint tray shell
|
||||
run: npm run tray:fmt && npm run tray:lint
|
||||
|
||||
- name: Test tray shell
|
||||
run: npm run tray:test
|
||||
|
||||
- name: Install NSIS
|
||||
run: choco install nsis --yes --no-progress
|
||||
|
||||
- name: Build tray installer
|
||||
run: npm run tray:build
|
||||
|
||||
- name: Smoke-test tray exe launch
|
||||
shell: pwsh
|
||||
run: |
|
||||
# $HOME is a read-only automatic variable in PowerShell (names are
|
||||
# case-insensitive), so use a distinct scratch name; only the
|
||||
# $env:HOME / $env:USERPROFILE environment vars are writable.
|
||||
$smokeHome = Join-Path $env:RUNNER_TEMP 'hermes-tray-smoke-home'
|
||||
New-Item -ItemType Directory -Force -Path $smokeHome | Out-Null
|
||||
$env:USERPROFILE = $smokeHome
|
||||
$env:HOME = $smokeHome
|
||||
$env:HERMES_RELAY_CLI_PATH = (Resolve-Path dist/bin/hermes-relay-win-x64.exe).Path
|
||||
$proc = Start-Process -FilePath tray/target/release/hermes-relay-tray.exe -WindowStyle Hidden -PassThru
|
||||
Start-Sleep -Seconds 5
|
||||
if ($proc.HasExited) { throw "tray app exited early with code $($proc.ExitCode)" }
|
||||
$traySize = (Get-Item tray/target/release/hermes-relay-tray.exe).Length
|
||||
if ($traySize -le 0) { throw 'tray executable is empty' }
|
||||
Stop-Process -Id $proc.Id -Force
|
||||
Write-Host "management tray launch smoke OK pid=$($proc.Id) bytes=$traySize"
|
||||
|
||||
- name: Smoke-test packaged installer lifecycle
|
||||
shell: pwsh
|
||||
env:
|
||||
EXPECTED_DESKTOP_VERSION: ${{ needs.validate-release.outputs.version }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Normalize-UserPath([string]$Value) {
|
||||
return (@($Value -split ';' | Where-Object { $_ }) -join ';')
|
||||
}
|
||||
|
||||
function Get-RawUserPath {
|
||||
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment')
|
||||
if ($null -eq $environmentKey) { return '' }
|
||||
try {
|
||||
return [string]$environmentKey.GetValue(
|
||||
'Path',
|
||||
'',
|
||||
[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames
|
||||
)
|
||||
} finally {
|
||||
$environmentKey.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
$setup = (Resolve-Path 'dist/tray/hermes-relay-windows-x64-setup.exe').Path
|
||||
$smokeRoot = Join-Path $env:RUNNER_TEMP 'hermes-installer-lifecycle-smoke'
|
||||
$smokeProfile = Join-Path $smokeRoot 'profile'
|
||||
$installDir = Join-Path $smokeRoot 'installed files'
|
||||
$sessionDir = Join-Path $smokeProfile '.hermes'
|
||||
$sessionSentinel = Join-Path $sessionDir 'remote-sessions.json'
|
||||
$uninstaller = Join-Path $installDir 'uninstall-hermes-relay.exe'
|
||||
$uninstallKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\HermesRelay'
|
||||
$productKey = 'HKCU:\Software\HermesRelay'
|
||||
$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
|
||||
$startMenuDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Hermes-Relay CLI'
|
||||
$oldUserProfile = $env:USERPROFILE
|
||||
$oldHomeEnv = $env:HOME
|
||||
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
|
||||
$hadUserPath = $environmentKey.GetValueNames() -contains 'Path'
|
||||
$originalUserPath = Get-RawUserPath
|
||||
$originalUserPathKind = if ($hadUserPath) { $environmentKey.GetValueKind('Path') } else { $null }
|
||||
$userPathBefore = 'C:\Windows\System32'
|
||||
$environmentKey.Dispose()
|
||||
$startupBefore = (Get-ItemProperty -Path $startupKey -Name HermesRelayTray -ErrorAction SilentlyContinue).HermesRelayTray
|
||||
|
||||
if (Test-Path $uninstallKey) { throw 'installer smoke requires a clean HermesRelay uninstall registry key' }
|
||||
if (Test-Path $productKey) { throw 'installer smoke requires a clean HermesRelay product registry key' }
|
||||
if (Test-Path $smokeRoot) { Remove-Item -LiteralPath $smokeRoot -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $sessionDir | Out-Null
|
||||
Set-Content -LiteralPath $sessionSentinel -Value '{"sentinel":"preserve-me"}' -Encoding UTF8
|
||||
|
||||
$env:USERPROFILE = $smokeProfile
|
||||
$env:HOME = $smokeProfile
|
||||
try {
|
||||
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
|
||||
$environmentKey.SetValue('Path', $userPathBefore, [Microsoft.Win32.RegistryValueKind]::String)
|
||||
$environmentKey.Dispose()
|
||||
|
||||
$installProcess = Start-Process -FilePath $setup -ArgumentList @('/S', "/D=$installDir") -Wait -PassThru
|
||||
if ($installProcess.ExitCode -ne 0) { throw "installer exited with code $($installProcess.ExitCode)" }
|
||||
|
||||
$expectedFiles = @(
|
||||
'hermes-relay.exe',
|
||||
'hermes-relay-tray.exe',
|
||||
'hermes-relay-ui.cmd',
|
||||
'hermes-relay-path.ps1',
|
||||
'uninstall-hermes-relay.exe'
|
||||
)
|
||||
foreach ($name in $expectedFiles) {
|
||||
$path = Join-Path $installDir $name
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
|
||||
throw "packaged installer did not create $path"
|
||||
}
|
||||
}
|
||||
|
||||
$cli = Join-Path $installDir 'hermes-relay.exe'
|
||||
$versionOutput = (& $cli --version | Out-String).Trim()
|
||||
if ($LASTEXITCODE -ne 0) { throw "installed CLI --version exited with code $LASTEXITCODE" }
|
||||
if ($versionOutput -ne "hermes-relay $env:EXPECTED_DESKTOP_VERSION") {
|
||||
throw "installed CLI version mismatch: expected $env:EXPECTED_DESKTOP_VERSION, got '$versionOutput'"
|
||||
}
|
||||
$helpOutput = (& $cli --help | Out-String)
|
||||
if ($LASTEXITCODE -ne 0 -or $helpOutput -notmatch 'Usage:') {
|
||||
throw 'installed CLI --help smoke failed'
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $sessionSentinel -PathType Leaf)) {
|
||||
throw 'installer removed profile session data'
|
||||
}
|
||||
|
||||
$uninstallProcess = Start-Process -FilePath $uninstaller -ArgumentList '/S' -Wait -PassThru
|
||||
if ($uninstallProcess.ExitCode -ne 0) { throw "uninstaller exited with code $($uninstallProcess.ExitCode)" }
|
||||
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(20)
|
||||
while ((Test-Path -LiteralPath $uninstaller) -and [DateTime]::UtcNow -lt $deadline) {
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
foreach ($name in $expectedFiles) {
|
||||
$path = Join-Path $installDir $name
|
||||
if (Test-Path -LiteralPath $path) { throw "uninstaller left owned artifact $path" }
|
||||
}
|
||||
if (Test-Path $uninstallKey) { throw 'uninstaller left the Installed Apps registry key' }
|
||||
if (Test-Path $productKey) { throw 'uninstaller left the HermesRelay product registry key' }
|
||||
if (Test-Path -LiteralPath $startMenuDir) { throw "uninstaller left Start-menu artifacts at $startMenuDir" }
|
||||
if (-not (Test-Path -LiteralPath $sessionSentinel -PathType Leaf)) {
|
||||
throw 'uninstaller removed preserved profile session data'
|
||||
}
|
||||
if ((Get-Content -LiteralPath $sessionSentinel -Raw) -notmatch 'preserve-me') {
|
||||
throw 'installer lifecycle modified preserved profile session data'
|
||||
}
|
||||
|
||||
# Compare the raw registry value so expandable entries such as
|
||||
# %USERPROFILE% are not resolved against the isolated smoke profile.
|
||||
$userPathAfter = Normalize-UserPath (Get-RawUserPath)
|
||||
if ($userPathAfter -ne $userPathBefore) {
|
||||
throw "uninstaller did not restore user PATH (before='$userPathBefore', after='$userPathAfter')"
|
||||
}
|
||||
$startupAfter = (Get-ItemProperty -Path $startupKey -Name HermesRelayTray -ErrorAction SilentlyContinue).HermesRelayTray
|
||||
if ($startupAfter -ne $startupBefore) {
|
||||
throw "installer lifecycle changed the pre-existing tray startup preference"
|
||||
}
|
||||
|
||||
Write-Host "packaged installer lifecycle smoke OK version=$versionOutput install=$installDir"
|
||||
} finally {
|
||||
Get-Process -Name 'hermes-relay-tray' -ErrorAction SilentlyContinue |
|
||||
Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
if (Test-Path -LiteralPath $uninstaller) {
|
||||
Start-Process -FilePath $uninstaller -ArgumentList '/S' -Wait | Out-Null
|
||||
}
|
||||
$env:USERPROFILE = $oldUserProfile
|
||||
$env:HOME = $oldHomeEnv
|
||||
$environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
|
||||
if ($hadUserPath) {
|
||||
$environmentKey.SetValue('Path', $originalUserPath, $originalUserPathKind)
|
||||
} else {
|
||||
$environmentKey.DeleteValue('Path', $false)
|
||||
}
|
||||
$environmentKey.Dispose()
|
||||
if (Test-Path -LiteralPath $smokeRoot) {
|
||||
Remove-Item -LiteralPath $smokeRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
- name: Upload Windows tray release asset
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cli-windows-installer
|
||||
path: desktop/dist/tray/hermes-relay-windows-x64-setup.exe
|
||||
retention-days: 7
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-cli-binaries
|
||||
- smoke-windows-cli-release-asset
|
||||
- smoke-macos-cli-release-asset
|
||||
- smoke-linux-arm64-cli-release-asset
|
||||
- build-windows-tray-installer
|
||||
steps:
|
||||
# Needed so CLI_RELEASE_NOTES.md is available to render into the release body
|
||||
# (the other publish-release steps only consume downloaded build artifacts).
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Extract CLI+UI version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: release-assets
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
run: |
|
||||
set -e
|
||||
find release-assets -type f ! -name SHA256SUMS.txt -print0 \
|
||||
| sort -z \
|
||||
| xargs -0 sha256sum \
|
||||
| sed -E 's#release-assets/[^/]+/##' > release-assets/SHA256SUMS.txt
|
||||
cat release-assets/SHA256SUMS.txt
|
||||
|
||||
# Render CLI_RELEASE_NOTES.md (hand-written per release) into the GitHub
|
||||
# Release body. __VERSION__ = bare version (0.3.0), __TAG__ = full tag
|
||||
# (desktop-v0.3.0) so install/pin commands stay accurate without manual edits.
|
||||
- name: Render release notes
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
sed -e "s/__VERSION__/${VERSION}/g" -e "s/__TAG__/${TAG}/g" \
|
||||
CLI_RELEASE_NOTES.md > cli_release_notes_rendered.md
|
||||
echo "=== rendered release body ===" && cat cli_release_notes_rendered.md
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay CLI+UI v${{ steps.version.outputs.version }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }}
|
||||
fail_on_unmatched_files: true
|
||||
body_path: cli_release_notes_rendered.md
|
||||
files: |
|
||||
release-assets/cli-binaries/hermes-relay-win-x64.exe
|
||||
release-assets/cli-binaries/hermes-relay-linux-x64
|
||||
release-assets/cli-binaries/hermes-relay-linux-arm64
|
||||
release-assets/cli-binaries/hermes-relay-darwin-x64
|
||||
release-assets/cli-binaries/hermes-relay-darwin-arm64
|
||||
release-assets/cli-windows-installer/hermes-relay-windows-x64-setup.exe
|
||||
release-assets/SHA256SUMS.txt
|
||||
@@ -0,0 +1,241 @@
|
||||
name: Release Desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['desktop-v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-cli-binaries:
|
||||
name: Build cross-platform CLI binaries via Bun compile
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js (for npm ci + tsc)
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.x'
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build dist/ (tsc)
|
||||
run: npm run build
|
||||
|
||||
- name: Print Bun version (diagnostics)
|
||||
run: bun --version
|
||||
|
||||
- name: Prepare binary output dir
|
||||
run: mkdir -p dist/bin
|
||||
|
||||
# Keep the package.json scripts as the single source of truth for Bun
|
||||
# compile flags so release and local smoke builds cannot diverge.
|
||||
- name: Build Windows x64
|
||||
run: npm run build:bin:win
|
||||
|
||||
- name: Build Linux x64
|
||||
run: npm run build:bin:linux
|
||||
|
||||
- name: Build macOS x64
|
||||
run: npm run build:bin:mac-x64
|
||||
|
||||
- name: Build macOS arm64
|
||||
run: npm run build:bin:mac-arm
|
||||
|
||||
- name: Size guard (<150 MB each)
|
||||
run: |
|
||||
set -e
|
||||
for f in dist/bin/hermes-relay-*; do
|
||||
sz=$(stat -c%s "$f")
|
||||
mb=$(( sz / 1024 / 1024 ))
|
||||
echo " $f - ${mb} MB"
|
||||
if [ "$sz" -gt 157286400 ]; then
|
||||
echo "FAIL: $f exceeds 150 MB - Bun likely shipped a debug build or we added a large dep."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Smoke-test Linux binary
|
||||
run: |
|
||||
set -e
|
||||
chmod +x dist/bin/hermes-relay-linux-x64
|
||||
for cmd in --version --help doctor; do
|
||||
out=$(./dist/bin/hermes-relay-linux-x64 "$cmd" 2>&1 || true)
|
||||
exit_code=$?
|
||||
if [ -z "$out" ] || [ ${#out} -lt 10 ]; then
|
||||
echo "SMOKE FAIL: './hermes-relay-linux-x64 $cmd' produced no output (exit=$exit_code)"
|
||||
echo "Raw output was: [$out]"
|
||||
exit 1
|
||||
fi
|
||||
echo " smoke OK: $cmd -> $(echo "$out" | head -1)"
|
||||
done
|
||||
|
||||
- name: Upload CLI release assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-cli-release
|
||||
path: |
|
||||
desktop/dist/bin/hermes-relay-win-x64.exe
|
||||
desktop/dist/bin/hermes-relay-linux-x64
|
||||
desktop/dist/bin/hermes-relay-darwin-x64
|
||||
desktop/dist/bin/hermes-relay-darwin-arm64
|
||||
retention-days: 7
|
||||
|
||||
build-windows-tray-installer:
|
||||
name: Build Windows tray installer
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.x'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build dist/ (tsc)
|
||||
run: npm run build
|
||||
|
||||
- name: Test tray shell
|
||||
run: npm run tray:test
|
||||
|
||||
- name: Build tray installer
|
||||
run: npm run tray:build
|
||||
|
||||
- name: Normalize installer asset name
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist/tray | Out-Null
|
||||
$installer = Get-ChildItem -Path tray/src-tauri/target/release/bundle/nsis -Filter '*_x64-setup.exe' | Select-Object -First 1
|
||||
if (-not $installer) { throw 'NSIS installer was not produced' }
|
||||
Copy-Item -Force $installer.FullName dist/tray/hermes-relay-desktop-windows-x64-setup.exe
|
||||
|
||||
- name: Smoke-test tray exe launch
|
||||
shell: pwsh
|
||||
run: |
|
||||
$home = Join-Path $env:RUNNER_TEMP 'hermes-tray-smoke-home'
|
||||
New-Item -ItemType Directory -Force -Path $home | Out-Null
|
||||
$env:USERPROFILE = $home
|
||||
$env:HOME = $home
|
||||
$proc = Start-Process -FilePath tray/src-tauri/target/release/hermes-relay-desktop.exe -WindowStyle Hidden -PassThru
|
||||
Start-Sleep -Seconds 5
|
||||
if ($proc.HasExited) { throw "tray app exited early with code $($proc.ExitCode)" }
|
||||
Stop-Process -Id $proc.Id -Force
|
||||
Write-Host "tray launch smoke OK pid=$($proc.Id)"
|
||||
|
||||
- name: Upload Windows tray release asset
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-windows-tray-release
|
||||
path: desktop/dist/tray/hermes-relay-desktop-windows-x64-setup.exe
|
||||
retention-days: 7
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-cli-binaries
|
||||
- build-windows-tray-installer
|
||||
steps:
|
||||
- name: Extract desktop version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: release-assets
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
run: |
|
||||
set -e
|
||||
find release-assets -type f ! -name SHA256SUMS.txt -print0 \
|
||||
| sort -z \
|
||||
| xargs -0 sha256sum \
|
||||
| sed -E 's#release-assets/[^/]+/##' > release-assets/SHA256SUMS.txt
|
||||
cat release-assets/SHA256SUMS.txt
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }}
|
||||
fail_on_unmatched_files: true
|
||||
body: |
|
||||
# Hermes-Relay-Desktop v${{ steps.version.outputs.version }}
|
||||
|
||||
**Experimental phase.** Assets are unsigned - Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows now ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
|
||||
|
||||
## Install
|
||||
|
||||
**Windows tray app (PowerShell):**
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Windows CLI only:**
|
||||
```powershell
|
||||
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**macOS / Linux CLI:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Pin this specific release with `HERMES_RELAY_VERSION=${{ github.ref_name }}`.
|
||||
|
||||
## Verify
|
||||
|
||||
```text
|
||||
hermes-relay --version
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
hermes-relay shell
|
||||
```
|
||||
|
||||
Open **Hermes Relay Desktop** from the Windows Start menu for tray pairing, devices, task log, settings, pause, and emergency stop.
|
||||
|
||||
See [Desktop docs](https://codename-11.github.io/hermes-relay/desktop/) for full usage.
|
||||
|
||||
files: |
|
||||
release-assets/desktop-cli-release/hermes-relay-win-x64.exe
|
||||
release-assets/desktop-cli-release/hermes-relay-linux-x64
|
||||
release-assets/desktop-cli-release/hermes-relay-darwin-x64
|
||||
release-assets/desktop-cli-release/hermes-relay-darwin-arm64
|
||||
release-assets/desktop-windows-tray-release/hermes-relay-desktop-windows-x64-setup.exe
|
||||
release-assets/SHA256SUMS.txt
|
||||
@@ -1,140 +0,0 @@
|
||||
name: Hermes-Relay Plugin Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "server-v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Plugin 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/server-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify Plugin version sync and changelog
|
||||
run: |
|
||||
python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
|
||||
if ! grep -Eq "^## \[Plugin ${TAG_VERSION}\]" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no Plugin release heading for $TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
TAG_VERSION: ${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Verify tag belongs to the correct integration branch
|
||||
env:
|
||||
TAG_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag_commit="$(git rev-parse HEAD)"
|
||||
if [[ "$TAG_VERSION" == *-* ]]; then
|
||||
git fetch origin dev --no-tags
|
||||
if ! git merge-base --is-ancestor "$tag_commit" origin/dev; then
|
||||
echo "Plugin prereleases must be tagged from dev; $tag_commit is not in origin/dev" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
git fetch origin main --no-tags
|
||||
if ! git merge-base --is-ancestor "$tag_commit" origin/main; then
|
||||
echo "Stable Plugin releases must be tagged from main; $tag_commit is not in origin/main" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
test:
|
||||
name: Test Plugin package
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
pip install -r relay_server/requirements.txt
|
||||
pip install pytest responses
|
||||
|
||||
- name: Syntax check
|
||||
run: |
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/voice.py
|
||||
python -m py_compile plugin/relay/upstream_voice.py
|
||||
python -m py_compile plugin/relay/voice_auth.py
|
||||
python -m py_compile plugin/tools/android_tool.py
|
||||
python -m py_compile plugin/tools/desktop_tool.py
|
||||
python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
- name: Run focused Plugin tests
|
||||
run: |
|
||||
python -m pytest \
|
||||
plugin/tests/test_relay_security.py \
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.py \
|
||||
plugin/tests/test_proactive_channel.py \
|
||||
plugin/tests/test_android_phone_status.py
|
||||
|
||||
package:
|
||||
name: Build and publish Plugin package
|
||||
needs: [validate, test]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Build wheel and sdist
|
||||
run: |
|
||||
pip install build
|
||||
python -m build
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd dist
|
||||
sha256sum * > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
# Render PLUGIN_RELEASE_NOTES.md (hand-written per release) into the GitHub
|
||||
# Release body, substituting the version token so the Install command stays
|
||||
# accurate without a manual edit. The file is the single source of the notes;
|
||||
# see RELEASE.md "Plugin / Python package release".
|
||||
- name: Render release notes
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
sed "s/__VERSION__/${VERSION}/g" PLUGIN_RELEASE_NOTES.md > release_notes_rendered.md
|
||||
echo "=== rendered release body ===" && cat release_notes_rendered.md
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay Plugin 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
|
||||
files: |
|
||||
dist/*.whl
|
||||
dist/*.tar.gz
|
||||
dist/SHA256SUMS.txt
|
||||
@@ -0,0 +1,118 @@
|
||||
name: Release Server
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "server-v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Server release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/server-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify Server version sync
|
||||
run: python scripts/check-server-version-sync.py --expect "$TAG_VERSION"
|
||||
env:
|
||||
TAG_VERSION: ${{ steps.version.outputs.version }}
|
||||
|
||||
test:
|
||||
name: Test Server package
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install test dependencies
|
||||
run: |
|
||||
pip install -r relay_server/requirements.txt
|
||||
pip install pytest responses
|
||||
|
||||
- name: Syntax check
|
||||
run: |
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/voice.py
|
||||
python -m py_compile plugin/relay/upstream_voice.py
|
||||
python -m py_compile plugin/relay/voice_auth.py
|
||||
python -m py_compile plugin/tools/android_tool.py
|
||||
python -m py_compile plugin/tools/desktop_tool.py
|
||||
python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
- name: Run focused Server tests
|
||||
run: |
|
||||
python -m pytest \
|
||||
plugin/tests/test_relay_security.py \
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.py
|
||||
|
||||
package:
|
||||
name: Build and publish Server package
|
||||
needs: [validate, test]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Build wheel and sdist
|
||||
run: |
|
||||
pip install build
|
||||
python -m build
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd dist
|
||||
sha256sum * > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Hermes-Relay-Server v${{ needs.validate.outputs.version }}
|
||||
tag_name: server-v${{ needs.validate.outputs.version }}
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
fail_on_unmatched_files: true
|
||||
body: |
|
||||
# Hermes-Relay-Server v${{ needs.validate.outputs.version }}
|
||||
|
||||
This release contains the server/Python plugin package.
|
||||
Android releases use `android-v*` tags. Desktop releases use
|
||||
`desktop-v*` tags. Historical server releases before this lane
|
||||
rename used `relay-v*` tags.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install hermes-relay==${{ needs.validate.outputs.version }}
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
python -m relay_server --help
|
||||
```
|
||||
files: |
|
||||
dist/*.whl
|
||||
dist/*.tar.gz
|
||||
dist/SHA256SUMS.txt
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Report Review Bundle
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_id:
|
||||
description: Completed Build Review Bundle run ID to report
|
||||
required: true
|
||||
type: string
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Build Review Bundle
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: review-bundle-report-${{ github.event.workflow_run.id || inputs.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
report:
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
}}
|
||||
name: Update pull request comment
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# Check out only the trusted default branch. Never check out the PR head or
|
||||
# execute/download its candidate artifact in this write-capable workflow.
|
||||
- name: Checkout trusted reporter
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Test trusted reporter
|
||||
run: node .github/scripts/review-bundle-report.test.cjs
|
||||
|
||||
- name: Report candidate status
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const reporter = require(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/review-bundle-report.cjs`
|
||||
);
|
||||
await reporter.reportReviewBundle({ github, context, core });
|
||||
@@ -1,198 +0,0 @@
|
||||
name: Build Review Bundle
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
types:
|
||||
- labeled
|
||||
- reopened
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: review-bundle-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
if: >-
|
||||
${{
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'review-candidate') ||
|
||||
(github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'review-candidate'))
|
||||
}}
|
||||
name: Resolve exact source
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
repository: ${{ steps.source.outputs.repository }}
|
||||
sha: ${{ steps.source.outputs.sha }}
|
||||
short_sha: ${{ steps.source.outputs.short_sha }}
|
||||
label: ${{ steps.source.outputs.label }}
|
||||
artifact_slug: ${{ steps.source.outputs.artifact_slug }}
|
||||
source_kind: ${{ steps.source.outputs.source_kind }}
|
||||
source_value: ${{ steps.source.outputs.source_value }}
|
||||
steps:
|
||||
- name: Resolve exact pull request head
|
||||
id: source
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const kind = "pull_request";
|
||||
const source = process.env.PR_NUMBER;
|
||||
const repository = process.env.PR_HEAD_REPOSITORY;
|
||||
const sha = process.env.PR_HEAD_SHA;
|
||||
if (!repository || !sha) {
|
||||
core.setFailed("the PR head repository is no longer available");
|
||||
return;
|
||||
}
|
||||
const label = `PR #${source}`;
|
||||
const slug = `pr-${source}`;
|
||||
|
||||
core.setOutput("repository", repository);
|
||||
core.setOutput("sha", sha);
|
||||
core.setOutput("short_sha", sha.slice(0, 12));
|
||||
core.setOutput("label", label);
|
||||
core.setOutput("artifact_slug", slug);
|
||||
core.setOutput("source_kind", kind);
|
||||
core.setOutput("source_value", source);
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
build:
|
||||
name: Build matched Android + Relay bundle
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout exact review source
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ needs.resolve.outputs.repository }}
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify immutable source
|
||||
env:
|
||||
EXPECTED_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
run: test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
with:
|
||||
cache-read-only: true
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Build side-by-side candidate APK
|
||||
env:
|
||||
SOURCE_LABEL: ${{ needs.resolve.outputs.label }}
|
||||
SOURCE_REF: ${{ needs.resolve.outputs.source_kind }}:${{ needs.resolve.outputs.source_value }}
|
||||
SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
run: |
|
||||
./gradlew :app:assembleSideloadCandidate \
|
||||
-Pcandidate.kind=review \
|
||||
-Pcandidate.label="$SOURCE_LABEL" \
|
||||
-Pcandidate.sourceRef="$SOURCE_REF" \
|
||||
-Pcandidate.sourceSha="$SOURCE_SHA" \
|
||||
--console=plain
|
||||
|
||||
- name: Build Relay packages
|
||||
run: |
|
||||
python -m pip install build
|
||||
python -m build
|
||||
|
||||
- name: Verify candidate application identity
|
||||
run: |
|
||||
apk="$(find app/build/outputs/apk/sideload/candidate -name '*.apk' -print -quit)"
|
||||
test -n "$apk"
|
||||
aapt="$(find "$ANDROID_HOME/build-tools" -type f -name aapt -print | sort -V | tail -1)"
|
||||
test -x "$aapt"
|
||||
"$aapt" dump badging "$apk" | grep -F "package: name='com.axiomlabs.hermesrelay.sideload.candidate'"
|
||||
"$aapt" dump badging "$apk" | grep -F "application-label:'HR Candidate'"
|
||||
|
||||
- name: Assemble review bundle
|
||||
env:
|
||||
SOURCE_KIND: ${{ needs.resolve.outputs.source_kind }}
|
||||
SOURCE_VALUE: ${{ needs.resolve.outputs.source_value }}
|
||||
SOURCE_REPOSITORY: ${{ needs.resolve.outputs.repository }}
|
||||
SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
SOURCE_LABEL: ${{ needs.resolve.outputs.label }}
|
||||
SHORT_SHA: ${{ needs.resolve.outputs.short_sha }}
|
||||
run: |
|
||||
mkdir -p review-bundle/android review-bundle/relay
|
||||
cp app/build/outputs/apk/sideload/candidate/*.apk review-bundle/android/
|
||||
cp dist/*.whl dist/*.tar.gz review-bundle/relay/
|
||||
git archive \
|
||||
--format=tar.gz \
|
||||
--output="review-bundle/relay/hermes-relay-source-${SHORT_SHA}.tar.gz" \
|
||||
HEAD plugin pyproject.toml relay_server
|
||||
cp docs/review-candidates.md review-bundle/INSTALL.md
|
||||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"kind": "review",
|
||||
"label": os.environ["SOURCE_LABEL"],
|
||||
"source": {
|
||||
"kind": os.environ["SOURCE_KIND"],
|
||||
"value": os.environ["SOURCE_VALUE"],
|
||||
"repository": os.environ["SOURCE_REPOSITORY"],
|
||||
"sha": os.environ["SOURCE_SHA"],
|
||||
},
|
||||
"android": {
|
||||
"application_id": "com.axiomlabs.hermesrelay.sideload.candidate",
|
||||
"stable_install_affected": False,
|
||||
},
|
||||
"relay": {
|
||||
"side_by_side_in_same_hermes_process": False,
|
||||
"staging_or_snapshot_rollback_required": True,
|
||||
},
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
Path("review-bundle/REVIEW_MANIFEST.json").write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
cd review-bundle
|
||||
find android relay -type f -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS.txt
|
||||
|
||||
- name: Upload matched review bundle
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: hermes-relay-review-${{ needs.resolve.outputs.artifact_slug }}-${{ needs.resolve.outputs.short_sha }}
|
||||
path: review-bundle/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Review summary
|
||||
env:
|
||||
SOURCE_LABEL: ${{ needs.resolve.outputs.label }}
|
||||
SOURCE_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
run: |
|
||||
echo "## Hermes-Relay review bundle" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Source: **$SOURCE_LABEL**" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Commit: \`$SOURCE_SHA\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Android package: \`com.axiomlabs.hermesrelay.sideload.candidate\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Stable Android installs are not replaced." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Relay review requires a staging Hermes instance or an explicit snapshot/rollback window." >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -26,15 +26,10 @@ local.properties
|
||||
/app/build/
|
||||
/relay-core/build/
|
||||
/relay-ui/build/
|
||||
/ui-preview/build/
|
||||
/quest/build/
|
||||
/app/release/
|
||||
*.apk
|
||||
*.aab
|
||||
|
||||
# Scratch / working directory (local pet packs, generated test assets, etc.)
|
||||
/tmp/
|
||||
/build-*.log
|
||||
*.jks
|
||||
*.keystore
|
||||
/captures
|
||||
@@ -77,9 +72,6 @@ hermes-agent-fork/
|
||||
.claude/
|
||||
.claude-launcher/
|
||||
|
||||
# Per-issue dev-loop brief generated by scripts/start-issue.sh into each worktree
|
||||
ISSUE-BRIEF.md
|
||||
|
||||
# Kotlin compiler cache
|
||||
.kotlin/
|
||||
|
||||
@@ -91,8 +83,5 @@ keystore.properties
|
||||
.smoke-relay.pid
|
||||
.smoke-relay.log
|
||||
|
||||
# Legacy generated desktop tray assets may remain after upgrading a worktree.
|
||||
# Generated tray frontend vendor assets copied from desktop/node_modules
|
||||
desktop/tray/ui/vendor/
|
||||
# Generated from assets/screenshots/02_chat.png before docs dev/build.
|
||||
/user-docs/public/chat-demo.png
|
||||
/user-docs/public/product/desktop-ui/
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mobile-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mobilenext/mobile-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
Universal agent instructions for **Hermes-Relay**. This is the entry point for any
|
||||
coding agent (Claude Code, Codex, Cursor, etc.).
|
||||
|
||||
## Read this first
|
||||
|
||||
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)**
|
||||
- Gateway/session/reconnect testing → **[docs/gateway-contract-testing.md](docs/gateway-contract-testing.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` |
|
||||
| Integration authority | `origin/dev`; local `dev` is a fast-forward-only mirror, never a private staging queue |
|
||||
| Release branch | `main`; release history and hotfix integration only |
|
||||
| Production tag source | The new `main` tip after an approved `dev` → `main` release PR, or after an approved hotfix PR to `main` |
|
||||
| Candidate tag source | An exact release-prepared and tested `dev` SHA; prerelease suffix required (`-alpha`, `-beta`, or `-rc.N`) |
|
||||
| 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.
|
||||
|
||||
### Local integration discipline
|
||||
|
||||
- Fetch `origin/dev` before creating a task branch or worktree; do not base new
|
||||
work on a stale local `dev` ref.
|
||||
- Keep the primary local `dev` checkout tracked-clean and update it only with
|
||||
`git merge --ff-only origin/dev`. Feature, fix, docs, release-prep, and
|
||||
integration commits belong on their own branches and reach `dev` through PRs.
|
||||
- When several reviewed branches must move together, combine them on a named
|
||||
`integration/<batch>` branch in its own worktree, then open one PR to `dev`.
|
||||
An integration branch is not a second `dev` and must not become a hidden queue.
|
||||
- One coordinator owns final base refresh, required checks, and merges while
|
||||
concurrent worktrees continue independently.
|
||||
|
||||
## Non-negotiables (the short list)
|
||||
|
||||
- **Vanilla Hermes path = upstream-only.** The standard (no-plugin) connection
|
||||
uses the upstream Dashboard/Gateway for chat, authentication, Manage, sessions,
|
||||
and Vanilla Hermes voice. The API server is an optional automatic fallback and
|
||||
advanced headless-compatibility surface; Relay adds optional extensions. This
|
||||
path must work against unmodified upstream hermes-agent. Server-side needs go
|
||||
through upstream 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.
|
||||
- **Use the Gateway contract lab when its boundary changes.** Changes to
|
||||
Gateway chat events, session identity/resume/activation, streaming completion,
|
||||
queue ownership, reconnect/lifecycle recovery, or authoritative history must
|
||||
reuse or extend the declarative fixture scenarios, run the relevant Android
|
||||
instrumentation when rendered/lifecycle behavior is affected, and run the
|
||||
scenario manifest through current-upstream conformance. Physical ADB
|
||||
certification is required only when device/runtime behavior is claimed. All
|
||||
of these lanes are on demand; do not add scheduled execution without explicit
|
||||
approval.
|
||||
- **Conventional Commits + `main`/`dev` branching.** Normal branches start at
|
||||
current `origin/dev` and PR back to `dev`; merge commits/no-ff are the
|
||||
repository policy.
|
||||
Version bumps happen only on a release-prep branch targeting `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
|
||||
everywhere, structured `logging` (no `print`). **Desktop CLI (Node ≥21):**
|
||||
zero runtime deps, strict TS + ES modules, ship compiled `dist/`. Full
|
||||
per-language style and the dev loop live in CLAUDE.md → "Code Style".
|
||||
|
||||
## Review guidelines
|
||||
|
||||
- Report only actionable correctness, security, compatibility, or release-risk
|
||||
findings; avoid stylistic preferences unless they violate a documented rule.
|
||||
- Treat the vanilla Hermes upstream boundary as release-critical. Flag any
|
||||
default-path dependency on relay-only or fork-only server behavior.
|
||||
- Check that changes preserve public-repo writing hygiene and do not expose
|
||||
secrets, private infrastructure, or personal information.
|
||||
- Use the affected surface's CI result as evidence, but do not imply Android UI
|
||||
or device behavior was proven without an explicit on-device verification.
|
||||
- Prioritize findings that warrant holding the merge. State the impacted path
|
||||
and the concrete failure mode.
|
||||
|
||||
## Public-repo writing hygiene
|
||||
|
||||
Everything committed is public. In CHANGELOG, DEVLOG, README, docs, and release
|
||||
notes:
|
||||
|
||||
- **No personal names** — attribute impersonally; identity lives in git + the
|
||||
signing cert.
|
||||
- **No private infrastructure** — real hostnames/IPs, internal deployment names,
|
||||
`~/SYSTEM.md`. (Generic example IPs in setup docs are fine.)
|
||||
- **No AI/assistant process self-narration** ("I should have…", course
|
||||
corrections) — state the technical conclusion only.
|
||||
- **No internal jargon or fork/branch plumbing** in user-facing notes.
|
||||
- **CHANGELOG** uses Keep-a-Changelog grouping; condense the version block to
|
||||
crisp public bullets at release-prep (see RELEASE.md §2 "Scrub for public
|
||||
distribution"). **DEVLOG** is a depersonalized, factual engineering log.
|
||||
@@ -1,93 +1,74 @@
|
||||
# Hermes-Relay — Claude Code Adapter
|
||||
# Hermes-Relay — Claude Code Context
|
||||
|
||||
> 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`.
|
||||
> Read this before touching code. Then read docs/spec.md and docs/decisions.md.
|
||||
|
||||
## What This Is
|
||||
|
||||
A native Android app (Kotlin + Jetpack Compose) paired with an optional Python relay plugin/server (aiohttp) for the Hermes agent platform. Vanilla Hermes chat, Manage, and dashboard voice work against unmodified upstream Hermes. The Relay plugin adds phone control, terminal, remote desktop tooling, extra voice engines, and dashboard Relay management via the official Hermes web dashboard.
|
||||
A native Android app (Kotlin + Jetpack Compose) paired with a Python relay server (aiohttp) for the Hermes agent platform. Chat connects directly to the Hermes API Server via HTTP/SSE; bridge and terminal use a relay over WSS.
|
||||
|
||||
**Current state:** Reference latest released version for stable state and current dev branch for working state. The default no-plugin path supports chat, Manage, and voice on vanilla upstream Hermes. Chat auto-prefers the dashboard `/api/ws` gateway transport when Manage auth is ready, then falls back to API-server SSE routes. Vanilla Hermes voice uses dashboard `/api/audio/*` with the Manage session. Relay remains an additive power path for terminal, bridge/device control, notification companion, extra/provider-native voice, remote access, and desktop tooling. Two Android product flavors ship: `googlePlay` (conservative, no unattended Device Control surface) and `sideload` (full-capability).
|
||||
**Current state:** v0.8.0 (release-prep on `dev`) — Phase 0–3 complete. Direct API chat, session management, pairing + security (now multi-endpoint, ADR 24), inbound media, voice mode (stable Hermes Chat + Voice Output plus opt-in provider-native Realtime Agent with reliable low-latency playback and a text/mic Voice Lab), bridge/accessibility control, notification companion, safety rails, multi-Connection, agent profiles + inspector, connection diagnostics, and first-class Tailscale (ADR 25). Two product flavors: `googlePlay` (conservative, Bridge Core without Device Control) and `sideload` (full-capability).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Phone (WS) -> Hermes dashboard (:9119) [vanilla Hermes gateway chat, live thinking]
|
||||
Phone (HTTP/SSE) -> Hermes API Server (:8642) [vanilla Hermes chat fallback, sessions, runs]
|
||||
Phone (HTTP) -> Hermes dashboard (:9119) [vanilla Hermes Manage + voice]
|
||||
Phone (WSS/HTTP) -> Relay plugin/server (:8767) [optional bridge, terminal, relay voice, remote tools]
|
||||
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct]
|
||||
Phone (WSS) → Relay Server (:8767) [bridge, terminal]
|
||||
```
|
||||
|
||||
The Vanilla Hermes path must stay upstream-only. API-server bearer auth and dashboard cookie auth are separate. Terminal and bridge require Relay pairing; Vanilla Hermes chat, Manage, and dashboard voice must not.
|
||||
Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is optional — most local setups run without one. Terminal will go through tmux via the relay. Bridge wraps existing relay protocol. See docs/decisions.md for why.
|
||||
|
||||
### Upstream Hermes API Reference
|
||||
|
||||
**IMPORTANT:** Always verify endpoints against the actual hermes-agent source (`gateway/platforms/api_server.py`). The upstream repo is the source of truth — not our docs, not our memory, not assumptions from other frontends.
|
||||
|
||||
**Vanilla Hermes endpoints (confirmed in hermes-agent source):**
|
||||
**Standard endpoints (confirmed in hermes-agent source):**
|
||||
|
||||
| Endpoint | Purpose | Tool Call Format |
|
||||
|----------|---------|-----------------|
|
||||
| `POST /v1/chat/completions` | OpenAI-compatible chat (stream=true for SSE) | Inline markdown text (`` `💻 terminal` ``) — no separate tool events |
|
||||
| `POST /v1/runs` | Start an agent run | Returns `run_id` |
|
||||
| `GET /v1/runs/{run_id}/events` | SSE stream of run lifecycle events | **Structured events**: `tool.started`, `tool.completed`, `message.delta`, `reasoning.available`, `run.completed`, `run.failed` |
|
||||
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
|
||||
| `GET /v1/models` | List available models | — |
|
||||
| `GET /health` | Health check | — |
|
||||
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management (api_server surface) | — |
|
||||
|
||||
| Endpoint | Purpose | Tool Call Format |
|
||||
| --------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `POST /v1/chat/completions` | OpenAI-compatible chat (stream=true for SSE) | Inline markdown text (``💻 terminal``) — no separate tool events |
|
||||
| `POST /v1/runs` | Start an agent run | Returns `run_id` |
|
||||
| `GET /v1/runs/{run_id}/events` | SSE stream of run lifecycle events | **Structured events**: `tool.started`, `tool.completed`, `message.delta`, `reasoning.available`, `run.completed`, `run.failed` |
|
||||
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
|
||||
| `GET /v1/capabilities` | Machine-readable feature + endpoint discovery | Use before assuming optional surfaces exist |
|
||||
| `GET /v1/models` | List available models | — |
|
||||
| `GET /v1/skills` | Read-only skill list for the API-server agent | `{"object":"list","data":[...]}` |
|
||||
| `GET /v1/toolsets` | Read-only API-server toolset inventory | `{"object":"list","platform":"api_server","data":[...]}` |
|
||||
| `GET/POST/PATCH/DELETE /api/sessions/*` | Native session CRUD, messages, fork, sync chat, SSE chat | Upstream merged via NousResearch/hermes-agent PR #33134 |
|
||||
| `GET /health` | Health check | — |
|
||||
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management (api_server surface) | — |
|
||||
**Baseline upstream endpoints vs compatibility endpoints:**
|
||||
|
||||
Upstream hermes-agent now has a native baseline for API Server session control and skill/toolset discovery:
|
||||
|
||||
**Compatibility endpoints (not all native upstream API-server routes):**
|
||||
1. **Native upstream** — commit [`f7527b0`](https://github.com/NousResearch/hermes-agent/commit/f7527b0fdb54f01691547df03fc65a6d367f9fde), merged via PR [#33134](https://github.com/NousResearch/hermes-agent/pull/33134), salvaged the focused session-control work from closed PR [#29302](https://github.com/NousResearch/hermes-agent/pull/29302). It provides `/api/sessions/*`, session chat/stream, fork/messages, plus `/v1/skills` and `/v1/toolsets`.
|
||||
2. **Codename-11 `axiom` fork** — still carries compatibility/client-metadata routes that upstream does not provide yet: `/api/sessions/search`, `/api/memory`, `/api/skills` detail routes, `/api/config`, and `/api/available-models`.
|
||||
3. **Bootstrap injection** (`hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file and injects only missing compatibility routes for older or partial upstream builds. It should remain per-route/per-feature, not all-or-nothing.
|
||||
|
||||
Upstream main now contains the focused session-control API (`#33134`) and read-only skills/toolsets (`#33016`). The original broad PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) was closed as superseded. Keep these distinctions straight:
|
||||
| Endpoint | Purpose | Provided by |
|
||||
|----------|---------|-------------|
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream OR fork OR bootstrap |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream OR fork OR bootstrap |
|
||||
| `GET /api/sessions/search` | Full-text message search | Fork OR bootstrap only |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Native upstream OR fork only (NOT bootstrap) |
|
||||
| `GET /v1/skills` | Skill list metadata | Native upstream OR fork |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Fork OR bootstrap only |
|
||||
| `GET /api/skills`, `/{name}` | Legacy skill discovery/detail routes | Fork OR bootstrap only; Android prefers `/v1/skills` first |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; mirrored into bootstrap |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Fork OR bootstrap only |
|
||||
| `GET /api/available-models` | Provider-aware model list | Fork OR bootstrap only |
|
||||
|
||||
1. **Native upstream** — `/api/sessions`, `/api/sessions/{id}/messages`, `/api/sessions/{id}/chat`, `/api/sessions/{id}/chat/stream`, `/v1/capabilities`, `/v1/skills`, and `/v1/toolsets` exist in current `gateway/platforms/api_server.py`.
|
||||
2. **Bootstrap compatibility** (`plugin/hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file, injecting only compatibility-only surfaces (session search, memory, legacy skill detail/toggle, config, available-models, slash middleware). Sessions CRUD/messages/fork and the legacy skills list are **retired** — native upstream owns them (#33134/#33016) and the bootstrap carries no fallback for old builds. Native routes still win per method/path for the remaining set. The repo-root `hermes_relay_bootstrap/` package is a legacy import shim.
|
||||
3. **Legacy fork branches** — useful as lineage only. Do not cite `feat/session-api` / `#8556` as the current upstream contract.
|
||||
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions` or `runs` based on the capability snapshot.
|
||||
|
||||
**Dashboard web server (separate surface — loopback-only):**
|
||||
|
||||
| Endpoint | Purpose | Provided by |
|
||||
| -------------------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream (#33134); bootstrap injection retired |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream (#33134); bootstrap injection retired |
|
||||
| `POST /api/sessions/{id}/chat` | Synchronous session chat | Native upstream (#33134) |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Native upstream (#33134); bootstrap does NOT inject |
|
||||
| `GET /v1/skills`, `GET /v1/toolsets` | Read-only skill/toolset discovery | Native upstream (#33016) |
|
||||
| `GET /api/sessions/search` | Full-text message search | Bootstrap/fork legacy; not in current upstream main |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Bootstrap/fork legacy or dashboard web-server surface; not current API-server upstream |
|
||||
| `GET /api/skills/{name}` | Legacy skill detail | Bootstrap compat; list (`GET /api/skills`) retired — use native `/v1/skills` |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; bootstrap stub returns 501 |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Bootstrap/fork legacy; not current API-server upstream |
|
||||
| `GET /api/available-models` | Provider model list | Bootstrap/fork legacy; not current API-server upstream |
|
||||
|
||||
|
||||
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions`, `completions`, or `runs` based on the capability snapshot.
|
||||
|
||||
**Dashboard web server (separate surface — standard Manage / Desktop remote gateway):**
|
||||
|
||||
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info` + `/api/model/options` + `POST /api/model/set`, `/api/profiles/*` (CRUD, `POST /api/profiles/active`, per-profile soul/description/model), `/api/mcp/*`, `/api/logs`, `/api/analytics/usage`, and `**POST /api/audio/transcribe` + `POST /api/audio/speak`** (base64 data-url contract, built for hermes-desktop voice). The API server has **no audio routes** — its `/v1/capabilities` advertises `audio_api: false`; PR #8199 (`/v1/audio/*`) is the canonical future surface but is unmerged. Android's **Vanilla Hermes (no-plugin) voice** therefore rides this dashboard surface via `StandardHermesVoiceClient` with the per-connection dashboard cookie session (Manage sign-in unlocks voice); `AutoVoiceAudioClient` prefers Relay when paired and falls back to standard.
|
||||
|
||||
Current upstream supports two auth modes on this surface. Loopback dashboards still use the injected `window.__HERMES_SESSION_TOKEN__` path. Remote/non-loopback dashboards use the Desktop-style dashboard auth gate: `/api/status` advertises `auth_required` and providers, `/auth/password-login` handles password providers, `/auth/login?provider=...` handles Nous/OIDC redirects, `/api/auth/me` returns the verified session, and `/api/auth/ws-ticket` mints a short-lived ticket for `/api/ws` / `/api/pty`. This dashboard session is **not** an `API_SERVER_KEY`. Android uses it for Manage, Vanilla Hermes voice, and the gateway chat transport. `/api/ws` is backed by `tui_gateway/server.py` (what hermes-desktop + the Ink TUI speak) and is the only upstream surface with **live** `reasoning.delta`/`thinking.delta` streaming; the api_server SSE paths remain the SSE fallback. Relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
|
||||
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info`, `/api/logs`, `/api/analytics/usage`. Auth is a page-injected `window.__HERMES_SESSION_TOKEN__` — loopback-only, no external issuance. **Do not proxy this surface over the relay.** Phone consumes the narrower, fork/bootstrap `api_server.py` surface or relay-native profile-scoped endpoints.
|
||||
|
||||
**Tool call rendering paths:**
|
||||
|
||||
1. **Runs API** — Emits `tool.started`/`tool.completed` as real SSE events → `ToolProgressCard` in real-time.
|
||||
2. **Sessions API** — Native upstream emits structured SSE (`run.started`, `message.started`, `assistant.delta`, `tool.progress`, `tool.started/completed/failed`, `assistant.completed`, `run.completed`, `done`). `run.completed.messages` can reconcile authoritative per-turn transcript.
|
||||
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (``💻 terminal``).
|
||||
2. **Sessions API** — No structured tool events during streaming; reloads message history on stream complete ("session_end reload" pattern).
|
||||
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (`` `💻 terminal` ``).
|
||||
|
||||
## Key Instructions
|
||||
|
||||
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection path — gateway/API chat, Manage, and Vanilla Hermes voice via the dashboard surface — must work against **unmodified upstream hermes-agent**: no fork patches, no bespoke server config as a dependency. The app ships on Google Play to users whose servers we don't control. Features that need server-side changes go through upstream PRs (with graceful degradation until merged) or live behind the opt-in relay plugin.
|
||||
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document whether bootstrap injects it or it requires the fork.
|
||||
- If we use a non-standard endpoint, ensure `probeCapabilities()` covers it and the auto-resolver degrades gracefully.
|
||||
- **Bootstrap maintenance:** Retire `plugin/hermes_relay_bootstrap/` per surface. Done: sessions CRUD/messages/fork and the legacy skills list are retired from the bootstrap (native upstream #33134/#33016, no old-build fallback kept). Remaining: config, memory, legacy skill detail/toggle, available-models, session search, and slash middleware still need explicit replacement decisions before full removal.
|
||||
- **Bootstrap maintenance:** Do not remove `hermes_relay_bootstrap/` just because upstream has native sessions. It can start shrinking only after each Relay-consuming compatibility route has a native replacement or the Android/Desktop clients have migrated away from it.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
@@ -104,10 +85,6 @@ hermes-android/
|
||||
│ ├── accessibility/ # HermesAccessibilityService, ScreenReader, ActionExecutor
|
||||
│ ├── bridge/ # BridgeSafetyManager, BridgeForegroundService, BridgeStatusOverlay
|
||||
│ └── notifications/ # HermesNotificationCompanion
|
||||
├── relay-core/ ← [EXPERIMENTAL] Quest/XR shared core lib (com.axiomlabs.hermesrelay.core) — pairing, transport, terminal, voice, wire
|
||||
├── relay-ui/ ← [EXPERIMENTAL] Quest/XR shared Compose UI lib — sphere, terminal WebView, QR scanner
|
||||
├── quest/ ← [EXPERIMENTAL] Meta Spatial SDK Quest/XR app (gradle includeBuild; in development, not shipped)
|
||||
├── ui-preview/ ← Desktop Compose Hot Reload harness for PC UI iteration (NOT shipped; shares MorphingSphereCore)
|
||||
├── desktop/ ← Node thin-client CLI (`@hermes-relay/cli`)
|
||||
│ ├── bin/hermes-relay.js # #!/usr/bin/env node shim → dist/cli.js
|
||||
│ ├── src/
|
||||
@@ -124,7 +101,6 @@ hermes-android/
|
||||
│ │ ├── transport/ # RelayTransport (reconnect state machine + TLS probe TOFU)
|
||||
│ │ └── lib/ # gracefulExit, rpc, circularBuffer (vendored)
|
||||
│ └── scripts/ # install.sh + install.ps1 curl/iwr one-liners
|
||||
├── website/ ← Astro product/marketing site (static Coolify/Nixpacks deployment)
|
||||
├── plugin/ ← Hermes agent plugin
|
||||
│ ├── android_tool.py # 18 android_* tool handlers
|
||||
│ ├── pair.py # QR pairing implementation
|
||||
@@ -132,7 +108,7 @@ hermes-android/
|
||||
│ ├── tools/ # android_navigate.py, android_notifications.py
|
||||
│ └── dashboard/ # hermes-agent dashboard plugin — manifest, React UI, FastAPI proxy
|
||||
├── relay_server/ ← Thin compat shim → plugin.relay (legacy entrypoint)
|
||||
├── hermes_relay_bootstrap/ ← Legacy import shim for older startup hooks
|
||||
├── hermes_relay_bootstrap/ ← Runtime patch for vanilla/partial upstream compatibility routes
|
||||
├── skills/devops/hermes-relay-pair/ ← /hermes-relay-pair slash command
|
||||
├── scripts/ ← dev.bat, bridge-smoke.sh, bump-version.sh
|
||||
└── docs/ ← spec, decisions, security, relay-server, mcp-tooling
|
||||
@@ -141,36 +117,21 @@ hermes-android/
|
||||
## Project Conventions
|
||||
|
||||
### File Structure
|
||||
|
||||
- **Root-level:** README.md, CLAUDE.md, AGENTS.md, DEVLOG.md, TODO.md, .gitignore
|
||||
- **Root-level:** README.md, CLAUDE.md, AGENTS.md, DEVLOG.md, .gitignore
|
||||
- **docs/** — spec, decisions, security, and any other long-form documentation
|
||||
- **DEVLOG.md** — update at end of each work session with what was done + verification (the factual record of *what happened*). It churns; do NOT park forward work here.
|
||||
- **TODO.md** — the single home for follow-ups / deferred work / known gaps ("what's next"). Record them here — never buried in DEVLOG or scattered through code/doc comments where they get lost.
|
||||
- **DEVLOG.md** — update at end of each work session with what was done, what's next, blockers
|
||||
- **CLAUDE.md hygiene:** Key Files entries must stay one line — implementation detail belongs in the file or `docs/`. Run `/revise-claude-md` after feature-heavy sessions to trim drift.
|
||||
|
||||
### Public-repo writing hygiene
|
||||
|
||||
This is a **public, distributed repo** — every committed file (CHANGELOG, DEVLOG, README, docs, release notes) is public-facing. Write accordingly:
|
||||
|
||||
- **No personal names** in prose — attribute impersonally ("a user reported", "observed"). Author identity lives in git history + the signing cert, not the changelog.
|
||||
- **No private infrastructure** — real server hostnames/IPs, internal deployment names, `~/SYSTEM.md` contents. (Generic example IPs like `192.168.1.100` in setup docs are fine.)
|
||||
- **No AI/assistant process self-narration** — no "I should have…", no course-correction confessionals. State the technical conclusion, not the path to it.
|
||||
- **No internal jargon / fork-branch plumbing** in user-facing notes — keep *what changed*, drop *where we staged it*.
|
||||
- **CHANGELOG** uses Keep-a-Changelog grouping (Added / Changed / Fixed). Detail may accumulate during iteration, but at **release-prep the version block is condensed to crisp public bullets** (1–2 lines each) — deep "how we debugged it" stays in commits/DEVLOG. See [RELEASE.md](RELEASE.md) §2 "Scrub for public distribution".
|
||||
- **DEVLOG.md** is a committed, factual engineering log — what changed, why, and verification — depersonalized and third-person, not a diary.
|
||||
|
||||
### Code Style — Android (Kotlin)
|
||||
|
||||
- **Jetpack Compose** — no XML layouts. Material 3 / Material You.
|
||||
- **kotlinx.serialization** — not Gson. Type-safe, faster.
|
||||
- **OkHttp** for WebSocket + SSE — `okhttp` for WSS relay, `okhttp-sse` for API streaming
|
||||
- **Single-activity** — Compose Navigation for all routing
|
||||
- **Namespace (Kotlin source tree):** `com.hermesandroid.relay` — stable, drives on-disk layout + class FQCNs
|
||||
- **applicationId:** `com.axiomlabs.hermesrelay` (googlePlay), `com.axiomlabs.hermesrelay.sideload` (sideload)
|
||||
- **Min SDK 26, Target SDK 35, Compile SDK 37** / **Kotlin 2.0+**, JVM toolchain 17
|
||||
- **Min SDK 26, Target SDK 35, Compile SDK 36** / **Kotlin 2.0+**, JVM toolchain 17
|
||||
|
||||
### Code Style — Desktop CLI (Node/TypeScript)
|
||||
|
||||
- **Node ≥21** — uses built-in global `WebSocket` (no `ws`/`undici` dep). Strict TS, ES modules, `NodeNext` resolution.
|
||||
- **Zero runtime deps** — `@types/node` + `tsx`/`rimraf`/`typescript` are devDeps only. Ship compiled `dist/`, not tsx.
|
||||
- **One binary, subcommands** — idiomatic for Node CLIs (codex, continue, vite pattern). Bare invocation is `chat`.
|
||||
@@ -178,189 +139,148 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
- **Dev loop:** `npx tsx src/cli.ts <args>` (no rebuild). `npm run build` + `npm link` before pushing to verify the bin shim. Never ship tsx in the published tarball — pre-build with `tsc` so Windows `npm install -g` can cmd-shim the JS directly.
|
||||
|
||||
### Code Style — Server (Python)
|
||||
|
||||
- **aiohttp** — async, matches existing Hermes relay patterns
|
||||
- **Type hints everywhere** — Python 3.11+ syntax
|
||||
- **asyncio** — no threading; **structured logging** — use `logging`, not print()
|
||||
|
||||
### Git
|
||||
|
||||
- **Conventional Commits:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
|
||||
- **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.
|
||||
- **Branching model (as of 2026-04-19):** `main` + `dev`. Feature branches target `dev`, not `main`. `main` receives only release merges (and tags). No straight-to-main exemption — even single-file typos go through `dev`.
|
||||
- **Merge style:** `git merge --no-ff` — no squash. Preserves per-commit trail for agent-team branches on every merge in the chain (feature → dev → main).
|
||||
- **Merging ≠ releasing.** Feature branches land on `dev` continuously as CI goes green; each PR appends to `[Unreleased]` in `CHANGELOG.md` on `dev`. Releases are a separate act — cut when accumulated state is worth shipping, not per-feature. See `RELEASE.md` "When to cut a release."
|
||||
- **Version bumps happen on `dev`, then release-merge to `main`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-server-version.sh` for `server-vX.Y.Z`, and `desktop/package.json` for `desktop-vX.Y.Z`. The release commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the surface tag is cut from `main`.
|
||||
- **Server tracks `dev` for staging.** The hermes-host deployment pulls `dev` so merged features are exercised before they reach a tag. Released state lives on tags cut from `main`.
|
||||
- **Branch protection** on `main` — direct push blocked; only release-merge PRs from `dev` land here. `dev` also requires CI to pass on PRs but accepts feature-branch merges freely.
|
||||
|
||||
### Testing
|
||||
|
||||
- **Android:** JUnit + Compose testing for UI, MockK for mocks
|
||||
- **Gateway/session/reconnect work:** follow the on-demand scenario,
|
||||
current-upstream conformance, Android instrumentation, and physical-proof
|
||||
routing in `docs/gateway-contract-testing.md`; do not infer device behavior
|
||||
from fixture or source checks.
|
||||
- **Python:** `python -m unittest plugin.tests.test_<name>` — avoid bare `pytest` (conftest imports `responses` which may not be installed in the venv)
|
||||
- **CI and release gates:** follow the repository-wide requirements in
|
||||
`AGENTS.md` and `RELEASE.md`; Claude-specific guidance does not redefine them.
|
||||
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-server.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
|
||||
|
||||
## Key Files
|
||||
|
||||
|
||||
| File | Why |
|
||||
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `docs/spec.md` | Full specification — protocol, UI layouts, phases, dependencies |
|
||||
| `docs/decisions.md` | Architecture decisions — framework choice, channel design, auth model |
|
||||
| `docs/gateway-contract-testing.md` | On-demand reusable Gateway scenarios, upstream conformance, Android instrumentation, and ADB certification |
|
||||
| `AGENTS.md` | Universal agent entry point — points here + the non-negotiables (standard-path, commits, writing hygiene) |
|
||||
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp; `android_*` tool usage patterns |
|
||||
| **App — Core** | |
|
||||
| `ui/RelayApp.kt` | Main scaffold (Scaffold + Compose nav); Chat is home — no mode strip, Manage/Bridge reached via Settings; `bottomBar` is a status pill, not a NavigationBar |
|
||||
| `viewmodel/ChatViewModel.kt` | Chat orchestration — send, stream, cancel, slash commands |
|
||||
| `viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay); `resolveStreamingEndpoint()`; derived `relayUiState` flow + `markPaired` hook stamp the active Connection |
|
||||
| `viewmodel/RelayUiState.kt` | Shared sealed state for the relay row — 5 cases + `asBadgeState()` / `statusText()` extensions; 5s grace window before Stale |
|
||||
| `network/HermesApiClient.kt` | Direct HTTP/SSE — `sendRunStream()`, `sendChatStream()`, `probeCapabilities()` |
|
||||
| `network/GatewayChatClient.kt` | Gateway chat transport — JSON-RPC over dashboard `/api/ws` (tui_gateway); live `reasoning.delta`; fresh ws-ticket per connect; per-turn SSE fallback via `onPreflightFailure`; `prewarm()` (connect+resume off the send path); `setKeepAliveInBackground()` suppresses the 120s idle-close |
|
||||
| `network/GatewayKeepAliveService.kt` | Opt-in `specialUse` foreground service (BOTH flavors; declared in main manifest; Play needs a Console FGS declaration) holding the process up so the gateway socket survives background/Doze; driven by ConnectionViewModel from the `KEY_GATEWAY_KEEP_ALIVE` toggle; stops on task-removal |
|
||||
| `data/GatewayKeepAlivePrefs.kt` | Shared `KEY_GATEWAY_KEEP_ALIVE` pref key + `Context.setGatewayKeepAlive()` — used by ConnectionViewModel (StateFlow/setter) and the FGS Stop action |
|
||||
| `network/GatewayEventMapper.kt` | Pure-JVM gateway event→callback mapping for one turn; unknown event types silently ignored; tui_gateway usage-key translation |
|
||||
| `network/GatewayModels.kt` | `GatewayAvailability`, `ActiveTurnHandle`, `GatewayTurnCallbacks` (all members REQUIRED — forces dispatchOn main-thread wrap), `GatewayAsk`, `GatewaySubagentEvent`, `resolveStreamingEndpointPreference()` |
|
||||
| `ui/components/ChatInputBar.kt` | Redesigned input bar — pill field, one trailing slot morphing Send/Voice/Stop/Steer/Queue, no slash button (long-press + opens palette) |
|
||||
| `ui/components/SubagentLane.kt` | Per-taskIndex subagent progress lane — guide rail, compact tool rows, auto-collapse |
|
||||
| `notifications/TurnCompleteNotifier.kt` | Turn-complete local notification when backgrounded — channel `chat_turn_complete`, cancel on resume, settings-gated |
|
||||
| `network/ConnectionManager.kt` | WSS to relay with auto-reconnect; rebuilds OkHttpClient with fresh CertPinner on connect |
|
||||
| `network/ChannelMultiplexer.kt` | Envelope routing by channel; `sendNotification()` for notification outbound |
|
||||
| `network/handlers/ChatHandler.kt` | Chat message state, streaming events, tool annotation parser |
|
||||
| `network/models/SessionModels.kt` | Session, message, SSE event data models |
|
||||
| `data/FeatureFlags.kt` | Feature gating — DEV_MODE + DataStore overrides; `BuildFlavor` (googlePlay/sideload Tier flags) |
|
||||
| **App — Auth** | |
|
||||
| `auth/AuthManager.kt` | Wires SessionTokenStore + CertPinStore; parses auth.ok; `applyServerIssuedCodeAndReset()` |
|
||||
| `auth/SessionTokenStore.kt` | Keystore (StrongBox) + EncryptedSharedPrefs fallback; lossless migration on upgrade |
|
||||
| `auth/CertPinStore.kt` | TOFU cert pinning — SHA-256 SPKI per host:port in DataStore |
|
||||
| `auth/PairedSession.kt` | PairedSession state + PairedDeviceInfo wire model |
|
||||
| `data/Endpoint.kt` | `EndpointCandidate` / `ApiEndpoint` / `RelayEndpoint` — multi-endpoint pairing (ADR 24); `displayLabel()` for LAN/Tailscale/Public/Custom chips |
|
||||
| `network/RelayHttpClient.kt` | OkHttp for /media, /sessions (list/revoke/extend), /health |
|
||||
| **App — Bridge** | |
|
||||
| `network/handlers/BridgeCommandHandler.kt` | Routes `bridge.command` → ActionExecutor; full path inventory + safety-rail integration |
|
||||
| `viewmodel/BridgeViewModel.kt` | BridgeScreen VM — masterToggle, bridgeStatus, permissionStatus, activityLog |
|
||||
| `bridge/BridgeSafetyManager.kt` | Connection-scoped capabilities + timed screen expiry + blocklist + destructive confirmation; unknown, denied, and expired commands fail closed |
|
||||
| `bridge/BridgeCapabilities.kt` / `data/BridgeCapabilityPolicyRepository.kt` | Closed method/path registry + no-backup-bound per-Connection Always/Never/Timed policy; global safety vocabulary and timer duration remain in `BridgeSafetyPreferences.kt` |
|
||||
| `ui/screens/BridgeScreen.kt` | Bridge cockpit — master → Agent access posture/setup → single Unattended Access control → capability-scoped Android readiness (expandable full matrix) → Advanced safety/full editor → activity log |
|
||||
| `ui/components/BridgeAccessCards.kt` | Native access cockpit + first-use preset and screen-lease sheets (renewable idle limits or warned Until-off dedicated-device mode); preserves full permission/safety drilldowns while keeping selected policy/readiness above the fold |
|
||||
| `ui/components/UnattendedAccessRow.kt` | Unattended toggle card (sideload); `enabled=masterEnabled`; inline `KeyguardDetectedAlert` |
|
||||
| `ui/components/UnattendedGlobalBanner.kt` | 28dp amber strip at scaffold top when master+unattended on (sideload); tap → Bridge tab |
|
||||
| `bridge/BridgeStatusOverlay.kt` | WindowManager overlay; `ConfirmationOverlayHost`; requires `SavedStateRegistryOwner` init order (CREATED→restore→RESUMED) |
|
||||
| `accessibility/HermesAccessibilityService.kt` | AccessibilityService subclass; `@Volatile instance` singleton for BridgeCommandHandler |
|
||||
| `accessibility/ScreenReader.kt` | UI tree → ScreenContent; `findNodeBoundsByText()`, `findFocusedInput()` |
|
||||
| `accessibility/ActionExecutor.kt` | Gesture/text dispatch via GestureDescription + ACTION_SET_TEXT; pressKey maps vocab only |
|
||||
| **App — Voice** | |
|
||||
| `voice/VoiceViewModel.kt` | Voice turn state machine; TTS queue; `ignoreAssistantId`; `errorEvents: SharedFlow` |
|
||||
| `audio/VoiceRecorder.kt` | MediaRecorder wrapper; perceptual amplitude curve; `.m4a` at 16kHz/64kbps |
|
||||
| `audio/VoicePlayer.kt` | Media3 ExoPlayer (gapless TTS queue) + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine; `audioSessionId` is a thread-safe `@Volatile` cache |
|
||||
| `network/RelayVoiceClient.kt` | OkHttp for `/voice/transcribe`, `/synthesize`, `/config` |
|
||||
| `voice/VoiceBridgeIntentHandler.kt` | Interface routing voice utterances to bridge; impls per flavor via factory |
|
||||
| `voice/VoiceIntentClassifier.kt` | Regex phone-control classifier (sideload only); false-negatives preferred over false-positives |
|
||||
| `ui/components/VoiceModeOverlay.kt` | Full-screen voice UI — MorphingSphere + VoiceWaveform + mic button |
|
||||
| `ui/components/MorphingSphere.kt` | Compose renderer for the agent sphere — delegates math to `MorphingSphereCore` |
|
||||
| `ui/components/MorphingSphereCore.kt` | Platform-agnostic sphere algorithm (`kotlin.math` only) — single source of truth; mirrored byte-for-byte in `preview/web/sphere.js` |
|
||||
| `preview/web/` | Zero-dep browser harness — live `index.html` preview + `parity-check.mjs`; paired with `MorphingSphereCoreParityTest` (JVM) for struct/full checksum diffing |
|
||||
| `user-docs/.vitepress/theme/components/SphereMark.vue` | Docs-site sphere embed — imports `preview/web/sphere.js` directly; autonomous fbm drift + pointer-proximity gaze/state blend; `<ClientOnly>` + `IntersectionObserver` + `prefers-reduced-motion` aware |
|
||||
| **App — Media + Notifications** | |
|
||||
| `util/MediaCacheWriter.kt` | `cacheDir/hermes-media/` LRU writer; returns FileProvider URIs |
|
||||
| `util/MediaSaver.kt` | Save/share/open for chat media — MediaStore scoped-storage save (Pictures/Download `Hermes-Relay`, no perms on API 29+; pre-Q → share sheet); FileProvider share staging; remote-byte fetch; magic-byte image-MIME sniff for correct extensions |
|
||||
| `ui/components/ChatImageViewer.kt` | Full-screen image viewer — pinch-zoom/pan (`detectTransformGestures`), double-tap 1×/2.5×, Share/Save/Close; `ChatImageViewerSource` decouples Coil-model/bitmap display from a suspend `bytesProvider` so Save keeps original bytes |
|
||||
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic; image tap → ChatImageViewer, file card long-press → Open/Share/Save menu |
|
||||
| `ui/components/ChatImageContent.kt` | Parses `` out of assistant content; remote http(s) → Coil (tap → ChatImageViewer), server-local/failed → inline "can't render" notice with the path |
|
||||
| `data/HermesCard.kt` | `CARD:{json}` envelope (ADR 26) — type/accent/fields/actions; kotlinx.serialization |
|
||||
| `ui/components/HermesCardBubble.kt` | Rich-card renderer — accent stripe + FlowRow actions + dispatch stamp collapse |
|
||||
| `viewmodel/CardDispatchSyncBuilder.kt` | Twin of VoiceIntentSyncBuilder — synthesizes card dispatches as `hermes_card_action` OpenAI pairs for session memory |
|
||||
| `notifications/HermesNotificationCompanion.kt` | NotificationListenerService; cold-start buffer (50); forwards via ChannelMultiplexer |
|
||||
| `util/RelayErrorClassifier.kt` | `classifyError(Throwable, context) → HumanError`; used by Voice/Chat/Connection |
|
||||
| `util/TurnLatencyTracer.kt` | One `TurnLatency` INFO line per chat turn — `warm/cold` + `connect/session/submit/ttfe/ttft/done@…ms`; gateway + 3 SSE paths use it for desktop-comparable latency diagnosis; durations only |
|
||||
| **Relay — Server** | |
|
||||
| `plugin/relay/server.py` | Canonical relay — WSS + HTTP routes; bridge, media, voice, session, pairing handlers. `handle_pairing_mint` mirrors `pair.py:762` — top-level = API server, `relay.{url,code}` nested |
|
||||
| `plugin/relay/auth.py` | PairingManager, SessionManager, RateLimiter; `math.inf` for never-expire |
|
||||
| `plugin/relay/channels/bridge.py` | Bridge handler — `handle_command()` mints request_id, awaits response, 30s timeout |
|
||||
| `plugin/relay/channels/notifications.py` | Bounded deque (100) of notification metadata; in-memory only |
|
||||
| `plugin/relay/media.py` | MediaRegistry — LRU token store; `strict_sandbox` off by default for `/media/by-path` |
|
||||
| `plugin/relay/voice.py` | Voice endpoints — transcribe, synthesize, voice_config; lazy tool imports |
|
||||
| `plugin/relay/qr_sign.py` | HMAC-SHA256 QR signing; secret at `~/.hermes/hermes-relay-qr-secret`; canonical form preserves `endpoints` array order + role strings verbatim (ADR 24) |
|
||||
| `plugin/relay/tailscale.py` | First-class Tailscale helper (ADR 25) — `status()` / `enable(port)` / `disable(port)` / `canonical_upstream_present()`; safe-absent via shell-out to `tailscale` CLI |
|
||||
| `plugin/relay/_env_bootstrap.py` | Loads `~/.hermes/.env` before relay imports; called from both entry points |
|
||||
| **Plugin — Tools + Installer** | |
|
||||
| `plugin/tools/android_tool.py` | 18 `android_*` tool handlers (14 baseline + send_sms, call, search_contacts, return_to_hermes); `android_screenshot` first consumer of `register_media()` |
|
||||
| `plugin/tools/android_navigate.py` | Vision-driven navigation loop; up to 20 iterations; `llm_gap` error until vision client wired |
|
||||
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
|
||||
| `plugin/doctor.py` | `hermes relay doctor`; checks standard upstream API/dashboard reachability, Relay loopback state, plugin layout, and compat hook state |
|
||||
| `plugin/compat.py` | `hermes relay compat status/install/remove`; owns the optional `hermes_relay_bootstrap.pth` lifecycle |
|
||||
| `plugin/hermes_relay_bootstrap/` | Plugin-owned runtime compatibility patch — compat-only surfaces (session search, memory, skill detail/toggle, config, available-models, slash middleware); sessions + skills-list injection retired (#33134/#33016) |
|
||||
| `install.sh` | Canonical installer — 6 steps; idempotent; drops `hermes-relay-update` shim |
|
||||
| `uninstall.sh` | Canonical uninstaller; reverses install.sh; never touches `.env` or `state.db` |
|
||||
| `hermes_relay_bootstrap/` | Legacy import shim for old `.pth` files and editable installs |
|
||||
| **Plugin — Dashboard** | |
|
||||
| `plugin/dashboard/manifest.json` | Declares tab, entry bundle, and FastAPI module for hermes-agent discovery |
|
||||
| `plugin/dashboard/plugin_api.py` | FastAPI router proxying 5 routes to relay over loopback; `/pairing` body = API-server overrides (host/port/tls/api_key), relay URL auto-derived |
|
||||
| `plugin/dashboard/src/index.jsx` | React root registering `hermes-relay` plugin with 4-tab shell |
|
||||
| `plugin/dashboard/dist/index.js` | Committed IIFE bundle loaded verbatim by dashboard |
|
||||
| **Desktop CLI** | |
|
||||
| `desktop/package.json` | `@hermes-relay/cli` package manifest — Node ≥21, one `hermes-relay` bin, pre-built dist |
|
||||
| `desktop/bin/hermes-relay.js` | Tiny shim: `import('../dist/cli.js').then(m => m.main())` + error surfacing |
|
||||
| `desktop/src/chatAttach.ts` | captureClipboardImage / captureScreenshot / readImageFile; ships base64 to server via `image.attach.bytes` RPC before next prompt.submit |
|
||||
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat`; command-scoped `--help` falls through to each command |
|
||||
| `desktop/src/lib/theme.ts` | Shared ANSI palette + `colorEnabled()` + `Theme` (semantic helpers, `statusDot`) — single visual language; `--no-color`/`NO_COLOR`/TTY aware |
|
||||
| `desktop/src/lib/table.ts` | Zero-dep column-aligned table renderer (ANSI-width aware, last column flexes to terminal width) — used by devices/sessions/audit |
|
||||
| `desktop/src/lib/spinner.ts` | Stderr braille spinner for slow ops (pair probe, gateway connect); no-op when piped/quiet/json |
|
||||
| `desktop/src/lib/usage.ts` | `UsageSpec` + `renderUsage`/`printUsage`/`unknownSubcommand` — per-subcommand `--help` + self-documenting sub-verb fallback |
|
||||
| `desktop/src/lib/hints.ts` | `suggestedFix(err, ctx)` → next-step command (re-pair on auth fail, etc.); `formatError` renders error + hint |
|
||||
| `desktop/src/lib/logo.ts` | Slim box-drawing "Hermes Relay" wordmark; shown atop `--help`, first-run welcome, REPL header, and `hermes-relay logo`; theme/no-color aware |
|
||||
| `desktop/src/lib/auditLog.ts` | Local desktop-tool audit JSONL (`~/.hermes/desktop-audit.jsonl`); router appends per dispatch; backs `audit` command (relay's ring is loopback-only) |
|
||||
| `desktop/src/lib/daemonStatus.ts` | Daemon heartbeat file (`~/.hermes/daemon-status.json`) + `isPidAlive` liveness; backs `daemon --status` |
|
||||
| `desktop/src/commands/audit.ts` | `hermes-relay audit` — tails the local audit log into a table (WHEN/TOOL/STATUS/DETAIL); `--limit`, `--json` |
|
||||
| `desktop/src/commands/relay.ts` | `hermes-relay relay info/security/context/queue` — relay-server management surface; info/security/queue loopback-only, context works remote with bearer; `queue` lists/cancels the agent→phone outbound buffer (`--clear` / `--cancel <id>`) |
|
||||
| `desktop/src/commands/chat.ts` | REPL + one-shot + piped-stdin; `runOneTurn` returns `{promise, cancel}` for safe SIGINT; auto-wires `DesktopToolRouter` when consented |
|
||||
| `desktop/src/commands/shell.ts` | Pipes the `terminal` relay channel to raw-mode stdin/stdout; post-attach `exec hermes` 350ms after tmux settles; `Ctrl+A .` detach / `Ctrl+A k` kill / `Ctrl+A Ctrl+A` literal |
|
||||
| `desktop/src/commands/pair.ts` | Either 6-char code + `--remote`, or full v3 QR via `--pair-qr` — probes + picks endpoint, records role; `--grant-tools` (TTY prompt) / `--auto-grant-tools` (silent) stamp `toolsConsented` so `daemon` works without a `shell` round-trip |
|
||||
| `desktop/src/commands/tools.ts` | `tools.list` RPC → enabled/available toolsets; `--verbose` lists individual tools |
|
||||
| `desktop/src/commands/status.ts` | Local read of `~/.hermes/remote-sessions.json`; renders `grants:` + `expires:` + `route:`; `--json` redacts tokens, `--reveal-tokens` opts in |
|
||||
| `desktop/src/commands/devices.ts` | Server-side session management — `GET/DELETE/PATCH /sessions` via `fetch` over http(s)://host:port; `list` / `revoke <prefix>` / `extend <prefix> --ttl <s>` |
|
||||
| `desktop/src/banner.ts` | `buildConnectBanner({url, meta, endpointRole})` → "Connected via LAN (plain) — server 0.6.0"; `humanExpiry()` for TTL formatting |
|
||||
| `desktop/src/endpoint.ts` | `EndpointCandidate` / `EndpointRole` types + `displayLabel()` — mirrors Android `data/Endpoint.kt` |
|
||||
| `desktop/src/pairingQr.ts` | `decodePairingPayload` (JSON or base64), `payloadToCandidates` (v3 verbatim / v1–v2 synthesized), `probeCandidatesByPriority` (`Promise.any` within tier, `AbortSignal.any`, 4s timeout, 60s cache) |
|
||||
| `desktop/src/certPin.ts` | `extractSpkiSha256(der)` via `crypto.X509Certificate` + `publicKey.export({type:'spki'})`; `pinKey(url)`, `comparePins()`, `isSecureUrl()` |
|
||||
| `desktop/src/tools/router.ts` | `DesktopToolRouter.attach(relay)` — `onChannel('desktop')` dispatch under 30s `AbortController`; heartbeat enriched with host/platform/version/uptime_ms + sticky `last_error` for `desktop_health` |
|
||||
| `desktop/src/tools/handlerSet.ts` | Single source of truth for the desktop tool map — `DESKTOP_HANDLERS` + `DESKTOP_ADVERTISED_TOOLS`; consumed by `chat.ts` / `shell.ts` / `daemon.ts` so adding a tool is a one-file change |
|
||||
| `desktop/src/tools/consent.ts` | `ensureToolsConsent(url)` — stored per-URL in `toolsConsented`; TTY prompt; non-TTY fails closed |
|
||||
| `desktop/src/tools/handlers/fs.ts` | `readFileHandler` / `writeFileHandler` / `patchHandler` — strict unified-diff applier, no fuzz |
|
||||
| `desktop/src/tools/handlers/terminal.ts` | `bash -lc` / `cmd /c`, SIGKILL on timeout or abort, returns `{stdout, stderr, exit_code, duration_ms}` |
|
||||
| `desktop/src/tools/handlers/powershell.ts` | Spawns `pwsh`/`powershell` directly with `-Command -`, script piped via stdin — no cmd.exe quote-mangling; auto-picks pwsh > powershell |
|
||||
| `desktop/src/tools/handlers/process.ts` | `spawn_detached` (unref'd, returns pid+log_path), `list_processes` (tasklist /FO CSV — no /V to dodge window-title latency), `kill_process`, `find_pid_by_port` (netstat/lsof/ss) |
|
||||
| `desktop/src/tools/handlers/jobs.ts` | Job API — `~/.hermes/desktop-jobs/<id>/{stdout.log, stderr.log, meta.json}` is source of truth across daemon restarts; `taskkill /T` on Windows so build trees die fully |
|
||||
| `desktop/src/tools/handlers/transfer.ts` | `copy_directory` via `fs.cp`, `zip`/`unzip` via tar > zip > PowerShell probe, `checksum` streamed (sha256/sha1/md5) |
|
||||
| `desktop/src/tools/handlers/search.ts` | ripgrep with pure-Node fallback, skips `.git`/`node_modules`/`dist`/`.next`/`.cache` |
|
||||
| `desktop/src/renderer.ts` | Streams `message.delta` → stdout, tool events → decorated lines; NO_COLOR / --json / --quiet aware |
|
||||
| `desktop/src/pairing.ts` | readline-based 6-char prompt (`A-Z0-9`); headless mirror of TUI's Ink prompt; `validatePairingPayloadString` discriminated-union wrapper |
|
||||
| `desktop/src/credentials.ts` | Precedence: `--token` → `--pair-qr` (probe+pair) → `--code` → stored → prompt; returns `Credentials{sessionToken?, pairingCode?, resolvedEndpoint?}` |
|
||||
| `desktop/src/transport/RelayTransport.ts` | Fork of ui-tui's transport + reconnect state machine (`idle/connecting/connected/reconnecting`, exp backoff 1→30s, 5min on 429, gate re-check post-sleep) + pre-WS TLS probe for TOFU |
|
||||
| `desktop/src/remoteSessions.ts` | Same file path as TUI (`~/.hermes/remote-sessions.json`, 0600); schema widened with `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented`; `saveSession` back-compat overload |
|
||||
| `desktop/src/commands/daemon.ts` | Headless WSS + tool router for always-on access; JSON-line logs; fails closed on missing consent unless `--allow-tools` with explicit `--token` |
|
||||
| `desktop/src/commands/doctor.ts` | Local-only diagnostic report — version / binary path / PATH / sessions / daemon detection; `--json` for support-paste; omits tokens entirely |
|
||||
| `desktop/src/relayUrlPrompt.ts` | First-run URL fallback — `resolveFirstRunUrl()` auto-picks single stored session, numbered picker for multiple, welcome banner for zero; throws on non-interactive + ambiguous |
|
||||
| `desktop/src/version.ts` | Build-time-generated constant (`npm run gen:version` before every build) — Bun compiled binaries can't read package.json via `__dirname` so version is embedded at build |
|
||||
| `desktop/scripts/install.sh` / `install.ps1` | curl/iwr one-liner installers — download prebuilt Bun binary (no Node required), SHA256-verified, API-resolver for `latest` that includes prereleases, version-aware pre/post-install readback |
|
||||
| `desktop/scripts/uninstall.sh` / `uninstall.ps1` | 3-tier removal — default (binary + PATH), `--purge` (also wipes `~/.hermes/remote-sessions.json`), `--service` (stub for future service installers); Windows iex-safe env-var fallback |
|
||||
| `desktop/README.md` | User-facing install + usage reference |
|
||||
| **Desktop CLI — dev iteration** | |
|
||||
| `npm run smoke` (in `desktop/`) | Builds Windows binary + runs `--version` / `--help` / `doctor`, fails loud on zero-output. Local pre-flight before cutting any tag. |
|
||||
| `npm run gen:version` | Regenerates `src/version.ts` from `package.json`. Runs automatically before every `build` / `build:bin:*`. |
|
||||
| `release-cli.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
|
||||
| **Server — Desktop tool routing (Phase B)** | |
|
||||
| `plugin/relay/channels/desktop.py` | Mirrors `bridge.py` — `desktop.command`/`desktop.response`/`desktop.status`, UUID-correlated futures, 30s timeout, single-client MVP, per-session advertised-tools set |
|
||||
| `plugin/tools/desktop_tool.py` | 24 `desktop_*` tools (fs/shell/powershell/process/jobs/transfer/health) — registers with `tools.registry` under `desktop` toolset; per-tool `check_fn` pings `/desktop/_ping?tool=<name>`; `desktop_health` is `_RELAY_ONLY` and pings `/desktop/health` so it works even when the client is wedged |
|
||||
| **Gradle modules — experimental Quest/XR (in development)** | |
|
||||
| `relay-core/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.core`) — shared pairing/transport/terminal/voice/wire for the Quest port; not yet wired into the shipped `:app` |
|
||||
| `relay-ui/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.ui`) — shared Compose UI (sphere, terminal WebView, QR scanner) for the Quest port; carries its own sphere copy |
|
||||
| `quest/` | [EXPERIMENTAL] Meta Spatial SDK Quest/XR app — gradle `includeBuild("quest")`; needs further development, not shipped |
|
||||
| **Tooling — dev iteration (not shipped)** | |
|
||||
| `ui-preview/` | Desktop Compose Hot Reload harness — JVM Compose for Desktop; source-shares `MorphingSphereCore` from `:relay-ui`; `Main.kt` gallery; see `ui-preview/README.md` |
|
||||
| `app/src/test/.../screenshots/StoreScreenshotTest.kt` | Roborazzi host-side store/docs screenshot renderer — deterministic, no device, exact 1080×2160; reuses real components+chrome with mock data; `capture(name, themeId){…}` renders any view; see `docs/screenshot-automation.md` §Deterministic rendering (JDK-21 + no-plugin gotchas) |
|
||||
|
||||
| File | Why |
|
||||
|------|-----|
|
||||
| `docs/spec.md` | Full specification — protocol, UI layouts, phases, dependencies |
|
||||
| `docs/decisions.md` | Architecture decisions — framework choice, channel design, auth model |
|
||||
| `AGENTS.md` | Tool usage patterns for the `android_*` toolset |
|
||||
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp |
|
||||
| **App — Core** | |
|
||||
| `ui/RelayApp.kt` | Main scaffold — bottom nav, Compose navigation |
|
||||
| `viewmodel/ChatViewModel.kt` | Chat orchestration — send, stream, cancel, slash commands |
|
||||
| `viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay); `resolveStreamingEndpoint()`; derived `relayUiState` flow + `markPaired` hook stamp the active Connection |
|
||||
| `viewmodel/RelayUiState.kt` | Shared sealed state for the relay row — 5 cases + `asBadgeState()` / `statusText()` extensions; 5s grace window before Stale |
|
||||
| `network/HermesApiClient.kt` | Direct HTTP/SSE — `sendRunStream()`, `sendChatStream()`, `probeCapabilities()` |
|
||||
| `network/ConnectionManager.kt` | WSS to relay with auto-reconnect; rebuilds OkHttpClient with fresh CertPinner on connect |
|
||||
| `network/ChannelMultiplexer.kt` | Envelope routing by channel; `sendNotification()` for notification outbound |
|
||||
| `network/handlers/ChatHandler.kt` | Chat message state, streaming events, tool annotation parser |
|
||||
| `network/models/SessionModels.kt` | Session, message, SSE event data models |
|
||||
| `data/FeatureFlags.kt` | Feature gating — DEV_MODE + DataStore overrides; `BuildFlavor` (googlePlay/sideload Tier flags) |
|
||||
| **App — Auth** | |
|
||||
| `auth/AuthManager.kt` | Wires SessionTokenStore + CertPinStore; parses auth.ok; `applyServerIssuedCodeAndReset()` |
|
||||
| `auth/SessionTokenStore.kt` | Keystore (StrongBox) + EncryptedSharedPrefs fallback; lossless migration on upgrade |
|
||||
| `auth/CertPinStore.kt` | TOFU cert pinning — SHA-256 SPKI per host:port in DataStore |
|
||||
| `auth/PairedSession.kt` | PairedSession state + PairedDeviceInfo wire model |
|
||||
| `data/Endpoint.kt` | `EndpointCandidate` / `ApiEndpoint` / `RelayEndpoint` — multi-endpoint pairing (ADR 24); `displayLabel()` for LAN/Tailscale/Public/Custom chips |
|
||||
| `network/RelayHttpClient.kt` | OkHttp for /media, /sessions (list/revoke/extend), /health |
|
||||
| **App — Bridge** | |
|
||||
| `network/handlers/BridgeCommandHandler.kt` | Routes `bridge.command` → ActionExecutor; full path inventory + safety-rail integration |
|
||||
| `viewmodel/BridgeViewModel.kt` | BridgeScreen VM — masterToggle, bridgeStatus, permissionStatus, activityLog |
|
||||
| `bridge/BridgeSafetyManager.kt` | Blocklist + destructive-verb confirmation + auto-disable timer; fails-closed on /call and /send_sms |
|
||||
| `data/BridgeSafetyPreferences.kt` | DataStore for blocklist, destructive verbs, auto-disable minutes, confirmation timeout |
|
||||
| `ui/screens/BridgeScreen.kt` | Bridge UI — master → permission checklist → [Advanced] → unattended → safety → activity log (v0.4.1 reorder) |
|
||||
| `ui/components/UnattendedAccessRow.kt` | Unattended toggle card (sideload); `enabled=masterEnabled`; inline `KeyguardDetectedAlert` |
|
||||
| `ui/components/UnattendedGlobalBanner.kt` | 28dp amber strip at scaffold top when master+unattended on (sideload); tap → Bridge tab |
|
||||
| `bridge/BridgeStatusOverlay.kt` | WindowManager overlay; `ConfirmationOverlayHost`; requires `SavedStateRegistryOwner` init order (CREATED→restore→RESUMED) |
|
||||
| `accessibility/HermesAccessibilityService.kt` | AccessibilityService subclass; `@Volatile instance` singleton for BridgeCommandHandler |
|
||||
| `accessibility/ScreenReader.kt` | UI tree → ScreenContent; `findNodeBoundsByText()`, `findFocusedInput()` |
|
||||
| `accessibility/ActionExecutor.kt` | Gesture/text dispatch via GestureDescription + ACTION_SET_TEXT; pressKey maps vocab only |
|
||||
| **App — Voice** | |
|
||||
| `voice/VoiceViewModel.kt` | Voice turn state machine; TTS queue; `ignoreAssistantId`; `errorEvents: SharedFlow` |
|
||||
| `audio/VoiceRecorder.kt` | MediaRecorder wrapper; perceptual amplitude curve; `.m4a` at 16kHz/64kbps |
|
||||
| `audio/VoicePlayer.kt` | Media3 ExoPlayer (gapless TTS queue) + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine; `audioSessionId` is a thread-safe `@Volatile` cache |
|
||||
| `network/RelayVoiceClient.kt` | OkHttp for `/voice/transcribe`, `/synthesize`, `/config` |
|
||||
| `voice/VoiceBridgeIntentHandler.kt` | Interface routing voice utterances to bridge; impls per flavor via factory |
|
||||
| `voice/VoiceIntentClassifier.kt` | Regex phone-control classifier (sideload only); false-negatives preferred over false-positives |
|
||||
| `ui/components/VoiceModeOverlay.kt` | Full-screen voice UI — MorphingSphere + VoiceWaveform + mic button |
|
||||
| `ui/components/MorphingSphere.kt` | Compose renderer for the agent sphere — delegates math to `MorphingSphereCore` |
|
||||
| `ui/components/MorphingSphereCore.kt` | Platform-agnostic sphere algorithm (`kotlin.math` only) — single source of truth; mirrored byte-for-byte in `preview/web/sphere.js` |
|
||||
| `preview/web/` | Zero-dep browser harness — live `index.html` preview + `parity-check.mjs`; paired with `MorphingSphereCoreParityTest` (JVM) for struct/full checksum diffing |
|
||||
| `user-docs/.vitepress/theme/components/SphereMark.vue` | Docs-site sphere embed — imports `preview/web/sphere.js` directly; autonomous fbm drift + pointer-proximity gaze/state blend; `<ClientOnly>` + `IntersectionObserver` + `prefers-reduced-motion` aware |
|
||||
| **App — Media + Notifications** | |
|
||||
| `util/MediaCacheWriter.kt` | `cacheDir/hermes-media/` LRU writer; returns FileProvider URIs |
|
||||
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic |
|
||||
| `data/HermesCard.kt` | `CARD:{json}` envelope (ADR 26) — type/accent/fields/actions; kotlinx.serialization |
|
||||
| `ui/components/HermesCardBubble.kt` | Rich-card renderer — accent stripe + FlowRow actions + dispatch stamp collapse |
|
||||
| `viewmodel/CardDispatchSyncBuilder.kt` | Twin of VoiceIntentSyncBuilder — synthesizes card dispatches as `hermes_card_action` OpenAI pairs for session memory |
|
||||
| `notifications/HermesNotificationCompanion.kt` | NotificationListenerService; cold-start buffer (50); forwards via ChannelMultiplexer |
|
||||
| `util/RelayErrorClassifier.kt` | `classifyError(Throwable, context) → HumanError`; used by Voice/Chat/Connection |
|
||||
| **Relay — Server** | |
|
||||
| `plugin/relay/server.py` | Canonical relay — WSS + HTTP routes; bridge, media, voice, session, pairing handlers. `handle_pairing_mint` mirrors `pair.py:762` — top-level = API server, `relay.{url,code}` nested |
|
||||
| `plugin/relay/auth.py` | PairingManager, SessionManager, RateLimiter; `math.inf` for never-expire |
|
||||
| `plugin/relay/channels/bridge.py` | Bridge handler — `handle_command()` mints request_id, awaits response, 30s timeout |
|
||||
| `plugin/relay/channels/notifications.py` | Bounded deque (100) of notification metadata; in-memory only |
|
||||
| `plugin/relay/media.py` | MediaRegistry — LRU token store; `strict_sandbox` off by default for `/media/by-path` |
|
||||
| `plugin/relay/voice.py` | Voice endpoints — transcribe, synthesize, voice_config; lazy tool imports |
|
||||
| `plugin/relay/qr_sign.py` | HMAC-SHA256 QR signing; secret at `~/.hermes/hermes-relay-qr-secret`; canonical form preserves `endpoints` array order + role strings verbatim (ADR 24) |
|
||||
| `plugin/relay/tailscale.py` | First-class Tailscale helper (ADR 25) — `status()` / `enable(port)` / `disable(port)` / `canonical_upstream_present()`; safe-absent via shell-out to `tailscale` CLI |
|
||||
| `plugin/relay/_env_bootstrap.py` | Loads `~/.hermes/.env` before relay imports; called from both entry points |
|
||||
| **Plugin — Tools + Installer** | |
|
||||
| `plugin/tools/android_tool.py` | 18 `android_*` tool handlers (14 baseline + send_sms, call, search_contacts, return_to_hermes); `android_screenshot` first consumer of `register_media()` |
|
||||
| `plugin/tools/android_navigate.py` | Vision-driven navigation loop; up to 20 iterations; `llm_gap` error until vision client wired |
|
||||
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
|
||||
| `install.sh` | Canonical installer — 6 steps; idempotent; drops `hermes-relay-update` shim |
|
||||
| `uninstall.sh` | Canonical uninstaller; reverses install.sh; never touches `.env` or `state.db` |
|
||||
| `hermes_relay_bootstrap/` | Runtime patch for vanilla/partial upstream compatibility routes; shrink per route group after native parity or client migration |
|
||||
| **Plugin — Dashboard** | |
|
||||
| `plugin/dashboard/manifest.json` | Declares tab, entry bundle, and FastAPI module for hermes-agent discovery |
|
||||
| `plugin/dashboard/plugin_api.py` | FastAPI router proxying 5 routes to relay over loopback; `/pairing` body = API-server overrides (host/port/tls/api_key), relay URL auto-derived |
|
||||
| `plugin/dashboard/src/index.jsx` | React root registering `hermes-relay` plugin with 4-tab shell |
|
||||
| `plugin/dashboard/dist/index.js` | Committed IIFE bundle loaded verbatim by dashboard |
|
||||
| **Desktop CLI** | |
|
||||
| `desktop/package.json` | `@hermes-relay/cli` package manifest — Node ≥21, one `hermes-relay` bin, pre-built dist |
|
||||
| `desktop/bin/hermes-relay.js` | Tiny shim: `import('../dist/cli.js').then(m => m.main())` + error surfacing |
|
||||
| `desktop/src/chatAttach.ts` | captureClipboardImage / captureScreenshot / readImageFile; ships base64 to server via `image.attach.bytes` RPC before next prompt.submit |
|
||||
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat` |
|
||||
| `desktop/src/commands/chat.ts` | REPL + one-shot + piped-stdin; `runOneTurn` returns `{promise, cancel}` for safe SIGINT; auto-wires `DesktopToolRouter` when consented |
|
||||
| `desktop/src/commands/shell.ts` | Pipes the `terminal` relay channel to raw-mode stdin/stdout; post-attach `exec hermes` 350ms after tmux settles; `Ctrl+A .` detach / `Ctrl+A k` kill / `Ctrl+A Ctrl+A` literal |
|
||||
| `desktop/src/commands/pair.ts` | Either 6-char code + `--remote`, or full v3 QR via `--pair-qr` — probes + picks endpoint, records role; `--grant-tools` (TTY prompt) / `--auto-grant-tools` (silent) stamp `toolsConsented` so `daemon` works without a `shell` round-trip |
|
||||
| `desktop/src/commands/tools.ts` | `tools.list` RPC → enabled/available toolsets; `--verbose` lists individual tools |
|
||||
| `desktop/src/commands/status.ts` | Local read of `~/.hermes/remote-sessions.json`; renders `grants:` + `expires:` + `route:`; `--json` redacts tokens, `--reveal-tokens` opts in |
|
||||
| `desktop/src/commands/devices.ts` | Server-side session management — `GET/DELETE/PATCH /sessions` via `fetch` over http(s)://host:port; `list` / `revoke <prefix>` / `extend <prefix> --ttl <s>` |
|
||||
| `desktop/src/banner.ts` | `buildConnectBanner({url, meta, endpointRole})` → "Connected via LAN (plain) — server 0.6.0"; `humanExpiry()` for TTL formatting |
|
||||
| `desktop/src/endpoint.ts` | `EndpointCandidate` / `EndpointRole` types + `displayLabel()` — mirrors Android `data/Endpoint.kt` |
|
||||
| `desktop/src/pairingQr.ts` | `decodePairingPayload` (JSON or base64), `payloadToCandidates` (v3 verbatim / v1–v2 synthesized), `probeCandidatesByPriority` (`Promise.any` within tier, `AbortSignal.any`, 4s timeout, 60s cache) |
|
||||
| `desktop/src/certPin.ts` | `extractSpkiSha256(der)` via `crypto.X509Certificate` + `publicKey.export({type:'spki'})`; `pinKey(url)`, `comparePins()`, `isSecureUrl()` |
|
||||
| `desktop/src/tools/router.ts` | `DesktopToolRouter.attach(relay)` — `onChannel('desktop')` dispatch under 30s `AbortController`; heartbeat enriched with host/platform/version/uptime_ms + sticky `last_error` for `desktop_health` |
|
||||
| `desktop/src/tools/handlerSet.ts` | Single source of truth for the desktop tool map — `DESKTOP_HANDLERS` + `DESKTOP_ADVERTISED_TOOLS`; consumed by `chat.ts` / `shell.ts` / `daemon.ts` so adding a tool is a one-file change |
|
||||
| `desktop/src/tools/consent.ts` | `ensureToolsConsent(url)` — stored per-URL in `toolsConsented`; TTY prompt; non-TTY fails closed |
|
||||
| `desktop/src/tools/handlers/fs.ts` | `readFileHandler` / `writeFileHandler` / `patchHandler` — strict unified-diff applier, no fuzz |
|
||||
| `desktop/src/tools/handlers/terminal.ts` | `bash -lc` / `cmd /c`, SIGKILL on timeout or abort, returns `{stdout, stderr, exit_code, duration_ms}` |
|
||||
| `desktop/src/tools/handlers/powershell.ts` | Spawns `pwsh`/`powershell` directly with `-Command -`, script piped via stdin — no cmd.exe quote-mangling; auto-picks pwsh > powershell |
|
||||
| `desktop/src/tools/handlers/process.ts` | `spawn_detached` (unref'd, returns pid+log_path), `list_processes` (tasklist /FO CSV — no /V to dodge window-title latency), `kill_process`, `find_pid_by_port` (netstat/lsof/ss) |
|
||||
| `desktop/src/tools/handlers/jobs.ts` | Job API — `~/.hermes/desktop-jobs/<id>/{stdout.log, stderr.log, meta.json}` is source of truth across daemon restarts; `taskkill /T` on Windows so build trees die fully |
|
||||
| `desktop/src/tools/handlers/transfer.ts` | `copy_directory` via `fs.cp`, `zip`/`unzip` via tar > zip > PowerShell probe, `checksum` streamed (sha256/sha1/md5) |
|
||||
| `desktop/src/tools/handlers/search.ts` | ripgrep with pure-Node fallback, skips `.git`/`node_modules`/`dist`/`.next`/`.cache` |
|
||||
| `desktop/src/renderer.ts` | Streams `message.delta` → stdout, tool events → decorated lines; NO_COLOR / --json / --quiet aware |
|
||||
| `desktop/src/pairing.ts` | readline-based 6-char prompt (`A-Z0-9`); headless mirror of TUI's Ink prompt; `validatePairingPayloadString` discriminated-union wrapper |
|
||||
| `desktop/src/credentials.ts` | Precedence: `--token` → `--pair-qr` (probe+pair) → `--code` → stored → prompt; returns `Credentials{sessionToken?, pairingCode?, resolvedEndpoint?}` |
|
||||
| `desktop/src/transport/RelayTransport.ts` | Fork of ui-tui's transport + reconnect state machine (`idle/connecting/connected/reconnecting`, exp backoff 1→30s, 5min on 429, gate re-check post-sleep) + pre-WS TLS probe for TOFU |
|
||||
| `desktop/src/remoteSessions.ts` | Same file path as TUI (`~/.hermes/remote-sessions.json`, 0600); schema widened with `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented`; `saveSession` back-compat overload |
|
||||
| `desktop/src/commands/daemon.ts` | Headless WSS + tool router for always-on access; JSON-line logs; fails closed on missing consent unless `--allow-tools` with explicit `--token` |
|
||||
| `desktop/src/commands/doctor.ts` | Local-only diagnostic report — version / binary path / PATH / sessions / daemon detection; `--json` for support-paste; omits tokens entirely |
|
||||
| `desktop/src/relayUrlPrompt.ts` | First-run URL fallback — `resolveFirstRunUrl()` auto-picks single stored session, numbered picker for multiple, welcome banner for zero; throws on non-interactive + ambiguous |
|
||||
| `desktop/src/version.ts` | Build-time-generated constant (`npm run gen:version` before every build) — Bun compiled binaries can't read package.json via `__dirname` so version is embedded at build |
|
||||
| `desktop/scripts/install.sh` / `install.ps1` | curl/iwr one-liner installers — download prebuilt Bun binary (no Node required), SHA256-verified, API-resolver for `latest` that includes prereleases, version-aware pre/post-install readback |
|
||||
| `desktop/scripts/uninstall.sh` / `uninstall.ps1` | 3-tier removal — default (binary + PATH), `--purge` (also wipes `~/.hermes/remote-sessions.json`), `--service` (stub for future service installers); Windows iex-safe env-var fallback |
|
||||
| `desktop/README.md` | User-facing install + usage reference |
|
||||
| **Desktop CLI — dev iteration** | |
|
||||
| `npm run smoke` (in `desktop/`) | Builds Windows binary + runs `--version` / `--help` / `doctor`, fails loud on zero-output. Local pre-flight before cutting any tag. |
|
||||
| `npm run gen:version` | Regenerates `src/version.ts` from `package.json`. Runs automatically before every `build` / `build:bin:*`. |
|
||||
| `release-desktop.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
|
||||
| **Server — Desktop tool routing (Phase B)** | |
|
||||
| `plugin/relay/channels/desktop.py` | Mirrors `bridge.py` — `desktop.command`/`desktop.response`/`desktop.status`, UUID-correlated futures, 30s timeout, single-client MVP, per-session advertised-tools set |
|
||||
| `plugin/tools/desktop_tool.py` | 24 `desktop_*` tools (fs/shell/powershell/process/jobs/transfer/health) — registers with `tools.registry` under `desktop` toolset; per-tool `check_fn` pings `/desktop/_ping?tool=<name>`; `desktop_health` is `_RELAY_ONLY` and pings `/desktop/health` so it works even when the client is wedged |
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
@@ -369,20 +289,16 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
- **Don't use Ktor for networking** — OkHttp for WebSocket
|
||||
- **Don't use plaintext WebSocket** — `wss://` only, even in development
|
||||
- **Don't put documentation in root** — long-form docs go in `docs/`
|
||||
- **Don't forget DEVLOG.md** — update it (record *what happened*)
|
||||
- **Don't bury follow-ups** — deferred work / known gaps go in `TODO.md`, never in DEVLOG or one-off code/doc comments
|
||||
- **Don't touch production / remote hosts** — automation and orchestrated agents must NEVER SSH into, deploy to, pull/restart/reconfigure, or push code to a live/remote Hermes host. Building, on-device testing, and server deployment are owner-driven (see Server Deployment). Stop at committing on your branch; surface "this needs a deploy/on-device check" rather than doing it.
|
||||
- **Don't forget DEVLOG.md** — update it
|
||||
|
||||
## MCP Tooling
|
||||
|
||||
Two MCP servers are configured for AI-assisted development. See `docs/mcp-tooling.md` for full reference.
|
||||
|
||||
|
||||
| Server | Layer | Requires |
|
||||
| ------------------- | --------------------------------------------------------------- | ---------------------------------------- |
|
||||
| Server | Layer | Requires |
|
||||
|--------|-------|----------|
|
||||
| `android-tools-mcp` | IDE/Build — Compose previews, Gradle, code search, Android docs | Android Studio running with project open |
|
||||
| `mobile-mcp` | Device/Runtime — tap, swipe, screenshot, app management | ADB + connected device/emulator |
|
||||
|
||||
| `mobile-mcp` | Device/Runtime — tap, swipe, screenshot, app management | ADB + connected device/emulator |
|
||||
|
||||
## Dev Workflow
|
||||
|
||||
@@ -412,8 +328,8 @@ Curls every bridge HTTP route via `localhost:8767`. Catches the silent-drop regr
|
||||
1. **Edit locally** — Windows checkout. Both plugin (`plugin/`) and app (`app/`) live here.
|
||||
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** — follow `AGENTS.md` and `RELEASE.md`; normal work PRs to `dev`.
|
||||
4. **Before pushing Kotlin changes** — run `./gradlew lint` locally. It's the exact task CI runs (see `.github/workflows/ci.yml` → `gradlew lint` fallback) and catches errors Android Studio's live inspections miss — e.g. `UnsafeOptInUsageError` with `kotlin.OptIn` vs `androidx.annotation.OptIn`, `FlowOperatorInvokedInComposition` (mapped flows inside Composables), Media3 `@UnstableApi` propagation. Lint is a hard blocker in CI: Build + Test show "skipping" until lint passes, and lint prints only the **first failure** before aborting — so CI iterations reveal errors one at a time while a single local lint run surfaces all of them.
|
||||
5. **Commit + push** — feature branch off `dev`, merged back to `dev` via PR. `main` is reserved for release merges.
|
||||
6. **Pull + restart on server** — see Server Deployment below.
|
||||
7. **Test on phone** — Bailey builds from Studio, installs to Samsung device, pairs via `/hermes-relay-pair`.
|
||||
|
||||
@@ -421,105 +337,83 @@ Curls every bridge HTTP route via `localhost:8767`. Catches the silent-drop regr
|
||||
|
||||
Server is a Linux box running hermes-agent with hermes-relay editable-installed (`pip install -e`). Sensitive details (IP, user, secrets) in `~/SYSTEM.md` on the server — not in this repo.
|
||||
|
||||
|
||||
| What | Where |
|
||||
| ------------------ | ------------------------------------------------------------------ |
|
||||
| hermes-agent repo | `~/.hermes/hermes-agent/` |
|
||||
| hermes-relay clone | `~/.hermes/hermes-relay/` |
|
||||
| Plugin symlink | `~/.hermes/plugins/hermes-relay` → `~/.hermes/hermes-relay/plugin` |
|
||||
| Config | `~/.hermes/config.yaml` + `~/.hermes/.env` |
|
||||
| Relay log | `journalctl --user -u hermes-relay -f` |
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| hermes-agent repo | `~/.hermes/hermes-agent/` |
|
||||
| hermes-relay clone | `~/.hermes/hermes-relay/` |
|
||||
| Plugin symlink | `~/.hermes/plugins/hermes-relay` → `~/.hermes/hermes-relay/plugin` |
|
||||
| Config | `~/.hermes/config.yaml` + `~/.hermes/.env` |
|
||||
| Relay log | `journalctl --user -u hermes-relay -f` |
|
||||
|
||||
**Update:** `hermes-relay-update` (idempotent, re-fetches install.sh). Or manually: `git pull --ff-only && systemctl --user restart hermes-relay`.
|
||||
|
||||
**Compat hook:** `hermes relay compat status/install/remove` manages only the
|
||||
|
||||
optional `hermes_relay_bootstrap.pth` startup hook. New installs load the
|
||||
|
||||
plugin-owned bootstrap from `plugin/hermes_relay_bootstrap/`; the repo-root
|
||||
|
||||
package is only a legacy import shim. Vanilla Hermes chat, Manage, and dashboard voice
|
||||
|
||||
must not depend on this hook.
|
||||
|
||||
**Key conventions:**
|
||||
|
||||
- Phone pairing **survives** relay restart — `SessionManager` persists sessions to `~/.hermes/hermes-relay-sessions.json` (`server.py:88-90`, `persistence_path` from `RelayConfig.from_env`); a trusted-device refresh token recovers a lost/revoked/reset session without a new QR scan. (Only the in-memory *live-connection presence* clears on restart; the phone reconnects automatically.)
|
||||
- Phone re-pairs after each relay restart (SessionManager is in-memory; wiped on restart)
|
||||
- Use `python -m unittest` not `pytest` — conftest imports `responses` which may not be installed
|
||||
- `_env_bootstrap.py` loads `~/.hermes/.env` on every relay start — no stale API keys
|
||||
|
||||
### Where Python vs. Kotlin changes land
|
||||
|
||||
|
||||
| Change type | Who restarts? | Command |
|
||||
| ------------------------------------ | ------------------------ | -------------------------------------------------- |
|
||||
| Plugin tool (`android_tool.py` etc.) | `hermes-gateway.service` | `systemctl --user restart hermes-gateway` |
|
||||
| Relay code (`plugin/relay/*.py`) | `hermes-relay.service` | `systemctl --user restart hermes-relay` |
|
||||
| Pair CLI / skill files | — | No restart — fresh process / scanned on invocation |
|
||||
| Android app | Bailey (Studio) | Studio run button |
|
||||
|
||||
| Change type | Who restarts? | Command |
|
||||
|---|---|---|
|
||||
| Plugin tool (`android_tool.py` etc.) | `hermes-gateway.service` | `systemctl --user restart hermes-gateway` |
|
||||
| Relay code (`plugin/relay/*.py`) | `hermes-relay.service` | `systemctl --user restart hermes-relay` |
|
||||
| Pair CLI / skill files | — | No restart — fresh process / scanned on invocation |
|
||||
| Android app | Bailey (Studio) | Studio run button |
|
||||
|
||||
### Release Process
|
||||
|
||||
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.
|
||||
See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
|
||||
- **Version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`)
|
||||
- **Bump atomically:** `bash scripts/bump-version.sh <new-version>` — updates all three sources
|
||||
- **`appVersionCode` is monotonic** — always increment across prereleases
|
||||
- **Cut a release:** bump → commit → `git tag vMAJOR.MINOR.PATCH` → push tag → CI builds + GitHub Release
|
||||
- **Required secrets:** `HERMES_KEYSTORE_BASE64`, `HERMES_KEYSTORE_PASSWORD`, `HERMES_KEY_ALIAS`, `HERMES_KEY_PASSWORD`
|
||||
|
||||
## Integration Points
|
||||
|
||||
|
||||
| Surface | Endpoint | Notes |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Chat (gateway) | Dashboard `POST /api/auth/ws-ticket` -> WS `/api/ws` | Vanilla Hermes dashboard/tui_gateway path; live thinking/reasoning; requires dashboard auth |
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; async run-control path |
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | Native upstream session-persisted SSE; preferred when capability probe finds it |
|
||||
| Chat (compat) | `POST /v1/chat/completions` (stream=true) | Inline tool annotations only |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback retired |
|
||||
| Manage | Dashboard `/api/status`, `/api/auth/me`, `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*` | Vanilla Hermes dashboard surface; do not proxy through Relay |
|
||||
| Vanilla Hermes voice | Dashboard `POST /api/audio/transcribe`, `POST /api/audio/speak` | Vanilla Hermes no-plugin voice; uses dashboard session from Manage |
|
||||
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim; accepts optional `endpoints` for multi-endpoint QRs |
|
||||
| Pairing (multi-endpoint) | QR `endpoints` array (ADR 24) | `hermes: 3` schema; ordered `lan`/`tailscale`/`public`/... candidates; phone re-probes on network change |
|
||||
| Pairing auth | WSS `auth.ok` payload | Includes `expires_at`, `grants`, `transport_hint` |
|
||||
| Tailscale Serve (ADR 25) | `hermes-relay-tailscale enable|disable|status` CLI | Fronts loopback `:8767` with `tailscale serve --bg --https=<port>`; auto-retires on upstream PR #9295 |
|
||||
| Inbound media (token) | `GET /media/{token}` | Bearer auth; 24h TTL |
|
||||
| Inbound media (path) | `GET /media/by-path?path=<abs>` | Permissive by default; `RELAY_MEDIA_STRICT_SANDBOX=1` to restrict |
|
||||
| Session management | `GET /sessions`, `DELETE /sessions/{prefix}`, `PATCH /sessions/{prefix}` | List/revoke/extend; RelayHttpClient |
|
||||
| Voice transcribe | `POST /voice/transcribe` | multipart/form-data; bearer auth |
|
||||
| Voice synthesize | `POST /voice/synthesize` | JSON → audio/mpeg; max 5000 chars |
|
||||
| Voice config | `GET /voice/config` | Returns current tts/stt provider info |
|
||||
| Plugin diagnostics | `hermes relay doctor --json` | Reports upstream route reachability, Relay loopback state, plugin layout, and legacy bootstrap state |
|
||||
| Compat hook lifecycle | `hermes relay compat status/install/remove` | Optional legacy API compatibility hook; not required for the standard path |
|
||||
| Notifications | `GET /notifications/recent?limit=N` | Loopback callers skip bearer |
|
||||
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
|
||||
| Capabilities | `GET /v1/capabilities` plus targeted `HEAD` probes | Prefer capabilities when present; HEAD probes keep mixed-version fallback working |
|
||||
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
|
||||
| Desktop CLI (terminal channel) | WSS `terminal.attach` / `terminal.input` / `terminal.output` / `terminal.resize` / `terminal.detached` | Existing channel (shared with Android). CLI `shell` subcommand attaches, injects `clear; exec hermes\n` 350ms after ack, pipes raw bytes. `Ctrl+A .` detaches (tmux preserved), `Ctrl+A k` kills. |
|
||||
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
|
||||
| Desktop CLI devices | HTTP `GET/DELETE/PATCH /sessions` on the relay's same port | Wrapped by `hermes-relay devices list |
|
||||
| Desktop tool routing (Phase B) | WSS `desktop.command` (s→c) + `desktop.response` (c→s) + `desktop.status` (c→s heartbeat) | New channel. Hermes calls `desktop_read_file(path)` → Python handler POSTs to `/desktop/desktop_read_file` → relay forwards over `desktop.command` → Node client's `DesktopToolRouter` runs the handler locally → response bubbles back. Mirror of Android's `bridge.command` pattern. |
|
||||
| Desktop tool check_fn | HTTP `GET /desktop/_ping?tool=<name>` | Returns 200 if a client is connected AND advertises this tool; 503 otherwise. Hermes uses this to fail the tool quickly when no desktop client is live, instead of waiting 30s for the dispatch timeout. |
|
||||
| Desktop health | HTTP `GET /desktop/health` | Returns full status snapshot — connected/host/platform/version/pid/uptime/advertised_tools/last_error/recent_commands. Loopback-only. Backs the `desktop_health` agent tool, which intentionally does NOT round-trip through the client so it remains callable when other tools are wedged. |
|
||||
|
||||
| Surface | Endpoint | Notes |
|
||||
|---------|----------|-------|
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; preferred |
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | No live tool events; reloads history on stream complete |
|
||||
| Chat (compat) | `POST /v1/chat/completions` (stream=true) | Inline tool annotations only |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Non-standard; bootstrap or fork |
|
||||
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim; accepts optional `endpoints` for multi-endpoint QRs |
|
||||
| Pairing (multi-endpoint) | QR `endpoints` array (ADR 24) | `hermes: 3` schema; ordered `lan`/`tailscale`/`public`/... candidates; phone re-probes on network change |
|
||||
| Pairing auth | WSS `auth.ok` payload | Includes `expires_at`, `grants`, `transport_hint` |
|
||||
| Tailscale Serve (ADR 25) | `hermes-relay-tailscale enable\|disable\|status` CLI | Fronts loopback `:8767` with `tailscale serve --bg --https=<port>`; auto-retires on upstream PR #9295 |
|
||||
| Inbound media (token) | `GET /media/{token}` | Bearer auth; 24h TTL |
|
||||
| Inbound media (path) | `GET /media/by-path?path=<abs>` | Permissive by default; `RELAY_MEDIA_STRICT_SANDBOX=1` to restrict |
|
||||
| Session management | `GET /sessions`, `DELETE /sessions/{prefix}`, `PATCH /sessions/{prefix}` | List/revoke/extend; RelayHttpClient |
|
||||
| Voice transcribe | `POST /voice/transcribe` | multipart/form-data; bearer auth |
|
||||
| Voice synthesize | `POST /voice/synthesize` | JSON → audio/mpeg; max 5000 chars |
|
||||
| Voice config | `GET /voice/config` | Returns current tts/stt provider info |
|
||||
| Notifications | `GET /notifications/recent?limit=N` | Loopback callers skip bearer |
|
||||
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
|
||||
| Capabilities | `HEAD /api/sessions`, `HEAD /v1/runs`, etc. | HEAD avoids CORS 403 on OPTIONS preflight |
|
||||
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
|
||||
| Desktop CLI (terminal channel) | WSS `terminal.attach` / `terminal.input` / `terminal.output` / `terminal.resize` / `terminal.detached` | Existing channel (shared with Android). CLI `shell` subcommand attaches, injects `clear; exec hermes\n` 350ms after ack, pipes raw bytes. `Ctrl+A .` detaches (tmux preserved), `Ctrl+A k` kills. |
|
||||
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
|
||||
| Desktop CLI devices | HTTP `GET/DELETE/PATCH /sessions` on the relay's same port | Wrapped by `hermes-relay devices list | revoke <prefix> | extend <prefix> --ttl <s>`; bearer token from stored session; token prefix only (never full token) |
|
||||
| Desktop tool routing (Phase B) | WSS `desktop.command` (s→c) + `desktop.response` (c→s) + `desktop.status` (c→s heartbeat) | New channel. Hermes calls `desktop_read_file(path)` → Python handler POSTs to `/desktop/desktop_read_file` → relay forwards over `desktop.command` → Node client's `DesktopToolRouter` runs the handler locally → response bubbles back. Mirror of Android's `bridge.command` pattern. |
|
||||
| Desktop tool check_fn | HTTP `GET /desktop/_ping?tool=<name>` | Returns 200 if a client is connected AND advertises this tool; 503 otherwise. Hermes uses this to fail the tool quickly when no desktop client is live, instead of waiting 30s for the dispatch timeout. |
|
||||
| Desktop health | HTTP `GET /desktop/health` | Returns full status snapshot — connected/host/platform/version/pid/uptime/advertised_tools/last_error/recent_commands. Loopback-only. Backs the `desktop_health` agent tool, which intentionally does NOT round-trip through the client so it remains callable when other tools are wedged. |
|
||||
|
||||
## Upstream References
|
||||
|
||||
|
||||
| Topic | Upstream File |
|
||||
| -------------------------- | ----------------------------------------------------------------------------- |
|
||||
| API endpoints | `gateway/platforms/api_server.py` — all registered HTTP routes |
|
||||
| Platform adapter interface | `gateway/platforms/base.py` — `BasePlatformAdapter` abstract class |
|
||||
| Adding a platform | `gateway/platforms/ADDING_A_PLATFORM.md` — 16-step checklist |
|
||||
| Platform registration | `gateway/run.py` → `_create_adapter()`, `gateway/config.py` → `Platform` enum |
|
||||
| Channel directory | `gateway/channel_directory.py` — how platforms/channels are enumerated |
|
||||
| Send message routing | `tools/send_message_tool.py` → `platform_map` dict |
|
||||
| SSE streaming (runs) | `gateway/platforms/api_server.py` → runs endpoint, `_on_tool_progress` |
|
||||
|
||||
| Topic | Upstream File |
|
||||
|-------|--------------|
|
||||
| API endpoints | `gateway/platforms/api_server.py` — all registered HTTP routes |
|
||||
| Platform adapter interface | `gateway/platforms/base.py` — `BasePlatformAdapter` abstract class |
|
||||
| Adding a platform | `gateway/platforms/ADDING_A_PLATFORM.md` — 16-step checklist |
|
||||
| Platform registration | `gateway/run.py` → `_create_adapter()`, `gateway/config.py` → `Platform` enum |
|
||||
| Channel directory | `gateway/channel_directory.py` — how platforms/channels are enumerated |
|
||||
| Send message routing | `tools/send_message_tool.py` → `platform_map` dict |
|
||||
| SSE streaming (runs) | `gateway/platforms/api_server.py` → runs endpoint, `_on_tool_progress` |
|
||||
|
||||
## Related Projects
|
||||
|
||||
- [**hermes-agent**](https://github.com/NousResearch/hermes-agent) — the agent platform (gateway, WebAPI, plugin system)
|
||||
- [**android-tools-mcp**](https://github.com/Codename-11/android-tools-mcp) — our fork of Android Studio MCP bridge (Compose previews, Gradle, docs)
|
||||
- [**mobile-mcp**](https://github.com/mobile-next/mobile-mcp) — device control MCP server (ADB, tap/swipe, screenshots)
|
||||
|
||||
- **[hermes-agent](https://github.com/NousResearch/hermes-agent)** — the agent platform (gateway, WebAPI, plugin system)
|
||||
- **[android-tools-mcp](https://github.com/Codename-11/android-tools-mcp)** — our fork of Android Studio MCP bridge (Compose previews, Gradle, docs)
|
||||
- **[mobile-mcp](https://github.com/mobile-next/mobile-mcp)** — device control MCP server (ADB, tap/swipe, screenshots)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Hermes-Relay CLI+UI v__VERSION__
|
||||
|
||||
**Release Date:** 2026-08-25
|
||||
|
||||
This beta makes the Desktop connector resilient through Relay interruptions,
|
||||
aligns Windows computer control with current CUA Driver releases, adds a native
|
||||
Linux ARM64 build, and hardens installation and update discovery.
|
||||
|
||||
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64; the management UI is Windows-only.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
|
||||
- **Linux ARM64 is a first-class release target.** The one-line installer,
|
||||
updater, checksums, and release artifacts now cover both Linux x64 and arm64.
|
||||
- **The public site shows the real Windows CLI UI.** Deterministic screenshots
|
||||
cover connections, host access, activity, computer control, and updates.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Public naming is aligned.** Releases use `Hermes-Relay CLI+UI` while the
|
||||
beta keeps its existing `desktop-v*` tag and updater contract.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The daemon reconnects instead of exiting after an interrupted Relay socket.** Relay restarts and repeated transient replacement failures stay on bounded automatic backoff, and terminal failures persist an accurate stopped reason for the UI.
|
||||
- **Oversized desktop-tool output no longer closes the shared connection.** PowerShell output and every serialized desktop response stay inside the Relay WebSocket budget.
|
||||
- **Current CUA Driver releases remain compatible by contract.** Driver 0.20 and newer are accepted when their manifest and required tools match Hermes, and Windows uses the manifest-declared direct standard-mode runtime instead of a stale machine-wide daemon.
|
||||
- **Install and update discovery paginates the multi-surface release history.** Desktop releases remain discoverable after more Android and Server releases, Windows cooperative updates clean their released backup, and unsigned installers retain the normal SmartScreen warning.
|
||||
|
||||
## Install
|
||||
|
||||
**Windows CLI + management tray (PowerShell):**
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Windows CLI only:**
|
||||
|
||||
```powershell
|
||||
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**macOS / Linux CLI:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Pin this release with `HERMES_RELAY_VERSION=__TAG__`.
|
||||
|
||||
## Verify
|
||||
|
||||
```text
|
||||
hermes-relay --version
|
||||
hermes-relay hosts list --json
|
||||
hermes-relay daemon start
|
||||
hermes-relay daemon status --json
|
||||
hermes-relay computer-use status --json
|
||||
```
|
||||
|
||||
On Windows, click the Hermes-Relay CLI UI notification-area icon to open the management popup directly above it.
|
||||
|
||||
See the [CLI and tray guide](https://hermes-relay.dev/docs/desktop/) for installation, access modes, grants, and troubleshooting.
|
||||
@@ -1,78 +0,0 @@
|
||||
# Code of Conduct
|
||||
|
||||
Hermes-Relay adopts the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/),
|
||||
version 2.1, as its code of conduct. The canonical, full text lives at that
|
||||
link; the summary below states what it means for this project.
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and maintainers pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity and
|
||||
orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Behavior that helps create a positive environment includes:
|
||||
|
||||
- Showing empathy and kindness toward others.
|
||||
- Being respectful of differing opinions, viewpoints, and experiences.
|
||||
- Giving and gracefully accepting constructive feedback.
|
||||
- Taking responsibility, apologizing to those affected by our mistakes, and
|
||||
learning from the experience.
|
||||
- Focusing on what is best for the overall community, not just ourselves.
|
||||
|
||||
Behavior that is not acceptable includes:
|
||||
|
||||
- Harassment, intimidation, or discrimination in any form.
|
||||
- Personal or political attacks, insults, or derogatory comments.
|
||||
- Unwelcome advances or attention, including of a romantic or sexual nature.
|
||||
- Publishing others' private information (such as a physical or email address)
|
||||
without their explicit permission.
|
||||
- Other conduct that could reasonably be considered inappropriate in a
|
||||
professional setting.
|
||||
|
||||
For the complete, canonical list of standards and examples, see the
|
||||
[Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing these standards
|
||||
and will take appropriate and fair corrective action in response to any behavior
|
||||
they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Maintainers have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, issues, and other contributions that are not aligned
|
||||
with this Code of Conduct, and will communicate reasons for moderation decisions
|
||||
when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all project spaces — the repository, issues,
|
||||
pull requests, discussions, and the documentation site — and also applies when
|
||||
an individual is officially representing the project in public spaces.
|
||||
|
||||
## Reporting & Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported privately to the maintainers at **`conduct@codename-11.dev`**. All
|
||||
complaints will be reviewed and investigated promptly and fairly. Maintainers
|
||||
are obligated to respect the privacy and security of the reporter of any
|
||||
incident.
|
||||
|
||||
For the **Enforcement Guidelines** (the tiered Correction → Warning →
|
||||
Temporary Ban → Permanent Ban ladder maintainers use to determine consequences),
|
||||
see the corresponding section of the
|
||||
[Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/#enforcement-guidelines).
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the
|
||||
[Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
|
||||
@@ -17,63 +17,17 @@ That's it — no extra setup or credentials required for a debug build.
|
||||
Helper scripts for common development tasks:
|
||||
|
||||
```bash
|
||||
scripts/dev.bat build # Build the sideload debug APK
|
||||
scripts/dev.bat compile # Compile sideload Kotlin only
|
||||
scripts/dev.bat test-one "com.hermesandroid.relay.SomeTest" # Run one test class
|
||||
scripts/dev.bat install-fast # Build arm64 only + install + launch
|
||||
scripts/dev.bat build # Build debug APK
|
||||
scripts/dev.bat release # Build signed release APK
|
||||
scripts/dev.bat bundle # Build release AAB for Google Play
|
||||
scripts/dev.bat run # Build sideload + install + launch + logcat
|
||||
scripts/dev.bat test # Run sideload debug unit tests
|
||||
scripts/dev.bat run # Build + install + launch + logcat
|
||||
scripts/dev.bat test # Run unit tests
|
||||
scripts/dev.bat version # Show current version
|
||||
scripts/dev.bat relay # Start relay server (dev, no TLS)
|
||||
```
|
||||
|
||||
### Review bundles
|
||||
|
||||
Maintainers can produce a matched Android + Relay handoff for one pull request
|
||||
without cutting a release. Apply the `review-candidate` label to an open PR
|
||||
targeting `dev`. The short-lived artifact contains a side-by-side
|
||||
**HR Candidate** APK, Relay packages/source from the same exact PR commit,
|
||||
provenance, checksums, and install/rollback guidance. While the label remains
|
||||
applied, a new PR head commit automatically replaces any in-progress build with
|
||||
a bundle for the new head.
|
||||
For a first-time fork contributor, GitHub may hold the first run for explicit
|
||||
maintainer approval before any untrusted code executes.
|
||||
When an opted-in candidate run completes, a separate trusted reporter creates or
|
||||
updates one PR comment with the exact source SHA, artifact link, expiry, and
|
||||
concise install and rollback guidance. Skipped workflow shells for unlabeled PRs
|
||||
do not create comments.
|
||||
|
||||
Review bundles never bump versions, create tags, upload to Play, or replace the
|
||||
stable Android app. Relay review still requires a staging Hermes instance or an
|
||||
explicit immutable snapshot/rollback window because two Relay plugins cannot
|
||||
own the same tools and hooks in one Hermes process. See
|
||||
[Review builds and release candidates](docs/review-candidates.md).
|
||||
|
||||
Linux/macOS equivalent lives at `scripts/dev.sh`.
|
||||
|
||||
### Fast Android iteration
|
||||
|
||||
Gradle's daemon, local build cache, configuration cache, and parallel task
|
||||
execution are enabled for repeat local builds. Keep the same Gradle JVM
|
||||
configuration between invocations and do not add `--no-daemon` to normal dev
|
||||
commands; a different heap or Java home starts a separate daemon and discards
|
||||
the warm-process benefit.
|
||||
|
||||
Use the narrowest command that proves the change:
|
||||
|
||||
1. `scripts/dev.bat compile` for a Kotlin compile check.
|
||||
2. `scripts/dev.bat test-one "<fully-qualified-class-or-pattern>"` for a focused regression.
|
||||
3. `scripts/dev.bat install-fast` when the result must run on the connected
|
||||
arm64 phone. This passes `-Phermes.devAbi=arm64-v8a`, avoiding the x86,
|
||||
x86_64, and armeabi-v7a native libraries in the local APK.
|
||||
4. `scripts/dev.bat prepush` before pushing Android work.
|
||||
|
||||
`install-fast` is intentionally phone-specific. Use `install` for a universal
|
||||
sideload debug APK or when the target ABI is not arm64. Release builds remain
|
||||
universal and are unaffected unless `-Phermes.devAbi` is explicitly supplied.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
@@ -97,12 +51,12 @@ The legacy `relay_server/` directory is a thin compatibility shim around `plugin
|
||||
|
||||
| Component | Stack |
|
||||
|-----------|-------|
|
||||
| **Android App** | Kotlin 2.4, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Relay Server** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization |
|
||||
| **Build** | AGP 9.3.1, Gradle 9.6.1, JVM toolchain 17 |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 |
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, signed APK artifacts) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 36 |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
|
||||
|
||||
## Running the Relay Locally
|
||||
|
||||
@@ -138,112 +92,18 @@ 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: `main` + `dev`.** Feature branches — `feature/<name>`,
|
||||
`fix/<name>`, `docs/<name>`, `chore/<name>` — branch from current `origin/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.
|
||||
**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`.
|
||||
|
||||
`origin/dev` is the canonical integration ref. Keep local `dev` as a clean,
|
||||
fast-forward-only mirror and create each task in its own branch/worktree from the
|
||||
current `origin/dev`. Do not accumulate unpublished commits on local `dev`. If a
|
||||
maintainer needs to combine several reviewed branches, use a temporary
|
||||
`integration/<batch>` branch and merge that branch through a normal PR to `dev`.
|
||||
See [docs/worktree-workflow.md](docs/worktree-workflow.md) for the concurrent
|
||||
worktree procedure.
|
||||
|
||||
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 use a dedicated branch
|
||||
and PR into `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
|
||||
|
||||
A valuable pull request can become unsafe to merge when `dev` has materially
|
||||
changed around it. Maintainers may create a replacement **salvage PR** from the
|
||||
current `dev` instead of resolving a stale branch by choosing whole conflict
|
||||
sides.
|
||||
|
||||
A salvage PR must:
|
||||
|
||||
- Link the original PR and contributor in its title or opening summary.
|
||||
- Recover only the intended feature; unrelated fork, release, signing, and
|
||||
generated migration changes stay out.
|
||||
- Preserve the original commit author when a substantive commit can be safely
|
||||
cherry-picked.
|
||||
- Use a verified `Co-authored-by: Name <email>` trailer when the implementation
|
||||
must be reconstructed or substantially rewritten.
|
||||
- Include a `Lineage` section listing source and superseded PRs, plus a concise
|
||||
explanation of integration changes made for current `dev`.
|
||||
- Run current verification rather than relying on checks from the stale branch.
|
||||
- Leave a comment linking the replacement before the source PR is closed.
|
||||
|
||||
The maintainer remains the committer for integration commits. The original
|
||||
contributor remains the author or co-author of the recovered work. Do not guess
|
||||
an email address: use the source commit's verified address or ask the
|
||||
contributor.
|
||||
|
||||
## Localization contributions
|
||||
|
||||
English resources are canonical and Android locale catalogs must retain exact
|
||||
resource and format-argument parity. Read [docs/localization.md](docs/localization.md)
|
||||
before changing user-facing strings or adding a language.
|
||||
|
||||
Translation PRs should cover one locale or one clear catalog refresh. They must
|
||||
not include custom APK publishing, signing configuration, version bumps, or
|
||||
fork-specific branding. Run:
|
||||
|
||||
```bash
|
||||
python scripts/check-android-locales.py
|
||||
./gradlew lint
|
||||
```
|
||||
|
||||
Update `docs/localization-status.json` with the actual review level. AI-assisted
|
||||
translations may ship as `ai-translated`; do not claim fluent review unless a
|
||||
review reference is recorded. Focused correction PRs from fluent contributors
|
||||
are the canonical way to improve wording and can advance a locale to
|
||||
`community-reviewed` or `verified` under `docs/translation-playbook.md`.
|
||||
Translated READMEs use separate `README.<locale>.md` files; `README.md` remains
|
||||
the canonical project description. User docs may be added incrementally under
|
||||
`user-docs/<locale>/`, with links back to canonical English reference material.
|
||||
|
||||
## Changelog & writing conventions
|
||||
|
||||
This is a **public repo** — `CHANGELOG.md`, `DEVLOG.md`, the README, and everything under `docs/` ship publicly. Keep them clean:
|
||||
|
||||
- **`CHANGELOG.md`** follows [Keep a Changelog](https://keepachangelog.com/) (Added / Changed / Fixed). Append your change to the `## [Unreleased]` block in the PR. Entries can carry detail while they accumulate, but at release-prep the version block is **condensed to crisp public bullets** (1–2 lines each) — the deep "how we debugged it" narrative belongs in commit messages and `DEVLOG.md`, not the public changelog.
|
||||
- **`DEVLOG.md`** is a factual engineering log — what changed, why, and how it was verified. Keep it depersonalized and third-person; it's a record, not a diary.
|
||||
- **No non-public wording anywhere committed:** no personal names (attribute impersonally — identity lives in git history), no real server hostnames/IPs or internal deployment names, no AI/assistant process self-narration, no fork/branch plumbing in user-facing notes. Generic example IPs in setup docs are fine.
|
||||
|
||||
Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/play-store-listing.md`) are theme-framed and user-facing; see [RELEASE.md](RELEASE.md) §2 "Scrub for public distribution" for the full checklist.
|
||||
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a surface-specific release PR merges `dev` → `main` with `--no-ff`. Tags are cut from `main` after the merge: `android-vX.Y.Z`, `server-vX.Y.Z`, or `desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Android pre-push gate:** `scripts\dev.bat prepush` on Windows or
|
||||
`./scripts/dev.sh prepush` on macOS/Linux. This runs the Android repository
|
||||
checks, Google Play debug lint, and the same focused unit-test shard used by
|
||||
CI in one cached Gradle invocation. Run it before pushing Android PR updates
|
||||
to catch common hosted failures without waiting for another full Actions
|
||||
cycle; hosted CI remains the exhaustive all-variant gate.
|
||||
- **Focused Android unit test:** `scripts/dev.bat test-one "<fully-qualified-class-or-pattern>"`
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs the sideload debug JUnit + MockK + Compose suite)
|
||||
- **Gateway contract lab:** [`docs/gateway-contract-testing.md`](docs/gateway-contract-testing.md)
|
||||
covers the on-demand vanilla-Gateway fixture, Android instrumentation,
|
||||
upstream conformance, and physical-device ADB certification. No contract or
|
||||
device lane is scheduled automatically.
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
||||
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
||||
|
||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
|
||||
Superseded Android runs on `dev` and PR refs are canceled automatically; `main`
|
||||
runs are never canceled because each release-branch commit must complete its
|
||||
independent validation.
|
||||
|
||||
## Questions?
|
||||
|
||||
- **Architecture context?** [docs/spec.md](docs/spec.md) covers protocols, UI layouts, and the channel model. [docs/decisions.md](docs/decisions.md) covers the forks in the road and why we picked what we did.
|
||||
- Need help or want to explore an early idea? Start a [GitHub Discussion](https://github.com/Codename-11/hermes-relay/discussions).
|
||||
- Found a reproducible bug or have a specific, actionable feature request? [Open an issue](https://github.com/Codename-11/hermes-relay/issues/new).
|
||||
- **Something unclear?** [Open an issue](https://github.com/Codename-11/hermes-relay/issues/new) — we read every one, and "this contributing guide is confusing" is a completely fair bug report.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# GEMINI.md
|
||||
|
||||
Agent instructions for **Hermes-Relay**. This file exists so Gemini CLI (which
|
||||
does not read `AGENTS.md` natively) picks up the project's guidance.
|
||||
|
||||
**Read [AGENTS.md](AGENTS.md) — it is the single source of truth** for every
|
||||
coding agent: the entry point, the non-negotiables (standard-path-is-vanilla-
|
||||
upstream, verify-endpoints, Conventional Commits + `main`/`dev` branching, the
|
||||
per-language stack rules), and the public-repo writing hygiene. It links on to
|
||||
`CLAUDE.md` for the deep reference (architecture, upstream Hermes API, repo
|
||||
layout, code style, the dev loop, and the Key Files map).
|
||||
|
||||
Do not restate rules here — keep them in `AGENTS.md` so they can't drift.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Hermes-Relay Plugin v__VERSION__
|
||||
|
||||
**Release Date:** August 25, 2026
|
||||
|
||||
## Summary
|
||||
|
||||
This release adds a provider-neutral account-usage surface for Android and Dashboard clients. Relay resolves Codex credential pools, structured Nous balances, and OpenCode Go windows on the Hermes host without returning provider credentials.
|
||||
|
||||
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
|
||||
## Added
|
||||
|
||||
- **Provider-neutral usage snapshots.** Authenticated Dashboard clients can resolve the exact active Codex pool entry, Nous balances, and OpenCode Go account windows through one normalized schema.
|
||||
- **Bounded paired-client fallback.** Operators may explicitly enable the Relay usage route for paired standalone clients while credentials remain host-side.
|
||||
|
||||
## Changed
|
||||
|
||||
- **Usage capabilities are explicit.** Responses identify Relay-enhanced credential pools, structured balances, and provider adapters instead of implying unsupported upstream data.
|
||||
- **Public product naming is aligned.** Releases use `Hermes-Relay Plugin` while retaining the `server-v*` tag and installation contract.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Custom Hermes homes resolve correctly.** Relay profile discovery and session persistence follow `HERMES_HOME` by default while preserving the explicit `RELAY_HERMES_CONFIG` override.
|
||||
|
||||
## Install / update
|
||||
|
||||
# Native upstream plugin path:
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
|
||||
# Classic install / update on a systemd host:
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/server-v__VERSION__/install.sh | bash
|
||||
# or, if already installed:
|
||||
hermes-relay-update
|
||||
|
||||
## Verify
|
||||
|
||||
hermes relay doctor
|
||||
python scripts/check-plugin-version-sync.py --expect __VERSION__
|
||||
|
||||
---
|
||||
|
||||
Tag prefixes: Android releases use android-v*, Plugin releases use server-v*, and CLI+UI releases use desktop-v*.
|
||||
@@ -1,275 +1,144 @@
|
||||
<p align="center">
|
||||
<img src="assets/play-store-feature-1024x500.png" alt="Hermes-Relay — your Hermes agent, in your pocket" width="800">
|
||||
<img src="assets/logo.svg" alt="Hermes-Relay" width="120">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Runs on your machine. Lives on your devices.</strong><br>
|
||||
A native Android companion for your <a href="https://github.com/NousResearch/hermes-agent">Hermes agent</a> — streaming chat, hands-free voice,
|
||||
and full agent management. Plus a single-binary CLI that gives the agent hands on any machine you pair.
|
||||
</p>
|
||||
<h1 align="center">Hermes-Relay</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
|
||||
<strong>One Hermes agent. Two ways to use it.</strong><br>
|
||||
A native Android remote-control app for your phone, plus a desktop CLI that lets you<br>
|
||||
use a server-deployed Hermes from your laptop as if it were running locally.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT"></a>
|
||||
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Android-8.0%2B-3DDC84.svg?logo=android&logoColor=white" alt="Android 8.0+"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml/badge.svg" alt="Android CI"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases"><img src="https://img.shields.io/github/v/release/Codename-11/hermes-relay?filter=android-v*&label=release&color=8B5CF6" alt="Latest release"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/CLI-beta-756cff.svg" alt="CLI (beta)"></a>
|
||||
<a href="https://developer.android.com"><img src="https://img.shields.io/badge/Surface%201-Android-green.svg" alt="Android"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/Surface%202-Desktop%20CLI-orange.svg" alt="Desktop CLI"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Min%20SDK-26-brightgreen.svg" alt="Min SDK 26"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a><br>
|
||||
<a href="https://hermes-relay.dev/docs/">Documentation</a> ·
|
||||
<a href="https://codename-11.github.io/hermes-relay/">Documentation</a> ·
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases">Releases</a> ·
|
||||
<a href="https://github.com/Codename-11/hermes-relay/discussions">Discussions</a> ·
|
||||
<a href="CHANGELOG.md">Changelog</a> ·
|
||||
<a href="https://hermes-agent.nousresearch.com">Hermes Agent</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## What it is
|
||||
|
||||
Hermes-Relay puts your [Hermes agent](https://github.com/NousResearch/hermes-agent) on the devices you actually carry. The brain stays on your own machine — Hermes-Relay is how you reach it.
|
||||
|
||||
- **📱 Android app** — streaming chat, hands-free voice, native plugin pages, and the full Hermes dashboard (models, keys, skills, profiles), rebuilt native. Add a floating Petdex companion or optionally make Hermes your Android assistant; sideload builds can also let the agent read and act on your screen.
|
||||
- **⌨️ Hermes-Relay CLI** *(beta)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
|
||||
|
||||
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough for the upstream standard path: chat, management, voice, Petdex, and ordinary installed-plugin pages. The Hermes-Relay plugin is optional for that base but encouraged for the complete current experience: Terminal/TUI, notifications, media, desktop tools, enhanced voice, Relay sessions, page drafts, and optional Device Control. Hermes-Relay prefers compatible upstream surfaces as they become available instead of keeping duplicate extension paths. **Connect Hermes first, then grant Hermes-Relay separately; the same one-time invite contract pairs Android or the Desktop CLI.**
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/architecture-homepage.png" alt="How Hermes-Relay connects — upstream Hermes owns Chat, Manage, and standard Voice; the encouraged Relay extension fills current gaps for Terminal, notifications, media, enhanced voice, sessions, desktop tools, and optional Device Control." width="900">
|
||||
<video src="https://github.com/Codename-11/hermes-relay/raw/main/assets/chat_demo.mp4" poster="https://github.com/Codename-11/hermes-relay/raw/main/assets/chat_demo_poster.jpg" autoplay loop muted playsinline width="280"></video>
|
||||
</p>
|
||||
|
||||
## Quick Start (Android)
|
||||
---
|
||||
|
||||
Install → connect → talk, in about two minutes.
|
||||
## Two surfaces, one pair
|
||||
|
||||
### 1 · Install the app
|
||||
| Surface | What | Status |
|
||||
|---------|------|--------|
|
||||
| **[Android app](#1a-android-app)** | Native phone control — chat, voice, the agent reads your screen and acts on it (tap, type, swipe), notification companion, multi-Connection. | Available — Google Play (Internal testing) + sideload APK |
|
||||
| **[Desktop app + CLI](#1b-desktop-app--cli-experimental)** | Use a server-deployed Hermes from your laptop **like it's local**. Windows gets the native tray app first: pair, start/pause the daemon, view devices, task log, settings, overlay status, and emergency stop. The CLI remains the terminal/headless surface and powers macOS/Linux installs. Experimental computer-use tools are opt-in. | **Experimental** — `desktop-v0.3.0-alpha.18` (Windows tray installer + native CLI binaries, no Node required) |
|
||||
|
||||
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, sessions, and Manage work with standard Hermes; pairing the Hermes-Relay plugin adds 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://hermes-relay.dev/docs/guide/getting-started.html#sideload-apk).
|
||||
Both share `~/.hermes/remote-sessions.json` and the same WSS relay. **Pair once from either, both work.**
|
||||
|
||||
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 the Hermes Dashboard running
|
||||
## Quick Start
|
||||
|
||||
The normal Android connection uses the upstream Hermes Dashboard/Gateway for
|
||||
chat, sign-in, sessions, Manage, and voice. Installing Hermes and choosing a
|
||||
provider is vanilla Hermes setup:
|
||||
Three steps: pick your surface (or install both), then install the relay plugin on your Hermes server.
|
||||
|
||||
```bash
|
||||
hermes setup --portal # install / log in / pick a provider — skip if already done
|
||||
hermes dashboard # start the standard Dashboard/Gateway surface
|
||||
```
|
||||
### 1a. Android app
|
||||
|
||||
Make the dashboard reachable from your phone over a trusted LAN, Tailscale, or
|
||||
an HTTPS reverse proxy. The [full walkthrough](https://hermes-relay.dev/docs/guide/getting-started)
|
||||
covers Windows, remote access, and dashboard authentication. You do not need to
|
||||
enable the separate API server or invent an API key for the standard path.
|
||||
<!-- TODO: Uncomment when Play Store listing is live
|
||||
<a href="https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80"></a>
|
||||
-->
|
||||
|
||||
Start on a trusted LAN. For away-from-home access, Tailscale is the recommended
|
||||
path. Secure Link, public TLS, and experimental routing options are covered in
|
||||
the [remote-access guide](https://hermes-relay.dev/docs/guide/remote-access/).
|
||||
- **Google Play** — coming soon (currently on Internal testing)
|
||||
- **APK** — download from [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and choose the newest Android release (`android-v*`; historical Android releases used bare `v*`)
|
||||
|
||||
### 3 · Connect and talk
|
||||
#### Sideload APK (GitHub Releases)
|
||||
|
||||
For a plugin-enabled host, open the Web Dashboard's **Relay** page, click
|
||||
**Connect mobile app**, and scan that tokenless QR from Android **Connect → Scan
|
||||
Hermes setup QR**. It contains only the Dashboard address and configures the
|
||||
upstream Chat, sessions, Manage, sign-in, and standard voice connection.
|
||||
Prefer not to wait for Google Play? Grab the signed APK directly:
|
||||
|
||||
Without the Dashboard plugin, use **Find Hermes on LAN** or enter the Dashboard
|
||||
address manually (conventionally `http://<host>:9119`). Sign in through the
|
||||
Dashboard's configured provider when prompted. The app probes the available
|
||||
upstream capabilities and finishes with a connection summary.
|
||||
1. Download the file ending in **`-sideload-release.apk`** from the newest Android release (`android-v*`; historical Android releases used bare `v*`) on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) — that's the full-featured "Hermes Dev" build. (Skip any `.aab` file — those are the Google Play bundle format and won't install directly.)
|
||||
2. On your phone: **Settings → Apps → Special app access → Install unknown apps** and allow your browser (first time only).
|
||||
3. Open the APK from your downloads and tap **Install**.
|
||||
4. Optionally verify integrity against `SHA256SUMS.txt` from the same release (`sha256sum` on macOS/Linux, `Get-FileHash -Algorithm SHA256` on Windows).
|
||||
|
||||
The separate API server can be discovered automatically or added later under
|
||||
**Advanced** as a chat fallback or for a headless compatibility setup. Its API
|
||||
key is requested only when that optional endpoint is configured. Existing
|
||||
API-first setup QRs remain importable.
|
||||
Full walkthrough, including signing-certificate fingerprint: [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
|
||||
The wizard probes everything and finishes with a capability card:
|
||||
**Staying up to date (sideload):** the app checks GitHub for a newer release on cold start (at most once every 6 hours) and shows a dismissable banner when you're behind. Tapping **Update** opens the next APK in your browser so Android's Downloads notification hands it to the system installer — no second app required. You can also trigger a check manually under **Settings → About → Updates**. Google Play installs get auto-updates through the Play Store and don't show this banner.
|
||||
|
||||
| Line | What it means |
|
||||
|------|---------------|
|
||||
| **Chat** | Dashboard/Gateway ready — you can talk |
|
||||
| **Manage** | Models, keys, skills, and profiles are available from the phone |
|
||||
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
|
||||
| **API fallback** | Optional API route available/unavailable |
|
||||
| **Relay** | Recommended extensions paired/unpaired; never blocks the upstream path |
|
||||
### 1b. Desktop app + CLI (experimental)
|
||||
|
||||
One dashboard sign-in unlocks Chat, Manage, sessions, and standard voice. That's
|
||||
the whole Vanilla Hermes setup.
|
||||
The desktop surface talks to a server-deployed Hermes over WSS. On Windows, the default installer launches the native tray app with pairing, daemon control, devices, task log, settings, overlay status, pause, and emergency stop. The same release still ships the `hermes-relay` CLI for shell/TUI use, scripting, headless daemon mode, and macOS/Linux.
|
||||
|
||||
> **Going places?** Add the Dashboard's Tailscale address — for example `http://100.x.y.z:9119` or a separately published `https://host.ts.net` URL — under **Settings → Connections → Routes**. Android tests it as a Dashboard route; no API server or API key is required. The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://hermes-relay.dev/docs/guide/remote-access).
|
||||
The remote agent can also reach back through the relay and run `desktop_read_file`, `desktop_terminal`, `desktop_search_files`, `desktop_screenshot`, `desktop_clipboard_*`, `desktop_open_in_editor`, etc. **on your machine** while its brain stays on the host. One pair, two surfaces (with the Android app), no `ssh`.
|
||||
|
||||
### 4 · Recommended: pair Relay for the complete experience
|
||||
|
||||
Install Relay for Terminal/TUI, notifications, media handoff, desktop tools,
|
||||
enhanced voice, Relay sessions, approval-gated page drafts, and optional Device
|
||||
Control:
|
||||
|
||||
```bash
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
hermes relay doctor
|
||||
hermes relay start --no-ssl
|
||||
```
|
||||
|
||||
Use `--no-ssl` only on a trusted LAN or VPN. Use the
|
||||
[remote-access guide](https://hermes-relay.dev/docs/guide/remote-access/) before
|
||||
exposing any Hermes surface beyond that network.
|
||||
|
||||
Refresh or restart the Dashboard/Gateway, open **Relay → Pair new device**, and
|
||||
scan the one-time QR from Android **Settings → Connections → Pair Hermes Relay**.
|
||||
Leave mode on **Auto** for the recommended route discovery. The same dialog
|
||||
shows a copyable invite for Desktop CLI clients:
|
||||
|
||||
```bash
|
||||
hermes-relay pair --pair-qr "hermes-relay://pair?payload=…" --grant-tools
|
||||
```
|
||||
|
||||
As alternatives, `hermes pair` renders the same Android QR and pasteable invite
|
||||
in a terminal, while URL + six-character code and `--register-code` remain
|
||||
manual fallbacks when QR or clipboard transfer is unavailable.
|
||||
|
||||
**Next:** [Android + Hermes-Relay Quick Start](https://hermes-relay.dev/docs/guide/quick-start) ·
|
||||
[Desktop CLI pairing](https://hermes-relay.dev/docs/desktop/pairing) ·
|
||||
[server, TLS, legacy install, and uninstall reference](https://hermes-relay.dev/docs/reference/relay-server)
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the Dashboard/Gateway enabled · Python 3.11+ when installing the Hermes-Relay plugin. The API fallback is optional; the Hermes-Relay plugin is encouraged for the complete experience.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/01_voice_conversation.png" alt="Voice controls in chat" width="100%"><br><sub><b>Voice in chat</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/02_chat.png" alt="Streaming chat" width="100%"><br><sub><b>Streaming chat</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/03_voice.png" alt="Hands-free voice" width="100%"><br><sub><b>Hands-free voice</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/04_sessions.png" alt="Session history" width="100%"><br><sub><b>Session history</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/05_themes.png" alt="App themes" width="100%"><br><sub><b>App themes</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/06_manage.png" alt="Manage your agent" width="100%"><br><sub><b>Manage your agent</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/07_connections.png" alt="Connections and routes" width="100%"><br><sub><b>Connections & routes</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/08_appearance.png" alt="Agent avatar & skins" width="100%"><br><sub><b>Avatars & skins</b></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Simplified Chinese
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh01.jpg" alt="中文设置界面" width="100%"><br><sub><b>设置 — 全面汉化</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh02.jpg" alt="中文管理界面" width="100%"><br><sub><b>管理 — 仪表盘汉化</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh03.jpg" alt="中文导航界面" width="100%"><br><sub><b>导航菜单 — 简体中文</b></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
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://hermes-relay.dev/docs/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
|
||||
|
||||
## Features
|
||||
|
||||
### Android
|
||||
|
||||
- **Streaming chat** — rides vanilla Hermes, preferring the dashboard gateway (`/api/ws`, live thinking) when signed in to Manage and falling back to API-server SSE otherwise, with live markdown, tool-call cards, session history, a searchable command palette, file attachments, quote-in-reply, conversation share, and send-while-streaming queuing.
|
||||
- **Manage your agent** — the full Hermes dashboard, native: switch models from your provider catalog, manage keys (write-only, masked, rate-limited reveal), create and edit profiles including `SOUL.md`, and browse/install/update skills. One dashboard sign-in covers it all.
|
||||
- **Hands-free voice** — talk on a vanilla install: speech rides your server's configured providers, unlocked by the same Manage sign-in. Relay-paired setups add per-profile voice and an opt-in provider-native Realtime Agent with background task handoff.
|
||||
- **Works away from home** — add a Tailscale or public URL and the app roams automatically (LAN at home, fallback elsewhere). An unreachable server gets a diagnosis, not just a red dot.
|
||||
- **Multi-Connection + profiles** — pair multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay a profile's model + `SOUL.md` per chat.
|
||||
- **Device Control (Sideload + Hermes-Relay required)** — the agent can read the screen and act: tap, type, swipe, scroll, screenshots, clipboard, media keys, and batched macros. This is not included in the Google Play build. It is guarded by a per-app blocklist (banking/2FA blocked by default), destructive-verb confirmation, idle auto-disable, and a full activity log.
|
||||
- **Notification companion** — opt-in access so the agent can triage, summarize, and route incoming notifications.
|
||||
- **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://hermes-relay.dev/docs/guide/release-tracks).
|
||||
|
||||
## Hands on any machine — the Hermes-Relay CLI <sub>(beta)</sub>
|
||||
|
||||
> **Beta.** Self-contained CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64 — no Node required. Windows also has an optional compact management tray. Assets are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
|
||||
|
||||
The agent's brain stays on the host; the CLI lets it call tools **on your machine** over the same WSS relay — `read_file`, `write_file`, `terminal`, `search_files`, `screenshot`, `clipboard`, `open_in_editor`, and more — behind a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch.
|
||||
**Install tray app** (Windows PowerShell):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Install CLI only** (Windows PowerShell):
|
||||
|
||||
```powershell
|
||||
$env:HERMES_RELAY_INSTALL_SURFACE='cli'; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Install CLI** (macOS / Linux):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767 # once
|
||||
hermes-relay daemon start # background tool router — agent reaches you anytime
|
||||
hermes-relay # interactive Hermes TUI in tmux
|
||||
hermes-relay "summarize the last commit" # one-shot
|
||||
hermes-relay --json "..." | jq # structured events for scripting
|
||||
hermes-relay daemon # headless tool router (agent reaches you anytime)
|
||||
hermes-relay update # self-update via GitHub Releases
|
||||
```
|
||||
|
||||
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*`.
|
||||
**Native paste workflow** (the killer demo): inside `hermes-relay shell`, hit `Win+Shift+S` to screenshot, then `Ctrl+A v` — the client reads your clipboard, ships the image to the server's inbox, and types `/paste` into the TUI for you. Identical UX to native local-Hermes paste. The same chord set works on macOS (`Cmd+Shift+4` → `Ctrl+A v`) and Linux (Wayland/X11 detected automatically).
|
||||
|
||||
On Windows, the default installer adds the optional compact **Hermes-Relay CLI UI** tray popup for host selection and pairing, connection and daemon state, per-host Ask/Trusted/Full Access, local grant dialogs, authorized-client revocation, activity, settings, and emergency stop. It is a management surface only—chat, TUI, plugins, voice, and agent sessions remain CLI/upstream concerns.
|
||||
**No Node required** — the Windows tray installer bundles the compiled CLI sidecar; CLI-only installs use Bun-compiled native binaries (~60–110 MB per platform) via curl/irm. Version-aware install (`upgrading X → Y`), collision-safe `hermes` short alias for CLI installs, self-update via `hermes-relay update`. Assets are **unsigned** during the experimental phase — SmartScreen/Gatekeeper warnings are expected. Code signing, multi-client server-side routing, and service installers (sc.exe / systemd / launchd) land with v1.0.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/desktop-ui/overview.png" alt="Hermes-Relay CLI UI connected overview" width="100%"><br><sub><b>Connection & activity</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/desktop-ui/host-access.png" alt="Hermes-Relay CLI UI host access presets" width="100%"><br><sub><b>Per-host access</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/desktop-ui/settings.png" alt="Hermes-Relay CLI UI computer control and updates" width="100%"><br><sub><b>Control & maintenance</b></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
- **Docs**: [Desktop guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **Release track**: tagged `desktop-v*`, [separate from Android](https://github.com/Codename-11/hermes-relay/releases?q=desktop)
|
||||
- **AI-agent setup recipe**: `/hermes-relay-desktop-setup` (the agent can run `desktop_terminal` on your machine to diagnose install/pair issues live)
|
||||
|
||||
Structured Windows computer control prefers a compatible local CUA Driver
|
||||
runtime for window-targeted background actions and virtual per-session agent
|
||||
cursors. It remains behind Hermes host policy, grants, targeting, audit, and
|
||||
emergency stop; Windows input is an explicit compatibility backend. CUA is not
|
||||
bundled or updated automatically, but the local CLI/UI can explicitly install,
|
||||
check, or update its verified canonical package. It is never exposed as a raw
|
||||
remote tool surface. See the [desktop tools guide](https://hermes-relay.dev/docs/desktop/tools.html#computer-use-engines).
|
||||
### 2. Install the server plugin (one-liner)
|
||||
|
||||
- **Docs:** [CLI guide](https://hermes-relay.dev/docs/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
|
||||
On the machine running your Hermes agent:
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Phone (HTTP/WSS) --> Hermes Dashboard (:9119) [chat gateway, manage, vanilla voice]
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat fallback, sessions, runs]
|
||||
Phone (WSS/HTTP) --> Relay (:8767) [terminal, bridge, media, relay voice, sessions]
|
||||
CLI (WSS) --> Relay (:8767) [machine tools, tui, terminal]
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
```
|
||||
|
||||
Chat prefers the Hermes dashboard gateway when Manage auth is ready, then falls
|
||||
back to the upstream API server SSE path with the API key. Manage and Vanilla Hermes
|
||||
voice ride the Hermes dashboard with its own one-time sign-in, so a vanilla
|
||||
install needs no plugin for either. The optional relay on `:8767` adds the power
|
||||
surfaces: terminal, bridge phone control, media handoff, machine tools, and
|
||||
relay-side voice, which is preferred automatically when paired. One QR can
|
||||
configure API, dashboard, and relay routes without merging their auth models.
|
||||
The installer clones Hermes-Relay to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`), `pip install -e`s the package into the hermes-agent venv, registers the `skills/` directory in your `~/.hermes/config.yaml` under `skills.external_dirs` (so updates flow through `git pull`), symlinks the plugin into `~/.hermes/plugins/hermes-relay`, drops a thin `hermes-pair` shim into `~/.local/bin/`, and (optionally) installs a systemd user service for the WSS relay. After restart, pair your client via either of these equivalent entry points:
|
||||
|
||||
## Documentation
|
||||
- **From any Hermes chat surface** (CLI, Discord, Telegram, etc.): type `/hermes-relay-pair` and the `hermes-relay-pair` skill renders the QR + 6-char code inline. Shortest path if you're already chatting with the agent.
|
||||
- **From a shell**: `hermes-pair` (dashed) — a thin wrapper around `python -m plugin.pair` in the hermes-agent venv. Use this in scripts or when you want the raw output.
|
||||
- **No camera?** `hermes-pair --register-code ABCD12` — manual fallback for SSH-only / camera-less setups. For Android: read the 6-char code from the app's **Settings → Connection → Manual pairing code (fallback)** card, pre-register it on the host with this command, then tap **Connect** in the app. For the desktop CLI: just pass it as `hermes-relay pair ABCD12 --remote ws://<host>:8767`. Composes with `--ttl` / `--grants`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[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*`, `server-v*`, `desktop-v*`; historical prefixes remain immutable) |
|
||||
Scan the QR from the Android app's onboarding screen, OR paste the 6-char code into `hermes-relay pair --remote ws://<host>:8767` on your laptop, and you're connected. One pair configures **both** the direct-chat API server **and** the relay (WSS for terminal / bridge / TUI / desktop tools, HTTP for voice routes) — if a local relay is running at `localhost:8767`, the pair command pre-registers a fresh 6-char pairing code with it and embeds the relay URL + code in the same QR. If you only want direct chat from the Android app, pass `--no-relay` (or just don't start the relay). Plain-text connection details are always printed alongside the QR so you can copy values by hand if your terminal can't render QR blocks.
|
||||
|
||||
<details>
|
||||
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
|
||||
**Dashboard plugin.** If your hermes-agent install has the Dashboard Plugin System (upstream `axiom` branch), Hermes-Relay ships a plugin at `plugin/dashboard/` that surfaces paired devices, bridge command activity, and active inbound-media tokens in the gateway's web UI. It auto-registers through the same `~/.hermes/plugins/hermes-relay` symlink created by `install.sh` — restart the gateway and a "Relay" tab appears. See [docs/relay-server.md](docs/relay-server.md) and `user-docs/features/dashboard.md` for details.
|
||||
|
||||
<br>
|
||||
**Updating:** `hermes-relay-update` (shortest path — installed as part of the one-liner) or re-run the same `curl … | bash` from above. Both are equivalent and fully idempotent: pulls latest main, refreshes the editable install, recreates all three shims, restarts `hermes-relay`, and prompts before restarting `hermes-gateway`. Set `HERMES_RELAY_RESTART_GATEWAY=1` to opt into the gateway restart non-interactively. For routine plugin/skill updates without restarting anything, a plain `cd ~/.hermes/hermes-relay && git pull` is enough — the editable install picks up the new code on next process start.
|
||||
|
||||
If an AI assistant manages your server, paste this block into its chat and it will fetch the canonical setup recipe and walk you through install, pairing, and troubleshooting:
|
||||
**Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` reverses every install step in the opposite order. Idempotent, never touches state shared with other Hermes tools (`.env`, sessions DB, hermes-agent venv core). Flags: `--dry-run`, `--keep-clone`, `--remove-secret`. Or pull the script via curl if you've already removed the clone.
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26) for the Android app · macOS / Linux / Windows for the desktop CLI · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+ on the server.
|
||||
|
||||
### For AI Agents
|
||||
|
||||
If you have an AI assistant (Claude, GPT, etc.) and want it to install or maintain Hermes-Relay for you, paste the block below into the chat. The agent will fetch the canonical setup recipe from this repo and walk you through it — verification, pairing, troubleshooting included.
|
||||
|
||||
```text
|
||||
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a CLI + a Python plugin for the Hermes AI agent platform.
|
||||
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a desktop CLI + a Python plugin for the Hermes AI agent platform.
|
||||
|
||||
Read the canonical setup recipe before acting:
|
||||
https://raw.githubusercontent.com/Codename-11/hermes-relay/main/skills/devops/hermes-relay-self-setup/SKILL.md
|
||||
@@ -277,109 +146,210 @@ Read the canonical setup recipe before acting:
|
||||
Then guide me through:
|
||||
- Verifying hermes-agent is already installed (it's a prerequisite — Hermes-Relay is a plugin, not standalone)
|
||||
- Running the server-plugin install one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash`
|
||||
- Connecting my phone by Vanilla Hermes API URL/key first, then optionally pairing Relay via `hermes pair` or `/hermes-relay-pair` for power tools; OR pairing my laptop via the Hermes-Relay CLI (`irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
|
||||
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (CLI)
|
||||
- Pairing my phone via `hermes-pair` or `/hermes-relay-pair` (Android), OR pairing my laptop via the `hermes-relay` desktop CLI (binary one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh` or `irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
|
||||
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (desktop CLI)
|
||||
|
||||
Always confirm before running shell commands. Never restart hermes-gateway without asking. If any step fails, consult the Troubleshooting section in the SKILL.md and ask me for the exact error.
|
||||
```
|
||||
|
||||
Already installed? The same recipe is auto-loaded as a Hermes skill — invoke `/hermes-relay-self-setup` from any chat for re-setup or "is everything wired correctly?" checks.
|
||||
Already have Hermes-Relay installed? The same recipe is auto-loaded as a Hermes skill — invoke it from any chat with `/hermes-relay-self-setup` for re-setup, troubleshooting, or "is everything wired correctly?" checks. Single source, two delivery modes (raw URL pre-install + Hermes skill post-install), no drift.
|
||||
|
||||
</details>
|
||||
## What It Does
|
||||
|
||||
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — native on Android, native in the terminal, with the agent able to reach back through the relay and act on either surface.
|
||||
|
||||
| Surface | Channel | What | Status |
|
||||
|---------|---------|------|--------|
|
||||
| Android | **Chat** | Stream conversations to Hermes via HTTP/SSE | Available |
|
||||
| Android | **Voice** | Real-time voice conversation via relay TTS/STT | Available |
|
||||
| Android | **Bridge** | Agent reads the screen and performs UI actions (tap, long-press, drag, type, clipboard, media, macros, events) | Available |
|
||||
| Android | **Terminal** | Secure remote shell via tmux | Phase 2 |
|
||||
| Desktop CLI | **Shell** | Full Hermes Ink TUI piped over PTY in tmux on the host. Bare `hermes-relay` drops you in. | Available (experimental) |
|
||||
| Desktop CLI | **Chat** | Structured-event REPL / one-shot / piped stdin. `--json` for scripting. REPL supports `/paste`, `/screenshot`, `/image <path>`. | Available (experimental) |
|
||||
| Desktop CLI | **In-shell paste / screenshot** | `Ctrl+A v` (clipboard image → server inbox → `/paste` auto-typed). `/screenshot` is multi-monitor by default. | Available (experimental) |
|
||||
| Desktop CLI | **Local tool routing** | Agent calls `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` / `_clipboard_*` / `_screenshot` / `_open_in_editor` — runs on YOUR machine over the same relay | Available (experimental) |
|
||||
| Desktop CLI | **Daemon** | Headless tool router — keeps tools advertised even when no shell is open | Available (experimental) |
|
||||
| Desktop CLI | **Self-update** | `hermes-relay update` polls GitHub Releases, atomic-swaps the binary | Available (experimental) |
|
||||
|
||||
## What's new in v0.6.0
|
||||
|
||||
- **Connect from anywhere** — multi-endpoint pairing with first-class Tailscale support; plug in any VPN or reverse proxy mode. See [`docs/remote-access.md`](docs/remote-access.md).
|
||||
- **Multi-Connection support** — pair with multiple Hermes servers (home + work, dev + prod, etc.) and switch in one tap from the Chat top bar. Each Connection keeps its own sessions, personalities, profiles, and relay state; theme and safety preferences stay global. Existing installs migrate transparently.
|
||||
- **Agent Profiles** — the relay auto-discovers upstream Hermes profiles at `~/.hermes/profiles/*/` and the phone overlays the selected profile's model + `SOUL.md` on chat turns. Ephemeral, chat-only, clears on Connection switch. Gated by `RELAY_PROFILE_DISCOVERY_ENABLED` (default on).
|
||||
- **Consolidated agent sheet** — Profile + Personality selection and per-session analytics now live in one scrollable bottom sheet opened from the Chat top-bar agent name.
|
||||
|
||||
See the [changelog](CHANGELOG.md) for the full list.
|
||||
|
||||
## Features
|
||||
|
||||
### Android
|
||||
|
||||
- **Streaming chat** — Direct SSE to the Hermes API Server with real-time markdown rendering, session history, tool-call visualization, personality picker, searchable command palette (29+ gateway commands), file attachments, and send-while-streaming message queuing
|
||||
- **Multi-Connection + agent profiles** — Pair with multiple Hermes servers and switch targets from the top bar; select an upstream-discovered agent profile to overlay model + `SOUL.md` on chat turns. Three-layer model: Connection (server) → Profile (agent directory) → Personality (prompt preset)
|
||||
- **Voice mode** — Experimental server-mediated voice conversation via the relay; the sphere listens with you and performs the agent's reply as it speaks. Hermes owns chat, tool calls, and approvals, while relay voice output defaults to provider-neutral streaming TTS (`xai_tts` first) with realtime voice-agent providers kept as a separate lab mode.
|
||||
- **Phone control (bridge)** — The agent can read what's on screen and act on it — tap, long-press, drag, swipe, scroll, type, and press system keys — plus take screenshots, read/write the clipboard, and control system-wide media playback. Gesture reliability is hardened for dim/idle screens, and a smarter tap-fallback cascade handles apps where labels sit inside non-clickable wrappers
|
||||
- **Screen understanding** — Filtered accessibility-tree search, per-node property lookups with stable IDs, cheap screen-hash change detection, and multi-window reads (system overlays, popups, notification shade) so the agent can reason about UI without guessing
|
||||
- **Workflow automation** — Batched macro execution for multi-step flows, real-time accessibility event streaming for "wait until something happens" waits, and a raw-Intent escape hatch for apps that expose deep-link actions
|
||||
- **Notification companion** — Opt-in notification access so the agent can triage, summarize, and route incoming notifications
|
||||
- **Bridge safety rails** — Per-app blocklist (banking, payments, 2FA default-blocked), destructive-verb confirmation modal (send, pay, delete, transfer…), idle auto-disable timer, optional persistent-status overlay, full activity log
|
||||
- **Security & pairing** — QR-code pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL
|
||||
- **Analytics** — Stats for Nerds with TTFT, token usage, stream health, and peak-time charts
|
||||
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free voice intents like "text Sam I'll be 10 minutes late". See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the full sideload capability matrix.
|
||||
|
||||
### Desktop CLI
|
||||
|
||||
- **Shell mode (default)** — bare `hermes-relay` pipes the host's actual `hermes` Ink TUI through a PTY in tmux. Same banner, same skin, same slash commands as a local install. `Ctrl+A .` detaches (preserves tmux), `Ctrl+A k` kills, `Ctrl+A v` pastes a clipboard image, `Ctrl+A ?` re-prints chord help, `Ctrl+A Ctrl+A` literal.
|
||||
- **Chat mode** — REPL or one-shot or piped stdin. `--json` emits `GatewayEvent`s per line for `jq` / automation. REPL slash commands `/paste` (clipboard), `/screenshot` (multi-monitor by default; `primary` / `1` / `2` to narrow), `/image <path>` attach the next message.
|
||||
- **Local tool routing** — agent calls `desktop_read_file`, `desktop_write_file`, `desktop_terminal`, `desktop_search_files`, `desktop_patch`, `desktop_clipboard_read/write`, `desktop_screenshot`, `desktop_open_in_editor` — all run on YOUR machine over the same WSS relay. One-time per-URL consent gate; `--no-tools` kill-switch; non-TTY stdin fails closed; agent-proposed patches render as colored diffs with `y/n/e/r` interactive approval. Experimental `desktop_computer_*` control tools require `--experimental-computer-use` / `HERMES_RELAY_EXPERIMENTAL_COMPUTER_USE=1`, task-scoped grants, and visible local approval.
|
||||
- **Daemon mode** — `hermes-relay daemon` runs the tool router headless so the agent can reach you even when no shell is open. JSON-line lifecycle logs by default, auto-human on TTY. Fails closed on missing consent.
|
||||
- **Self-update** — `hermes-relay update` polls GitHub Releases (SemVer-max picker, prerelease-aware), verifies SHA256, atomic-swaps the binary on POSIX (running daemon keeps inode), cooperative `.new.exe` swap on Windows.
|
||||
- **Multi-endpoint pairing + reconnect-on-drop + TOFU cert pinning** — same as the Android app. One QR carries LAN + Tailscale + public; client races candidates in priority order, re-probes on every network change.
|
||||
- **Workspace awareness** — on connect, client advertises `cwd`, `git_root`, `git_branch`, `repo_name`, `hostname`, `platform`, `active_shell` to the relay (server-side prompt-context consumption coming).
|
||||
- **Conversation picker on attach** — without `--conversation` / `--new`, you get a numbered list of recent server-side hermes sessions to resume.
|
||||
- **One install, one binary, no Node required** — Bun-compiled native binaries via curl/irm one-liners; collision-safe `hermes` short alias auto-installed.
|
||||
|
||||
## Getting Started
|
||||
|
||||
**Android:**
|
||||
|
||||
1. **Install the app** from the [link above](#1a-android-app)
|
||||
2. **Enter your Hermes server URL** (e.g. `http://192.168.1.100:8642`) during onboarding, or scan a QR via `/hermes-relay-pair`
|
||||
3. **Start chatting** — the app connects directly to the Hermes API Server
|
||||
|
||||
**Desktop CLI:**
|
||||
|
||||
1. **Install the binary** — [PowerShell `irm`](#1b-desktop-cli-experimental) (Windows) / curl (macOS / Linux) one-liner
|
||||
2. **Pair once** — `hermes-relay pair --remote ws://<host>:8767` (mint code via `hermes-pair` or `/hermes-relay-pair` on the server first)
|
||||
3. **Drop into the shell** — bare `hermes-relay` opens the full Hermes TUI in tmux on the host
|
||||
|
||||
For detailed setup, server configuration, and feature guides, see the **[full documentation](https://codename-11.github.io/hermes-relay/)**.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
|
||||
Phone (HTTP) --> Server (:8767) [voice routes — API key or relay session]
|
||||
Phone (WSS/HTTP) --> Server (:8767) [terminal, bridge, media, sessions]
|
||||
Desktop CLI (WSS) --> Server (:8767) [tui, terminal, desktop tools]
|
||||
```
|
||||
|
||||
Chat from the Android app connects directly to the Hermes API Server with the Hermes API key — same pattern used by Open WebUI and other Hermes frontends. Voice calls the relay's `/voice/*` HTTP routes and authenticates with that Hermes API bearer when present, falling back to the relay session token for paired devices. Remote control surfaces such as terminal, bridge, TUI, media/session management, and desktop tools require relay pairing on `:8767`, so one scan can configure both the API route and the relay route without merging their auth models.
|
||||
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Getting started, both surfaces, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android-specific install + setup + features |
|
||||
| [Desktop CLI](https://codename-11.github.io/hermes-relay/desktop/) | Desktop CLI guide — shell/chat, pairing, subcommands, local tool routing |
|
||||
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
|
||||
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
|
||||
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
|
||||
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
|
||||
| [Upstream Integration Sync](docs/upstream-integration-sync.md) | Supported Hermes extension points vs server-owned compatibility layers |
|
||||
| [Changelog](CHANGELOG.md) | Release history (Android `android-v*`, Server `server-v*`, and Desktop `desktop-v*`) |
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **File > Open** the repo root in Android Studio
|
||||
2. Wait for Gradle sync
|
||||
3. **Run** (Shift+F10) to deploy to emulator or device
|
||||
|
||||
### Dev Scripts
|
||||
|
||||
```bash
|
||||
# Android: open the repo root in Android Studio, wait for Gradle sync, Run (Shift+F10).
|
||||
scripts/dev.bat build # Build sideload debug APK
|
||||
scripts/dev.bat compile # Compile sideload Kotlin only
|
||||
scripts/dev.bat test-one "com.hermesandroid.relay.SomeTest" # Focused unit test
|
||||
scripts/dev.bat install-fast # arm64 phone build + install + launch
|
||||
scripts/dev.bat build # Build debug APK
|
||||
scripts/dev.bat release # Build signed release APK
|
||||
scripts/dev.bat bundle # Build release AAB for Google Play
|
||||
scripts/dev.bat run # Build sideload + install + launch + logcat
|
||||
scripts/dev.bat test # Run sideload debug unit tests
|
||||
scripts/dev.bat run # Build + install + launch + logcat
|
||||
scripts/dev.bat test # Run unit tests
|
||||
scripts/dev.bat version # Show current version
|
||||
scripts/dev.bat relay # Start the relay server (dev, no TLS)
|
||||
scripts/dev.bat relay # Start Server (dev, no TLS)
|
||||
```
|
||||
|
||||
Gateway, session, streaming, reconnect, or authoritative-history changes use
|
||||
the reusable, on-demand [Gateway contract lab](docs/gateway-contract-testing.md).
|
||||
It includes deterministic protocol scenarios, current-upstream conformance,
|
||||
Android instrumentation, and opt-in physical-device certification; none of
|
||||
those lanes is scheduled automatically.
|
||||
### Repository Structure
|
||||
|
||||
```
|
||||
hermes-relay/
|
||||
├── app/ # Android app (Kotlin + Jetpack Compose)
|
||||
├── desktop/ # Desktop CLI thin-client (@hermes-relay/cli — TS + Bun-compiled binary)
|
||||
├── relay_server/ # WSS Server (Python + aiohttp; thin shim → plugin/relay)
|
||||
├── plugin/ # Hermes agent plugin
|
||||
│ ├── relay/ # - canonical relay (server.py, channels/, media, voice, desktop tools)
|
||||
│ ├── tools/ # - android_* bridge + desktop_* tool handlers
|
||||
│ └── pair.py # - QR pairing CLI + multi-endpoint payload builder
|
||||
├── skills/ # Hermes agent skills
|
||||
│ └── devops/
|
||||
│ ├── hermes-relay-pair/ # /hermes-relay-pair slash-command skill
|
||||
│ ├── hermes-relay-self-setup/ # AI-agent setup recipe (Android + desktop)
|
||||
│ └── hermes-relay-desktop-setup/ # AI-agent recipe specifically for the desktop CLI
|
||||
├── user-docs/ # VitePress documentation site (Android + desktop sections)
|
||||
├── docs/ # Spec, decisions, security
|
||||
├── scripts/ # Dev helper scripts
|
||||
├── .github/workflows/ # CI + release pipelines (ci-android / ci-server / ci-desktop)
|
||||
└── gradle/ # Wrapper (8.13) + version catalog
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Component | Stack |
|
||||
|-----------|-------|
|
||||
| **Android app** | Kotlin 2.4, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Hermes-Relay CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
|
||||
| **Server / plugin** | Python 3.11+, aiohttp |
|
||||
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Desktop CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
|
||||
| **Server** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization (Android) |
|
||||
| **Build** | AGP 9.3.1, Gradle 9.6.1, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
|
||||
| **CI/CD** | GitHub Actions — lint, build, test, APK artifact, CLI binaries per platform |
|
||||
| **Min SDK** | 26 (Android 8.0) · Target SDK 36 |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (desktop) |
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact, desktop binaries per platform) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
|
||||
|
||||
<details>
|
||||
<summary><b>Repository structure</b></summary>
|
||||
|
||||
```
|
||||
hermes-relay/
|
||||
├── app/ # Android app (Kotlin + Jetpack Compose)
|
||||
├── desktop/ # Hermes-Relay CLI thin-client (TS + Bun-compiled binary)
|
||||
├── relay_server/ # WSS server (Python + aiohttp; thin shim → plugin/relay)
|
||||
├── plugin/ # Hermes agent plugin
|
||||
│ ├── relay/ # - canonical relay (server.py, channels/, media, voice, machine tools)
|
||||
│ ├── tools/ # - android_* bridge + desktop_* tool handlers
|
||||
│ └── pair.py # - QR pairing CLI + multi-endpoint payload builder
|
||||
├── skills/devops/ # Hermes agent skills (pairing, self-setup, CLI setup recipes)
|
||||
├── user-docs/ # VitePress documentation site
|
||||
├── docs/ # Spec, decisions, security
|
||||
├── scripts/ # Dev helper scripts
|
||||
├── .github/workflows/ # CI + release pipelines (ci-android / ci-plugin / ci-desktop)
|
||||
└── gradle/ # Wrapper (8.13) + version catalog
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Running the server / plugin from a clone</b></summary>
|
||||
|
||||
<br>
|
||||
|
||||
End users should follow the [recommended Hermes-Relay setup](#4--recommended-pair-relay-for-the-complete-experience) above. For local development:
|
||||
### Server (optional — bridge, terminal, TUI, media, and voice routes)
|
||||
|
||||
```bash
|
||||
hermes relay start --no-ssl # if you installed the plugin
|
||||
python -m plugin.relay --no-ssl # or from a repo checkout
|
||||
# or from a repo checkout:
|
||||
python -m plugin.relay --no-ssl
|
||||
```
|
||||
|
||||
# Docker:
|
||||
Or with Docker:
|
||||
|
||||
```bash
|
||||
docker build -t hermes-relay relay_server/ && docker run -d --network host --name hermes-relay hermes-relay
|
||||
```
|
||||
|
||||
# Live-edit the plugin against a local Hermes:
|
||||
See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
|
||||
|
||||
### Hermes Plugin (for contributors)
|
||||
|
||||
End users should install via the [one-liner](#2-install-the-server-plugin-one-liner) at the top. For local development from a clone:
|
||||
|
||||
```bash
|
||||
cp -r plugin ~/.hermes/plugins/hermes-relay
|
||||
# Or symlink for live edits:
|
||||
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
|
||||
```
|
||||
|
||||
Then restart hermes and run `hermes pair` to verify. The 35 `android_*` and 25 `desktop_*` tools register regardless of hermes-agent version. See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
|
||||
Then restart hermes and run `hermes-pair` (dashed shell shim) or type `/hermes-relay-pair` in any Hermes chat surface to verify pairing. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. **Note:** a top-level `hermes pair` CLI sub-command is *not* currently exposed — hermes-agent v0.8.0's top-level argparser doesn't yet forward to third-party plugins' `register_cli_command()` dict. Use the slash command or the dashed shim instead.
|
||||
|
||||
</details>
|
||||
|
||||
## Built for Hermes Agent
|
||||
## Hermes Agent
|
||||
|
||||
Hermes-Relay is built for [Hermes Agent](https://github.com/NousResearch/hermes-agent) — an open-source AI agent platform by [Nous Research](https://nousresearch.com). See the [Hermes Agent docs](https://hermes-agent.nousresearch.com) for server setup, gateway configuration, and plugin development.
|
||||
|
||||
## Questions, ideas, or bugs?
|
||||
## Found a bug? Let us know!
|
||||
|
||||
Use [GitHub Discussions](https://github.com/Codename-11/hermes-relay/discussions) for setup questions, early ideas, broader conversation, and things you are building with Hermes-Relay. If something is reproducibly broken or you have a specific, actionable feature request, [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). This is an indie project and every report helps shape where it goes next.
|
||||
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line "this didn't work on my Pixel 7" / "the alpha.14 Windows binary segfaults on my Surface" is genuinely useful.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Codename-11%2Fhermes-relay&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Codename-11/hermes-relay&type=date&theme=dark&legend=top-left&sealed_token=LpoTO7nnGWAwvnRyEeMuKowbf1fe6tQP9n6EbjX-9HTG0uGPrSD_OaNkloMDIM5ugTCg_14LB3XpQTx7v4fBn7PAtMZhO87iIlK5lo42Z31x8myptmcmnQ" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Codename-11/hermes-relay&type=date&legend=top-left&sealed_token=LpoTO7nnGWAwvnRyEeMuKowbf1fe6tQP9n6EbjX-9HTG0uGPrSD_OaNkloMDIM5ugTCg_14LB3XpQTx7v4fBn7PAtMZhO87iIlK5lo42Z31x8myptmcmnQ" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Codename-11/hermes-relay&type=date&legend=top-left&sealed_token=LpoTO7nnGWAwvnRyEeMuKowbf1fe6tQP9n6EbjX-9HTG0uGPrSD_OaNkloMDIM5ugTCg_14LB3XpQTx7v4fBn7PAtMZhO87iIlK5lo42Z31x8myptmcmnQ" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Codename-11/hermes-relay&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Codename-11/hermes-relay&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Codename-11/hermes-relay&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="assets/play-store-feature-1024x500.png" alt="Hermes-Relay — 随身携带您的 Hermes 代理" width="800">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>运行在您的电脑上,连接到您的设备。</strong><br>
|
||||
Hermes-Relay 是 <a href="https://github.com/NousResearch/hermes-agent">Hermes Agent</a> 的原生 Android 客户端,提供流式聊天、免手动语音和代理管理;另有单文件 CLI,让代理在已配对的电脑上安全使用终端、文件和截图工具。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>简体中文</strong> · <a href="README.md">English</a><br>
|
||||
<a href="https://hermes-relay.dev/docs/zh-CN/">中文文档</a> ·
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases">版本下载</a> ·
|
||||
<a href="https://github.com/Codename-11/hermes-relay/discussions">社区讨论</a> ·
|
||||
<a href="CHANGELOG.md">更新日志</a>
|
||||
</p>
|
||||
|
||||
> 英文 [README.md](README.md) 是最新、完整的项目说明。本页维护中文安装入口和核心功能摘要;协议、架构和维护者文档以英文版本为准。
|
||||
|
||||
## 功能简介
|
||||
|
||||
- **Android 应用**:流式聊天、会话历史、文件附件、Hermes 管理、语音模式、原生插件页面、Petdex 悬浮宠物、多连接和配置文件;也可将 Hermes 设为 Android 助手。
|
||||
- **无需插件的标准路径**:聊天、管理和标准语音可直接连接未修改的上游 Hermes Agent。
|
||||
- **可选 Relay 插件**:增加终端、手机控制、媒体传输、通知助手、Relay 语音、电脑工具,以及需确认的代理创建插件页面草稿。
|
||||
- **安全连接**:二维码配对、Android Keystore、证书固定、按通道授权和可配置会话有效期。
|
||||
- **远程使用**:可配置 Tailscale 或 HTTPS 地址,在家庭局域网和远程路由之间自动切换。
|
||||
- **两种 Android 发行渠道**:Google Play 版本适合日常使用;sideload 版本包含完整手机控制能力。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装 Android 应用
|
||||
|
||||
- [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay):自动更新,包含聊天、语音、管理、终端、媒体和通知功能。
|
||||
- [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases):下载最新 `android-v*` 版本中以 `-sideload-release.apk` 结尾的文件,获得完整手机控制功能。
|
||||
|
||||
### 2. 启动 Hermes API 服务
|
||||
|
||||
手机需要能够访问 Hermes API 服务,并使用 API 密钥进行身份验证:
|
||||
|
||||
```bash
|
||||
hermes setup --portal
|
||||
|
||||
mkdir -p ~/.hermes
|
||||
API_SERVER_KEY="$(openssl rand -hex 32)"
|
||||
cat >> ~/.hermes/.env <<EOF
|
||||
API_SERVER_ENABLED=true
|
||||
API_SERVER_HOST=0.0.0.0
|
||||
API_SERVER_PORT=8642
|
||||
API_SERVER_KEY=$API_SERVER_KEY
|
||||
EOF
|
||||
chmod 600 ~/.hermes/.env
|
||||
|
||||
echo "Android API URL: http://<电脑IP>:8642 key: $API_SERVER_KEY"
|
||||
hermes gateway
|
||||
```
|
||||
|
||||
`0.0.0.0` 会让同一网络中的设备访问 API。请保留强密钥;离开可信局域网时,应使用 Tailscale 或 HTTPS 反向代理,不要直接把端口暴露到互联网。
|
||||
|
||||
### 3. 在手机上连接
|
||||
|
||||
打开应用后,可以:
|
||||
|
||||
- 扫描局域网中的 Hermes;
|
||||
- 手动输入 `http://<主机>:8642` 和 API 密钥;
|
||||
- 扫描包含 API、Dashboard 和可选 Relay 地址的设置二维码。
|
||||
|
||||
如需在手机上管理模型、密钥、技能和配置文件,请运行 Hermes Dashboard,并在应用的 **管理** 页面登录一次。同一登录会话也会启用标准语音。
|
||||
|
||||
### 4. 可选:安装 Relay
|
||||
|
||||
仅在需要终端、手机控制、媒体路由、Relay 会话、实时语音、电脑工具或代理创建插件页面草稿时安装:
|
||||
|
||||
```bash
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
hermes relay doctor
|
||||
hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
已安装的 Hermes 插件可通过已认证的 Dashboard 向 Android 提供由应用安全渲染的原生页面,无需在手机上运行插件代码。Relay 1.5.0 另支持需用户确认的代理创建页面草稿。
|
||||
|
||||
完整说明请阅读[中文快速开始](https://hermes-relay.dev/docs/zh-CN/guide/quick-start);远程访问、协议和高级配置暂时链接到英文参考文档。
|
||||
|
||||
安装问题、早期想法、一般交流和作品分享请使用 [GitHub Discussions](https://github.com/Codename-11/hermes-relay/discussions)。可复现的错误和明确、可执行的功能请求请提交到 [Issues](https://github.com/Codename-11/hermes-relay/issues/new)。
|
||||
|
||||
## 中文界面
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh01.jpg" alt="中文设置界面" width="100%"><br><sub><b>设置</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh02.jpg" alt="中文管理界面" width="100%"><br><sub><b>管理</b></sub></td>
|
||||
<td align="center" width="33%"><img src="assets/screenshots/Zh03.jpg" alt="中文导航界面" width="100%"><br><sub><b>导航</b></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 参与翻译
|
||||
|
||||
Android 英文资源是规范来源。新增语言必须保持资源名称、类型和格式参数一致,并通过:
|
||||
|
||||
```bash
|
||||
python scripts/check-android-locales.py
|
||||
./gradlew lint
|
||||
```
|
||||
|
||||
翻译规范、目录命名、复数和占位符规则见 [docs/localization.md](docs/localization.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE) — Copyright (c) 2026 [Axiom-Labs](https://codename-11.dev)
|
||||
@@ -13,24 +13,20 @@ with optional prerelease identifiers.
|
||||
- `PATCH` — bug fixes, backwards compatible
|
||||
- Prerelease suffixes: `-alpha`, `-beta`, `-rc.N` (e.g. `0.2.0-beta.1`)
|
||||
|
||||
Hermes-Relay ships three independently versioned production surfaces. Public
|
||||
GitHub Release titles use `Hermes-Relay <Surface> v<version>` (for example,
|
||||
`Hermes-Relay Android v1.13.0-rc.1`); immutable tag prefixes select the
|
||||
corresponding build and deployment lane.
|
||||
Hermes-Relay now ships three independently versioned surfaces:
|
||||
|
||||
| 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 | `server-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-plugin-version.sh` | `.github/workflows/release-plugin.yml` |
|
||||
| Hermes-Relay CLI+UI | `desktop-v*` | `desktop/package.json` | `cd desktop && npm version --no-git-tag-version <version>` | `.github/workflows/release-cli.yml` |
|
||||
| Android app | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
|
||||
| Server / Python package | `server-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-server-version.sh` | `.github/workflows/release-server.yml` |
|
||||
| Desktop CLI | `desktop-v*` | `desktop/package.json` | `npm version` or manual package bump | `.github/workflows/release-desktop.yml` |
|
||||
|
||||
This split is intentional. The 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*` and `plugin-v*` tags. Historical
|
||||
desktop/CLI releases also include `cli-v*` tags. Those tags remain immutable;
|
||||
new releases use the canonical prefixes above.
|
||||
This split is intentional. The server now carries features for both Android
|
||||
and desktop, so server fixes can ship without forcing an Android app
|
||||
`versionCode` bump, and desktop CLI alphas can continue on their own cadence.
|
||||
Historical Android releases before this naming split used bare `v*` tags, and
|
||||
historical server releases used `relay-v*` tags. New releases use the explicit
|
||||
surface prefixes above.
|
||||
|
||||
### Android app versioning
|
||||
|
||||
@@ -74,87 +70,35 @@ bash scripts/bump-android-version.sh 0.6.2
|
||||
`scripts/bump-version.sh` remains as a backward-compatible alias for the
|
||||
Android script.
|
||||
|
||||
### Plugin / Python package versioning
|
||||
### Server / Python package versioning
|
||||
|
||||
Plugin version metadata lives in these plugin-owned files and must stay in
|
||||
Server version metadata lives in these server-owned files and must stay in
|
||||
lockstep:
|
||||
|
||||
| File | Line | Purpose |
|
||||
|---|---|---|
|
||||
| `pyproject.toml` | `version = "..."` | Python package metadata |
|
||||
| `plugin/relay/__init__.py` | `__version__ = "..."` | runtime version reported by `/health` and `/relay/info` |
|
||||
| `plugin/relay/__init__.py` | `__version__ = "..."` | runtime version reported by `/health` |
|
||||
| `plugin/plugin.yaml` | `version: ...` | Hermes plugin metadata |
|
||||
| `plugin/dashboard/manifest.json` | `"version": "..."` | Hermes dashboard plugin metadata |
|
||||
| `plugin/dashboard/package.json` | `"version": "..."` | dashboard build/package metadata |
|
||||
| `plugin/dashboard/package-lock.json` | `"version": "..."` | locked dashboard package metadata |
|
||||
|
||||
Always bump Plugin releases via:
|
||||
Always bump Server releases via:
|
||||
|
||||
```bash
|
||||
bash scripts/bump-plugin-version.sh 0.6.2
|
||||
bash scripts/bump-server-version.sh 0.6.2
|
||||
```
|
||||
|
||||
Check the current metadata with:
|
||||
|
||||
```bash
|
||||
python scripts/check-plugin-version-sync.py
|
||||
python scripts/check-server-version-sync.py
|
||||
```
|
||||
|
||||
Check all release tracks at once with:
|
||||
|
||||
```bash
|
||||
python scripts/check-version-tracks.py
|
||||
```
|
||||
|
||||
This aggregate check reports Android, Plugin, and CLI+UI 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 `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
|
||||
artifacts.
|
||||
|
||||
### CLI / tray versioning
|
||||
|
||||
`desktop/package.json` is the CLI+UI release track's source of truth. Its version
|
||||
must match the generated CLI and Windows tray metadata. The tray is a compact
|
||||
management popup over the installed CLI and shared state; it has no chat,
|
||||
embedded terminal, plugins, voice, or separate desktop product surface. The public
|
||||
release remains one `Hermes-Relay CLI+UI` track containing CLI binaries plus the
|
||||
optional Windows installer.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `desktop/package.json` | canonical CLI version |
|
||||
| `desktop/.bun-version` | exact Bun compiler/runtime for standalone binaries |
|
||||
| `desktop/package-lock.json` | npm root/workspace package metadata |
|
||||
| `desktop/src/version.ts` | compiled CLI runtime version |
|
||||
| `desktop/tray/Cargo.toml` | native systray package version |
|
||||
| `desktop/tray/Cargo.lock` | locked systray package version |
|
||||
| `desktop/tray/tauri.conf.json` | tray application and bundle version |
|
||||
| `desktop/tray/package.json` | tray UI package version |
|
||||
| `desktop/tray/package-lock.json` | locked tray UI package version |
|
||||
|
||||
Prepare a new CLI version on its release-prep branch targeting `dev`, without
|
||||
creating a tag or npm-generated commit:
|
||||
|
||||
```powershell
|
||||
cd desktop
|
||||
npm version --no-git-tag-version 0.4.0-alpha.2
|
||||
npm run check:version-sync
|
||||
npm run verify
|
||||
```
|
||||
|
||||
The npm `version` lifecycle runs `sync:version`, which copies the canonical
|
||||
version into the generated CLI and tray metadata. If `package.json` was edited
|
||||
manually, run `npm run sync:version` before checking. `npm run verify` is the
|
||||
single Windows release-parity gate: version sync, type-check, tests, TypeScript
|
||||
build, compiled CLI smoke, and tray formatting, Clippy, check, and tests. CI runs
|
||||
the portable portions on every desktop change and the Windows tray gates separately.
|
||||
Release jobs read `desktop/.bun-version`; cross-built and Windows-built artifacts
|
||||
must not silently embed different Bun runtime versions.
|
||||
runs server tests, builds a wheel and sdist, generates checksums, and publishes
|
||||
a GitHub Release with the package artifacts.
|
||||
|
||||
## Branching policy
|
||||
|
||||
@@ -173,32 +117,12 @@ 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`. Feature completion means merged and verified on `dev`; it
|
||||
does not mean released.
|
||||
then tagging `main`.
|
||||
|
||||
**Staging is an environment, not a branch.** Deploy an exact tested `dev` SHA or
|
||||
an immutable prerelease tag (`-alpha`, `-beta`, or `-rc.N`) cut from a
|
||||
release-prepared `dev` commit. 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. Stable production tags are cut only from the new
|
||||
`main` tip after the approved `dev` → `main` release merge.
|
||||
|
||||
### Normal contribution and release flow
|
||||
|
||||
1. Fetch `origin/dev` and branch `feature/*`, `fix/*`, `docs/*`, or `chore/*`
|
||||
from that exact ref in a dedicated worktree.
|
||||
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. Create `release/<surface-version>` from current `origin/dev`, prepare the
|
||||
affected surface version and notes there, and merge its PR into `dev`.
|
||||
8. Fast-forward local `dev` to the exact merged `origin/dev`, then 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.
|
||||
**Server tracks `dev` for staging.** The hermes-host deployment pulls
|
||||
`dev` so merged features get exercised against real data before they
|
||||
reach a tag. Users (Play Store, sideload, `hermes-relay-update`) only
|
||||
see state that lives on `main` and on release tags.
|
||||
|
||||
### Branch names
|
||||
|
||||
@@ -208,12 +132,10 @@ never create a staging branch. Stable production tags are cut only from the new
|
||||
| `fix/<name>` | Focused bug fix | `fix/media-projection-fgs` |
|
||||
| `docs/<name>` | Docs-only changes larger than a typo | `docs/sideload-guide` |
|
||||
| `chore/<name>` | Cleanup / refactor / tooling | `chore/sync-version-sources` |
|
||||
| `integration/<batch>` | Maintainer-owned batch of reviewed branches | `integration/android-routing-batch` |
|
||||
| `release/<surface-version>` | Surface release preparation targeting `dev` | `release/android-1.13.0` |
|
||||
|
||||
All of the above branch from current `origin/dev` and merge back to `dev`.
|
||||
There is no straight-to-main exemption — even single-file typos go through a
|
||||
task branch and PR into `dev`.
|
||||
All of the above branch off `dev` and merge back to `dev`. There is no
|
||||
straight-to-main exemption — even single-file typos go through a feature
|
||||
branch and PR into `dev`.
|
||||
|
||||
### Merge style: `--no-ff`
|
||||
|
||||
@@ -231,45 +153,32 @@ preserves the branch context as a visible merge commit in
|
||||
|
||||
Squash merges lose that detail and are **not** the house style.
|
||||
|
||||
### Version bumps happen on release-prep branches, NOT feature branches
|
||||
### Version bumps happen at release-prep on `dev`, NOT on feature branches
|
||||
|
||||
Feature branches **never** touch `gradle/libs.versions.toml`,
|
||||
plugin-owned version metadata, or `desktop/package.json`.
|
||||
server-owned version metadata, or `desktop/package.json`.
|
||||
If two feature branches both bumped a release version, they'd collide on
|
||||
version files and, for Android, on `appVersionCode` (which must be
|
||||
monotonic).
|
||||
|
||||
Version-bump commits land on `dev` through the release-prep PR as the final
|
||||
release-preparation commit. 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` →
|
||||
Version-bump commits live on `dev` as the last commit of release-prep
|
||||
work. Android commits use `release(android): android-vX.Y.Z`; server commits
|
||||
use `release(server): server-vX.Y.Z`; desktop commits use the existing
|
||||
`release: desktop-vX.Y.Z` convention. A release PR then merges `dev` →
|
||||
`main` with `--no-ff`, and the matching tag is cut from the resulting
|
||||
`main` tip.
|
||||
|
||||
### Branch protection
|
||||
|
||||
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.
|
||||
Light branch protection is enabled:
|
||||
|
||||
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.
|
||||
- **`main`** — direct pushes blocked; only release PRs from `dev` merge
|
||||
here. PR must pass CI (Android + Server) before merge. Force push and
|
||||
branch deletion blocked.
|
||||
- **`dev`** — direct pushes blocked for non-trivial work; feature
|
||||
branches PR in. PR must pass CI. Force push and branch deletion
|
||||
blocked.
|
||||
- Signed commits + review approval NOT required (solo-dev overhead).
|
||||
|
||||
## One-time Setup
|
||||
|
||||
@@ -366,34 +275,22 @@ for the full text.
|
||||
|
||||
### 3. Play Developer API service account (optional)
|
||||
|
||||
Required for automated upload (the `android-v*` workflow's Play step, or local
|
||||
`gradlew publishGooglePlayReleaseBundle`). Manual UI uploads work without this.
|
||||
Required only if you want `gradlew publishReleaseBundle` to upload directly
|
||||
to Play Console. Manual UI uploads work without this.
|
||||
|
||||
The service account is **created in Google Cloud Console** and then **authorized
|
||||
in Play Console** — two separate consoles. (Play Console's older "Setup > API
|
||||
access" page has been reorganized; there is no longer a "Setup" group. Use the
|
||||
paths below.)
|
||||
|
||||
1. **Create the service account (Google Cloud Console).** Open
|
||||
<https://console.cloud.google.com/iam-admin/serviceaccounts>, pick the project
|
||||
(any project works; if Play Console's **API access** page already names a linked
|
||||
project, use that one). **Create service account** → name it e.g.
|
||||
`hermes-relay-publisher` → **Done**. No project roles needed.
|
||||
2. **Create a JSON key.** On the new service account → **Keys** tab → **Add key >
|
||||
Create new key > JSON** → download. This file's *contents* are the secret.
|
||||
3. **Authorize it in Play Console.** Open the Play Console account-level left
|
||||
sidebar → **Users and permissions** → **Invite new users** → paste the service
|
||||
account's email (`...@...iam.gserviceaccount.com`). Under **App permissions**
|
||||
(for `com.axiomlabs.hermesrelay`) or **Account permissions**, grant the
|
||||
**Release** permissions — "Release apps to testing tracks" and "Release to
|
||||
production, exclude devices, and use Play App Signing" — plus "View app
|
||||
information". (Granting **Admin (all permissions)** also works but is broader
|
||||
than needed.) **Invite user**.
|
||||
4. **Use it.** For CI, paste the JSON contents into the `PLAY_SERVICE_ACCOUNT_JSON`
|
||||
repo secret (step 4 / secrets table). For local publish, save the JSON as
|
||||
`play-service-account.json` in the repo root (already in `.gitignore`).
|
||||
5. Verify locally with `gradlew bootstrapGooglePlayReleaseResources` — succeeds
|
||||
without auth errors once permissions propagate (allow a few minutes).
|
||||
1. Open <https://console.cloud.google.com/> and select the project linked
|
||||
to your Play Console account (Play Console > Setup > API access shows
|
||||
which one).
|
||||
2. **IAM & Admin > Service Accounts > Create Service Account** (e.g.
|
||||
`hermes-relay-publisher`). No project roles needed.
|
||||
3. On the new service account, **Keys > Add key > Create new key > JSON**
|
||||
and download the file.
|
||||
4. In Play Console > **Setup > API access**, find the service account,
|
||||
click **Grant access**, and assign the **Release manager** role.
|
||||
5. Save the JSON as `play-service-account.json` in the repo root (already
|
||||
in `.gitignore`).
|
||||
6. Verify with `gradlew bootstrapReleasePlayResources` — should succeed
|
||||
without auth errors.
|
||||
|
||||
### 4. GitHub Actions secrets
|
||||
|
||||
@@ -425,27 +322,10 @@ it sit alongside in `[Unreleased]`, and ship them together. A release
|
||||
is a statement to users that "this is a thing worth updating to," so
|
||||
the threshold is intent-driven, not event-driven.
|
||||
|
||||
If you want to dogfood a frozen `dev` release candidate without declaring GA,
|
||||
tag the exact release-prepared `dev` commit with a **prerelease** tag such as
|
||||
`android-vX.Y.Z-rc.N` or `server-vX.Y.Z-rc.N`. Android prereleases publish the
|
||||
side-by-side **HR Candidate** app and never upload to Play. Plugin prereleases
|
||||
publish opt-in packages for staging and do not automatically replace production.
|
||||
See [Review builds and release candidates](docs/review-candidates.md).
|
||||
|
||||
For one-PR review, do not bump versions or create a tag. Apply the
|
||||
`review-candidate` label to an open PR targeting `dev`. It produces one
|
||||
short-lived matched Android + Relay artifact; the **HR Candidate** app uses a
|
||||
separate application ID and the Relay package requires an explicit staging or
|
||||
snapshot/rollback install.
|
||||
|
||||
## 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.
|
||||
If you want to dogfood accumulated `main` state without declaring GA,
|
||||
tag a **pre-release** (`android-vX.Y.Z-rc.N`). Users can opt in via
|
||||
`hermes-relay-update --branch rc/vX.Y.Z-rc.N` without being auto-pushed
|
||||
the unstable build.
|
||||
|
||||
## Release Process
|
||||
|
||||
@@ -470,12 +350,6 @@ the new app version and a higher `appVersionCode`.
|
||||
|
||||
### 2. Update release notes and changelog
|
||||
|
||||
> Each surface has its own GitHub-Release-body file, all in the same format
|
||||
> (Summary + Added/Changed/Fixed + Install/Verify): `RELEASE_NOTES.md` (Android),
|
||||
> `PLUGIN_RELEASE_NOTES.md` (plugin), `CLI_RELEASE_NOTES.md` (CLI). This step covers
|
||||
> the Android artifacts; the plugin/CLI files are filled in their own release
|
||||
> sections below but follow the identical scrub and Keep-a-Changelog grouping.
|
||||
|
||||
- `CHANGELOG.md` — promote the accumulated `[Unreleased]` block to a
|
||||
versioned header. The block already exists: every feature PR has
|
||||
been appending to it. All you do here is:
|
||||
@@ -485,89 +359,22 @@ the new app version and a higher `appVersionCode`.
|
||||
3. Skim the new versioned block and tighten / reorder if needed —
|
||||
Keep-a-Changelog grouping (`Added` / `Changed` / `Fixed`) should
|
||||
already be in place from the accumulator phase.
|
||||
4. **Per-surface split.** `[Unreleased]` accumulates entries from *all
|
||||
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 `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.)
|
||||
- `RELEASE_NOTES.md` — body of the GitHub Release for this version
|
||||
(rewritten each release; the workflow uses this as-is). This is the
|
||||
operator-facing summary, not the CHANGELOG mirror. Keep the
|
||||
**Download** section near the top, in the required format (#144):
|
||||
1. A lead callout naming the **one file most people want** —
|
||||
"Installing on your phone? Download
|
||||
`hermes-relay-<version>-sideload-release.apk` and tap it"
|
||||
(full feature set), with the Play Store link for the
|
||||
conservative build.
|
||||
2. One explicit line that the `.aab` is a Play Console upload
|
||||
bundle and **cannot** be installed by tapping it on a phone.
|
||||
3. The `SHA256SUMS.txt` verify line + sideload-guide link.
|
||||
No download table, no parity/testing artifacts: releases attach
|
||||
exactly **two** app artifacts — the sideload APK and the googlePlay
|
||||
AAB — plus `SHA256SUMS.txt` covering exactly those two (the 2-asset
|
||||
policy in `.github/workflows/release-android.yml`; the parity twins
|
||||
stay reproducible from the tag via CI but are not attached).
|
||||
Every artifact is version-tagged as
|
||||
**Download** section near the top — it should spell out which file
|
||||
to grab by its `-sideload-release.apk` / `-googlePlay-release.aab`
|
||||
suffix (every artifact is version-tagged as
|
||||
`hermes-relay-<version>-<flavor>-<buildType>` via `archivesName`
|
||||
in `app/build.gradle.kts`. Never rename the sideload APK — the
|
||||
in-app update checker matches assets by `.apk` + `sideload` in the
|
||||
name, and user-docs verify steps cite the filename.
|
||||
The release workflow also retains
|
||||
`app/build/outputs/mapping/{googlePlayRelease,sideloadRelease}/mapping.txt`
|
||||
for 90 days in the `android-r8-mappings-<version>-<sha>` workflow
|
||||
artifact. It is intentionally not a GitHub Release asset. To symbolicate an
|
||||
in-app or sideload report, download the artifact for the exact version/SHA and
|
||||
run Android's retrace tool with the matching flavor mapping:
|
||||
`retrace <mapping.txt> <obfuscated-trace.txt>`. Play reports can additionally
|
||||
use the mapping bundled into the uploaded AAB through Play Console.
|
||||
in `app/build.gradle.kts`) and link to the sideload guide.
|
||||
The v0.3.0 body is a good template.
|
||||
- `app/src/main/assets/whats_new.txt` — in-app "What's New" content
|
||||
shown in the settings/about screen. Update with the version number
|
||||
and a brief feature summary. Gets stale silently if forgotten
|
||||
(v0.4.0 shipped with 0.1.0 content until caught post-release).
|
||||
- `app/src/googlePlay/play/release-notes/en-US/default.txt` — the Play
|
||||
Console **"What's new"** text, which gradle-play-publisher reads at
|
||||
upload to fill the Production-draft release notes. This is **separate**
|
||||
from `RELEASE_NOTES.md` (that one is only the GitHub Release body) — if
|
||||
this file is missing or stale, the Play draft ships with empty/wrong
|
||||
notes (shipped empty in v1.1.0 until caught post-release). Keep it
|
||||
**≤500 chars per language**, user-facing, Android-only.
|
||||
- `docs/play-store-listing.md` — Play Store listing copy. Update
|
||||
the version reference and the "Release Notes" section that gets
|
||||
pasted into the Play Console "What's new" field. Keep the Play
|
||||
"What's new" within **500 characters** and framed around the
|
||||
release's themes, not a feature dump. Compare its **Foreground service
|
||||
permissions** section with the merged `googlePlayRelease` manifest and
|
||||
complete Play Console declarations for every declared service type before
|
||||
approval; the Publisher API can upload a draft and still reject promotion
|
||||
when an App content declaration is missing.
|
||||
|
||||
#### Scrub for public distribution
|
||||
|
||||
This is a **public repo** and these four files are user-facing. Before
|
||||
promoting the `[Unreleased]` block and writing the notes, scrub the
|
||||
versioned CHANGELOG block and all three release-notes artifacts for
|
||||
wording that shouldn't ship publicly. The CHANGELOG accumulates in a
|
||||
dev-log voice during the iteration phase — release-prep is where it
|
||||
becomes public copy. Check for and remove/rewrite:
|
||||
|
||||
- **Personal names / quoted asides** — `git grep -niE "bailey|: \"" CHANGELOG.md`
|
||||
on the new block. Attribute fixes impersonally ("a user reported"),
|
||||
not by name. (Author identity already lives in git + the signing cert.)
|
||||
- **Private infrastructure** — server hostnames/IPs, `~/SYSTEM.md`,
|
||||
internal deployment names, anything that should stay in the operator's
|
||||
environment and not the repo. `grep -niE "192\.168|10\.0\.|hermes-host|SYSTEM\.md"`.
|
||||
(Example IPs like `192.168.1.100` in install docs are fine.)
|
||||
- **Fork / branch plumbing + internal nicknames** — references to private
|
||||
fork branches, rollout channels, or in-team incident nicknames read as
|
||||
internal. Keep the *what changed*, drop the *where we staged it*.
|
||||
- **Personal example data** — genericize sample profile/agent names to
|
||||
neutral placeholders so the copy doesn't expose a specific setup.
|
||||
|
||||
The goal is that someone who has never seen the repo can read the block
|
||||
and the release notes and learn only what the software does.
|
||||
pasted into the Play Console "What's new" field.
|
||||
|
||||
### 3. Build and verify locally
|
||||
|
||||
@@ -590,41 +397,11 @@ prefixed `hermes-relay-<version>-` via `archivesName` in
|
||||
Optional device smoke test: `scripts\dev.bat release` then
|
||||
`adb install -r app\build\outputs\apk\sideload\release\hermes-relay-*-sideload-release.apk`.
|
||||
|
||||
### 4. Run the private Play preflight from `dev`
|
||||
### 4. Commit on `dev`, merge to `main`, tag from `main`
|
||||
|
||||
The release-prep commit lands on `dev` first. Before any public tag or GitHub
|
||||
Release exists, open **Actions → Hermes-Relay Android Play Preflight**, choose **Run
|
||||
workflow**, select the final `dev` branch, and enter the prepared version.
|
||||
|
||||
The preflight workflow:
|
||||
|
||||
1. requires the workflow to run from `dev` or untagged `main` with matching
|
||||
version metadata;
|
||||
2. runs the release metadata, locale, and Android collection-API checks;
|
||||
3. builds and release-signs the same APK/AAB variants used by the public release;
|
||||
4. scans the final minified APK DEX for unsupported collection calls;
|
||||
5. uploads the Google Play AAB as a private **Production draft**; and
|
||||
6. records a 30-day preflight proof keyed to the version and Git tree hash.
|
||||
|
||||
No sideload APK or GitHub Release is published by preflight. A successful signed
|
||||
build, final DEX scan, and Production-draft upload is the automated Play release
|
||||
gate. Play Console pre-review and pre-launch reports are informational and
|
||||
non-blocking because their detailed results are not exposed through the release
|
||||
automation API. If the release source changes after preflight, rerun it—the
|
||||
approval workflow matches the complete Git tree, not just the version number.
|
||||
|
||||
GitHub exposes manual workflows only after their workflow file exists on the
|
||||
default branch. For the first release that introduces this process, merge the
|
||||
release PR without creating a tag, run preflight from untagged `main`, and then
|
||||
use the approval workflow. This publishes no app artifacts before the automated
|
||||
Play upload gate.
|
||||
|
||||
### 5. Merge to `main` and approve the public release
|
||||
|
||||
After Play preflight passes, merge the release PR from `dev` to `main`
|
||||
with `--no-ff`. The merge commit may differ from the preflight commit, but its
|
||||
tree must be identical. If the merge changes the tree, rerun private preflight
|
||||
from untagged `main`:
|
||||
The release-prep commit lands on `dev` first. Then a release PR merges
|
||||
`dev` → `main` with `--no-ff`, and the `android-v<version>` tag is cut from the
|
||||
resulting merge commit on `main`:
|
||||
|
||||
```bash
|
||||
# From a clean dev checkout:
|
||||
@@ -636,50 +413,33 @@ git add gradle/libs.versions.toml RELEASE_NOTES.md CHANGELOG.md \
|
||||
git commit -m "release(android): android-v0.6.2"
|
||||
git push origin dev
|
||||
|
||||
# Run Hermes-Relay Android Play Preflight from dev and require a successful workflow.
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from the new main tip:
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag android-v0.6.2
|
||||
git push origin android-v0.6.2
|
||||
```
|
||||
|
||||
Then open **Actions → Hermes-Relay Android Release Approval**, choose **Run workflow**, select
|
||||
`main`, and enter the version. Starting the workflow is the release approval. It
|
||||
verifies that `main` has the exact preflighted tree and creates the
|
||||
`android-v<version>` tag. Because tags created with `GITHUB_TOKEN` do not trigger
|
||||
another workflow, approval dispatches the current release workflow definition
|
||||
from `main`; every release job explicitly checks out and verifies the immutable
|
||||
`android-v<version>` tag. This lets release-workflow fixes apply without moving
|
||||
an existing tag or changing its artifact tree. Manual stable tags are still
|
||||
guarded by the same preflight proof in the tag workflow.
|
||||
Pushing a tag matching `android-v*` triggers `.github/workflows/release-android.yml`,
|
||||
which builds, signs, checksums, and creates a GitHub Release. Watch the
|
||||
run under the **Actions** tab.
|
||||
|
||||
The tag-triggered `.github/workflows/release-android.yml` rebuilds and scans the
|
||||
artifacts, changes the existing Play Production draft to `completed` (submitting
|
||||
it for review), and only after Play accepts that operation creates the public
|
||||
GitHub Release with the sideload APK. A missing preflight, changed release tree,
|
||||
missing Play credential, or Play submission failure prevents public GitHub
|
||||
publication.
|
||||
Server/Python version files are intentionally not part of an Android app
|
||||
release unless the server package itself is also being released.
|
||||
|
||||
Plugin/Python version files are intentionally not part of an Android app
|
||||
release unless the plugin package itself is also being released.
|
||||
### Server / Python package release
|
||||
|
||||
### Plugin / 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
|
||||
`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.
|
||||
Name the promoted changelog heading `## [Plugin <version>]`; the compatibility
|
||||
tag remains `server-v<version>`.
|
||||
Use this when Server behavior changes independently of Android app
|
||||
delivery, for example desktop channel support, bridge routes, pairing
|
||||
server fixes, voice auth, or packaging changes.
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull --ff-only origin dev
|
||||
|
||||
bash scripts/bump-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
|
||||
bash scripts/bump-server-version.sh 0.6.2
|
||||
git add pyproject.toml plugin/relay/__init__.py plugin/plugin.yaml plugin/dashboard/manifest.json plugin/dashboard/package.json plugin/dashboard/package-lock.json CHANGELOG.md
|
||||
git commit -m "release(server): server-v0.6.2"
|
||||
git push origin dev
|
||||
|
||||
@@ -691,110 +451,38 @@ git tag server-v0.6.2
|
||||
git push origin server-v0.6.2
|
||||
```
|
||||
|
||||
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.
|
||||
Pushing `server-v*` triggers `.github/workflows/release-server.yml`, which
|
||||
validates all server-owned version metadata with
|
||||
`scripts/check-server-version-sync.py`, runs server tests, builds a wheel and
|
||||
sdist, generates `SHA256SUMS.txt`, and creates a GitHub Release for the server
|
||||
package.
|
||||
|
||||
### CLI+UI release
|
||||
### 5. Upload to Play Console
|
||||
|
||||
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+UI release and promote only
|
||||
CLI/tray-relevant changelog bullets into the release block. The compatibility
|
||||
tag and source directory remain `desktop-v<version>` and `desktop/`. Then:
|
||||
|
||||
```powershell
|
||||
git switch dev
|
||||
git pull --ff-only origin dev
|
||||
|
||||
cd desktop
|
||||
npm version --no-git-tag-version 0.4.0-alpha.2
|
||||
npm run verify
|
||||
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(desktop): desktop-v0.4.0-alpha.2"
|
||||
git push origin dev
|
||||
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from main:
|
||||
git switch main
|
||||
git pull --ff-only origin main
|
||||
cd desktop
|
||||
npm run check:version-sync -- --expect 0.4.0-alpha.2
|
||||
cd ..
|
||||
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
|
||||
`origin/main`, reruns CLI tests, builds all four standalone binaries, tests and
|
||||
packages the Windows tray, generates checksums, and publishes the GitHub Release.
|
||||
|
||||
### 6. Play review and publishing behavior
|
||||
|
||||
> **Stable Android releases require `PLAY_SERVICE_ACCOUNT_JSON`.** Preflight
|
||||
> uploads the Production draft; approval promotes that same version code to
|
||||
> `completed`. Play Console-only reports are informational and non-blocking.
|
||||
> Stable releases do not fall back to publishing GitHub first when Play
|
||||
> credentials or submission are unavailable.
|
||||
>
|
||||
> This automated path is intentionally bundle-only. It uploads the
|
||||
> `googlePlayRelease` AAB and release-scoped "What's new" notes, but it does
|
||||
> not republish static listing assets such as screenshots, title, description,
|
||||
> icon, or feature graphic. Use the Play Store Listing workflow when those
|
||||
> assets change.
|
||||
|
||||
If Play Console **Managed publishing** is enabled, an approved submission remains
|
||||
under **Changes ready to publish** until a Play Console user publishes it. If it
|
||||
is disabled, the production submission may become available after Google review.
|
||||
Either behavior begins only after the public-release approval described above.
|
||||
|
||||
**Pick the track first.** The AAB is track-agnostic — the same
|
||||
`-googlePlay-release.aab` goes to whichever track you publish on. Choose by intent,
|
||||
not habit:
|
||||
|
||||
- **Production** — the default for a stable GA release (`android-vX.Y.Z`). The
|
||||
listing is live, so this is where real releases land. The org account is
|
||||
D-U-N-S-verified, so the 14-day / 12-tester closed-testing gate does **not**
|
||||
apply — you can publish straight to Production.
|
||||
- **Open / Closed testing** — only when you actually want a public/private beta
|
||||
channel for this build.
|
||||
- **Internal testing** — only for a throwaway pre-release smoke check (e.g. a
|
||||
prerelease tag), not for a GA. Don't default here.
|
||||
|
||||
**Manual upload:**
|
||||
**Manual upload (default):**
|
||||
|
||||
1. Download the file ending in `-googlePlay-release.aab` from the GitHub
|
||||
Release assets (for example, `hermes-relay-1.0.0-googlePlay-release.aab`),
|
||||
Release assets (for example, `hermes-relay-0.3.0-googlePlay-release.aab`),
|
||||
or use your local build at
|
||||
`app\build\outputs\bundle\googlePlayRelease\hermes-relay-<version>-googlePlay-release.aab`.
|
||||
2. In Play Console, open the track you chose above — for a GA that's
|
||||
**Release > Production**.
|
||||
2. In Play Console: **Release > Testing > Internal testing** (the 14-day
|
||||
closed-testing rule does NOT apply to this account — see "Google Play
|
||||
Console developer account" above).
|
||||
3. **Create new release** > upload the AAB.
|
||||
4. Paste the Play "What's new" from `docs/play-store-listing.md` (≤500 chars) into
|
||||
the release notes field. (`RELEASE_NOTES.md` is the GitHub-Release body, not the
|
||||
Play field — don't paste that; it's over the limit.)
|
||||
5. **Review release** > **Start rollout** (set the staged-rollout percentage if you
|
||||
want a gradual production ramp).
|
||||
4. Paste `RELEASE_NOTES.md` into the release notes field.
|
||||
5. **Review release** > **Start rollout.**
|
||||
|
||||
**Automated upload (if `play-service-account.json` is configured):**
|
||||
|
||||
```bat
|
||||
scripts\dev.bat bundle
|
||||
gradlew publishReleaseBundle --track=production
|
||||
gradlew publishReleaseBundle
|
||||
```
|
||||
|
||||
The `play { }` block in `app/build.gradle.kts` defaults to the `internal` track
|
||||
with `DRAFT` status as a safety net for unattended runs, so pass `--track` explicitly
|
||||
for a real release: `--track=production` (GA), or `--track=alpha` (Closed) /
|
||||
`--track=beta` (Open) for a beta channel.
|
||||
Defaults to the `internal` track with `DRAFT` status (configured in the
|
||||
`play { }` block in `app/build.gradle.kts`). Override per-invocation with
|
||||
`--track=alpha` (= Closed testing), `--track=beta` (= Open testing), or
|
||||
`--track=production`.
|
||||
|
||||
To promote an existing release between tracks without rebuilding:
|
||||
|
||||
@@ -802,26 +490,20 @@ To promote an existing release between tracks without rebuilding:
|
||||
gradlew promoteReleaseArtifact --from-track=internal --promote-track=alpha
|
||||
```
|
||||
|
||||
### 7. Tracks (a menu, not a mandatory ladder)
|
||||
### 6. Promote through tracks
|
||||
|
||||
The org account is exempt from the 14-day / 12-tester closed-testing rule, so a
|
||||
stable GA publishes **straight to Production** — there is no required promotion
|
||||
chain. The other tracks are opt-in tools, not steps you must climb:
|
||||
Typical path:
|
||||
|
||||
- **Production** — live on the Play Store. Where GA releases go.
|
||||
- **Open testing (beta)** — opt-in public beta channel.
|
||||
- **Closed testing (alpha)** — opt-in private beta (named tester lists).
|
||||
- **Internal testing** — throwaway smoke check (e.g. a prerelease tag), no tester
|
||||
or time minimum.
|
||||
1. **Internal testing** — personal smoke test (no tester or time minimum)
|
||||
2. **Closed testing (alpha)** — optional for staged rollout; Axiom-Labs'
|
||||
org account is exempt from the 14-day / 12-tester rule, so you can skip
|
||||
straight from Internal to Production if the build is ready
|
||||
3. **Open testing (beta)** — optional public beta
|
||||
4. **Production** — live on the Play Store
|
||||
|
||||
If you *do* stage through tracks, promote an existing release without rebuilding via
|
||||
the Play Console UI or:
|
||||
Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
|
||||
|
||||
```bat
|
||||
gradlew promoteReleaseArtifact --from-track=internal --promote-track=production
|
||||
```
|
||||
|
||||
### 8. After release
|
||||
### 7. After release
|
||||
|
||||
- Verify the GitHub Release has APK, AAB, and `SHA256SUMS.txt` attached.
|
||||
- Confirm the release body includes the **Download** section that tells
|
||||
@@ -840,56 +522,42 @@ gradlew promoteReleaseArtifact --from-track=internal --promote-track=production
|
||||
|
||||
## CI Behavior
|
||||
|
||||
Android, Plugin, dashboard, and desktop now have separate CI/release lanes.
|
||||
Android, Server, dashboard, and desktop now have separate CI/release lanes.
|
||||
This keeps a dashboard CSS fix from running the full server suite, and keeps
|
||||
plugin changes from forcing an Android app `versionCode` bump.
|
||||
server changes from forcing an Android app `versionCode` bump.
|
||||
|
||||
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
|
||||
|
||||
1. Verifies a stable tag resolves to a commit contained in `main`, or a
|
||||
prerelease tag resolves to a commit contained in `dev`, and that the tag matches `appVersionName` in
|
||||
1. Validates 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.
|
||||
3. Decodes `HERMES_KEYSTORE_BASE64` into `$RUNNER_TEMP/release.keystore`
|
||||
and exports `HERMES_KEYSTORE_PATH` (skipped if the secret is unset).
|
||||
4. For stable releases, builds all four flavored release artifacts
|
||||
(`./gradlew bundleRelease assembleRelease`); only the sideload APK and
|
||||
googlePlay AAB are attached. For prereleases, builds only the side-by-side
|
||||
`sideloadCandidate` APK.
|
||||
5. Generates `SHA256SUMS.txt` covering the two attached files.
|
||||
6. For stable releases only, promotes the exact preflighted Production draft to
|
||||
`completed`; prereleases never upload to Play.
|
||||
7. Creates a GitHub Release named `Hermes-Relay Android v<version>` with `RELEASE_NOTES.md` as
|
||||
4. Builds both Android release artifacts:
|
||||
`./gradlew bundleRelease assembleRelease`.
|
||||
5. Generates `SHA256SUMS.txt` covering both.
|
||||
6. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
|
||||
the body. Attaches the APK, AAB, and `SHA256SUMS.txt`. Tags any version
|
||||
containing a dash (e.g. `android-v0.2.0-beta.1`) as a prerelease automatically.
|
||||
8. Prints a `$GITHUB_STEP_SUMMARY` with the release and Play result.
|
||||
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.
|
||||
|
||||
On every push of a tag matching `server-v*`,
|
||||
`.github/workflows/release-plugin.yml`:
|
||||
`.github/workflows/release-server.yml`:
|
||||
|
||||
1. Verifies a stable tag commit is contained in `main`, or a prerelease tag is
|
||||
contained in `dev`, then 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.
|
||||
1. Validates the tag matches all server-owned version metadata checked by
|
||||
`scripts/check-server-version-sync.py`.
|
||||
2. Runs server syntax checks and the focused route/auth/session test slice.
|
||||
3. Builds the Python wheel and sdist with `python -m build`.
|
||||
4. Generates `dist/SHA256SUMS.txt`.
|
||||
5. Creates a GitHub Release named `Hermes-Relay 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 `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.
|
||||
`desktop-v0.3.0`) so the install/pin commands stay accurate. It requires stable
|
||||
tags to be contained in `main` and prerelease tags to be contained in `dev`,
|
||||
with a version matching `desktop/package.json` and a corresponding
|
||||
`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/release-desktop.yml` builds and publishes the desktop
|
||||
CLI binaries. Dashboard-only changes are covered by
|
||||
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
|
||||
runs the dashboard API tests, and verifies the modal CSS markers are present
|
||||
in the built bundle.
|
||||
@@ -902,28 +570,12 @@ 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` | Stable Play submission | Paste the full Play Developer API service-account JSON (step 3) |
|
||||
|
||||
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, 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.
|
||||
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.
|
||||
|
||||
For an Android app hotfix:
|
||||
|
||||
@@ -937,23 +589,17 @@ 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. Verify the automated Play submission, GitHub artifacts, and rollout.
|
||||
7. Upload to Play Console as normal.
|
||||
8. Merge `main` back into `dev` (`git checkout dev && git merge --no-ff main`)
|
||||
so `dev` picks up the hotfix and the versionCode bump. Without this,
|
||||
`dev`'s `appVersionCode` lags behind `main` and the next app release
|
||||
bump collides.
|
||||
|
||||
For a Plugin hotfix, branch from the affected `server-v*` tag, apply
|
||||
the fix, run `bash scripts/bump-plugin-version.sh <next-version>`, merge to
|
||||
`main`, tag `server-v<next-version>`, verify the package/deployment, and merge
|
||||
`main` back to `dev`. Do not touch
|
||||
For a Server hotfix, branch from the affected `server-v*` tag, apply
|
||||
the fix, run `bash scripts/bump-server-version.sh <next-version>`, merge to
|
||||
`main`, and tag `server-v<next-version>`. Do not touch
|
||||
`gradle/libs.versions.toml` unless an Android app release is also shipping.
|
||||
|
||||
For a CLI+UI 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**
|
||||
|
||||
@@ -1,39 +1,33 @@
|
||||
# Hermes-Relay Android v1.13.0
|
||||
# Hermes-Relay-Android v0.8.1
|
||||
|
||||
**Release Date:** August 25, 2026
|
||||
**Release Date:** May 26, 2026
|
||||
**Since v0.8.0:** A focused patch fixing a voice-mode crash. No new features.
|
||||
|
||||
v0.8.1 is a patch release. If you don't use voice mode with barge-in enabled, v0.8.0 is unaffected — but updating is still recommended.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.13.0-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).
|
||||
v0.8.1 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
| Flavor | File | Who it's for |
|
||||
|---|---|---|
|
||||
| Google Play | `hermes-relay-0.8.1-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, wake locks, or unattended phone control. |
|
||||
| sideload | `hermes-relay-0.8.1-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-0.8.1-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-0.8.1-sideload-release.aab` | Parity/testing artifact. |
|
||||
|
||||
Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://hermes-relay.dev/docs/guide/sideload) for installation help.
|
||||
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
|
||||
|
||||
## Summary
|
||||
|
||||
This feature release adds Bot Mode across saved Hermes gateways, provider usage and limits, and bounded Assistant screen context. It also settles stale Gateway composer state, improves onboarding, and keeps idle Sphere motion efficient.
|
||||
|
||||
## Added
|
||||
|
||||
- Use Bot Mode as one messenger-style workspace across saved Hermes gateways, with exact gateway/profile ownership and read-only group rooms.
|
||||
- Review Codex credential pools, Nous balances, and OpenCode Go windows from one provider-neutral Usage & limits screen.
|
||||
- Start a compatible unlocked Assistant invocation with bounded visible text and an available screenshot in the first Standard voice turn.
|
||||
|
||||
## Changed
|
||||
|
||||
- Follow the Dashboard-first setup path with current screenshots and clearer separation between standard Hermes and optional Relay extensions.
|
||||
- Use clear `Hermes-Relay Android` and isolated `HR Candidate` product names without changing package identities or update behavior.
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
- Settle orphaned Gateway busy state automatically while preserving active or detached turns owned by another session.
|
||||
- Keep the visible idle Sphere gently animated without running hidden, backgrounded, or motion-disabled loops.
|
||||
- Retry Windows-hosted `MEDIA:` attachments through the Relay by-path route instead of treating drive-letter paths as expired tokens.
|
||||
### Voice mode crash with barge-in on legacy TTS playback
|
||||
|
||||
## Install / Verify
|
||||
Starting voice mode with **barge-in enabled** while the relay served audio over the legacy `/voice/synthesize` path crashed the app the instant the agent began speaking — the first word or two played, then the app died with `Player is accessed on the wrong thread`.
|
||||
|
||||
- App version: **1.13.0** (versionCode **49**).
|
||||
- Standard Chat, sessions, Manage, sharing, profile switching, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
|
||||
- The optional Relay plugin enhances provider usage, media retry, and device surfaces but remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
|
||||
The barge-in listener reads the audio session id from a background thread to attach the echo canceller, but Media3's `ExoPlayer` is thread-confined and throws when its `audioSessionId` getter is read off the main thread. `VoicePlayer.audioSessionId` is now backed by a thread-safe cache populated from main-thread playback callbacks, so it's safe to read from any thread.
|
||||
|
||||
This only affected the **opt-in** barge-in feature on the legacy text-to-speech path; the provider-native Realtime Agent and Voice Output paths were never affected.
|
||||
|
||||
@@ -14,7 +14,7 @@ Native Android companion for the [Hermes agent platform](https://github.com/Nous
|
||||
|
||||
### Desktop track (parallel lane to Android) — **experimental**
|
||||
|
||||
Release tags: `cli-v*` (separate cadence from Android `android-v*` and Plugin `plugin-v*`). Historical alpha prereleases used `desktop-v*`, and the installer/updater keep a migration fallback. Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-cli.yml`](.github/workflows/release-cli.yml).
|
||||
Release tags: `desktop-v*` (separate cadence from Android `android-v*` and Server `server-v*`). Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-desktop.yml`](.github/workflows/release-desktop.yml).
|
||||
|
||||
**Shipped (2026-04-23 — first tagged release `desktop-v0.3.0-alpha.1`):**
|
||||
|
||||
@@ -47,11 +47,11 @@ Release tags: `cli-v*` (separate cadence from Android `android-v*` and Plugin `p
|
||||
|
||||
**Earlier alpha.2–alpha.5 workstreams (now in-flight / done — see DEVLOG 2026-04-23 entries for specifics):**
|
||||
|
||||
- **`hermes-relay update` subcommand + auto-update nudge.** The binary self-update path polls the GitHub Releases API, prefers `cli-v*`, falls back to historical `desktop-v*` prereleases during migration, compares to `readVersion()`, and downloads the binary directly + `rename` over the current one (Windows can rename while running; Linux/macOS atomic replace is fine for long-lived daemons because the running process keeps the old inode open). Add a once-per-day background check in `daemon` mode that emits `update_available` as a log event — opt-in via `--check-updates`, never auto-installs without user action. Signing prerequisite: SmartScreen/Gatekeeper would warn on every auto-downloaded binary until we sign, so this is behind code signing.
|
||||
- **`hermes-relay update` subcommand + auto-update nudge.** The binary today does NOT self-update — users have to re-run the `curl | sh` / `irm | iex` one-liner to pick up a new release. Close the gap: `hermes-relay update` polls the GitHub Releases API, filters to `desktop-v*`, compares to `readVersion()`, and either shells out to the installer or downloads the binary directly + `rename` over the current one (Windows can rename while running; Linux/macOS atomic replace is fine for long-lived daemons because the running process keeps the old inode open). Add a once-per-day background check in `daemon` mode that emits `update_available` as a log event — opt-in via `--check-updates`, never auto-installs without user action. Signing prerequisite: SmartScreen/Gatekeeper would warn on every auto-downloaded binary until we sign, so this is behind code signing.
|
||||
- **Workspace-awareness — desktop client sends cwd/git/hostname on connect.** Biggest lingering "is the agent working against the right tree?" problem. On WSS auth, the client advertises an ephemeral workspace descriptor — `cwd`, `git_root`, `git_branch`, `git_status_summary` (staged/modified counts), `repo_name`, `hostname`, `platform`, `active_shell`. Server-side `DesktopHandler` stashes it as live session metadata (NOT persistent state). New hermes-agent plugin hook injects a one-line ephemeral prompt prefix into the session context — *"Active desktop workspace: machine=Bailey-PC · repo=hermes-relay · branch=dev · staged=3"* — so the LLM reads it every turn without the operator having to explain. Also default `desktop_terminal` / `desktop_read_file` / `desktop_search_files` `cwd` to the repo root when unset. Expose the snapshot in `hermes-relay doctor` + `hermes-relay status` + a new `hermes-relay workspace` subcommand + a relay dashboard tab so both operator and agent have a common view. Pair with a `.hermes/workspace-context.json` file-based fallback for when the socket path can't be reached. Requires: new WSS envelope (`desktop.workspace` on connect), hermes-agent plugin hook for ephemeral context injection, schema coordination with the upstream `ContextVar` multi-client work.
|
||||
- **Service installers** — `scripts/install-service-{win,linux,mac}.{ps1,sh}` — Windows Service via `sc.exe create`, `systemd --user` unit with `loginctl enable-linger`, `launchctl load` plist for macOS. Auto-start on login so the daemon is always reachable.
|
||||
- **Multi-client routing on the `desktop` channel** — replace single-client MVP with per-token indexing + device-id reconnect handoff. Hermes session state carries `desktop_session_token` via a new `ContextVar` in `gateway/session_context.py` (hermes-agent PR candidate — won't affect Android). Natural pairing with the workspace-awareness envelope — the ContextVar scheme determines which client's workspace the active session sees.
|
||||
- **Harden `release-cli.yml` retag semantics.** The `softprops/action-gh-release` step failed during the alpha.1 retag with `tag_name already_exists` after deleting + re-uploading all 5 assets; recovered by `gh api` cleanup (delete orphan draft + PATCH draft→false on the release with the real assets). Follow-up: pin the action version, add `make_latest: false` + explicit `release_id` lookup, or switch to `ncipollo/release-action` which handles retags without the duplicate-draft creation.
|
||||
- **Harden `release-desktop.yml` retag semantics.** The `softprops/action-gh-release` step failed during the alpha.1 retag with `tag_name already_exists` after deleting + re-uploading all 5 assets; recovered by `gh api` cleanup (delete orphan draft + PATCH draft→false on the release with the real assets). Follow-up: pin the action version, add `make_latest: false` + explicit `release_id` lookup, or switch to `ncipollo/release-action` which handles retags without the duplicate-draft creation.
|
||||
- **Signed binaries** — Windows EV code-signing (~$300/yr, DigiCert or SSL.com) + Apple Developer ID + notarization ($99/yr). Removes SmartScreen/Gatekeeper warnings. Prerequisite for the auto-update path.
|
||||
- **npm registry publication** — future v1.0 distribution work. The package name is local workspace metadata today; current install paths are GitHub Release binaries or local clone + `npm link`.
|
||||
- **HMAC verification on QR payloads** — defer until a client-accessible secret story exists (same deferral as the Android app). Not blocking GA.
|
||||
@@ -109,7 +109,7 @@ Small follow-ons to v0.4 deliberately deferred to keep the v0.4.0 release surfac
|
||||
|
||||
**Files.** New `hermes_relay_bootstrap/_command_middleware.py` (~150 LOC), one-line append in `_patch.py` inside `_maybe_register_routes`, stdlib `unittest` coverage in `plugin/tests/test_bootstrap_command_middleware.py` mirroring the existing `test_bootstrap_patch.py` harness. Mirrors the upstream Option B PR exactly so the two can be reviewed side-by-side.
|
||||
|
||||
**Phase 2 — stateful dispatch on the session chat stream endpoint (unblocked by PR #33134).** Since `/api/sessions/{id}/chat/stream` now ships natively in upstream, a separate middleware (or a follow-up upstream PR) can add a preprocessor **scoped to that endpoint only**, leveraging the `session_id` in the URL as the persistence handle. At that point stateful commands become a dict write against session-scoped state — `session.model_override = new_model` — without needing to refactor `GatewayRouter` or plumb api_server into the router. Much smaller than a full router refactor, and it matches upstream's partition: `/v1/*` stays stateless and statefulness lives on `/api/sessions/*`.
|
||||
**Phase 2 — stateful dispatch on the session chat stream endpoint (unblocked by PR #33134 / commit `f7527b0`).** Since `/api/sessions/{id}/chat/stream` now ships natively in upstream, a separate middleware (or a follow-up upstream PR) can add a preprocessor **scoped to that endpoint only**, leveraging the `session_id` in the URL as the persistence handle. At that point stateful commands become a dict write against session-scoped state — `session.model_override = new_model` — without needing to refactor `GatewayRouter` or plumb api_server into the router. Much smaller than a full router refactor, and it matches upstream's partition: `/v1/*` stays stateless, statefulness lives on `/api/sessions/*`.
|
||||
|
||||
## Future — v0.5+
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
Hermes-Relay can give a remote AI agent real control of a phone and, via the
|
||||
CLI, of a paired desktop. We take security reports seriously and welcome
|
||||
responsible disclosure.
|
||||
|
||||
For the architecture, threat model, and the `googlePlay` vs. `sideload`
|
||||
capability boundary, see [`docs/security.md`](docs/security.md). This document
|
||||
covers **how to report a problem**.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not open a public issue, discussion, or pull request for a security
|
||||
vulnerability.** Public reports expose users before a fix is available.
|
||||
|
||||
Use one of these private channels instead:
|
||||
|
||||
1. **GitHub Private Vulnerability Reporting (preferred).** Go to the
|
||||
repository's **Security** tab → **Report a vulnerability**, or
|
||||
[open a draft advisory directly](https://github.com/Codename-11/hermes-relay/security/advisories/new).
|
||||
This keeps the whole exchange private and threaded with the code.
|
||||
2. **Email** — `security@codename-11.dev`. Use this if you can't use GitHub.
|
||||
If you'd like to encrypt the report, say so in a first contact message and
|
||||
we'll arrange a key.
|
||||
|
||||
### What to include
|
||||
|
||||
A good report lets us reproduce and assess impact quickly:
|
||||
|
||||
- The affected surface — **Android app** (and which flavor, `googlePlay` or
|
||||
`sideload`), **relay plugin / server**, **desktop CLI**, or the **docs site**.
|
||||
- Affected version(s) — app version/code, plugin version, or CLI version.
|
||||
- A clear description of the issue and its security impact.
|
||||
- Step-by-step reproduction, a proof of concept, or a minimal example.
|
||||
- Any suggested remediation, if you have one.
|
||||
|
||||
> ⚠️ **Scrub secrets before sending.** Remove API keys, relay session tokens,
|
||||
> pairing codes, real hostnames/IPs, and personal data from logs, traces, and
|
||||
> screenshots.
|
||||
|
||||
## What to Expect
|
||||
|
||||
This is an indie, open-source project, so timelines are best-effort rather than
|
||||
contractual:
|
||||
|
||||
- **Acknowledgement** of your report — typically within **5 business days**.
|
||||
- An initial **assessment and severity triage** after we can reproduce it.
|
||||
- **Coordinated disclosure:** we'll work with you on a fix and a disclosure
|
||||
timeline, and credit you in the advisory and release notes if you'd like
|
||||
(or keep you anonymous if you prefer).
|
||||
- A public GitHub Security Advisory and a `CHANGELOG.md` entry once a fix ships.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope** — vulnerabilities in code this project ships:
|
||||
|
||||
- The Android app (`app/`) on either flavor.
|
||||
- The relay plugin and server (`plugin/`).
|
||||
- The desktop CLI (`desktop/`).
|
||||
- The pairing, auth, transport, media, and tool-routing surfaces.
|
||||
|
||||
**Out of scope** — please report these to the right place instead:
|
||||
|
||||
- **Your own Hermes server configuration** (missing TLS, an exposed dashboard,
|
||||
weak provider keys). The relay connects only to endpoints you configure; how
|
||||
you deploy and secure your Hermes host is outside this app. See
|
||||
[`docs/security.md`](docs/security.md) and the relay-server docs for hardening
|
||||
guidance.
|
||||
- **Upstream [hermes-agent](https://github.com/NousResearch/hermes-agent)**
|
||||
issues — report those to the upstream project (a heads-up to us is welcome if
|
||||
it affects how Hermes-Relay should behave).
|
||||
- **Third-party dependencies** — report upstream; if a dependency issue affects
|
||||
Hermes-Relay users, tell us so we can pin or patch.
|
||||
- Findings that require a **rooted device, a physical-access attacker, or a
|
||||
malicious app already granted Accessibility/overlay permissions** — these are
|
||||
outside the model documented in `docs/security.md`, though we'll still read
|
||||
the report.
|
||||
|
||||
## Safe Harbor
|
||||
|
||||
We consider security research conducted in good faith under this policy to be
|
||||
authorized. We will not pursue or support legal action against researchers who:
|
||||
|
||||
- Make a good-faith effort to avoid privacy violations, data destruction, and
|
||||
service disruption.
|
||||
- Test only against **their own devices, installs, and Hermes servers** — never
|
||||
another person's data or infrastructure.
|
||||
- Report promptly and give us a reasonable chance to remediate before any
|
||||
public disclosure.
|
||||
|
||||
Thank you for helping keep Hermes-Relay and its users safe.
|
||||
@@ -1,8 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
fun String.asBuildConfigString(): String =
|
||||
"\"" + replace("\\", "\\\\").replace("\"", "\\\"") + "\""
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
@@ -10,19 +7,6 @@ plugins {
|
||||
alias(libs.plugins.play.publisher)
|
||||
}
|
||||
|
||||
val supportedHermesDevAbis = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
val hermesDevAbi = providers.gradleProperty("hermes.devAbi").orNull
|
||||
val candidateKind = providers.gradleProperty("candidate.kind").orElse("review").get()
|
||||
val candidateLabel = providers.gradleProperty("candidate.label").orElse("Local review").get()
|
||||
val candidateSourceRef = providers.gradleProperty("candidate.sourceRef").orElse("local").get()
|
||||
val candidateSourceSha = providers.gradleProperty("candidate.sourceSha").orElse("unknown").get()
|
||||
hermesDevAbi?.let { requestedAbi ->
|
||||
require(requestedAbi in supportedHermesDevAbis) {
|
||||
"Unsupported hermes.devAbi '$requestedAbi'. Expected one of: " +
|
||||
supportedHermesDevAbis.sorted().joinToString()
|
||||
}
|
||||
}
|
||||
|
||||
// Rename output artifacts to include the app version. AGP respects
|
||||
// `archivesName` for both APK (assemble*) and AAB (bundle*) outputs, so
|
||||
// this single line produces `hermes-relay-<version>-<flavor>-<buildType>`
|
||||
@@ -43,7 +27,7 @@ android {
|
||||
// and `applicationId` is the runtime install identity; they don't have
|
||||
// to match.
|
||||
namespace = "com.hermesandroid.relay"
|
||||
compileSdk = 37
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
// Axiom-Labs, LLC Play Console listing. Changed from the original
|
||||
@@ -53,30 +37,14 @@ android {
|
||||
// exempt from Play's 14-day closed-testing rule. See RELEASE.md.
|
||||
applicationId = "com.axiomlabs.hermesrelay"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
targetSdk = 35
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
versionName = libs.versions.appVersionName.get()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// Optional local-only fast path for device iteration. Native voice/VAD
|
||||
// dependencies make the universal sideload APK very large, while a
|
||||
// connected phone needs only its own ABI. Release and normal debug
|
||||
// builds remain universal unless the developer explicitly supplies
|
||||
// -Phermes.devAbi=<abi>.
|
||||
hermesDevAbi?.let { requestedAbi ->
|
||||
ndk {
|
||||
abiFilters += requestedAbi
|
||||
}
|
||||
}
|
||||
|
||||
// Feature flags — DEV_MODE enables all experimental features in debug builds
|
||||
buildConfigField("boolean", "DEV_MODE", "false")
|
||||
buildConfigField("boolean", "CANDIDATE_BUILD", "false")
|
||||
buildConfigField("String", "CANDIDATE_KIND", "".asBuildConfigString())
|
||||
buildConfigField("String", "CANDIDATE_LABEL", "".asBuildConfigString())
|
||||
buildConfigField("String", "CANDIDATE_SOURCE_REF", "".asBuildConfigString())
|
||||
buildConfigField("String", "CANDIDATE_SOURCE_SHA", "".asBuildConfigString())
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -138,26 +106,12 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// Structural guard: the sideload flavor is distributed via GitHub Releases /
|
||||
// F-Droid / ADB and must NEVER be uploaded to Play Console (it declares the
|
||||
// unattended Device Control surface Play forbids). gradle-play-publisher
|
||||
// generates a publish task per variant, so the aggregate `publishReleaseBundle`
|
||||
// would otherwise try BOTH flavors. Disabling sideload here means only
|
||||
// `publishGooglePlayReleaseBundle` can ever reach Play — see the `play { }`
|
||||
// block below and .github/workflows/release-android.yml.
|
||||
playConfigs {
|
||||
register("sideload") {
|
||||
enabled.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
buildConfigField("boolean", "DEV_MODE", "true")
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
ndk {
|
||||
debugSymbolLevel = "SYMBOL_TABLE"
|
||||
}
|
||||
@@ -173,18 +127,6 @@ android {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
create("candidate") {
|
||||
initWith(getByName("release"))
|
||||
applicationIdSuffix = ".candidate"
|
||||
versionNameSuffix = "-candidate"
|
||||
isDebuggable = false
|
||||
matchingFallbacks += listOf("release")
|
||||
buildConfigField("boolean", "CANDIDATE_BUILD", "true")
|
||||
buildConfigField("String", "CANDIDATE_KIND", candidateKind.asBuildConfigString())
|
||||
buildConfigField("String", "CANDIDATE_LABEL", candidateLabel.asBuildConfigString())
|
||||
buildConfigField("String", "CANDIDATE_SOURCE_REF", candidateSourceRef.asBuildConfigString())
|
||||
buildConfigField("String", "CANDIDATE_SOURCE_SHA", candidateSourceSha.asBuildConfigString())
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -209,21 +151,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
// sherpa-onnx v1.13.4 and the Silero VAD both use ONNX Runtime.
|
||||
// Keep them on sherpa's 1.27.0 baseline and package one shared core.
|
||||
pickFirsts += "**/libonnxruntime.so"
|
||||
|
||||
// The Android app calls only sherpa's JNI facade. These native C/C++
|
||||
// API facades are development surfaces and are not loaded by the app.
|
||||
excludes += setOf(
|
||||
"**/libsherpa-onnx-c-api.so",
|
||||
"**/libsherpa-onnx-cxx-api.so",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// JVM unit tests run against the stubbed Android SDK jar, where every
|
||||
// platform API method throws RuntimeException("... not mocked") by
|
||||
// default. With returnDefaultValues = true, those stubs instead
|
||||
@@ -239,16 +166,6 @@ android {
|
||||
// Robolectric (VoicePlayerTest) needs merged Android resources +
|
||||
// manifest on the unit-test classpath to bootstrap its sandbox.
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
// [POC] Roborazzi runs without its Gradle plugin (the plugin needs AGP's
|
||||
// removed TestedExtension). Force record mode via the test-JVM system
|
||||
// property the plugin would otherwise inject, so captureRoboImage writes.
|
||||
// Heap: the Roborazzi store renders (1080×2160 native graphics) share a
|
||||
// worker JVM with the Robolectric suites; Gradle's 512m default OOMs
|
||||
// once both are in the same run.
|
||||
unitTests.all {
|
||||
it.systemProperty("roborazzi.test.record", "true")
|
||||
it.maxHeapSize = "2g"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,17 +185,6 @@ kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
// [screenshots] Host-side screenshot tests render MessageBubble -> MarkdownContent,
|
||||
// whose code-highlighter (dev.snipme.highlights) ships Java-21 bytecode. The build
|
||||
// toolchain pins test execution to JDK 17, which can't load class-file v65, so run
|
||||
// unit tests on a 21 JVM. Compile target stays 17; on-device (dexed) is unaffected.
|
||||
// foojay (settings.gradle.kts) auto-provisions the 21 JDK if absent.
|
||||
tasks.withType<Test>().configureEach {
|
||||
javaLauncher.set(
|
||||
javaToolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(21)) }
|
||||
)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose BOM
|
||||
val composeBom = platform(libs.compose.bom)
|
||||
@@ -305,8 +211,6 @@ dependencies {
|
||||
|
||||
// Activity
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.browser)
|
||||
implementation(libs.appcompat)
|
||||
|
||||
// Core
|
||||
implementation(libs.core.ktx)
|
||||
@@ -322,34 +226,12 @@ dependencies {
|
||||
// Bundled ONNX Silero model (~2.2 MB); pulled from JitPack.
|
||||
implementation(libs.android.vad.silero)
|
||||
|
||||
// Experimental, opt-in local keyword spotting. Models are downloaded only
|
||||
// after the user enables the feature; no model binary is bundled in APKs.
|
||||
// Keep the shared runtime aligned with sherpa-onnx v1.13.4.
|
||||
implementation(libs.onnxruntime.android)
|
||||
implementation(libs.sherpa.onnx)
|
||||
|
||||
// Google Play In-App Update — googlePlay flavor ONLY (FLEXIBLE flow).
|
||||
// Scoped via the `googlePlayImplementation` configuration so it never
|
||||
// ships in the sideload APK, which updates via the GitHub-releases
|
||||
// UpdateChecker instead. The `app/src/googlePlay/.../update/` impl
|
||||
// references AppUpdateManager; the `app/src/sideload/.../update/` impl
|
||||
// never touches this library.
|
||||
"googlePlayImplementation"(libs.play.app.update)
|
||||
"googlePlayImplementation"(libs.play.app.update.ktx)
|
||||
|
||||
// Markdown rendering
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
implementation(libs.markdown.renderer.code)
|
||||
|
||||
// Coil 3 — async image loading for generated images in chat
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.gif)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
implementation(libs.exifinterface)
|
||||
|
||||
// QR Code scanning (ML Kit + CameraX)
|
||||
implementation(libs.mlkit.barcode)
|
||||
implementation(libs.zxing.core)
|
||||
implementation(libs.camera.core)
|
||||
implementation(libs.camera.camera2)
|
||||
implementation(libs.camera.lifecycle)
|
||||
@@ -367,9 +249,6 @@ dependencies {
|
||||
|
||||
// Security
|
||||
implementation(libs.security.crypto)
|
||||
// Force a Tink newer than security-crypto's transitive one — older Tink's
|
||||
// HybridConfig removeFirst()/removeLast() trips the Android-15 crash lint.
|
||||
implementation(libs.tink.android)
|
||||
|
||||
// DataStore
|
||||
implementation(libs.datastore.preferences)
|
||||
@@ -387,22 +266,8 @@ dependencies {
|
||||
// across priority groups against real local sockets so the behavior we
|
||||
// validate matches on-device.
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
// Konsist — enforces the ADR 34 upstream/relay/shared package fence as a JUnit test
|
||||
testImplementation(libs.konsist)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
// On-device vanilla-Gateway contract tests exercise the production
|
||||
// Dashboard ticket + WebSocket stack over real loopback sockets.
|
||||
androidTestImplementation(libs.okhttp.mockwebserver)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
|
||||
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
|
||||
// Renders real composables on the JVM at an exact canvas — no device, no
|
||||
// status bar, no clipping. See StoreScreenshotTest.
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.72.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.72.0")
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.ui.test.manifest)
|
||||
testImplementation("androidx.test.ext:junit:1.3.0")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Local device-review helper. Never runs in or ships with the application APK. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ConnectionReviewSeedTest {
|
||||
|
||||
@Test
|
||||
fun seedOfflineSecondaryConnection() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val store = ConnectionStore(context)
|
||||
store.isHydrated.first { it }
|
||||
if (store.connections.value.none { it.id == REVIEW_ID }) {
|
||||
store.addConnection(
|
||||
Connection(
|
||||
id = REVIEW_ID,
|
||||
label = "Lab NAS",
|
||||
apiServerUrl = "",
|
||||
relayUrl = "",
|
||||
tokenStoreKey = Connection.buildTokenStoreKey(REVIEW_ID),
|
||||
dashboardUrl = "http://192.0.2.10:9119",
|
||||
lastUsedAt = System.currentTimeMillis() - 2L * 24L * 60L * 60L * 1_000L,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeOfflineSecondaryConnection() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val store = ConnectionStore(context)
|
||||
store.isHydrated.first { it }
|
||||
if (store.connections.value.any { it.id == REVIEW_ID }) {
|
||||
store.removeConnection(REVIEW_ID)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val REVIEW_ID = "00000000-0000-4000-8000-000000000220"
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package com.hermesandroid.relay.plugins.ui
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.hermesandroid.relay.plugins.document.PluginDocumentState
|
||||
import com.hermesandroid.relay.plugins.document.PluginElement
|
||||
import com.hermesandroid.relay.plugins.document.PluginPage
|
||||
import com.hermesandroid.relay.plugins.document.PluginText
|
||||
import com.hermesandroid.relay.plugins.document.PluginValue
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class PluginDocumentRendererTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun pageRendersBindingsAndEmitsControlledStateChanges() {
|
||||
var interaction: PluginInteraction? = null
|
||||
val page = PluginPage(
|
||||
id = "home",
|
||||
title = PluginText.Binding("title", "Fallback"),
|
||||
content = PluginElement.Group(
|
||||
id = "root",
|
||||
children = listOf(
|
||||
PluginElement.Text(
|
||||
id = "message",
|
||||
text = PluginText.Binding("message"),
|
||||
),
|
||||
PluginElement.Toggle(
|
||||
id = "enabled-toggle",
|
||||
label = PluginText.Literal("Enabled"),
|
||||
binding = "enabled",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val state = PluginDocumentState(
|
||||
mapOf(
|
||||
"title" to PluginValue.StringValue("Status plugin"),
|
||||
"message" to PluginValue.StringValue("Everything is healthy"),
|
||||
"enabled" to PluginValue.BooleanValue(false),
|
||||
),
|
||||
)
|
||||
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PluginPageRenderer(page, state, { interaction = it })
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Status plugin").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Everything is healthy").assertIsDisplayed()
|
||||
composeTestRule.onNode(isToggleable()).performClick()
|
||||
|
||||
assertEquals(
|
||||
PluginInteraction.ValueChanged(
|
||||
elementId = "enabled-toggle",
|
||||
key = "enabled",
|
||||
value = PluginValue.BooleanValue(true),
|
||||
),
|
||||
interaction,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.avatar.AgentAvatar
|
||||
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
||||
import com.hermesandroid.relay.ui.components.avatar.AvatarSource
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalBackgroundVisualizationEnabled
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class AmbientVisualizationVisibilityTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun cleanMode_backgroundOff_hidesSphereAndKeepsComposer() {
|
||||
composeTestRule.setContent {
|
||||
AmbientTestProviders(enabled = false) {
|
||||
CleanChatMode(
|
||||
messages = emptyList(),
|
||||
isStreaming = false,
|
||||
sphereState = SphereState.Idle,
|
||||
streamingIntensity = 0f,
|
||||
toolCallBurst = 0f,
|
||||
animationEnabled = true,
|
||||
enabled = true,
|
||||
onSend = {},
|
||||
onExit = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithContentDescription(targetString(R.string.agent_text_send_cd))
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cleanMode_backgroundOn_rendersSphere() {
|
||||
composeTestRule.setContent {
|
||||
AmbientTestProviders(enabled = true) {
|
||||
CleanChatMode(
|
||||
messages = emptyList(),
|
||||
isStreaming = false,
|
||||
sphereState = SphereState.Idle,
|
||||
streamingIntensity = 0f,
|
||||
toolCallBurst = 0f,
|
||||
animationEnabled = false,
|
||||
enabled = true,
|
||||
onSend = {},
|
||||
onExit = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceMode_backgroundOff_hidesSphereAndKeepsVoiceUi() {
|
||||
composeTestRule.setContent {
|
||||
AmbientTestProviders(enabled = false) {
|
||||
TestVoiceOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(targetString(R.string.voice_overlay_tap_mic)).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceMode_backgroundOn_rendersSphere() {
|
||||
composeTestRule.setContent {
|
||||
AmbientTestProviders(enabled = true) {
|
||||
TestVoiceOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertExists()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AmbientTestProviders(enabled: Boolean, content: @Composable () -> Unit) {
|
||||
MaterialTheme {
|
||||
CompositionLocalProvider(
|
||||
LocalAgentAvatar provides TaggedAmbientRenderer,
|
||||
LocalBackgroundVisualizationEnabled provides enabled,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TestVoiceOverlay() {
|
||||
VoiceModeOverlay(
|
||||
uiState = VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Idle,
|
||||
interactionMode = InteractionMode.TapToTalk,
|
||||
),
|
||||
onMicTap = {},
|
||||
onMicRelease = {},
|
||||
onInterrupt = {},
|
||||
onDismiss = {},
|
||||
onModeChange = {},
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
|
||||
private fun targetString(id: Int): String =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
|
||||
|
||||
private object TaggedAmbientRenderer : AgentAvatar {
|
||||
override val id = "ambient-test"
|
||||
override val label = "Ambient test"
|
||||
override val description = "Test renderer"
|
||||
override val source = AvatarSource.BUILT_IN
|
||||
override val reactivity = SphereReactivity()
|
||||
|
||||
@Composable
|
||||
override fun Render(state: AvatarRenderState, modifier: Modifier) {
|
||||
Box(modifier = modifier.testTag(AMBIENT_RENDERER_TAG))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val AMBIENT_RENDERER_TAG = "ambientVisualizationRenderer"
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNode
|
||||
import androidx.compose.ui.test.performClick
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class PowerFeatureGateUiTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun requiresPairingCard_showsPairToUnlock() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Terminal",
|
||||
summary = "Open a server shell through your paired relay session.",
|
||||
status = PowerFeatureGateStatus.RequiresPairing,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Requires pairing").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Pair to unlock").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("This feature uses relay grants", substring = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expiredPairingCard_showsPairAgain() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Bridge",
|
||||
summary = "Let Hermes send approved bridge commands to this phone.",
|
||||
status = PowerFeatureGateStatus.PairingExpired,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Pairing expired").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Pair again").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardSignInCard_usesDashboardLanguage() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
PowerFeatureGateCard(
|
||||
title = "Manage",
|
||||
summary = "Open dashboard-backed management features.",
|
||||
status = PowerFeatureGateStatus.DashboardSignInRequired,
|
||||
onPrimaryAction = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Dashboard sign-in required").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Open sign-in").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNode
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for the Standard-first onboarding pager.
|
||||
* Instrumented tests for the onboarding pager flow.
|
||||
*
|
||||
* These tests require an Android device or emulator because they use
|
||||
* Compose UI testing APIs and interact with real Compose components.
|
||||
*/
|
||||
class OnboardingFlowTest {
|
||||
|
||||
@@ -21,205 +24,270 @@ class OnboardingFlowTest {
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private fun setOnboardingContent() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val connectionViewModel = ConnectionViewModel(app)
|
||||
composeTestRule.setContent {
|
||||
HermesRelayTheme {
|
||||
OnboardingScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = {},
|
||||
onComplete = { _, _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Page 1: Welcome ---
|
||||
|
||||
@Test
|
||||
fun firstPage_showsHermesForAndroidTitle() {
|
||||
fun firstPage_showsHermesRelayTitle() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes-Relay for Android")
|
||||
.onNodeWithText("Hermes-Relay")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstPage_showsStandardFirstDescription() {
|
||||
fun firstPage_showsWelcomeDescription() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Chat with Hermes and manage your dashboard from your phone.")
|
||||
.onNodeWithText("Your AI agent, in your pocket. Chat, control, and connect — all from your phone.")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Skip button ---
|
||||
|
||||
@Test
|
||||
fun skipButton_isAlwaysVisible_onFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Standard")
|
||||
.onNodeWithText("Skip")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Navigation: Next button ---
|
||||
|
||||
@Test
|
||||
fun nextButton_isDisplayed_onFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Advanced")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Setup Guide")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes Docs")
|
||||
.onNodeWithText("Next")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextButton_navigatesForward_toChatPage() {
|
||||
fun nextButton_navigatesForward_toPage2() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Page 1 -> Page 2
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 2 is "Talk to Your Agent"
|
||||
composeTestRule
|
||||
.onNodeWithText("Chat")
|
||||
.onNodeWithText("Talk to Your Agent")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canNavigateForward_throughStandardAndPowerPages() {
|
||||
fun canNavigateForward_throughAllPages() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
|
||||
// Page 1: Hermes-Relay (Welcome)
|
||||
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
|
||||
// Page 2: Talk to Your Agent (Chat)
|
||||
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Manage").assertIsDisplayed()
|
||||
// Page 3: Remote Terminal
|
||||
composeTestRule.onNodeWithText("Remote Terminal").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Power tools").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Connect").performClick()
|
||||
// Page 4: Device Bridge
|
||||
composeTestRule.onNodeWithText("Device Bridge").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 5: Connect to Hermes
|
||||
composeTestRule.onNodeWithText("Connect to Hermes").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 6: Relay Server (last page)
|
||||
composeTestRule.onNodeWithText("Relay Server").assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Back button ---
|
||||
|
||||
@Test
|
||||
fun backButton_hiddenOnFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
// On page 1, Back should not exist
|
||||
composeTestRule
|
||||
.onNodeWithText("Back")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backButton_navigatesBackward() {
|
||||
fun backButton_visibleOnPage2() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Back")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backButton_navigatesBackward() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Go to page 2
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
|
||||
|
||||
// Go back to page 1
|
||||
composeTestRule.onNodeWithText("Back").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Page 5: Connect page ---
|
||||
|
||||
@Test
|
||||
fun connectPage_recommendsGeneralSetupQr() {
|
||||
fun connectPage_hasApiServerUrlField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
navigateToPage(4) // 0-indexed, page 5 is index 4
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Scan Hermes setup QR")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Recommended")
|
||||
.onNodeWithText("API Server URL")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manualSetup_showsHermesAddressWithoutApiCredentials() {
|
||||
fun connectPage_hasApiKeyField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Server or VPS").performClick()
|
||||
composeTestRule
|
||||
.onNodeWithText("API Key (optional)", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_whereDoIFindThis_showsHelpDialog() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
// Tap "Where do I find this?"
|
||||
composeTestRule
|
||||
.onNodeWithText("Where do I find this?")
|
||||
.performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog should show
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes address")
|
||||
.onNodeWithText("Do I need an API key?")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manualSetup_findButton_isShown() {
|
||||
fun connectPage_helpDialog_canBeDismissed() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Server or VPS").performClick()
|
||||
composeTestRule.onNodeWithText("Where do I find this?").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog is showing
|
||||
composeTestRule.onNodeWithText("Do I need an API key?").assertIsDisplayed()
|
||||
|
||||
// Dismiss it
|
||||
composeTestRule.onNodeWithText("Got it").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog should be gone
|
||||
composeTestRule
|
||||
.onNodeWithText("Find Hermes")
|
||||
.onNodeWithText("Do I need an API key?")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
// --- Page 6: Relay page ---
|
||||
|
||||
@Test
|
||||
fun relayPage_showsOptionalMessaging() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5) // Last page
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("This is optional", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cloudSetup_requestsTheHostedDashboardAddress() {
|
||||
fun relayPage_showsRelayUrlField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Nous-hosted Hermes").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
navigateToPage(5)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Connect to Nous-hosted Hermes")
|
||||
.onNodeWithText("Relay URL (optional)")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Get Started button ---
|
||||
|
||||
@Test
|
||||
fun lastPage_showsGetStartedButton() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Use the complete HTTPS address shown for your hosted agent.")
|
||||
.onNodeWithText("Get Started")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_keepsPairingOptional() {
|
||||
fun lastPage_getStartedButton_isEnabled_withDefaultUrl() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Advanced").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
navigateToPage(5)
|
||||
|
||||
// Default URL is "http://localhost:8642" which is non-blank
|
||||
composeTestRule
|
||||
.onNodeWithText("Pair Relay by code")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Power-user path for Terminal, Bridge, Relay sessions, and grants")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun powerPage_linksToPermissionReview() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(3)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Review permissions")
|
||||
.assertIsDisplayed()
|
||||
.onNodeWithText("Get Started")
|
||||
.assertIsEnabled()
|
||||
}
|
||||
|
||||
// --- Skip button visibility across pages ---
|
||||
|
||||
@Test
|
||||
fun skipButton_visibleOnIntroPages_andWizardSkipOnConnectPage() {
|
||||
fun skipButton_visibleOnAllPages() {
|
||||
setOnboardingContent()
|
||||
|
||||
repeat(4) {
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
// Check skip on first page
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Skip for now — set up later in Settings")
|
||||
.assertIsDisplayed()
|
||||
// Navigate through all pages and check skip
|
||||
for (i in 0 until 5) {
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
|
||||
private fun navigateToPage(pageIndex: Int) {
|
||||
repeat(pageIndex) {
|
||||
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,157 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented smoke tests for the current Terminal and Bridge surfaces.
|
||||
* Instrumented tests for Terminal and Bridge empty state screens.
|
||||
*/
|
||||
class EmptyStateTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsCurrentTopBar() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val terminalViewModel = TerminalViewModel(app)
|
||||
val connectionViewModel = ConnectionViewModel(app)
|
||||
// --- Terminal Screen ---
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen(
|
||||
terminalViewModel = terminalViewModel,
|
||||
connectionViewModel = connectionViewModel,
|
||||
)
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Terminal").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription("Search scrollback").assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Remote Terminal")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsCurrentTopBar() {
|
||||
fun terminalScreen_showsPhase2Chip() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Coming in Phase 2")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsDescription() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Secure shell access", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsTopBarTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Terminal")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsPlannedFeatures() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Full ANSI terminal emulator", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("tmux session management", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Bridge Screen ---
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Bridge").assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Device Bridge")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsPhase3Chip() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Coming in Phase 3")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsDescription() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Let your Hermes agent interact with your phone", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsTopBarTitle() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Bridge")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeScreen_showsPlannedFeatures() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Agent-controlled device interaction", substring = true)
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Permission management", substring = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class PermissionsStatusScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun permissionsScreen_showsStandardAndOnDemandRows() {
|
||||
composeTestRule.setContent {
|
||||
HermesRelayTheme {
|
||||
PermissionsStatusScreen(
|
||||
onBack = {},
|
||||
onOpenBridge = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Permissions and capabilities")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Chat and Manage")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("No Android runtime permission needed. API/dashboard auth is configured separately.")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Camera")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Microphone")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Opt-in physical-device/emulator adapter for the shared Python fixture.
|
||||
*
|
||||
* Pass `-e gatewayFixtureBaseUrl http://127.0.0.1:8765` after exposing the
|
||||
* host fixture with `adb reverse`. With no argument this test alone is skipped;
|
||||
* the embedded regression remains fully standalone.
|
||||
*/
|
||||
class GatewayExternalFixtureInstrumentedTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private var gatewayScope: CoroutineScope? = null
|
||||
private var gatewayClient: GatewayChatClient? = null
|
||||
private var viewModel: ChatViewModel? = null
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
viewModel?.updateGatewayClient(null)
|
||||
gatewayClient?.shutdown()
|
||||
gatewayScope?.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalGapActivate_externalFixtureRecoversFromAuthoritativeHttpHistory() {
|
||||
val fixtureBaseUrl = InstrumentationRegistry.getArguments()
|
||||
.getString(ARG_FIXTURE_BASE_URL)
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
assumeTrue(
|
||||
"Pass -e $ARG_FIXTURE_BASE_URL <url> to run the external fixture lane",
|
||||
!fixtureBaseUrl.isNullOrBlank(),
|
||||
)
|
||||
requireNotNull(fixtureBaseUrl)
|
||||
|
||||
val okHttp = OkHttpClient.Builder()
|
||||
.callTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
val initialState = readFixtureJson(okHttp, "$fixtureBaseUrl/__fixture__/state")
|
||||
assertEquals("terminal_gap_activate", initialState["scenario"]?.jsonString())
|
||||
assertEquals("1", initialState["remaining_turns"].toString())
|
||||
val dashboard = DashboardApiClient(fixtureBaseUrl, okHttp)
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { gatewayScope = it }
|
||||
val gateway = GatewayChatClient(
|
||||
initialDashboardClient = dashboard,
|
||||
okHttpClient = okHttp,
|
||||
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
|
||||
scope = scope,
|
||||
reconnectJitterUnit = { 0.0 },
|
||||
).also { gatewayClient = it }
|
||||
val handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
|
||||
val vm = ChatViewModel().also {
|
||||
// Deliberately omit HermesApiClient: this lane has no API-server
|
||||
// fallback surface, so a passing turn proves Gateway ownership.
|
||||
it.initialize(null, handler)
|
||||
it.streamingEndpoint = "gateway"
|
||||
it.setProfileMessageLoaderWithMode { profile, sessionId, mode ->
|
||||
dashboard.getSessionMessages(sessionId, profile, mode)
|
||||
}
|
||||
it.updateGatewayClient(gateway)
|
||||
it.setChatVisible(true)
|
||||
}.also { viewModel = it }
|
||||
|
||||
compose.setContent {
|
||||
val messages by vm.messages.collectAsStateWithLifecycle()
|
||||
val streaming by vm.isStreaming.collectAsStateWithLifecycle()
|
||||
MaterialTheme {
|
||||
Column(Modifier.testTag("external-contract-transcript")) {
|
||||
Text(
|
||||
text = if (streaming) "STREAMING" else "IDLE",
|
||||
modifier = Modifier.testTag("external-stream-state"),
|
||||
)
|
||||
messages.forEach { message ->
|
||||
Text(
|
||||
text = "${message.role.name}:${message.content}",
|
||||
modifier = Modifier.testTag("external-message-${message.id}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(runBlocking { gateway.prewarmAwait(STORED_SESSION_ID) })
|
||||
vm.sendMessage("Exercise terminal gap.")
|
||||
|
||||
compose.waitUntil(10_000) {
|
||||
!handler.isStreaming.value &&
|
||||
!gateway.hasActiveTurn() &&
|
||||
handler.messages.value.any {
|
||||
it.role == MessageRole.ASSISTANT && it.content == AUTHORITATIVE_ANSWER
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag("external-contract-transcript").assertIsDisplayed()
|
||||
compose.onNodeWithTag("external-stream-state").assertTextEquals("IDLE")
|
||||
compose.onAllNodesWithText("${MessageRole.ASSISTANT.name}:$AUTHORITATIVE_ANSWER")
|
||||
.assertCountEquals(1)
|
||||
|
||||
val messages = handler.messages.value
|
||||
assertEquals(
|
||||
1,
|
||||
messages.count {
|
||||
it.role == MessageRole.ASSISTANT && it.content == AUTHORITATIVE_ANSWER
|
||||
},
|
||||
)
|
||||
assertEquals(1, messages.count { it.role == MessageRole.USER })
|
||||
assertFalse(messages.any { it.isStreaming || it.isThinkingStreaming })
|
||||
assertEquals("gateway", vm.streamingEndpoint)
|
||||
|
||||
val evidence = readFixtureJson(okHttp, "$fixtureBaseUrl/__fixture__/evidence")
|
||||
assertEquals("terminal_gap_activate", evidence["scenario"]?.jsonString())
|
||||
val entries = evidence["entries"] as? JsonArray ?: JsonArray(emptyList())
|
||||
assertEquals(1, entries.rpcCount("prompt.submit"))
|
||||
assertEquals(1, entries.rpcCount("session.activate"))
|
||||
|
||||
val state = readFixtureJson(okHttp, "$fixtureBaseUrl/__fixture__/state")
|
||||
assertEquals("terminal_gap_activate", state["scenario"]?.jsonString())
|
||||
assertEquals("2", state["history_rows"].toString())
|
||||
}
|
||||
|
||||
private fun readFixtureJson(client: OkHttpClient, url: String): JsonObject {
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
check(response.isSuccessful) { "fixture HTTP ${response.code}" }
|
||||
Json.parseToJsonElement(response.body.string()).jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonArray.rpcCount(method: String): Int = count { element ->
|
||||
val entry = element as? JsonObject ?: return@count false
|
||||
entry["kind"]?.jsonString() == "rpc" && entry["method"]?.jsonString() == method
|
||||
}
|
||||
|
||||
private fun kotlinx.serialization.json.JsonElement.jsonString(): String? =
|
||||
(this as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
private companion object {
|
||||
const val ARG_FIXTURE_BASE_URL = "gatewayFixtureBaseUrl"
|
||||
const val STORED_SESSION_ID = "20260821_120000_fixture"
|
||||
const val AUTHORITATIVE_ANSWER = "Persisted after the socket gap."
|
||||
}
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.HermesApiClient
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* On-device contract coverage for issue #365.
|
||||
*
|
||||
* This deliberately uses the production [GatewayChatClient], [ChatViewModel],
|
||||
* and [ChatHandler]. [DeviceGatewayFixture] supplies only the upstream HTTP/WSS
|
||||
* boundary, so Android main-looper dispatch and Compose collection are real.
|
||||
*/
|
||||
class GatewayForegroundRecoveryInstrumentedTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private lateinit var fixture: AndroidGatewayContractFixture
|
||||
private lateinit var gatewayScope: CoroutineScope
|
||||
private lateinit var gatewayClient: GatewayChatClient
|
||||
private lateinit var handler: ChatHandler
|
||||
private lateinit var viewModel: ChatViewModel
|
||||
private lateinit var serverSocket: WebSocket
|
||||
|
||||
@Volatile
|
||||
private var persistedHistory: List<MessageItem> = emptyList()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
fixture = AndroidGatewayContractFixture()
|
||||
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val okHttp = OkHttpClient()
|
||||
gatewayClient = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = okHttp,
|
||||
),
|
||||
okHttpClient = okHttp,
|
||||
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
|
||||
scope = gatewayScope,
|
||||
reconnectJitterUnit = { 0.0 },
|
||||
)
|
||||
handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
|
||||
viewModel = ChatViewModel().also {
|
||||
it.initialize(
|
||||
HermesApiClient(fixture.server.url("/").toString(), "fixture-key"),
|
||||
handler,
|
||||
)
|
||||
it.streamingEndpoint = "gateway"
|
||||
it.setProfileMessageLoader { Result.success(persistedHistory) }
|
||||
it.updateGatewayClient(gatewayClient)
|
||||
it.setChatVisible(true)
|
||||
}
|
||||
|
||||
compose.setContent {
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val streaming by viewModel.isStreaming.collectAsStateWithLifecycle()
|
||||
MaterialTheme {
|
||||
Column(Modifier.testTag("contract-transcript")) {
|
||||
Text(
|
||||
text = if (streaming) "STREAMING" else "IDLE",
|
||||
modifier = Modifier.testTag("stream-state"),
|
||||
)
|
||||
messages.forEach { message ->
|
||||
Text(
|
||||
text = "${message.role.name}:${message.content}",
|
||||
modifier = Modifier.testTag("message-${message.id}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(runBlocking { gatewayClient.prewarmAwait(STORED_SESSION_ID) })
|
||||
serverSocket = fixture.awaitServerSocket()
|
||||
fixture.awaitRpc("session.resume")
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
viewModel.updateGatewayClient(null)
|
||||
gatewayClient.shutdown()
|
||||
gatewayScope.cancel()
|
||||
fixture.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalGapActivate_recoversForegroundTurnWithoutNavigationOrCrossSessionLeak() {
|
||||
viewModel.sendMessage("Run a long foreground task")
|
||||
fixture.awaitRpc("prompt.submit")
|
||||
|
||||
// A multiplexed Gateway shares one socket. Foreign-session events must
|
||||
// neither render nor settle the visible turn.
|
||||
serverSocket.send(fixture.event("message.start", null, FOREIGN_SESSION_ID))
|
||||
serverSocket.send(
|
||||
fixture.event(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", FOREIGN_ANSWER) },
|
||||
FOREIGN_SESSION_ID,
|
||||
),
|
||||
)
|
||||
serverSocket.send(
|
||||
fixture.event(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", FOREIGN_ANSWER) },
|
||||
FOREIGN_SESSION_ID,
|
||||
),
|
||||
)
|
||||
|
||||
serverSocket.send(fixture.event("message.start", null, LIVE_SESSION_ID))
|
||||
serverSocket.send(
|
||||
fixture.event(
|
||||
"tool.start",
|
||||
buildJsonObject {
|
||||
put("tool_id", "tool-foreground")
|
||||
put("name", "terminal")
|
||||
},
|
||||
LIVE_SESSION_ID,
|
||||
),
|
||||
)
|
||||
serverSocket.send(
|
||||
fixture.event(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", PARTIAL_ANSWER) },
|
||||
LIVE_SESSION_ID,
|
||||
),
|
||||
)
|
||||
|
||||
compose.waitUntil(5_000) { handler.isStreaming.value }
|
||||
compose.onNodeWithTag("stream-state").assertTextEquals("STREAMING")
|
||||
compose.onNodeWithTag("contract-transcript").assertIsDisplayed()
|
||||
assertFalse(handler.messages.value.any { it.content.contains(FOREIGN_ANSWER) })
|
||||
|
||||
// Exercise the real Activity collection boundary while the turn is
|
||||
// still live. STARTED models a covered/backgrounded activity without
|
||||
// destroying the test host; returning to RESUMED must preserve the
|
||||
// same turn and transcript without navigation.
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
compose.waitUntil(5_000) { handler.isStreaming.value }
|
||||
compose.onNodeWithTag("stream-state").assertTextEquals("STREAMING")
|
||||
|
||||
// The server finishes while this socket is detached. The replacement
|
||||
// socket cannot replay message.complete; exact-session activation
|
||||
// reports running=false and history is now authoritative.
|
||||
persistedHistory = listOf(
|
||||
MessageItem(
|
||||
id = PERSISTED_ANSWER_ID,
|
||||
sessionId = STORED_SESSION_ID,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(AUTHORITATIVE_ANSWER),
|
||||
),
|
||||
)
|
||||
fixture.recoveryRunning = false
|
||||
serverSocket.close(1011, "fixture foreground gap")
|
||||
serverSocket = fixture.awaitServerSocket()
|
||||
fixture.awaitRpc("session.activate")
|
||||
|
||||
compose.waitUntil(5_000) {
|
||||
!handler.isStreaming.value &&
|
||||
handler.messages.value.singleOrNull()?.id == PERSISTED_ANSWER_ID
|
||||
}
|
||||
compose.onNodeWithTag("contract-transcript").assertIsDisplayed()
|
||||
compose.onNodeWithTag("stream-state").assertTextEquals("IDLE")
|
||||
compose.onNodeWithTag("message-$PERSISTED_ANSWER_ID")
|
||||
.assertTextEquals("${MessageRole.ASSISTANT.name}:$AUTHORITATIVE_ANSWER")
|
||||
|
||||
val visible = handler.messages.value
|
||||
assertEquals(1, visible.size)
|
||||
assertEquals(AUTHORITATIVE_ANSWER, visible.single().content)
|
||||
assertFalse(visible.single().isStreaming)
|
||||
assertFalse(visible.any { it.content.contains(PARTIAL_ANSWER) })
|
||||
assertFalse(visible.any { it.content.contains(FOREIGN_ANSWER) })
|
||||
assertEquals(
|
||||
"history catch-up must not duplicate the authoritative assistant row",
|
||||
1,
|
||||
compose.onAllNodesWithTag("message-$PERSISTED_ANSWER_ID").fetchSemanticsNodes().size,
|
||||
)
|
||||
assertEquals(
|
||||
"the prompt must never be resubmitted during recovery",
|
||||
1,
|
||||
fixture.rpcCount("prompt.submit"),
|
||||
)
|
||||
assertEquals(
|
||||
"the exact live session should be activated once",
|
||||
1,
|
||||
fixture.rpcCount("session.activate"),
|
||||
)
|
||||
assertEquals(0, fixture.requestsTo("/v1/chat/completions"))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STORED_SESSION_ID = "20260821_120000_fixture"
|
||||
const val LIVE_SESSION_ID = "fixture-live-1"
|
||||
const val FOREIGN_SESSION_ID = "live-foreign"
|
||||
const val PERSISTED_ANSWER_ID = "persisted-foreground-answer"
|
||||
const val PARTIAL_ANSWER = "Partial foreground answer"
|
||||
const val AUTHORITATIVE_ANSWER = "Foreground task finished."
|
||||
const val FOREIGN_ANSWER = "Wrong session content"
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal real-socket implementation of the vanilla Gateway contract used above. */
|
||||
internal class AndroidGatewayContractFixture {
|
||||
val server = MockWebServer()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val sockets = LinkedBlockingQueue<WebSocket>()
|
||||
private val allSockets = ConcurrentLinkedQueue<WebSocket>()
|
||||
private val rpcLog = ConcurrentLinkedQueue<Pair<String, JsonObject>>()
|
||||
private val requestPaths = ConcurrentLinkedQueue<String>()
|
||||
private val ticketCount = AtomicInteger(0)
|
||||
|
||||
@Volatile
|
||||
var recoveryRunning = false
|
||||
|
||||
private val listener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
sockets.add(webSocket)
|
||||
allSockets.add(webSocket)
|
||||
webSocket.send(event("gateway.ready", null, null))
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val method = (frame["method"] as? JsonPrimitive)?.contentOrNull ?: return
|
||||
val id = (frame["id"] as? JsonPrimitive)?.contentOrNull?.toLongOrNull() ?: return
|
||||
val params = frame["params"] as? JsonObject ?: JsonObject(emptyMap())
|
||||
rpcLog.add(method to params)
|
||||
|
||||
val result = when (method) {
|
||||
"session.resume" -> sessionSnapshot("fixture-live-1")
|
||||
"session.activate" -> sessionSnapshot(
|
||||
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "fixture-live-1",
|
||||
)
|
||||
"prompt.submit", "session.interrupt" -> buildJsonObject { put("ok", true) }
|
||||
else -> JsonObject(emptyMap())
|
||||
}
|
||||
webSocket.send(
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("id", id)
|
||||
put("result", result)
|
||||
}.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
requestPaths.add(path)
|
||||
return when {
|
||||
path.startsWith("/api/auth/ws-ticket") -> MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""{"ticket":"device-${ticketCount.incrementAndGet()}","ttl_seconds":30}""",
|
||||
)
|
||||
path.startsWith("/api/ws") -> MockResponse().withWebSocketUpgrade(listener)
|
||||
else -> MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
}
|
||||
server.start()
|
||||
}
|
||||
|
||||
private fun sessionSnapshot(sessionId: String): JsonObject = buildJsonObject {
|
||||
put("session_id", sessionId)
|
||||
put("running", recoveryRunning)
|
||||
put("status", if (recoveryRunning) "streaming" else "idle")
|
||||
put("info", buildJsonObject { put("profile_name", "default") })
|
||||
}
|
||||
|
||||
fun event(type: String, payload: JsonObject?, sessionId: String?): String =
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("method", "event")
|
||||
put("params", buildJsonObject {
|
||||
put("type", type)
|
||||
payload?.let { put("payload", it) }
|
||||
sessionId?.let { put("session_id", it) }
|
||||
})
|
||||
}.toString()
|
||||
|
||||
fun awaitServerSocket(): WebSocket =
|
||||
sockets.poll(5, TimeUnit.SECONDS) ?: error("Gateway WebSocket did not open")
|
||||
|
||||
fun awaitRpc(method: String): JsonObject {
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
while (System.nanoTime() < deadline) {
|
||||
rpcLog.firstOrNull { it.first == method }?.let { return it.second }
|
||||
Thread.sleep(20)
|
||||
}
|
||||
error("Gateway RPC $method not observed; saw ${rpcLog.map { it.first }}")
|
||||
}
|
||||
|
||||
fun requestsTo(path: String): Int = requestPaths.count { it.startsWith(path) }
|
||||
|
||||
fun rpcCount(method: String): Int = rpcLog.count { it.first == method }
|
||||
|
||||
fun shutdown() {
|
||||
allSockets.forEach { socket -> runCatching { socket.close(1001, "teardown") } }
|
||||
runCatching { server.shutdown() }
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher_candidate"
|
||||
android:label="HR Candidate"
|
||||
android:roundIcon="@mipmap/ic_launcher_candidate_round"
|
||||
tools:replace="android:icon,android:label" />
|
||||
</manifest>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/candidate_icon_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/candidate_icon_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="candidate_icon_background">#FFB300</color>
|
||||
</resources>
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
<activity
|
||||
android:name="com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,196 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationPlaceholder
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationResultTransition
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationVisualStyle
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
|
||||
/**
|
||||
* Debug-build-only live host for fast image-generation motion tuning.
|
||||
*
|
||||
* Launch directly:
|
||||
* adb shell am start -n <applicationId>/
|
||||
* com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity
|
||||
*/
|
||||
class ImageGenerationDesignQaActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val themePreference = intent.getStringExtra("theme") ?: "auto"
|
||||
setContent {
|
||||
HermesRelayTheme(themePreference = themePreference) {
|
||||
ImageGenerationDesignQaScene(onBack = ::finish)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ImageGenerationDesignQaScene(onBack: () -> Unit) {
|
||||
var restartKey by remember { mutableIntStateOf(0) }
|
||||
var durationMillis by remember { mutableIntStateOf(4_800) }
|
||||
var visualStyle by remember { androidx.compose.runtime.mutableStateOf(ImageGenerationVisualStyle.LatentGrid) }
|
||||
var showResult by remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Image generation lab") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Live debug preview · no generation request",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(
|
||||
ImageGenerationVisualStyle.LatentGrid to "Grid",
|
||||
ImageGenerationVisualStyle.ParticleOrb to "Orb",
|
||||
ImageGenerationVisualStyle.Constellation to "Nodes",
|
||||
).forEach { (style, label) ->
|
||||
FilterChip(
|
||||
selected = visualStyle == style,
|
||||
onClick = { visualStyle = style },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
key(restartKey, durationMillis, visualStyle) {
|
||||
val startedAtMillis = remember { System.currentTimeMillis() }
|
||||
ImageGenerationResultTransition(
|
||||
generating = !showResult,
|
||||
startedAtMillis = startedAtMillis,
|
||||
animationDurationMillis = durationMillis,
|
||||
visualStyle = visualStyle,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_generation_transition_preview),
|
||||
contentDescription = "Generated landscape preview",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "Generated image",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "12.4s",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Cycle speed",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(
|
||||
7_200 to "Slow",
|
||||
4_800 to "Normal",
|
||||
3_200 to "Fast",
|
||||
).forEach { (duration, label) ->
|
||||
FilterChip(
|
||||
selected = durationMillis == duration,
|
||||
onClick = { durationMillis = duration },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
showResult = true
|
||||
},
|
||||
enabled = !showResult,
|
||||
) {
|
||||
Text("Reveal result")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
showResult = false
|
||||
restartKey++
|
||||
},
|
||||
) {
|
||||
Text("Restart")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.network.relay.RealtimeProviderInfo
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.VoicePreviewUiState
|
||||
|
||||
/** Debug-build-only deterministic host for design QA screenshots. */
|
||||
class VoiceSettingsDesignQaActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val themePreference = intent.getStringExtra("theme") ?: "auto"
|
||||
setContent { HermesRelayTheme(themePreference = themePreference) { VoiceSettingsDesignQaScene() } }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun VoiceSettingsDesignQaScene() {
|
||||
val provider = remember {
|
||||
RealtimeProviderInfo(
|
||||
id = "xai_tts",
|
||||
name = "xAI Grok TTS",
|
||||
status = "ready",
|
||||
models = listOf("grok-tts", "grok-tts-fast"),
|
||||
voices = listOf("eve", "ara", "sal", "rex", "leo"),
|
||||
model_labels = mapOf("grok-tts" to "Grok TTS"),
|
||||
voice_labels = mapOf("eve" to "Eve", "ara" to "Ara", "sal" to "Sal"),
|
||||
recommended_voices = listOf("eve", "ara"),
|
||||
supports_tts = true,
|
||||
)
|
||||
}
|
||||
var selectedSection by remember { mutableStateOf(VoiceSettingsSection.Output) }
|
||||
var selectedVoice by remember { mutableStateOf("eve") }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val allVoices = remember {
|
||||
listOf(
|
||||
VoiceChoice("eve", "Eve", "Warm · expressive", recommended = true),
|
||||
VoiceChoice("ara", "Ara", "Clear · balanced", recommended = true),
|
||||
VoiceChoice("sal", "Sal", "Calm · grounded"),
|
||||
VoiceChoice("rex", "Rex", "Direct · confident"),
|
||||
VoiceChoice("leo", "Leo", "Bright · conversational"),
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(topBar = { TopAppBar(title = { Text("Voice") }) }) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f),
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Hermes Chat + Voice Output", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Default profile · Profile voice", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
VoiceSettingsTabs(selectedSection) { selectedSection = it }
|
||||
VoiceProviderGroupCard(
|
||||
provider = provider,
|
||||
providerValue = provider.id,
|
||||
enabled = true,
|
||||
providerChoices = listOf(VoiceChoice(provider.id, provider.name.orEmpty())),
|
||||
onEnabledChange = {},
|
||||
onProviderChange = {},
|
||||
controlsEnabled = true,
|
||||
)
|
||||
ModelAndVoiceGroupCard(
|
||||
modelValue = "grok-tts",
|
||||
modelChoices = listOf(VoiceChoice("grok-tts", "Grok TTS")),
|
||||
voices = previewVoiceChoices(allVoices, selectedVoice),
|
||||
allVoices = allVoices,
|
||||
selectedVoice = selectedVoice,
|
||||
previewState = VoicePreviewUiState(
|
||||
selectionKey = "voice:eve",
|
||||
isPlaying = true,
|
||||
amplitude = 0.42f,
|
||||
),
|
||||
onModelChange = {},
|
||||
onVoiceChange = { selectedVoice = it },
|
||||
onPreviewVoice = {},
|
||||
enabled = true,
|
||||
)
|
||||
LanguageQualityCard(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
language = "English",
|
||||
languages = listOf(VoiceChoice("en", "English")),
|
||||
onLanguageChange = {},
|
||||
sampleRate = "24000",
|
||||
sampleRates = listOf(VoiceChoice("24000", "24 kHz")),
|
||||
onSampleRateChange = {},
|
||||
enabled = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.0 MiB |
@@ -1,199 +0,0 @@
|
||||
package com.hermesandroid.relay.update
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.android.play.core.appupdate.AppUpdateInfo
|
||||
import com.google.android.play.core.appupdate.AppUpdateManager
|
||||
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
|
||||
import com.google.android.play.core.appupdate.AppUpdateOptions
|
||||
import com.google.android.play.core.install.InstallState
|
||||
import com.google.android.play.core.install.InstallStateUpdatedListener
|
||||
import com.google.android.play.core.install.model.AppUpdateType
|
||||
import com.google.android.play.core.install.model.InstallStatus
|
||||
import com.google.android.play.core.install.model.UpdateAvailability
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* === update (googlePlay flavor): factory ===
|
||||
*
|
||||
* Backs [UpdateAvailabilitySource] onto Google Play's In-App Update API,
|
||||
* FLEXIBLE flow. Mirrors `voice/VoiceBridgeIntentFactory`'s flavor-split
|
||||
* factory pattern: both flavors export this exact function signature +
|
||||
* package, so the UI layer has one static call site and no reflection / no
|
||||
* `#if` gating.
|
||||
*/
|
||||
fun createUpdateAvailabilitySource(context: Context): UpdateAvailabilitySource =
|
||||
PlayUpdateAvailabilitySource(context.applicationContext)
|
||||
|
||||
private const val TAG = "PlayUpdate"
|
||||
|
||||
/**
|
||||
* Google Play FLEXIBLE in-app update source.
|
||||
*
|
||||
* - [check] queries `AppUpdateManager.appUpdateInfo`. If Play reports
|
||||
* `UPDATE_AVAILABLE` and FLEXIBLE is allowed, returns [UpdateStatus.Available]
|
||||
* (or [UpdateStatus.Downloaded] / [UpdateStatus.Downloading] if a previously
|
||||
* started flexible update is already mid-flight). Anything else →
|
||||
* [UpdateStatus.UpToDate].
|
||||
* - [startUpdate] launches Play's FLEXIBLE consent + background download and
|
||||
* registers an [InstallStateUpdatedListener] so DOWNLOADED is reported back
|
||||
* asynchronously via [onStatusChanged].
|
||||
* - [completeUpdate] calls `AppUpdateManager.completeUpdate()` which restarts
|
||||
* the app to install the staged APK.
|
||||
*
|
||||
* Robustness: every Play interaction is wrapped in try/catch. On any failure
|
||||
* (no Play services, sideloaded "googlePlay" build on an AOSP device, RESULT
|
||||
* errors) it degrades to [UpdateStatus.UpToDate] / [UpdateStatus.Unsupported]
|
||||
* — the banner just never shows. Play is never a crash surface.
|
||||
*/
|
||||
private class PlayUpdateAvailabilitySource(
|
||||
private val appContext: Context,
|
||||
) : UpdateAvailabilitySource {
|
||||
|
||||
override var onStatusChanged: ((UpdateStatus) -> Unit)? = null
|
||||
|
||||
private val manager: AppUpdateManager? = runCatching {
|
||||
AppUpdateManagerFactory.create(appContext)
|
||||
}.getOrNull()
|
||||
|
||||
/** Cached label/code from the last [check] so async listener events can label themselves. */
|
||||
@Volatile private var lastVersionCode: Long? = null
|
||||
|
||||
private val installListener = InstallStateUpdatedListener { state: InstallState ->
|
||||
when (state.installStatus()) {
|
||||
InstallStatus.DOWNLOADING ->
|
||||
onStatusChanged?.invoke(
|
||||
UpdateStatus.Downloading(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
// bytesDownloaded()/totalBytesToDownload() are base
|
||||
// app-update InstallState methods (Long); no ktx import.
|
||||
bytesDownloaded = state.bytesDownloaded(),
|
||||
totalBytes = state.totalBytesToDownload(),
|
||||
)
|
||||
)
|
||||
InstallStatus.DOWNLOADED ->
|
||||
onStatusChanged?.invoke(
|
||||
UpdateStatus.Downloaded(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
)
|
||||
)
|
||||
else -> Unit // INSTALLING / INSTALLED / FAILED / CANCELED → no banner change
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile private var listenerRegistered = false
|
||||
|
||||
override suspend fun check(): UpdateStatus {
|
||||
val mgr = manager ?: return UpdateStatus.Unsupported
|
||||
return try {
|
||||
val info = mgr.awaitAppUpdateInfo()
|
||||
lastVersionCode = info.availableVersionCode().toLong()
|
||||
when {
|
||||
// A previously started FLEXIBLE update already finished downloading.
|
||||
info.installStatus() == InstallStatus.DOWNLOADED -> {
|
||||
ensureListener(mgr)
|
||||
UpdateStatus.Downloaded(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
)
|
||||
}
|
||||
info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS ||
|
||||
info.installStatus() == InstallStatus.DOWNLOADING -> {
|
||||
ensureListener(mgr)
|
||||
UpdateStatus.Downloading(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
)
|
||||
}
|
||||
info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
|
||||
info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) ->
|
||||
UpdateStatus.Available(
|
||||
versionLabel = labelFor(lastVersionCode),
|
||||
versionCode = lastVersionCode,
|
||||
openUrl = null,
|
||||
)
|
||||
else -> UpdateStatus.UpToDate
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "appUpdateInfo check failed; treating as up-to-date", t)
|
||||
UpdateStatus.UpToDate
|
||||
}
|
||||
}
|
||||
|
||||
override fun startUpdate(activity: Activity?): Boolean {
|
||||
val mgr = manager ?: return false
|
||||
if (activity == null) return false
|
||||
return try {
|
||||
ensureListener(mgr)
|
||||
mgr.appUpdateInfo
|
||||
.addOnSuccessListener { info: AppUpdateInfo ->
|
||||
val canStart = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE &&
|
||||
info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)
|
||||
val resuming = info.updateAvailability() ==
|
||||
UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS
|
||||
if (canStart || resuming) {
|
||||
runCatching {
|
||||
mgr.startUpdateFlow(
|
||||
info,
|
||||
activity,
|
||||
AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build(),
|
||||
)
|
||||
}.onFailure { Log.w(TAG, "startUpdateFlow failed", it) }
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { Log.w(TAG, "startUpdate appUpdateInfo failed", it) }
|
||||
true
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "startUpdate failed", t)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun completeUpdate() {
|
||||
val mgr = manager ?: return
|
||||
runCatching { mgr.completeUpdate() }
|
||||
.onFailure { Log.w(TAG, "completeUpdate failed", it) }
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
val mgr = manager ?: return
|
||||
if (listenerRegistered) {
|
||||
runCatching { mgr.unregisterListener(installListener) }
|
||||
listenerRegistered = false
|
||||
}
|
||||
onStatusChanged = null
|
||||
}
|
||||
|
||||
private fun ensureListener(mgr: AppUpdateManager) {
|
||||
if (!listenerRegistered) {
|
||||
runCatching { mgr.registerListener(installListener) }
|
||||
.onSuccess { listenerRegistered = true }
|
||||
.onFailure { Log.w(TAG, "registerListener failed", it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Play exposes only the numeric versionCode, not a marketing version
|
||||
// string, so the banner copy stays generic ("A new version"). The code is
|
||||
// still carried on the status for per-version dismissal keying.
|
||||
private fun labelFor(@Suppress("UNUSED_PARAMETER") code: Long?): String = "A new version"
|
||||
}
|
||||
|
||||
// === END update (googlePlay) ===
|
||||
|
||||
/**
|
||||
* `await()` for Play's [AppUpdateInfo] task without pulling in
|
||||
* `kotlinx-coroutines-play-services`. Named `await…` (not the ktx
|
||||
* `requestAppUpdateInfo`) to avoid any overload ambiguity with the
|
||||
* `app-update-ktx` suspend extension. Resumable + cancels cleanly if the
|
||||
* coroutine is torn down.
|
||||
*/
|
||||
private suspend fun AppUpdateManager.awaitAppUpdateInfo(): AppUpdateInfo =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
appUpdateInfo
|
||||
.addOnSuccessListener { info -> if (cont.isActive) cont.resume(info) }
|
||||
.addOnFailureListener { e -> if (cont.isActive) cont.cancel(e) }
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.shared.LocalDispatchResult
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import com.hermesandroid.relay.network.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.handlers.LocalDispatchResult
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
|
||||
/**
|
||||
* Local in-process bridge dispatcher type. The Play flavor never invokes
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
info@axiom-labs.dev
|
||||
@@ -1 +0,0 @@
|
||||
en-US
|
||||
@@ -1,59 +0,0 @@
|
||||
Hermes-Relay is the native Android companion for the Hermes agent you run. Chat, talk hands-free, continue sessions, and manage models, keys, skills, profiles, and automations from your phone.
|
||||
|
||||
It is not a hosted AI service. Your Hermes agent stays on infrastructure you control, and the app talks only to instances you configure.
|
||||
|
||||
QUICK START
|
||||
|
||||
1. Start the Hermes Dashboard/Gateway on your computer or home server with hermes dashboard.
|
||||
2. Install Hermes-Relay from Google Play.
|
||||
3. For the recommended full setup, install the Hermes-Relay plugin on the host and refresh the Web Dashboard. A Relay page will appear.
|
||||
4. Scan Connect mobile app from Android Connect. Then scan Pair new device from Android Settings > Connections.
|
||||
|
||||
The QR codes are separate on purpose. Connect mobile app adds the standard Dashboard/Gateway connection. Pair new device grants a time-limited Hermes-Relay session for the additional capabilities you approve.
|
||||
|
||||
Standard Hermes without the plugin is supported. Choose Find Hermes on LAN or enter the Dashboard address you open in a browser, normally http://<host>:9119. Pair the Hermes-Relay plugin later when you want the full experience.
|
||||
|
||||
HOW IT WORKS
|
||||
|
||||
Chat, sessions, Manage, sign-in, and standard voice use the unmodified Hermes Dashboard/Gateway. The separate Hermes API server is an optional fallback for advanced or headless setups; it is not required for the normal Android connection.
|
||||
|
||||
The encouraged Hermes-Relay plugin adds Terminal/TUI, notifications, media handoff, enhanced voice, Relay sessions, desktop-tool handoff, and time-limited per-feature grants. When upstream Hermes provides a compatible capability, Hermes-Relay prefers it instead of duplicating it.
|
||||
|
||||
GOOGLE PLAY AND SIDELOAD
|
||||
|
||||
The Google Play build includes Chat, voice, sessions, Manage, profiles, notifications, media, and Terminal/TUI when the Hermes-Relay plugin is paired.
|
||||
|
||||
Google Play does not include Android Device Control. It cannot read the phone screen, tap, type, swipe, take device screenshots, send SMS, place calls, or access contacts or location.
|
||||
|
||||
Device Control is available only in the signed Sideload build on this project's GitHub Releases. It requires the Sideload app, a paired Hermes-Relay plugin, explicit Android accessibility permission, and the app's safety controls.
|
||||
|
||||
FEATURES
|
||||
|
||||
- Streaming Chat with reasoning, markdown, tool progress, attachments, mid-turn steering, edit-and-resend, and searchable commands.
|
||||
- Manage models and provider keys, edit profiles, and browse, install, or update skills through the Hermes Dashboard.
|
||||
- Hands-free voice through your server's speech providers. Hermes-Relay pairing adds per-profile voices and an experimental realtime engine.
|
||||
- Create, switch, search, rename, pin, archive, and continue sessions.
|
||||
- Connect multiple Hermes servers and switch in one tap; add LAN, Tailscale, or public routes.
|
||||
- Pair the Hermes-Relay plugin for Terminal/TUI, notifications, media, enhanced voice, Relay sessions, and per-feature grants.
|
||||
- Inspect connection readiness, routes, response timing, token usage, and stream health without exposing credentials.
|
||||
|
||||
SECURITY AND PRIVACY
|
||||
|
||||
- Dashboard sessions and Hermes-Relay tokens use encrypted Android storage.
|
||||
- Cleartext is limited to trusted local-network setups. Use a VPN or HTTPS remotely.
|
||||
- No telemetry, ads, tracking, or third-party analytics SDKs.
|
||||
- Notification and microphone access are optional and user-controlled.
|
||||
- App traffic goes only to servers you configure.
|
||||
|
||||
REQUIREMENTS
|
||||
|
||||
- Android 8.0 or later.
|
||||
- A reachable Hermes Dashboard/Gateway.
|
||||
- The Hermes-Relay plugin is encouraged for the complete experience but never blocks standard Hermes.
|
||||
- Network access through a local network, VPN, or operator-managed internet route.
|
||||
|
||||
OPEN SOURCE
|
||||
|
||||
Hermes-Relay is MIT licensed. Source, setup guides, downloads, and issue tracking are on GitHub.
|
||||
|
||||
This community project is not affiliated with or endorsed by NousResearch.
|
||||
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 168 KiB |
@@ -1 +0,0 @@
|
||||
Your Hermes agent on Android — chat, voice, sessions, and Manage.
|
||||
@@ -1 +0,0 @@
|
||||
Hermes-Relay
|
||||
@@ -1 +0,0 @@
|
||||
Bot Mode now brings bots from saved Hermes gateways into one messenger-style workspace. Settings adds provider-neutral Codex, Nous, and OpenCode Go usage. Compatible Assistant launches can include bounded visible text and an available screenshot. Gateway chats now settle stale busy state automatically, onboarding is clearer, and idle Sphere motion uses less power.
|
||||
@@ -1 +0,0 @@
|
||||
Bot 模式现在可将已保存 Hermes 网关中的机器人汇集到一个消息式工作区。设置新增统一的 Codex、Nous 和 OpenCode Go 用量视图。兼容的助手启动可在首个语音回合中包含受限的可见文本和可用截图。Gateway 聊天会自动清除过期的忙碌状态,引导更清晰,空闲 Sphere 动画也更省电。
|
||||
@@ -1,26 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<!-- Turn-complete chat notification (TurnCompleteNotifier) — runtime-requested
|
||||
on API 33+ from the Chat Settings toggle. Lives in main (not just the
|
||||
sideload overlay) so the googlePlay flavor can notify too. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Opt-in "Keep connected in background" (GatewayKeepAliveService). In main
|
||||
(not the sideload overlay) so the googlePlay flavor ships it too — the
|
||||
Home-Assistant-class persistent-connection use case Play permits. The
|
||||
specialUse type requires a one-time Play Console foreground-service
|
||||
declaration at submission. (Also already present in the sideload overlay
|
||||
for the device-control bridge service; the merger dedups.) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
@@ -30,7 +15,6 @@
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.HermesRelay">
|
||||
@@ -39,74 +23,14 @@
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="portrait"
|
||||
tools:ignore="LockedOrientationActivity"
|
||||
android:configChanges="uiMode|fontScale|density|orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="uiMode|fontScale|locale|density|orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:theme="@style/Theme.HermesRelay.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- User-mediated sharesheet handoff. Shared text and files open in
|
||||
a fresh reviewable Chat draft; external intents never send. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
<!-- The loopback native-PKCE result page uses this fixed, tokenless
|
||||
link only to bring the installed flavor back to the foreground.
|
||||
MainActivity intentionally does not interpret the URI as an auth
|
||||
callback or navigation command. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="${applicationId}"
|
||||
android:host="return" />
|
||||
</intent-filter>
|
||||
<!-- Some Android OEM assistant pickers enumerate ACTION_ASSIST
|
||||
activities in addition to VoiceInteractionService providers. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.ASSIST" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.VOICE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".assistant.AssistantLaunchActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:noHistory="true"
|
||||
android:permission="android.permission.STATUS_BAR_SERVICE"
|
||||
android:taskAffinity=""
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.speech.action.WEB_SEARCH" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- AppCompat persists in-app language choices on Android 12 and lower.
|
||||
Android 13+ stores the same selection in the platform LocaleManager. -->
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
@@ -129,92 +53,6 @@
|
||||
</service>
|
||||
<!-- === END PHASE3-notif-listener === -->
|
||||
|
||||
<!-- Inline-reply receiver for proactive-message notifications
|
||||
(Phase 2c — two-way phone messaging). Not exported: it is only
|
||||
ever triggered by the app's own mutable RemoteInput PendingIntent
|
||||
delivered by the system, never by a third party. -->
|
||||
<receiver
|
||||
android:name=".notifications.ProactiveReplyReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Protects user-started active turns automatically; the optional
|
||||
"Persistent connection" setting extends the same foreground
|
||||
protection to idle/background connectivity (and relay-paired
|
||||
device features). In main so BOTH flavors ship it. Every Play
|
||||
foreground-service type needs its matching App content declaration. -->
|
||||
<service
|
||||
android:name=".network.upstream.GatewayKeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Keeps user-started Hermes turns connected until they finish or need input, and optionally keeps idle connections responsive when the user enables Persistent connection." />
|
||||
</service>
|
||||
|
||||
<!-- Experimental, explicitly user-started on-device wake-word listener.
|
||||
Audio remains local and the service is never boot/restart started.
|
||||
The Play build's microphone type needs an App content declaration. -->
|
||||
<service
|
||||
android:name=".wake.WakeWordForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone"
|
||||
android:stopWithTask="false" />
|
||||
|
||||
<!-- User-started protection for voice capture from the system overlay.
|
||||
The service does not own AudioRecord; it keeps foreground-only
|
||||
microphone app-ops available while Hermes is behind another app.
|
||||
Include this use case in the Play microphone declaration. -->
|
||||
<service
|
||||
android:name=".voice.VoiceOverlayForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone"
|
||||
android:stopWithTask="false" />
|
||||
|
||||
<!-- Explicitly opt-in Android Digital Assistant integration. Android
|
||||
binds this only after the user selects Hermes for ROLE_ASSISTANT. -->
|
||||
<service
|
||||
android:name=".assistant.HermesVoiceInteractionService"
|
||||
android:exported="true"
|
||||
android:label="@string/assistant_service_label"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.voice_interaction"
|
||||
android:resource="@xml/voice_interaction_service" />
|
||||
</service>
|
||||
|
||||
<!-- Heavy assistant UI is isolated from the always-running interaction
|
||||
service, matching the platform lifecycle guidance. -->
|
||||
<service
|
||||
android:name=".assistant.HermesVoiceInteractionSessionService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION"
|
||||
android:process=":assistant_session" />
|
||||
|
||||
<!-- Required companion component for VoiceInteractionService metadata.
|
||||
Hermes session transcription remains owned by the existing voice
|
||||
pipeline; this service does not open a second microphone stream. -->
|
||||
<service
|
||||
android:name=".assistant.HermesRecognitionService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION">
|
||||
<intent-filter>
|
||||
<action android:name="android.speech.RecognitionService" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".assistant.AssistantSessionStateReceiver"
|
||||
android:exported="false"
|
||||
android:process=":assistant_session" />
|
||||
|
||||
<receiver
|
||||
android:name=".assistant.AssistantSessionLifecycleReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,962 +0,0 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.13.0",
|
||||
"title": "Bots, usage, and reliable chat",
|
||||
"date": "2026-08-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Talk across saved gateways",
|
||||
"bullets": [
|
||||
"Use Bot Mode as one messenger-style workspace for bots and read-only groups across saved Hermes gateways.",
|
||||
"Keep every Bot Chat bound to its exact gateway and profile without changing the foreground connection."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Understand account limits",
|
||||
"bullets": [
|
||||
"Review Codex credential pools, Nous balances, and OpenCode Go windows from one provider-neutral Usage and limits screen.",
|
||||
"Choose Summary, Expanded, or Hidden presentation while provider credentials remain on the Hermes host."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Keep chat and voice in context",
|
||||
"bullets": [
|
||||
"Settle orphaned Gateway busy state automatically while preserving another session's active or detached turn.",
|
||||
"Include bounded visible text and an available screenshot in the first compatible Assistant voice turn."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.12.1",
|
||||
"title": "Sharing and recovery that work",
|
||||
"date": "2026-08-22",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Share complete drafts",
|
||||
"bullets": [
|
||||
"Open shared links, text, images, files, and mixed or multi-item shares as one fresh reviewable draft.",
|
||||
"Keep every share in the composer until you review it; Hermes never sends shared content automatically."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Recover connections and conversations",
|
||||
"bullets": [
|
||||
"Add or renew a connection without getting stuck during secure local preparation, with Retry and Cancel when setup cannot finish.",
|
||||
"See clear recovery guidance when no chat route is available or a profile's conversation history cannot be reached."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Understand secure storage",
|
||||
"bullets": [
|
||||
"Review secret-free Diagnostics evidence when Android falls back from Keystore storage, repairs encrypted storage, or can keep credentials only temporarily."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.12.0",
|
||||
"title": "Themes and identity that stay put",
|
||||
"date": "2026-08-21",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Make the app yours",
|
||||
"bullets": [
|
||||
"Create and save custom themes with editable palette roles, Light or Dark ownership, shape, and a live chat preview.",
|
||||
"Apply Soft, Balanced, or Sharp styling consistently across chat, settings, sheets, dialogs, terminal, voice, and Bridge."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Keep the right agent active",
|
||||
"bullets": [
|
||||
"Selecting a session from All Profiles activates its owning agent with the correct header, avatar, transcript, draft, and routing.",
|
||||
"Language changes preserve the exact active profile and session while relocalizing the persistent connection notification without reconnecting."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Recover cleanly",
|
||||
"bullets": [
|
||||
"Settle and reconcile Gateway turns when a terminal completion frame is missed without resubmitting through fallback transport.",
|
||||
"Normalize Relay base, /ws, and /health endpoint forms without producing duplicate route segments."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.11.0",
|
||||
"title": "Access with clear boundaries",
|
||||
"date": "2026-08-20",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Choose what Bridge can do",
|
||||
"bullets": [
|
||||
"Use read-only, read-and-confirm, or custom capability presets for the active connection in sideload builds.",
|
||||
"Allow screen inspection and control for a bounded period or explicitly keep access unlimited."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Recover without losing context",
|
||||
"bullets": [
|
||||
"Keep stored-session failures visible with route-aware details and clear retry or dismiss actions.",
|
||||
"Insert newlines across more software keyboards and retain Stopped status when answer recovery is cancelled."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Use less power while idle",
|
||||
"bullets": [
|
||||
"Pause invisible Sphere, waveform, and drawer animation loops when no motion is needed.",
|
||||
"Attach capture surfaces only for requested frames and release audio or wake-lock resources at their lifecycle boundaries."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.10.0",
|
||||
"title": "Chat that stays put",
|
||||
"date": "2026-08-18",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Watch replies take shape",
|
||||
"bullets": [
|
||||
"Render paragraphs, lists, links, fenced code, and tables incrementally without replacing the message at completion.",
|
||||
"Keep bottom-follow smooth while intentional scrollback remains exactly where you left it."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Pick up where you left off",
|
||||
"bullets": [
|
||||
"Resume the visible Hermes session automatically after returning from another app.",
|
||||
"Restore composer text, quote or edit context, and pending attachments in the correct conversation after an app restart."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Review before sending",
|
||||
"bullets": [
|
||||
"Turn large pastes into compact text attachments while preserving compatible fallback delivery.",
|
||||
"Use Return on the software keyboard while the dedicated composer button remains the Send action."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.9.1",
|
||||
"title": "Profile identity that sticks",
|
||||
"date": "2026-08-16",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Identity follows the right scope",
|
||||
"bullets": [
|
||||
"Change shared avatars from Android with automatic orientation, resizing, and safe conversion to the Hermes profile-asset contract.",
|
||||
"Select upstream animated pets that follow the Hermes profile while phone-only animated icons, local avatar overrides, and Sphere skins stay local."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Profile setup stays explicit",
|
||||
"bullets": [
|
||||
"Create profiles with clear shared, copied, or isolated authentication choices and see partial setup outcomes.",
|
||||
"Named-profile sessions and profile drafts fail closed when Hermes cannot confirm their owner."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Safer Gateway operations",
|
||||
"bullets": [
|
||||
"Attachments, rewinds, recovery, model-consent changes, and hosted sign-in now follow stricter upstream contracts.",
|
||||
"Finite schedules, bounded reset evidence, and host resource warnings make consequential actions easier to review."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.9.0",
|
||||
"title": "Better sessions, reactions, and voice",
|
||||
"date": "2026-08-14",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Sessions keep their identity",
|
||||
"bullets": [
|
||||
"Browse one profile or all profiles, customize sorting and filters, and optionally group sessions by project, recency, status, or profile.",
|
||||
"Cross-profile sessions hydrate, resume, and send with their owning agent without changing the global profile selection; New Chat in All Profiles uses the default profile."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Conversation controls stay attached",
|
||||
"bullets": [
|
||||
"Reactions pin to durable rows on both user and assistant messages.",
|
||||
"Vanilla Hermes voice stays on the authenticated Gateway instead of requiring the optional API fallback."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Context without clutter",
|
||||
"bullets": [
|
||||
"Session rows show profile, project, branch, and pull-request context when Hermes supplies it, while the default view remains ungrouped.",
|
||||
"The session drawer restores secondary actions in All Profiles and closes when you tap outside it."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.8.1",
|
||||
"title": "Complete, reliable transcripts",
|
||||
"date": "2026-08-09",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Keep long sessions complete",
|
||||
"bullets": [
|
||||
"Android pages explicitly through complete API-server and profile-scoped Dashboard history instead of silently stopping at Hermes' latest-500 default.",
|
||||
"Sharing, retry, edit, and recovery retain stable transcript anchors while bounded safety limits keep unusually large reads controlled."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Follow Gateway truth",
|
||||
"bullets": [
|
||||
"Authoritative submit rejections preserve the server's message without an unintended SSE fallback.",
|
||||
"Gateway event envelopes and edit-and-regenerate truncation confirmation now follow current upstream contracts."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.8.0",
|
||||
"title": "Conversations with more context",
|
||||
"date": "2026-08-09",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Keep the whole turn together",
|
||||
"bullets": [
|
||||
"Quote, edit, search, and attach or reorder files without losing the active connection, profile, or session.",
|
||||
"Share text from another Android app into a fresh Chat draft for review before sending."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See the work without the clutter",
|
||||
"bullets": [
|
||||
"Live thinking settles into a compact Thought disclosure, while routine tool activity groups into concise runs.",
|
||||
"Approvals, failures, generated media, file changes, risks, and delegated work remain clearly distinct."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Switch agents, not identities",
|
||||
"bullets": [
|
||||
"The Profile Shelf switches agents from Chat while restoring each profile's last session.",
|
||||
"Agent Passport model and reasoning controls remain scoped to the active session instead of rewriting server defaults."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Make it yours",
|
||||
"bullets": [
|
||||
"Preview theme accents and shapes, Sphere skins, and pets in one Appearance workflow.",
|
||||
"Message speech controls, pet touch targets, scrolling terrain, image rotation, and edge-to-edge settings layout are more reliable."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.7.1",
|
||||
"title": "Safer, steadier conversations",
|
||||
"date": "2026-08-08",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Chat stays with you",
|
||||
"bullets": [
|
||||
"Growing streamed replies stay visible while you are at the bottom, and intentional scrollback remains undisturbed.",
|
||||
"Completed replies render Markdown immediately while live tool details remain expandable."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Sessions keep their ownership",
|
||||
"bullets": [
|
||||
"Queued follow-ups retain their originating connection, profile, session, route, attachments, and voice context.",
|
||||
"Session pins and archives persist across restarts, and duplicate model rows are reconciled before rendering."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Safer controls and setup",
|
||||
"bullets": [
|
||||
"Approval cards require an explicit labeled decision, and Agent Passport safety controls are easier to read and dismiss.",
|
||||
"Hosted Hermes setup completes through the official Dashboard system-browser sign-in flow."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.7.0",
|
||||
"title": "Smarter controls, steadier sessions",
|
||||
"date": "2026-08-06",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Model controls fit the model",
|
||||
"bullets": [
|
||||
"Reasoning effort choices follow the selected provider and model when an exact supported list is available.",
|
||||
"Unmodified Hermes and setups without the optional Relay capability overlay keep a fail-soft standard choice list."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Active chats stay easy to follow",
|
||||
"bullets": [
|
||||
"The searchable session drawer shows which conversations are working or waiting for input.",
|
||||
"Restored and completed chats remain bottom-pinned through late layout changes without overriding intentional scrollback.",
|
||||
"Chat and Voice keep stable rows through recovery, and Focus Voice controls receive taps normally."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Support stays private and useful",
|
||||
"bullets": [
|
||||
"Review locally redacted support information before choosing to copy, share, or open GitHub; nothing uploads automatically.",
|
||||
"Connection diagnostics identify the failed operation and offer targeted guidance without exposing hosts or credentials."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.6.1",
|
||||
"title": "Clearer recovery, steadier chat",
|
||||
"date": "2026-08-03",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Relay stays optional",
|
||||
"bullets": [
|
||||
"Relay-only surfaces now use consistent Optional, Ready, Reconnecting, Unavailable, and Needs re-pair states without nagging from background session refreshes.",
|
||||
"Foreground recovery retries ordinary reconnect backoff immediately and explains whether Relay credentials are merely stored or actually need re-pairing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Sessions and chat stay stable",
|
||||
"bullets": [
|
||||
"The session drawer restores its 200-row window through upstream-compatible 100-row pages.",
|
||||
"Selecting streamed text stays stable when a completed response changes to rendered Markdown."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice controls stay reachable",
|
||||
"bullets": [
|
||||
"Manual recording waits for the previous microphone owner to release it and gives a useful recovery message if capture cannot start.",
|
||||
"New-chat coaching yields while Voice owns the composer so it cannot cover the expanding Voice drawer."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.6.0",
|
||||
"title": "Pets, plugins, and voice",
|
||||
"date": "2026-08-02",
|
||||
"sections": [
|
||||
{
|
||||
"header": "A companion with personality",
|
||||
"bullets": [
|
||||
"Browse and install Petdex companions, or import your own pet without replacing the agent avatar or background Sphere.",
|
||||
"Drag a pet anywhere or let it roam across measured chat bubbles, settings cards, controls, and other safe UI ledges."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Native plugin pages",
|
||||
"bullets": [
|
||||
"Installed Hermes plugins can contribute host-rendered native pages without loading executable plugin code on the phone.",
|
||||
"Scoped writes stay off until granted, while Relay 1.5.0 adds approval-gated agent-created page previews."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Hermes as your assistant",
|
||||
"bullets": [
|
||||
"Optionally select Hermes as Android’s Digital Assistant and use a local “Hey Hermes” listener for background or locked-screen sessions.",
|
||||
"Compact assistant and floating Voice controls expand for detail and continue the same turn when full Voice opens."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "More reliable everywhere",
|
||||
"bullets": [
|
||||
"Voice output recovery, long recordings, route failover, streamed chat identity, and pet terrain recovery are more resilient.",
|
||||
"Android now includes a complete AI-assisted Russian catalog refreshed for the 1.6 feature set."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.5.3",
|
||||
"title": "Voice stays open",
|
||||
"date": "2026-07-31",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stable voice transcripts",
|
||||
"bullets": [
|
||||
"Voice Focus keeps stable transcript rows while live messages reconcile with persisted chat history, preventing duplicate-key crashes that could close the app."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.5.2",
|
||||
"title": "Sign in without detours",
|
||||
"date": "2026-07-28",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Provider-compatible sign-in",
|
||||
"bullets": [
|
||||
"Self-hosted OIDC returns through the dashboard callback, while Nous Portal opens securely in the system browser.",
|
||||
"Private-LAN and Tailscale dashboard routes preserve the configured HTTPS callback and keep credentials scoped to the active connection."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Stable conversation updates",
|
||||
"bullets": [
|
||||
"Replayed upstream chat events are coalesced before rendering so duplicate message identifiers do not destabilize the conversation list."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.5.1",
|
||||
"title": "Voice and chat stay in place",
|
||||
"date": "2026-07-26",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Voice at the right depth",
|
||||
"bullets": [
|
||||
"Use Voice Focus for a compact spoken-turn view or Conversation for the complete Chat renderer without leaving the active voice session.",
|
||||
"Keep intermediate work visual while supported voice paths wait to speak the settled final response."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Reliable narration and background work",
|
||||
"bullets": [
|
||||
"Standard Voice now speaks valid completed replies after generation hands off to narration.",
|
||||
"Realtime background tasks release foreground voice controls while their progress and results remain reachable."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Formatted answers stay readable",
|
||||
"bullets": [
|
||||
"Completed streams render headings, lists, emphasis, and code blocks without returning to the beginning of the answer.",
|
||||
"Assistant text uses stronger theme contrast and a more comfortable chat reading scale."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"title": "Hermes, always in reach",
|
||||
"date": "2026-07-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "One secure Hermes connection",
|
||||
"bullets": [
|
||||
"Connect through secure Dashboard sign-in while Chat, sessions, Manage, and Standard Voice follow the same active route.",
|
||||
"Switch profiles and control personality, model, reasoning, approvals, and processing speed from the new Agent Passport."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Active work stays reachable",
|
||||
"bullets": [
|
||||
"Multiple user-started chats remain active in the background until every session settles.",
|
||||
"Approval, question, elevated-permission, and secure-response alerts reopen the correct conversation."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Richer chat and voice",
|
||||
"bullets": [
|
||||
"Attachments, image generation, model routing, recovery, advisor progress, and upstream events are clearer and more reliable.",
|
||||
"Browse and preview Standard and Realtime voices, and hear Standard replies begin speaking as completed segments arrive."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Setup without surprises",
|
||||
"bullets": [
|
||||
"Onboarding explains optional notification, camera, microphone, companion, and device permissions without blocking standard chat.",
|
||||
"Tailscale, QR, and remote routes now move all Hermes surfaces together and recover the original session after connection loss."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.9",
|
||||
"title": "Clearer Hermes connections",
|
||||
"date": "2026-07-19",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Dashboard-first setup",
|
||||
"bullets": [
|
||||
"Connect through the Hermes dashboard with one sign-in for Chat, sessions, Manage, and voice; API fallback and optional Relay remain available.",
|
||||
"Onboarding and connection management now explain nearby, remote, Tailscale, custom-port, startup, route, and security choices."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Consistent identity",
|
||||
"bullets": [
|
||||
"Server default now displays Hermes' pinned active profile consistently across the app.",
|
||||
"Successful local discovery adds useful hostname identity without replacing a custom connection label."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"date": "2026-07-15",
|
||||
"sections": [
|
||||
{
|
||||
"header": "One Server-default profile",
|
||||
"bullets": [
|
||||
"Server default now keeps the selected agent, session drawer, transcript, and new messages in Hermes' sticky active profile.",
|
||||
"Reorder or hide profiles per connection without changing server configuration."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Profile icons",
|
||||
"bullets": [
|
||||
"Choose an image through Android's file picker or import avatar.png/profile.jpg from an updated paired Relay.",
|
||||
"Host import now distinguishes an outdated Relay from a genuinely missing profile image."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.5",
|
||||
"title": "Chats that keep running",
|
||||
"date": "2026-07-15",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Keep moving between chats",
|
||||
"bullets": [
|
||||
"Switch to another chat, profile, draft, or Thread without stopping a running Gateway reply.",
|
||||
"Return to the session and reattach to its live checkpoint and progress."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Cleaner live state",
|
||||
"bullets": [
|
||||
"Expired secret and sudo prompts collapse when Hermes reports their expiry, so stale actions no longer look usable.",
|
||||
"Provider wait, reconnect, and continuation notices stay in Chat's live status line instead of cluttering the conversation."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.4",
|
||||
"title": "Spanish and clearer diagnostics",
|
||||
"date": "2026-07-12",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Language that is ready to grow",
|
||||
"bullets": [
|
||||
"Use Spanish throughout the app from Settings → Appearance.",
|
||||
"Translation freshness checks flag catalogs whenever the English source changes, while fluent verification remains tracked separately."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Know what is connected",
|
||||
"bullets": [
|
||||
"Refresh Diagnostics to see the Relay plugin version, protocol, capability count, profile status, and last-check time.",
|
||||
"Open the complete release history directly from the cleaner What’s New modal."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.3",
|
||||
"title": "Language switching inside the app",
|
||||
"date": "2026-07-11",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Language at your fingertips",
|
||||
"bullets": [
|
||||
"Choose System default, English, or Simplified Chinese from Settings → Appearance without leaving Hermes-Relay.",
|
||||
"The picker stays synchronized with Android's per-app language setting and persists the choice on Android 12 and lower.",
|
||||
"Release builds reject collection APIs that can crash on Android versions before API 35."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.2",
|
||||
"title": "Simplified Chinese and scalable localization",
|
||||
"date": "2026-07-11",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Simplified Chinese throughout the app",
|
||||
"bullets": [
|
||||
"Use onboarding, connection setup, Chat, Manage, Voice, settings, diagnostics, notifications, and accessibility labels in Simplified Chinese across both product flavors.",
|
||||
"Switch between English and Simplified Chinese through Android's per-app language settings on supported versions, or follow the device language elsewhere."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Localization built to grow",
|
||||
"bullets": [
|
||||
"Automated catalog checks protect resource, plural, and format-argument parity, while contributor docs and translated entry points make another language easier to add safely.",
|
||||
"Connection scan and queued-message counts now use locale-aware Android plurals."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.1",
|
||||
"title": "Chat that keeps up",
|
||||
"date": "2026-07-11",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Chat that stays with you",
|
||||
"bullets": [
|
||||
"Follow background terminal work from a compact process strip and expandable sheet. Its completed answer appears in the same conversation automatically.",
|
||||
"Close and reopen while a reply runs: partial text, thinking, tool progress, background-task state, and pending approvals return in the same chat without repeating your prompt."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice you can direct",
|
||||
"bullets": [
|
||||
"Use spoken commands to pause or resume listening, stop speech, cancel background work, repeat a finished result, or start Standard voice chat.",
|
||||
"Hands-free, Low latency, Careful tools, and Quiet presets tune existing voice behavior without changing your selected voice or route."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Clearer conversations",
|
||||
"bullets": [
|
||||
"Browse adjacent images as a gallery, read smoother streaming Markdown and wide tables, and see background-process completion as a compact process notice."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.0",
|
||||
"title": "Realtime voice that finishes the job",
|
||||
"date": "2026-07-09",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Voice that keeps going",
|
||||
"bullets": [
|
||||
"Quick follow-ups can be answered while a long Hermes task runs, another long request can wait in a bounded queue, and the finished answer can stay in the selected realtime voice.",
|
||||
"Voice route recovery now waits for relay confirmation, replays unacknowledged input without starting a second Hermes run, and rejects stale sockets or sessions before they can overwrite a healthy connection.",
|
||||
"Listening, thinking, reconnecting, and cancellation states now settle cleanly after Stop, exit, route loss, or terminal retry failure."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Models and phone automation",
|
||||
"bullets": [
|
||||
"Realtime Agent model and voice choices apply to the next session, persist per connection/profile, and survive restart.",
|
||||
"Chat and Manage can refresh dynamic provider model catalogs on demand.",
|
||||
"Opt-in notification rules can offer a local Ask Hermes action, and Bridge tools can target a specific paired Android device."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Reliability and safety",
|
||||
"bullets": [
|
||||
"Long chat turns avoid premature transport fallback, and supported voice, card, and attachment context now reaches upstream Hermes through channels it consumes.",
|
||||
"Malformed server addresses fail through normal connection errors, older Android versions avoid newer collection APIs, and relay media blocks credential and token paths.",
|
||||
"Model management keeps unconfigured providers visible with key-setup guidance, and session cleanup gains export, prune preview/apply, archive, and restore plumbing."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.3.0",
|
||||
"title": "Voice that multitasks & sturdier chats",
|
||||
"date": "2026-07-06",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Voice, hands-free",
|
||||
"bullets": [
|
||||
"Ask for something big and keep talking — long tasks hand off to the background with a live chip showing the current step, steps done, and a running timer, with a tap-to-cancel. The answer is spoken when it's ready, even after a brief disconnect — and if the voice session is gone, it arrives as a notification (the full answer is always in the chat).",
|
||||
"Leaving voice mode (or tapping stop to interrupt speech) no longer cancels a running background task — the chip's ✕ is the one deliberate kill switch, and a delivered answer keeps its text instead of flipping to \"Cancelled.\"",
|
||||
"Quieter and quicker: the agent speaks at milestones instead of narrating every step, clearly long tasks hand off to the background right away, and the first turn starts faster — the session warms up when you open voice mode."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Chats that keep their answers",
|
||||
"bullets": [
|
||||
"An answer is no longer lost when the connection drops mid-reply on a long turn (slow local models, delegating skills) — the app quietly re-checks the conversation and completes the turn when the server finishes, with the usual done-notification if you've switched away.",
|
||||
"Markdown reads like chat: headings are proportionate instead of billboard-sized, lists and paragraphs share one size, links are clearly styled, and timestamps show once per message group."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Your agent can reach out",
|
||||
"bullets": [
|
||||
"Proactive messages: your Hermes agent can message your phone first (off by default, opt-in on both server and phone), and you can reply straight from the notification or the new Hermes inbox — the conversation continues like any other chat."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Make it yours",
|
||||
"bullets": [
|
||||
"Pick your app font — Inter (new default), Nunito, or your system font — applied instantly, everywhere.",
|
||||
"The in-bubble working indicator can be a small animated dot-matrix (Wave, Pulse, Bounce, Sparkle) with a color of your choice.",
|
||||
"Quick Controls at the top of Settings puts Persistent connection and Turn-complete alerts one tap away."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Setup & housekeeping",
|
||||
"bullets": [
|
||||
"Onboarding slides now scroll on small screens and large font sizes, so no setup guidance is cut off.",
|
||||
"Reporting a diagnostic files the right kind of issue: informational entries ask what you expected and file as a question, and every report carries your actual connection mode.",
|
||||
"Connections is a scannable list with a tabbed detail screen (Overview, Routes, Advanced, Security), and voice settings can now read and edit your server's voice engine (provider, voice, model) over the dashboard."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.6",
|
||||
"title": "Tidier chats & calmer status",
|
||||
"date": "2026-06-27",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Tidier chats",
|
||||
"bullets": [
|
||||
"Chats no longer get stuck showing \"Untitled\" — your first message stands in as the title until the chat is named, titles refresh once a turn settles, and a new refresh button in the session drawer pulls the latest on demand. Renaming a chat now sticks when you're on a non-default agent profile."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Calmer status",
|
||||
"bullets": [
|
||||
"Connection status — reconnecting, checking, network handoffs — now shows as a thin banner at the top that gently slides the screen down, instead of a card floating over your chat; the floating alert is kept for persistent errors. Quick confirmations (copied, profiles updated, profile/personality switches) land in the same calm banner instead of a pop-up at the bottom."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.5",
|
||||
"title": "Stability + Try the demo",
|
||||
"date": "2026-06-27",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app when a non-URL value — a UI label, or a line copied from the docs — was entered in the API server or Dashboard URL field. The setup fields now reject anything that isn't a valid host or http(s) URL with an inline error, and the dashboard and voice request paths treat a bad address as unreachable instead of crashing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Try the demo",
|
||||
"bullets": [
|
||||
"A new \"Try the demo\" option on the setup screen — and on the empty chat screen if you skip setup — opens an offline preview of the real chat experience: a sample conversation with Markdown, a tool-progress card, and a rich card, with no server, account, or network. A banner shows it's a demo, with a one-tap Connect to set up for real."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.4",
|
||||
"title": "Stability + connection security",
|
||||
"date": "2026-06-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app when the dashboard connection check hit a transient network failure — a pooled connection aborting or timing out over Tailscale. The check now reports the failure cleanly and the connection probe degrades gracefully instead of force-closing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See if you're secure",
|
||||
"bullets": [
|
||||
"The chat status chip, connection card, and route picker now show at a glance whether your connection is encrypted — Encrypted · TLS, Encrypted · Tailscale (both secure), Mixed routes, or Not encrypted — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale or WireGuard route is now correctly shown as encrypted rather than implied insecure."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"title": "Connection crash fix",
|
||||
"date": "2026-06-23",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS) — a live secure connection was being torn down on the main thread as it came up. Securing your connection no longer force-closes the app; plain-LAN connections were never affected."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.2",
|
||||
"title": "Multi-profile polish",
|
||||
"date": "2026-06-22",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Profiles that behave",
|
||||
"bullets": [
|
||||
"Deleting a session while a non-default agent profile is active now sticks — it no longer reappears after the list refreshes.",
|
||||
"On a cold start with a non-default profile selected, the session drawer opens on that profile's chats directly instead of briefly showing the default profile's."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Clearer diagnostics",
|
||||
"bullets": [
|
||||
"Diagnostics is now a full screen led by a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a pass / warning / fail state and the reason when something's wrong; tap a failing check for full detail. The recent-activity log stays below."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Small touches",
|
||||
"bullets": [
|
||||
"The default connection is now simply \"Hermes\" (and the optional power features are labelled \"Relay\"), across setup, the switcher, voice, and permissions.",
|
||||
"Distraction-free chat mode gives its text a taller, scrollable area."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.1",
|
||||
"title": "Polish & control",
|
||||
"date": "2026-06-21",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Yours to control",
|
||||
"bullets": [
|
||||
"Lock the app to a single agent profile (Settings → Profile lock) and hide the rest from the pickers."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Find your way back",
|
||||
"bullets": [
|
||||
"A new \"What's New\" entry in Settings shows current and past release notes any time — not just after an update."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "When something breaks",
|
||||
"bullets": [
|
||||
"Diagnostics show clean error titles — tap any entry for a detail view with Copy, Share, and a one-tap GitHub issue.",
|
||||
"A tasteful in-app banner tells you when a newer version is live (Play or sideload) — dismissable, and it never nags."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice fixes",
|
||||
"bullets": [
|
||||
"Stop now halts realtime speech instantly, hold-to-talk is steadier, the voice overlay is easier to read, and a chosen voice applies in Auto mode.",
|
||||
"Realtime turns that reach back to Hermes no longer drop with a session error."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"title": "Make it yours",
|
||||
"date": "2026-06-20",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Personalize",
|
||||
"bullets": [
|
||||
"Eight app themes in Settings → Appearance — the Hermes Relay brand plus ports of the Nous Hermes looks (Teal, Nous Blue, Midnight, Ember, Mono, Cyberpunk, Rosé), with light/dark.",
|
||||
"Swap the agent orb for an animated pet that reacts to what the agent is doing — add, preview, and tune pets right in the app, or generate one from sprite art with the AI authoring kit.",
|
||||
"Reskin the sphere, and give each agent profile its own icon."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See what's happening",
|
||||
"bullets": [
|
||||
"The chat status strip names the actual streaming path (Gateway, Sessions, Completions, Runs), with a basic→best tier ladder in Chat Settings.",
|
||||
"Tap the context meter for a \"What the agent sees\" sheet — the exact extra context prepended to your next turn.",
|
||||
"Voice and Realtime turns are badged in the scrollback."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Privacy",
|
||||
"bullets": [
|
||||
"When paired to the relay, the agent can mark private media and the phone blurs it per your setting — sensitivity stays model-emitted."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Faster & more reliable",
|
||||
"bullets": [
|
||||
"Cold start is about 3× faster, and model/personality/approvals load honestly instead of showing a maybe-wrong value.",
|
||||
"In-app crash reporting offers a one-tap, pre-filled bug report.",
|
||||
"QR pairing no longer force-closes on unusual cameras (foldables); fixed crashes opening server images and PDFs; in-chat model picks now apply."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice & terminal",
|
||||
"bullets": [
|
||||
"Enhanced voice control for Gemini and xAI providers.",
|
||||
"Leaner terminal with TUI-correct input and an isolated, tuned tmux."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.1.0",
|
||||
"title": "Release plumbing & polish",
|
||||
"date": "2026-06-16",
|
||||
"sections": [
|
||||
{
|
||||
"header": "New",
|
||||
"bullets": [
|
||||
"Automated Play Console upload when a release tag ships (a human still starts the rollout).",
|
||||
"/relay slash commands — status, devices, and pair from any platform — plus a relay-status badge in the dashboard header.",
|
||||
"The relay plugin prompts for its optional voice-provider keys on install, and a tools-only native install path."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Improved",
|
||||
"bullets": [
|
||||
"Settings overhaul: status pills are now exception-only, Power tools shows a single Plugin active/required/offline badge, and Connections moved to the top.",
|
||||
"Release names and notes are now split per surface (Android, plugin, CLI)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Fixed",
|
||||
"bullets": [
|
||||
"No more force-close on connect when the stored credential keyset was corrupt — it now heals in place.",
|
||||
"The installer works on uv-managed Hermes hosts, and the dashboard relay panel buttons are readable again."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"title": "Stable launch",
|
||||
"date": "2026-06-14",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Gateway chat with live thinking",
|
||||
"bullets": [
|
||||
"Chat can ride the upstream dashboard gateway — the only vanilla-upstream path that streams reasoning live, so the Thinking block and sphere light up during generation. \"Auto\" prefers it and falls back to the SSE endpoints per turn.",
|
||||
"Desktop parity: native image/PDF/file attachments, mid-turn steering, edit & resend, approval/clarify/sudo/secret cards, live subagent lanes, a context-window meter, server slash commands, and turn-complete notifications.",
|
||||
"Warm-start and an opt-in Keep connected in background toggle so long-backgrounded conversations resume instantly."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Agents, Manage & media",
|
||||
"bullets": [
|
||||
"Switch agent profiles per conversation — model, SOUL, personality, and skills — with the selection bound to the session, never changing the server default for other clients.",
|
||||
"Manage parity with the desktop dashboard: change models, manage provider keys, edit profiles and SOUL.md, and browse/install skills.",
|
||||
"Open and save chat images and attachments — full-screen viewer with pinch-zoom, plus an Open/Share/Save menu."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Standard path is first-class",
|
||||
"bullets": [
|
||||
"Chat, Manage, and voice all work against an unmodified upstream Hermes agent; the relay plugin is now purely additive.",
|
||||
"Seamless connection UX — LAN↔Tailscale handoffs and reconnects no longer reload the chat, and status shows as in-theme slide-down toasts.",
|
||||
"Persistent Realtime Agent voice that keeps one session across turns, with long runs promoted to tracked background tasks."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -26,9 +26,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
/* Bottom gap so xterm's last row clears the extra-keys footer
|
||||
instead of butting flush against it (read as an overlap). */
|
||||
padding: 8px 6px 8px 8px;
|
||||
padding: 8px 6px 0 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.xterm .xterm-viewport {
|
||||
@@ -151,18 +149,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Report scroll position so the host can show a "jump to latest" pill
|
||||
// while the user is scrolled up into scrollback. atBottom is true when
|
||||
// the viewport is pinned to the live tail.
|
||||
const reportScroll = function () {
|
||||
if (!(window.AndroidBridge && window.AndroidBridge.onScrollPosition)) return;
|
||||
try {
|
||||
const buf = term.buffer.active;
|
||||
window.AndroidBridge.onScrollPosition(buf.viewportY >= buf.baseY);
|
||||
} catch (_) {}
|
||||
};
|
||||
term.onScroll(function () { reportScroll(); });
|
||||
|
||||
// ── Inbound: Android → terminal ───────────────────────────────────
|
||||
// Base64-encoded payloads avoid JS string-escaping headaches when the
|
||||
// stream contains control characters, raw escape sequences, or bytes
|
||||
@@ -237,13 +223,6 @@
|
||||
try { term.focus(); } catch (_) {}
|
||||
};
|
||||
|
||||
// Current xterm selection as plain text ('' when nothing selected).
|
||||
// Read back via WebView.evaluateJavascript for the toolbar Copy key,
|
||||
// since long-press copy is unreliable inside an Android WebView.
|
||||
window.getSelectionText = function () {
|
||||
try { return term.getSelection() || ''; } catch (_) { return ''; }
|
||||
};
|
||||
|
||||
window.clearTerminal = function () {
|
||||
try { term.clear(); } catch (_) {}
|
||||
};
|
||||
@@ -260,36 +239,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Mode-aware encoder for the on-screen toolbar's special keys
|
||||
// (arrows / Home / End / Page). Arrows must follow xterm's current
|
||||
// DECCKM (application cursor keys) mode: when an app like vim, less,
|
||||
// or readline has requested it, an arrow is SS3-encoded (\eOA) rather
|
||||
// than CSI (\e[A). The old path always sent CSI from Kotlin, which the
|
||||
// running TUI could misread. We read term.modes here (where the mode
|
||||
// actually lives) and route bytes back through onInput so sticky
|
||||
// modifiers still apply. Page keys are mode-independent.
|
||||
window.termSendKey = function (name) {
|
||||
var appCursor = false;
|
||||
try {
|
||||
appCursor = !!(term.modes && term.modes.applicationCursorKeysMode);
|
||||
} catch (_) {}
|
||||
var p = appCursor ? 'O' : '[';
|
||||
var map = {
|
||||
ArrowUp: p + 'A',
|
||||
ArrowDown: p + 'B',
|
||||
ArrowRight: p + 'C',
|
||||
ArrowLeft: p + 'D',
|
||||
Home: p + 'H',
|
||||
End: p + 'F',
|
||||
PageUp: '[5~',
|
||||
PageDown: '[6~',
|
||||
};
|
||||
var seq = map[name];
|
||||
if (seq && window.AndroidBridge && window.AndroidBridge.onInput) {
|
||||
window.AndroidBridge.onInput(seq);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Scroll shims + gesture ────────────────────────────────────────
|
||||
// xterm.js ships a scrollback buffer (scrollback: 10000 above) but
|
||||
// has no built-in mobile touch-to-scroll — its input handlers are
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
v1.13.0 - Bots, usage, and reliable chat
|
||||
v0.8.1 - Voice mode crash fix
|
||||
|
||||
* Use Bot Mode across saved Hermes gateways without changing the foreground connection.
|
||||
* Review Codex, Nous, and OpenCode Go usage from one provider-neutral screen.
|
||||
* Include bounded visible text and an available screenshot in compatible Assistant turns.
|
||||
* Keep the composer accurate when Gateway completion frames and visible bubbles settle separately.
|
||||
Voice
|
||||
* Fixed a crash that could hit voice mode when barge-in was enabled on the
|
||||
legacy text-to-speech path — the agent's first words no longer cut off
|
||||
into a crash. Barge-in is opt-in; the Realtime Agent and Voice Output
|
||||
paths were never affected.
|
||||
|
||||
@@ -1,62 +1,34 @@
|
||||
package com.hermesandroid.relay
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.gif.AnimatedImageDecoder
|
||||
import coil3.gif.GifDecoder
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import androidx.compose.ui.ComposeUiFlags
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.power.WakeLockManager
|
||||
import com.hermesandroid.relay.runtime.HermesProcessRuntime
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
import com.hermesandroid.relay.util.CrashReporter
|
||||
|
||||
class HermesRelayApp : Application(), SingletonImageLoader.Factory {
|
||||
class HermesRelayApp : Application() {
|
||||
|
||||
/**
|
||||
* Shared chat/voice runtime for the main application process. It is lazy so
|
||||
* the always-available assistant session UI process stays lightweight and
|
||||
* cannot accidentally become a second microphone/session owner.
|
||||
*/
|
||||
val runtime: HermesProcessRuntime by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
check(isMainApplicationProcess()) {
|
||||
"HermesProcessRuntime may only be created in the main application process"
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
override fun attachBaseContext(base: android.content.Context?) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
ComposeUiFlags.isAdaptiveRefreshRateEnabled = false
|
||||
}
|
||||
HermesProcessRuntime(this)
|
||||
super.attachBaseContext(base)
|
||||
}
|
||||
|
||||
/**
|
||||
* Coil's singleton image loader for the whole app. Registering the OkHttp
|
||||
* network fetcher EXPLICITLY guarantees `http(s)` image URLs (e.g. a
|
||||
* generated-image link in a chat reply) load, rather than relying on
|
||||
* artifact auto-registration. Crossfade for a clean fade-in.
|
||||
*/
|
||||
override fun newImageLoader(context: PlatformContext): ImageLoader =
|
||||
ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(OkHttpNetworkFetcherFactory())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
add(AnimatedImageDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.crossfade(true)
|
||||
.build()
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
// Compose's adaptive refresh-rate hint path on API 35 can emit
|
||||
// `setRequestedFrameRate frameRate=NaN` from inside AndroidComposeView
|
||||
// on every draw pass. Disable ARR globally until the upstream fix lands.
|
||||
ComposeUiFlags.isAdaptiveRefreshRateEnabled = false
|
||||
}
|
||||
instance = this
|
||||
// Install the crash handler FIRST so any failure in the rest of app
|
||||
// init (or anywhere later) is captured and surfaced on next launch.
|
||||
CrashReporter.install(this)
|
||||
AppAnalytics.initialize(this)
|
||||
// A8 — wire the bridge-gesture wake-lock wrapper so
|
||||
// ActionExecutor.tap/tapText/typeText/swipe/scroll can hold
|
||||
@@ -76,19 +48,6 @@ class HermesRelayApp : Application(), SingletonImageLoader.Factory {
|
||||
AppForegroundTracker.initialize()
|
||||
}
|
||||
|
||||
private fun isMainApplicationProcess(): Boolean {
|
||||
val processName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
getProcessName()
|
||||
} else {
|
||||
val pid = android.os.Process.myPid()
|
||||
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
activityManager.runningAppProcesses
|
||||
?.firstOrNull { process -> process.pid == pid }
|
||||
?.processName
|
||||
}
|
||||
return processName == packageName
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: HermesRelayApp
|
||||
private set
|
||||
|
||||
@@ -5,37 +5,28 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Bundle
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.hermesandroid.relay.accessibility.ScreenCaptureRequester
|
||||
import com.hermesandroid.relay.bridge.BridgeForegroundService
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
|
||||
import com.hermesandroid.relay.notifications.InteractionRequestNotifier
|
||||
import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.util.ComposeArrWorkaround
|
||||
import com.hermesandroid.relay.util.NavRouteRequest
|
||||
import com.hermesandroid.relay.util.SharedContentRequest
|
||||
import com.hermesandroid.relay.util.extractSharedContent
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.collect
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val connectionViewModel: ConnectionViewModel
|
||||
get() = (applicationContext as HermesRelayApp).runtime.connectionViewModel
|
||||
private val connectionViewModel: ConnectionViewModel by viewModels()
|
||||
|
||||
// === PHASE3-bridge-ui-followup: MediaProjection consent flow ===
|
||||
// ActivityResultLauncher for the system screen-capture consent dialog.
|
||||
@@ -46,7 +37,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// We do NOT call MediaProjectionHolder directly from here. On Android
|
||||
// 14+, getMediaProjection() must run from inside a foreground service
|
||||
// that has already called startForeground(type=mediaProjection), and
|
||||
// that startForeground call must happen AFT consent. So we hand the
|
||||
// that startForeground call must happen AFTER consent. So we hand the
|
||||
// result off to BridgeForegroundService, which:
|
||||
// 1. Upgrades its FGS type to SPECIAL_USE | MEDIA_PROJECTION
|
||||
// 2. Calls MediaProjectionHolder.acceptGrantInsideForegroundService
|
||||
@@ -75,8 +66,6 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val splashScreen = installSplashScreen()
|
||||
com.hermesandroid.relay.assistant.AssistantSessionProtocol
|
||||
.prepareAssistActivation(intent)
|
||||
|
||||
// Hold splash until DataStore is loaded and onboarding status is known
|
||||
splashScreen.setKeepOnScreenCondition {
|
||||
@@ -98,12 +87,6 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
configureAssistantWindow(intent)
|
||||
lifecycleScope.launch {
|
||||
com.hermesandroid.relay.assistant.AssistantAppSessionState.active.collect { active ->
|
||||
if (!active) clearAssistantWindow()
|
||||
}
|
||||
}
|
||||
enableEdgeToEdge()
|
||||
|
||||
// === PHASE3-bridge-ui-followup: install MediaProjection requester ===
|
||||
@@ -130,26 +113,17 @@ class MainActivity : AppCompatActivity() {
|
||||
// in RelayApp's NavRouteRequest collector — we just pump the request
|
||||
// into the SharedFlow here.
|
||||
consumeNavRouteIntent(intent)
|
||||
consumeSharedContentIntent(intent)
|
||||
val consumedAssistantActivation =
|
||||
com.hermesandroid.relay.assistant.AssistantSessionProtocol.consumeActivation(
|
||||
this,
|
||||
intent,
|
||||
)
|
||||
if (!consumedAssistantActivation) {
|
||||
com.hermesandroid.relay.assistant.AssistantSessionProtocol.restoreActivation(this)
|
||||
}
|
||||
// === END PHASE3-safety-rails-followup ===
|
||||
setContent {
|
||||
RelayApp()
|
||||
}
|
||||
window.decorView.post {
|
||||
ComposeArrWorkaround.disableForViewTree(window.decorView)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
com.hermesandroid.relay.assistant.AssistantSessionProtocol
|
||||
.prepareAssistActivation(intent)
|
||||
configureAssistantWindow(intent)
|
||||
// === PHASE3-safety-rails-followup: deep-link nav route on re-launch ===
|
||||
// Same as onCreate but for the singleTask / FLAG_ACTIVITY_CLEAR_TOP
|
||||
// path: when the app is already running and the foreground service's
|
||||
@@ -157,8 +131,6 @@ class MainActivity : AppCompatActivity() {
|
||||
// instead of onCreate. RelayApp's collector handles both cases.
|
||||
setIntent(intent)
|
||||
consumeNavRouteIntent(intent)
|
||||
consumeSharedContentIntent(intent)
|
||||
com.hermesandroid.relay.assistant.AssistantSessionProtocol.consumeActivation(this, intent)
|
||||
// === END PHASE3-safety-rails-followup ===
|
||||
}
|
||||
|
||||
@@ -168,88 +140,8 @@ class MainActivity : AppCompatActivity() {
|
||||
NavRouteRequest.tryRequest(route)
|
||||
}
|
||||
|
||||
private fun consumeSharedContentIntent(intent: Intent?) {
|
||||
intent ?: return
|
||||
val streamUris = buildList {
|
||||
if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
IntentCompat.getParcelableArrayListExtra(
|
||||
intent,
|
||||
Intent.EXTRA_STREAM,
|
||||
android.net.Uri::class.java,
|
||||
)?.let(::addAll)
|
||||
} else {
|
||||
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, android.net.Uri::class.java)
|
||||
?.let(::add)
|
||||
}
|
||||
}
|
||||
val clipUris = buildList {
|
||||
val clipData = intent.clipData ?: return@buildList
|
||||
repeat(clipData.itemCount) { index -> clipData.getItemAt(index).uri?.let(::add) }
|
||||
}
|
||||
val clipTexts = buildList {
|
||||
val clip = intent.clipData ?: return@buildList
|
||||
repeat(clip.itemCount) { index -> clip.getItemAt(index).text?.let(::add) }
|
||||
}
|
||||
val sharedTexts = if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
intent.getCharSequenceArrayListExtra(Intent.EXTRA_TEXT).orEmpty()
|
||||
} else {
|
||||
listOfNotNull(intent.getCharSequenceExtra(Intent.EXTRA_TEXT))
|
||||
}
|
||||
val payload = extractSharedContent(
|
||||
action = intent.action,
|
||||
texts = sharedTexts,
|
||||
subject = intent.getCharSequenceExtra(Intent.EXTRA_SUBJECT),
|
||||
streamUriStrings = streamUris.map(android.net.Uri::toString),
|
||||
clipTexts = clipTexts,
|
||||
clipUriStrings = clipUris.map(android.net.Uri::toString),
|
||||
)
|
||||
SharedContentRequest.tryRequest(payload)
|
||||
}
|
||||
|
||||
private fun configureAssistantWindow(intent: Intent?) {
|
||||
if (
|
||||
intent?.getBooleanExtra(
|
||||
com.hermesandroid.relay.assistant.AssistantSessionProtocol.EXTRA_ASSISTANT_SESSION,
|
||||
false,
|
||||
) == true ||
|
||||
com.hermesandroid.relay.assistant.AssistantSessionPersistence.isActive(this)
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.addFlags(
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearAssistantWindow() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(false)
|
||||
setTurnScreenOn(false)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.clearFlags(
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
SharedContentRequest.retryFailed()
|
||||
// Returning to the app clears the one-slot "Hermes finished
|
||||
// responding" notification — the chat surface is the answer.
|
||||
TurnCompleteNotifier.cancel(this)
|
||||
// Action-required notifications are durable across process death.
|
||||
// Once the authenticated chat surface is visible it owns presentation;
|
||||
// unresolved asks are re-posted if the app returns to the background.
|
||||
InteractionRequestNotifier.cancelAll(this)
|
||||
// v0.4.1 — register this activity as the host for
|
||||
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so
|
||||
// we don't leak the Activity past its lifecycle. The unattended-
|
||||
|
||||
@@ -1551,7 +1551,7 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
* googlePlay as a dialer-opener" per the plan.
|
||||
*
|
||||
* The destructive-verb confirmation modal is fired in
|
||||
* [com.hermesandroid.relay.network.relay.BridgeCommandHandler]
|
||||
* [com.hermesandroid.relay.network.handlers.BridgeCommandHandler]
|
||||
* before we even get here — by the time this method runs, the user
|
||||
* has explicitly approved the call.
|
||||
*/
|
||||
|
||||