Compare commits
@@ -1,213 +0,0 @@
|
||||
<!-- @subframe-version 0.15.1-beta -->
|
||||
<!-- @subframe-managed -->
|
||||
---
|
||||
name: onboard
|
||||
description: Analyze project intelligence files and bootstrap SubFrame's STRUCTURE.json, PROJECT_NOTES.md, and initial sub-tasks from existing codebase context.
|
||||
disable-model-invocation: false
|
||||
argument-hint: [--dry-run]
|
||||
allowed-tools: Bash, Read, Write, Glob, Grep
|
||||
---
|
||||
|
||||
# SubFrame Onboard
|
||||
|
||||
Analyze an existing project and bootstrap SubFrame-compatible output files: `.subframe/STRUCTURE.json`, `.subframe/PROJECT_NOTES.md`, and initial sub-tasks.
|
||||
|
||||
## Dynamic Context
|
||||
|
||||
Root directory listing:
|
||||
!`ls -la`
|
||||
|
||||
Package manifest:
|
||||
!`cat package.json 2>/dev/null || cat pyproject.toml 2>/dev/null || cat Cargo.toml 2>/dev/null || echo "No package manifest found"`
|
||||
|
||||
Project overview:
|
||||
!`head -100 README.md 2>/dev/null || echo "No README found"`
|
||||
|
||||
AI configuration (Codex):
|
||||
!`head -50 AGENTS.md 2>/dev/null || echo "No AGENTS.md found"`
|
||||
|
||||
AI configuration (Gemini):
|
||||
!`head -50 GEMINI.md 2>/dev/null || echo "No GEMINI.md found"`
|
||||
|
||||
Source file survey:
|
||||
!`find . -maxdepth 2 -name "*.ts" -o -name "*.tsx" -o -name "*.py" -o -name "*.rs" -o -name "*.go" -o -name "*.java" -o -name "*.rb" 2>/dev/null | head -50`
|
||||
|
||||
Existing SubFrame state:
|
||||
!`cat .subframe/STRUCTURE.json 2>/dev/null || echo "No STRUCTURE.json yet"`
|
||||
|
||||
## Instructions
|
||||
|
||||
**Argument:** \`$ARGUMENTS\`
|
||||
|
||||
### Dry-Run Mode
|
||||
|
||||
If \`$ARGUMENTS\` contains \`--dry-run\`, **do not write any files**. Instead, show the full output that *would* be written for each file, clearly labeled with the target path. Then stop.
|
||||
|
||||
### Step 1: Analyze the Project
|
||||
|
||||
Using the gathered dynamic context, determine:
|
||||
|
||||
1. **Project type** — What kind of project is this? (web app, CLI tool, library, monorepo, etc.)
|
||||
2. **Language and framework** — Primary language, framework, and build tooling
|
||||
3. **Architecture** — Entry points, module structure, process model (single, client-server, microservices, etc.)
|
||||
4. **Key modules** — Identify the most important source files and their purposes (scan up to 3 directory levels deep)
|
||||
5. **Existing documentation** — What context already exists in README, AGENTS.md, GEMINI.md, or other docs?
|
||||
6. **Dependencies** — Key runtime and dev dependencies from the package manifest
|
||||
|
||||
### Step 2: Generate STRUCTURE.json
|
||||
|
||||
Build a SubFrame-compatible \`STRUCTURE.json\` following this schema:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"version": "1.0",
|
||||
"description": "<project-name> - Module structure and communication map",
|
||||
"lastUpdated": "<YYYY-MM-DD>",
|
||||
"architecture": {
|
||||
"type": "<project-type>",
|
||||
"entryPoint": "<main-entry-file>",
|
||||
"notes": "<brief architecture description>"
|
||||
},
|
||||
"modules": {
|
||||
"<module-key>": {
|
||||
"file": "<relative-path>",
|
||||
"description": "<what this module does>",
|
||||
"exports": ["<exported-function-or-class>"],
|
||||
"depends": ["<dependency-module-key>"],
|
||||
"functions": {
|
||||
"<function-name>": {
|
||||
"line": 0,
|
||||
"params": ["<param>"],
|
||||
"purpose": "<what it does>"
|
||||
}
|
||||
},
|
||||
"loc": 0
|
||||
}
|
||||
},
|
||||
"conventions": {
|
||||
"naming": "<file/variable naming conventions observed>",
|
||||
"patterns": "<architectural patterns used (MVC, hooks, modules, etc.)>"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Rules:**
|
||||
- If a \`STRUCTURE.json\` already exists, **merge** new data into it. Do not overwrite user-supplied descriptions or manually curated content. Only fill in empty fields and add newly discovered modules.
|
||||
- If no \`STRUCTURE.json\` exists, create a fresh one.
|
||||
- Scan source files to populate the \`modules\` section. For each module, read the first ~50 lines to identify exports and purpose.
|
||||
- Set \`lastUpdated\` to today's date.
|
||||
|
||||
### Step 3: Generate PROJECT_NOTES.md
|
||||
|
||||
Build a SubFrame-compatible \`PROJECT_NOTES.md\` following this structure:
|
||||
|
||||
\`\`\`markdown
|
||||
# <Project Name> - Project Documentation
|
||||
|
||||
## Project Vision
|
||||
|
||||
**Problem:** <What problem does this project solve?>
|
||||
**Solution:** <Brief description of the solution>
|
||||
**Target User:** <Who is this for?>
|
||||
|
||||
---
|
||||
|
||||
## Project Summary
|
||||
|
||||
<1-2 paragraph summary of the project, its purpose, and current state.>
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Core
|
||||
- **<Technology>** (<version>): <Why it's used>
|
||||
|
||||
### Why These Technologies?
|
||||
- **<Technology>**: <Rationale>
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
<Description of the project's architecture, module layout, and data flow.>
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
<Any architecture or technology decisions discoverable from the codebase.>
|
||||
|
||||
---
|
||||
|
||||
## Session Notes
|
||||
|
||||
<Empty section — to be filled during future development sessions.>
|
||||
\`\`\`
|
||||
|
||||
**Rules:**
|
||||
- If a \`PROJECT_NOTES.md\` already exists, **do not overwrite it**. Instead, show a diff of suggested additions and ask the user before applying changes.
|
||||
- If no \`PROJECT_NOTES.md\` exists, create a fresh one from the template above.
|
||||
- Fill in as much detail as the codebase context allows. Leave sections with \`<placeholder>\` text if insufficient information is available.
|
||||
|
||||
### Step 4: Suggest Initial Sub-Tasks
|
||||
|
||||
Analyze the project's current state and suggest **3 to 5 initial sub-tasks**. Good candidates include:
|
||||
|
||||
- Missing documentation that should exist
|
||||
- Test coverage gaps (if a test framework is configured but few tests exist)
|
||||
- TODO/FIXME comments found in the source code
|
||||
- Configuration improvements (linting, formatting, CI)
|
||||
- Architecture improvements visible from the structure analysis
|
||||
|
||||
For each suggested sub-task, show:
|
||||
- **Title** — concise imperative description
|
||||
- **Description** — what needs to be done and why
|
||||
- **Priority** — \`low\`, \`medium\`, or \`high\`
|
||||
- **Category** — \`feature\`, \`fix\`, \`docs\`, \`refactor\`, \`test\`, \`chore\`
|
||||
|
||||
**Ask the user to confirm** which sub-tasks to create before writing any. Then create the approved ones using the task CLI:
|
||||
|
||||
\`\`\`bash
|
||||
node scripts/task.js add --title "<title>" --description "<description>" --priority <priority> --category <category>
|
||||
\`\`\`
|
||||
|
||||
If the task CLI script (\`scripts/task.js\`) does not exist in the target project, skip sub-task creation and inform the user that the SubFrame task CLI is not available.
|
||||
|
||||
### Step 5: Ensure Directory Structure
|
||||
|
||||
Before writing any files, ensure the \`.subframe/\` directory and its subdirectories exist:
|
||||
|
||||
\`\`\`bash
|
||||
mkdir -p .subframe/tasks
|
||||
\`\`\`
|
||||
|
||||
### Step 6: Write Files
|
||||
|
||||
Write the generated content:
|
||||
1. \`.subframe/STRUCTURE.json\` — the module map
|
||||
2. \`.subframe/PROJECT_NOTES.md\` — the project documentation
|
||||
|
||||
### Step 7: Summary
|
||||
|
||||
Show a summary of what was created or updated:
|
||||
|
||||
\`\`\`
|
||||
## Onboard Summary
|
||||
|
||||
**Project:** <name> (<type>)
|
||||
**Language:** <primary language> + <framework>
|
||||
|
||||
### Files Written
|
||||
- \`.subframe/STRUCTURE.json\` — <N> modules mapped
|
||||
- \`.subframe/PROJECT_NOTES.md\` — project documentation bootstrapped
|
||||
|
||||
### Sub-Tasks Created
|
||||
- [ST-XXX] <title> (priority, category)
|
||||
- ...
|
||||
|
||||
### Next Steps
|
||||
- Review the generated files and refine descriptions
|
||||
- Run \`npm run structure\` if available to enrich STRUCTURE.json with line numbers
|
||||
- Start working on the created sub-tasks
|
||||
\`\`\`
|
||||
@@ -1,83 +0,0 @@
|
||||
<!-- @subframe-version 0.15.1-beta -->
|
||||
<!-- @subframe-managed -->
|
||||
---
|
||||
name: sub-audit
|
||||
description: Run a code review and documentation audit on recent changes. Finds bugs, edge cases, missing docs, and type safety issues.
|
||||
argument-hint: [scope - e.g., "auth feature", "last 5 commits"]
|
||||
disable-model-invocation: false
|
||||
allowed-tools: Bash, Read, Grep, Glob, Agent
|
||||
---
|
||||
|
||||
# SubFrame Audit
|
||||
|
||||
Run a thorough audit on recent changes, combining code review and documentation checks.
|
||||
|
||||
## Dynamic Context
|
||||
|
||||
Recent commits (last 15):
|
||||
!`git log --oneline --no-decorate -15 2>/dev/null || echo "No git history"`
|
||||
|
||||
Files changed vs main:
|
||||
!`git diff --name-only main...HEAD 2>/dev/null | head -40`
|
||||
|
||||
## Instructions
|
||||
|
||||
**Argument:** `$ARGUMENTS`
|
||||
|
||||
The argument should describe the scope to audit. If empty, audit all changes since the last merge to main.
|
||||
|
||||
### Phase 1: Identify Scope
|
||||
|
||||
Determine which files to audit:
|
||||
- If argument specifies a feature/scope, identify the relevant files
|
||||
- If empty, use `git diff --name-only main...HEAD` to find all changed files
|
||||
- Group files by layer (e.g., backend, frontend, shared, config, tests)
|
||||
|
||||
### Phase 2: Code Review (spawn agent)
|
||||
|
||||
Spawn a code review agent (`feature-dev:code-reviewer` subagent type) to review the changed files. The agent should check for:
|
||||
|
||||
1. **Critical bugs** — null/undefined access, race conditions, unhandled errors, infinite loops
|
||||
2. **Type safety** — `as any` casts, missing type imports, loose typing where strict types exist
|
||||
3. **Platform issues** — Windows path handling, file system edge cases
|
||||
4. **Security** — command injection, XSS in rendered content, path traversal
|
||||
5. **Logic errors** — off-by-one, incorrect conditions, missing edge cases
|
||||
|
||||
### Phase 3: Documentation Audit (spawn agent)
|
||||
|
||||
Spawn an explore agent (`Explore` subagent type) to check documentation completeness:
|
||||
|
||||
1. **AGENTS.md** — Are all modules/components listed?
|
||||
2. **changelog.md** — Does [Unreleased] reflect all new features?
|
||||
3. **PROJECT_NOTES.md** — Are architecture decisions documented?
|
||||
4. **STRUCTURE.json** — Is it up to date? (compare module count with actual files)
|
||||
|
||||
### Phase 4: Report
|
||||
|
||||
Present findings in this format:
|
||||
|
||||
```
|
||||
## Audit Report
|
||||
|
||||
### Critical Issues (must fix)
|
||||
1. [FILE:LINE] Description — severity, impact
|
||||
|
||||
### Important Issues (should fix)
|
||||
1. [FILE:LINE] Description — severity, impact
|
||||
|
||||
### Documentation Gaps
|
||||
1. [FILE] What's missing
|
||||
|
||||
### Suggestions (nice to have)
|
||||
1. Description
|
||||
```
|
||||
|
||||
**Confidence filtering:** Only report issues you are confident about. Skip speculative concerns or style preferences. Each reported issue should include:
|
||||
- Exact file and line number
|
||||
- What the problem is
|
||||
- Why it matters (impact)
|
||||
- Suggested fix
|
||||
|
||||
### Phase 5: Offer Fixes
|
||||
|
||||
After presenting the report, ask the user if they want to fix any of the reported issues. If yes, apply fixes starting with Critical → Important → Documentation.
|
||||
@@ -1,91 +0,0 @@
|
||||
<!-- @subframe-version 0.15.1-beta -->
|
||||
<!-- @subframe-managed -->
|
||||
---
|
||||
name: sub-docs
|
||||
description: Sync all SubFrame documentation after feature work. Updates AGENTS.md lists, changelog, PROJECT_NOTES decisions, and STRUCTURE.json.
|
||||
argument-hint: [summary of what changed]
|
||||
disable-model-invocation: false
|
||||
allowed-tools: Bash, Read, Edit, Write, Grep, Glob
|
||||
---
|
||||
|
||||
# SubFrame Documentation Sync
|
||||
|
||||
After significant feature work, synchronize all SubFrame documentation references. This skill automates the "Before Ending Work" checklist.
|
||||
|
||||
## Dynamic Context
|
||||
|
||||
Current version:
|
||||
!`node -e "console.log(require('./package.json').version)" 2>/dev/null || echo "unknown"`
|
||||
|
||||
Recent commits (last 10):
|
||||
!`git log --oneline --no-decorate -10 2>/dev/null || echo "No git history"`
|
||||
|
||||
Files changed (unstaged + staged):
|
||||
!`git diff --name-only HEAD 2>/dev/null | head -30`
|
||||
|
||||
## Instructions
|
||||
|
||||
**Argument:** `$ARGUMENTS`
|
||||
|
||||
The argument should describe what feature/changes were made. If empty, infer from recent git changes.
|
||||
|
||||
### Step 1: Identify What Changed
|
||||
|
||||
Read the recent changes (git diff, argument context) and categorize:
|
||||
- **New source modules** → update AGENTS.md module lists (if applicable)
|
||||
- **New components** → update AGENTS.md component lists (if applicable)
|
||||
- **Architecture decisions** → add to `.subframe/PROJECT_NOTES.md` Session Notes
|
||||
- **User-facing features** → add to `.subframe/docs-internal/changelog.md` under [Unreleased]
|
||||
|
||||
### Step 2: Update AGENTS.md
|
||||
|
||||
Read `AGENTS.md` and update only the sections that need changes. If AGENTS.md has module/component lists, add new entries. Preserve existing formatting and ordering.
|
||||
|
||||
**Rules:**
|
||||
- Only add genuinely new entries — don't duplicate
|
||||
- Keep formatting consistent with existing entries
|
||||
- Don't modify user-written content outside SubFrame-managed sections
|
||||
|
||||
### Step 3: Update Changelog
|
||||
|
||||
Read `.subframe/docs-internal/changelog.md` and add entries under `## [Unreleased]`.
|
||||
|
||||
**Format:** Follow the existing changelog style:
|
||||
- Group under `### Added`, `### Changed`, `### Fixed`, `### Removed`
|
||||
- Bold feature name, em-dash, brief description
|
||||
- Sub-bullets for implementation details
|
||||
|
||||
### Step 4: Update PROJECT_NOTES (if architecture decision)
|
||||
|
||||
If the work involved an architecture decision worth preserving, add a session note to `.subframe/PROJECT_NOTES.md` under `## Session Notes`.
|
||||
|
||||
**Format:**
|
||||
```markdown
|
||||
### [YYYY-MM-DD] Title
|
||||
|
||||
**Context:** Why this decision was needed.
|
||||
|
||||
**Decision:** What was chosen.
|
||||
|
||||
**Key architectural choices:**
|
||||
- Point 1
|
||||
- Point 2
|
||||
|
||||
**Files:** list of key files
|
||||
```
|
||||
|
||||
**Skip this step** for routine changes (bug fixes, minor UI tweaks, config changes).
|
||||
|
||||
### Step 5: Regenerate STRUCTURE.json
|
||||
|
||||
Run: `npm run structure`
|
||||
|
||||
This picks up any new/renamed/deleted source files.
|
||||
|
||||
### Step 6: Summary
|
||||
|
||||
Present a checklist of what was updated:
|
||||
- [ ] AGENTS.md — what was added/changed
|
||||
- [ ] changelog.md — entries added
|
||||
- [ ] PROJECT_NOTES.md — decision added (or skipped)
|
||||
- [ ] STRUCTURE.json — regenerated
|
||||
@@ -1,106 +0,0 @@
|
||||
<!-- @subframe-version 0.15.1-beta -->
|
||||
<!-- @subframe-managed -->
|
||||
---
|
||||
name: sub-tasks
|
||||
description: View and manage SubFrame Sub-Tasks. Use when starting work, completing tasks, checking what's pending, or creating new tasks from conversation.
|
||||
disable-model-invocation: false
|
||||
argument-hint: [list|start|complete|add|get|archive]
|
||||
allowed-tools: Bash, Read, Write, Edit, Glob
|
||||
---
|
||||
|
||||
# SubFrame Sub-Tasks
|
||||
|
||||
Manage the project's Sub-Task system. Sub-Tasks are SubFrame's project task tracking stored as individual markdown files in `.subframe/tasks/`.
|
||||
|
||||
## Dynamic Context
|
||||
|
||||
Current task index:
|
||||
!`cat .subframe/tasks.json 2>/dev/null || echo "No tasks.json found"`
|
||||
|
||||
## Instructions
|
||||
|
||||
**Argument:** `$ARGUMENTS`
|
||||
|
||||
### Task File Format
|
||||
|
||||
Each task is a markdown file in `.subframe/tasks/` with YAML frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: task-abc123
|
||||
title: My task title
|
||||
status: pending
|
||||
priority: medium
|
||||
category: feature
|
||||
description: What needs to be done
|
||||
userRequest: The user's original words
|
||||
acceptanceCriteria: How to verify completion
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
createdAt: 2024-01-01T00:00:00.000Z
|
||||
updatedAt: 2024-01-01T00:00:00.000Z
|
||||
completedAt: null
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
Session notes go here.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] Step one
|
||||
- [x] Step two (completed)
|
||||
```
|
||||
|
||||
### Operations
|
||||
|
||||
#### List tasks
|
||||
Read `.subframe/tasks.json` for the index overview, or glob `.subframe/tasks/*.md` and read frontmatter.
|
||||
|
||||
#### Get task details
|
||||
Read the specific `.subframe/tasks/<id>.md` file.
|
||||
|
||||
#### Start a task (pending → in_progress)
|
||||
Edit the task's frontmatter: set `status: in_progress` and update `updatedAt`.
|
||||
|
||||
#### Complete a task
|
||||
Edit the task's frontmatter: set `status: completed`, set `completedAt` to current ISO timestamp, update `updatedAt`.
|
||||
|
||||
#### Add a new task
|
||||
Create a new `.subframe/tasks/<id>.md` file with:
|
||||
- Generate id: `task-` + 8 random alphanumeric chars
|
||||
- Set `status: pending`, `createdAt` and `updatedAt` to current ISO timestamp
|
||||
- `completedAt: null`
|
||||
- Include all required fields in frontmatter
|
||||
|
||||
#### Update a task
|
||||
Edit the frontmatter fields as needed. Always update `updatedAt`.
|
||||
|
||||
#### Archive completed tasks
|
||||
Move completed `.md` files to `.subframe/tasks/archive/YYYY/` (create directory if needed).
|
||||
|
||||
### After Any Write Operation
|
||||
|
||||
Regenerate the `.subframe/tasks.json` index by reading all `.subframe/tasks/*.md` files (excluding archive/) and building the JSON structure with tasks grouped by status (pending, inProgress, completed).
|
||||
|
||||
### If invoked without arguments
|
||||
|
||||
Show the current task list and ask the user what they'd like to do:
|
||||
1. Start a pending task
|
||||
2. Complete an in-progress task
|
||||
3. Create a new task
|
||||
4. Archive completed tasks
|
||||
|
||||
### If invoked with a task ID
|
||||
|
||||
Show full details for that task by reading its .md file.
|
||||
|
||||
### Creating tasks from conversation
|
||||
|
||||
When the user says things like "let's do this later", "add a task for...", or "we should...":
|
||||
1. Capture the user's exact words as `userRequest`
|
||||
2. Write a detailed `description` explaining what, how, and which files
|
||||
3. Set appropriate `priority` and `category`
|
||||
4. Create the .md file
|
||||
5. Regenerate the index
|
||||
6. Confirm the task was created
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1,90 @@
|
||||
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
|
||||
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Security guidance
|
||||
url: https://github.com/Codename-11/hermes-relay/blob/main/docs/security.md
|
||||
about: Review the security model before posting sensitive vulnerability details publicly.
|
||||
- name: User documentation
|
||||
url: https://codename-11.github.io/hermes-relay/
|
||||
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.
|
||||
@@ -0,0 +1,64 @@
|
||||
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
|
||||
@@ -0,0 +1,78 @@
|
||||
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
|
||||
@@ -6,11 +6,20 @@
|
||||
|
||||
-
|
||||
|
||||
## Verification
|
||||
|
||||
<!-- List the checks you ran, or explain why a check is not applicable. -->
|
||||
|
||||
-
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `./gradlew assembleDebug` succeeds
|
||||
- [ ] `./gradlew test` passes
|
||||
- [ ] Tested on emulator or device (if UI change)
|
||||
- [ ] Target branch is `dev` unless this is a release PR
|
||||
- [ ] Android changes: lint and focused unit tests ran, or rationale is listed above
|
||||
- [ ] 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
|
||||
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
- [ ] CHANGELOG.md updated (if user-facing)
|
||||
- [ ] No credentials or secrets in committed files
|
||||
- [ ] Public writing hygiene checked: no secrets, private infrastructure, personal names, or AI/process narration
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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,9 @@
|
||||
# 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 + test (parallel) -> upload artifacts
|
||||
# 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.
|
||||
|
||||
name: CI — Android
|
||||
|
||||
@@ -38,11 +40,12 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
# Android Lint — gate for build and test jobs
|
||||
# Android Lint
|
||||
# ──────────────────────────────────────────────
|
||||
lint:
|
||||
name: Lint (Android)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -55,25 +58,20 @@ jobs:
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
# 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
|
||||
- name: Run Android lint
|
||||
run: ./gradlew lint --console=plain
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Android Build — assembleDebug + upload APK
|
||||
# Android Build — assembleDebug for PRs and main pushes
|
||||
# ──────────────────────────────────────────────
|
||||
build:
|
||||
name: Build (Android)
|
||||
needs: lint
|
||||
if: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -86,12 +84,15 @@ jobs:
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
run: ./gradlew assembleDebug --console=plain
|
||||
|
||||
- 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
|
||||
@@ -109,8 +110,8 @@ 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
|
||||
# pushes to main and PRs whose base branch is main; true (= advisory)
|
||||
# for everything else (dev pushes, dev-targeted PRs, feature branches).
|
||||
@@ -127,14 +128,26 @@ jobs:
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
- name: Run unit tests
|
||||
run: ./gradlew test
|
||||
# The broad Gradle `test` aggregate currently hangs in deferred JVM test
|
||||
# suites tracked by issue #32. Keep CI release-relevant until that suite is
|
||||
# split: pairing URL derivation plus connection switching are the stable
|
||||
# Android regression slice for the active release work.
|
||||
- name: Run focused Android unit tests
|
||||
run: |
|
||||
./gradlew :app:testSideloadDebugUnitTest \
|
||||
--tests com.hermesandroid.relay.network.ArchitectureBoundaryTest \
|
||||
--tests com.hermesandroid.relay.network.relay.RelayUrlDeriverTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
|
||||
--console=plain
|
||||
|
||||
# Upload test reports even if tests fail, for debugging
|
||||
# Upload reports only for failures. Successful PR report uploads add
|
||||
# noticeable latency and are rarely inspected.
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
if: failure()
|
||||
with:
|
||||
name: test-reports
|
||||
path: app/build/reports/tests/
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# 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).
|
||||
#
|
||||
# PR/push runs check a pinned ref (non-flaky); the weekly schedule tracks
|
||||
# upstream `main` as a drift siren so a route rename surfaces on our clock.
|
||||
|
||||
name: CI — Upstream Contract
|
||||
|
||||
on:
|
||||
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/**"
|
||||
pull_request:
|
||||
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@v6
|
||||
|
||||
- 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@v6
|
||||
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@v6
|
||||
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"
|
||||
@@ -0,0 +1,67 @@
|
||||
name: CI dashboard plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/dashboard/**"
|
||||
- ".github/workflows/ci-dashboard.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-dashboard-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
name: Build and test dashboard plugin
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: plugin/dashboard/package-lock.json
|
||||
|
||||
- name: Install dashboard deps
|
||||
working-directory: plugin/dashboard
|
||||
run: npm ci
|
||||
|
||||
- name: Build dashboard bundle
|
||||
working-directory: plugin/dashboard
|
||||
run: npm run build
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Verify plugin-owned version metadata
|
||||
run: python scripts/check-plugin-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
|
||||
|
||||
- name: Run dashboard API tests
|
||||
run: python -m unittest plugin.dashboard.test_plugin_api
|
||||
|
||||
- name: Verify dashboard bundle outputs
|
||||
run: |
|
||||
test -s plugin/dashboard/dist/index.js
|
||||
test -s plugin/dashboard/dist/style.css
|
||||
grep -q "hr-modal-card" plugin/dashboard/dist/style.css
|
||||
@@ -1,4 +1,4 @@
|
||||
name: CI desktop CLI
|
||||
name: CI desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -14,6 +14,10 @@ on:
|
||||
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
|
||||
@@ -25,7 +29,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -47,16 +51,8 @@ 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:
|
||||
@@ -69,7 +65,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -86,3 +82,31 @@ jobs:
|
||||
|
||||
- name: --help
|
||||
run: node bin/hermes-relay.js --help
|
||||
|
||||
tray-shell:
|
||||
name: Tray shell checks
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Cargo check tray shell
|
||||
run: npm run tray:check
|
||||
|
||||
- name: Test tray shell
|
||||
run: npm run tray:test
|
||||
|
||||
@@ -1,43 +1,66 @@
|
||||
# Hermes-Relay — Python Relay CI Pipeline
|
||||
# Hermes-Relay — Plugin CI Pipeline
|
||||
#
|
||||
# Runs on pushes to main/dev and on PRs targeting main/dev, scoped to
|
||||
# Python-affecting paths so Android-only changes don't spin up the
|
||||
# plugin-affecting paths so Android-only changes don't spin up the
|
||||
# Python toolchain.
|
||||
#
|
||||
# Pipeline: syntax-check -> unit-tests
|
||||
# Pipeline: syntax-check and focused plugin tests run concurrently.
|
||||
|
||||
name: CI — Relay
|
||||
name: CI — Plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/**"
|
||||
- "plugin/__init__.py"
|
||||
- "plugin/android_tool.py"
|
||||
- "plugin/cli.py"
|
||||
- "plugin/pair.py"
|
||||
- "plugin/plugin.yaml"
|
||||
- "plugin/relay/**"
|
||||
- "plugin/tools/**"
|
||||
- "plugin/tests/**"
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- ".github/workflows/ci-relay.yml"
|
||||
- "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"
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/**"
|
||||
- "plugin/__init__.py"
|
||||
- "plugin/android_tool.py"
|
||||
- "plugin/cli.py"
|
||||
- "plugin/pair.py"
|
||||
- "plugin/plugin.yaml"
|
||||
- "plugin/relay/**"
|
||||
- "plugin/tools/**"
|
||||
- "plugin/tests/**"
|
||||
- "relay_server/**"
|
||||
- "hermes_relay_bootstrap/**"
|
||||
- "pyproject.toml"
|
||||
- ".github/workflows/ci-relay.yml"
|
||||
- "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"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
concurrency:
|
||||
group: ci-relay-${{ github.ref }}
|
||||
group: ci-plugin-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Relay — py_compile syntax sanity
|
||||
# Python Plugin — py_compile syntax sanity
|
||||
# ──────────────────────────────────────────────
|
||||
syntax-check:
|
||||
name: Syntax check (Python)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -47,30 +70,32 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r relay_server/requirements.txt
|
||||
|
||||
- name: Syntax check (plugin.relay — canonical location)
|
||||
- name: Syntax check (plugin relay — canonical location)
|
||||
run: |
|
||||
python -m py_compile plugin/relay/server.py
|
||||
python -m py_compile plugin/relay/channels/terminal.py
|
||||
python -m py_compile plugin/relay/channels/chat.py
|
||||
python -m py_compile plugin/relay/channels/bridge.py
|
||||
python -m py_compile plugin/relay/voice.py
|
||||
python -m py_compile plugin/relay/upstream_voice.py
|
||||
|
||||
- name: Syntax check (relay_server shim)
|
||||
run: python -m py_compile relay_server/__init__.py relay_server/__main__.py
|
||||
|
||||
- name: Validate Plugin version metadata
|
||||
run: python scripts/check-plugin-version-sync.py
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Python Relay — unittest discover
|
||||
# Python Plugin — 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: Unit tests (Python)
|
||||
needs: syntax-check
|
||||
name: Focused Plugin tests (Python)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Advisory on dev, strict on main. Evaluates to false (= strict) for
|
||||
# pushes to main and PRs whose base branch is main; true (= advisory)
|
||||
# for everything else (dev pushes, dev-targeted PRs, feature branches).
|
||||
@@ -84,14 +109,14 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
# conftest.py imports `pytest` and `responses` at collection time.
|
||||
# `unittest discover` walks conftest.py like any other module, so both
|
||||
# must be importable even though none of the tests themselves use
|
||||
# pytest fixtures (they're all stdlib unittest.TestCase).
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r relay_server/requirements.txt
|
||||
pip install pytest responses
|
||||
|
||||
- name: Run unit tests
|
||||
run: python -m unittest discover plugin/tests
|
||||
- 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
|
||||
@@ -0,0 +1,49 @@
|
||||
# Required-checks sentinel — always runs on every PR + push to main/dev so
|
||||
# branch protection on `main` has a check name it can rely on, regardless
|
||||
# of which paths the PR touches.
|
||||
#
|
||||
# Why this exists. The other CI workflows (`ci-android.yml`, `ci-plugin.yml`,
|
||||
# `ci-desktop.yml`) are scoped via `paths:` filters so a docs-only or
|
||||
# desktop-only PR doesn't spin up the Android toolchain. Branch protection's
|
||||
# "required status checks" treat a check that doesn't run as failing — so
|
||||
# any PR that didn't touch the protected paths was blocked from merging,
|
||||
# even with all the relevant gates green. We were admin-overriding every
|
||||
# desktop-only PR. Same for relay-touching PRs (the protection rule named
|
||||
# `Relay Check (Python)` didn't even match any actual job — broken since
|
||||
# day one).
|
||||
#
|
||||
# This sentinel + claude-review become the only required checks. The
|
||||
# path-filtered workflows still run when relevant and surface their
|
||||
# results on the PR — visible, clickable, but advisory rather than
|
||||
# blocking. Reviewers (human + claude-review) eyeball them. This is the
|
||||
# standard pattern for monorepos with path-filtered CI.
|
||||
#
|
||||
# Trade-off acknowledged: a broken Android build on an Android-touching
|
||||
# PR could merge if the reviewer ignores the failing CI badge. Mitigation:
|
||||
# claude-review reads CI conclusions in its review prompt + the project's
|
||||
# release-merge cadence catches issues before they reach a tag. If a
|
||||
# stricter gate is later wanted, fold it into this workflow as a job that
|
||||
# fans out to the path-filtered work — but the simplest version (just an
|
||||
# `echo`) is what's needed to make branch protection useful again today.
|
||||
|
||||
name: Required checks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR. Doesn't matter much for
|
||||
# a 5-second job, but matches every other workflow's concurrency shape.
|
||||
concurrency:
|
||||
group: ci-required-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
name: Required checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: OK
|
||||
run: echo "Required-checks sentinel — see ci-required.yml header for context."
|
||||
@@ -17,21 +17,67 @@ jobs:
|
||||
# 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:
|
||||
# Any dev -> main PR is, by the branching model, the aggregate release PR
|
||||
# (main only ever receives release merges from dev). Detect it by base+head
|
||||
# alone — a title-format match (e.g. "release:") is fragile and silently
|
||||
# let a "Release v1.0.0 …"-titled PR run the full review and time out.
|
||||
IS_RELEASE_PR: ${{ github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev' }}
|
||||
# Bot-authored PRs such as Dependabot do not receive the same secret
|
||||
# surface as human-authored PRs, and Claude Code rejects bot actors unless
|
||||
# explicitly allow-listed. Keep the required check green with a no-op and
|
||||
# rely on the dependency CI/status checks for those PRs.
|
||||
IS_BOT_PR: ${{ github.event.pull_request.user.type == 'Bot' }}
|
||||
|
||||
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: Skip bot-authored PR review
|
||||
if: env.IS_BOT_PR == 'true'
|
||||
run: |
|
||||
echo "Skipping Claude Code Review for bot-authored PR."
|
||||
echo "Bot PRs are gated by Required checks plus their path-specific CI jobs."
|
||||
|
||||
- name: Checkout repository
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
# Depth 2 includes the pull_request merge commit's first parent, which
|
||||
# lets the next step detect whether this PR changes the workflow file.
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect Claude review workflow changes
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true'
|
||||
id: changed-workflow
|
||||
shell: bash
|
||||
run: |
|
||||
if git rev-parse --verify HEAD^1 >/dev/null 2>&1 &&
|
||||
git diff --name-only HEAD^1 HEAD | grep -Fxq ".github/workflows/claude-code-review.yml"; then
|
||||
echo "claude_review_workflow=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "claude_review_workflow=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Skip Claude review workflow self-change
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true' && steps.changed-workflow.outputs.claude_review_workflow == 'true'
|
||||
run: |
|
||||
echo "Skipping Claude Code Review because this PR changes the review workflow itself."
|
||||
echo "The Claude action requires this workflow file to match the default branch before it can exchange the app token."
|
||||
|
||||
- name: Run Claude Code Review
|
||||
if: env.IS_RELEASE_PR != 'true' && env.IS_BOT_PR != 'true' && steps.changed-workflow.outputs.claude_review_workflow != 'true'
|
||||
timeout-minutes: 15
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
|
||||
@@ -36,14 +36,14 @@ jobs:
|
||||
fetch-depth: 0 # Full history for lastUpdated timestamps
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: user-docs/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Build VitePress site
|
||||
@@ -51,10 +51,10 @@ jobs:
|
||||
working-directory: user-docs
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: user-docs/.vitepress/dist
|
||||
|
||||
@@ -68,4 +68,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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/default-language.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/default-language.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@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
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@v6
|
||||
|
||||
- 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
|
||||
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,15 +1,16 @@
|
||||
# Hermes-Relay — Release Pipeline
|
||||
# Hermes-Relay-Android — Release Pipeline
|
||||
#
|
||||
# Triggered when a version tag (v*) is pushed.
|
||||
# Triggered when an Android release tag (android-v*) is pushed.
|
||||
# Validates the tag matches the app version in libs.versions.toml,
|
||||
# runs CI checks, builds a release APK, and creates a GitHub Release.
|
||||
# runs focused Android checks, builds release APK/AAB artifacts, and creates a
|
||||
# GitHub Release. Plugin/Python package releases use plugin-v* tags.
|
||||
|
||||
name: Release
|
||||
name: Release Android
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "android-v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -26,7 +27,7 @@ jobs:
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
run: echo "version=${GITHUB_REF#refs/tags/android-v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify version sync
|
||||
run: |
|
||||
@@ -47,6 +48,7 @@ jobs:
|
||||
name: CI Checks
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -58,17 +60,24 @@ jobs:
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Run unit tests
|
||||
run: ./gradlew test
|
||||
# Keep the tag release gate aligned with CI — Android's broad Gradle
|
||||
# `test` aggregate currently hangs in deferred JVM suites tracked by
|
||||
# issue #32, so the release gate runs the stable connection/pairing slice.
|
||||
- name: Run focused Android unit tests
|
||||
run: |
|
||||
./gradlew :app:testSideloadDebugUnitTest \
|
||||
--tests com.hermesandroid.relay.network.RelayUrlDeriverTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
|
||||
--console=plain
|
||||
|
||||
release:
|
||||
name: Build & Publish Release
|
||||
needs: [validate, ci]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -80,6 +89,8 @@ jobs:
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
- name: Decode release keystore
|
||||
env:
|
||||
@@ -123,9 +134,10 @@ jobs:
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: v${{ needs.validate.outputs.version }}
|
||||
name: Hermes-Relay-Android v${{ needs.validate.outputs.version }}
|
||||
tag_name: android-v${{ needs.validate.outputs.version }}
|
||||
body_path: RELEASE_NOTES.md
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
# Attach all four flavored artifacts — users sideload the
|
||||
@@ -140,11 +152,43 @@ jobs:
|
||||
app/build/outputs/bundle/*Release/*.aab
|
||||
app/build/outputs/SHA256SUMS.txt
|
||||
|
||||
- name: Upload to Play Console (production draft)
|
||||
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 }}
|
||||
# Runs only when the Play service-account secret is configured AND this is
|
||||
# a stable tag (prereleases — versions containing a dash — are skipped so
|
||||
# an `-rc.N` build never lands on the production listing). HERMES_KEYSTORE_PATH
|
||||
# was exported into $GITHUB_ENV by the "Decode release keystore" step above
|
||||
# and persists across steps in this job, so the AAB is release-signed.
|
||||
#
|
||||
# `publishGooglePlayReleaseBundle` is the flavor-scoped task — only the
|
||||
# googlePlay AAB is uploaded (sideload is disabled via playConfigs in
|
||||
# app/build.gradle.kts). The play{} block pins releaseStatus = DRAFT, so the
|
||||
# build lands on the Production track as a DRAFT: CI does the upload, a human
|
||||
# clicks "Start rollout" in Play Console. A bad tag can never auto-go-live.
|
||||
if: ${{ env.PLAY_SERVICE_ACCOUNT_JSON != '' && !contains(needs.validate.outputs.version, '-') }}
|
||||
run: |
|
||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
||||
./gradlew publishGooglePlayReleaseBundle --track=production
|
||||
rm -f play-service-account.json
|
||||
|
||||
- name: Play upload skipped (no secret)
|
||||
env:
|
||||
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
if: ${{ env.PLAY_SERVICE_ACCOUNT_JSON == '' }}
|
||||
run: |
|
||||
echo "ℹ️ PLAY_SERVICE_ACCOUNT_JSON not set — skipped Play Console upload." \
|
||||
"GitHub Release artifacts are still published; upload to Play manually" \
|
||||
"(see RELEASE.md §5)." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Release summary
|
||||
env:
|
||||
HERMES_KEYSTORE_BASE64: ${{ secrets.HERMES_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
echo "## Release v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## Hermes-Relay-Android v${{ needs.validate.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ -n "$HERMES_KEYSTORE_BASE64" ]; then
|
||||
echo "✅ **Signed with release keystore** — suitable for Play Store upload" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,222 @@
|
||||
name: Release CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['cli-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@v6
|
||||
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: cli-binaries
|
||||
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: cli-windows-tray-installer
|
||||
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:
|
||||
# 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@v4
|
||||
|
||||
- name: Extract CLI version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF_NAME#cli-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
|
||||
|
||||
# Render CLI_RELEASE_NOTES.md (hand-written per release) into the GitHub
|
||||
# Release body. __VERSION__ = bare version (0.3.0), __TAG__ = full tag
|
||||
# (cli-v0.3.0) so the install/pin commands stay accurate without manual edits.
|
||||
- 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 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-darwin-x64
|
||||
release-assets/cli-binaries/hermes-relay-darwin-arm64
|
||||
release-assets/cli-windows-tray-installer/hermes-relay-desktop-windows-x64-setup.exe
|
||||
release-assets/SHA256SUMS.txt
|
||||
@@ -1,146 +0,0 @@
|
||||
name: Release desktop CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['desktop-v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-binaries:
|
||||
name: Build cross-platform 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
|
||||
|
||||
# NOTE: delegate to the package.json scripts so there's a single source
|
||||
# of truth for Bun compile flags. Previously these steps inlined their
|
||||
# own flag list, which silently diverged from `npm run build:bin:*` and
|
||||
# made the "drop --bytecode" fix ineffective on desktop-v0.3.0-alpha.2.
|
||||
- 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
|
||||
|
||||
# Smoke-test the Linux binary (runner platform) before publishing.
|
||||
# Catches the two failure modes we've burned alpha tags on:
|
||||
# - startup segfault (exit != 0, no output)
|
||||
# - silent exit 0 with zero output (main() never invoked)
|
||||
# We can only smoke the Linux target without a cross-platform matrix;
|
||||
# Windows/macOS smoke would need their own runners — tracked as an
|
||||
# alpha.5+ hardening item.
|
||||
- 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: Generate SHA256SUMS
|
||||
working-directory: desktop/dist/bin
|
||||
run: |
|
||||
sha256sum hermes-relay-* > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }}
|
||||
fail_on_unmatched_files: true
|
||||
body: |
|
||||
# Hermes-Relay Desktop CLI — ${{ github.ref_name }}
|
||||
|
||||
**Experimental phase.** Binaries are unsigned — Windows SmartScreen and macOS Gatekeeper will warn on first launch. See the install scripts for the `Unblock-File` / `xattr -dr` escape hatches.
|
||||
|
||||
## Install
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**macOS / Linux:**
|
||||
```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
|
||||
|
||||
```
|
||||
hermes-relay --version
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
hermes-relay shell
|
||||
```
|
||||
|
||||
See [Desktop CLI docs](https://codename-11.github.io/hermes-relay/desktop/) for full usage.
|
||||
|
||||
files: |
|
||||
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
|
||||
desktop/dist/bin/SHA256SUMS.txt
|
||||
@@ -0,0 +1,111 @@
|
||||
name: Release Plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "plugin-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@v6
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/plugin-v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify Plugin version sync
|
||||
run: python scripts/check-plugin-version-sync.py --expect "$TAG_VERSION"
|
||||
env:
|
||||
TAG_VERSION: ${{ steps.version.outputs.version }}
|
||||
|
||||
test:
|
||||
name: Test Plugin 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 Plugin 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 Plugin 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
|
||||
|
||||
# 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: plugin-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
|
||||
@@ -24,9 +24,17 @@ Thumbs.db
|
||||
local.properties
|
||||
/build/
|
||||
/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
|
||||
@@ -43,6 +51,13 @@ certs/
|
||||
|
||||
# Local tools
|
||||
.subframe/
|
||||
voice-lab-runs/
|
||||
realtime-voice-runs/
|
||||
realtime-agent-runs/
|
||||
voice_rec_*.wav
|
||||
|
||||
# Ad-hoc debugging artifacts (logcat dumps, screenshots, UI XMLs)
|
||||
.scratch/
|
||||
|
||||
# VitePress
|
||||
user-docs/.vitepress/cache/
|
||||
@@ -72,3 +87,6 @@ keystore.properties
|
||||
# Desktop TUI smoke harness runtime artifacts
|
||||
.smoke-relay.pid
|
||||
.smoke-relay.log
|
||||
|
||||
# Generated tray frontend vendor assets copied from desktop/node_modules
|
||||
desktop/tray/ui/vendor/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mobile-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mobilenext/mobile-mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,386 +1,49 @@
|
||||
<!-- @subframe-version 0.15.1-beta -->
|
||||
<!-- @subframe-managed -->
|
||||
# hermes-android - SubFrame Project
|
||||
|
||||
This project is managed with **SubFrame**. AI assistants should follow the rules below to keep documentation up to date.
|
||||
|
||||
> **Note:** This file is named `AGENTS.md` to be AI-tool agnostic. CLAUDE.md and GEMINI.md contain a reference to this file.
|
||||
|
||||
---
|
||||
|
||||
## Core Working Principle
|
||||
|
||||
**Only do what the user asks.** Do not go beyond the scope of the request.
|
||||
|
||||
- Implement exactly what the user requested — nothing more, nothing less.
|
||||
- Do not change business logic, flow, or architecture unless the user explicitly asks for it.
|
||||
- If a user asks for a design change, only change the design. Do not refactor, restructure, or modify functionality alongside it.
|
||||
- If you have additional suggestions or improvements, **present them as suggestions** to the user. Never implement them without approval.
|
||||
- The user's request must be completed first. Additional ideas come after, as proposals.
|
||||
|
||||
---
|
||||
|
||||
## Relationship to Native AI Tools
|
||||
|
||||
SubFrame **enhances** native AI coding tools — it does not replace them.
|
||||
|
||||
**Claude Code** works exactly as normal. Built-in features (`/init`, `/commit`, `/review-pr`, `/compact`, `/memory`, CLAUDE.md) are fully supported. CLAUDE.md is Claude Code's native instruction file — users can add their own tool-specific instructions freely. SubFrame adds a small backlink reference pointing to this AGENTS.md file using HTML comment markers (`<!-- SUBFRAME:BEGIN -->` / `<!-- SUBFRAME:END -->`). SubFrame will never overwrite user content in CLAUDE.md.
|
||||
|
||||
**Gemini CLI** works exactly as normal. Built-in features (`/init`, `/model`, `/memory`, `/compress`, `/settings`, GEMINI.md) are fully supported. GEMINI.md is Gemini CLI's native instruction file — same backlink approach as CLAUDE.md. Users can add their own instructions freely and SubFrame won't overwrite them.
|
||||
|
||||
**Codex CLI** gets SubFrame context via a wrapper script at `.subframe/bin/codex` that injects AGENTS.md as an initial prompt.
|
||||
|
||||
**This file (AGENTS.md)** contains SubFrame-specific rules that apply across all tools:
|
||||
- Sub-Task management (`.subframe/tasks/*.md`, index at `.subframe/tasks.json`)
|
||||
- Codebase mapping (`.subframe/STRUCTURE.json`)
|
||||
- Context preservation (`.subframe/PROJECT_NOTES.md`)
|
||||
- Internal docs and changelog (`.subframe/docs-internal/`)
|
||||
- Session notes and decision tracking
|
||||
|
||||
---
|
||||
|
||||
## Session Start
|
||||
|
||||
**Read these files at the start of each session:**
|
||||
|
||||
1. **`.subframe/STRUCTURE.json`** — Module map, file locations, architecture notes
|
||||
2. **`.subframe/PROJECT_NOTES.md`** — Project vision, past decisions, session notes
|
||||
3. **`.subframe/tasks.json`** — Sub-task index (pending, in-progress, completed)
|
||||
|
||||
This gives you full project context before making any changes. The session-start hook (if configured) automatically injects pending/in-progress sub-tasks into your context, but you should still read these files for deeper understanding.
|
||||
|
||||
### Concurrent Work & Worktrees
|
||||
|
||||
Before making changes, check whether other AI sessions or agent teams are already working on this repository. Signs of concurrent work include:
|
||||
- In-progress sub-tasks you didn't start (check `.subframe/tasks.json`)
|
||||
- Recent uncommitted changes in `git status` that aren't yours
|
||||
- Lock files or active worktrees (`git worktree list`)
|
||||
|
||||
**If concurrent work is detected**, ask the user: "Another session appears to be working on this project. Should I use a git worktree to avoid conflicts?"
|
||||
|
||||
**Git worktrees** create an isolated copy of the repo on a separate branch, allowing parallel work without merge conflicts:
|
||||
- Each worktree has its own working directory and branch
|
||||
- Changes in one worktree don't affect others until merged
|
||||
- Use worktrees when multiple agents or sessions work on different features simultaneously
|
||||
|
||||
**When to suggest a worktree:**
|
||||
- Agent teams spawning multiple workers on the same repo
|
||||
- User asks to work on a feature while another is in progress
|
||||
- The session-start hook flags concurrent sessions
|
||||
|
||||
**When worktrees are NOT needed:**
|
||||
- Single-session work with no concurrent agents
|
||||
- Read-only exploration or research tasks
|
||||
- Quick fixes that won't conflict with in-progress work
|
||||
|
||||
---
|
||||
|
||||
## Hooks (Automatic Awareness)
|
||||
|
||||
SubFrame can configure project-level hooks that automate sub-task awareness. These hooks fire automatically — no manual intervention needed.
|
||||
|
||||
| Hook | When it fires | What it does |
|
||||
|------|---------------|--------------|
|
||||
| **SessionStart** | Startup, resume, after compaction | Injects pending/in-progress sub-tasks into context |
|
||||
| **UserPromptSubmit** | Each user prompt | Fuzzy-matches prompt against pending sub-tasks, suggests starting a match |
|
||||
| **Stop** | When AI finishes responding | Reminds about in-progress sub-tasks; flags untracked work if source files changed |
|
||||
| **PreToolUse** | Before tool execution | Project-specific guardrails (if configured) |
|
||||
| **PostToolUse** | After tool execution | Project-specific follow-ups (if configured) |
|
||||
|
||||
These hooks ensure sub-task awareness even after context compaction. Hook configuration lives in `.claude/settings.json`.
|
||||
|
||||
---
|
||||
|
||||
## Skills (Slash Commands)
|
||||
|
||||
SubFrame provides optional slash commands for AI coding tools that support them (e.g., Claude Code):
|
||||
|
||||
| Skill | Purpose |
|
||||
|-------|---------|
|
||||
| `/sub-tasks` | Interactive sub-task management — list, start, complete, add, archive |
|
||||
| `/sub-docs` | Sync all SubFrame documentation after feature work (changelog, CLAUDE.md, PROJECT_NOTES, STRUCTURE) |
|
||||
| `/sub-audit` | Code review + documentation audit on recent changes |
|
||||
| `/onboard` | Bootstrap SubFrame files from existing codebase context |
|
||||
|
||||
Skills are deployed to `.claude/skills/` and enhance the workflow — but direct file editing always works as a fallback. If your AI tool doesn't support skills, follow the manual instructions in each section below.
|
||||
|
||||
---
|
||||
|
||||
## Sub-Task Management
|
||||
|
||||
> **Terminology:** "Sub-Tasks" are SubFrame's project task tracking system. The name plays on "Sub" from SubFrame and disambiguates from Claude Code's internal todo tools. When the user says "sub-task", they mean this system.
|
||||
|
||||
### Sub-Task File Format
|
||||
|
||||
Each sub-task lives in its own markdown file at `.subframe/tasks/<id>.md` with YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
id: task-abc12345
|
||||
title: Short and clear title (max 60 characters)
|
||||
status: pending | in_progress | completed
|
||||
priority: high | medium | low
|
||||
category: feature | fix | refactor | docs | test | chore
|
||||
description: AI's detailed explanation — what, how, which files affected
|
||||
userRequest: User's original prompt/request — copy exactly
|
||||
acceptanceCriteria: When is this task done? Concrete testable criteria
|
||||
blockedBy: [] # task IDs this depends on
|
||||
blocks: [] # task IDs that depend on this
|
||||
createdAt: ISO timestamp
|
||||
updatedAt: ISO timestamp
|
||||
completedAt: ISO timestamp | null
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
[YYYY-MM-DD] Session notes, alternatives considered, dependencies.
|
||||
|
||||
## Steps
|
||||
|
||||
- [x] Completed step
|
||||
- [ ] Pending step
|
||||
```
|
||||
|
||||
A generated index is kept at `.subframe/tasks.json` for hooks and quick lookups. After creating or modifying task `.md` files, regenerate the index by reading all `.subframe/tasks/*.md` files (excluding `archive/`) and building the JSON with tasks grouped by status.
|
||||
|
||||
### Sub-Task Recognition Rules
|
||||
|
||||
**These ARE SUB-TASKS:**
|
||||
- When the user requests a feature or change
|
||||
- Decisions like "Let's do this", "Let's add this", "Improve this"
|
||||
- Deferred work: "We'll do this later", "Let's leave it for now"
|
||||
- Gaps or improvement opportunities discovered while coding
|
||||
- Situations requiring bug fixes
|
||||
|
||||
**These are NOT SUB-TASKS:**
|
||||
- Error messages and debugging sessions
|
||||
- Questions, explanations, information exchange
|
||||
- Temporary experiments and tests
|
||||
- Work already completed and closed
|
||||
- Instant fixes (like typo fixes)
|
||||
|
||||
### Sub-Task Creation Flow
|
||||
|
||||
1. Detect sub-task patterns during conversation
|
||||
2. **Check existing sub-tasks first** — read `.subframe/tasks.json` to avoid duplicates
|
||||
3. Ask the user: "I identified these sub-tasks from our conversation, should I add them?"
|
||||
4. If approved, create `.subframe/tasks/<id>.md` with all required frontmatter fields
|
||||
5. Regenerate the `.subframe/tasks.json` index
|
||||
|
||||
### Sub-Task Content Rules
|
||||
|
||||
**title:** Short, action-oriented
|
||||
- OK: "Add tasks button to terminal toolbar"
|
||||
- Bad: "Tasks"
|
||||
|
||||
**description:** AI's detailed technical explanation
|
||||
- What will be done, how, which files affected
|
||||
- Minimum 2-3 sentences
|
||||
|
||||
**userRequest:** User's original words — copy verbatim for context preservation
|
||||
|
||||
**acceptanceCriteria:** Concrete, testable completion criteria
|
||||
|
||||
### Sub-Task Status Updates
|
||||
|
||||
**Before starting any work**, check `.subframe/tasks.json` for an existing sub-task that matches. If found, set it to `in_progress` — do not create a duplicate.
|
||||
|
||||
- `pending` → `in_progress` — immediately when you begin working (update `updatedAt`)
|
||||
- `in_progress` → `completed` — when done and verified (set `completedAt`, update `updatedAt`)
|
||||
- `completed` → `pending` — when reopening, add a note explaining why
|
||||
- After commit: check and update the status of all related sub-tasks
|
||||
- **Incomplete work:** If partially done at session end, leave as `in_progress` and add a notes entry
|
||||
|
||||
### Sub-Task Lifecycle
|
||||
|
||||
- If a sub-task grows beyond its original scope, split it — create new sub-tasks and reference the parent ID in notes
|
||||
- Cross-reference relevant commit hashes or PR numbers in notes
|
||||
- Update the description if the approach changes significantly
|
||||
|
||||
### Priority Guidelines
|
||||
|
||||
- **high** — Blocking other work or explicitly flagged as urgent by the user
|
||||
- **medium** — Normal feature work and standard bug fixes
|
||||
- **low** — Nice-to-have improvements, deferred items, minor polish
|
||||
|
||||
---
|
||||
|
||||
## .subframe/PROJECT_NOTES.md Rules
|
||||
|
||||
### When to Update?
|
||||
- When an important architectural decision is made
|
||||
- When a technology choice is made
|
||||
- When an important problem is solved and the solution method is noteworthy
|
||||
- When an approach is determined together with the user
|
||||
|
||||
### Format
|
||||
Free format. Date + title is sufficient:
|
||||
```markdown
|
||||
### [YYYY-MM-DD] Topic title
|
||||
Conversation/decision as is, with its context...
|
||||
```
|
||||
|
||||
### Update Flow
|
||||
- Update immediately after a decision is made
|
||||
- You can add without asking the user (for important decisions)
|
||||
- You can accumulate small decisions and add them in bulk
|
||||
|
||||
### Organization Rules
|
||||
- Keep **"Project Vision"** at the top, then **"Session Notes"** in chronological order
|
||||
- Notes should capture the **why** (decisions, trade-offs, alternatives rejected), not the **what** (code structure belongs in STRUCTURE.json)
|
||||
- When the same topic spans multiple sessions, consolidate related notes under the original heading rather than creating duplicates
|
||||
- When notes grow beyond ~500 lines, consider archiving older session notes or grouping by month
|
||||
|
||||
---
|
||||
|
||||
## Context Preservation (Automatic Note Taking)
|
||||
|
||||
SubFrame's core purpose is to prevent context loss. Capture important moments and ask the user.
|
||||
|
||||
### When to Ask?
|
||||
|
||||
Ask the user: **"Should I add this to .subframe/PROJECT_NOTES.md?"** when:
|
||||
|
||||
- A sub-task is successfully completed
|
||||
- An important architectural/technical decision is made
|
||||
- A bug is fixed and the solution method is noteworthy
|
||||
- "Let's do this later" is said (also add as a sub-task)
|
||||
- A new pattern or best practice is discovered
|
||||
|
||||
### Importance Threshold
|
||||
|
||||
**Would it take more than 5 minutes to re-derive or re-explain in a future session?** If yes, capture it.
|
||||
|
||||
**Always capture:** Architecture decisions, technology choices, approach changes, user preferences discovered during work.
|
||||
|
||||
**Never capture:** Routine debugging steps, simple config changes, typo fixes.
|
||||
|
||||
**Note failed approaches too** — a brief "We tried X, it didn't work because Y" prevents future re-exploration of dead ends.
|
||||
|
||||
### Completion Detection
|
||||
|
||||
Pay attention to these signals:
|
||||
- User approval: "okay", "done", "it worked", "nice", "fixed", "yes"
|
||||
- Moving from one topic to another
|
||||
- User continuing after build/run succeeds
|
||||
|
||||
### How to Add?
|
||||
|
||||
1. **DON'T write a summary** — Add the conversation as is, with its context
|
||||
2. **Add date** — In `### [YYYY-MM-DD] Title` format
|
||||
3. **Add to Session Notes section** — At the end of PROJECT_NOTES.md
|
||||
|
||||
### When NOT to Ask
|
||||
|
||||
- For every small change (it becomes spam)
|
||||
- Typo fixes, simple corrections
|
||||
- If the user already said "no" or "not needed", don't ask again for that topic
|
||||
|
||||
### If User Says "No"
|
||||
|
||||
No problem, continue. The user can also say what they consider important themselves: "add this to notes"
|
||||
|
||||
---
|
||||
|
||||
## .subframe/STRUCTURE.json Rules
|
||||
|
||||
**This file is the map of the codebase.**
|
||||
|
||||
### When to Update?
|
||||
- When a new file/folder is created
|
||||
- When a file/folder is deleted or moved
|
||||
- When module dependencies change
|
||||
- When an IPC channel is added or changed
|
||||
- When an important architectural pattern is discovered (architectureNotes)
|
||||
|
||||
### Full Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"modules": {
|
||||
"main/moduleName": {
|
||||
"file": "src/main/moduleName.ts",
|
||||
"description": "What this module does",
|
||||
"exports": ["init", "loadData"],
|
||||
"depends": ["fs", "path", "shared/ipcChannels"],
|
||||
"functions": {
|
||||
"init": { "line": 15 },
|
||||
"loadData": { "line": 42 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"ipcChannels": {
|
||||
"CHANNEL_NAME": {
|
||||
"direction": "renderer → main",
|
||||
"handler": "main/moduleName"
|
||||
}
|
||||
},
|
||||
"architectureNotes": {
|
||||
"topicName": {
|
||||
"issue": "Description of the pattern or concern",
|
||||
"solution": "How it was resolved"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Update Rules
|
||||
- The pre-commit hook (if configured) auto-updates STRUCTURE.json when source files in `src/` are committed
|
||||
- When deleting files, remove their entries from `modules` and update any `depends` arrays that referenced them
|
||||
- When adding IPC channels, also add them to the `ipcChannels` section with `direction` and `handler`
|
||||
- `architectureNotes` is for **structural patterns** (e.g., circular dependency workarounds, init ordering). Use PROJECT_NOTES.md for **decisions and session context**
|
||||
- If function line numbers drift significantly after edits, re-run the pre-commit hook or update manually
|
||||
|
||||
---
|
||||
|
||||
## .subframe/docs-internal/ Directory
|
||||
|
||||
This directory holds project documentation that doesn't belong in the root:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `changelog.md` | Track changes under `## [Unreleased]`, grouped by Added/Changed/Fixed/Removed |
|
||||
| `*.md` (ADRs) | Architecture Decision Records for significant design choices |
|
||||
|
||||
**What goes here:** Changelog entries, architecture decision records, internal reference docs.
|
||||
|
||||
**What does NOT go here:** User-facing docs (those go in `docs/` or project root), task files (those go in `.subframe/tasks/`).
|
||||
|
||||
---
|
||||
|
||||
## .subframe/QUICKSTART.md Rules
|
||||
|
||||
### When to Update?
|
||||
- When installation steps change
|
||||
- When new requirements are added
|
||||
- When important commands change
|
||||
|
||||
---
|
||||
|
||||
## Before Ending Work
|
||||
|
||||
After significant work (code changes, architecture decisions), verify SubFrame files are in sync:
|
||||
|
||||
1. **Sub-Tasks** — Was this work tracked? Check `.subframe/tasks.json` → create/complete as needed
|
||||
2. **PROJECT_NOTES.md** — Any decisions worth preserving? Ask the user
|
||||
3. **Changelog** — Does `.subframe/docs-internal/changelog.md` reflect the changes?
|
||||
4. **STRUCTURE.json** — Source files changed? The pre-commit hook handles this automatically if configured; otherwise update manually
|
||||
|
||||
The stop hook (if configured) will flag untracked work automatically.
|
||||
|
||||
---
|
||||
|
||||
## General Rules
|
||||
|
||||
1. **Language:** Write documentation in English (except code examples)
|
||||
2. **Date Format:** ISO 8601 (YYYY-MM-DDTHH:mm:ssZ)
|
||||
3. **After Commit:** Check sub-tasks (`.subframe/tasks/*.md`) and `.subframe/STRUCTURE.json`
|
||||
4. **Session Start:** Read STRUCTURE.json, PROJECT_NOTES.md, and tasks.json before making changes
|
||||
5. **Don't Duplicate:** Always check existing sub-tasks before creating new ones
|
||||
|
||||
---
|
||||
|
||||
*This file was automatically created by SubFrame.*
|
||||
*Creation date: 2026-04-14*
|
||||
|
||||
<!-- subframe-template-version: 1 -->
|
||||
# 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
|
||||
|
||||
The detailed, authoritative context lives in **[CLAUDE.md](CLAUDE.md)** —
|
||||
architecture, the upstream Hermes API reference, repository layout, per-language
|
||||
code style, the dev loop, and the Key Files map. Read it before touching code,
|
||||
then `docs/spec.md` and `docs/decisions.md`.
|
||||
|
||||
- Release process → **[RELEASE.md](RELEASE.md)**
|
||||
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
|
||||
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.md)**
|
||||
- Follow-ups / deferred work / known gaps → **[TODO.md](TODO.md)** (the single home for "what's next" — never DEVLOG, never scattered code comments)
|
||||
|
||||
## Non-negotiables (the short list)
|
||||
|
||||
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection —
|
||||
chat via the API server, Vanilla Hermes voice via the Hermes dashboard — 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.
|
||||
- **Conventional Commits + `main`/`dev` branching.** Feature branches off `dev`,
|
||||
`--no-ff` merges, version bumps at release-prep on `dev`, tags cut 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".
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -6,8 +6,289 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.0] - 2026-06-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Sensitive-media classification (relay).** The relay teaches the agent — server-side, via a removable system-prompt block — to mark private/NSFW media so the phone blurs it per your setting. **On by default for relay installs** (installing the relay is itself the opt-in); reversible from the "Agent context" toggle in the Relay dashboard, or `RELAY_AGENT_CONTEXT_ENABLED=0`. The exact injected instruction is visible in the chat "What the agent sees" sheet under "Relay context (server-side)". No on-device or relay-side classifier — sensitivity stays model-emitted. Vanilla upstream (no plugin) is unaffected. See `docs/plans/2026-06-20-relay-enhancement-layer.md`.
|
||||
- **Transport path is visible (chat).** The chat status strip now shows which streaming path is actually in use — ⚡ Gateway (live thinking), 📡 Sessions, Completions, or Runs — instead of a generic "api online", and Chat Settings adds a basic→best tier ladder explaining the active path and its fallback.
|
||||
- **Injected-context audit (chat).** Tap the context-usage meter in chat to open a "What the agent sees" sheet showing the exact extra context prepended to your next turn — persona/profile, phone status, and any per-turn (voice) hint. On the gateway path it notes the persona is applied server-side, so the audit is honest about what the phone does and doesn't send.
|
||||
- **Spoken-turn badges (chat).** Voice-mode replies now carry a "Voice" chip and realtime replies a "Realtime Agent" chip — both with a speaker glyph — so spoken turns are distinguishable from typed ones in the scrollback.
|
||||
- **App themes.** A new theme picker in Settings → Appearance ships eight looks: the signature Hermes Relay brand (with full light/dark) plus ports of the Nous Hermes baselines — Hermes Teal, Nous Blue (light), Midnight, Ember, Mono, Cyberpunk, and Rosé. The whole app — brand chrome, accents, and chat background — follows the chosen theme. Light/Dark/Auto applies to themes that ship both modes; fixed-mode themes show their own complete look.
|
||||
- **Hot-swappable agent sphere.** The orb is now a pluggable "skin": an Adaptive skin that recolors to match your theme, built-in Classic / Aurora / Solar / Mono looks, and support for **user-authored skins** loaded from a small JSON spec. Each skin declares which live signals it reacts to (voice, tool bursts, activity), shown as capability badges in the picker. See `docs/sphere-spec.md`.
|
||||
- **Connections separate features from routes (Android).** Connection settings now distinguish what a connection can *do* (a **Features** section) from how this phone *reaches* Hermes (a **Route** section), so you can enable Relay features over whichever transport you prefer. A plugin-provided **Secure proxy** route is surfaced alongside LAN, Tailscale, public, and custom routes. The standard direct-to-upstream path is unchanged and still needs no plugin. See `docs/plans/2026-06-18-native-secure-routes.md`.
|
||||
- **Enhanced voice control (Gemini & xAI).** When the relay uses a Gemini or xAI voice provider, Voice Settings can now steer it: pick a Gemini voice and model and turn on expressive tone tags (with optional natural-language voice direction), or set an xAI voice with expressive speech tags. Expressive tags also apply to xAI on the streaming voice-output renderer. Standard (no-plugin) voice stays configured server-side.
|
||||
- **Voice render-path visibility.** Voice Settings shows which path is rendering speech (streaming vs. basic), and Diagnostics records it each session, making voice issues easier to troubleshoot.
|
||||
- **Agent pets — a living, swappable avatar.** The orb can be replaced with an animated "pet" that reacts to what the agent is doing: idle / thinking / writing / speaking / listening states, a distinct **working** pose during tool calls, one-shot **greet** / **celebrate** reactions, and a loop that quickens as output streams. Add or remove pets right in Settings → Appearance (no `adb` needed), with a live state preview, a playback-speed slider, and optional frame auto-stabilization; capability badges (Voice · Tools · Activity) show honestly what each pet actually reacts to. Pets are pure data — an AI authoring kit and a JSON schema let you generate one from sprite art. See `docs/pet-spec.md` and the custom-avatars guide.
|
||||
- **Per-profile agent icon + single-image avatars.** Each agent profile can wear its own small icon beside its name (client-side, never sent to Hermes), shown in chat, the agent sheet, the top bar, and Settings. Importing an avatar now also accepts a single image (auto-wrapped as a one-frame pet) — no animated pack required.
|
||||
- **In-app crash reporting.** If the app ever force-closes, the next launch shows a clean dialog with the stack trace — **Copy** it, or **Report** to open a pre-filled GitHub issue from the bug template. The report persists until you acknowledge it, and the handler re-raises so the OS still records the crash in Play vitals.
|
||||
- **Clean text-flow mode (chat).** A distraction-free chat layout where your sent text slides up into a continuous flow, paired with the swappable-avatar/pet system.
|
||||
- **Permissions review screen.** A central page makes the permission model explicit — standard Chat and Manage need no phone-control permissions, while voice, camera, notifications, and sideload Device Control stay opt-in — reading the same live grants Bridge does.
|
||||
- **In-app attachment previews + richer capture.** Attachments preview inline before sending, sensitive media is blurred per your setting, and the capture flow is richer.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Much faster cold start.** The app was building several hardware-keystore-encrypted stores at launch, which serialize on a process-global lock and stalled the chat header (model, personality, approvals) for seconds. It now builds a single keyset and the dashboard cookies share it, cutting measured time-to-connected from ~2.9 s to ~1 s after first frame, with the keystore lock contention gone. Existing sign-ins are migrated automatically on first launch.
|
||||
- **Honest loading, never stale, never hidden.** Model, personality, and approvals now show a brief "checking…" state and fade in once the server confirms them, instead of popping in or showing a possibly-wrong value. Standard upstream controls (Model, YOLO, Fast, reasoning effort) are no longer hidden while loading or when unavailable — they always appear: a live control when ready, "checking…" while a value loads, or a cleanly disabled control with the reason (e.g. "available over the gateway transport") when this connection can't use them. The chat composer's reasoning-effort chip now shows alongside the model chip instead of lagging seconds behind the gateway check, and picker lists (models, personalities) show a brief, bounded "loading…" cue. The same fade-in is applied to the context meter, session drawer, and Manage panels.
|
||||
- **Tidier chat header.** The LAN/Tailscale chip was dropped from the top bar (the bottom status strip already shows the route, and is now tappable to open Connections), and a `none` personality is no longer shown — leaving more room for the model name.
|
||||
- **Connection toast reads like the cold-start screen.** The floating connection status toast now shows a live checklist — Route / API / Relay each with a spinner, ✓, or ✕ as the checks land — instead of flat text, matching the splash screen's stepper. Swiping it up now tracks your finger (slide + fade) rather than snapping, and connection problems get an explicit "Open Connections →" link at the bottom so the path to the detailed view is obvious.
|
||||
- **Tidier chat header.** The "approvals off" warning moved out of the agent subtitle into a single amber ⚡ icon in the top bar (tap for the full explanation in the agent sheet), and Share folded into a ⋮ overflow menu — so the personality · model subtitle no longer gets clipped by the trailing action icons.
|
||||
- **Voice replies are formatted for listening.** In voice mode the assistant is now guided to answer in short, conversational sentences without markdown, emoji, or raw URLs — without changing what is stored in chat history.
|
||||
- **Leaner terminal screen (Android).** The extra-keys bar scrolls horizontally with compact, fully-legible keys (no more clipped "CTRL"), the header is a single compact row showing one inline connection-status dot plus state, and the tab strip is hidden for single-tab sessions — the new-tab "+" moves into the header — reclaiming vertical space for the terminal.
|
||||
- **Relay terminals run on an isolated, TUI-tuned tmux.** Sessions now use a dedicated tmux server/socket with its own config — instant ESC (`escape-time 0`), truecolor `$TERM`, mouse and focus events on, and no status bar — so editors and full-screen tools behave correctly, without touching the user's personal tmux.
|
||||
- **"Standard" is now "Vanilla Hermes" throughout.** The user-facing name for the no-plugin upstream path is now **Vanilla Hermes**, so it's clear the default path runs on a plain Hermes agent.
|
||||
- **QR pairing degrades gracefully on unusual cameras.** On foldables and devices where the camera can't initialize, the scanner now shows a "camera unavailable — pair manually" card instead of force-closing.
|
||||
- **Image & attachment viewers rotate to landscape.** The full-screen image / attachment viewers can rotate to landscape even though the rest of the app stays portrait-locked.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Clearer error when a feature needs a newer relay.** Toggling a setting an older relay plugin doesn't recognize (e.g. xAI expressive speech tags) now shows "Relay update needed" instead of a generic HTTP 400 with a dead Retry button. Genuine input errors are unaffected.
|
||||
- **Connection status toast is no longer see-through.** The floating connection-lost/switching toast renders fully opaque so content behind it no longer bleeds through and hurts legibility.
|
||||
- **Provenance badges survive the post-turn history reload.** "Voice", "Realtime Agent", "Stopped", and "Error" chips are now preserved when the conversation reloads after a turn, instead of silently vanishing.
|
||||
- **Chat and Manage no longer stay dark in Light mode.** Brand-styled surfaces bypassed the theme and were effectively hardcoded dark; they now follow the selected theme and light/dark mode, and the glow/border flourishes key off the active theme rather than the system setting.
|
||||
- **Realtime voice no longer drops the conversation mid-session with some providers.** A normal end-of-turn signal was being rejected on certain voice providers, ending the session every turn.
|
||||
- **Relay voice synthesis no longer leaves temporary audio files behind** on the server.
|
||||
- **Clearer voice errors and an oversize-recording guard.** Standard voice now rejects an over-long recording before uploading it and shows a helpful message for audio the server can't read, instead of a generic HTTP error.
|
||||
- **Terminal paste no longer auto-runs multi-line text.** The key-bar PASTE now uses bracketed paste, so multi-line content lands intact in shells and editors instead of executing line by line.
|
||||
- **Terminal on-screen arrows behave inside TUIs.** Arrow/Home/End keys follow the running app's cursor-key mode (application vs. normal), so they work correctly in vim, less, and fzf.
|
||||
- **Terminal footer spacing.** A small gap now keeps the last terminal row clear of the key bar (it could previously look like the footer overlapped it), and a redundant navigation-bar inset that left empty space below the keys was removed.
|
||||
- **In-chat model picker now actually applies on a new chat.** Picking a model and provider in the chat composer (e.g. Grok 4.3 via your xAI subscription) is bound to the new conversation, so the agent runs on the picked model instead of silently falling back to the account's global default. Switching profiles retires an explicit pick so the profile's own model takes over, and the picker label updates immediately instead of lagging a round-trip.
|
||||
- **Server-generated images render in chat when paired to the relay.** An assistant image that points at a server-side file path is now fetched through the relay's media route and shown inline (tap to zoom), instead of degrading to an "image is on the server" notice. On the SSE chat path the agent is also told it can surface images and files by path when a relay route is configured (visible in the chat "What the agent sees" sheet). Standard (no-plugin) connections are unchanged.
|
||||
- **Smoother profile switching.** Switching profiles no longer blanks the conversation to an empty/"Loading…" state before the new history loads; the previous transcript is held and cross-fades to the new one.
|
||||
- **In-chat model switch now applies mid-conversation, not just on new chats.** Picking a model in an already-started chat switches the live session in place — the same path the desktop/TUI `/model` uses — instead of racing into a global-default write, so the turn runs the model you picked.
|
||||
- **Server-side turn errors always surface.** A failed turn (e.g. a provider rejecting the request) now stays on screen as an error bubble with the message, instead of appearing for a moment and then vanishing when the conversation reconciled after the turn.
|
||||
- **The model shown in chat matches the live session.** The chat header and the agent detail sheet now show the model the current session is actually running (reflecting a mid-session switch) rather than the profile/global default, and the agent sheet no longer pairs the global default model name with the session's provider — it now also names the host's "Server default" when the session runs something different.
|
||||
- **Server steering markers no longer appear as chat bubbles.** The "[System: the active model/personality changed]" notes the server injects into history for the agent's benefit are hidden from the transcript by default (matching the desktop/TUI); a new "Show system messages" debug toggle in Chat Settings can reveal them.
|
||||
- **Per-reply token counts (and other per-message details) survive the post-turn reload.** The input/output token subtext, provenance badges, tapped-card state, and voice/realtime sync traces are now preserved when the conversation reconciles against the server after a turn — previously a normal reply lost its token line once the turn finished (the error bubble kept it only because errored turns skip that reload). The reloader now preserves client-only message details by default instead of dropping any it doesn't re-derive from the server.
|
||||
- **PDF viewer no longer crashes when the document closes mid-render.** A PDF preview that was torn down during a layout pass could read a closed renderer and throw `IllegalStateException: Document already closed`; the renderer is now guarded so it returns nothing instead of crashing.
|
||||
- **No crash opening a chat with a server-local image.** Rendering a relay-fetched image could throw `ClassCastException: kotlin.Result cannot be cast to byte[]` because a `suspend` function returned `kotlin.Result` (which collides with the coroutine machinery's own wrapper); a purpose-built result type fixes it.
|
||||
- **Side-loaded avatars and sphere skins are reachable again.** Both loaders read internal storage while the docs (correctly) pointed `adb push` at external app-scoped storage, so a side-loaded pet or skin never appeared. Both now resolve through one external-preferred location, so the documented install path works.
|
||||
- **Reopened chats paint the session's real model** (not the profile/global default), the model-picker "Server default" caption shows the true default rather than the active override, and a chat's media badge shows only when paired — with the underlying server-image fetch-failure reason surfaced when a fetch fails.
|
||||
|
||||
## [1.1.0] - 2026-06-16
|
||||
|
||||
### Added
|
||||
|
||||
- **Automated Play Console upload on release.** When a `PLAY_SERVICE_ACCOUNT_JSON` secret is configured, pushing a stable `android-v*` tag uploads the `googlePlay` App Bundle to the Production track as a draft (a human still starts the rollout). Prereleases are skipped, and the `sideload` flavor is structurally blocked from ever publishing to Play. Without the secret, the release builds publish to GitHub Releases exactly as before.
|
||||
- **Desktop UI preview harness (`:ui-preview`).** A non-shipped Compose for Desktop module renders presentational composables in a window on the PC with Compose Hot Reload, for fast UI iteration without a device build/install loop. It reuses the shared sphere algorithm as its single source of truth.
|
||||
- **Plugin: guided env-key setup.** The relay plugin declares its optional voice-provider keys (`XAI_API_KEY`, `OPENAI_API_KEY`, `ELEVENLABS_API_KEY`) in its manifest, so `hermes plugins install` prompts for them (masked, with a "get yours" link) instead of hand-editing `.env`. The standard no-plugin path needs none.
|
||||
- **Plugin: native install path.** Tools-only setups can install via `hermes plugins install Codename-11/hermes-relay/plugin`; the full relay still uses the curl `install.sh`.
|
||||
- **`/relay` slash commands.** `relay status · devices · pair` usable mid-conversation from any platform (CLI / Discord / TUI).
|
||||
- **Dashboard relay-status widget.** A `Relay · connected / offline / unpaired` badge in the dashboard header, visible on every page.
|
||||
- **Session-start relay health check.** A minimal, fully-guarded `on_session_start` hook records relay reachability without slowing the gateway.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release names normalized by surface.** Future GitHub Releases are named `Hermes-Relay-Android`, `Hermes-Relay-Plugin`, and `Hermes-Relay-CLI`, with future tags on `android-v*`, `plugin-v*`, and `cli-v*`. The CLI installer and updater still understand historical `desktop-v*` prereleases during the migration.
|
||||
- **Per-surface release notes.** Plugin and CLI GitHub Releases now use hand-written `PLUGIN_RELEASE_NOTES.md` / `CLI_RELEASE_NOTES.md` files (Summary + Added/Changed/Fixed + Install/Verify) — the same format as Android's `RELEASE_NOTES.md` — instead of static boilerplate baked into the workflow. The release workflows substitute the version into the install commands automatically.
|
||||
- **Settings screen overhaul (Android).** Status pills are now exception-only — they appear only when a surface needs attention and stay quiet when healthy. The Power tools section shows a single state-aware **Plugin active / required / offline** badge instead of an identical "Relay paired" chip on every card. Connections moved to the top (above the Hermes section), Diagnostics + Developer options moved into the App section, the status chips were restyled to match the app's translucent-bordered language, and the brand blue was deepened.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Force-close on connect when the stored credential keyset was corrupt.** A corrupt encrypted token store (which can happen after an app upgrade or device restore) threw during construction and crashed the app right after a successful pair, on both standard and relay connections. The token store now heals a corrupt keyset on the spot, and credential storage degrades to a re-pair instead of crashing if the device keystore is unusable.
|
||||
- **Dashboard plugin: unreadable button labels.** Solid buttons in the relay dashboard panel inherited the container text colour, which matched their background. Solid button variants now keep their proper contrast colour.
|
||||
- **Installer failed on uv-managed Hermes hosts.** `install.sh` assumed `pip` lived in the hermes-agent virtualenv, but environments created by `uv` (the upstream default) ship no `pip` module, so the editable install aborted at step 2. The installer now bootstraps `pip` via `ensurepip`, or falls back to `uv pip`, so the plugin installs cleanly on uv-managed cores.
|
||||
- **Chat settings (Android).** The streaming-endpoint picker no longer wraps "Gateway"/"Sessions" onto a second line, and the system-prompt preview now reflects the enabled context toggles (foreground app, battery, safety rails) with representative placeholder values instead of looking inert.
|
||||
- **Dashboard plugin: buttons rendered as blank boxes.** The host dashboard's Nous design-system `Button`/`Badge` use boolean variant flags (`outlined`/`ghost`/`invert`) and a `tone` prop — not the shadcn-style `variant` prop the plugin passed — so every button collapsed to a solid near-white fill with an invisible label. The plugin now translates its props to the design-system contract via an adapter, and drops a label-hiding CSS reset.
|
||||
|
||||
## [1.0.0] - 2026-06-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Relay plugin diagnostics and install guidance.** `hermes relay doctor` now reports standard upstream API/dashboard reachability, Relay loopback state, dashboard plugin presence, plugin-manager layout, and whether the legacy bootstrap monkeypatch is installed. The plugin manifest now advertises its Android and desktop tools, and `after-install.md` gives the upstream plugin manager a first-run handoff.
|
||||
|
||||
- **Plugin-owned compatibility hook lifecycle.** `hermes relay compat status/install/remove` now owns the optional `hermes_relay_bootstrap.pth` startup hook, so the monkeypatch can be inspected, added, or removed without rerunning the legacy installer. The standard v1.0.0 path does not require this hook.
|
||||
|
||||
- **Legacy cleanup alignment.** The legacy installer now installs the optional `.pth` hook through the plugin compat lifecycle, and the uninstaller removes every shell shim it creates (`hermes-pair`, `hermes-status`, `hermes-relay`, `hermes-relay-update`, `hermes-relay-tailscale`) while delegating hook cleanup to `hermes relay compat remove` when available.
|
||||
|
||||
- **Gateway chat transport with live thinking.** Chat can ride the upstream dashboard `/api/ws` (the `tui_gateway` surface the official hermes-desktop client speaks) — the only vanilla-upstream path that streams reasoning *live*, so the Thinking block and sphere light up during generation. "Auto" prefers it when the dashboard is reachable and Manage is signed in, and falls back to the SSE endpoints per turn.
|
||||
|
||||
- **Gateway desktop parity.** Native image/PDF/file attachments (with an in-chat notice when a turn falls back to a transport that can't carry files), mid-turn **steering**, **edit & resend**, interactive **approval / clarify / sudo / secret** cards, live **subagent lanes**, a **context-window meter**, server **slash commands** in autocomplete, and **turn-complete notifications** when the app is backgrounded.
|
||||
|
||||
- **Gateway warm-start + Keep connected in background.** Pre-warming the gateway on foreground moves the cold session-setup cost off the send path. An opt-in foreground-service toggle (both flavors; `specialUse`, off by default) holds the socket open in the background so a long-backgrounded conversation resumes instantly.
|
||||
|
||||
- **Switch agent profiles from chat.** Pick a different agent — model, SOUL, personality, and skills — per conversation. The selection is **ephemeral** (bound to the session like the official desktop; it never changes the server's default agent for other clients). The session drawer scopes to the active profile and loads that profile's history, and the right agent is restored on cold start. The Manage tab's server-wide **Activate Profile** action now confirms first.
|
||||
|
||||
- **Manage parity with the desktop dashboard.** Change models from the full provider catalog, manage provider keys (write-only, masked, reveal), create/edit profiles and SOUL.md, and browse/install/update skills. Manage data is cached to disk for an instant cold launch.
|
||||
|
||||
- **Open & save chat images and attachments.** Tap an image for a full-screen viewer (pinch-zoom, double-tap, Share/Save); non-image attachments gain an Open/Share/Save menu. Saves land in `Pictures`/`Download/Hermes-Relay` with no permission on Android 10+, preserving the original bytes.
|
||||
|
||||
- **Persistent Realtime Agent voice + background runs (ADR 33).** The realtime engine keeps one session across turns (follow-ups retain context); a long Hermes run is promoted to a tracked background task and spoken when ready, so the conversation stays responsive.
|
||||
|
||||
- **Redesigned chat input bar.** A Telegram-clean pill field with one trailing button that morphs between Send / Voice / Stop / Steer / Queue; the slash button is gone (typing `/` still opens autocomplete).
|
||||
|
||||
- **Routes card reachability verdicts** ("Reachable", or the specific failure reason) and per-turn **latency tracing** (`TurnLatency`, durations only) for diagnosing transport speed.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Relay plugin/server version aligned to v1.0.0.** The Python package, plugin manifest, dashboard manifest, and relay runtime now use the same `1.0.0` line as the stable Android release so a retagged source checkout describes one product version.
|
||||
|
||||
- **The standard (no-plugin) path is first-class.** Chat, Manage, and voice all work against an unmodified upstream Hermes agent; standard voice rides the dashboard audio surface (`/api/audio/*`) with the Manage sign-in, and relay-paired voice is the profile-aware fallback. The relay plugin is now purely additive.
|
||||
|
||||
- **Seamless connection UX.** LAN↔Tailscale handoffs and reconnects no longer reload the chat; connection and update status are now in-theme slide-down toasts over the content instead of banners that pushed the UI around.
|
||||
|
||||
- **Editable, roaming routes.** Add/edit/remove routes in Settings → Connections; bare-host URLs default their scheme and port (and preview what will be saved); remote-access (Tailscale) is surfaced in the main setup flow with a "Remote" readiness line.
|
||||
|
||||
- **Faster Manage.** A shared auth preamble plus concurrent payloads cut a full load from ~40 round trips to ~12; a process-lifetime cache and startup pre-warm render the last-seen data instantly, and Manage now names which dashboard URL it's talking to.
|
||||
|
||||
- **Faster, calmer cold start.** Key-less connections skip the multi-second keystore decrypt; the startup sphere is now the actual loading screen with narrated check lines, and the OS splash blends into it.
|
||||
|
||||
- **Docs + branding.** The docs site was rechromed to the app theme and repositioned around the two-path story; the README and Play listing were refreshed standard-first; product-name copy normalized to **Hermes-Relay**.
|
||||
|
||||
- **Quality-of-life.** Quote-in-reply, share-conversation-as-Markdown, ambient mode as a long-press gesture, a floating status pill, decluttered Manage cards, back buttons on pushed screens, and a softer active-connection card.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **No "Connect to Hermes" flash on cold start.** The empty-state now distinguishes "still hydrating from disk" from "nothing configured" (`ConnectionStore.isHydrated` → `chatConnectState`), showing a quiet "Connecting to Hermes…" spinner until ready and the connect CTA only once hydration confirms no connection exists.
|
||||
|
||||
- **In-app What's New renders cleanly** — parsed into a version subtitle, bold section headers, and real bullets instead of raw text with literal `*`.
|
||||
|
||||
- **App-start UI freeze from Keystore lock contention.** The encrypted cookie store built its StrongBox-backed prefs eagerly in its constructor (1–4 s under a process-global lock) from several code paths at once; it now builds lazily on an I/O thread and is shared per connection.
|
||||
|
||||
- **Standard connections now follow LAN↔Tailscale changes**, standard voice follows the resolved route (not the persisted URL), and a stale probe cache can no longer pin a dead route after a handoff or resume.
|
||||
|
||||
- **Editing a URL no longer wipes fallback routes** (edits merge with stored extras instead of rebuilding from the edited URL alone); **"Re-check" / "Use now" no longer fail silently** (the probe always publishes its outcome and per-route failure reasons); and a network change can no longer resurrect a deliberately disconnected relay socket.
|
||||
|
||||
## [0.8.1] - 2026-05-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Voice mode crash with barge-in on legacy TTS playback.** When barge-in was enabled and the relay served audio over the legacy `/voice/synthesize` (Media3) path, the first agent sentence played for ~2 syllables and then the app crashed with `IllegalStateException: Player is accessed on the wrong thread`. The barge-in listener's `Dispatchers.IO` reader was reading `ExoPlayer.getAudioSessionId()` (a thread-confined accessor) to attach the echo canceller. `VoicePlayer.audioSessionId` now serves a `@Volatile` cache populated from main-thread Media3 callbacks, so it is safe to read from any thread.
|
||||
|
||||
## [0.8.0] - 2026-05-23
|
||||
|
||||
### Added
|
||||
|
||||
- **Provider-native Realtime Agent voice.** Android can opt into a Realtime Agent voice engine where Android streams mic PCM to the relay, xAI or OpenAI owns realtime speech recognition and speech generation, and Hermes remains the governed authority for tools, memory, profiles, confirmations, current-data checks, side effects, and durable transcript context.
|
||||
|
||||
- **Hermes-brokered realtime tool timeline.** Realtime Agent turns now mirror transcript, assistant speech, Hermes task state, concise tool-status rows, confirmation state, path badges, and compact result provenance into chat/voice UI without dumping raw tool output aloud.
|
||||
|
||||
- **Connection diagnostics and activity logs.** Settings now includes a Diagnostics surface with sanitized recent API, relay, session, endpoint, and voice activity. API / Relay / Session detail drawers also tail the relevant recent activity so hung or unreachable relays are visible without ADB first.
|
||||
|
||||
- **Realtime and Voice Settings active-engine layout.** Voice Settings now separates **Voice Engine** from global voice controls, shows only the selected engine's provider card, keeps fallback TTS visible as a global safety-net card, and provides **Test Current Engine**: stable voice plays the saved Voice Output sample, while Realtime Agent opens a provider-native `/voice/realtime-agent/*` test session and plays streamed realtime audio.
|
||||
|
||||
- **Voice Lab text and mic demos.** The realtime voice test screen now offers two clearly separated demos: a **Text demo** that plays raw provider TTS, and a **Mic demo** that exercises the full agent path — real speech recognition, Hermes brokering, and a spoken reply — with tap-to-record / tap-to-stop capture. A `scripts/realtime-voice-lab-smoke.ps1` smoke script accompanies the lab.
|
||||
|
||||
- **Realtime playback diagnostics.** Playback now records a time-to-first-audio metric, logs requested-vs-actual AudioTrack buffer sizes, runs a first-frame watchdog, and cross-checks playback drain drift so cold-start and underrun regressions surface in the Diagnostics log instead of as silent dead air.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Google Play Bridge Core split.** The Google Play Android track keeps relay pairing, chat, profiles, voice, terminal/TUI, media, notification companion, relay sessions, diagnostics, and status while removing AccessibilityService-backed Device Control declarations and permissions. Sideload remains the track for screen reading, gestures, screenshots, SMS/calls, contacts/location, overlays, wake locks, and unattended control.
|
||||
|
||||
- **Release lanes now use explicit product tags and names.** Future Android releases use `android-v*`, plugin/Python releases use `server-v*`, and CLI releases continue on `desktop-v*`. GitHub Release names now publish as `Hermes-Relay-Android vX.Y.Z`, `Hermes-Relay-Plugin vX.Y.Z`, and `Hermes-Relay-CLI vX.Y.Z`; the old relay-named server scripts remain compatibility shims.
|
||||
|
||||
- **Realtime voice instructions are provider-neutral.** Realtime providers receive active interface context, local date/time, provider/model/voice/profile metadata, and guidance to ask Hermes for current facts, research, device/desktop state, project context, precise/versioned data, and any requested checks instead of guessing from model knowledge.
|
||||
|
||||
- **Play/user docs now match the actual artifact.** Release-track docs, feature matrix, getting-started copy, privacy/security references, and Play listing copy now say Google Play has no AccessibilityService, screen reading, gestures, screenshots, or phone-control utility permissions.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Silent / choppy first-turn realtime voice playback.** The AudioTrack deep-buffer cold-start was parking the playback head at zero so the first turn dropped or stuttered. The streaming buffer was shrunk from 4000ms to 700ms, the low-latency prebuffer threshold retuned, and a preroll force-start removed, giving reliable low-latency playback from the first frame. Confirmed on-device.
|
||||
|
||||
- **Voice Lab waveform now tracks the playback cursor.** The waveform is driven by `RealtimePcmPlayer.playbackAmplitude()` at the playback position instead of socket-arrival time, so the visual matches what is actually being heard.
|
||||
|
||||
- **Realtime Hermes calls no longer depend on the phone's saved Hermes API key.** Provider-native Hermes tool calls are brokered by the relay with its server-side Hermes credential, so a phone can be paired for realtime voice without exposing or misusing its saved API bearer.
|
||||
|
||||
- **Hung relay voice turns fail visibly.** Voice turns run relay health preflight and shorter realtime/session timeouts so Settings and Voice mode surface unreachable relay state instead of sitting indefinitely on Thinking.
|
||||
|
||||
- **OpenAI realtime is no longer treated as render-after-Hermes fallback.** `openai_realtime` is registered as a native Realtime Agent provider path alongside xAI, with provider-native audio events normalized through the same broker contract.
|
||||
|
||||
- **Local release signing no longer falls back to debug when `local.properties` uses a repo-root relative keystore path.** The Android Gradle signing config now resolves relative keystore paths from the repo root, matching the documented `release.keystore` setup.
|
||||
|
||||
## [0.7.0] - 2026-05-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Profile-aware Hermes sessions and voice settings.** Android now treats Hermes profiles as first-class connection state: profile selection resolves against the active server, profile-specific chat sessions are persisted separately, default/Victor display is normalized, and per-profile voice provider/model/voice settings can be read and saved through server-owned endpoints without depending on Hermes config mutations.
|
||||
|
||||
- **Realtime voice playground and provider lab.** The relay now includes standalone OpenAI/xAI/ElevenLabs-oriented voice lab tooling, provider adapters, provider option discovery routes, realtime playground routes, and generated WAV/JSONL artifact ignores for iterative voice quality testing outside production Hermes routes.
|
||||
|
||||
- **Streaming voice output routes.** server-owned `/voice/output/*`, realtime playground, profile voice config, and provider option endpoints support provider-neutral TTS rendering, dynamic voice/model option surfaces, and profile-scoped voice configuration for Android.
|
||||
|
||||
- **Experimental Android realtime voice overlay.** Android adds a richer voice overlay with tap-to-talk, continuous mode controls, optional system overlay mode, compact mode, realtime waveform visualization, playback controls, and an experimental badge around barge-in instead of treating all voice as experimental.
|
||||
|
||||
- **Experimental realtime Hermes voice-agent plan.** `docs/plans/2026-05-19-realtime-hermes-voice-agent.md` records the next architecture step: provider-native realtime speech with Hermes-brokered profiles, sessions, tools, confirmations, and transcript mirroring. The stable Hermes chat + voice-output path remains the default.
|
||||
|
||||
- **Desktop tray pairing and consent flow.** The desktop surface gained Tauri tray pairing, QR/consent affordances, sidecar preparation, and computer-action approval polish so desktop and Android pairing flows are closer to parity.
|
||||
|
||||
- **Shared relay/Quest scaffolding.** Experimental `relay-core`, `relay-ui`, and Quest prototype modules were added for shared pairing, terminal, transport, voice, and morphing-sphere work without changing the Android phone app's default route.
|
||||
|
||||
- **Desktop Chat tab with first-run route setup.** The Tauri tray dashboard now has a Chat tab inspired by the Hermes Desktop chat-first flow. It streams through the saved paired relay when `~/.hermes/remote-sessions.json` has an active session, or through a direct Hermes gateway/API URL when relay pairing is not available. The tab supports stop, retry, new chat, clear, current-session transcript history, and a setup panel that offers relay pairing or direct WebAPI configuration without saving the optional API key.
|
||||
|
||||
- **First-class desktop TUI tab.** The Tauri tray dashboard now gives the embedded xterm/PTY Hermes session its own sidebar tab instead of nesting it under Terminal / CLI. Terminal remains the external launcher, shim-state, and copyable-command surface, while plugin embeds route into the same TUI tab.
|
||||
|
||||
- **Desktop surface plugins.** The desktop CLI and Tauri tray now register built-in terminal surface plugins, starting with Herm (`herm-tui`) from `liftaris/herm`. Users can inspect plugin status, install or update Herm, launch a fresh dashboard, resume with `herm -c`, or embed the plugin in the tray's xterm/PTY surface with `bunx`/`npx` fallback when the `herm` binary is not installed.
|
||||
|
||||
- **Relay server release track.** Relay server and Python package releases now use `relay-v*` tags, validate relay-owned version metadata, build wheel/sdist artifacts, generate checksums, and publish through `.github/workflows/release-relay.yml`. This lets Relay server fixes ship independently from Android app `versionCode` bumps and desktop CLI alphas.
|
||||
|
||||
- **Dashboard plugin CI.** `.github/workflows/ci-dashboard.yml` builds the dashboard plugin, runs the dashboard API tests, and verifies the plugin-owned QR modal CSS markers are present in the built bundle.
|
||||
|
||||
- **Upstream integration sync reference.** `docs/upstream-integration-sync.md` now tracks which Hermes-Relay surfaces use upstream-supported extension points, which pieces are server-owned compatibility layers, and what has to be checked before changing relay, Android, desktop, dashboard, bootstrap, or user-doc surfaces.
|
||||
|
||||
- **Relay version sync verifier.** `scripts/check-relay-version-sync.py` validates the relay package version against plugin metadata and dashboard metadata so release and dashboard surfaces cannot silently drift.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Stable voice is now the main Android voice path.** Voice mode defaults to Hermes chat streaming plus relay-managed voice output, with realtime-provider work kept as a standalone lab/testbench and future experimental mode instead of replacing Hermes session/tool authority.
|
||||
|
||||
- **Realtime voice output uses balanced coalescing.** Normal assistant speech is batched into more natural chunks while tool/status speech stays immediate, reducing provider render resets and tone/volume variation during voice replies.
|
||||
|
||||
- **Voice settings are profile-scoped and option-aware.** Android can fetch provider/model/voice options from relay endpoints, show profile context in voice settings, save voice choices per Hermes profile, and expose advanced manual entry when provider metadata is incomplete.
|
||||
|
||||
- **Voice UI state is synchronized with chat state.** Voice mode now reuses more of the chat session/profile state, preserves live transcript and tool timeline visibility, and improves overlay exit/minimize behavior for hands-free use.
|
||||
|
||||
- **Release versioning is split by surface.** Android app releases remain on `v*` and use `gradle/libs.versions.toml`; Relay releases use `relay-v*` and keep `pyproject.toml`, `plugin/relay/__init__.py`, `plugin/plugin.yaml`, and dashboard plugin metadata in lockstep; desktop remains on `desktop-v*` and `desktop/package.json`. `scripts/bump-version.sh` is now a backward-compatible Android alias, with new explicit `scripts/bump-android-version.sh` and `scripts/bump-relay-version.sh` helpers.
|
||||
|
||||
- **Upstream voice imports are isolated.** Relay voice routes now call upstream Hermes STT/TTS helpers through `plugin.relay.upstream_voice`, keeping private upstream voice helper imports in one adapter module until Hermes exposes a stable HTTP voice API.
|
||||
|
||||
- **CI paths and release actions tightened.** Relay CI now watches Relay-owned paths instead of all `plugin/**`, validates Relay version metadata during syntax checks, uses explicit timeouts, and runs the focused route/auth/session test slice instead of broad test discovery. Release workflows now use `softprops/action-gh-release@v3`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Profile switching no longer silently falls back to the wrong local API host.** Profile API URL resolution now handles per-profile Hermes API servers, default/Victor compatibility, and relay-managed profile metadata so selecting a profile does not try to create sessions against `localhost` from the phone.
|
||||
|
||||
- **Non-default profile names remain visible in chat.** Agent display metadata is normalized so selected profile names persist above finalized assistant messages instead of disappearing back to the default label after stream completion.
|
||||
|
||||
- **Voice waveform and playback state are better aligned to real audio.** The output waveform waits for audio playback, handles processing separately, and avoids returning to the microphone too early at the end of an assistant response.
|
||||
|
||||
- **Continuous voice mode no longer starts a session just because auto mode is enabled.** Auto/continuous remains a preference, while explicit voice start/stop controls decide when a voice session is active.
|
||||
|
||||
- **Android voice mode no longer 403s when paired over plain-LAN `ws://` with a Hermes API key saved.** Symptom: tap the mic in Voice mode → red banner *"Voice access expired — extend or re-pair with voice grants"* even though the Connections card shows API Server / Relay / Session all green. Root cause: `RelayVoiceClient` preferred the saved Hermes API key over the paired Relay session token; the relay's `_request_is_secure_enough_for_api_bearer` correctly rejects API-bearer auth on `/voice/*` over plaintext outside loopback/Tailscale, returning a generic 403 that the client flattened to "expired." Fix: invert bearer precedence so paired devices use the session token first (no transport guard — it's the credential the QR/pair handshake already established), with the API key as fallback for chat+voice-only installs that never paired. `describeHttpError` now also reads the server's text/plain response body when present so future 403s show the relay's actual reason instead of a one-size-fits-all string.
|
||||
|
||||
## [0.6.1] - 2026-05-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Android bridge media sharing and MMS handoff.** New `android_share_media` and `android_send_mms` tools expose full file/attachment support through the relay media registry and Android `FileProvider` `content://` grants. Host-local paths are registered with `/media/register`, phones fetch bytes with their paired relay session, and the sideload app opens Android's native share or MMS compose UI after on-device confirmation. Relay HTTP now includes `/share_media` and `/send_mms`, and docs spell out that direct `android_send_sms` remains text-only `{to, body}`.
|
||||
|
||||
- **Relay voice endpoints accept Hermes API bearer tokens.** `/voice/config`, `/voice/transcribe`, and `/voice/synthesize` now accept either a Relay session token with explicit `voice:*` grants or the existing Hermes API bearer token. API bearer validation is voice-only, uses the configured Hermes API server's protected `/v1/models` endpoint with a short positive cache, and rejects non-loopback plaintext by default unless a trusted HTTPS proxy header or the explicit dev escape hatch is configured. Existing Relay sessions are backfilled with voice grants so paired phones do not need to re-pair.
|
||||
|
||||
- **Relay CLI can toggle plain-LAN API-key voice auth without restart.** `hermes relay insecure-api-key status|on|off` calls the running relay's loopback-only `/relay/security` endpoint and flips the runtime `allow_insecure_api_bearer` flag immediately. This keeps HTTPS as the default for API-key voice auth while making Android phone LAN smoke tests possible without exporting env vars or restarting the service.
|
||||
|
||||
- **Desktop CLI alpha.14 — `Ctrl+A ?` chord re-displays the chord-help banner.** The attach-time banner scrolls off as soon as anything writes to the terminal, so users mid-session forgot the verb list and had to detach + re-attach (or guess). New `Ctrl+A ?` (and `Ctrl+A h` synonym) reprints the banner to stderr without leaving the session. Banner text refactored into a single `CHORD_HELP` constant so the attach-time print, the `?` chord, and the unknown-chord hint can't drift out of sync. Unknown-chord hint now also lists `?` as one of the known verbs.
|
||||
|
||||
- **Desktop CLI alpha.13 — `Ctrl+A v` chord in `hermes-relay shell` for in-session paste.** Reported gap: *"...we have to exit hermes-relay shell to run `hermes-relay paste`. Can we leverage a tmux hook?"* Tmux runs on the Linux server with no path back to the Windows clipboard, so server-side hooks can't help — but the existing client-side chord state machine (`Ctrl+A .` detach, `Ctrl+A k` kill, `Ctrl+A Ctrl+A` literal) is the right place. Added `Ctrl+A v`: client reads its own clipboard image (same `captureClipboardImage()` path as the `/paste` REPL command), POSTs to `/clipboard/inbox` via the new shared `stageClipboardImageToInbox(url, token)` helper exported from `commands/paste.ts`, then types `/paste\r` into the PTY so the upstream Hermes TUI consumes it in the same flow the user would have typed by hand. Status line goes to stderr so it doesn't pollute the PTY stream: `[shell] pasted 1920×1080 (245 KB) → /paste`. Reentrancy guard prevents double-stage on a fast double-press. Banner help and chord doc-comment updated to list the new verb.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android bridge tool/route contract drift.** The active plugin import now uses `plugin.tools.android_tool` as the single source of truth, while top-level `plugin/android_tool.py` remains as a compatibility shim. The relay now registers `/return_to_hermes`, matching the documented and phone-side command, and bridge status gating checks `/bridge/status` so tools are hidden unless a phone is actually connected.
|
||||
|
||||
- **Android CI/release gate no longer hangs on the broad Gradle test aggregate.** The Android CI and `v*` release workflows now run the stable sideload pairing/connection regression slice with explicit timeouts while the deferred full JVM test-suite cleanup remains tracked separately.
|
||||
|
||||
- **Android connection/profile state no longer leaks across switches.** Connection switches now clear the outgoing profile object immediately, load the destination connection's saved profile name only after that connection is active, and resolve it against the destination server's current profile list. The default local relay URL is now `ws://localhost:8767`, and auto-managed relay URLs are derived from the active API URL before reconnecting.
|
||||
|
||||
- **Desktop CLI alpha.12 — install scripts truncated the prerelease suffix in the "upgrading X → Y" line.** A user saw `existing install detected: 0.3.0-alpha.9 — upgrading to 0.3.` (literally truncated mid-token). Root cause: `normalize_pinned_version` (bash) and `Get-NormalizedPin` (PowerShell) stripped everything after the first `-`, including `-alpha.N`. Comment claimed this was "for comparison against the bare semver the binary reports" — but since alpha.4, the binary's `--version` reports the FULL semver (via the embedded `gen:version` constant), so the strip is no longer defensive, just lossy. Removed the suffix-strip from both normalizers; both now produce `0.3.0-alpha.11` from `desktop-v0.3.0-alpha.11`. The equality compare at line 138 still works because both sides include the prerelease tail.
|
||||
|
||||
- **Desktop CLI alpha.11 — `hermes-relay update` (and the install one-liners) saw the wrong "latest" release.** On alpha.9, `hermes-relay update --check` expected to see alpha.10 but reported "Up to date." Root cause: GitHub's `/repos/.../releases` API returns rows ordered by the release object's `created_at`, NOT by SemVer of the tag — and `created_at` shifts whenever the row is touched (re-tag, manual edit, asset replacement). When alpha.9's release row got touched after alpha.10 was tagged, the API listed alpha.9 first and all three of our resolvers blindly took `[0]`. Fix: pick the SemVer-max from all desktop-v* tags explicitly. (1) `desktop/src/updater.ts` — `desktop.reduce((max, r) => compareVersions(r.tag_name, max.tag_name) > 0 ? r : max)`. (2) `desktop/scripts/install.sh` — `sort -V | tail -1` (zero new deps; bash + sort is sufficient). (3) `desktop/scripts/install.ps1` — custom `Sort-Object` comparator that packs (Major, Minor, Patch, PrereleaseRank, PrereleaseNum) into a zero-padded sortable string with alpha=1, beta=2, rc=3, stable=999. Live-verified against the real API: all three now return `desktop-v0.3.0-alpha.10` instead of `alpha.9`.
|
||||
|
||||
- **Desktop CLI alpha.10 — `hermes-relay paste` always returned "No image on clipboard" on Windows even when an image was present.** Root cause: the PowerShell invocation in `captureClipboardWindows` (`src/chatAttach.ts`) was missing the `-STA` flag. `powershell.exe -Command` defaults to MTA (Multi-Threaded Apartment), and `[System.Windows.Forms.Clipboard]::GetImage()` only returns a valid image from STA threads — from MTA it silently returns null, indistinguishable from "no image present." Also affects the `chat` REPL's `/paste` command which routes through the same Windows code path. Fix: added `-STA` to the powershell args list (now `['-NoProfile', '-NonInteractive', '-STA', '-Command', ps]`). Live verification: empty clipboard returns null; a cyan 100×80 PNG placed via `[System.Windows.Forms.Clipboard]::SetImage` returns the expected 305-byte capture with correct dimensions. Affects `desktop-v0.3.0-alpha.7` through `desktop-v0.3.0-alpha.9`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Android voice no longer requires Relay pairing when a Hermes API key is saved.** The phone now resolves voice auth from the saved Hermes API key first, then falls back to the paired Relay session for `/voice/config`, `/voice/transcribe`, and `/voice/synthesize`. Chat+voice-only setups can use manual/API-key configuration without the full pairing-code flow; bridge, terminal, media, clipboard, profile writes, and Android-control routes remain paired-session-only.
|
||||
|
||||
- **Relay grant labels are now human-readable in Android and dashboard management UI.** Relay session grant chips still preserve the server keys internally, but user-facing lists now sort the known grant set and render labels such as `Voice STT` / `Voice TTS` instead of raw `voice:stt` / `voice:tts`. Privacy and configuration docs now reflect that Voice mode uses runtime microphone permission and split voice grants.
|
||||
|
||||
- **Desktop CLI alpha.8 — `/screenshot` is multi-monitor aware by default.** The alpha.6/alpha.7 `screenshotHandler` / `captureScreenshot` captured only the primary display on Windows and treated `display` as a number-only param. alpha.8 changes the default to `-1` (all monitors stitched) and accepts string aliases so both the agent tool call and the `/screenshot` slash command can say `'all'` / `'primary'` / `'1'` / `'2'` etc. Windows path uses `System.Windows.Forms.SystemInformation.VirtualScreen` for the union rect (handles negative coordinates when monitors are arranged left-of-primary). macOS path uses `screencapture -D N` for 1-indexed per-display capture. Linux path relies on grim/scrot/import's inherent whole-X-screen behavior. REPL `/screenshot` defaults to all monitors; `/screenshot primary` or `/screenshot 0 | 1 | 2` narrow. Live smoke on a multi-monitor Windows box: all = 1.6 MB stitched, primary = 405 KB — 4× size ratio confirms virtual-screen path. Zero server changes; `image.attach.bytes` RPC consumes whatever bytes the client sends.
|
||||
|
||||
### Added
|
||||
@@ -20,14 +301,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Fixed
|
||||
|
||||
- **desktop CLI binary was a no-op on alpha.3** — installed cleanly, exited 0, produced zero stdout/stderr, wasn't "recognized" as a CLI. Root cause: cli.ts guarded its entry-point invocation with `fileURLToPath(import.meta.url) === process.argv[1]`, which is a valid Node idiom but fails in Bun-compiled binaries because the entry module has a synthetic URL that doesn't match the `.exe` path — the check evaluated false, `main()` was never called, binary exited 0 silently. Replaced with `import.meta.main` (cross-runtime: Bun, Node 20.11+, tsx) which is true in the entry module regardless of compile mode. All four invocation paths stay correct (Bun --compile binary, `bin/hermes-relay.js` shim, `tsx src/cli.ts`, test imports). Caught by adding a local `npm run smoke` target that runs the compiled Windows binary against `--version` / `--help` / `doctor` and verifies each produces output. Same smoke added to `release-desktop.yml` on the Linux target so future regressions of this class are caught pre-publish. Affects `desktop-v0.3.0-alpha.3`; fix ships as `desktop-v0.3.0-alpha.4`.
|
||||
- **desktop CLI binary was a no-op on alpha.3** — installed cleanly, exited 0, produced zero stdout/stderr, wasn't "recognized" as a CLI. Root cause: cli.ts guarded its entry-point invocation with `fileURLToPath(import.meta.url) === process.argv[1]`, which is a valid Node idiom but fails in Bun-compiled binaries because the entry module has a synthetic URL that doesn't match the `.exe` path — the check evaluated false, `main()` was never called, binary exited 0 silently. Replaced with `import.meta.main` (cross-runtime: Bun, Node 20.11+, tsx) which is true in the entry module regardless of compile mode. All four invocation paths stay correct (Bun --compile binary, `bin/hermes-relay.js` shim, `tsx src/cli.ts`, test imports). Caught by adding a local `npm run smoke` target that runs the compiled Windows binary against `--version` / `--help` / `doctor` and verifies each produces output. Same smoke runs in `release-cli.yml` on the Linux target so future regressions of this class are caught pre-publish. Affects `desktop-v0.3.0-alpha.3`; fix ships as `desktop-v0.3.0-alpha.4`.
|
||||
- **`hermes-relay --version` printed `0.0.0` in compiled binaries.** `readVersion()` tried to read `package.json` via `__dirname + '../package.json'`, which doesn't resolve in a Bun `--compile` binary (no real filesystem layout). Replaced with a build-time-generated `src/version.ts` module (`npm run gen:version` writes the version from package.json before every build and every `build:bin:*`). `readVersion()` now just returns the embedded constant. Works identically in tsx / Node / Bun.
|
||||
- **desktop CLI binary segfaulted at startup on Bun 1.3.13 Windows x64** (`panic(main thread): Segmentation fault at address 0x100000D9C`). Root cause identified as Bun's experimental `--bytecode` flag; attempted fix in alpha.2 only edited `desktop/package.json`'s build scripts while the release workflow's inline `bun build` commands silently kept `--bytecode`, so alpha.2 shipped with the same crash. alpha.3 fixes the workflow two ways: (1) dropped `--bytecode` from release-desktop.yml, and (2) refactored the four build steps to delegate to `npm run build:bin:*` so the package.json scripts are the single source of truth for compile flags. Added a `bun --version` diagnostic step to the workflow for future triage. Versions affected: `desktop-v0.3.0-alpha.1` and `desktop-v0.3.0-alpha.2`. Fix ships as `desktop-v0.3.0-alpha.3`.
|
||||
- **desktop CLI binary segfaulted at startup on Bun 1.3.13 Windows x64** (`panic(main thread): Segmentation fault at address 0x100000D9C`). Root cause identified as Bun's experimental `--bytecode` flag; attempted fix in alpha.2 only edited `desktop/package.json`'s build scripts while the release workflow's inline `bun build` commands silently kept `--bytecode`, so alpha.2 shipped with the same crash. alpha.3 fixes the workflow two ways: (1) dropped `--bytecode` from the CLI release workflow, and (2) refactored the four build steps to delegate to `npm run build:bin:*` so the package.json scripts are the single source of truth for compile flags. Added a `bun --version` diagnostic step to the workflow for future triage. Versions affected: `desktop-v0.3.0-alpha.1` and `desktop-v0.3.0-alpha.2`. Fix ships as `desktop-v0.3.0-alpha.3`.
|
||||
- **Installer couldn't find alpha-only releases.** GitHub's `/releases/latest/download/` URL deliberately skips prereleases, so the default `curl | sh` / `irm | iex` one-liner failed against alpha.1 with "maybe no Windows release for this version yet?" Both `install.sh` and `install.ps1` now query the Releases API directly (`GET /repos/.../releases`, filter to `desktop-v*` tags, take first) when `HERMES_RELAY_VERSION=latest`. Pinned versions unchanged.
|
||||
|
||||
### Added
|
||||
|
||||
- **Pre-release hardening: uninstall, doctor, first-run prompts, version-aware install.** Four parallel workstreams that close the "feels like a dev preview" gap before tagging `desktop-v0.3.0-alpha.1`. (1) **Uninstall scripts** — new `desktop/scripts/uninstall.{sh,ps1}` matching install one-liners, 3-tier: default `--binary-only` (removes binary + PATH entry, preserves `~/.hermes/remote-sessions.json`), `--purge` (also wipes the shared session store with a loud cross-surface warning about Ink TUI + Android tooling dependencies), `--service` (stub for when daemon service installers ship — prints canonical systemd/launchd/sc.exe paths without acting). iex-pipe safety: Windows falls back to `HERMES_RELAY_UNINSTALL_{PURGE,SERVICE}` env vars since `$args` drops through `irm | iex`. Shell rc files deliberately untouched (mirrors install.sh philosophy). (2) **`hermes-relay doctor` subcommand** — local-only diagnostic report (225 lines, `src/commands/doctor.ts`); human format uses `!!` prefix for warnings + hint line at bottom, `--json` for support-paste / scripts. Fields: version / binary_path / install_dir / on_path / sessions file + size + count + summaries (no tokens — total omission, not even prefix) / daemon detection (stat of canonical service unit file paths) / platform + node version. Case-insensitive PATH comparison on Windows. (3) **Interactive first-run fallback** — new `src/relayUrlPrompt.ts` (~180 lines) with `promptForRelayUrl()` (readline on stderr, `^wss?:\/\/\S+$` validation, 3 retries) and `resolveFirstRunUrl()` (auto-picks single stored session, numbered picker for multiple, first-run banner for zero). Wired into `connectAndAuth` in `shell.ts` / `chat.ts` / `tools.ts` and `resolvePairTarget` in `pair.ts`, replacing the hard `No relay URL` error. Fresh-install UX: bare `hermes-relay` now prints `Welcome to hermes-relay. No stored sessions yet — let's pair with a relay server.` → URL prompt → pairing code prompt → drops into shell. `--non-interactive` still fails fast. Daemon command deliberately untouched — headless binaries must never prompt; fails closed on missing credentials/consent as before. (4) **Version-aware install** — `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or `installing fresh` (no prior install); post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN rather than failing (pre-release version-name drift is expected). 5s timeout on the version call (where `timeout(1)` available); all diagnostic failures fall through to the "could not read version" path. Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for matching. All structural flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively. Type-check + build green; live smoke: `doctor` both modes, `daemon` fails-closed without credentials, help text includes all new surfaces.
|
||||
- **Pre-release hardening: uninstall, doctor, first-run prompts, version-aware install.** Four parallel workstreams that close the "feels like a dev preview" gap before tagging `desktop-v0.3.0-alpha.1`. (1) **Uninstall scripts** — new `desktop/scripts/uninstall.{sh,ps1}` matching install one-liners, 3-tier: default `--binary-only` (removes binary + PATH entry, preserves `~/.hermes/remote-sessions.json`), `--purge` (also wipes the shared session store with a loud cross-surface warning about Ink TUI + Android tooling dependencies), `--service` (stub for when daemon service installers ship — prints canonical systemd/launchd/sc.exe paths without acting). iex-pipe safety: Windows falls back to `HERMES_RELAY_UNINSTALL_{PURGE,SERVICE}` env vars since `$args` drops through `irm | iex`. Shell rc files deliberately untouched (mirrors install.sh philosophy). (2) **`hermes-relay doctor` subcommand** — local-only diagnostic report (225 lines, `src/commands/doctor.ts`); human format uses `!!` prefix for warnings + hint line at bottom, `--json` for support-paste / scripts. Fields: version / binary_path / install_dir / on_path / sessions file + size + count + summaries (no tokens — total omission, not even prefix) / daemon detection (stat of canonical service unit file paths) / platform + node version. Case-insensitive PATH comparison on Windows. (3) **Interactive first-run fallback** — new `src/relayUrlPrompt.ts` (~180 lines) with `promptForRelayUrl()` (readline on stderr, `^wss?:\/\/\S+$` validation, 3 retries) and `resolveFirstRunUrl()` (auto-picks single stored session, numbered picker for multiple, first-run banner for zero). Wired into `connectAndAuth` in `shell.ts` / `chat.ts` / `tools.ts` and `resolvePairTarget` in `pair.ts`, replacing the hard `No relay URL` error. Fresh-install UX: bare `hermes-relay` now prints `Welcome to hermes-relay. No stored sessions yet — let's pair with a Server.` → URL prompt → pairing code prompt → drops into shell. `--non-interactive` still fails fast. Daemon command deliberately untouched — headless binaries must never prompt; fails closed on missing credentials/consent as before. (4) **Version-aware install** — `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or `installing fresh` (no prior install); post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN rather than failing (pre-release version-name drift is expected). 5s timeout on the version call (where `timeout(1)` available); all diagnostic failures fall through to the "could not read version" path. Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for matching. All structural flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively. Type-check + build green; live smoke: `doctor` both modes, `daemon` fails-closed without credentials, help text includes all new surfaces.
|
||||
|
||||
- **`hermes-relay daemon` — headless WSS + tool router, lifts the "tools only work while a shell is open" ceiling.** New `desktop/src/commands/daemon.ts` subcommand that opens a persistent relay connection and attaches `DesktopToolRouter` without a TTY. The agent can now reach the user's machine any time of day — first step toward "feels-local" parity. Fails closed on missing credentials (no stored session + no `--token` → exits 1) and on missing consent (no `toolsConsented: true` on the stored record → exits 1 unless `--allow-tools` is passed alongside an explicit `--token`); a headless binary must never be the thing that first grants tool access. Inherits `RelayTransport`'s reconnect state machine as-is — exp backoff 1s → 30s (5min on 429), reconnect listeners persistent across close/reconnect cycles because `channelListeners` is a Map on the transport (not wiped on socket close), so the router's `attach()` fires exactly once. Structured logging defaults to JSON-line on stderr (parseable by journald / log shippers / jq), auto-switches to human-readable when stderr is a TTY, or force either with `--log-json` / `--log-human`. Lifecycle events: `starting` → `authed` (includes `server_version`, `transport`) → `ready` (with `advertised_tools` list) → `reconnecting` (attempt + delay_ms) / `reconnected` → `shutdown` on SIGTERM/SIGINT/SIGHUP → `transport_exited` when the transport exhausts reconnects (exits 1 so the service manager restarts fresh). Live smoke against `ws://172.16.24.250:8767`: `starting` → `authed` (server 0.6.0) → `ready` (5 tools advertised) in ~120ms. New BOOLEAN_FLAGS entries: `log-human`, `log-json`, `allow-tools`. Service installers for Windows `sc.exe` / systemd user unit / macOS launchd plist are the obvious follow-up; the daemon binary is runnable standalone today via `hermes-relay daemon --remote <url>`.
|
||||
|
||||
@@ -72,13 +353,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Changed
|
||||
|
||||
- **Top-bar connection chip → inline switcher in the Agent sheet.** The app-wide `ConnectionChip` row that used to sit above every primary tab has been removed. Multi-connection switching now renders as a radio list inside the existing Agent sheet's Connection section (matching the visual pattern of the Profile and Personality sections above it), visible only when ≥2 connections are paired. Tapping a non-active connection fires `switchConnection` + a confirmation toast. Reasons: the chip duplicated the Agent sheet's Connection metadata, ate vertical space above every screen, and exposed the placeholder's `New connection…` label whenever an orphan existed (the root cause of Bailey's double-pair confusion). Dead code removed: the `ConnectionChip` import, the `connectionSheetVisible` state, the `ConnectionSwitcherSheet` render block at the bottom of `RelayApp`, and the `connectionChipVisible` / `activeConnection` vals. `ConnectionSwitcherSheet.kt` itself is kept for future programmatic callers.
|
||||
- **Top-bar connection chip → inline switcher in the Agent sheet.** The app-wide `ConnectionChip` row that used to sit above every primary tab has been removed. Multi-connection switching now renders as a radio list inside the existing Agent sheet's Connection section (matching the visual pattern of the Profile and Personality sections above it), visible only when ≥2 connections are paired. Tapping a non-active connection fires `switchConnection` + a confirmation toast. Reasons: the chip duplicated the Agent sheet's Connection metadata, ate vertical space above every screen, and exposed the placeholder's `New connection…` label whenever an orphan existed (the root cause of the double-pair confusion). Dead code removed: the `ConnectionChip` import, the `connectionSheetVisible` state, the `ConnectionSwitcherSheet` render block at the bottom of `RelayApp`, and the `connectionChipVisible` / `activeConnection` vals. `ConnectionSwitcherSheet.kt` itself is kept for future programmatic callers.
|
||||
|
||||
### Added
|
||||
|
||||
- **Card-dispatch → server session sync** (completes ADR 26). Every [HermesCardDispatch] now carries a `syncedToServer` idempotency flag; on the next chat send, `CardDispatchSyncBuilder` synthesizes unsynced dispatches into OpenAI-format `assistant`+`tool` pairs under a namespaced synthetic tool name `hermes_card_action` and splices them into the request body alongside the existing voice-intent synthetic messages. `ChatHandler.markCardDispatchesSynced` commits the flag after the API client accepts the request — same post-handoff timing as voice intents, so a thrown request-building exception leaves both streams retryable. Guarantees the LLM sees prior card interactions ("you approved the `Run shell command?` card") across server restarts and reconnects, including `open_url` dispatches that never go through `sendMessage`. Unit-tested under `CardDispatchSyncBuilderTest` (pure-function JVM tests, no Android deps).
|
||||
- **Rich cards in chat via `CARD:{json}` inline markers** (ADR 26). Assistant messages can now surface structured Material 3 cards — skill results, approval prompts, link previews, calendar entries, weather — emitted as a single-line `CARD:{...}` alongside prose text. Follows the same streaming-endpoint-agnostic marker recipe as `MEDIA:`, so it works unchanged on `/v1/runs`, `/api/sessions/{id}/chat/stream`, and `/v1/chat/completions`. New `HermesCard` data class (`@Serializable`, `ignoreUnknownKeys=true` so newer agent schemas don't crash older phone builds) carries `title` / `subtitle` / `body` (markdown) / `fields` / `actions` / `footer` / `accent` (`info`/`success`/`warning`/`danger`). Built-in types: `skill_result`, `approval_request`, `link_preview`, `calendar_event`, `weather`; unknown types render via a generic fallback. `approval_request` intentionally mirrors Slack's exec-approval pattern (Allow / Deny with primary/danger button styles) so upstream Phase B adapter parity is a translation exercise, not a data-model rethink. Action dispatch (`send_text` default, `slash_command`, `open_url`) routes through `ChatViewModel.dispatchCardAction`, which stamps a `HermesCardDispatch` on the owning message before forwarding so the card collapses into a "Chose: X" confirmation even if the side effect fails. Renderer is `HermesCardBubble.kt` — accent stripe + Icon + Title/Subtitle + markdown body + fields table + FlowRow of action buttons. Cards render between the assistant's prose and any attachments in `MessageBubble`.
|
||||
- **CI test jobs advisory on `dev`, strict on `main`.** Both `.github/workflows/ci-android.yml` (`test`) and `.github/workflows/ci-relay.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (Bailey's call: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
|
||||
- **CI test jobs advisory on `dev`, strict on `main`.** Both `.github/workflows/ci-android.yml` (`test`) and `.github/workflows/ci-server.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (deliberate: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
|
||||
- **MorphingSphere on the docs site.** New `SphereMark.vue` component (in `user-docs/.vitepress/theme/components/`) renders a 58×34 sphere directly above the "Install in 30 seconds" block — mounted in the `home-hero-after` slot alongside `InstallSection` for a hero → sphere → install stack. Imports `preview/web/sphere.js` directly so `MorphingSphereCore.kt` remains the single source of truth across app / preview / docs. The cursor reactivity is **eye-only** — the sphere body stays anchored while the bright-spot gaze tracks the pointer (no canvas translate / body bounce). Gaze composition: **scroll-tracking is the always-on baseline** — the eye anchors to the Install section's top edge (via `.install-section` DOM query), not to the viewport center. `installGap = installRect.top − viewportH` is the runway until install enters view; as it shrinks below 50 % viewport-height, `scrollVy` ramps linearly to 1, so by the time install's top crosses into the viewport the eye is already looking straight down at it. Before that runway, the eye sits forward (`scrollVy = 0`). **Cursor-tracking is a soft overlay** — inside a rectangular detection band (full viewport width × container height, linear falloff over 1.0 × container height past the top/bottom edges) the cursor's unit-vector direction crossfades into the scroll target via `cursorWeight`. The eye always has one coherent target — no mode switching, no fbm drift fighting the cursor at the band boundary, no eye-flip between modes. Palette retarget Idle ↔ Listening is gated on `cursorWeight` (0.2 / 0.5 hysteresis) so the sphere reads as *calmly watching* at the scroll baseline and *attentive* on direct hover. A tiny fbm wander (±0.07 on top of the target) keeps the eye breathing when both scroll and cursor are stationary. Fallback when the install element isn't on the page: viewport-center reference preserves the gaze-follows-scroll feel without the anchor. Pointer inputs pass through a per-frame EMA low-pass (180 ms direction / 280 ms proximity time constants) before any math runs — stops the per-event jitter from `pointermove`'s big discrete jumps; asin/acos inputs are capped at ±0.9 so we stay off the infinite-slope end of the inverse-trig curves. Canvas is square (`aspect-ratio: 1 / 1`, `clamp(280px, 48vw, 420px)`) so the sphere fills the frame at the algorithm's natural 0.60-envelope sizing — no dead space between the phone video and the Install block. Respects `prefers-reduced-motion` (zeroes the gaze blend so the eye stops tracking but the ambient animation continues), pauses drawing while scrolled off-screen via `IntersectionObserver`, and resizes via `ResizeObserver` on the container. SSR-safe without a `<ClientOnly>` wrapper — `sphere.js` has no side-effectful imports and all DOM access lives inside `onMounted`, which Vue 3 never runs on the server.
|
||||
- **`SphereFrame` gaze-bias fields in `MorphingSphereCore.kt` (mirrored in `sphere.js`).** New `lightAngleBiasX`, `lightAngleBiasY`, `lightAngleBlend` (all default 0f / 0) let callers aim the sphere's bright spot at a specific direction without touching the sphere body. The light-angle computation blends between the natural `t * lightSpeedX + noise` rotation (`blend = 0`) and the caller-supplied bias (`blend = 1`). Defaults preserve byte-identical behavior for every existing caller — Android `MorphingSphere.kt` composable, the parity test, and the JS parity harness all stay green because they never set the new fields. First consumer: `SphereMark.vue` on the docs site, which uses the bias to make the sphere's eye track the reader's cursor without bouncing the canvas.
|
||||
- **`SphereFrame.shadowStrength`** (mirrored in `sphere.js`, default 0f / 0). Darkens `distBrightness` on the hemisphere facing away from the light, scaling it by `(1 − shadowStrength · (1 − directionalLight))` — the lit side is untouched, the shadow side dims proportionally. At 0 the legacy uniform "pearl" shading is preserved byte-for-byte. Docs-site `SphereMark.vue` uses 0.6 so the eye reads clearly against the unlit half of the sphere; Android composable doesn't set it and stays on legacy shading.
|
||||
@@ -185,7 +466,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
- **Hermes-agent dashboard plugin** at `plugin/dashboard/` — surfaces
|
||||
relay state in the gateway's web UI via four tabs. **Relay
|
||||
Management** lists paired devices + health + relay version;
|
||||
Management** lists paired devices + health + Server version;
|
||||
**Bridge Activity** renders the in-memory ring buffer of recent
|
||||
bridge commands (method / path / decision, with safety-rail
|
||||
`executed` / `blocked` / `confirmed` / `timeout` / `error`
|
||||
@@ -455,7 +736,7 @@ sees the toggle, never installs the wake lock, and never invokes
|
||||
|
||||
### Added — Voice intent → server session sync (v0.4.1 fast-follow)
|
||||
|
||||
- **Voice actions now reach the server-side LLM's session memory.** Previously, phone-local voice intents (`open Chrome`, `text Sam saying hi`, etc.) ran in-process via `BridgeCommandHandler.handleLocalCommand` and appended local-only trace bubbles to the chat scroll. The Hermes API server's session never learned about them, so a follow-up text question like "did that work?" hit the LLM with no context and returned hallucinated answers (per Bailey's 2026-04-14 on-device repro).
|
||||
- **Voice actions now reach the server-side LLM's session memory.** Previously, phone-local voice intents (`open Chrome`, `text Sam saying hi`, etc.) ran in-process via `BridgeCommandHandler.handleLocalCommand` and appended local-only trace bubbles to the chat scroll. The Hermes API server's session never learned about them, so a follow-up text question like "did that work?" hit the LLM with no context and returned hallucinated answers (per a 2026-04-14 on-device repro).
|
||||
- **Implementation.** Each phone-local voice intent now records a structured `VoiceIntentTrace` (tool name, JSON args, success, JSON result envelope) on the post-dispatch chat-trace bubble it produces. `VoiceIntentSyncBuilder` walks the chat history before each `POST /v1/runs` / `POST /api/sessions/{id}/chat/stream` call and synthesizes OpenAI-format `assistant` (with `tool_calls`) + `tool` (with `tool_call_id`) message pairs from any unsynced traces. The synthesized array rides under the existing payload's new `messages` field — additive, ignored by older servers, picked up by anything OpenAI Chat Completions–shaped. Idempotency: traces flip to `syncedToServer=true` the moment the API client takes ownership of the request, so subsequent turns don't re-emit them.
|
||||
- **Zero server changes.** Frontend-only, no hermes-agent edits needed.
|
||||
- **Files.** `data/ChatMessage.kt` (new `voiceIntent: VoiceIntentTrace?` field), `voice/VoiceIntentSyncBuilder.kt` (pure-function builder + helpers), `network/HermesApiClient.kt` (optional `voiceIntentMessages` parameter on both stream methods), `viewmodel/ChatViewModel.kt` (build + sync + flag flip in `startStream`), `viewmodel/VoiceViewModel.kt` (extended dispatch callback wires the structured trace into the chat-trace bubble), `voice/VoiceBridgeIntentHandler.kt` (new `androidToolName` + `androidToolArgsJson` on `IntentResult.Handled`), sideload `VoiceBridgeIntentHandlerImpl.kt` populates them per intent, sideload + googlePlay `VoiceBridgeIntentFactory.kt` typealias updates. Tests in `test/voice/VoiceIntentSyncBuilderTest.kt` (12 cases — empty input, single success, failure with error_code, idempotency, chronological order, prefix gate, blank-args gate, call-id pairing, helpers) and `test/network/handlers/ChatHandlerTest.kt` (4 new cases for trace storage + `markVoiceIntentsSynced`).
|
||||
@@ -959,7 +1240,7 @@ picker.
|
||||
- **Stats for Nerds enhancements** — reset button, tokens per message average, peak TTFT, slowest completion, seconds subtext on all ms values
|
||||
- **Feature gating** — `FeatureFlags` singleton with compile-time defaults (`BuildConfig.DEV_MODE`) and runtime DataStore overrides
|
||||
- **Developer Options** — hidden settings section, tap version 7 times to unlock (same pattern as Android system Developer Options)
|
||||
- **Relay feature toggle** — relay server settings and pairing sections gated behind developer options in release builds
|
||||
- **Relay feature toggle** — Server settings and pairing sections gated behind developer options in release builds
|
||||
- **Dynamic onboarding** — terminal, bridge, and relay pages excluded from onboarding when relay feature disabled
|
||||
- **Parse tool annotations** — experimental annotation parsing for Sessions mode (marked with badge, disabled for Runs mode)
|
||||
- **Privacy policy link** — accessible from Settings → About
|
||||
@@ -993,7 +1274,7 @@ MVP release — native Android companion app for Hermes agent with direct API ch
|
||||
#### Core Chat
|
||||
- **Direct API chat** — connects to Hermes API Server via `/api/sessions/{id}/chat/stream` with SSE streaming
|
||||
- **HermesApiClient** — full session CRUD + SSE streaming, health checks, cancel support
|
||||
- **Dual connection model** — API Server (HTTP) for chat, Relay Server (WSS) for bridge/terminal
|
||||
- **Dual connection model** — API Server (HTTP) for chat, Server (WSS) for bridge/terminal
|
||||
- **API key auth** — optional Bearer token stored in EncryptedSharedPreferences
|
||||
- **Cancel streaming** — stop button to cancel in-flight chat responses
|
||||
- **Error retry** — retry button in error banner re-sends last failed message
|
||||
@@ -1023,20 +1304,24 @@ MVP release — native Android companion app for Hermes agent with direct API ch
|
||||
- **Auth flow** — 6-character pairing code with session token persistence
|
||||
- **Material 3 + Material You** — dynamic theming with light/dark/auto
|
||||
- **Onboarding** — multi-page pager with feature overview and connection setup
|
||||
- **Settings** — API Server + Relay Server config, theme, reasoning toggle, data export/import/reset
|
||||
- **Settings** — API Server + Server config, theme, reasoning toggle, data export/import/reset
|
||||
- **Offline detection** — banner shown when network connectivity is lost
|
||||
- **What's New dialog** — shown automatically when app version changes
|
||||
- **Splash screen** — branded splash via core-splashscreen API
|
||||
- **Network security** — cleartext restricted to localhost only
|
||||
|
||||
#### Infrastructure
|
||||
- **Relay server** — Python aiohttp WSS server for bridge/terminal channels
|
||||
- **Server** — Python aiohttp WSS server for bridge/terminal channels
|
||||
- **CI/CD** — GitHub Actions for lint, build, test, and tag-driven releases
|
||||
- **Claude Code automation** — issue triage, PR fix, chat, and code review workflows
|
||||
- **Dependabot** — weekly Gradle + GitHub Actions dependency updates with auto-merge
|
||||
- **Dev scripts** — build, install, run, test, relay via scripts/dev.bat
|
||||
- **ProGuard rules** — okhttp-sse, markdown renderer, intellij-markdown parser
|
||||
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/v0.1.0...HEAD
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.0.0...HEAD
|
||||
[1.0.0]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v1.0.0
|
||||
[0.8.1]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v0.8.1
|
||||
[0.8.0]: https://github.com/Codename-11/hermes-relay/compare/v0.7.0...android-v0.8.0
|
||||
[0.7.0]: https://github.com/Codename-11/hermes-relay/compare/v0.6.1...v0.7.0
|
||||
[0.1.0]: https://github.com/Codename-11/hermes-relay/compare/v0.1.0-beta...v0.1.0
|
||||
[0.1.0-beta]: https://github.com/Codename-11/hermes-relay/releases/tag/v0.1.0-beta
|
||||
|
||||
@@ -4,24 +4,26 @@
|
||||
|
||||
## What This Is
|
||||
|
||||
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.
|
||||
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. Relay adds phone control, terminal, remote desktop tooling, extra voice engines, and dashboard Relay management.
|
||||
|
||||
**Current state:** v0.7.x (unreleased on `dev`) — Phase 0–3 complete. Direct API chat, session management, pairing + security (now multi-endpoint, ADR 24), inbound media, voice mode, bridge/accessibility control, notification companion, safety rails, multi-Connection, agent profiles + inspector, and first-class Tailscale (ADR 25). Two product flavors: `googlePlay` (conservative) and `sideload` (full-capability).
|
||||
**Current state:** v1.0.0 stable. 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).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct]
|
||||
Phone (WSS) → Relay Server (:8767) [bridge, terminal]
|
||||
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]
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
**Standard endpoints (confirmed in hermes-agent source):**
|
||||
**Vanilla Hermes endpoints (confirmed in hermes-agent source):**
|
||||
|
||||
| Endpoint | Purpose | Tool Call Format |
|
||||
|----------|---------|-----------------|
|
||||
@@ -29,45 +31,54 @@ Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is
|
||||
| `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) | — |
|
||||
|
||||
**Non-standard endpoints (provided by fork OR by plugin bootstrap):**
|
||||
**Compatibility endpoints (not all native upstream API-server routes):**
|
||||
|
||||
These endpoints are not in stock upstream `gateway/platforms/api_server.py`. There are three ways a hermes-agent install can serve them:
|
||||
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:
|
||||
|
||||
1. **Codename-11 fork** (`feat/session-api` branch, deployed on the `axiom` branch) — adds them natively. Submitted upstream as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) *"feat(api-server): add session management API for frontend clients"* — scope is broader than the title: sessions CRUD + session chat/stream + memory + skills + config + available-models.
|
||||
2. **Bootstrap injection** (`hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file. Does NOT inject `/api/sessions/{id}/chat/stream` — use `/v1/runs` for chat.
|
||||
3. **Upstream-merged** (post PR #8556) — bootstrap auto-detects and no-ops.
|
||||
1. **Native upstream** — `/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 for older or partial core builds. It skips native routes per method/path and should be retired per surface, not treated as the preferred path. 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.
|
||||
|
||||
| Endpoint | Purpose | Provided by |
|
||||
|----------|---------|-------------|
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/sessions/search` | Full-text message search | Fork OR bootstrap OR upstream-merged |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Fork OR upstream-merged ONLY (NOT bootstrap) |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/skills`, `/{name}` | Skill discovery (list + detail) | Fork OR bootstrap OR upstream-merged |
|
||||
| `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 OR upstream-merged |
|
||||
| `GET /api/available-models` | Provider model list | Fork OR bootstrap OR upstream-merged |
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `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 discovery/detail | Bootstrap/fork legacy; prefer native `/v1/skills` for lists |
|
||||
| `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` or `runs` based on the capability snapshot.
|
||||
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 — loopback-only):**
|
||||
**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/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.
|
||||
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.**
|
||||
|
||||
**Tool call rendering paths:**
|
||||
1. **Runs API** — Emits `tool.started`/`tool.completed` as real SSE events → `ToolProgressCard` in real-time.
|
||||
2. **Sessions API** — No structured tool events during streaming; reloads message history on stream complete ("session_end reload" pattern).
|
||||
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` ``).
|
||||
|
||||
## 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:** Remove `hermes_relay_bootstrap/` in one PR once PR #8556 merges. It's no-op-compatible, so leaving it in place during rollout is harmless.
|
||||
- **Bootstrap maintenance:** Retire `plugin/hermes_relay_bootstrap/` per surface. Sessions and read-only skills/toolsets now have native upstream replacements; config, memory, legacy skill detail/toggle, available-models, and slash middleware still need explicit replacement decisions before full removal.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
@@ -84,6 +95,10 @@ 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/
|
||||
@@ -107,7 +122,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/ ← Runtime patch for vanilla upstream; removable after PR #8556
|
||||
├── hermes_relay_bootstrap/ ← Legacy import shim for older startup hooks
|
||||
├── 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
|
||||
@@ -116,11 +131,23 @@ hermes-android/
|
||||
## Project Conventions
|
||||
|
||||
### File Structure
|
||||
- **Root-level:** README.md, CLAUDE.md, AGENTS.md, DEVLOG.md, .gitignore
|
||||
- **Root-level:** README.md, CLAUDE.md, AGENTS.md, DEVLOG.md, TODO.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, what's next, blockers
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -147,14 +174,14 @@ hermes-android/
|
||||
- **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`.** Use `bash scripts/bump-version.sh <new-version>` to bump all three sources atomically (`gradle/libs.versions.toml`, `pyproject.toml`, `plugin/relay/__init__.py`). The `release: vX.Y.Z` commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the tag is cut from `main`.
|
||||
- **Version bumps happen on `dev`, then release-merge to `main`.** Bump only the surface being released: `scripts/bump-android-version.sh` for `android-vX.Y.Z`, `scripts/bump-plugin-version.sh` for `plugin-vX.Y.Z`, and `desktop/package.json` for `cli-vX.Y.Z`. The release commit lives on `dev`, then a release PR merges `dev` → `main` with `--no-ff`, then the surface tag is cut from `main`.
|
||||
- **Server tracks `dev` for staging.** The hermes-host deployment pulls `dev` so merged features are exercised before they reach a tag. Released state lives on tags cut from `main`.
|
||||
- **Branch protection** on `main` — direct push blocked; only release-merge PRs from `dev` land here. `dev` also requires CI to pass on PRs but accepts feature-branch merges freely.
|
||||
|
||||
### Testing
|
||||
- **Android:** JUnit + Compose testing for UI, MockK for mocks
|
||||
- **Python:** `python -m unittest plugin.tests.test_<name>` — avoid bare `pytest` (conftest imports `responses` which may not be installed in the venv)
|
||||
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-relay.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
|
||||
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-plugin.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
|
||||
|
||||
## Key Files
|
||||
|
||||
@@ -162,14 +189,22 @@ hermes-android/
|
||||
|------|-----|
|
||||
| `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 |
|
||||
| `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 — 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/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 |
|
||||
@@ -197,7 +232,7 @@ hermes-android/
|
||||
| **App — Voice** | |
|
||||
| `voice/VoiceViewModel.kt` | Voice turn state machine; TTS queue; `ignoreAssistantId`; `errorEvents: SharedFlow` |
|
||||
| `audio/VoiceRecorder.kt` | MediaRecorder wrapper; perceptual amplitude curve; `.m4a` at 16kHz/64kbps |
|
||||
| `audio/VoicePlayer.kt` | MediaPlayer + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine |
|
||||
| `audio/VoicePlayer.kt` | Media3 ExoPlayer (gapless TTS queue) + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine; `audioSessionId` is a thread-safe `@Volatile` cache |
|
||||
| `network/RelayVoiceClient.kt` | OkHttp for `/voice/transcribe`, `/synthesize`, `/config` |
|
||||
| `voice/VoiceBridgeIntentHandler.kt` | Interface routing voice utterances to bridge; impls per flavor via factory |
|
||||
| `voice/VoiceIntentClassifier.kt` | Regex phone-control classifier (sideload only); false-negatives preferred over false-positives |
|
||||
@@ -208,12 +243,16 @@ hermes-android/
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -228,9 +267,12 @@ hermes-android/
|
||||
| `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; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
|
||||
| `install.sh` | Canonical installer — 6 steps; idempotent; drops `hermes-relay-update` shim |
|
||||
| `uninstall.sh` | Canonical uninstaller; reverses install.sh; never touches `.env` or `state.db` |
|
||||
| `hermes_relay_bootstrap/` | Runtime patch for vanilla upstream; no-op on fork/upstream-merged; remove after PR #8556 |
|
||||
| `hermes_relay_bootstrap/` | 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 |
|
||||
@@ -243,7 +285,7 @@ hermes-android/
|
||||
| `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 |
|
||||
| `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>` |
|
||||
@@ -251,10 +293,15 @@ hermes-android/
|
||||
| `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)` — installs `onChannel('desktop')`, dispatches `desktop.command` under 30s `AbortController`, 30s heartbeat via `desktop.status` advertising handler names |
|
||||
| `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 |
|
||||
@@ -271,10 +318,17 @@ hermes-android/
|
||||
| **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. |
|
||||
| `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` | `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` — registers with `tools.registry` under `desktop` toolset; `_check_requirements` pings `/desktop/_ping` for "is a client connected AND does it advertise this tool?" |
|
||||
| `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) |
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
@@ -283,7 +337,8 @@ hermes-android/
|
||||
- **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
|
||||
- **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
|
||||
|
||||
## MCP Tooling
|
||||
|
||||
@@ -322,7 +377,7 @@ 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 (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.
|
||||
4. **Before pushing Kotlin changes** — run `./gradlew lint` locally. It's the exact task CI runs and catches errors Android Studio's live inspections miss — e.g. `UnsafeOptInUsageError` with `kotlin.OptIn` vs `androidx.annotation.OptIn`, `FlowOperatorInvokedInComposition` (mapped flows inside Composables), Media3 `@UnstableApi` propagation. Android CI runs lint alongside build/test for faster feedback, but a local lint run still surfaces issues before the workflow spends runner time compiling and packaging.
|
||||
5. **Commit + push** — feature branch off `dev`, merged back to `dev` via PR. `main` is reserved for release merges.
|
||||
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`.
|
||||
@@ -341,6 +396,12 @@ Server is a Linux box running hermes-agent with hermes-relay editable-installed
|
||||
|
||||
**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 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
|
||||
@@ -359,20 +420,25 @@ Server is a Linux box running hermes-agent with hermes-relay editable-installed
|
||||
|
||||
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
|
||||
- **Android version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`); bump with `scripts/bump-android-version.sh`
|
||||
- **Relay plugin version source:** `pyproject.toml`; keep plugin/dashboard metadata synced with `scripts/check-plugin-version-sync.py`; bump with `scripts/bump-plugin-version.sh`
|
||||
- **Desktop CLI version source:** `desktop/package.json`; regenerate `desktop/src/version.ts` with `npm run gen:version`
|
||||
- **Track audit:** `python scripts/check-version-tracks.py` reports Android, plugin, and CLI versions without forcing them to match
|
||||
- **`appVersionCode` is monotonic** — always increment across Android prereleases
|
||||
- **Cut a release:** bump the target surface → commit → merge `dev` to `main` → tag with `android-v*`, `plugin-v*`, or `cli-v*` → push tag → CI builds + GitHub Release
|
||||
- **Required secrets:** `HERMES_KEYSTORE_BASE64`, `HERMES_KEYSTORE_PASSWORD`, `HERMES_KEY_ALIAS`, `HERMES_KEY_PASSWORD`
|
||||
|
||||
## Integration Points
|
||||
|
||||
| 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 (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` | Non-standard; bootstrap or fork |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback only for old builds |
|
||||
| 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` |
|
||||
@@ -383,15 +449,18 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
| 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 | `HEAD /api/sessions`, `HEAD /v1/runs`, etc. | HEAD avoids CORS 403 on OPTIONS preflight |
|
||||
| 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 | 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
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Hermes-Relay-CLI v__VERSION__
|
||||
|
||||
**Release Date:** <!-- YYYY-MM-DD -->
|
||||
**Since the previous CLI release:** <!-- one line: the theme of this release -->
|
||||
|
||||
<!-- One short paragraph: what this desktop/CLI release is about and who should care. -->
|
||||
|
||||
<!--
|
||||
═══ RELEASE-PREP CHECKLIST (delete this comment block when done) ═══
|
||||
• This file is the GitHub Release body for `cli-v*` tags. The release workflow
|
||||
substitutes __VERSION__ (bare, e.g. 0.3.0) and __TAG__ (full, e.g. cli-v0.3.0) —
|
||||
leave those tokens in the Install section; do NOT hardcode versions there.
|
||||
• Rewrite the Summary + the Added/Changed/Fixed groups from the CLI/desktop-relevant
|
||||
bullets in CHANGELOG.md's promoted version block.
|
||||
• Keep-a-Changelog rules: include only the groups that have entries; delete empty ones.
|
||||
• Keep the "Experimental phase" notice until the CLI reaches GA.
|
||||
• Scrub for public distribution (RELEASE.md §2): no personal names, no private infra,
|
||||
no fork-branch plumbing, no AI self-narration.
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
-->
|
||||
|
||||
**Experimental phase.** Assets are unsigned — Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
-
|
||||
|
||||
### Changed
|
||||
-
|
||||
|
||||
### Fixed
|
||||
-
|
||||
|
||||
## 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=__TAG__`.
|
||||
|
||||
## 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.
|
||||
@@ -94,14 +94,24 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`,
|
||||
|
||||
**Branching model (as of 2026-04-19): `main` + `dev`.** Feature branches — `feature/<name>`, `fix/<name>`, `docs/<name>`, `chore/<name>` — branch off `dev` and merge back into `dev` via `--no-ff` PRs. `main` is released state only; it receives release merges from `dev` and nothing else. There is no straight-to-main exemption — even single-file typos go through `dev`.
|
||||
|
||||
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a `release: vX.Y.Z` PR merges `dev` → `main` with `--no-ff`. The tag is cut from `main` after the merge. See [RELEASE.md](RELEASE.md) for the full release process.
|
||||
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a surface-specific release PR merges `dev` → `main` with `--no-ff`. Tags are cut from `main` after the merge: `android-vX.Y.Z`, `server-vX.Y.Z`, or `desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
|
||||
|
||||
## 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.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
||||
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
||||
|
||||
CI is split into two path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes) and `.github/workflows/ci-relay.yml` (syntax check + unittest discover on plugin/Python changes). Both run on pushes to `main` and `dev` and on PRs targeting either.
|
||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Hermes-Relay-Plugin v__VERSION__
|
||||
|
||||
**Release Date:** June 20, 2026
|
||||
**Since the previous plugin release:** A new, removable **enhancement layer** that lets the relay teach the agent things only the relay knows — starting with sensitive-media classification — plus provider-aware enhanced voice and an isolated, TUI-tuned tmux for relay terminals.
|
||||
|
||||
This release adds a clean way for the relay to extend the agent without forking or touching the user's soul/memory. The first use is **sensitive-media classification**: the relay appends a small, auditable system-prompt block teaching the agent to mark private/NSFW media so the paired phone can blur it — with sensitivity staying model-emitted. It's on by default for relay installs (installing the relay is the opt-in), reversible from the dashboard or an env flag, fully visible over a new audit route, and a complete no-op on vanilla upstream. Voice gains provider-aware controls for Gemini and xAI, and relay terminals now run on a dedicated, correctly-configured tmux.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
- **Relay enhancement layer + agent-context injection.** A reusable, removable layer that injects auditable, fenced blocks into the agent's system prompt at plugin-load. Fail-open at every step (seam absent / block build throws ⇒ base prompt unchanged), config-gated, and a byte-for-byte no-op on vanilla upstream. Built to be retired per-surface as upstream adds a context hook — the same pattern as the bootstrap route shims. See `docs/plans/2026-06-20-relay-enhancement-layer.md`.
|
||||
- **Sensitive-media classification (first block).** Teaches the agent to mark private/NSFW media with the client's spoiler convention so the phone blurs it per the user's setting. **On by default for relay installs**; opt out with `RELAY_AGENT_CONTEXT_ENABLED=0` or the dashboard toggle. Sensitivity stays model-emitted — no relay-side or on-device classifier. No soul/memory is touched.
|
||||
- **`GET /context/injected` audit route.** The relay exposes exactly what it would inject (loopback-open, bearer-gated remotely), so the injection is never hidden — surfaced in the Android chat "What the agent sees" sheet as "Relay context (server-side)".
|
||||
- **Dashboard Agent-context controls.** The Relay management tab gained a master toggle and per-block toggles (labeled experimental / server-side / removable), shown on-by-default for relay installs.
|
||||
- **Provider-aware enhanced voice (Gemini + xAI).** `/voice/synthesize` accepts per-request overrides so a paired client can steer a Gemini voice/model with expressive tone tags, or an xAI voice with expressive speech tags, without changing the server's global voice config.
|
||||
|
||||
### Changed
|
||||
- **Relay terminals run on an isolated, TUI-tuned tmux.** Sessions spawn on a dedicated tmux server/socket with a generated config — `escape-time 0`, truecolor `tmux-256color`, `mouse`/`focus-events` on, `status off` — so editors and full-screen tools behave correctly without touching the user's personal tmux.
|
||||
|
||||
### Fixed
|
||||
- **Relay voice synthesis no longer leaves temporary audio files behind** on the server.
|
||||
- **Clearer voice errors.** Standard voice rejects an over-long recording before uploading and returns a helpful message for audio the server can't read, instead of a generic HTTP error.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install hermes-relay==__VERSION__
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
python -m relay_server --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Tag prefixes: Android releases use `android-v*`, CLI releases use `cli-v*`. Historical
|
||||
relay/plugin releases used `relay-v*` tags.
|
||||
@@ -1,19 +1,23 @@
|
||||
<p align="center">
|
||||
<img src="assets/logo.svg" alt="Hermes-Relay" width="120">
|
||||
<img src="assets/play-store-feature-1024x500.png" alt="Hermes-Relay — your Hermes agent, in your pocket" width="800">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Hermes-Relay</h1>
|
||||
<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>
|
||||
|
||||
<p align="center">
|
||||
Native Android client for the Hermes agent platform.<br>
|
||||
Chat, control, and connect — one app for your AI agent.
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT"></a>
|
||||
<a href="https://developer.android.com"><img src="https://img.shields.io/badge/Platform-Android-green.svg" alt="Android"></a>
|
||||
<a href="https://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>
|
||||
<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-alpha-orange.svg" alt="CLI (alpha)"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -23,262 +27,301 @@
|
||||
<a href="https://hermes-agent.nousresearch.com">Hermes Agent</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<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
|
||||
## What it is
|
||||
|
||||
Two steps: install the Android app on your phone, then install the plugin on your Hermes server.
|
||||
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.
|
||||
|
||||
### 1. Install the Android app
|
||||
- **📱 Android app** — streaming chat, hands-free voice, and the full Hermes dashboard (models, keys, skills, profiles), rebuilt native. On sideload builds, the agent can read your screen and act on it.
|
||||
- **⌨️ Hermes-Relay CLI** *(alpha)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
|
||||
|
||||
<!-- 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>
|
||||
-->
|
||||
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**. Add the optional relay only when you want terminal, phone control, or the CLI's tools. **Pair once from either surface; both work.**
|
||||
|
||||
- **Google Play** — coming soon (currently on Internal testing)
|
||||
- **APK** — download from [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases/latest)
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/architecture-homepage.png" alt="How Hermes-Relay connects — Vanilla Hermes (Chat, Manage, Voice) runs with no plugin; the optional Relay plugin adds Terminal, Bridge, relay voice and desktop tools to the app and CLI; Device Control needs the sideload build." width="900">
|
||||
</p>
|
||||
|
||||
#### Sideload APK (GitHub Releases)
|
||||
## Quick Start (Android)
|
||||
|
||||
Prefer not to wait for Google Play? Grab the signed APK directly:
|
||||
Install → connect → talk, in about two minutes.
|
||||
|
||||
1. Download the file ending in **`-sideload-release.apk`** from [the latest release](https://github.com/Codename-11/hermes-relay/releases/latest) — that's the full-featured "Hermes Dev" build. (Skip any `.aab` file — those are the Google Play bundle format and won't install directly.)
|
||||
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).
|
||||
### 1 · Install the app
|
||||
|
||||
Full walkthrough, including signing-certificate fingerprint: [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, Manage, terminal/TUI, media, notifications, and relay sessions.
|
||||
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
|
||||
**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.
|
||||
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
|
||||
|
||||
### 2. Install the server plugin (one-liner)
|
||||
### 2 · Have Hermes running
|
||||
|
||||
On the machine running your Hermes agent:
|
||||
The app needs your Hermes **API server enabled and reachable from your phone**, plus an **API key** — the token the app sends to authenticate Chat (pick any value you like). Installing Hermes and choosing a provider is vanilla Hermes setup; the [full walkthrough](https://codename-11.github.io/hermes-relay/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
|
||||
|
||||
```bash
|
||||
hermes setup --portal # install / log in / pick a provider — skip if already done
|
||||
|
||||
mkdir -p ~/.hermes
|
||||
API_SERVER_KEY="$(openssl rand -hex 32)" # strong random key — or substitute your own memorable value
|
||||
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://<this-computer-ip>:8642 key: $API_SERVER_KEY"
|
||||
hermes gateway
|
||||
```
|
||||
|
||||
`API_SERVER_ENABLED` turns the API server on; `API_SERVER_HOST=0.0.0.0` makes it reachable on your LAN (the default is localhost-only); `API_SERVER_KEY` is the bearer token the app sends — **your choice of value**.
|
||||
|
||||
> **Heads up on `0.0.0.0`:** that exposes the API to every device on your network — fine on a trusted home LAN, but off it keep the key set and front it with Tailscale or an HTTPS reverse proxy ([Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access)) rather than exposing it directly. You don't have to type the key on your phone — **Scan for Hermes on LAN**, or have your agent make a setup QR (below). For **Manage** (skills, models, keys), also run the Hermes dashboard — see [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
|
||||
|
||||
### 3 · Connect and talk
|
||||
|
||||
Open the app and pick how to connect — any of:
|
||||
|
||||
- **Vanilla Hermes** → tap **Scan for Hermes on LAN** to auto-find the server, then enter your key.
|
||||
- **Vanilla Hermes** → type the address (`http://<host>:8642`) and key by hand.
|
||||
- **Scan setup QR** → ask your Hermes agent to generate a QR with your URL + key (e.g. `{"api_url":"http://<host>:8642","api_key":"<key>","dashboard_url":"http://<host>:9119"}`) and scan it. `dashboard_url` is optional when the dashboard uses the conventional same-host `:9119` URL.
|
||||
|
||||
The wizard probes everything and finishes with a capability card:
|
||||
|
||||
| Line | What it means |
|
||||
|------|---------------|
|
||||
| **Chat** | API server reachable — you can talk |
|
||||
| **Manage** | Dashboard found — models, keys, skills, profiles from the phone |
|
||||
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
|
||||
| **Remote** | Fallback route configured — keeps working away from home |
|
||||
| **Relay** | Optional power tools — fine to leave unpaired |
|
||||
|
||||
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session unlocks voice. That's the whole Vanilla Hermes setup.
|
||||
|
||||
> **Going places?** Put your server's Tailscale URL in the setup form's *Remote access* field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access).
|
||||
|
||||
### 4 · Optional: install Relay for power tools
|
||||
|
||||
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, or the realtime voice engine:
|
||||
|
||||
```bash
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
hermes relay doctor
|
||||
hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
Use the legacy installer instead if you also want the systemd user service,
|
||||
shell shims, and the full clone/update workflow:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
```
|
||||
|
||||
The installer clones Hermes-Relay to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`), `pip install -e`s the package into the hermes-agent venv, registers the `skills/` directory in your `~/.hermes/config.yaml` under `skills.external_dirs` (so updates flow through `git pull`), symlinks the plugin into `~/.hermes/plugins/hermes-relay`, drops a thin `hermes-pair` shim into `~/.local/bin/`, and (optionally) installs a systemd user service for the WSS relay. After restart, pair your phone via either of these equivalent entry points:
|
||||
The plugin-manager install owns the plugin code, dashboard tab, CLI commands,
|
||||
and agent tools. `hermes relay compat status/install/remove` manages only the
|
||||
optional legacy API compatibility hook when an older Hermes build needs it. Scan
|
||||
the QR from the phone's Connections screen — or use
|
||||
`hermes pair --register-code ABCD12` with the manual code from Android
|
||||
**Settings → Connections → Advanced**.
|
||||
|
||||
- **From any Hermes chat surface** (CLI, Discord, Telegram, etc.): type `/hermes-relay-pair` and the `hermes-relay-pair` skill renders the QR inline. Shortest path if you're already chatting with the agent.
|
||||
- **From a shell**: `hermes-pair` (dashed) — a thin wrapper around `python -m plugin.pair` in the hermes-agent venv. Use this in scripts or when you want the raw output.
|
||||
- **No camera?** `hermes-pair --register-code ABCD12` — manual fallback for SSH-only / camera-less setups. Read the 6-char code from the app's **Settings → Connection → Manual pairing code (fallback)** card, pre-register it on the host with this command, then tap **Connect** in the app. Composes with `--ttl` / `--grants`.
|
||||
- **Plugin-manager uninstall:** `hermes relay compat remove --all` if you installed the optional hook, then `hermes plugins remove hermes-relay`.
|
||||
- **Legacy installer update:** `hermes-relay-update` (idempotent) — or re-run the install one-liner.
|
||||
- **Legacy installer uninstall:** `bash ~/.hermes/hermes-relay/uninstall.sh` — removes the service, shims, clone, external skill path, editable package, and compat hook. It never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
|
||||
- **Dashboard plugin:** installs with the same symlink — restart the gateway and a **Relay** tab (paired devices, bridge activity, media tokens) appears in the web UI.
|
||||
|
||||
Scan the QR from the Android app's onboarding screen and you're connected. One scan configures **both** the direct-chat API server **and** the WSS relay (for terminal/bridge) — if a local relay is running at `localhost:8767`, the pair command pre-registers a fresh 6-char pairing code with it and embeds the relay URL + code in the same QR. If you only want direct chat, pass `--no-relay` (or just don't start the relay). Plain-text connection details are always printed alongside the QR so you can copy values by hand if your terminal can't render QR blocks.
|
||||
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
|
||||
|
||||
**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.
|
||||
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the API server and dashboard enabled · Python 3.11+ on the server.
|
||||
|
||||
**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.
|
||||
## Screenshots
|
||||
|
||||
**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.
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/01_startup.png" alt="Cold start" width="100%"><br><sub><b>Cold start</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>
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26), [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+.
|
||||
<p align="center"><sub>▶ <a href="https://codename-11.github.io/hermes-relay/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
|
||||
|
||||
### For AI Agents
|
||||
## Features
|
||||
|
||||
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.
|
||||
### 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.
|
||||
- **Phone control (bridge)** — with Relay paired, the agent reads the screen and acts: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros. Guarded by 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://codename-11.github.io/hermes-relay/guide/release-tracks).
|
||||
|
||||
## Hands on any machine — the Hermes-Relay CLI <sub>(alpha)</sub>
|
||||
|
||||
> **Alpha · Windows today** (macOS / Linux coming soon). A single self-contained binary — no Node required. Binaries 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.
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767 # once
|
||||
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 a separate `cli-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=cli), with old alpha prereleases still visible under `desktop-v*`.
|
||||
|
||||
- **Docs:** [CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
|
||||
|
||||
## 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]
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Quick start, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
|
||||
| [Hermes-Relay CLI](https://codename-11.github.io/hermes-relay/desktop/) | Pairing, subcommands, local tool routing |
|
||||
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
|
||||
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
|
||||
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
|
||||
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
|
||||
| [Changelog](CHANGELOG.md) | Release history (`android-v*`, `plugin-v*`, `cli-v*`) |
|
||||
|
||||
<details>
|
||||
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
|
||||
|
||||
<br>
|
||||
|
||||
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:
|
||||
|
||||
```text
|
||||
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay), a native Android client + Python plugin for the Hermes AI agent platform.
|
||||
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a CLI + a Python plugin for the Hermes AI agent platform.
|
||||
|
||||
Read the canonical setup recipe before acting:
|
||||
https://raw.githubusercontent.com/Codename-11/hermes-relay/main/skills/devops/hermes-relay-self-setup/SKILL.md
|
||||
|
||||
Then guide me through:
|
||||
- Verifying hermes-agent is already installed (it's a prerequisite — Hermes-Relay is a plugin, not standalone)
|
||||
- Running the install one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash`
|
||||
- Pairing my phone via `hermes-pair` or `/hermes-relay-pair`
|
||||
- Verifying with `hermes-status`
|
||||
- Running the server-plugin install one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash`
|
||||
- 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)
|
||||
|
||||
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 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.
|
||||
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.
|
||||
|
||||
## What It Does
|
||||
|
||||
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — all native on Android, now also on the desktop command line.
|
||||
|
||||
| Client | 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 / Chat / Tools** | Full Hermes TUI over PTY + structured-event chat + **local tool routing** (agent reads/writes/execs on YOUR machine) over the same relay. Windows/macOS/Linux binaries, curl-install. | **Experimental** (see [`desktop/`](desktop/)) |
|
||||
|
||||
## Experimental: Desktop CLI
|
||||
|
||||
Early-preview command-line client for remote Hermes sessions. Pipes the full Hermes TUI over a PTY, or streams structured chat events for scripting, AND — uniquely — lets the remote agent execute tools (`desktop_read_file`, `desktop_write_file`, `desktop_terminal`, `desktop_search_files`, `desktop_patch`) **on your local machine**, routed over the same WSS relay the Android client uses. One pair, three modes, no `ssh`.
|
||||
|
||||
**Install** (Windows PowerShell):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Install** (macOS / Linux):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767 # once
|
||||
hermes-relay # interactive Hermes TUI
|
||||
hermes-relay "summarize the last commit" # one-shot
|
||||
hermes-relay --json "..." | jq # structured events for scripting
|
||||
```
|
||||
|
||||
**Binaries are unsigned** during experimental phase — SmartScreen/Gatekeeper warnings are expected; the install scripts show the one-line escape hatches. Daemon mode, multi-client routing, and signed releases land with v1.0.
|
||||
|
||||
- **Docs**: [Desktop CLI 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)
|
||||
- **AI-agent setup recipe**: `/hermes-relay-desktop-setup` (the agent can run `desktop_terminal` on your machine to diagnose install/pair issues live)
|
||||
|
||||
## 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
|
||||
|
||||
- **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** — Real-time voice conversation via the relay; the sphere listens with you and performs the agent's reply as it speaks. Uses your server's configured TTS/STT providers (Edge TTS, ElevenLabs, OpenAI, MiniMax, Mistral, NeuTTS / faster-whisper, Groq, OpenAI Whisper)
|
||||
- **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.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Install the app** from the link above
|
||||
2. **Enter your Hermes server URL** (e.g. `http://192.168.1.100:8642`) during onboarding
|
||||
3. **Start chatting** — the app connects directly to the Hermes API Server
|
||||
|
||||
For detailed setup, server configuration, and feature guides, see the **[full documentation](https://codename-11.github.io/hermes-relay/)**.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
|
||||
Phone (WSS) --> Relay Server (:8767) [terminal, bridge — future]
|
||||
```
|
||||
|
||||
Chat connects directly to the Hermes API Server — same pattern used by Open WebUI and other Hermes frontends. The relay server is a separate lightweight Python service for terminal and bridge channels (coming in Phase 2/3).
|
||||
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Getting started, features, configuration — start here** |
|
||||
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the app works under the hood |
|
||||
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by the app |
|
||||
| [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 |
|
||||
|
||||
---
|
||||
</details>
|
||||
|
||||
## 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 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 + 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)
|
||||
```
|
||||
|
||||
### Repository Structure
|
||||
|
||||
```
|
||||
hermes-relay/
|
||||
├── app/ # Android app (Kotlin + Jetpack Compose)
|
||||
├── desktop/ # Node thin-client CLI (@hermes-relay/cli)
|
||||
├── relay_server/ # WSS relay server (Python + aiohttp)
|
||||
├── plugin/ # Hermes agent plugin (18 android_* tools + pair module)
|
||||
├── skills/ # Hermes agent skills
|
||||
│ └── devops/
|
||||
│ └── hermes-relay-pair/ # /hermes-relay-pair slash-command skill
|
||||
├── user-docs/ # VitePress documentation site
|
||||
├── docs/ # Spec, decisions, security
|
||||
├── scripts/ # Dev helper scripts
|
||||
├── .github/workflows/ # CI + release pipelines
|
||||
└── gradle/ # Wrapper (8.13) + version catalog
|
||||
scripts/dev.bat relay # Start the relay server (dev, no TLS)
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Component | Stack |
|
||||
|-----------|-------|
|
||||
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Relay Server** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 |
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
|
||||
| **Android app** | Kotlin 2.0, 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 |
|
||||
| **Serialization** | kotlinx.serialization (Android) |
|
||||
| **Build** | AGP 9, Gradle 8.13, 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 35 |
|
||||
|
||||
### Relay Server (optional — terminal/bridge only)
|
||||
<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 install via the [one-liner](#4--optional-install-relay-for-power-tools) above. For local development:
|
||||
|
||||
```bash
|
||||
hermes relay start --no-ssl # if you installed the plugin
|
||||
# or from a repo checkout:
|
||||
python -m plugin.relay --no-ssl
|
||||
```
|
||||
python -m plugin.relay --no-ssl # or from a repo checkout
|
||||
|
||||
Or with Docker:
|
||||
|
||||
```bash
|
||||
# Docker:
|
||||
docker build -t hermes-relay relay_server/ && docker run -d --network host --name hermes-relay hermes-relay
|
||||
```
|
||||
|
||||
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:
|
||||
# Live-edit the plugin against a local Hermes:
|
||||
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
|
||||
```
|
||||
|
||||
Then restart hermes and run `hermes-pair` (dashed shell shim) or type `/hermes-relay-pair` in any Hermes chat surface to verify pairing. The 14 `android_*` tools register regardless of hermes-agent version. **Note:** a top-level `hermes pair` CLI sub-command is *not* currently exposed — hermes-agent v0.8.0's top-level argparser doesn't yet forward to third-party plugins' `register_cli_command()` dict. Use the slash command or the dashed shim instead.
|
||||
Then restart hermes and run `hermes pair` to verify. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
|
||||
|
||||
## Hermes Agent
|
||||
</details>
|
||||
|
||||
## Built for 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.
|
||||
|
||||
## Found a bug? Let us know!
|
||||
## Found a bug? Let us know
|
||||
|
||||
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line "this didn't work on my Pixel 7" is genuinely useful.
|
||||
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line *"this didn't work on my Pixel 7"* is genuinely useful.
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> The full recipe for cutting a new release. Read this end-to-end before
|
||||
> tagging your first release.
|
||||
|
||||
## Versioning
|
||||
## Release Tracks And Versioning
|
||||
|
||||
Hermes-Relay follows [SemVer](https://semver.org/): `MAJOR.MINOR.PATCH`,
|
||||
with optional prerelease identifiers.
|
||||
@@ -13,6 +13,26 @@ with optional prerelease identifiers.
|
||||
- `PATCH` — bug fixes, backwards compatible
|
||||
- Prerelease suffixes: `-alpha`, `-beta`, `-rc.N` (e.g. `0.2.0-beta.1`)
|
||||
|
||||
Hermes-Relay now ships three independently versioned surfaces. Public GitHub
|
||||
Release titles use product names (`Hermes-Relay-Android`,
|
||||
`Hermes-Relay-Plugin`, `Hermes-Relay-CLI`); tag prefixes stay short and stable
|
||||
for automation.
|
||||
|
||||
| Surface | Tag prefix | Version source | Bump script | Release workflow |
|
||||
|---|---|---|---|---|
|
||||
| Hermes-Relay-Android | `android-v*` | `gradle/libs.versions.toml` | `scripts/bump-android-version.sh` | `.github/workflows/release-android.yml` |
|
||||
| Hermes-Relay-Plugin | `plugin-v*` | `pyproject.toml` plus checked plugin/dashboard metadata | `scripts/bump-plugin-version.sh` | `.github/workflows/release-plugin.yml` |
|
||||
| Hermes-Relay-CLI | `cli-v*` | `desktop/package.json` | `npm version` or manual package bump | `.github/workflows/release-cli.yml` |
|
||||
|
||||
This split is intentional. The plugin carries relay features for both Android
|
||||
and CLI clients, so plugin fixes can ship without forcing an Android app
|
||||
`versionCode` bump, and CLI alphas can continue on their own cadence. Historical
|
||||
Android releases before this naming split used bare `v*` tags. Historical
|
||||
plugin/server releases used `relay-v*` tags, and historical CLI prereleases used
|
||||
`desktop-v*` tags. New releases use the explicit tag prefixes above.
|
||||
|
||||
### Android app versioning
|
||||
|
||||
**Source of truth:** `gradle/libs.versions.toml`
|
||||
|
||||
```toml
|
||||
@@ -44,30 +64,56 @@ Never decrement `appVersionCode` — Play Console rejects any upload whose
|
||||
code is lower than or equal to a previous upload on the same track. Confirm
|
||||
current values with `scripts\dev.bat version`.
|
||||
|
||||
### The three version sources (MUST stay in lockstep)
|
||||
|
||||
There are **three** places the version lives, and they MUST all match on
|
||||
every release commit. Drift is silent and painful — we chased a "why does
|
||||
/health say 0.2.0" bug for hours on 2026-04-12 because `pyproject.toml`
|
||||
had drifted to `0.5.0` speculatively and `plugin/relay/__init__.py` was
|
||||
still at a stale `0.2.0`.
|
||||
|
||||
| File | Line | Written by |
|
||||
|---|---|---|
|
||||
| `gradle/libs.versions.toml` | `appVersionName = "…"` | You (canonical) |
|
||||
| `pyproject.toml` | `version = "…"` | You (Python package) |
|
||||
| `plugin/relay/__init__.py` | `__version__ = "…"` | You (runtime, reported by `/health`) |
|
||||
|
||||
**Always bump them atomically via `scripts/bump-version.sh`**:
|
||||
Always bump Android releases via:
|
||||
|
||||
```bash
|
||||
bash scripts/bump-version.sh 0.3.0
|
||||
bash scripts/bump-android-version.sh 0.6.2
|
||||
```
|
||||
|
||||
The script validates SemVer, bumps `appVersionCode` monotonically, rewrites
|
||||
all three files, runs a post-bump sanity grep, prints the diff, and tells
|
||||
you the next steps. It deliberately does NOT commit, tag, or touch
|
||||
`CHANGELOG.md` / `RELEASE_NOTES.md` — those need human prose.
|
||||
`scripts/bump-version.sh` remains as a backward-compatible alias for the
|
||||
Android script.
|
||||
|
||||
### Plugin / Python package versioning
|
||||
|
||||
Plugin version metadata lives in these plugin-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/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:
|
||||
|
||||
```bash
|
||||
bash scripts/bump-plugin-version.sh 0.6.2
|
||||
```
|
||||
|
||||
Check the current metadata with:
|
||||
|
||||
```bash
|
||||
python scripts/check-plugin-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 versions
|
||||
side by side and validates that each track's own source files are internally
|
||||
consistent. It deliberately does not require all three tracks to share the same
|
||||
SemVer.
|
||||
|
||||
The `plugin-v*` release workflow validates the tag against the same metadata,
|
||||
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.
|
||||
|
||||
## Branching policy
|
||||
|
||||
@@ -85,8 +131,8 @@ release" rule. The `[Unreleased]` section of `CHANGELOG.md` on `dev` is
|
||||
the accumulator: every merged PR appends bullets there. A release is a
|
||||
separate act, taken when the accumulated state on `dev` is worth shipping
|
||||
(see "When to cut a release" below). Cutting a release means opening a
|
||||
`release: vX.Y.Z` PR from `dev` into `main`, merging it `--no-ff`, then
|
||||
tagging `main`.
|
||||
surface-specific release PR from `dev` into `main`, merging it `--no-ff`,
|
||||
then tagging `main`.
|
||||
|
||||
**Server tracks `dev` for staging.** The hermes-host deployment pulls
|
||||
`dev` so merged features get exercised against real data before they
|
||||
@@ -125,23 +171,24 @@ Squash merges lose that detail and are **not** the house style.
|
||||
### Version bumps happen at release-prep on `dev`, NOT on feature branches
|
||||
|
||||
Feature branches **never** touch `gradle/libs.versions.toml`,
|
||||
`pyproject.toml`, or `plugin/relay/__init__.py`. If two feature branches
|
||||
both bumped the version, they'd collide on `appVersionCode` (which must
|
||||
be monotonic) and you'd hit a merge conflict for no good reason.
|
||||
plugin-owned version metadata, or `desktop/package.json`.
|
||||
If two feature branches both bumped a release version, they'd collide on
|
||||
version files and, for Android, on `appVersionCode` (which must be
|
||||
monotonic).
|
||||
|
||||
The version-bump commit lives on `dev`, created via
|
||||
`scripts/bump-version.sh`, as the last commit of the release-prep work.
|
||||
It's a dedicated commit with the message `release: vX.Y.Z` that also
|
||||
lands the CHANGELOG and RELEASE_NOTES updates. A release PR then merges
|
||||
`dev` → `main` with `--no-ff`, and the `v<version>` tag is cut from the
|
||||
resulting `main` tip.
|
||||
Version-bump commits live on `dev` as the last commit of release-prep
|
||||
work. Android commits use `release(android): android-vX.Y.Z`; plugin commits
|
||||
use `release(plugin): plugin-vX.Y.Z`; CLI commits use
|
||||
`release(cli): cli-vX.Y.Z`. A release PR then merges `dev` →
|
||||
`main` with `--no-ff`, and the matching tag is cut from the resulting
|
||||
`main` tip.
|
||||
|
||||
### Branch protection
|
||||
|
||||
Light branch protection is enabled:
|
||||
|
||||
- **`main`** — direct pushes blocked; only release PRs from `dev` merge
|
||||
here. PR must pass CI (Android + Relay) before merge. Force push and
|
||||
here. PR must pass CI (Android + Plugin) before merge. Force push and
|
||||
branch deletion blocked.
|
||||
- **`dev`** — direct pushes blocked for non-trivial work; feature
|
||||
branches PR in. PR must pass CI. Force push and branch deletion
|
||||
@@ -177,10 +224,12 @@ hermes.key.password=YOUR_KEY_PASSWORD
|
||||
```
|
||||
|
||||
`local.properties`, `*.keystore`, and `*.jks` are already gitignored.
|
||||
Relative `hermes.keystore.path` values resolve from the repo root, so
|
||||
`release.keystore` works when the keystore lives beside this file.
|
||||
|
||||
> If the keystore at `hermes.keystore.path` is missing, `app/build.gradle.kts`
|
||||
> silently falls back to debug signing. The build succeeds but Play Console
|
||||
> rejects the AAB — always verify with `keytool -list -printcert` (step 3
|
||||
> rejects the AAB — always verify with `keytool -printcert` (step 3
|
||||
> below).
|
||||
|
||||
#### CI builds
|
||||
@@ -241,27 +290,40 @@ for the full text.
|
||||
|
||||
### 3. Play Developer API service account (optional)
|
||||
|
||||
Required only if you want `gradlew publishReleaseBundle` to upload directly
|
||||
to Play Console. Manual UI uploads work without this.
|
||||
Required for automated upload (the `android-v*` workflow's Play step, or local
|
||||
`gradlew publishGooglePlayReleaseBundle`). Manual UI uploads work without this.
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
### 4. GitHub Actions secrets
|
||||
|
||||
In the repo: **Settings > Secrets and variables > Actions > New repository
|
||||
secret.** Add all four (see the table in "Required GitHub Secrets" below).
|
||||
secret.** Add all four (see the table in "Required Android Release Secrets"
|
||||
below).
|
||||
|
||||
If `HERMES_KEYSTORE_BASE64` is missing, CI release builds fall back to
|
||||
debug signing and print a warning in the workflow summary — those
|
||||
@@ -288,21 +350,20 @@ is a statement to users that "this is a thing worth updating to," so
|
||||
the threshold is intent-driven, not event-driven.
|
||||
|
||||
If you want to dogfood accumulated `main` state without declaring GA,
|
||||
tag a **pre-release** (`vX.Y.Z-rc.N`). Users can opt in via
|
||||
tag a **pre-release** (`android-vX.Y.Z-rc.N`). Users can opt in via
|
||||
`hermes-relay-update --branch rc/vX.Y.Z-rc.N` without being auto-pushed
|
||||
the unstable build.
|
||||
|
||||
## Release Process
|
||||
|
||||
### 1. Bump the version (atomic across all three sources)
|
||||
### 1. Bump the Android app version
|
||||
|
||||
Use `scripts/bump-version.sh` — it rewrites `libs.versions.toml`,
|
||||
`pyproject.toml`, AND `plugin/relay/__init__.py::__version__` in lockstep,
|
||||
increments `appVersionCode` monotonically, and runs a sanity check. Don't
|
||||
edit the files by hand; drift is silent and painful.
|
||||
Use `scripts/bump-android-version.sh`. It rewrites
|
||||
`gradle/libs.versions.toml`, increments `appVersionCode` monotonically,
|
||||
and runs a sanity check. Don't edit the Android version files by hand.
|
||||
|
||||
```bash
|
||||
bash scripts/bump-version.sh 0.3.0
|
||||
bash scripts/bump-android-version.sh 0.6.2
|
||||
```
|
||||
|
||||
Confirm the bump:
|
||||
@@ -311,11 +372,17 @@ Confirm the bump:
|
||||
scripts\dev.bat version
|
||||
```
|
||||
|
||||
The script's diff output should show exactly three files changed and all
|
||||
three carrying the new version string.
|
||||
The script's diff output should show `gradle/libs.versions.toml` carrying
|
||||
the new app version and a higher `appVersionCode`.
|
||||
|
||||
### 2. Update release notes and changelog
|
||||
|
||||
> 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:
|
||||
@@ -338,15 +405,49 @@ three carrying the new version string.
|
||||
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.
|
||||
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.
|
||||
|
||||
#### 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.
|
||||
|
||||
### 3. Build and verify locally
|
||||
|
||||
```bat
|
||||
scripts\dev.bat bundle
|
||||
keytool -list -printcert -jarfile app\build\outputs\bundle\googlePlayRelease\hermes-relay-*-googlePlay-release.aab
|
||||
keytool -printcert -jarfile app\build\outputs\bundle\googlePlayRelease\hermes-relay-*-googlePlay-release.aab
|
||||
```
|
||||
|
||||
The `keytool` output must show your release certificate (the CN/OU/O
|
||||
@@ -366,7 +467,7 @@ Optional device smoke test: `scripts\dev.bat release` then
|
||||
### 4. Commit on `dev`, merge to `main`, tag from `main`
|
||||
|
||||
The release-prep commit lands on `dev` first. Then a release PR merges
|
||||
`dev` → `main` with `--no-ff`, and the `v<version>` tag is cut from the
|
||||
`dev` → `main` with `--no-ff`, and the `android-v<version>` tag is cut from the
|
||||
resulting merge commit on `main`:
|
||||
|
||||
```bash
|
||||
@@ -374,55 +475,117 @@ resulting merge commit on `main`:
|
||||
git checkout dev
|
||||
git pull --ff-only origin dev
|
||||
|
||||
git add gradle/libs.versions.toml pyproject.toml plugin/relay/__init__.py \
|
||||
RELEASE_NOTES.md CHANGELOG.md \
|
||||
git add gradle/libs.versions.toml RELEASE_NOTES.md CHANGELOG.md \
|
||||
app/src/main/assets/whats_new.txt docs/play-store-listing.md
|
||||
git commit -m "release: v0.3.0"
|
||||
git commit -m "release(android): android-v0.6.2"
|
||||
git push origin dev
|
||||
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from the new main tip:
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag v0.3.0
|
||||
git push origin v0.3.0
|
||||
git tag android-v0.6.2
|
||||
git push origin android-v0.6.2
|
||||
```
|
||||
|
||||
Pushing a tag matching `v*` triggers `.github/workflows/release.yml`,
|
||||
Pushing a tag matching `android-v*` triggers `.github/workflows/release-android.yml`,
|
||||
which builds, signs, checksums, and creates a GitHub Release. Watch the
|
||||
run under the **Actions** tab.
|
||||
|
||||
> **Why all three files in the commit?** See "The three version sources"
|
||||
> above — `bump-version.sh` rewrites them atomically, so they must be
|
||||
> staged + committed atomically too. Missing one creates the same drift
|
||||
> the script was built to prevent.
|
||||
Plugin/Python version files are intentionally not part of an Android app
|
||||
release unless the plugin package itself is also being released.
|
||||
|
||||
### Plugin / Python package release
|
||||
|
||||
Use this when plugin or relay behavior changes independently of Android app
|
||||
delivery, for example CLI channel support, bridge routes, pairing server fixes,
|
||||
voice auth, dashboard plugin UI, or packaging changes.
|
||||
|
||||
First **rewrite `PLUGIN_RELEASE_NOTES.md`** — it is the GitHub Release body for
|
||||
`plugin-v*` tags (the same role `RELEASE_NOTES.md` plays for Android). Fill the
|
||||
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.
|
||||
|
||||
```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
|
||||
git commit -m "release(plugin): plugin-v0.6.2"
|
||||
git push origin dev
|
||||
|
||||
# Open the release PR (dev -> main) and merge with --no-ff.
|
||||
# After merge, tag from the new main tip:
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
git tag plugin-v0.6.2
|
||||
git push origin plugin-v0.6.2
|
||||
```
|
||||
|
||||
Pushing `plugin-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.
|
||||
|
||||
### 5. Upload to Play Console
|
||||
|
||||
**Manual upload (default):**
|
||||
> **If `PLAY_SERVICE_ACCOUNT_JSON` is configured as a repo secret, this step is
|
||||
> automated for stable tags.** The release workflow runs
|
||||
> `publishGooglePlayReleaseBundle --track=production` and the build appears as a
|
||||
> Production **draft** — skip to the Play Console, confirm the draft, and click
|
||||
> **Start rollout**. The manual path below is the fallback when the secret is
|
||||
> unset (or for staging on a non-production track).
|
||||
>
|
||||
> This automated tag 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.
|
||||
|
||||
**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:**
|
||||
|
||||
1. Download the file ending in `-googlePlay-release.aab` from the GitHub
|
||||
Release assets (for example, `hermes-relay-0.3.0-googlePlay-release.aab`),
|
||||
Release assets (for example, `hermes-relay-1.0.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: **Release > Testing > Internal testing** (the 14-day
|
||||
closed-testing rule does NOT apply to this account — see "Google Play
|
||||
Console developer account" above).
|
||||
2. In Play Console, open the track you chose above — for a GA that's
|
||||
**Release > Production**.
|
||||
3. **Create new release** > upload the AAB.
|
||||
4. Paste `RELEASE_NOTES.md` into the release notes field.
|
||||
5. **Review release** > **Start rollout.**
|
||||
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).
|
||||
|
||||
**Automated upload (if `play-service-account.json` is configured):**
|
||||
|
||||
```bat
|
||||
scripts\dev.bat bundle
|
||||
gradlew publishReleaseBundle
|
||||
gradlew publishReleaseBundle --track=production
|
||||
```
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
To promote an existing release between tracks without rebuilding:
|
||||
|
||||
@@ -430,18 +593,24 @@ To promote an existing release between tracks without rebuilding:
|
||||
gradlew promoteReleaseArtifact --from-track=internal --promote-track=alpha
|
||||
```
|
||||
|
||||
### 6. Promote through tracks
|
||||
### 6. Tracks (a menu, not a mandatory ladder)
|
||||
|
||||
Typical path:
|
||||
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:
|
||||
|
||||
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
|
||||
- **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.
|
||||
|
||||
Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
|
||||
If you *do* stage through tracks, promote an existing release without rebuilding via
|
||||
the Play Console UI or:
|
||||
|
||||
```bat
|
||||
gradlew promoteReleaseArtifact --from-track=internal --promote-track=production
|
||||
```
|
||||
|
||||
### 7. After release
|
||||
|
||||
@@ -451,9 +620,9 @@ Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
|
||||
`RELEASE_NOTES.md` this will already be baked in. If for some reason
|
||||
it's missing, edit the body with:
|
||||
```bash
|
||||
gh release view vX.Y.Z --repo Codename-11/hermes-relay --json body --jq .body > /tmp/body.md
|
||||
gh release view android-vX.Y.Z --repo Codename-11/hermes-relay --json body --jq .body > /tmp/body.md
|
||||
# edit /tmp/body.md to add/fix the Download section
|
||||
gh release edit vX.Y.Z --repo Codename-11/hermes-relay --notes-file /tmp/body.md
|
||||
gh release edit android-vX.Y.Z --repo Codename-11/hermes-relay --notes-file /tmp/body.md
|
||||
```
|
||||
(This step was only needed as a retrofit for v0.1.0 — v0.1.1+ inherit
|
||||
the Download section automatically from `RELEASE_NOTES.md`.)
|
||||
@@ -462,23 +631,52 @@ Promote via the Play Console UI or `gradlew promoteReleaseArtifact`.
|
||||
|
||||
## CI Behavior
|
||||
|
||||
On every push of a tag matching `v*`, `.github/workflows/release.yml`:
|
||||
Android, Plugin, 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.
|
||||
|
||||
On every push of a tag matching `android-v*`, `.github/workflows/release-android.yml`:
|
||||
|
||||
1. Validates the tag matches `appVersionName` in
|
||||
`gradle/libs.versions.toml` (mismatches fail the workflow).
|
||||
2. Runs `./gradlew assembleDebug` and `./gradlew test`.
|
||||
2. Runs the Android debug build and the stable sideload pairing/connection
|
||||
regression slice with explicit timeouts.
|
||||
3. Decodes `HERMES_KEYSTORE_BASE64` into `$RUNNER_TEMP/release.keystore`
|
||||
and exports `HERMES_KEYSTORE_PATH` (skipped if the secret is unset).
|
||||
4. Builds both artifacts: `./gradlew bundleRelease assembleRelease`.
|
||||
4. Builds both Android release artifacts:
|
||||
`./gradlew bundleRelease assembleRelease`.
|
||||
5. Generates `SHA256SUMS.txt` covering both.
|
||||
6. Creates a GitHub Release named `v<version>` with `RELEASE_NOTES.md` as
|
||||
6. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
|
||||
the body. Attaches the APK, AAB, and `SHA256SUMS.txt`. Tags any version
|
||||
containing a dash (e.g. `v0.2.0-beta.1`) as a prerelease automatically.
|
||||
containing a dash (e.g. `android-v0.2.0-beta.1`) as a prerelease automatically.
|
||||
7. Prints a `$GITHUB_STEP_SUMMARY` showing whether release signing
|
||||
succeeded. If `HERMES_KEYSTORE_BASE64` is missing, the summary warns
|
||||
that the artifacts are debug-signed and unsuitable for Play Store.
|
||||
|
||||
## Required GitHub Secrets
|
||||
On every push of a tag matching `plugin-v*`,
|
||||
`.github/workflows/release-plugin.yml`:
|
||||
|
||||
1. Validates the tag matches all plugin-owned version metadata checked by
|
||||
`scripts/check-plugin-version-sync.py`.
|
||||
2. Runs plugin syntax checks and the focused route/auth/session test slice.
|
||||
3. Builds the Python wheel and sdist with `python -m build`.
|
||||
4. Generates `dist/SHA256SUMS.txt`.
|
||||
5. Creates a GitHub Release named `Hermes-Relay-Plugin v<version>` with the wheel,
|
||||
sdist, and checksum file attached.
|
||||
|
||||
On every push of a tag matching `cli-v*`,
|
||||
`.github/workflows/release-cli.yml` builds and publishes the CLI binaries and
|
||||
Windows tray installer. Its GitHub Release body comes from `CLI_RELEASE_NOTES.md`
|
||||
(rewritten per release — the CLI counterpart of `RELEASE_NOTES.md`); the workflow
|
||||
substitutes `__VERSION__` (bare, e.g. `0.3.0`) and `__TAG__` (full, e.g.
|
||||
`cli-v0.3.0`) so the install/pin commands stay accurate. Fill its Summary and
|
||||
Added/Changed/Fixed groups at CLI release-prep and apply the §2 public scrub.
|
||||
Dashboard-only changes are covered by
|
||||
`.github/workflows/ci-dashboard.yml`, which builds the dashboard plugin,
|
||||
runs the dashboard API tests, and verifies the modal CSS markers are present
|
||||
in the built bundle.
|
||||
|
||||
## Required Android Release Secrets
|
||||
|
||||
| Secret | Purpose | How to populate |
|
||||
|-----------------------------|-------------------------------------|--------------------------------------------------|
|
||||
@@ -486,37 +684,52 @@ On every push of a tag matching `v*`, `.github/workflows/release.yml`:
|
||||
| `HERMES_KEYSTORE_PASSWORD` | Store password | Password set during `keytool -genkey` |
|
||||
| `HERMES_KEY_ALIAS` | Key alias | Alias set during `keytool -genkey` |
|
||||
| `HERMES_KEY_PASSWORD` | Key password | Usually the same as the store password |
|
||||
| `PLAY_SERVICE_ACCOUNT_JSON` | **Optional** — Play auto-upload | Paste the full Play Developer API service-account JSON (step 3) |
|
||||
|
||||
If `PLAY_SERVICE_ACCOUNT_JSON` is set, the `android-v*` release workflow uploads
|
||||
the `googlePlay` AAB to the **Production track as a DRAFT** automatically (stable
|
||||
tags only — prereleases are skipped). CI does the upload; you still click **Start
|
||||
rollout** in Play Console. If the secret is unset, the workflow skips the upload
|
||||
and you upload manually (§5) — nothing else changes.
|
||||
|
||||
## Hotfix Recipe
|
||||
|
||||
When production has a bug and you need to ship a fix without picking up
|
||||
unreleased work from `dev`:
|
||||
unreleased work from `dev`, branch from the affected release tag and only
|
||||
bump the version source for the surface you are shipping.
|
||||
|
||||
1. `git checkout -b fix/short-name v0.1.0` — branch from the released
|
||||
tag (not from `main` or `dev`).
|
||||
For an Android app hotfix:
|
||||
|
||||
1. `git checkout -b fix/short-name android-v0.6.1` — branch from the released
|
||||
Android tag (not from `main` or `dev`).
|
||||
2. Apply the fix, add a test, commit.
|
||||
3. Bump `appVersionName` and `appVersionCode` in
|
||||
`gradle/libs.versions.toml` (and the other two version sources via
|
||||
`scripts/bump-version.sh`).
|
||||
4. Update `RELEASE_NOTES.md` and `CHANGELOG.md`.
|
||||
3. Run `bash scripts/bump-android-version.sh 0.6.2` to update
|
||||
`gradle/libs.versions.toml`.
|
||||
4. Update `RELEASE_NOTES.md`, `CHANGELOG.md`, in-app What's New, and Play
|
||||
listing notes as needed.
|
||||
5. Open a PR from `fix/short-name` into `main`, merge with `--no-ff`.
|
||||
6. `git tag v0.1.1` from the new `main` tip and `git push origin v0.1.1`
|
||||
— CI builds and publishes.
|
||||
6. `git tag android-v0.6.2` from the new `main` tip and `git push origin android-v0.6.2`
|
||||
so Android release CI builds and publishes.
|
||||
7. Upload to Play Console as normal.
|
||||
8. Merge `main` back into `dev` (`git checkout dev && git merge --no-ff main`)
|
||||
so `dev` picks up the hotfix and the version bumps. Without this,
|
||||
`dev`'s `appVersionCode` lags behind `main` and the next release
|
||||
so `dev` picks up the hotfix and the versionCode bump. Without this,
|
||||
`dev`'s `appVersionCode` lags behind `main` and the next app release
|
||||
bump collides.
|
||||
|
||||
For a Plugin hotfix, branch from the affected `plugin-v*` tag, apply
|
||||
the fix, run `bash scripts/bump-plugin-version.sh <next-version>`, merge to
|
||||
`main`, and tag `plugin-v<next-version>`. Do not touch
|
||||
`gradle/libs.versions.toml` unless an Android app release is also shipping.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`Tag version (X) does not match appVersionName (Y)` in CI validate step**
|
||||
You pushed a tag before bumping `gradle/libs.versions.toml`, or vice versa.
|
||||
Fix: update the file, commit, delete the remote tag
|
||||
(`git push --delete origin vX`), re-tag, and push again.
|
||||
(`git push --delete origin android-vX`), re-tag, and push again.
|
||||
|
||||
**Play Console rejects the AAB as debug-signed**
|
||||
Run `keytool -list -printcert -jarfile <aab>` locally — if it shows
|
||||
Run `keytool -printcert -jarfile <aab>` locally — if it shows
|
||||
`CN=Android Debug`, fix `local.properties` for local builds or
|
||||
`HERMES_KEYSTORE_BASE64` for CI. For CI, check the workflow summary; if it
|
||||
says "Debug-signed", one of the four `HERMES_*` secrets is missing or the
|
||||
|
||||
@@ -1,84 +1,66 @@
|
||||
# Hermes-Relay v0.6.0
|
||||
# Hermes-Relay-Android v1.2.0
|
||||
|
||||
**Release Date:** April 18, 2026
|
||||
**Since v0.5.1:** Multi-server pairing, agent profile discovery + picker, consolidated agent sheet, unified relay UI state machine, and the dashboard pairing schema fix
|
||||
**Release Date:** June 20, 2026
|
||||
**Since v1.1.0:** A big personalization release — app themes, swappable sphere skins, and animated agent **pets** — paired with a transparency pass (see which transport you're on and exactly what the agent is told), a much faster cold start, in-app crash reporting, and a broad reliability sweep.
|
||||
|
||||
> **The multi-connection release.** Pair with several Hermes servers and switch in one tap. The top bar shows a Connection chip (hidden when you only have one); the new Settings → Connections screen manages them all. Per-Connection state is fully isolated — sessions, memory, personalities, skills, profiles, relay URL, cert pin, voice endpoints, last-active session. Existing single-server installs migrate transparently on first launch.
|
||||
>
|
||||
> Plus agent Profiles — the relay auto-discovers upstream Hermes profile directories under `~/.hermes/profiles/*/` and advertises them in `auth.ok`. Pick one from the new consolidated agent sheet (tap the agent name in the Chat top bar) and the phone overlays `model` + `SOUL.md` on every chat turn.
|
||||
v1.2.0 is about making Hermes-Relay feel like *yours* and making it honest about what it's doing. Dress the app in one of eight themes, swap the agent orb for a hand-picked or AI-generated **pet** that reacts to what the agent is doing, and give each profile its own icon. At the same time, the chat status strip now names the actual streaming path (⚡ Gateway, 📡 Sessions, …), a "What the agent sees" sheet shows the exact extra context prepended to your next turn, and cold start is roughly three times faster. If something does go wrong, the app now catches the crash and offers a one-tap, pre-filled bug report.
|
||||
|
||||
---
|
||||
|
||||
## 📥 Download
|
||||
## Download
|
||||
|
||||
v0.6.0 ships in **two build flavors**. APK filenames are version-tagged:
|
||||
v1.2.0 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
|
||||
| Flavor | File | Who it's for |
|
||||
|---|---|---|
|
||||
| **sideload** (recommended) | `hermes-relay-0.6.0-sideload-release.apk` | Full feature set — bridge channel, voice intents, unattended access, vision-driven `android_navigate`. Installs alongside the Play build with a `.sideload` applicationId. |
|
||||
| **Google Play** | `hermes-relay-0.6.0-googlePlay-release.aab` | Conservative feature set (chat, voice, safety rails — no agent device control) to match Play Store's Accessibility policy. |
|
||||
| googlePlay APK | `hermes-relay-0.6.0-googlePlay-release.apk` | Parity + diff tooling — not the primary download. |
|
||||
| sideload AAB | `hermes-relay-0.6.0-sideload-release.aab` | Parity + diff tooling — not the primary download. |
|
||||
| Google Play | `hermes-relay-1.2.0-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
|
||||
| sideload | `hermes-relay-1.2.0-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-1.2.0-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-1.2.0-sideload-release.aab` | Parity/testing artifact. |
|
||||
|
||||
**Verify integrity** with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for install steps.
|
||||
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
## Highlights
|
||||
|
||||
### Multi-server Connections
|
||||
### Make it yours
|
||||
|
||||
- **Pair with several Hermes servers, switch in one tap.** New Connection chip on the Chat top bar opens a switcher sheet with per-server health indicators. Tapping a connection cancels in-flight chat, disconnects the old relay, rebinds to the new server, and reloads sessions + personalities + profiles in one coordinated context swap. Hidden automatically when only one Connection is configured.
|
||||
- **Connections management screen.** Settings → Connections lists every paired server as a card with inline **Rename / Re-pair / Revoke / Remove**. Add a new Connection from the same screen — launches the QR pairing wizard. The active card shows **live** WSS state (Connected / Reconnecting… / Stale — tap to reconnect) instead of a static "Paired N minutes ago" timestamp.
|
||||
- **Per-Connection isolation.** Sessions, memory, personalities, skills, profiles, relay URL + cert pin, voice endpoints, last-active session are all scoped per-Connection. Theme, bridge safety preferences, TOFU cert-pin map, and notification companion state stay global.
|
||||
- **Transparent migration.** Existing single-server installs become their first Connection automatically on first v0.6.0 launch — zero re-pair, zero token migration, zero data loss. Rename it at Settings → Connections whenever you like. See `docs/decisions.md` §19.
|
||||
- **App themes.** A theme picker in Settings → Appearance ships eight looks — the signature Hermes Relay brand (full light/dark) plus ports of the Nous Hermes baselines: Hermes Teal, Nous Blue, Midnight, Ember, Mono, Cyberpunk, and Rosé. The whole app follows your choice; Light/Dark/Auto applies to themes that ship both modes.
|
||||
- **Agent pets — a living avatar.** Replace the orb with an animated pet that reacts to the agent: idle / thinking / writing / speaking / listening, a distinct **working** pose during tool calls, one-shot **greet** and **celebrate** reactions, and a loop that speeds up as output streams. Add or remove pets right in Appearance (no `adb`), preview each state, tune playback speed, and toggle frame auto-stabilization. Pets are pure data — an AI authoring kit and JSON schema let you generate one from sprite art.
|
||||
- **Hot-swappable sphere skins + per-profile icons.** Keep the orb but reskin it (Adaptive, Classic, Aurora, Solar, Mono, or your own JSON skin), and give each agent profile its own small icon beside its name — all client-side, never sent to Hermes.
|
||||
|
||||
### Agent Profiles
|
||||
### See what's actually happening
|
||||
|
||||
- **Relay auto-discovers upstream profiles.** The relay walks `~/.hermes/profiles/*/`, reads each profile's `config.yaml` + `SOUL.md`, and advertises the list in `auth.ok` as `{name, model, description, system_message}`. Plus a synthetic `default` entry for the root config so there's always something to pick.
|
||||
- **One-tap overlay on chat turns.** Pick a profile from the agent sheet; the phone overlays the request's `model` and `system_message` on every subsequent chat turn. Selection is ephemeral — resets on Connection switch. Gated by `RELAY_PROFILE_DISCOVERY_ENABLED=1` (default on) so operators can disable it if needed.
|
||||
- **Three-layer agent model.** Connection (server) → Profile (agent directory on that server) → Personality (system-prompt preset within the agent's config). Picking a Connection resets Profile because Profile is server-scoped. Documented in `docs/spec.md`, `docs/decisions.md` §8 / §19 / §21, and `user-docs/features/{connections,profiles,personalities}.md`.
|
||||
- **Transport path is visible.** The chat status strip now shows which streaming path is in use — ⚡ Gateway (live thinking), 📡 Sessions, Completions, or Runs — and Chat Settings adds a basic→best tier ladder explaining the active path and its fallback.
|
||||
- **"What the agent sees" sheet.** Tap the context meter to see the exact extra context prepended to your next turn — persona/profile, phone status, any per-turn voice hint, and (when paired) the relay's own server-side context. The audit is honest about what the phone sends versus what's applied on the server.
|
||||
- **Spoken-turn badges + voice render-path visibility.** Voice and Realtime Agent replies carry a chip in the scrollback, and Voice Settings shows whether speech is rendering over the streaming or basic path.
|
||||
|
||||
### Consolidated agent sheet + Active Agent card
|
||||
### Privacy
|
||||
|
||||
- **One tap instead of two chips.** The standalone `ProfilePicker` / `PersonalityPicker` top-bar chips are gone. Tap the agent name in the middle of the Chat top bar to open a scrollable bottom sheet holding Profile selection, Personality selection, and session info + analytics (message count, tokens in/out, avg TTFT). Toast confirmations fire on both kinds of switch.
|
||||
- **"Active Agent" card at the top of Settings** — summarizes the current Connection / Profile / Personality. Tapping it navigates to Chat with the agent sheet auto-opened (`openAgentSheet` nav arg), closing the "how do I change my agent" discoverability gap for Settings-originating users.
|
||||
- **Sensitive-media blur.** When paired to the relay, the agent can mark private/NSFW media and the phone blurs it per your setting — sensitivity stays model-emitted (no on-device or relay-side classifier), and the exact instruction is visible in the "What the agent sees" sheet. Vanilla Hermes (no plugin) is unaffected.
|
||||
|
||||
### Unified relay UI state machine
|
||||
### Faster, calmer, more honest
|
||||
|
||||
- **One source of truth across three screens.** `SettingsScreen`, `ConnectionSettingsScreen`, and the Connections list used to each resolve relay status independently, sometimes disagreeing on what state the WSS was actually in (e.g. Settings card red/Disconnected while the sub-screen read amber/Reconnecting for the same moment). State resolution now lives on `ConnectionViewModel.relayUiState: StateFlow<RelayUiState>` with five well-defined cases (`NotConfigured` / `Connected` / `Connecting` / `Stale` / `Disconnected`). Every screen maps it onto the existing `ConnectionStatusRow`.
|
||||
- **5 s grace window before Stale.** A Paired-but-Disconnected pose (common during the WSS handshake on cold start or resume) renders as **Connecting…** for the first 5 seconds. If the handshake completes in that window, you never see red flicker. If it doesn't, the row promotes to **Stale — tap to reconnect** with amber styling and a reconnect action on any surface that renders the row.
|
||||
- **Settings "Connection" card → "Active Connection".** Renamed with the current Connection's label as subtitle, so multi-connection installs can see at a glance which server the status rows describe. Kicks `reconnectIfStale()` on first compose so the Relay row doesn't flash red before the lifecycle observer's resume path lands.
|
||||
- **~3× faster cold start.** The app was building several hardware-keystore-encrypted stores at launch, serializing on a process-global lock and stalling the chat header for seconds. It now builds a single keyset shared with the dashboard cookies, cutting measured time-to-connected from ~2.9 s to ~1 s. Existing sign-ins migrate automatically.
|
||||
- **Honest loading, never stale.** Model, personality, and approvals show a brief "checking…" state and fade in once the server confirms them; standard controls (Model, YOLO, Fast, reasoning effort) always appear — live when ready, "checking…" while loading, or cleanly disabled with the reason — instead of being hidden or showing a maybe-wrong value.
|
||||
|
||||
### Pairing wizard polish
|
||||
### More reliable
|
||||
|
||||
- **Field-scheme cross-validation.** The wizard's manual-entry + show-code forms now detect "obviously wrong scheme in the wrong field" immediately — `wss://` in the API URL box, `http://` in the Relay URL box, etc. Shows an inline hint with a helpful fix message. Matches the pairing-QR-override check on the dashboard's Pair dialog.
|
||||
- **Pair-stamp on the active Connection.** Successful auth now stamps the active Connection's pairing metadata (paired-at, transport hint, expiry) in place, so a re-pair from Settings doesn't leave stale state on the card. Closes the "Connections list says Not paired while Settings says Paired" bug.
|
||||
- **In-app crash reporting.** A force-close now surfaces a clean dialog on next launch with the stack trace — Copy it, or **Report** to open a pre-filled GitHub issue from the bug template. The handler re-raises so Play vitals still record the crash.
|
||||
- **QR pairing hardened for foldables.** On devices where the camera can't initialize, the scanner shows a "camera unavailable — pair manually" card instead of force-closing.
|
||||
- **Crash fixes.** No more crash opening a chat with a server-local image, and the PDF viewer no longer crashes when a document closes mid-render.
|
||||
- **Chat correctness.** In-chat model picks now actually apply — both on a new chat and mid-conversation — server-side turn errors stay on screen as an error bubble, per-reply token counts and provenance badges survive the post-turn reload, and server steering markers (`[System: …]`) no longer appear as chat bubbles.
|
||||
|
||||
### Voice & terminal polish
|
||||
|
||||
- **Enhanced voice control (Gemini & xAI).** When the relay uses a Gemini or xAI voice provider, Voice Settings can pick a voice/model and turn on expressive tone/speech tags. Vanilla Hermes voice stays configured server-side.
|
||||
- **Leaner terminal.** A scrollable, fully-legible key bar, TUI-correct arrows and bracketed paste, a compact single-row header, and relay sessions on an isolated, TUI-tuned tmux so editors and full-screen tools behave.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Fixes
|
||||
## Upgrade notes
|
||||
|
||||
- **`POST /pairing/mint` emits the correct wire format.** Dashboard-minted QRs were unscannable — the relay endpoint put the freshly-minted pairing code in top-level `key` and defaulted the top-level port to the relay's own `8767` instead of the Hermes API server's `8642`, so phones silently failed to pair because the API server URL pointed at the relay and the `relay` block had no URL / code for the WSS handshake. The `hermes-pair` CLI and `/hermes-relay-pair` skill were unaffected. `handle_pairing_mint` now mirrors `pair.py`'s CLI path — top-level = API server (default `:8642`, LAN-resolved), `relay.{url,code}` auto-derived from the relay's own bind config. Regression test at `plugin/tests/test_pairing_mint_schema.py` (8 cases) pins the shape against the Android parser.
|
||||
- **Dashboard Relay Management tab no longer crashes on paired-session list.** The dict-shaped `s.grants` (`{chat, terminal, bridge}`) was being rendered as a React child, tripping minified error #31. Now `Object.keys(s.grants)` so Badge children are always channel-name strings.
|
||||
- **Status badge multi-line rows.** `ConnectionStatusBadge` top-aligns cleanly on multi-line rows (was vertically centered and drifted off-center when the label wrapped — `Session` tests in the Settings card used to squeeze to one character per line when the error message was a full sentence).
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Verification checklist (post-install)
|
||||
|
||||
- Pair with two different Hermes servers. Confirm the Connection chip appears in the Chat top bar and switching cancels in-flight chat + disconnects / reconnects the relay cleanly.
|
||||
- Rename a Connection in Settings → Connections, then verify the rename persists across an app restart and shows in the top-bar chip.
|
||||
- With a paired session, force-kill the relay process. The Connections list active card should show "Stale — tap to reconnect" within ~5 seconds with a Reconnect button tinted amber. Tap it; Toast "Reconnecting to relay…" fires immediately and the row flips to Connecting then Connected once the relay is back.
|
||||
- Settings → Active Agent card shows Connection / Profile / Personality. Tap it; lands in Chat with the agent sheet open. Pick a different Profile; Toast confirms. Send a message; the response reflects the profile's `SOUL.md`.
|
||||
- Dashboard's "Pair new device" button mints a scannable QR. The Relay Management tab renders paired devices with grant badges (no React #31 crash).
|
||||
|
||||
## 🧩 Known — test suite deferred
|
||||
|
||||
- `ConnectionStoreTest` (11 tests) is `@Ignore`'d pending a `ConnectionStore` scope-injection refactor. The tests race against `ConnectionStore.init`'s hydrate coroutine on `Dispatchers.Default` (reads `dataStore.data.first()` on a real dispatcher vs. `runTest`'s `TestScope`). Not a user-visible bug — cold-start + mutation don't fire in the same tick in the real app. The 8 previously-deferred VoicePlayer tests from v0.5.1 are also still `@Ignore`'d. Follow-up PR will land both fixes together once the separate-source-set test infra is in place. On-device smoke testing (by Bailey, Samsung) validated feature behavior.
|
||||
|
||||
See `CHANGELOG.md` for the full file-level diff and `DEVLOG.md` for the per-feature session narratives.
|
||||
|
||||
---
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
- Cold-start speedup migrates the encrypted credential and dashboard-cookie stores automatically on first launch; in rare cases Manage/voice may ask for a one-time re-login (cookies are re-obtainable).
|
||||
- App themes, sphere skins, and pets are available on **both** flavors — they're client-side and need no Device Control.
|
||||
- `appVersionCode` is **14**.
|
||||
|
||||
@@ -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: `desktop-v*` (separate cadence from Android `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).
|
||||
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).
|
||||
|
||||
**Shipped (2026-04-23 — first tagged release `desktop-v0.3.0-alpha.1`):**
|
||||
|
||||
@@ -30,6 +30,10 @@ Release tags: `desktop-v*` (separate cadence from Android `v*`). Curl-installed
|
||||
|
||||
**Active — `desktop-v0.3.0-alpha.7` (native image paste):** Plan at [`docs/plans/2026-04-23-desktop-alpha-7-native-paste.md`](docs/plans/2026-04-23-desktop-alpha-7-native-paste.md). Two-repo workstream: client slash commands `/paste` (clipboard), `/screenshot` (primary display), `/image <path>` (file) land in `hermes-relay chat`, each echoes a one-line feedback and attaches the image to the next `prompt.submit` so the vision-capable model sees it in the same turn — parity with Claude Desktop's paste UX minus OS-level Ctrl+V (terminals don't pipe image bytes to stdin). Client half is new `desktop/src/chatAttach.ts` + slash-command branches in `desktop/src/commands/chat.ts`. Server half is ONE new `@method("image.attach.bytes")` on the fork's `tui_gateway/server.py` (branch `feat/image-attach-bytes` → merged to `axiom`); the fork's existing `_enrich_with_attached_images` already handles multimodal payload plumbing and session-scoped image state, so this release is almost entirely about bridging client-captured bytes to server-side state that's been there for months. Relay channel unchanged — `tui` is a transparent RPC forwarder. Graceful fallback when hermes-host hasn't been updated yet: client catches `method not found`, prints a pointer at the axiom rollout, REPL stays alive.
|
||||
|
||||
**Active — desktop control / computer-use:** Enhanced plan at [`docs/plans/desktop-control-computer-use-enhanced.md`](docs/plans/desktop-control-computer-use-enhanced.md); earlier MVP implementation record at [`docs/plans/desktop-computer-use-mvp.md`](docs/plans/desktop-computer-use-mvp.md). Windows now has the first Tauri tray/overlay app as the primary Easy/Standard install surface: pair, start/pause daemon, Devices/Revoke, Task Log, Settings, overlay status chip, emergency stop, and bundled CLI sidecar. The existing CLI and daemon remain the primary advanced/headless surface. `desktop_computer_*` schemas are registered on the normal desktop tool channel but advertised only behind the explicit experimental computer-use flag. Host input still requires desktop-tool consent plus a visible, task-scoped assist/control grant; there is no unrestricted or silent mouse/keyboard automation.
|
||||
|
||||
**Desktop control UX direction:** Tauri v2 (Rust + static web UI) is the native shell for the polished Easy-tier experience: tray icon, always-visible overlay chip, task log, settings, and one-click pause/emergency stop. Easy tier pairs once, shows a connected/observing chip, and exposes Devices / Revoke / Task Log / Settings / Emergency Stop from the tray. Standard tier adds full tray management; Advanced tier remains CLI + daemon + JSON policy (`~/.hermes/desktop-control.json`) for operators. The default policy baseline blocks password managers, credential prompts, banking/payment/crypto surfaces, OS security/admin settings, and private-key/token material until locally overridden.
|
||||
|
||||
**Deferred to alpha.8 / alpha.9 / v1.0:**
|
||||
|
||||
- **Per-project session stickiness** — blocked on hermes-agent plugin hook that consumes the workspace envelope; premature until the envelope shape stabilizes in use.
|
||||
@@ -38,17 +42,18 @@ Release tags: `desktop-v*` (separate cadence from Android `v*`). Curl-installed
|
||||
- **Environment-variable passthrough** — security-sensitive; needs per-var prompt UX + threat model before shipping.
|
||||
- **Global hotkey to summon a prompt** — OS-specific helper installers; out of scope for binary-only release.
|
||||
- **Watch mode** (`hermes-relay daemon --watch`) — needs a DSL and clear safety bounds; own feature branch.
|
||||
- **Native assist/control grant modal hardening** — the tray-managed daemon now has a local grant bridge and Grant Requests view. Next pass should polish native modal behavior, notification routing, and multi-client grant ownership.
|
||||
- **Kitty / iTerm2 inline image protocols for paste feedback** — would show a thumbnail of the attached image directly in the terminal after `/paste` instead of a plain text line. Most terminals don't support them; the slash-command feedback line works anywhere. Revisit if users request it.
|
||||
|
||||
**Earlier alpha.2–alpha.5 workstreams (now in-flight / done — see DEVLOG 2026-04-23 entries for specifics):**
|
||||
|
||||
- **`hermes-relay update` subcommand + auto-update nudge.** The binary today does NOT self-update — users have to re-run the `curl | sh` / `irm | iex` one-liner to pick up a new release. Close the gap: `hermes-relay update` polls GitHub Releases API (`/repos/Codename-11/hermes-relay/releases/latest`), 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.
|
||||
- **`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.
|
||||
- **Workspace-awareness — desktop client sends cwd/git/hostname on connect.** Biggest lingering "is the agent working against the right tree?" problem. On WSS auth, the client advertises an ephemeral workspace descriptor — `cwd`, `git_root`, `git_branch`, `git_status_summary` (staged/modified counts), `repo_name`, `hostname`, `platform`, `active_shell`. Server-side `DesktopHandler` stashes it as live session metadata (NOT persistent state). New hermes-agent plugin hook injects a one-line ephemeral prompt prefix into the session context — *"Active desktop workspace: machine=Bailey-PC · repo=hermes-relay · branch=dev · staged=3"* — so the LLM reads it every turn without the operator having to explain. Also default `desktop_terminal` / `desktop_read_file` / `desktop_search_files` `cwd` to the repo root when unset. Expose the snapshot in `hermes-relay doctor` + `hermes-relay status` + a new `hermes-relay workspace` subcommand + a relay dashboard tab so both operator and agent have a common view. Pair with a `.hermes/workspace-context.json` file-based fallback for when the socket path can't be reached. Requires: new WSS envelope (`desktop.workspace` on connect), hermes-agent plugin hook for ephemeral context injection, schema coordination with the upstream `ContextVar` multi-client work.
|
||||
- **Service installers** — `scripts/install-service-{win,linux,mac}.{ps1,sh}` — Windows Service via `sc.exe create`, `systemd --user` unit with `loginctl enable-linger`, `launchctl load` plist for macOS. Auto-start on login so the daemon is always reachable.
|
||||
- **Multi-client routing on the `desktop` channel** — replace single-client MVP with per-token indexing + device-id reconnect handoff. Hermes session state carries `desktop_session_token` via a new `ContextVar` in `gateway/session_context.py` (hermes-agent PR candidate — won't affect Android). Natural pairing with the workspace-awareness envelope — the ContextVar scheme determines which client's workspace the active session sees.
|
||||
- **Harden `release-desktop.yml` retag semantics.** The `softprops/action-gh-release` step failed during the alpha.1 retag with `tag_name already_exists` after deleting + re-uploading all 5 assets; recovered by `gh api` cleanup (delete orphan draft + PATCH draft→false on the release with the real assets). Follow-up: pin the action version, add `make_latest: false` + explicit `release_id` lookup, or switch to `ncipollo/release-action` which handles retags without the duplicate-draft creation.
|
||||
- **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.
|
||||
- **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 publish** — `@hermes-relay/cli` goes to the npm registry once v1.0 is cut, enabling `npm i -g` / `npx` for Node-having users in addition to the curl-binary path.
|
||||
- **npm registry publication** — future v1.0 distribution work. The package name is local workspace metadata today; current install paths are GitHub Release binaries or local clone + `npm link`.
|
||||
- **HMAC verification on QR payloads** — defer until a client-accessible secret story exists (same deferral as the Android app). Not blocking GA.
|
||||
|
||||
**Docs + references:** user-docs `/desktop/` section (Overview → Installation → Pairing → Subcommands → Local tool routing → Troubleshooting → FAQ) with an `<ExperimentalBadge />` Vue component on every page. README.md landing has a dedicated "Experimental: Desktop CLI" section with the install one-liners.
|
||||
|
||||
@@ -6,6 +6,121 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## User-Added:
|
||||
|
||||
- [ ] Enhance the 'clean chat' view mode to allow more a little more vertical visible text area and scrolling within.
|
||||
- [ ] Look into the voice-settings profile specific capabilities - confirm approach is sound - verify as I noticed that in 'auto' mode it didn't work, it still used the system default despite config despite override voice chosen being displayed to user in voice config in voice setting in app UI. Only switching to 'Relay' specifically allowed the user-override to work/apply.
|
||||
|
||||
- [ ] - analytics and diagnostics pages need cleaned up, improved, enhancements for UI/UX/layout. Diagnostics should have timeline vertical status checks with failure reason etc
|
||||
|
||||
- [x] **Per-profile agent icon + static-image avatar (shipped 2026-06-20 — `d827e46`, see DEVLOG).** Per-profile icon: client-side `ProfileIconStore` (per `(connection, profile)`, never sent to Hermes; stores a copied-file path) → small Coil image beside the agent name in `MessageBubble` via `LocalAgentIconPath`; picker is `AgentIconRow` under the local-name row in `ConnectionInfoSheet`. Static image: "Add a pet" accepts a single image (magic-byte detect → one-frame static pet). Scope shipped: small name-adjacent icon only; big avatar stays global. Follow-ups: on-device smoke (import an image as a pet; set a profile icon, confirm it shows by the name + persists across restart); optionally also show the icon in the profile picker.
|
||||
|
||||
## Hands-free agentic voice backlog
|
||||
|
||||
Goal: make Hermes usable for hands-free work without leaving the operator blind
|
||||
|
||||
to tool state, safety prompts, or the current task.
|
||||
|
||||
- **Waveform output-start sync** — current input waveform timing feels good, but
|
||||
|
||||
the agent-output waveform can unfold and begin movement before audible speech
|
||||
|
||||
starts. Split "preparing audio" from "speaking audio" in the visual layer, or
|
||||
|
||||
gate the unfolded Speaking waveform on the first real playback frame/audio
|
||||
|
||||
amplitude. Processing can stay as the folded circular spinner until output is
|
||||
|
||||
actually audible.
|
||||
|
||||
- **Voice command layer** — reserve local commands that bypass normal agent
|
||||
|
||||
routing: "pause", "resume", "stop talking", "cancel", "repeat that", "open
|
||||
|
||||
overlay", "return to Hermes", and "new chat". These should work while the
|
||||
|
||||
agent is thinking, speaking, or using tools.
|
||||
|
||||
- **Spoken tool progress** — when Hermes uses tools, voice mode should speak
|
||||
|
||||
short status updates such as "I'm checking the relay logs" or "I found an
|
||||
|
||||
error" without waiting for final assistant text. Long tool calls should emit
|
||||
|
||||
periodic, low-noise progress updates.
|
||||
|
||||
- **Realtime tool timeline parity** — the voice overlay should render the same
|
||||
|
||||
live thinking blocks, streaming assistant text, and tool call progress as the
|
||||
|
||||
normal chat surface without requiring exit/reload.
|
||||
|
||||
- **Hands-free confirmation flow** — risky actions need first-class spoken and
|
||||
|
||||
visual confirmation: "yes", "no", "cancel", "confirm", plus a visible and
|
||||
|
||||
audible countdown for destructive actions.
|
||||
|
||||
- **Voice session memory/status** — add a compact "where are we?" summary for
|
||||
|
||||
the current voice task: active objective, last tool result, pending next step,
|
||||
|
||||
and whether the agent is waiting on the user.
|
||||
|
||||
- **Mode presets** — add presets such as Hands-free, Low latency, Careful tool
|
||||
|
||||
mode, and Quiet/visual-only. Hands-free should favor Continuous listening,
|
||||
|
||||
spoken tool progress, confirmations, and overlay availability.
|
||||
|
||||
- **Barge-in hardening** — keep barge-in experimental until echo/self-recording
|
||||
|
||||
is solved. The target path is proper AEC, playback-ducking, and a rule that
|
||||
|
||||
output audio can never become a user turn.
|
||||
|
||||
- **Audio quality guardrails** — normalize output volume across realtime and
|
||||
|
||||
fallback TTS providers, keep pronunciation hints/profile voice tuning, and
|
||||
|
||||
measure provider-specific delay, chunk gaps, and tail clipping.
|
||||
|
||||
- **Pluggable Realtime Agent media transports** — add an OpenAI-first WebRTC
|
||||
|
||||
transport option for Realtime Agent so mobile audio can use provider-native
|
||||
|
||||
jitter buffering, interruption, and media handling instead of only relay
|
||||
|
||||
WebSocket PCM. Design this as a provider transport interface
|
||||
|
||||
(`websocket`, `webrtc`, future `livekit`/SIP-style bridges) so other
|
||||
|
||||
realtime providers can opt in without forking the Hermes broker/tool
|
||||
|
||||
contract. Hermes must still own tools, memory, confirmations, current data,
|
||||
|
||||
and durable transcript state.
|
||||
|
||||
- **Voice engine selector** — implemented as an opt-in experimental Realtime
|
||||
|
||||
Agent engine in `docs/plans/2026-05-19-realtime-hermes-voice-agent.md`.
|
||||
|
||||
Follow-up work is provider-native turn-taking, richer confirmation handling,
|
||||
|
||||
and quality/latency evaluation before promotion beyond Experimental.
|
||||
|
||||
- **Realtime-native Hermes bridge prototype** — first relay-brokered slice
|
||||
|
||||
implemented in `docs/plans/2026-05-19-realtime-hermes-voice-agent.md`.
|
||||
|
||||
Remaining work: let OpenAI/xAI realtime sessions own more of the live speech
|
||||
|
||||
turn while still proxying every tool, confirmation, memory, and Android bridge
|
||||
|
||||
action through Hermes/relay safety.
|
||||
|
||||
---
|
||||
|
||||
## Research / open questions
|
||||
|
||||
### Proper Hermes plugin / skill / tool distribution
|
||||
@@ -20,7 +135,7 @@ Things to look into:
|
||||
- **Skill distribution as separate from plugin distribution** — right now skills ride along with the plugin install via `external_dirs`. Should skills be installable independently (e.g. `hermes skill install <git-url>`)? Would that fragment maintenance or improve reuse?
|
||||
- **Tool registration discoverability** — `android_*` tools register at gateway import time. There's no canonical "list installed plugin tools" API. Would adding one to upstream make sense, or is `gateway tool list` already enough?
|
||||
- **Versioning + compatibility ranges** — `pip install -e` doesn't enforce version pins between hermes-agent and our plugin. A breaking change in upstream's plugin loader could silently break us. Do we need a `hermes_compat: ">=0.8.0,<1.0.0"` field somewhere?
|
||||
- **`hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
|
||||
- `**hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla upstream. This is intentional but feels like a hack. Upstream PR #8556 (`feat/session-api`) will eventually let us delete it — verified 2026-04-15 that its scope covers the full bootstrap surface (sessions, memory, skills, config, available-models). Track that PR's status periodically.
|
||||
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to #8556. Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
|
||||
- **Gateway slash-command preprocessor — bootstrap middleware (Stage 1 equivalent).** Sibling shim in `hermes_relay_bootstrap/_command_middleware.py` that mirrors the upstream Stage 1 PR as an aiohttp middleware injected at bootstrap time. Ships the hallucination fix to vanilla-upstream installs before the upstream PR lands. Planned for v0.4.1, after the current bridge feature branch wraps. See `ROADMAP.md` v0.4.1 entry.
|
||||
@@ -37,6 +152,64 @@ When the answer becomes clearer, this section becomes either an ADR in `docs/dec
|
||||
- **Wave 3 voice-bridge multi-turn confirmation** — currently a 5s TTS countdown with cancel; conversational confirmation is the follow-up
|
||||
- **LLM client wiring for `android_navigate`** — `_default_vision_model` is stubbed; production swap to a real Anthropic/OpenAI vision client
|
||||
- **Real screenshots of each flavor's a11y permission dialog** — for `user-docs/guide/release-tracks.md`
|
||||
- **`llms.txt` standard** — explicitly skipped in favor of the `hermes-relay-self-setup` SKILL.md path; revisit if the standard gains traction in the agent ecosystem
|
||||
- **`markdown-renderer` 0.40.x API update** — pinned at `0.30.0` in `gradle/libs.versions.toml` because 0.40.2 introduced breaking API changes that `app/src/main/kotlin/com/hermesandroid/relay/ui/components/MarkdownContent.kt` hasn't been updated for. Specifically: `markdownColor()` drops `codeText`/`linkText`, `MarkdownCodeBlock`/`MarkdownCodeFence` inner lambdas now take a 3rd `TextStyle` arg, and `MarkdownHighlightedCode`'s 3rd param is now `TextStyle` instead of `Highlights.Builder`. Dependabot auto-merged the bump on 2026-04-13 which silently broke CI; reverted for the v0.3.0 release. Update requires reading the new library API docs and testing in Studio — not a blind fix. Consider adding a dependabot ignore rule for `markdown-renderer` major bumps until this is handled.
|
||||
- `**llms.txt` standard** — explicitly skipped in favor of the `hermes-relay-self-setup` SKILL.md path; revisit if the standard gains traction in the agent ecosystem
|
||||
- `**markdown-renderer` 0.40.x API update** — pinned at `0.30.0` in `gradle/libs.versions.toml` because 0.40.2 introduced breaking API changes that `app/src/main/kotlin/com/hermesandroid/relay/ui/components/MarkdownContent.kt` hasn't been updated for. Specifically: `markdownColor()` drops `codeText`/`linkText`, `MarkdownCodeBlock`/`MarkdownCodeFence` inner lambdas now take a 3rd `TextStyle` arg, and `MarkdownHighlightedCode`'s 3rd param is now `TextStyle` instead of `Highlights.Builder`. Dependabot auto-merged the bump on 2026-04-13 which silently broke CI; reverted for the v0.3.0 release. Update requires reading the new library API docs and testing in Studio — not a blind fix. Consider adding a dependabot ignore rule for `markdown-renderer` major bumps until this is handled.
|
||||
- **Dependabot auto-merge guardrails** — Dependabot merged breaking bumps despite CI failing. Investigate why `.github/workflows/dependabot-auto-merge.yml` isn't gating on CI status, and consider adding an ignore rule for packages we know need manual attention on major bumps (`markdown-renderer`, compose BOM, activity-compose).
|
||||
|
||||
---
|
||||
|
||||
## Crash reporting + foldable hardening (shipped 2026-06-20)
|
||||
|
||||
Triggered by a Play Store review: app "keeps crashing" during setup on a Samsung Galaxy Z Fold7 (Android 16 / SDK 36, version code 13). Shipped: in-app crash capture (`util/CrashReporter.kt` — uncaught handler that persists a report then re-raises so Play vitals still collects; `ui/components/CrashReportDialog.kt` — show-once dialog with Copy + pre-filled GitHub-issue "Report"); QR camera-init hardening (`QrPairingScanner.kt` — try/catch around `ProcessCameraProvider.get()` and `InputImage.fromMediaImage()`, graceful `CameraUnavailableCard` → manual pairing instead of force-close).
|
||||
|
||||
Follow-ups:
|
||||
|
||||
- **Confirm the actual crash from Play vitals.** Pull the top crash cluster for Galaxy Z Fold7 / version code 13 (Quality → Android vitals → Crashes & ANRs) to verify the camera path is the real cause vs. another setup-path throw. The hardening is correct regardless, but the trace closes the loop.
|
||||
- **Portrait lock is moot on large screens under SDK 36.** `android:screenOrientation="portrait"` is largely ignored by Android 16's mandatory large-screen orientation override on foldables/tablets. Decide whether to keep the lock (it still applies on phones) or make it conditional; either way it does not *cause* the crash.
|
||||
- **Foldable camera lifecycle races (from the 2026-06-20 audit, not yet fixed).** `QrPairingScanner` can still hit bind/unbind races on rapid fold/unfold recomposition (the `DisposableEffect` `unbindAll()` vs. an in-flight `addListener` bind), and `mapBoxToViewport` runs on possibly-stale `viewportSizePx` during a fold transition. Not crash-fatal after the try/catch hardening (logged + skipped), but worth a fold-aware guard if foldable adoption grows.
|
||||
- **Optional: surface crash history in Settings.** The reporter keeps only the most recent crash (`files/crash/last-crash.json`, consumed on view). If repeat-crash diagnosis becomes common, keep a small ring of recent reports + a Settings entry to view/copy them.
|
||||
|
||||
---
|
||||
|
||||
## Relay enhancement layer + agent-context injection (shipped 2026-06-20 — `docs/plans/2026-06-20-relay-enhancement-layer.md`)
|
||||
|
||||
Shipped: `plugin/enhancements/` (registry + fail-open `context_injection` wrap of `AIAgent._build_system_prompt`), the `media-sensitivity` block, `GET /context/injected` audit route, dashboard toggles, client sensitivity re-thread + "Relay context (server-side)" audit section, and the transport-path UI (`ChatTransportStatusBadge` / `RelayStatusStrip` + tier ladder). OFF by default, removable, vanilla-safe.
|
||||
|
||||
Follow-ups:
|
||||
|
||||
- **Confirm the `AIAgent` seam on the live host before relying on it.** `context_injection._resolve_ai_agent_class()` tries `agent.system_prompt` / `run_agent`. When you flip `RELAY_AGENT_CONTEXT_ENABLED=1`, verify `GET /context/injected` shows the block AND that it actually lands in the prompt (the wrap is fail-open, so a wrong module = inert, not broken). If the class lives elsewhere, widen the module list.
|
||||
- **Retire the monkey-patch when upstream adds a plugin context hook.** Drop `context_injection` (and migrate to the native hook) the moment hermes-agent ships a first-class system-prompt contributor — same as we retire bootstrap routes for native upstream routes.
|
||||
- **Incremental bootstrap migration.** Fold the existing `hermes_relay_bootstrap` route-patches into `plugin/enhancements/` per-surface (startup phase) so patching is one surface; don't big-bang the working compat.
|
||||
- **Structured media channel** — `docs/plans/2026-06-20-structured-media-channel.md` (design only). Replace fragile `MEDIA:`/markdown text markers with a structured channel carrying `sensitive` natively; lead with a relay `relay_send_media(path, sensitive, …)` tool.
|
||||
- **Gateway voice-ephemeral via the same slot.** The enhancement layer's server-side injection can carry per-turn voice instructions on the gateway (which has no ephemeral `system_message`), letting voice stay on the gateway instead of being forced to SSE. Wire when the voice path is revisited.
|
||||
|
||||
---
|
||||
|
||||
## Attachments (shipped 2026-06-18 — `docs/plans/2026-06-18-attachment-experience.md`)
|
||||
|
||||
- **B3 — download progress + cancel.** Inbound fetch is un-cancelable; the previews work scaffolded an indeterminate bar + nullable `onCancel`. Live wiring needs the fetch-path owner (`ChatViewModel`/`Attachment`) to expose determinate progress (Content-Length) + a cancel hook.
|
||||
- **A6 — multi-image gallery.** N images in one message → grid + swipe-across viewer (Telegram media-group parity).
|
||||
- **C5 — agent-side sensitivity config gate.** `RELAY_MEDIA_SENSITIVITY_HINTS` (env or per-profile) instructing the agent to annotate sensitive media via the prompt-builder. Transport (relay `X-Media-Sensitive` header + client blur) already ships; the agent isn't asked to set the bit yet.
|
||||
- **Relay thumbnails (D6).** Server-side thumbnail generation to avoid full-size download for cards/galleries. Needs an image lib (Pillow not currently a dep) — evaluate before adding.
|
||||
- **D5 — outbound upload progress.** No per-attachment progress during the 60s gateway PDF-render window.
|
||||
|
||||
## Voice overhaul (shipped 2026-06-18 — `docs/plans/2026-06-18-voice-overhaul.md`)
|
||||
|
||||
- **Per-profile voice on Standard (upstream PR).** Upstream `/api/profiles/*` has no voice field and `/api/audio/*` is host-global. Long-term: PR a voice section to the profile config + make `/api/audio/*` honor the active/`?profile=` profile. The relay path already carries per-profile voice; ship that first.
|
||||
- **Wire connectionId for per-profile voice namespacing.** `VoicePreferencesRepository` is scope-aware (`base_connId_profile`), but `RelayApp` passes only the profile *name* to `onProfileChanged`, so `connectionId` is null and keys namespace by profile-only. Wire `setVoicePrefsConnection` to `ConnectionViewModel.activeConnectionId` (in `RelayApp`) so two connections with same-named profiles don't share voice settings.
|
||||
- **Realtime-PCM waveform output gating.** The basic-TTS output waveform is now Visualizer-accurate (gated on real playback amplitude), but the realtime path gates `outputAudioActive` on `audioSeen` (first decoded PCM bytes) in `VoiceViewModel.handleRealtimeVoiceEvent`, which can still lead audible output by the `RealtimePcmPlayer` start prebuffer. Gate realtime on actual playback-start (head moved) to match the basic-TTS path.
|
||||
|
||||
## Chat clean-mode + pets (shipped 2026-06-18 — `docs/plans/2026-06-18-chat-clean-mode-and-pets.md`)
|
||||
|
||||
- **Part-A chat polish (optional bundle).** Per-code-block copy + horizontal scroll, visible copy affordance, mid-stream stall feedback, profile/skill-aware empty-state chips, the ~40-flow recomposition hotspot at the top of `ChatScreen`. (Sphere `contentDescription`/reduced-motion was handled by the clean-mode a11y work.)
|
||||
- **Pet hot-load + in-app add/remove (shipped 2026-06-20).** Pets now live-refresh: an `avatarsRefreshTick` keys the avatar `produceState` in `RelayApp`, and Appearance re-scans `pets/` on open and after in-app import/delete — no app restart. Appearance gained "Add a pet" (SAF `.zip` import via `PetImporter`, zip-slip/zip-bomb guarded + validated through `toAvatar`) and an "Installed pets" list with per-pet remove (`PetLoader.deletePet`, confirm dialog, Sphere fallback). Remaining:
|
||||
- **Sphere-skin parity.** Skins are still process-scoped + `adb push` only — the live tick and the importer cover pets, not skins. Extend the tick to `loadUserSkins` and add a `.json` skin import if hot-loading/adding skins in-app is wanted.
|
||||
- **`adb push` into `Android/data` hangs on Samsung scoped storage.** Confirmed: pushing a pet pack to `/sdcard/Android/data/<pkg>/files/pets/` stalls (no bytes written) although `adb shell ls` of the dir works. In-app `.zip` import is the supported path; `/sdcard/Download` pushes fine. Consider softening `docs/pet-spec.md` + user-docs to lead with in-app import over adb.
|
||||
- **On-device import/delete smoke.** Import `/sdcard/Download/lucy.zip` via Add a pet → confirm Lucy appears, selects, and animates all states; then remove it and confirm the avatar falls back to the Sphere.
|
||||
- **Pet state-change re-decode can flash one blank frame.** When the agent state switches clips, the first frame of the new clip may briefly be blank during decode; prewarm/hold-last-frame to smooth it. Root cause is the same as the next item: `PetAvatar.Render` re-decodes from disk on every clip change.
|
||||
- **Pet frame-sequence memory: no cap or downsample (audit 2026-06-19).** `decodeClip` decodes every frame of the selected clip into `List<ImageBitmap>` at full resolution with no `inSampleSize` downscale to the display size and no frame-count/dimension ceiling — a long sequence of large PNGs can use a lot of RAM and a single very large image can OOM `BitmapFactory`. Add `inSampleSize` downsampling to the avatar's draw size and/or a documented hard cap. Spec now warns authors (prefer sprite sheets), but the renderer doesn't enforce it.
|
||||
- **Pet decoded-clip cache (audit 2026-06-19).** `PetAvatar.Render` keys `produceState` on `clip`, so idle→thinking→speaking→idle within one turn re-runs `BitmapFactory.decodeFile` from disk each transition (repeated I/O + GC churn, and the blank-frame flash above). Add a small per-avatar `Map<SphereState, PetFrames>` decode cache.
|
||||
- **Pet behavior model — richer state association (spec'd 2026-06-19, `docs/pet-spec.md` "Agent states & pet behavior").** Shipped: the honesty clamp (declared reactivity ∩ `PET_RENDERER_CAPABILITIES`), the friendly `writing` alias, the `**working`/tool-use overlay** (pet-local sub-state from `toolCallBurst`; opt-in `working` clip drives both the swap and the Tools badge), the **one-shot reaction layer** (`greet`/`wake` on appear, `done`/`celebrate` on turn-finish — opt-in, play-once-then-revert, transition-derived; `ONE_SHOT_MAX_MS` backstop), and `**intensity` modulation** (opt-in `reactive.intensity` → live playback speedup ≤1.6× via `rememberUpdatedState`; un-clamps the Activity badge). Voice · Tools · Activity reactivity is now complete. Remaining:
|
||||
- `**attention` one-shot (only deferred behavior).** A reaction on notification arrival — needs a host event the avatar doesn't yet receive (unlike `greet`/`done`, which ride state transitions). Would plumb a notification edge into `AvatarRenderState` (or a side channel) + a `PetOneShot.Attention`. Low priority: the avatar is rarely on-screen when notifications land (backgrounded) — see the value analysis; revisit only if the avatar becomes an always-on surface (persistent overlay / Quest port).
|
||||
- **On-device verification (working + one-shots + intensity).** Best seen in clean mode (`AgentTextFlow` feeds `toolCallBurst` + `streamingIntensity` + state transitions). Confirm: a `working` clip swaps in during a tool run and releases ~600ms after (`WORKING_BURST_THRESHOLD` 0.5); a `done` clip plays once on reply completion then returns to idle; a `greet` clip plays once when the avatar appears; with `intensity:true`, a writing/working loop visibly quickens while streaming. Watch for the known clip re-decode flash on each swap (separate TODO — decoded-clip cache).
|
||||
- **Undecodable-but-present image appears valid (audit 2026-06-19).** A file that exists but isn't a decodable image passes the loader's `isFile` check, so the pet shows in the picker but renders blank. Documented as a caveat; consider a cheap header sniff at load time if false-valid pets become a support issue.
|
||||
@@ -54,11 +54,10 @@ android {
|
||||
Properties().apply { localProps.inputStream().use { stream -> load(stream) } }
|
||||
} else null
|
||||
|
||||
storeFile = file(
|
||||
System.getenv("HERMES_KEYSTORE_PATH")
|
||||
?: props?.getProperty("hermes.keystore.path")
|
||||
?: "/nonexistent"
|
||||
)
|
||||
val keystorePath = System.getenv("HERMES_KEYSTORE_PATH")
|
||||
?: props?.getProperty("hermes.keystore.path")
|
||||
?: "/nonexistent"
|
||||
storeFile = rootProject.file(keystorePath)
|
||||
storePassword = System.getenv("HERMES_KEYSTORE_PASSWORD")
|
||||
?: props?.getProperty("hermes.keystore.password") ?: ""
|
||||
keyAlias = System.getenv("HERMES_KEY_ALIAS")
|
||||
@@ -68,20 +67,18 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 3 — Bridge channel release tracks ────────────────────────────────
|
||||
// Google Play scrutinizes AccessibilityService heavily (policy review + manual
|
||||
// appeals are common), so Phase 3 ships two distinct tracks via flavor-merged
|
||||
// manifests + flavor-scoped strings + flavor-scoped accessibility configs:
|
||||
// ─── Bridge release tracks ─────────────────────────────────────────────────
|
||||
// Google Play ships Bridge Core only: pairing, chat, voice, terminal/TUI,
|
||||
// media, notification companion, relay sessions, and status. It does not
|
||||
// declare AccessibilityService, overlay, MediaProjection, wake-lock device
|
||||
// control, SMS/call/contact/location, or unattended-control permissions.
|
||||
//
|
||||
// googlePlay — conservative use-case description targeted at Play Store
|
||||
// policy review. Subset of event types + flagDefault only.
|
||||
// No gestures, no interactive-window reporting. Feature gates
|
||||
// in BuildFlavor.kt hide tier 3/4/6 surfaces in the UI.
|
||||
// googlePlay — canonical Play Store install. Bridge Core only.
|
||||
//
|
||||
// sideload — full agent-control description for users who install the
|
||||
// APK directly (GitHub Releases, F-Droid, ADB). typeAllMask,
|
||||
// gestures, interactive windows, view-id reporting. All six
|
||||
// tiers enabled.
|
||||
// sideload — Device Control for users who install directly (GitHub
|
||||
// Releases, F-Droid, ADB). AccessibilityService, gestures,
|
||||
// screenshots, overlay/status chip, and phone utilities are
|
||||
// declared in the sideload manifest.
|
||||
//
|
||||
// applicationIdSuffix decision: sideload gets `.sideload` so both tracks can
|
||||
// coexist on the same device. The Play build keeps the base
|
||||
@@ -109,6 +106,19 @@ 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")
|
||||
@@ -166,6 +176,13 @@ android {
|
||||
// both failing with RuntimeException from unmocked Log.w calls.
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
// Robolectric (VoicePlayerTest) needs merged Android resources +
|
||||
// manifest on the unit-test classpath to bootstrap its sandbox.
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
// [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.
|
||||
unitTests.all { it.systemProperty("roborazzi.test.record", "true") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +202,17 @@ 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)
|
||||
@@ -230,6 +258,10 @@ dependencies {
|
||||
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.network.okhttp)
|
||||
|
||||
// QR Code scanning (ML Kit + CameraX)
|
||||
implementation(libs.mlkit.barcode)
|
||||
implementation(libs.camera.core)
|
||||
@@ -259,14 +291,26 @@ dependencies {
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.kotlinx.serialization.json)
|
||||
// MockWebServer for ADR 24 EndpointResolver tests — probes HEAD /health
|
||||
// across priority groups against real local sockets so the behavior we
|
||||
// validate matches on-device.
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
// Konsist — enforces the ADR 34 upstream/relay/shared package fence as a JUnit test
|
||||
testImplementation(libs.konsist)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
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.43.1")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.43.1")
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.ui.test.manifest)
|
||||
testImplementation("androidx.test.ext:junit:1.2.1")
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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,7 +6,6 @@ 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,22 +1,19 @@
|
||||
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.compose.ui.test.performScrollTo
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
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 onboarding pager flow.
|
||||
*
|
||||
* These tests require an Android device or emulator because they use
|
||||
* Compose UI testing APIs and interact with real Compose components.
|
||||
* Instrumented tests for the Standard-first onboarding pager.
|
||||
*/
|
||||
class OnboardingFlowTest {
|
||||
|
||||
@@ -24,270 +21,186 @@ class OnboardingFlowTest {
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private fun setOnboardingContent() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val connectionViewModel = ConnectionViewModel(app)
|
||||
composeTestRule.setContent {
|
||||
HermesRelayTheme {
|
||||
OnboardingScreen(
|
||||
onComplete = { _, _, _ -> }
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Page 1: Welcome ---
|
||||
|
||||
@Test
|
||||
fun firstPage_showsHermesRelayTitle() {
|
||||
fun firstPage_showsHermesForAndroidTitle() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes-Relay")
|
||||
.onNodeWithText("Hermes-Relay for Android")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstPage_showsWelcomeDescription() {
|
||||
fun firstPage_showsStandardFirstDescription() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Your AI agent, in your pocket. Chat, control, and connect — all from your phone.")
|
||||
.onNodeWithText("Chat with Hermes and manage your dashboard from your phone.")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Skip button ---
|
||||
|
||||
@Test
|
||||
fun skipButton_isAlwaysVisible_onFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Skip")
|
||||
.onNodeWithText("Standard")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Navigation: Next button ---
|
||||
|
||||
@Test
|
||||
fun nextButton_isDisplayed_onFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Next")
|
||||
.onNodeWithText("Advanced")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Setup Guide")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes Docs")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextButton_navigatesForward_toPage2() {
|
||||
fun nextButton_navigatesForward_toChatPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Page 1 -> Page 2
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 2 is "Talk to Your Agent"
|
||||
composeTestRule
|
||||
.onNodeWithText("Talk to Your Agent")
|
||||
.onNodeWithText("Chat")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canNavigateForward_throughAllPages() {
|
||||
fun canNavigateForward_throughStandardAndPowerPages() {
|
||||
setOnboardingContent()
|
||||
|
||||
// Page 1: Hermes-Relay (Welcome)
|
||||
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 2: Talk to Your Agent (Chat)
|
||||
composeTestRule.onNodeWithText("Talk to Your Agent").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 3: Remote Terminal
|
||||
composeTestRule.onNodeWithText("Remote Terminal").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Manage").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Page 4: Device Bridge
|
||||
composeTestRule.onNodeWithText("Device Bridge").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.onNodeWithText("Power tools").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Connect").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_visibleOnPage2() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
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()
|
||||
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
|
||||
|
||||
// Go back to page 1
|
||||
composeTestRule.onNodeWithText("Back").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay").assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Page 5: Connect page ---
|
||||
|
||||
@Test
|
||||
fun connectPage_hasApiServerUrlField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4) // 0-indexed, page 5 is index 4
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("API Server URL")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_hasApiKeyField() {
|
||||
fun connectPage_showsStandardChoiceFirst() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("API Key (optional)", substring = true)
|
||||
.onNodeWithText("Vanilla Hermes")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_whereDoIFindThis_showsHelpDialog() {
|
||||
fun standardSetup_showsApiFields() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
// Tap "Where do I find this?"
|
||||
composeTestRule
|
||||
.onNodeWithText("Where do I find this?")
|
||||
.performClick()
|
||||
composeTestRule.onNodeWithText("Vanilla Hermes").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
// Dialog should show
|
||||
composeTestRule
|
||||
.onNodeWithText("Do I need an API key?")
|
||||
.onNodeWithText("API server URL")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("API key")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectPage_helpDialog_canBeDismissed() {
|
||||
fun standardSetup_connectButton_isEnabled_withDefaultUrl() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Where do I find this?").performClick()
|
||||
composeTestRule.onNodeWithText("Vanilla Hermes").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("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 relayPage_showsRelayUrlField() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Relay URL (optional)")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Get Started button ---
|
||||
|
||||
@Test
|
||||
fun lastPage_showsGetStartedButton() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Started")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lastPage_getStartedButton_isEnabled_withDefaultUrl() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(5)
|
||||
|
||||
// Default URL is "http://localhost:8642" which is non-blank
|
||||
composeTestRule
|
||||
.onNodeWithText("Get Started")
|
||||
.onNodeWithText("Connect")
|
||||
.assertIsEnabled()
|
||||
}
|
||||
|
||||
// --- Skip button visibility across pages ---
|
||||
|
||||
@Test
|
||||
fun skipButton_visibleOnAllPages() {
|
||||
fun connectPage_keepsPairingOptional() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
// Check skip on first page
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
|
||||
// Navigate through all pages and check skip
|
||||
for (i in 0 until 5) {
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
}
|
||||
composeTestRule
|
||||
.onNodeWithText("Pair Relay by code")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Power-user path for Terminal, Bridge, Relay sessions, and grants")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
@Test
|
||||
fun powerPage_linksToPermissionReview() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(3)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Review permissions")
|
||||
.assertIsDisplayed()
|
||||
.assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipButton_visibleOnIntroPages_andWizardSkipOnConnectPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
repeat(4) {
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Skip for now — set up later in Settings")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun navigateToPage(pageIndex: Int) {
|
||||
repeat(pageIndex) {
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,157 +1,52 @@
|
||||
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.compose.material3.MaterialTheme
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for Terminal and Bridge empty state screens.
|
||||
* Instrumented smoke tests for the current Terminal and Bridge surfaces.
|
||||
*/
|
||||
class EmptyStateTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
// --- Terminal Screen ---
|
||||
|
||||
@Test
|
||||
fun terminalScreen_showsTitle() {
|
||||
fun terminalScreen_showsCurrentTopBar() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val terminalViewModel = TerminalViewModel(app)
|
||||
val connectionViewModel = ConnectionViewModel(app)
|
||||
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
TerminalScreen()
|
||||
TerminalScreen(
|
||||
terminalViewModel = terminalViewModel,
|
||||
connectionViewModel = connectionViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Remote Terminal")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Terminal").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithContentDescription("Search scrollback").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
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() {
|
||||
fun bridgeScreen_showsCurrentTopBar() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
BridgeScreen()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
composeTestRule.onNodeWithText("Bridge").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -5,24 +5,23 @@
|
||||
Merged on top of `app/src/main/AndroidManifest.xml` by AGP when the
|
||||
`googlePlayDebug` / `googlePlayRelease` variants are built.
|
||||
|
||||
The AccessibilityService is declared exactly once in `app/src/main/AndroidManifest.xml`.
|
||||
The flavor distinction is purely at the resource layer: this flavor's
|
||||
`res/xml/accessibility_service_config.xml` carries the conservative use-case
|
||||
description required for Google Play policy review, and `res/values/strings.xml`
|
||||
carries the description string. Gradle's resource merger picks the right
|
||||
files at build time, so we don't need to redeclare the <service> here.
|
||||
Google Play ships Hermes Bridge Core only. It intentionally does not merge
|
||||
any Device Control services or permissions.
|
||||
|
||||
This file is intentionally kept as an empty overlay so future flavor-specific
|
||||
permissions / activities have an obvious home. Mirror structural additions
|
||||
in `app/src/sideload/AndroidManifest.xml` unless the change is intentionally
|
||||
track-specific.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Media3 ExoPlayer contributes a power-management permission from its
|
||||
library manifest. Strip it from the merged Play artifact. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WAKE_LOCK"
|
||||
tools:node="remove" />
|
||||
|
||||
<!-- googlePlay inherits main manifest's specialUse-only FGS type
|
||||
directly — no override needed. The sideload manifest ADDS
|
||||
mediaProjection via tools:replace; googlePlay gets the safe
|
||||
default. -->
|
||||
<application />
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import com.hermesandroid.relay.network.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.handlers.LocalDispatchResult
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.shared.LocalDispatchResult
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
|
||||
/**
|
||||
* Local in-process bridge dispatcher type. The Play flavor never invokes
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
en-US
|
||||
@@ -0,0 +1,62 @@
|
||||
Hermes-Relay is the native Android client for the Hermes agent platform. Point it at your own Hermes instance and chat with your agent, talk to it hands-free, and manage models, keys, skills, and profiles from anywhere.
|
||||
|
||||
It is not a hosted AI service. It is a companion app for the Hermes agent you run, and it talks only to the instances you configure.
|
||||
|
||||
QUICK START
|
||||
|
||||
1. Run hermes-agent with its API server and dashboard enabled on your computer or home server.
|
||||
2. Install Hermes-Relay and enter your server address, for example http://192.168.1.100:8642.
|
||||
3. The setup wizard checks what your server supports and shows a readiness card, then you are ready to chat.
|
||||
|
||||
A plain Hermes install is enough. Chat, management, and voice work with no plugin or extra service.
|
||||
|
||||
HOW IT WORKS
|
||||
|
||||
Chat streams directly from your Hermes API Server or dashboard gateway in real time. Manage and voice use your Hermes dashboard with one sign-in. Run the optional relay service and the app can pair by QR code to add power tools: remote terminal, notification companion, media handoff, relay-session management, and additional voice engines.
|
||||
|
||||
GOOGLE PLAY BUILD
|
||||
|
||||
The Google Play build ships Hermes Bridge Core only. It has no AccessibilityService Device Control: it cannot read your screen, tap, type, swipe, screenshot, send SMS, place calls, or access contacts or location. Device Control is reserved for sideload builds distributed outside Google Play.
|
||||
|
||||
FEATURES
|
||||
|
||||
- Streaming Chat: real-time responses with reasoning, markdown, tool-call visibility, attachments, mid-turn steering, edit-and-resend, and a searchable command palette.
|
||||
|
||||
- Manage Your Agent: use your Hermes dashboard from your phone to switch models, manage provider keys, edit profiles, and browse, install, and update skills.
|
||||
|
||||
- Voice Mode: talk hands-free using your server's speech providers. Relay-paired setups add per-profile voices and an experimental realtime engine.
|
||||
|
||||
- Works Away From Home: add LAN, Tailscale, or public routes and the app chooses the best available path on connect.
|
||||
|
||||
- Sessions: create, switch, rename, and delete chats. Message history loads on demand.
|
||||
|
||||
- Multiple Servers and Profiles: connect to more than one server and switch in a tap; overlay an agent profile or personality per conversation.
|
||||
|
||||
- Relay Power Tools: optional QR pairing for remote terminal, relay-session management, media handoff, and per-feature grants.
|
||||
|
||||
- Notification Companion: optionally forward notification metadata to your paired relay so your assistant can summarize it. Toggle it anytime in system settings.
|
||||
|
||||
- Stats for Nerds: local-only counters for response timing, token usage, cost, and stream health.
|
||||
|
||||
- Material You: Material 3 dynamic color, light/dark/system themes, and haptics.
|
||||
|
||||
SECURITY AND PRIVACY
|
||||
|
||||
- API keys and relay tokens are stored in encrypted Android storage.
|
||||
- HTTPS is enforced for remote connections; cleartext is limited to localhost or LAN setups.
|
||||
- No telemetry, ads, tracking, or third-party analytics SDKs.
|
||||
- Notification access and the microphone are optional and user-controlled.
|
||||
- All app traffic goes only to servers you configure.
|
||||
|
||||
REQUIREMENTS
|
||||
|
||||
- Android 8.0 or later.
|
||||
- A running Hermes agent for chat, management, and voice.
|
||||
- Optional Hermes relay service for power tools such as terminal, notifications, and media.
|
||||
- Network access to your server by local network, VPN, or internet.
|
||||
|
||||
OPEN SOURCE
|
||||
|
||||
Hermes-Relay is MIT licensed. Source, docs, and issue tracking are on GitHub.
|
||||
|
||||
This app is a community project and is not affiliated with or endorsed by NousResearch.
|
||||
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 165 KiB |
@@ -0,0 +1 @@
|
||||
Your Hermes AI agent, in your pocket - chat, voice, and control.
|
||||
@@ -0,0 +1 @@
|
||||
Hermes-Relay
|
||||
@@ -0,0 +1,7 @@
|
||||
v1.2.0 — Make it yours.
|
||||
|
||||
• Eight app themes, swappable sphere skins, and animated agent "pets" that react to what your agent is doing.
|
||||
• See which streaming path you're on, plus a "What the agent sees" sheet showing the agent's exact context.
|
||||
• ~3× faster cold start and honest loading states.
|
||||
• In-app crash reporting with one-tap bug reports.
|
||||
• Fixes: QR pairing on foldables, server-image & PDF crashes, in-chat model picks now apply.
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Google Play flavor strings.
|
||||
|
||||
`a11y_description_googleplay` is the user-facing description shown in
|
||||
Android's Accessibility settings when enabling the Hermes Bridge service.
|
||||
It is ALSO what Play Store reviewers read when evaluating our
|
||||
AccessibilityService use-case declaration, so phrasing matters: stay
|
||||
narrowly scoped, emphasize user confirmation, emphasize dormancy until
|
||||
the user opts in inside the app.
|
||||
|
||||
Do not reference voice or vision features here — tier 3/4/6 are gated
|
||||
off for this flavor via FeatureFlags.BuildFlavor.
|
||||
-->
|
||||
<resources>
|
||||
<string name="a11y_service_label">Hermes-Bridge</string>
|
||||
<string name="a11y_description_googleplay">Hermes assists you by reading on-screen content and summarizing notifications. The service is read-only — it does not perform taps, type text, or control other apps. It is dormant until you explicitly enable Bridge mode in the app.</string>
|
||||
</resources>
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Google Play AccessibilityService configuration.
|
||||
|
||||
Conservative event-type subset targeted at the "read notifications,
|
||||
summarize messages, reply with confirmation" use case Play Store policy
|
||||
review expects. Does NOT subscribe to typeAllMask, does NOT request
|
||||
gestures, does NOT request flagRetrieveInteractiveWindows.
|
||||
|
||||
Keep these attributes aligned with the description in strings.xml
|
||||
(`a11y_description_googleplay`) — if the description widens, reviewers
|
||||
will expect the config to widen too.
|
||||
-->
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/a11y_description_googleplay"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged|typeViewClicked"
|
||||
android:accessibilityFlags="flagDefault"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:notificationTimeout="100"
|
||||
android:canRetrieveWindowContent="true" />
|
||||
@@ -1,79 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<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" />
|
||||
<!-- === A8 wake-lock: keep CPU awake while dispatching bridge gestures === -->
|
||||
<!-- Normal-protection permission (no runtime prompt). Held only inside
|
||||
WakeLockManager.wakeForAction { ... }, with a 10s hard timeout and
|
||||
ref-counted release. -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- === PHASE3-accessibility: AccessibilityService + bridge permissions === -->
|
||||
<!-- BIND_ACCESSIBILITY_SERVICE is intentionally NOT declared as a
|
||||
<uses-permission> here — it's a system-only permission granted to
|
||||
services that declare android:permission on their <service> tag
|
||||
(see the BridgeAccessibilityService entry below). Declaring it as
|
||||
a uses-permission trips lint's [ProtectedPermissions] check.
|
||||
|
||||
FOREGROUND_SERVICE* are for the persistent notification that
|
||||
Agent safety-rails will wire in Wave 2 for MediaProjection-backed
|
||||
screenshots. POST_NOTIFICATIONS is required on API 33+ for that
|
||||
same foreground-service notification. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!-- FOREGROUND_SERVICE_MEDIA_PROJECTION moved to sideload manifest.
|
||||
googlePlay doesn't need screen recording: /screenshot route is
|
||||
gated sideload-only in BridgeCommandHandler. Declaring the
|
||||
permission on the Play track would flag review since our
|
||||
accessibility use-case ("read-only screen reading") doesn't
|
||||
justify screen capture. -->
|
||||
<!-- 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" />
|
||||
<!-- === END PHASE3-accessibility === -->
|
||||
|
||||
<!-- === PHASE3-safety-rails: safety service + overlay === -->
|
||||
<!-- SYSTEM_ALERT_WINDOW is user-granted via Settings.ACTION_MANAGE_OVERLAY_PERMISSION.
|
||||
Used for (a) the destructive-verb confirmation modal that must be
|
||||
visible even when Hermes isn't in the foreground, and (b) the optional
|
||||
floating "Hermes active" status chip. The permission is declared here
|
||||
so the user-visible grant flow triggers, but the overlay itself only
|
||||
appears when the user has explicitly consented.
|
||||
|
||||
FOREGROUND_SERVICE_SPECIAL_USE is required on Android 14+ for the
|
||||
persistent "Bridge active" notification (BridgeForegroundService),
|
||||
because the specialUse type needs its own declared permission. -->
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<!-- 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" />
|
||||
<!-- === END PHASE3-safety-rails === -->
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<!-- === PHASE3-baseline-handlers: package visibility for /get_apps + /open_app === -->
|
||||
<!-- On Android 11+ (API 30+), apps can only see other packages that
|
||||
are implicitly visible (own UID, system apps, etc.) unless they
|
||||
declare a <queries> filter or hold QUERY_ALL_PACKAGES. The
|
||||
BridgeCommandHandler /get_apps route uses
|
||||
queryIntentActivities(ACTION_MAIN + CATEGORY_LAUNCHER) to enumerate
|
||||
launchable apps, and BridgeSafetySettingsScreen uses the same call
|
||||
to populate the blocklist UI — both need this declaration to see
|
||||
the full launcher set. Without it queryIntentActivities silently
|
||||
returns a near-empty list (typical symptom: blocklist UI shows
|
||||
only Hermes-Relay itself + a handful of system apps).
|
||||
|
||||
Declaring an <intent> filter with ACTION_MAIN + CATEGORY_LAUNCHER
|
||||
is the Play-policy-safe approach — it does NOT require the
|
||||
restricted QUERY_ALL_PACKAGES permission, which Play would
|
||||
otherwise demand a policy declaration for. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
</queries>
|
||||
<!-- === END PHASE3-baseline-handlers === -->
|
||||
|
||||
<application
|
||||
android:name=".HermesRelayApp"
|
||||
android:allowBackup="true"
|
||||
@@ -88,7 +37,10 @@
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="portrait"
|
||||
tools:ignore="LockedOrientationActivity"
|
||||
android:configChanges="uiMode|fontScale|locale|density|orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.HermesRelay.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -106,30 +58,6 @@
|
||||
android:resource="@xml/file_provider_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- === PHASE3-accessibility: AccessibilityService declaration === -->
|
||||
<!-- The @xml/accessibility_service_config resource is provided by
|
||||
the flavor-specific source sets (app/src/googlePlay/ and
|
||||
app/src/sideload/) owned by Agent flavor-split. Each flavor declares its
|
||||
own accessibility use-case description and flag bitset — the
|
||||
googlePlay track declares a conservative "notifications + reply
|
||||
with confirmation" use case, the sideload track declares the
|
||||
full agent-control use case. Gradle merges the flavor XML into
|
||||
main at build time.
|
||||
-->
|
||||
<service
|
||||
android:name=".accessibility.HermesAccessibilityService"
|
||||
android:exported="true"
|
||||
android:label="@string/a11y_service_label"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_service_config" />
|
||||
</service>
|
||||
<!-- === END PHASE3-accessibility === -->
|
||||
|
||||
<!-- === PHASE3-notif-listener: notification companion service === -->
|
||||
<service
|
||||
android:name=".notifications.HermesNotificationCompanion"
|
||||
@@ -142,45 +70,19 @@
|
||||
</service>
|
||||
<!-- === END PHASE3-notif-listener === -->
|
||||
|
||||
<!-- === PHASE3-safety-rails: safety service + overlay === -->
|
||||
<!-- BridgeForegroundService is a plain (non-exported) foreground
|
||||
service driven by BridgeViewModel based on the master toggle.
|
||||
It owns the persistent "Hermes agent has device control"
|
||||
notification.
|
||||
|
||||
foregroundServiceType is the OR of two API 34+ subtypes:
|
||||
|
||||
- specialUse — backs the persistent "bridge active"
|
||||
indicator we shipped with Tier 5 safety rails. Comes
|
||||
with the SPECIAL_USE foreground-service permission and
|
||||
the Play Console policy declaration.
|
||||
|
||||
- mediaProjection — REQUIRED by Android 14+ before any
|
||||
call to MediaProjectionManager.getMediaProjection().
|
||||
Without this declaration, getMediaProjection() returns
|
||||
a projection that the system auto-revokes within a
|
||||
frame, leaving us with a permanently-null
|
||||
MediaProjectionHolder.projection. Symptom on the
|
||||
device: consent dialog appears, user allows full
|
||||
screen, dialog closes, grant evaporates. Sample-tested
|
||||
on a Samsung S24 / Android 14 on 2026-04-12.
|
||||
|
||||
Both types share the same notification + same lifecycle —
|
||||
one service, one notification, two type slots.
|
||||
|
||||
Android 14+ requires a <property> tag justifying the
|
||||
specialUse subtype. The mediaProjection subtype does NOT
|
||||
need a property tag because it has its own dedicated
|
||||
permission (FOREGROUND_SERVICE_MEDIA_PROJECTION). -->
|
||||
<!-- Opt-in "Keep connected in background" — holds the gateway chat
|
||||
socket open while backgrounded. In main so BOTH flavors ship it
|
||||
(Home-Assistant-class persistent connection). Off by default; only
|
||||
runs while the user has explicitly enabled the toggle. specialUse
|
||||
needs a Play Console foreground-service declaration at submission. -->
|
||||
<service
|
||||
android:name=".bridge.BridgeForegroundService"
|
||||
android:name=".network.upstream.GatewayKeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Maintains a persistent WebSocket connection to the user's Hermes server for real-time chat relay and notification mirroring. The service is dormant until the user explicitly enables Bridge mode in the app." />
|
||||
android:value="Keeps the user's chat connection to their Hermes agent open while the app is backgrounded, only when the user has explicitly enabled 'Keep connected in background'." />
|
||||
</service>
|
||||
<!-- === END PHASE3-safety-rails === -->
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 8px 6px 0 8px;
|
||||
/* 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;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.xterm .xterm-viewport {
|
||||
@@ -149,6 +151,18 @@
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -223,6 +237,13 @@
|
||||
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 (_) {}
|
||||
};
|
||||
@@ -239,6 +260,36 @@
|
||||
}
|
||||
};
|
||||
|
||||
// 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,25 +1,32 @@
|
||||
v0.5.1 — Voice Mode Quality Pass
|
||||
v1.2.0 - Make it yours
|
||||
|
||||
Voice Quality
|
||||
• Gapless TTS playback — Media3 ExoPlayer with persistent player + addMediaItem
|
||||
• Sanitizer strips markdown, tool annotations, URLs, and emoji before ElevenLabs
|
||||
(chat UI still shows emoji — only the voice path is cleaned)
|
||||
• Sentence coalescing + 800ms idle flush so short fragments join naturally
|
||||
• Prefetch-while-playing pipeline — no dead air between sentence chunks
|
||||
Personalize
|
||||
* 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.
|
||||
|
||||
Conversational Barge-In (opt-in, Voice Settings)
|
||||
• Interrupt the agent by just speaking — Silero VAD + AEC + hysteresis
|
||||
• Soft-duck on first positive, hard-cut on confirmed speech
|
||||
• Optional resume-from-next-sentence if the barge-in was a cough
|
||||
• Sensitivity picker (Off / Low / Default / High) + AEC compatibility hint
|
||||
See what's happening
|
||||
* 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.
|
||||
|
||||
Silence Auto-Stop
|
||||
• The Silence Threshold slider in Voice Settings finally works — Continuous
|
||||
and Tap-to-Talk modes now auto-submit after your configured silence window
|
||||
• Grace window: auto-stop waits until you've actually started speaking
|
||||
• Hold-to-Talk unchanged (physical release is the stop signal)
|
||||
Privacy
|
||||
* When paired to the relay, the agent can mark private media and the phone
|
||||
blurs it per your setting — sensitivity stays model-emitted.
|
||||
|
||||
Fixes
|
||||
• Final short sentence with emoji now spoken in Continuous mode
|
||||
• Continuous mode preference survives app restarts
|
||||
• Bootstrap gateway crash on startup ('tuple' has no attribute 'freeze') — fixed
|
||||
Faster & more reliable
|
||||
* 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.
|
||||
|
||||
Voice & terminal
|
||||
* Enhanced voice control for Gemini and xAI providers.
|
||||
* Leaner terminal with TUI-correct input and an isolated, tuned tmux.
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
package com.hermesandroid.relay
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import androidx.compose.ui.ComposeUiFlags
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.power.WakeLockManager
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
import com.hermesandroid.relay.util.CrashReporter
|
||||
|
||||
class HermesRelayApp : Application() {
|
||||
class HermesRelayApp : Application(), SingletonImageLoader.Factory {
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
override fun attachBaseContext(base: android.content.Context?) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
ComposeUiFlags.isAdaptiveRefreshRateEnabled = false
|
||||
}
|
||||
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()) }
|
||||
.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
|
||||
|
||||
@@ -18,8 +18,9 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.hermesandroid.relay.accessibility.ScreenCaptureRequester
|
||||
import com.hermesandroid.relay.bridge.BridgeForegroundService
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
|
||||
import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.util.ComposeArrWorkaround
|
||||
import com.hermesandroid.relay.util.NavRouteRequest
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
|
||||
@@ -36,7 +37,7 @@ class MainActivity : ComponentActivity() {
|
||||
// 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 AFTER consent. So we hand the
|
||||
// that startForeground call must happen AFT consent. So we hand the
|
||||
// result off to BridgeForegroundService, which:
|
||||
// 1. Upgrades its FGS type to SPECIAL_USE | MEDIA_PROJECTION
|
||||
// 2. Calls MediaProjectionHolder.acceptGrantInsideForegroundService
|
||||
@@ -50,6 +51,10 @@ class MainActivity : ComponentActivity() {
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
val data = result.data
|
||||
if (!BuildFlavor.isSideload) {
|
||||
Log.w(TAG, "Ignoring MediaProjection result on Google Play Bridge Core build")
|
||||
return@registerForActivityResult
|
||||
}
|
||||
if (result.resultCode == RESULT_OK && data != null) {
|
||||
Log.i(TAG, "MediaProjection consent granted — handing off to FGS")
|
||||
BridgeForegroundService.grantMediaProjection(this, result.resultCode, data)
|
||||
@@ -88,13 +93,15 @@ class MainActivity : ComponentActivity() {
|
||||
// Hand the launcher to the process-singleton rendezvous so
|
||||
// BridgeViewModel.requestScreenCapture() can fire the consent
|
||||
// dialog without holding an Activity reference.
|
||||
ScreenCaptureRequester.install {
|
||||
val mgr = getSystemService(Context.MEDIA_PROJECTION_SERVICE)
|
||||
as MediaProjectionManager
|
||||
try {
|
||||
mediaProjectionLauncher.launch(mgr.createScreenCaptureIntent())
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "failed to launch MediaProjection consent: ${t.message}")
|
||||
if (BuildFlavor.isSideload) {
|
||||
ScreenCaptureRequester.install {
|
||||
val mgr = getSystemService(Context.MEDIA_PROJECTION_SERVICE)
|
||||
as MediaProjectionManager
|
||||
try {
|
||||
mediaProjectionLauncher.launch(mgr.createScreenCaptureIntent())
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "failed to launch MediaProjection consent: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
@@ -110,9 +117,6 @@ class MainActivity : ComponentActivity() {
|
||||
setContent {
|
||||
RelayApp()
|
||||
}
|
||||
window.decorView.post {
|
||||
ComposeArrWorkaround.disableForViewTree(window.decorView)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
@@ -135,20 +139,29 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Returning to the app clears the one-slot "Hermes finished
|
||||
// responding" notification — the chat surface is the answer.
|
||||
TurnCompleteNotifier.cancel(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-
|
||||
// access manager only attempts dismiss when an activity is
|
||||
// registered AND the user has opted in.
|
||||
UnattendedAccessManager.setHostActivity(this)
|
||||
if (BuildFlavor.isSideload) {
|
||||
UnattendedAccessManager.setHostActivity(this)
|
||||
}
|
||||
// Re-probe the credential-lock state on resume so the Bridge
|
||||
// tab badge updates immediately if the user just changed their
|
||||
// lock screen in system Settings between app sessions.
|
||||
UnattendedAccessManager.refreshKeyguardState()
|
||||
if (BuildFlavor.isSideload) {
|
||||
UnattendedAccessManager.refreshKeyguardState()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
UnattendedAccessManager.setHostActivity(null)
|
||||
if (BuildFlavor.isSideload) {
|
||||
UnattendedAccessManager.setHostActivity(null)
|
||||
}
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
@@ -157,7 +170,9 @@ class MainActivity : ComponentActivity() {
|
||||
// Drop the launcher closure so we don't hold a stale Activity ref
|
||||
// after destroy. ScreenCaptureRequester.request() will return false
|
||||
// until the next MainActivity instance reinstalls itself.
|
||||
ScreenCaptureRequester.uninstall()
|
||||
if (BuildFlavor.isSideload) {
|
||||
ScreenCaptureRequester.uninstall()
|
||||
}
|
||||
// === END PHASE3-bridge-ui-followup ===
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -140,8 +140,11 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
fun ok(data: Map<String, Any?> = mapOf("ok" to true)): ActionResult =
|
||||
ActionResult(ok = true, data = data)
|
||||
|
||||
fun failure(message: String): ActionResult =
|
||||
ActionResult(ok = false, error = message)
|
||||
fun failure(
|
||||
message: String,
|
||||
data: Map<String, Any?> = emptyMap(),
|
||||
): ActionResult =
|
||||
ActionResult(ok = false, data = data, error = message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1548,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.handlers.BridgeCommandHandler]
|
||||
* [com.hermesandroid.relay.network.relay.BridgeCommandHandler]
|
||||
* before we even get here — by the time this method runs, the user
|
||||
* has explicitly approved the call.
|
||||
*/
|
||||
@@ -1622,13 +1625,22 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
*/
|
||||
suspend fun sendSms(to: String, body: String): ActionResult {
|
||||
if (to.isBlank()) {
|
||||
return ActionResult.failure("send_sms: recipient must be non-blank")
|
||||
return ActionResult.failure(
|
||||
"send_sms: recipient must be non-blank",
|
||||
mapOf("status" to "failed", "reason" to "invalid_recipient"),
|
||||
)
|
||||
}
|
||||
if (body.isEmpty()) {
|
||||
return ActionResult.failure("send_sms: body must be non-empty")
|
||||
return ActionResult.failure(
|
||||
"send_sms: body must be non-empty",
|
||||
mapOf("status" to "failed", "reason" to "invalid_schema"),
|
||||
)
|
||||
}
|
||||
if (!to.matches(Regex("^[+0-9 ()\\-.]{2,}$"))) {
|
||||
return ActionResult.failure("send_sms: recipient contains invalid characters")
|
||||
return ActionResult.failure(
|
||||
"send_sms: recipient contains invalid characters",
|
||||
mapOf("status" to "failed", "reason" to "invalid_recipient"),
|
||||
)
|
||||
}
|
||||
|
||||
val ctx: Context = service
|
||||
@@ -1636,7 +1648,12 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return ActionResult.failure(
|
||||
"Grant SMS permission in Settings > Apps > Hermes-Relay > Permissions"
|
||||
"Grant SMS permission in Settings > Apps > Hermes-Relay > Permissions",
|
||||
mapOf(
|
||||
"status" to "blocked",
|
||||
"reason" to "permission_denied",
|
||||
"required_permission" to Manifest.permission.SEND_SMS,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1652,7 +1669,10 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
null
|
||||
}
|
||||
if (smsManager == null) {
|
||||
return ActionResult.failure("SmsManager unavailable on this device")
|
||||
return ActionResult.failure(
|
||||
"SmsManager unavailable on this device",
|
||||
mapOf("status" to "failed", "reason" to "sms_manager_unavailable"),
|
||||
)
|
||||
}
|
||||
|
||||
// Pick one intent action value — the receiver identifies us by
|
||||
@@ -1708,7 +1728,10 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
ContextCompat.registerReceiver(ctx, receiver, filter, flags)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "registerReceiver for SMS_SENT threw: ${t.message}")
|
||||
return ActionResult.failure("sms receiver registration failed: ${t.message}")
|
||||
return ActionResult.failure(
|
||||
"sms receiver registration failed: ${t.message}",
|
||||
mapOf("status" to "failed", "reason" to "receiver_registration_failed"),
|
||||
)
|
||||
}
|
||||
|
||||
// One PendingIntent per part — SmsManager fires the broadcast with
|
||||
@@ -1741,12 +1764,20 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
} catch (se: SecurityException) {
|
||||
try { ctx.unregisterReceiver(receiver) } catch (_: Throwable) { }
|
||||
return ActionResult.failure(
|
||||
"SMS permission revoked or restricted — re-grant in system Settings"
|
||||
"SMS permission revoked or restricted — re-grant in system Settings",
|
||||
mapOf(
|
||||
"status" to "blocked",
|
||||
"reason" to "permission_denied",
|
||||
"required_permission" to Manifest.permission.SEND_SMS,
|
||||
),
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
try { ctx.unregisterReceiver(receiver) } catch (_: Throwable) { }
|
||||
Log.w(TAG, "sendTextMessage threw: ${t.message}")
|
||||
return ActionResult.failure("send_sms failed: ${t.message}")
|
||||
return ActionResult.failure(
|
||||
"send_sms failed: ${t.message}",
|
||||
mapOf("status" to "failed", "reason" to "android_exception"),
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for the receiver to complete — with a 15s cap. If the radio
|
||||
@@ -1757,18 +1788,28 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
|
||||
if (result == null) {
|
||||
return ActionResult.failure(
|
||||
"send_sms timeout after ${SEND_SMS_TIMEOUT_MS}ms — carrier never acked"
|
||||
"send_sms timeout after ${SEND_SMS_TIMEOUT_MS}ms — carrier never acked",
|
||||
mapOf(
|
||||
"status" to "timeout",
|
||||
"reason" to "carrier_ack_timeout",
|
||||
"android_result" to "timeout",
|
||||
"parts" to expectedParts,
|
||||
),
|
||||
)
|
||||
}
|
||||
return if (result == android.app.Activity.RESULT_OK) {
|
||||
ActionResult.ok(
|
||||
mapOf(
|
||||
"status" to "sent",
|
||||
"android_result" to "RESULT_OK",
|
||||
"to" to to,
|
||||
"length" to body.length,
|
||||
"parts" to expectedParts,
|
||||
"summary" to "SMS sent to $to ($expectedParts part(s))",
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val androidResult = smsResultName(result)
|
||||
val reason = when (result) {
|
||||
SmsManager.RESULT_ERROR_GENERIC_FAILURE -> "generic failure"
|
||||
SmsManager.RESULT_ERROR_NO_SERVICE -> "no service"
|
||||
@@ -1776,8 +1817,25 @@ class ActionExecutor(private val service: HermesAccessibilityService) {
|
||||
SmsManager.RESULT_ERROR_RADIO_OFF -> "radio off (airplane mode?)"
|
||||
else -> "result code $result"
|
||||
}
|
||||
ActionResult.failure("send_sms failed: $reason")
|
||||
ActionResult.failure(
|
||||
"send_sms failed: $reason",
|
||||
mapOf(
|
||||
"status" to "failed",
|
||||
"reason" to reason,
|
||||
"android_result" to androidResult,
|
||||
"parts" to expectedParts,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun smsResultName(result: Int): String = when (result) {
|
||||
android.app.Activity.RESULT_OK -> "RESULT_OK"
|
||||
SmsManager.RESULT_ERROR_GENERIC_FAILURE -> "RESULT_ERROR_GENERIC_FAILURE"
|
||||
SmsManager.RESULT_ERROR_NO_SERVICE -> "RESULT_ERROR_NO_SERVICE"
|
||||
SmsManager.RESULT_ERROR_NULL_PDU -> "RESULT_ERROR_NULL_PDU"
|
||||
SmsManager.RESULT_ERROR_RADIO_OFF -> "RESULT_ERROR_RADIO_OFF"
|
||||
else -> "RESULT_$result"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import android.util.Log
|
||||
import com.hermesandroid.relay.bridge.BridgeSafetyManager
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.network.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -65,10 +65,10 @@ import kotlinx.serialization.json.put
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* `unattended.supported` is false on the googlePlay flavor — the Play
|
||||
* APK has no wake-lock path — which lets the agent distinguish "user
|
||||
* hasn't opted in" from "this build can't do unattended at all" without
|
||||
* a separate probe.
|
||||
* `bridge.device_control_supported` and `unattended.supported` are false on
|
||||
* the googlePlay flavor — the Play APK ships Bridge Core without
|
||||
* AccessibilityService, wake locks, overlays, screenshots, or unattended
|
||||
* control — which lets the agent avoid attempting sideload-only tools.
|
||||
*
|
||||
* The legacy top-level keys (`screen_on`, `battery`, `current_app`,
|
||||
* `accessibility_enabled`, `ts`) are ALSO emitted for backwards
|
||||
@@ -188,6 +188,7 @@ class BridgeStatusReporter(
|
||||
val currentApp = HermesAccessibilityService.instance?.currentApp
|
||||
val accessibilityGranted = HermesAccessibilityService.instance != null
|
||||
val masterEnabled = HermesAccessibilityService.instance?.isMasterEnabled() ?: false
|
||||
val deviceControlSupported = BuildFlavor.isSideload
|
||||
|
||||
// Screen-capture grant — the process-singleton holder is non-null
|
||||
// iff the user granted MediaProjection consent this session.
|
||||
@@ -257,10 +258,17 @@ class BridgeStatusReporter(
|
||||
})
|
||||
})
|
||||
put("bridge", buildJsonObject {
|
||||
put("master_enabled", masterEnabled)
|
||||
put("accessibility_granted", accessibilityGranted)
|
||||
put("screen_capture_granted", screenCaptureGranted)
|
||||
put("overlay_granted", overlayGranted)
|
||||
put("device_control_supported", deviceControlSupported)
|
||||
put("master_enabled", if (deviceControlSupported) masterEnabled else false)
|
||||
put(
|
||||
"accessibility_granted",
|
||||
if (deviceControlSupported) accessibilityGranted else false,
|
||||
)
|
||||
put(
|
||||
"screen_capture_granted",
|
||||
if (deviceControlSupported) screenCaptureGranted else false,
|
||||
)
|
||||
put("overlay_granted", if (deviceControlSupported) overlayGranted else false)
|
||||
put("notification_listener_granted", notificationListenerGranted)
|
||||
})
|
||||
put("safety", buildJsonObject {
|
||||
@@ -285,11 +293,18 @@ class BridgeStatusReporter(
|
||||
// when both `enabled=true` and this is true, commands
|
||||
// will wake the screen but stop at the lock screen.
|
||||
put("unattended", buildJsonObject {
|
||||
put("supported", BuildFlavor.isSideload)
|
||||
put("enabled", UnattendedAccessManager.enabled.value)
|
||||
put("supported", deviceControlSupported)
|
||||
put(
|
||||
"enabled",
|
||||
if (deviceControlSupported) UnattendedAccessManager.enabled.value else false,
|
||||
)
|
||||
put(
|
||||
"credential_lock_detected",
|
||||
UnattendedAccessManager.credentialLockDetected.value,
|
||||
if (deviceControlSupported) {
|
||||
UnattendedAccessManager.credentialLockDetected.value
|
||||
} else {
|
||||
false
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -300,8 +315,8 @@ class BridgeStatusReporter(
|
||||
// groups above.
|
||||
put("screen_on", screenOn)
|
||||
put("battery", batteryFinal)
|
||||
put("current_app", currentApp ?: "unknown")
|
||||
put("accessibility_enabled", accessibilityGranted)
|
||||
put("current_app", if (deviceControlSupported) currentApp ?: "unknown" else "unknown")
|
||||
put("accessibility_enabled", if (deviceControlSupported) accessibilityGranted else false)
|
||||
put("ts", System.currentTimeMillis())
|
||||
}
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ import kotlinx.coroutines.launch
|
||||
*
|
||||
* # Master enable / disable
|
||||
*
|
||||
* The Android system toggle in `Settings → Accessibility → Hermes Relay` is
|
||||
* The Android system toggle in `Settings → Accessibility → Hermes-Relay` is
|
||||
* the hard switch — if it's off we never receive events. On top of that the
|
||||
* user can flip a soft master in Settings (`bridge_master_enabled`); when
|
||||
* that's false we still run (Android requires it to stay connected) but we
|
||||
@@ -68,7 +68,7 @@ class HermesAccessibilityService : AccessibilityService() {
|
||||
* service is not running. Written on [onServiceConnected],
|
||||
* cleared on [onUnbind] / [onDestroy].
|
||||
*
|
||||
* Read by [com.hermesandroid.relay.network.handlers.BridgeCommandHandler]
|
||||
* Read by [com.hermesandroid.relay.network.relay.BridgeCommandHandler]
|
||||
* and by the Bridge UI screen (bridge-ui) to check live status.
|
||||
*/
|
||||
@Volatile
|
||||
@@ -267,13 +267,12 @@ class HermesAccessibilityService : AccessibilityService() {
|
||||
* # Fallback semantics
|
||||
*
|
||||
* `service.windows` returns an empty list unless the accessibility
|
||||
* config XML requests `flagRetrieveInteractiveWindows`. That flag is
|
||||
* **only** set in the `sideload` flavor — the `googlePlay` flavor
|
||||
* deliberately runs on the conservative config subset to pass Play
|
||||
* Store policy review. When `windows` is empty (or throws, or every
|
||||
* window's root is null) we fall back to a single-element list
|
||||
* wrapping [rootInActiveWindow], preserving pre-P1 behaviour on
|
||||
* `googlePlay` builds.
|
||||
* config XML requests `flagRetrieveInteractiveWindows`. The service is
|
||||
* declared only by the `sideload` manifest, and that sideload config sets
|
||||
* the flag. When `windows` is empty (or throws, or every window's root is
|
||||
* null) we fall back to a single-element list wrapping
|
||||
* [rootInActiveWindow], preserving pre-P1 behaviour for tests and
|
||||
* defensive runtime fallback.
|
||||
*
|
||||
* Returns an empty list only if the service cannot read any window
|
||||
* root at all (e.g. lock screen, master-off state). Callers should
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -175,11 +176,26 @@ class BargeInListener internal constructor(
|
||||
_aecAttached.value = false
|
||||
readerJob = scope.launch(readerDispatcher) {
|
||||
try {
|
||||
audioSource.start()
|
||||
try {
|
||||
audioSource.start()
|
||||
} catch (t: CancellationException) {
|
||||
throw t
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "AudioFrameSource.start failed: ${t.message}")
|
||||
return@launch
|
||||
}
|
||||
Log.i(TAG, "Barge-in AudioRecord reader started")
|
||||
maybeAttachEffects()
|
||||
|
||||
while (isActive) {
|
||||
val read = audioSource.read(frameBuffer, VadEngine.FRAME_SIZE_SAMPLES)
|
||||
val read = try {
|
||||
audioSource.read(frameBuffer, VadEngine.FRAME_SIZE_SAMPLES)
|
||||
} catch (t: CancellationException) {
|
||||
throw t
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "AudioFrameSource.read failed; stopping reader: ${t.message}")
|
||||
break
|
||||
}
|
||||
if (read <= 0) {
|
||||
// Negative values are AudioRecord error codes; 0 means
|
||||
// no data yet. Either way, yield briefly and retry
|
||||
@@ -196,7 +212,16 @@ class BargeInListener internal constructor(
|
||||
continue
|
||||
}
|
||||
|
||||
val result = vadEngine.analyze(frameBuffer)
|
||||
if (!isActive) break
|
||||
|
||||
val result = try {
|
||||
vadEngine.analyze(frameBuffer)
|
||||
} catch (t: CancellationException) {
|
||||
throw t
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "VadEngine.analyze failed; stopping reader: ${t.message}")
|
||||
break
|
||||
}
|
||||
if (result.probability > 0f) {
|
||||
_maybeSpeech.tryEmit(Unit)
|
||||
}
|
||||
@@ -228,9 +253,14 @@ class BargeInListener internal constructor(
|
||||
* actual release happens in the reader coroutine's `finally` block, which
|
||||
* is typically a single frame later.
|
||||
*/
|
||||
fun stop() {
|
||||
readerJob?.cancel()
|
||||
fun stop(): Job? {
|
||||
val job = readerJob
|
||||
if (job?.isActive == true) {
|
||||
Log.i(TAG, "Stopping barge-in AudioRecord reader")
|
||||
}
|
||||
job?.cancel()
|
||||
readerJob = null
|
||||
return job
|
||||
}
|
||||
|
||||
private suspend fun maybeAttachEffects() {
|
||||
@@ -253,6 +283,7 @@ class BargeInListener internal constructor(
|
||||
created.enabled = true
|
||||
aec = created
|
||||
_aecAttached.value = true
|
||||
Log.i(TAG, "AcousticEchoCanceler attached to session=$sessionId")
|
||||
} else {
|
||||
Log.i(TAG, "AcousticEchoCanceler.create returned null; continuing without")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,830 @@
|
||||
package com.hermesandroid.relay.audio
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Small streaming PCM sink for the realtime voice dev testbench.
|
||||
*
|
||||
* The relay sends mono 16-bit little-endian PCM chunks over the websocket. This
|
||||
* writes them directly to an AudioTrack so the Android Studio dev build can
|
||||
* hear provider output without waiting for an encoded file.
|
||||
*/
|
||||
class RealtimePcmPlayer(context: Context? = null) {
|
||||
private val trackLock = Any()
|
||||
private val writeLock = Any()
|
||||
private val audioManager =
|
||||
context?.applicationContext?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
private val realtimeAudioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { change ->
|
||||
Log.i(TAG, "Realtime PCM audio focus change=$change")
|
||||
}
|
||||
private var audioTrack: AudioTrack? = null
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private var audioFocusHeld: Boolean = false
|
||||
private var currentSampleRate: Int = 0
|
||||
private var currentVolume: Float = 1f
|
||||
private var estimatedPlaybackEndAtMs: Long = 0L
|
||||
private var playbackStarted: Boolean = false
|
||||
private var pendingStartBytes: Int = 0
|
||||
private var firstBufferedAtMs: Long = 0L
|
||||
private var lastUnderrunCount: Int = 0
|
||||
private var lastHeadPositionLogAtMs: Long = 0L
|
||||
private var lastLoggedHeadFrames: Int = 0
|
||||
private var headAdvanceConfirmed: Boolean = false
|
||||
private var playbackStartedAtMs: Long = 0L
|
||||
private var totalFramesWritten: Long = 0L
|
||||
// (endFrame, rms) per written chunk — lets [playbackAmplitude] report the
|
||||
// amplitude of the audio actually at the hardware cursor right now, instead
|
||||
// of the chunk that most recently *arrived* over the socket.
|
||||
private val playbackAmpQueue = ArrayDeque<FrameAmp>()
|
||||
private var lastPlaybackGapDiagnosticAtMs: Long = 0L
|
||||
private var lastMutedVolumeDiagnosticAtMs: Long = 0L
|
||||
private var adaptiveStartPrebufferMs: Long = RealtimePcmBufferPolicy.START_PREBUFFER_MS
|
||||
private var playbackGapSeenThisTrack: Boolean = false
|
||||
private val _amplitude = MutableStateFlow(0f)
|
||||
val amplitude: StateFlow<Float> = _amplitude.asStateFlow()
|
||||
|
||||
val isActive: Boolean
|
||||
get() = synchronized(trackLock) { audioTrack != null }
|
||||
|
||||
val audioSessionId: Int
|
||||
get() = synchronized(trackLock) { audioTrack?.audioSessionId ?: 0 }
|
||||
|
||||
fun write(pcm: ByteArray, sampleRate: Int): Float {
|
||||
if (pcm.isEmpty()) return 0f
|
||||
val level = computePcm16LeRms(pcm)
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val written = synchronized(writeLock) {
|
||||
val track = try {
|
||||
synchronized(trackLock) {
|
||||
val currentTrack = ensureTrackLocked(sampleRate)
|
||||
notePlaybackGapLocked(currentTrack, now)
|
||||
currentTrack
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "PCM track preparation failed: ${e.message}")
|
||||
synchronized(trackLock) { releaseTrackLocked(reason = "PCM track preparation failure") }
|
||||
return@synchronized 0
|
||||
}
|
||||
|
||||
try {
|
||||
val prerollWritten = maybeWriteStartupPreroll(track, sampleRate)
|
||||
if (prerollWritten < 0) {
|
||||
Log.w(TAG, "PCM preroll write returned $prerollWritten; restarting track")
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) releaseTrackLocked(reason = "PCM preroll write error")
|
||||
}
|
||||
return@synchronized 0
|
||||
}
|
||||
// Intentionally do NOT start playback on the bare silent preroll.
|
||||
// Starting here would begin draining ~120ms of silence with zero
|
||||
// real audio queued, guaranteeing an immediate underrun on the
|
||||
// first speech chunk. The real audio written just below feeds the
|
||||
// normal start decision, and the end-of-turn flush
|
||||
// (voice.output_audio.done) force-starts anything still buffered.
|
||||
|
||||
val writtenBytes = writeBlocking(track, pcm)
|
||||
if (writtenBytes < 0) {
|
||||
Log.w(TAG, "PCM write returned $writtenBytes; restarting track")
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) releaseTrackLocked(reason = "PCM write error")
|
||||
}
|
||||
return@synchronized 0
|
||||
}
|
||||
|
||||
var accepted = 0
|
||||
if (writtenBytes > 0) {
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) {
|
||||
noteWrittenBytesLocked(writtenBytes, sampleRate)
|
||||
enqueuePlaybackAmplitudeLocked(level)
|
||||
maybeStartPlaybackLocked(track, sampleRate, force = false)
|
||||
updateUnderrunCursorLocked(track)
|
||||
logPlaybackHealthLocked(track, now)
|
||||
accepted = writtenBytes
|
||||
}
|
||||
}
|
||||
}
|
||||
accepted
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "PCM write failed: ${e.message}")
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) releaseTrackLocked(reason = "PCM write failure")
|
||||
}
|
||||
return@synchronized 0
|
||||
}
|
||||
}
|
||||
if (written > 0) {
|
||||
_amplitude.value = level
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
private fun writeBlocking(track: AudioTrack, pcm: ByteArray): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
track.write(pcm, 0, pcm.size, AudioTrack.WRITE_BLOCKING)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
track.write(pcm, 0, pcm.size)
|
||||
}
|
||||
|
||||
fun flushBufferedPlayback(cushionMs: Long = DEFAULT_DRAIN_CUSHION_MS): Long {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
return synchronized(trackLock) {
|
||||
val track = audioTrack ?: return@synchronized 0L
|
||||
maybeStartPlaybackLocked(track, currentSampleRate, force = true)
|
||||
val remaining = remainingPlaybackMsLocked(now, cushionMs)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM flush playState=${readPlayState(track)} " +
|
||||
"headFrames=${readHeadFrames(track)} remainingMs=$remaining " +
|
||||
"underruns=${readUnderrunCount(track)}",
|
||||
)
|
||||
remaining
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
synchronized(writeLock) {
|
||||
synchronized(trackLock) {
|
||||
releaseTrackLocked(reason = "stop")
|
||||
currentSampleRate = 0
|
||||
estimatedPlaybackEndAtMs = 0L
|
||||
}
|
||||
}
|
||||
_amplitude.value = 0f
|
||||
}
|
||||
|
||||
fun estimatedRemainingPlaybackMs(cushionMs: Long = DEFAULT_DRAIN_CUSHION_MS): Long {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
return synchronized(trackLock) {
|
||||
remainingPlaybackMsLocked(now, cushionMs)
|
||||
}
|
||||
}
|
||||
|
||||
fun setVolume(volume: Float) {
|
||||
val clamped = volume.coerceIn(0f, 1f)
|
||||
synchronized(trackLock) {
|
||||
currentVolume = clamped
|
||||
try { audioTrack?.setVolume(clamped) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
fun duck() {
|
||||
setVolume(0.3f)
|
||||
}
|
||||
|
||||
fun unduck() {
|
||||
setVolume(1f)
|
||||
}
|
||||
|
||||
private fun releaseTrackLocked(reason: String) {
|
||||
audioTrack?.let { track ->
|
||||
Log.i(TAG, "Stopping streaming PCM playback ($reason)")
|
||||
try { track.pause() } catch (_: Exception) { }
|
||||
try { track.flush() } catch (_: Exception) { }
|
||||
try { track.release() } catch (_: Exception) { }
|
||||
}
|
||||
abandonAudioFocusLocked()
|
||||
settleAdaptivePrebufferLocked()
|
||||
audioTrack = null
|
||||
playbackStarted = false
|
||||
pendingStartBytes = 0
|
||||
firstBufferedAtMs = 0L
|
||||
lastUnderrunCount = 0
|
||||
lastHeadPositionLogAtMs = 0L
|
||||
lastLoggedHeadFrames = 0
|
||||
headAdvanceConfirmed = false
|
||||
playbackStartedAtMs = 0L
|
||||
totalFramesWritten = 0L
|
||||
playbackAmpQueue.clear()
|
||||
playbackGapSeenThisTrack = false
|
||||
}
|
||||
|
||||
private fun enqueuePlaybackAmplitudeLocked(rms: Float) {
|
||||
// [totalFramesWritten] has already been advanced past this chunk, so it
|
||||
// is the chunk's end frame. The cursor reaches this amplitude once
|
||||
// playbackHeadPosition passes the previous end frame.
|
||||
playbackAmpQueue.addLast(FrameAmp(endFrame = totalFramesWritten, rms = rms))
|
||||
while (playbackAmpQueue.size > MAX_AMP_QUEUE) playbackAmpQueue.removeFirst()
|
||||
}
|
||||
|
||||
/**
|
||||
* Amplitude of the audio currently at the hardware cursor (0 if not playing
|
||||
* or drained). This is the playback-synced signal a UI waveform should draw:
|
||||
* it advances with [AudioTrack.getPlaybackHeadPosition], so it matches what
|
||||
* the user hears rather than what most recently arrived over the socket.
|
||||
*/
|
||||
fun playbackAmplitude(): Float = synchronized(trackLock) {
|
||||
val track = audioTrack ?: return@synchronized 0f
|
||||
if (!playbackStarted) return@synchronized 0f
|
||||
val head = readHeadFrames(track).toLong()
|
||||
// Drop fully-played chunks so the head of the queue is the one playing now.
|
||||
while (playbackAmpQueue.size > 1 && playbackAmpQueue.first().endFrame <= head) {
|
||||
playbackAmpQueue.removeFirst()
|
||||
}
|
||||
amplitudeAtHead(playbackAmpQueue, head)
|
||||
}
|
||||
|
||||
private fun ensureTrackLocked(sampleRate: Int): AudioTrack {
|
||||
val existing = audioTrack
|
||||
if (existing != null && currentSampleRate == sampleRate) {
|
||||
return existing
|
||||
}
|
||||
releaseTrackLocked(reason = "sample rate changed")
|
||||
|
||||
val minBuffer = AudioTrack.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
val bufferSize = RealtimePcmBufferPolicy.streamBufferSize(
|
||||
minBufferBytes = minBuffer,
|
||||
sampleRate = sampleRate,
|
||||
)
|
||||
val format = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build()
|
||||
|
||||
val track = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
AudioTrack.Builder()
|
||||
.setAudioAttributes(realtimeAudioAttributes)
|
||||
.setAudioFormat(format)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.setBufferSizeInBytes(bufferSize)
|
||||
.build()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
AudioTrack(
|
||||
AudioManager.STREAM_MUSIC,
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
bufferSize,
|
||||
AudioTrack.MODE_STREAM,
|
||||
)
|
||||
}
|
||||
|
||||
if (track.state != AudioTrack.STATE_INITIALIZED) {
|
||||
try { track.release() } catch (_: Exception) { }
|
||||
throw IllegalStateException("AudioTrack failed to initialize")
|
||||
}
|
||||
|
||||
requestAudioFocusLocked()
|
||||
audioTrack = track
|
||||
currentSampleRate = sampleRate
|
||||
playbackStarted = false
|
||||
pendingStartBytes = 0
|
||||
firstBufferedAtMs = 0L
|
||||
totalFramesWritten = 0L
|
||||
lastUnderrunCount = readUnderrunCount(track)
|
||||
// Log requested vs. actual allocated frames. If a device coerces our
|
||||
// sub-second request back up to a multi-second allocation, that's the
|
||||
// tell-tale of deep-buffer routing (the cold-start parking class) and
|
||||
// explains a regression of the silent-first-turn bug on new hardware.
|
||||
val requestedFrames = bufferSize / BYTES_PER_FRAME
|
||||
val actualFrames = try { track.bufferSizeInFrames } catch (_: Exception) { -1 }
|
||||
Log.i(
|
||||
TAG,
|
||||
"Initialized streaming PCM playback at ${sampleRate}Hz " +
|
||||
"session=${track.audioSessionId} buffer=${bufferSize}B " +
|
||||
"requestedFrames=$requestedFrames actualFrames=$actualFrames " +
|
||||
"(${frameMs(actualFrames, sampleRate)}ms)",
|
||||
)
|
||||
return track
|
||||
}
|
||||
|
||||
private fun frameMs(frames: Int, sampleRate: Int): Long {
|
||||
if (frames <= 0 || sampleRate <= 0) return 0L
|
||||
return (frames * 1000L / sampleRate)
|
||||
}
|
||||
|
||||
private fun noteWrittenBytesLocked(writtenBytes: Int, sampleRate: Int) {
|
||||
if (writtenBytes <= 0 || sampleRate <= 0) return
|
||||
totalFramesWritten += (writtenBytes / BYTES_PER_FRAME).toLong()
|
||||
val durationMs = ((writtenBytes / 2.0) / sampleRate * 1000.0)
|
||||
.toLong()
|
||||
.coerceAtLeast(1L)
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (!playbackStarted) {
|
||||
if (firstBufferedAtMs == 0L) firstBufferedAtMs = now
|
||||
pendingStartBytes += writtenBytes
|
||||
return
|
||||
}
|
||||
val base = max(now, estimatedPlaybackEndAtMs)
|
||||
estimatedPlaybackEndAtMs = base + durationMs
|
||||
}
|
||||
|
||||
private fun maybeWriteStartupPreroll(track: AudioTrack, sampleRate: Int): Int {
|
||||
if (
|
||||
synchronized(trackLock) {
|
||||
playbackStarted ||
|
||||
pendingStartBytes > 0 ||
|
||||
firstBufferedAtMs > 0L ||
|
||||
sampleRate <= 0 ||
|
||||
audioTrack !== track
|
||||
}
|
||||
) {
|
||||
return 0
|
||||
}
|
||||
|
||||
val prerollMs = startupPrerollMsLocked()
|
||||
val silenceBytes = RealtimePcmBufferPolicy.bytesForDurationMs(sampleRate, prerollMs)
|
||||
if (silenceBytes <= 0) return 0
|
||||
|
||||
val written = writeBlocking(track, ByteArray(silenceBytes))
|
||||
if (written > 0) {
|
||||
synchronized(trackLock) {
|
||||
if (audioTrack === track) {
|
||||
noteWrittenBytesLocked(written, sampleRate)
|
||||
enqueuePlaybackAmplitudeLocked(0f) // preroll is silence
|
||||
Log.i(
|
||||
TAG,
|
||||
"Primed realtime PCM playback with " +
|
||||
"${RealtimePcmBufferPolicy.durationMsForBytes(written, sampleRate)}ms " +
|
||||
"silent preroll",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
private fun startupPrerollMsLocked(): Long {
|
||||
return RealtimePcmBufferPolicy.STARTUP_PREROLL_MS
|
||||
}
|
||||
|
||||
private fun maybeStartPlaybackLocked(
|
||||
track: AudioTrack,
|
||||
sampleRate: Int,
|
||||
force: Boolean,
|
||||
) {
|
||||
if (playbackStarted || pendingStartBytes <= 0 || sampleRate <= 0) return
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val waitedMs = if (firstBufferedAtMs > 0L) now - firstBufferedAtMs else 0L
|
||||
val decision = RealtimePcmBufferPolicy.startDecision(
|
||||
pendingBytes = pendingStartBytes,
|
||||
sampleRate = sampleRate,
|
||||
waitedMs = waitedMs,
|
||||
force = force,
|
||||
startPrebufferMs = adaptiveStartPrebufferMs,
|
||||
)
|
||||
if (!decision.shouldStart) return
|
||||
|
||||
try {
|
||||
requestAudioFocusLocked()
|
||||
track.play()
|
||||
try { track.setVolume(currentVolume) } catch (_: Exception) { }
|
||||
} catch (e: Exception) {
|
||||
try { track.release() } catch (_: Exception) { }
|
||||
audioTrack = null
|
||||
throw e
|
||||
}
|
||||
|
||||
playbackStarted = true
|
||||
estimatedPlaybackEndAtMs = now + decision.bufferedMs
|
||||
playbackStartedAtMs = now
|
||||
lastHeadPositionLogAtMs = now
|
||||
lastLoggedHeadFrames = readHeadFrames(track)
|
||||
headAdvanceConfirmed = false
|
||||
Log.i(
|
||||
TAG,
|
||||
"Started streaming PCM playback at ${sampleRate}Hz " +
|
||||
"session=${track.audioSessionId} prebuffer=${decision.bufferedMs}ms " +
|
||||
"waited=${waitedMs}ms target=${adaptiveStartPrebufferMs}ms " +
|
||||
"reason=${decision.reason} playState=${readPlayState(track)} " +
|
||||
"headFrames=$lastLoggedHeadFrames ${mediaVolumeSummaryLocked()}",
|
||||
)
|
||||
pendingStartBytes = 0
|
||||
firstBufferedAtMs = 0L
|
||||
lastUnderrunCount = readUnderrunCount(track)
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodically logs whether the AudioTrack hardware cursor is actually
|
||||
* advancing. This is the decisive signal for the "speaking animation + valid
|
||||
* PCM logs but no sound" class of bug:
|
||||
*
|
||||
* - head frames advancing + still no sound → output route / volume problem
|
||||
* (e.g. the Samsung HAL not opening the path until a volume key nudges it).
|
||||
* - head frames pinned at the start value → the track was play()'d but the
|
||||
* mixer never pulled from it (focus / state problem on this device).
|
||||
*/
|
||||
private fun logPlaybackHealthLocked(track: AudioTrack, now: Long) {
|
||||
if (!playbackStarted) return
|
||||
val headFrames = readHeadFrames(track)
|
||||
// First-frame detection runs on EVERY write until confirmed (not gated by
|
||||
// the throttle) and uses a fresh timestamp, so time-to-first-audio is
|
||||
// accurate to write cadence rather than the 1s health-log window — the
|
||||
// throttle/stale-`now` combination otherwise inflates it by ~1.5s.
|
||||
if (!headAdvanceConfirmed && headFrames > 0) {
|
||||
headAdvanceConfirmed = true
|
||||
val freshNow = SystemClock.elapsedRealtime()
|
||||
val ttfaMs = if (playbackStartedAtMs > 0L) freshNow - playbackStartedAtMs else -1L
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM time-to-first-audio=${ttfaMs}ms (headFrames=$headFrames)",
|
||||
)
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Realtime audio started",
|
||||
detail = "First sample reached the speaker after ${ttfaMs}ms.",
|
||||
)
|
||||
}
|
||||
if (now - lastHeadPositionLogAtMs < HEAD_POSITION_LOG_THROTTLE_MS) return
|
||||
val advancedFrames = headFrames - lastLoggedHeadFrames
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM playback health playState=${readPlayState(track)} " +
|
||||
"headFrames=$headFrames advanced=$advancedFrames " +
|
||||
"underruns=${readUnderrunCount(track)} ${mediaVolumeSummaryLocked()}",
|
||||
)
|
||||
if (advancedFrames <= 0) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Realtime PCM hardware cursor not advancing (headFrames=$headFrames " +
|
||||
"playState=${readPlayState(track)}); audio queued but mixer is not pulling",
|
||||
)
|
||||
maybeRecordStuckCursorDiagnosticLocked(track, now)
|
||||
}
|
||||
lastHeadPositionLogAtMs = now
|
||||
lastLoggedHeadFrames = headFrames
|
||||
}
|
||||
|
||||
/**
|
||||
* If the hardware cursor never started after [STUCK_CURSOR_DIAGNOSTIC_MS] of
|
||||
* "playing", surface it to the in-app Diagnostics screen once per track —
|
||||
* this is the field-visible signal for the cold-start parking class when no
|
||||
* logcat cable is attached. Write-sampled here; the [VoiceViewModel] watchdog
|
||||
* provides the timer-driven guarantee when writes stall.
|
||||
*/
|
||||
private fun maybeRecordStuckCursorDiagnosticLocked(track: AudioTrack, now: Long) {
|
||||
if (headAdvanceConfirmed || playbackStartedAtMs <= 0L) return
|
||||
val stuckMs = now - playbackStartedAtMs
|
||||
if (stuckMs < STUCK_CURSOR_DIAGNOSTIC_MS) return
|
||||
if (now - lastPlaybackGapDiagnosticAtMs < PLAYBACK_GAP_DIAGNOSTIC_THROTTLE_MS) return
|
||||
lastPlaybackGapDiagnosticAtMs = now
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime audio not starting",
|
||||
detail = "Playback running ${stuckMs}ms but no audio reached the speaker " +
|
||||
"(${mediaVolumeSummaryLocked()}).",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable snapshot of playback progress for the [VoiceViewModel] watchdog
|
||||
* and drain cross-check. Reads are cheap and lock-guarded.
|
||||
*/
|
||||
fun snapshot(): RealtimePlaybackSnapshot = synchronized(trackLock) {
|
||||
val track = audioTrack
|
||||
RealtimePlaybackSnapshot(
|
||||
active = track != null,
|
||||
playbackStarted = playbackStarted,
|
||||
headFrames = track?.let { readHeadFrames(it) } ?: 0,
|
||||
framesWritten = totalFramesWritten,
|
||||
sampleRate = currentSampleRate,
|
||||
playStatePlaying = track != null && readPlayState(track) == "playing",
|
||||
startedAtElapsedMs = playbackStartedAtMs,
|
||||
)
|
||||
}
|
||||
|
||||
private fun readHeadFrames(track: AudioTrack): Int =
|
||||
try { track.playbackHeadPosition } catch (_: Exception) { lastLoggedHeadFrames }
|
||||
|
||||
private fun readPlayState(track: AudioTrack): String =
|
||||
try {
|
||||
when (track.playState) {
|
||||
AudioTrack.PLAYSTATE_PLAYING -> "playing"
|
||||
AudioTrack.PLAYSTATE_PAUSED -> "paused"
|
||||
AudioTrack.PLAYSTATE_STOPPED -> "stopped"
|
||||
else -> "unknown"
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
"error"
|
||||
}
|
||||
|
||||
private fun notePlaybackGapLocked(track: AudioTrack, now: Long) {
|
||||
if (!playbackStarted) return
|
||||
val underrunCount = readUnderrunCount(track)
|
||||
val platformUnderrun = underrunCount > lastUnderrunCount
|
||||
val estimatedDrained = estimatedPlaybackEndAtMs > 0L &&
|
||||
now > estimatedPlaybackEndAtMs + RealtimePcmBufferPolicy.UNDERFLOW_GRACE_MS
|
||||
if (!platformUnderrun && !estimatedDrained) return
|
||||
|
||||
val reason = if (platformUnderrun) {
|
||||
"platform underrun ${lastUnderrunCount}→$underrunCount"
|
||||
} else {
|
||||
"stream gap ${now - estimatedPlaybackEndAtMs}ms"
|
||||
}
|
||||
Log.w(TAG, "Realtime PCM continuing after $reason")
|
||||
recordPlaybackGapDiagnosticLocked(now, reason)
|
||||
playbackGapSeenThisTrack = true
|
||||
increaseAdaptivePrebufferLocked(reason)
|
||||
|
||||
// Provider-native realtime streams can legitimately arrive in uneven
|
||||
// bursts while the model decides to call tools. Keep the AudioTrack
|
||||
// alive so already queued speech is not flushed and the next chunk can
|
||||
// resume naturally after Android's underrun recovery.
|
||||
if (estimatedDrained) {
|
||||
estimatedPlaybackEndAtMs = now
|
||||
}
|
||||
lastUnderrunCount = underrunCount
|
||||
}
|
||||
|
||||
private fun increaseAdaptivePrebufferLocked(reason: String) {
|
||||
val previous = adaptiveStartPrebufferMs
|
||||
adaptiveStartPrebufferMs = (adaptiveStartPrebufferMs + ADAPTIVE_PREBUFFER_STEP_MS)
|
||||
.coerceAtMost(RealtimePcmBufferPolicy.MAX_ADAPTIVE_START_PREBUFFER_MS)
|
||||
if (adaptiveStartPrebufferMs != previous) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM adaptive prebuffer increased to ${adaptiveStartPrebufferMs}ms " +
|
||||
"after $reason",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun settleAdaptivePrebufferLocked() {
|
||||
if (playbackGapSeenThisTrack) return
|
||||
val previous = adaptiveStartPrebufferMs
|
||||
adaptiveStartPrebufferMs = (adaptiveStartPrebufferMs - ADAPTIVE_PREBUFFER_DECAY_MS)
|
||||
.coerceAtLeast(RealtimePcmBufferPolicy.START_PREBUFFER_MS)
|
||||
if (adaptiveStartPrebufferMs != previous) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM adaptive prebuffer relaxed to ${adaptiveStartPrebufferMs}ms",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordPlaybackGapDiagnosticLocked(now: Long, reason: String) {
|
||||
if (now - lastPlaybackGapDiagnosticAtMs < PLAYBACK_GAP_DIAGNOSTIC_THROTTLE_MS) return
|
||||
lastPlaybackGapDiagnosticAtMs = now
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime audio stream gap",
|
||||
detail = reason,
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestAudioFocusLocked() {
|
||||
val manager = audioManager ?: return
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val mediaVolume = runCatching { manager.getStreamVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
val maxVolume = runCatching { manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
if (mediaVolume == 0 && now - lastMutedVolumeDiagnosticAtMs > MUTED_VOLUME_DIAGNOSTIC_THROTTLE_MS) {
|
||||
lastMutedVolumeDiagnosticAtMs = now
|
||||
Log.w(TAG, "Realtime PCM playback is starting while media volume is muted")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Realtime voice volume muted",
|
||||
detail = "Media volume is 0/${maxVolume ?: "?"}.",
|
||||
)
|
||||
}
|
||||
if (audioFocusHeld) {
|
||||
Log.i(TAG, "Realtime PCM audio focus already held ${mediaVolumeSummaryLocked()}")
|
||||
return
|
||||
}
|
||||
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val request = audioFocusRequest ?: AudioFocusRequest.Builder(
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
|
||||
)
|
||||
.setAudioAttributes(realtimeAudioAttributes)
|
||||
.setAcceptsDelayedFocusGain(false)
|
||||
.setOnAudioFocusChangeListener(audioFocusChangeListener)
|
||||
.build()
|
||||
.also { audioFocusRequest = it }
|
||||
manager.requestAudioFocus(request)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
manager.requestAudioFocus(
|
||||
audioFocusChangeListener,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
|
||||
)
|
||||
}
|
||||
audioFocusHeld = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime PCM audio focus result=$result held=$audioFocusHeld ${mediaVolumeSummaryLocked()}",
|
||||
)
|
||||
}
|
||||
|
||||
private fun abandonAudioFocusLocked() {
|
||||
val manager = audioManager ?: return
|
||||
if (!audioFocusHeld) return
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let { manager.abandonAudioFocusRequest(it) }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
manager.abandonAudioFocus(audioFocusChangeListener)
|
||||
}
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Realtime PCM audio focus abandon failed: ${it.message}")
|
||||
}
|
||||
audioFocusHeld = false
|
||||
}
|
||||
|
||||
private fun mediaVolumeSummaryLocked(): String {
|
||||
val manager = audioManager ?: return "mediaVolume=unknown"
|
||||
val volume = runCatching { manager.getStreamVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
val maxVolume = runCatching { manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
|
||||
val musicActive = runCatching { manager.isMusicActive }.getOrNull()
|
||||
return "mediaVolume=${volume ?: "?"}/${maxVolume ?: "?"} musicActive=${musicActive ?: "?"}"
|
||||
}
|
||||
|
||||
private fun updateUnderrunCursorLocked(track: AudioTrack) {
|
||||
val underrunCount = readUnderrunCount(track)
|
||||
if (underrunCount > lastUnderrunCount) {
|
||||
lastUnderrunCount = underrunCount
|
||||
}
|
||||
}
|
||||
|
||||
private fun readUnderrunCount(track: AudioTrack): Int =
|
||||
try { track.underrunCount } catch (_: Exception) { lastUnderrunCount }
|
||||
|
||||
private fun remainingPlaybackMsLocked(now: Long, cushionMs: Long): Long {
|
||||
if (audioTrack == null) return 0L
|
||||
if (!playbackStarted) {
|
||||
return RealtimePcmBufferPolicy.durationMsForBytes(
|
||||
bytes = pendingStartBytes,
|
||||
sampleRate = currentSampleRate,
|
||||
) + cushionMs.coerceAtLeast(0L)
|
||||
}
|
||||
return (estimatedPlaybackEndAtMs - now + cushionMs).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
private fun computePcm16LeRms(pcm: ByteArray): Float {
|
||||
val usable = pcm.size - (pcm.size % 2)
|
||||
if (usable <= 0) return 0f
|
||||
|
||||
var sumSquares = 0.0
|
||||
var samples = 0
|
||||
var index = 0
|
||||
while (index < usable) {
|
||||
val low = pcm[index].toInt() and 0xff
|
||||
val high = pcm[index + 1].toInt()
|
||||
val sample = ((high shl 8) or low).toShort().toInt()
|
||||
val normalized = sample / Short.MAX_VALUE.toDouble()
|
||||
sumSquares += normalized * normalized
|
||||
samples++
|
||||
index += 2
|
||||
}
|
||||
if (samples == 0) return 0f
|
||||
|
||||
val rms = sqrt(sumSquares / samples)
|
||||
val lifted = sqrt((rms / 0.28).coerceIn(0.0, 1.0))
|
||||
return if (lifted.isNaN() || lifted.isInfinite()) 0f else lifted.toFloat().coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RealtimePcmPlayer"
|
||||
private const val DEFAULT_DRAIN_CUSHION_MS = 250L
|
||||
private const val PLAYBACK_GAP_DIAGNOSTIC_THROTTLE_MS = 5_000L
|
||||
private const val MUTED_VOLUME_DIAGNOSTIC_THROTTLE_MS = 10_000L
|
||||
private const val ADAPTIVE_PREBUFFER_STEP_MS = 240L
|
||||
private const val ADAPTIVE_PREBUFFER_DECAY_MS = 120L
|
||||
private const val HEAD_POSITION_LOG_THROTTLE_MS = 1_000L
|
||||
private const val STUCK_CURSOR_DIAGNOSTIC_MS = 1_200L
|
||||
private const val BYTES_PER_FRAME = 2 // mono 16-bit PCM
|
||||
private const val MAX_AMP_QUEUE = 1_024
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock-free value snapshot of realtime playback progress, consumed by the
|
||||
* [com.hermesandroid.relay.viewmodel.VoiceViewModel] first-frame watchdog and
|
||||
* drain cross-check.
|
||||
*/
|
||||
data class RealtimePlaybackSnapshot(
|
||||
val active: Boolean,
|
||||
val playbackStarted: Boolean,
|
||||
val headFrames: Int,
|
||||
val framesWritten: Long,
|
||||
val sampleRate: Int,
|
||||
val playStatePlaying: Boolean,
|
||||
val startedAtElapsedMs: Long,
|
||||
)
|
||||
|
||||
/** A written PCM chunk's RMS amplitude tagged with the frame it finishes at. */
|
||||
internal data class FrameAmp(val endFrame: Long, val rms: Float)
|
||||
|
||||
/**
|
||||
* Returns the amplitude of the first chunk that has not finished playing
|
||||
* ([FrameAmp.endFrame] > [headFrames]) — i.e. the audio at the cursor right now.
|
||||
* 0 when the queue is empty or fully drained. Pure for unit testing.
|
||||
*/
|
||||
internal fun amplitudeAtHead(queue: List<FrameAmp>, headFrames: Long): Float {
|
||||
for (entry in queue) {
|
||||
if (entry.endFrame > headFrames) return entry.rms
|
||||
}
|
||||
return 0f
|
||||
}
|
||||
|
||||
internal data class RealtimePcmStartDecision(
|
||||
val shouldStart: Boolean,
|
||||
val bufferedMs: Long,
|
||||
val reason: String,
|
||||
)
|
||||
|
||||
internal object RealtimePcmBufferPolicy {
|
||||
// Realtime voice is latency-sensitive: the provider streams PCM at (or faster
|
||||
// than) realtime, so the start prebuffer only needs to cover network jitter,
|
||||
// not the whole turn. The large [STREAM_BUFFER_MS] AudioTrack buffer absorbs
|
||||
// bursts *after* playback starts; the start thresholds just decide when the
|
||||
// very first sample is allowed to leave the queue.
|
||||
//
|
||||
// A short turn whose audio arrives faster than realtime used to satisfy
|
||||
// neither the (2.4s) prebuffer nor the (1.2s) max-wait, so it never started
|
||||
// mid-stream and depended entirely on the end-of-turn flush. Lowering these
|
||||
// lets streaming start on the first few chunks while keeping enough cushion
|
||||
// to ride out jitter.
|
||||
const val STARTUP_PREROLL_MS = 120L
|
||||
const val START_PREBUFFER_MS = 320L
|
||||
const val MIN_PREBUFFER_MS = 160L
|
||||
const val MAX_PREBUFFER_WAIT_MS = 280L
|
||||
const val MAX_ADAPTIVE_START_PREBUFFER_MS = 1_200L
|
||||
// Keep the AudioTrack buffer modest. A multi-second buffer gets routed to
|
||||
// Samsung's "deep buffer" output mixer, whose thread is suspended at rest and
|
||||
// cold-starts very slowly — the hardware cursor (playbackHeadPosition) stays
|
||||
// pinned at 0 for ~2-5s after play() even though playState=PLAYING, focus is
|
||||
// held and volume is up. That parked window is the inaudible first/short
|
||||
// turn. A sub-second buffer keeps playback on the primary (fast) mixer path,
|
||||
// which begins pulling immediately. The ~700ms still absorbs normal network
|
||||
// jitter; longer provider gaps (tool calls) underrun-and-resume regardless of
|
||||
// buffer size and are handled by notePlaybackGapLocked.
|
||||
const val STREAM_BUFFER_MS = 700L
|
||||
const val UNDERFLOW_GRACE_MS = 180L
|
||||
|
||||
fun streamBufferSize(minBufferBytes: Int, sampleRate: Int): Int {
|
||||
val target = bytesForDurationMs(sampleRate, STREAM_BUFFER_MS)
|
||||
return max(minBufferBytes, target)
|
||||
}
|
||||
|
||||
fun startDecision(
|
||||
pendingBytes: Int,
|
||||
sampleRate: Int,
|
||||
waitedMs: Long,
|
||||
force: Boolean,
|
||||
startPrebufferMs: Long = START_PREBUFFER_MS,
|
||||
): RealtimePcmStartDecision {
|
||||
val bufferedMs = durationMsForBytes(pendingBytes, sampleRate)
|
||||
val targetPrebufferMs = startPrebufferMs.coerceIn(
|
||||
START_PREBUFFER_MS,
|
||||
MAX_ADAPTIVE_START_PREBUFFER_MS,
|
||||
)
|
||||
val reason = when {
|
||||
force && pendingBytes > 0 -> "flush"
|
||||
bufferedMs >= targetPrebufferMs -> "prebuffer"
|
||||
bufferedMs >= MIN_PREBUFFER_MS && waitedMs >= MAX_PREBUFFER_WAIT_MS -> "max-wait"
|
||||
else -> "buffering"
|
||||
}
|
||||
return RealtimePcmStartDecision(
|
||||
shouldStart = reason != "buffering",
|
||||
bufferedMs = bufferedMs,
|
||||
reason = reason,
|
||||
)
|
||||
}
|
||||
|
||||
fun durationMsForBytes(bytes: Int, sampleRate: Int): Long {
|
||||
if (bytes <= 0 || sampleRate <= 0) return 0L
|
||||
return ((bytes / 2.0) / sampleRate * 1000.0)
|
||||
.toLong()
|
||||
.coerceAtLeast(1L)
|
||||
}
|
||||
|
||||
fun bytesForDurationMs(sampleRate: Int, durationMs: Long): Int {
|
||||
if (sampleRate <= 0 || durationMs <= 0L) return 0
|
||||
return (sampleRate * 2L * durationMs / 1000L).toInt()
|
||||
}
|
||||
|
||||
fun startupPrerollBytes(sampleRate: Int): Int =
|
||||
bytesForDurationMs(sampleRate, STARTUP_PREROLL_MS)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.hermesandroid.relay.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Captures mono 16-bit PCM for realtime voice test runs.
|
||||
*
|
||||
* [capture] grabs a fixed short window (legacy/back-compat). [captureUntilStopped]
|
||||
* records open-endedly until [requestStop] is called (tap-to-stop), which is what
|
||||
* the Mic demo needs to capture a full spoken sentence.
|
||||
*/
|
||||
class RealtimePcmRecorder(
|
||||
private val sampleRate: Int = 16_000,
|
||||
) {
|
||||
@Volatile
|
||||
private var capturing = false
|
||||
|
||||
/** Signals an in-flight [captureUntilStopped] to finish and return. */
|
||||
fun requestStop() {
|
||||
capturing = false
|
||||
}
|
||||
|
||||
val isCapturing: Boolean
|
||||
get() = capturing
|
||||
|
||||
/**
|
||||
* Records until [requestStop] is called or [maxDurationMs] elapses, invoking
|
||||
* [onLevel] (0..1 RMS) per read so the UI can show a live input waveform.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun captureUntilStopped(
|
||||
maxDurationMs: Long = 15_000,
|
||||
onLevel: ((Float) -> Unit)? = null,
|
||||
): ByteArray = withContext(Dispatchers.IO) {
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
val maxBytes = ((sampleRate * maxDurationMs) / 1000L * 2L).toInt()
|
||||
|
||||
val recorder = AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer)
|
||||
.build()
|
||||
|
||||
val out = ByteArrayOutputStream(minBuffer * 4)
|
||||
val buffer = ByteArray(minBuffer)
|
||||
capturing = true
|
||||
try {
|
||||
recorder.startRecording()
|
||||
while (capturing && out.size() < maxBytes) {
|
||||
val read = recorder.read(buffer, 0, buffer.size)
|
||||
if (read > 0) {
|
||||
out.write(buffer, 0, read)
|
||||
onLevel?.invoke(rms16Le(buffer, read))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
capturing = false
|
||||
try { recorder.stop() } catch (_: Exception) { }
|
||||
recorder.release()
|
||||
}
|
||||
out.toByteArray()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun capture(durationMs: Long = 800): ByteArray = withContext(Dispatchers.IO) {
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(sampleRate / 10 * 2)
|
||||
val targetBytes = ((sampleRate * durationMs) / 1000L * 2L)
|
||||
.toInt()
|
||||
.coerceAtLeast(minBuffer)
|
||||
|
||||
val recorder = AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer)
|
||||
.build()
|
||||
|
||||
val out = ByteArrayOutputStream(targetBytes)
|
||||
val buffer = ByteArray(minBuffer)
|
||||
try {
|
||||
recorder.startRecording()
|
||||
while (out.size() < targetBytes) {
|
||||
val read = recorder.read(
|
||||
buffer,
|
||||
0,
|
||||
min(buffer.size, targetBytes - out.size()),
|
||||
)
|
||||
if (read > 0) {
|
||||
out.write(buffer, 0, read)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try { recorder.stop() } catch (_: Exception) { }
|
||||
recorder.release()
|
||||
}
|
||||
out.toByteArray()
|
||||
}
|
||||
|
||||
private fun rms16Le(buffer: ByteArray, length: Int): Float {
|
||||
val usable = length - (length % 2)
|
||||
if (usable <= 0) return 0f
|
||||
var sum = 0.0
|
||||
var i = 0
|
||||
while (i < usable) {
|
||||
val low = buffer[i].toInt() and 0xff
|
||||
val high = buffer[i + 1].toInt()
|
||||
val sample = ((high shl 8) or low).toShort().toInt() / 32768.0
|
||||
sum += sample * sample
|
||||
i += 2
|
||||
}
|
||||
val rms = sqrt(sum / (usable / 2))
|
||||
return sqrt((rms / 0.28).coerceIn(0.0, 1.0)).toFloat()
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -37,7 +38,13 @@ import kotlin.math.sqrt
|
||||
* The Visualizer is attached exactly once against the ExoPlayer's
|
||||
* [ExoPlayer.getAudioSessionId]. There is a known gotcha where re-attaching
|
||||
* the Visualizer on every track transition invalidates the session id — the
|
||||
* single-attach lifecycle here sidesteps it entirely.
|
||||
* single-attach lifecycle here sidesteps it entirely. The single attach is
|
||||
* triggered by whichever of {playback became live, a real session id landed}
|
||||
* arrives last, so a late AudioTrack allocation (deep-buffer cold-start) can't
|
||||
* leave amplitude pinned at 0 for the turn — see [attachVisualizerIfPlaying].
|
||||
* That promptness matters because the voice overlay gates its output waveform
|
||||
* on the first real playback-amplitude frame, so the visual follows audible
|
||||
* speech instead of leading it.
|
||||
*
|
||||
* @param context used for [ExoPlayer.Builder]. Application context is fine;
|
||||
* the player holds no view references.
|
||||
@@ -75,18 +82,56 @@ class VoicePlayer(
|
||||
// polling the player from arbitrary threads.
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
|
||||
// Mirrors ExoPlayer.getMediaItemCount() via the Player.Listener events
|
||||
// that can mutate it (onMediaItemTransition for drain, explicit add/clear
|
||||
// calls for growth). Kept as a StateFlow so awaitCompletion can reactively
|
||||
// wait for queue-drained + idle without polling.
|
||||
// Logical count of media items still owned by this playback turn. ExoPlayer
|
||||
// retains played playlist items after STATE_ENDED, so this cannot mirror
|
||||
// mediaItemCount blindly at end-of-queue.
|
||||
private val _queueCount = MutableStateFlow(0)
|
||||
|
||||
private var visualizer: Visualizer? = null
|
||||
private var visualizerAttached = false
|
||||
|
||||
// Thread-safe mirror of [ExoPlayer.getAudioSessionId]. ExoPlayer is
|
||||
// thread-confined — every accessor (the audioSessionId getter included)
|
||||
// calls verifyApplicationThread() and throws "Player is accessed on the
|
||||
// wrong thread" if touched off the player's construction thread. The
|
||||
// barge-in pipeline reads [audioSessionId] from BargeInListener's
|
||||
// Dispatchers.IO reader coroutine to attach AcousticEchoCanceler, so we
|
||||
// can't expose the raw getter. Instead we cache the id from the
|
||||
// main-thread Media3 callbacks below and serve the getter from this
|
||||
// @Volatile field. (Fixes the legacy-TTS + barge-in crash where the
|
||||
// first sentence played for ~2 syllables before the IO read threw.)
|
||||
@Volatile private var cachedAudioSessionId: Int = 0
|
||||
|
||||
private val exoPlayer: ExoPlayer = exoPlayerFactory(context.applicationContext)
|
||||
|
||||
init {
|
||||
// AnalyticsListener callbacks are delivered on the player's
|
||||
// application (main) thread, so caching the id here is the
|
||||
// authoritative, thread-correct way to track it as Media3 allocates
|
||||
// and reallocates the underlying AudioTrack.
|
||||
exoPlayer.addAnalyticsListener(object : AnalyticsListener {
|
||||
override fun onAudioSessionIdChanged(
|
||||
eventTime: AnalyticsListener.EventTime,
|
||||
audioSessionId: Int,
|
||||
) {
|
||||
cachedAudioSessionId = audioSessionId
|
||||
// Deep-buffer cold-start guard. On some OEM pipelines the
|
||||
// AudioTrack — and therefore a real (non-zero) session id —
|
||||
// isn't allocated until *after* onIsPlayingChanged(true) has
|
||||
// already fired. In that race the isPlaying-driven attach
|
||||
// below ran with id == 0, no-oped, and isPlaying will not
|
||||
// toggle again for the rest of a continuous TTS turn, so the
|
||||
// Visualizer would never attach and [amplitude] would stay
|
||||
// pinned at 0 for the whole turn. The output waveform gates
|
||||
// its unfold on the first real playback-amplitude frame, so a
|
||||
// never-firing amplitude leaves it stuck in the folded
|
||||
// processing/spinner shape even though audio is audible.
|
||||
// Attaching here — the moment a real session id lands while
|
||||
// playback is already live — makes the first-audible-frame
|
||||
// signal reliable regardless of when the track allocates.
|
||||
attachVisualizerIfPlaying()
|
||||
}
|
||||
})
|
||||
exoPlayer.addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
_isPlaying.value = isPlaying
|
||||
@@ -95,8 +140,16 @@ class VoicePlayer(
|
||||
// actually begins — the audio session id is stable from
|
||||
// player construction on Media3 1.x but some OEM pipelines
|
||||
// don't allocate the track until playback starts.
|
||||
if (isPlaying && !visualizerAttached) {
|
||||
attachVisualizer(exoPlayer.audioSessionId)
|
||||
if (isPlaying) {
|
||||
// Belt-and-braces with the analytics listener above: this
|
||||
// runs on the main thread too, so reading the getter here
|
||||
// is safe and guarantees the cache is warm by the time
|
||||
// playback is audible (and thus by the time barge-in
|
||||
// starts its IO reader). If the id isn't ready yet, the
|
||||
// analytics callback above re-tries the attach the instant
|
||||
// it lands (see attachVisualizerIfPlaying).
|
||||
cachedAudioSessionId = exoPlayer.audioSessionId
|
||||
attachVisualizerIfPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,10 +163,21 @@ class VoicePlayer(
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
if (state == Player.STATE_ENDED || state == Player.STATE_IDLE) {
|
||||
// ENDED fires when the full queue has been consumed;
|
||||
// sync queueCount so awaitCompletion can release.
|
||||
_queueCount.value = exoPlayer.mediaItemCount
|
||||
when (state) {
|
||||
Player.STATE_ENDED -> {
|
||||
// Media3 keeps consumed playlist entries around. Clear
|
||||
// them here so awaitCompletion observes a true drain and
|
||||
// voice mode can leave Speaking when the last TTS chunk ends.
|
||||
exoPlayer.clearMediaItems()
|
||||
_queueCount.value = 0
|
||||
_isPlaying.value = false
|
||||
_amplitude.value = 0f
|
||||
}
|
||||
Player.STATE_IDLE -> {
|
||||
if (exoPlayer.mediaItemCount == 0) {
|
||||
_queueCount.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -145,7 +209,7 @@ class VoicePlayer(
|
||||
*
|
||||
* **Semantic change from the old MediaPlayer implementation.** Previously
|
||||
* this returned when the *current file* completed. Now it returns when
|
||||
* the entire queue has been consumed — i.e. `mediaItemCount == 0 &&
|
||||
* the entire logical queue has been consumed — i.e. `_queueCount == 0 &&
|
||||
* !isPlaying`. This matches the gapless-playback model where adjacent
|
||||
* sentences play back-to-back from the same ExoPlayer, and it's exactly
|
||||
* what the V4 prefetch pipelining rewrite needs (synth worker can enqueue
|
||||
@@ -201,13 +265,20 @@ class VoicePlayer(
|
||||
* poll this property briefly rather than assume it's hot-ready at
|
||||
* [VoicePlayer] construction time.
|
||||
*
|
||||
* Exposed read-only. Internally the same id drives the Visualizer
|
||||
* attach logic in [attachVisualizer]; B4 reads it via a provider
|
||||
* lambda so the listener can re-check across the 1 s poll window
|
||||
* without holding a stale reference.
|
||||
* **Thread-safe.** Backed by [cachedAudioSessionId] rather than the raw
|
||||
* `ExoPlayer.getAudioSessionId()` getter, because ExoPlayer is
|
||||
* thread-confined and [BargeInListener] reads this from its
|
||||
* `Dispatchers.IO` reader coroutine. Reading the raw getter off-main
|
||||
* throws `IllegalStateException: Player is accessed on the wrong thread`.
|
||||
* The cache is populated from main-thread Media3 callbacks (the
|
||||
* [AnalyticsListener.onAudioSessionIdChanged] hook and `onIsPlayingChanged`).
|
||||
*
|
||||
* Exposed read-only. B4 reads it via a provider lambda so the listener
|
||||
* can re-check across the 1 s poll window without holding a stale
|
||||
* reference.
|
||||
*/
|
||||
val audioSessionId: Int
|
||||
get() = exoPlayer.audioSessionId
|
||||
get() = cachedAudioSessionId
|
||||
|
||||
/**
|
||||
* Set the playback volume of the underlying ExoPlayer.
|
||||
@@ -258,6 +329,24 @@ class VoicePlayer(
|
||||
exoPlayer.release()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the [Visualizer] iff playback is live and we haven't attached for
|
||||
* this session yet. Idempotent and main-thread-only: both call sites
|
||||
* ([Player.Listener.onIsPlayingChanged] and the [AnalyticsListener]'s
|
||||
* `onAudioSessionIdChanged`) are delivered on the player's application
|
||||
* thread, so the [visualizerAttached] check needs no extra synchronization.
|
||||
*
|
||||
* The delegate [attachVisualizer] still no-ops (without latching
|
||||
* [visualizerAttached]) when the cached session id is 0, which preserves
|
||||
* the retry: whichever of {isPlaying, valid session id} arrives last drives
|
||||
* the single attach. This is the cold-start race fix — see the
|
||||
* `onAudioSessionIdChanged` comment in `init`.
|
||||
*/
|
||||
private fun attachVisualizerIfPlaying() {
|
||||
if (visualizerAttached || !_isPlaying.value) return
|
||||
attachVisualizer(cachedAudioSessionId)
|
||||
}
|
||||
|
||||
private fun attachVisualizer(audioSessionId: Int) {
|
||||
if (audioSessionId == 0) {
|
||||
// ExoPlayer returns 0 before the audio track is allocated; retry
|
||||
|
||||
@@ -2,60 +2,47 @@ package com.hermesandroid.relay.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Captures the user's voice into an `.m4a` (AAC-in-MP4) file for V2a voice
|
||||
* mode. The relay's `/voice/transcribe` endpoint feeds this to whisper-1 via
|
||||
* OpenAI, which accepts m4a/mp4 natively.
|
||||
* Captures the user's voice as 16 kHz mono PCM and writes a `.wav` file for
|
||||
* the relay STT endpoint. The raw PCM is retained for the server-mediated
|
||||
* `/voice/realtime/{session}` path so the main voice UI can send the same utterance
|
||||
* through the realtime websocket without opening a second microphone stream.
|
||||
*
|
||||
* A live [amplitude] flow is exposed for the UI (MorphingSphere + meter) —
|
||||
* driven by polling `MediaRecorder.maxAmplitude` every ~16 ms. The polling
|
||||
* coroutine runs on the caller-supplied [scope] so it dies with the owning
|
||||
* ViewModel.
|
||||
*
|
||||
* One recorder instance owns at most one active recording at a time. Calling
|
||||
* [startRecording] again while a recording is in flight will stop the
|
||||
* previous one first. [stopRecording] is safe to call when nothing is
|
||||
* running (it just returns the last file, or throws if there never was one).
|
||||
* A live [amplitude] flow is exposed for the UI (MorphingSphere + meter). The
|
||||
* value is computed from the same PCM frames that are written to disk, which
|
||||
* keeps legacy STT fallback and realtime voice testing on a single capture
|
||||
* path.
|
||||
*/
|
||||
class VoiceRecorder(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
@Suppress("UNUSED_PARAMETER") private val scope: kotlinx.coroutines.CoroutineScope,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "VoiceRecorder"
|
||||
private const val SAMPLE_RATE = 16_000
|
||||
private const val BIT_RATE = 64_000
|
||||
private const val AMPLITUDE_POLL_MS = 16L
|
||||
private const val BYTES_PER_SAMPLE = 2
|
||||
private const val CHANNEL_COUNT = 1
|
||||
private const val MAX_AMPLITUDE_SHORT = 32_767f
|
||||
private const val MAX_PCM_BYTES = 25 * 1024 * 1024
|
||||
|
||||
// Perceptual amplitude mapping constants. Raw PCM peak values from
|
||||
// MediaRecorder.maxAmplitude for a phone at arm's length:
|
||||
// silence / ambient : 100..500 (≤0.015 of max)
|
||||
// quiet speech : 500..3000 (0.015..0.09)
|
||||
// normal speech : 3000..8000 (0.09..0.24)
|
||||
// loud speech : 8000..18000 (0.24..0.55)
|
||||
// shout / clipping : 18000..32767 (0.55..1.0)
|
||||
//
|
||||
// Linear 0..1 puts normal conversation between 0.09 and 0.24 — the
|
||||
// meter barely moves. Subtract a noise floor, rescale into the
|
||||
// speech-ceiling window, then apply a sqrt curve so quiet speech
|
||||
// still registers visually without drowning loud speech at the top.
|
||||
// Keep the perceptual curve from the previous MediaRecorder-backed
|
||||
// implementation so the on-screen meter feels the same.
|
||||
private const val NOISE_FLOOR = 0.01f
|
||||
private const val SPEECH_CEILING = 0.35f
|
||||
}
|
||||
@@ -63,162 +50,231 @@ class VoiceRecorder(
|
||||
private val _amplitude = MutableStateFlow(0f)
|
||||
val amplitude: StateFlow<Float> = _amplitude.asStateFlow()
|
||||
|
||||
private var mediaRecorder: MediaRecorder? = null
|
||||
val sampleRate: Int get() = SAMPLE_RATE
|
||||
|
||||
private val bufferLock = Any()
|
||||
private val stopRequested = AtomicBoolean(false)
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var currentOutputFile: File? = null
|
||||
private var pollJob: Job? = null
|
||||
private var readThread: Thread? = null
|
||||
private var readDone: CountDownLatch? = null
|
||||
private var pcmBuffer = ByteArrayOutputStream(SAMPLE_RATE * BYTES_PER_SAMPLE * 4)
|
||||
private var lastPcmBytes: ByteArray = ByteArray(0)
|
||||
|
||||
/**
|
||||
* Begin a new recording. Returns the output [File] that will receive the
|
||||
* audio once [stopRecording] is called. Throws on permission failure or
|
||||
* encoder init failure — callers should catch and surface to the UI.
|
||||
* Begin a new recording. Returns the output [File] that will contain WAV
|
||||
* audio once [stopRecording] is called.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startRecording(): File {
|
||||
// Defensive: if a recording is somehow still running, tear it down
|
||||
// before starting a new one. MediaRecorder transitions are strict.
|
||||
if (mediaRecorder != null) {
|
||||
Log.w(TAG, "startRecording called while another recording is in flight — stopping it first")
|
||||
if (audioRecord != null) {
|
||||
Log.w(TAG, "startRecording called while another recording is in flight; stopping it first")
|
||||
try {
|
||||
stopRecording()
|
||||
} catch (_: Exception) {
|
||||
// Swallow — we're about to overwrite state anyway.
|
||||
releaseRecorder()
|
||||
}
|
||||
}
|
||||
|
||||
val outFile = File(context.cacheDir, "voice_rec_${System.currentTimeMillis()}.m4a")
|
||||
currentOutputFile = outFile
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(SAMPLE_RATE / 10 * BYTES_PER_SAMPLE)
|
||||
|
||||
val recorder = buildRecorder()
|
||||
try {
|
||||
recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
recorder.setAudioSamplingRate(SAMPLE_RATE)
|
||||
recorder.setAudioEncodingBitRate(BIT_RATE)
|
||||
recorder.setAudioChannels(1)
|
||||
recorder.setOutputFile(outFile.absolutePath)
|
||||
recorder.prepare()
|
||||
recorder.start()
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.e(TAG, "MediaRecorder failed to start: ${e.message}")
|
||||
try {
|
||||
recorder.reset()
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
val outFile = File(context.cacheDir, "voice_rec_${System.currentTimeMillis()}.wav")
|
||||
currentOutputFile = outFile
|
||||
synchronized(bufferLock) {
|
||||
pcmBuffer = ByteArrayOutputStream(SAMPLE_RATE * BYTES_PER_SAMPLE * 4)
|
||||
lastPcmBytes = ByteArray(0)
|
||||
}
|
||||
stopRequested.set(false)
|
||||
_amplitude.value = 0f
|
||||
|
||||
val recorder = AudioRecord.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(SAMPLE_RATE)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(minBuffer * 2)
|
||||
.build()
|
||||
|
||||
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||
recorder.release()
|
||||
mediaRecorder = null
|
||||
currentOutputFile = null
|
||||
throw e
|
||||
throw IllegalStateException("AudioRecord failed to initialize")
|
||||
}
|
||||
|
||||
try {
|
||||
recorder.startRecording()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "MediaRecorder setup failed: ${e.message}")
|
||||
try {
|
||||
recorder.reset()
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
recorder.release()
|
||||
mediaRecorder = null
|
||||
currentOutputFile = null
|
||||
throw e
|
||||
}
|
||||
|
||||
mediaRecorder = recorder
|
||||
startAmplitudePolling()
|
||||
audioRecord = recorder
|
||||
val done = CountDownLatch(1)
|
||||
readDone = done
|
||||
readThread = Thread(
|
||||
{
|
||||
readPcmLoop(recorder, minBuffer)
|
||||
done.countDown()
|
||||
},
|
||||
"HermesVoiceRecorder",
|
||||
).also { it.start() }
|
||||
return outFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the active recording, flush the encoder, and return the completed
|
||||
* output [File]. Safe to call when nothing is recording — in that case
|
||||
* it returns the last file produced, or throws if there never was one.
|
||||
* Stop the active recording, write the WAV container, and return it.
|
||||
*/
|
||||
fun stopRecording(): File {
|
||||
val file = currentOutputFile
|
||||
?: throw IllegalStateException("stopRecording called with no active recording")
|
||||
|
||||
stopAmplitudePolling()
|
||||
|
||||
val recorder = mediaRecorder
|
||||
if (recorder != null) {
|
||||
val record = audioRecord
|
||||
stopRequested.set(true)
|
||||
if (record != null) {
|
||||
try {
|
||||
recorder.stop()
|
||||
record.stop()
|
||||
} catch (e: IllegalStateException) {
|
||||
// MediaRecorder.stop throws if called before any audio was
|
||||
// captured (sub-300ms recordings). Treat as recoverable —
|
||||
// the output file may be 0 bytes but the caller can check.
|
||||
Log.w(TAG, "MediaRecorder.stop threw — recording may be empty: ${e.message}")
|
||||
} catch (e: RuntimeException) {
|
||||
Log.w(TAG, "MediaRecorder.stop runtime error: ${e.message}")
|
||||
} finally {
|
||||
releaseRecorder()
|
||||
Log.w(TAG, "AudioRecord.stop threw; recording may be empty: ${e.message}")
|
||||
}
|
||||
}
|
||||
readDone?.await(1, TimeUnit.SECONDS)
|
||||
releaseRecorder()
|
||||
|
||||
val pcm = synchronized(bufferLock) {
|
||||
pcmBuffer.toByteArray().also { lastPcmBytes = it }
|
||||
}
|
||||
writeWav(file, pcm)
|
||||
_amplitude.value = 0f
|
||||
return file
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a recording is currently active. Cheap — just checks whether
|
||||
* we have a live [MediaRecorder] reference.
|
||||
*/
|
||||
fun isRecording(): Boolean = mediaRecorder != null
|
||||
fun isRecording(): Boolean = audioRecord != null && !stopRequested.get()
|
||||
|
||||
fun lastPcmBytes(): ByteArray = synchronized(bufferLock) {
|
||||
lastPcmBytes.copyOf()
|
||||
}
|
||||
|
||||
/**
|
||||
* Release any recorder resources without returning a file. Safe fallback
|
||||
* for error paths where the output file is known-invalid.
|
||||
* Release any recorder resources without returning a file.
|
||||
*/
|
||||
fun cancel() {
|
||||
stopAmplitudePolling()
|
||||
mediaRecorder?.let { r ->
|
||||
try {
|
||||
r.stop()
|
||||
} catch (_: Exception) { /* ignore */ }
|
||||
stopRequested.set(true)
|
||||
audioRecord?.let { record ->
|
||||
try { record.stop() } catch (_: Exception) { }
|
||||
}
|
||||
readDone?.await(500, TimeUnit.MILLISECONDS)
|
||||
releaseRecorder()
|
||||
currentOutputFile?.let { f ->
|
||||
try { f.delete() } catch (_: Exception) { /* ignore */ }
|
||||
currentOutputFile?.let { file ->
|
||||
try { file.delete() } catch (_: Exception) { }
|
||||
}
|
||||
currentOutputFile = null
|
||||
synchronized(bufferLock) {
|
||||
pcmBuffer.reset()
|
||||
lastPcmBytes = ByteArray(0)
|
||||
}
|
||||
_amplitude.value = 0f
|
||||
}
|
||||
|
||||
private fun releaseRecorder() {
|
||||
mediaRecorder?.let { r ->
|
||||
try { r.reset() } catch (_: Exception) { /* ignore */ }
|
||||
try { r.release() } catch (_: Exception) { /* ignore */ }
|
||||
}
|
||||
mediaRecorder = null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun buildRecorder(): MediaRecorder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}
|
||||
|
||||
private fun startAmplitudePolling() {
|
||||
pollJob?.cancel()
|
||||
pollJob = scope.launch(Dispatchers.Default) {
|
||||
while (isActive) {
|
||||
val recorder = mediaRecorder ?: break
|
||||
val raw = try {
|
||||
recorder.maxAmplitude
|
||||
} catch (e: IllegalStateException) {
|
||||
// Recorder torn down under us — exit quietly.
|
||||
break
|
||||
private fun readPcmLoop(record: AudioRecord, minBuffer: Int) {
|
||||
val buffer = ByteArray(minBuffer)
|
||||
while (!stopRequested.get()) {
|
||||
val read = try {
|
||||
record.read(buffer, 0, buffer.size)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "AudioRecord.read failed: ${e.message}")
|
||||
break
|
||||
}
|
||||
if (read > 0) {
|
||||
synchronized(bufferLock) {
|
||||
if (pcmBuffer.size() + read <= MAX_PCM_BYTES) {
|
||||
pcmBuffer.write(buffer, 0, read)
|
||||
} else {
|
||||
stopRequested.set(true)
|
||||
}
|
||||
}
|
||||
val raw01 = (raw.toFloat() / MAX_AMPLITUDE_SHORT).coerceIn(0f, 1f)
|
||||
val floored = ((raw01 - NOISE_FLOOR) / (SPEECH_CEILING - NOISE_FLOOR))
|
||||
.coerceIn(0f, 1f)
|
||||
_amplitude.value = sqrt(floored)
|
||||
delay(AMPLITUDE_POLL_MS)
|
||||
updateAmplitude(buffer, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopAmplitudePolling() {
|
||||
pollJob?.cancel()
|
||||
pollJob = null
|
||||
private fun updateAmplitude(buffer: ByteArray, read: Int) {
|
||||
var peak = 0
|
||||
var index = 0
|
||||
val usable = read - (read % BYTES_PER_SAMPLE)
|
||||
while (index < usable) {
|
||||
val low = buffer[index].toInt() and 0xff
|
||||
val high = buffer[index + 1].toInt()
|
||||
val sample = (high shl 8) or low
|
||||
val abs = kotlin.math.abs(sample.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()))
|
||||
if (abs > peak) peak = abs
|
||||
index += BYTES_PER_SAMPLE
|
||||
}
|
||||
val raw01 = (peak.toFloat() / MAX_AMPLITUDE_SHORT).coerceIn(0f, 1f)
|
||||
val floored = ((raw01 - NOISE_FLOOR) / (SPEECH_CEILING - NOISE_FLOOR))
|
||||
.coerceIn(0f, 1f)
|
||||
_amplitude.value = sqrt(floored)
|
||||
}
|
||||
|
||||
private fun releaseRecorder() {
|
||||
audioRecord?.let { record ->
|
||||
try { record.release() } catch (_: Exception) { }
|
||||
}
|
||||
audioRecord = null
|
||||
readThread = null
|
||||
readDone = null
|
||||
}
|
||||
|
||||
private fun writeWav(file: File, pcm: ByteArray) {
|
||||
try {
|
||||
file.outputStream().use { out ->
|
||||
out.write(wavHeader(pcm.size))
|
||||
out.write(pcm)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw IOException("Failed to write WAV recording: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun wavHeader(pcmBytes: Int): ByteArray {
|
||||
val totalDataLen = pcmBytes + 36
|
||||
val byteRate = SAMPLE_RATE * CHANNEL_COUNT * BYTES_PER_SAMPLE
|
||||
return ByteArray(44).also { header ->
|
||||
fun ascii(offset: Int, value: String) {
|
||||
value.encodeToByteArray().copyInto(header, offset)
|
||||
}
|
||||
fun leInt(offset: Int, value: Int) {
|
||||
header[offset] = (value and 0xff).toByte()
|
||||
header[offset + 1] = ((value shr 8) and 0xff).toByte()
|
||||
header[offset + 2] = ((value shr 16) and 0xff).toByte()
|
||||
header[offset + 3] = ((value shr 24) and 0xff).toByte()
|
||||
}
|
||||
fun leShort(offset: Int, value: Int) {
|
||||
header[offset] = (value and 0xff).toByte()
|
||||
header[offset + 1] = ((value shr 8) and 0xff).toByte()
|
||||
}
|
||||
|
||||
ascii(0, "RIFF")
|
||||
leInt(4, totalDataLen)
|
||||
ascii(8, "WAVE")
|
||||
ascii(12, "fmt ")
|
||||
leInt(16, 16)
|
||||
leShort(20, 1)
|
||||
leShort(22, CHANNEL_COUNT)
|
||||
leInt(24, SAMPLE_RATE)
|
||||
leInt(28, byteRate)
|
||||
leShort(32, CHANNEL_COUNT * BYTES_PER_SAMPLE)
|
||||
leShort(34, 16)
|
||||
ascii(36, "data")
|
||||
leInt(40, pcmBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.models.Envelope
|
||||
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -40,6 +41,15 @@ sealed class AuthState {
|
||||
data class Failed(val reason: String) : AuthState()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ConnectionAuthSecrets(
|
||||
val sessionToken: String? = null,
|
||||
val refreshToken: String? = null,
|
||||
val deviceId: String? = null,
|
||||
val apiKey: String? = null,
|
||||
val pairedSessionMetaJson: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Orchestrates pairing + session token lifecycle for the relay channel.
|
||||
*
|
||||
@@ -76,14 +86,39 @@ class AuthManager(
|
||||
* through.
|
||||
*/
|
||||
private val connectionId: String = CONNECTION_ID_LEGACY,
|
||||
/**
|
||||
* Exact EncryptedSharedPreferences filename for this connection. New
|
||||
* connections use the deterministic id-derived name, but the migrated
|
||||
* legacy connection intentionally keeps [Connection.LEGACY_TOKEN_STORE_KEY].
|
||||
*/
|
||||
private val tokenStoreKey: String? = null,
|
||||
/**
|
||||
* When false, [init] skips the eager session-token hydration (and the
|
||||
* keyset decrypt it forces). Used for the throwaway LEGACY SENTINEL manager
|
||||
* that `ConnectionViewModel` builds at field-init and replaces as soon as
|
||||
* the active connection hydrates — decrypting its keyset only to discard it
|
||||
* is a measured ~600 ms of wasted startup keystore work, and on a device
|
||||
* whose active connection isn't connection 0 the sentinel's file has no
|
||||
* token anyway. The real per-connection manager (created via the active
|
||||
* connection, [eagerHydrate] = true) hydrates normally; the
|
||||
* `restorePersistedActiveConnectionContext` path even awaits its
|
||||
* Paired/Failed state. Channel handlers are still registered either way.
|
||||
*/
|
||||
private val eagerHydrate: Boolean = true,
|
||||
) : ChannelMultiplexer.ChannelHandler {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AuthManager"
|
||||
private const val KEY_SESSION_TOKEN = "session_token"
|
||||
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||
private const val KEY_DEVICE_ID = "device_id"
|
||||
private const val KEY_API_KEY = "api_server_key"
|
||||
private const val HINT_API_KEY_PRESENT = "api_key_present"
|
||||
private const val KEY_PAIRED_META = "paired_session_meta_json"
|
||||
// Marker (in the connection-0 token store) recording that the one-shot
|
||||
// pre-StrongBox `hermes_companion_auth` → `hermes_companion_auth_hw`
|
||||
// migration has run, so we never rebuild the legacy keyset to re-check.
|
||||
private const val KEY_LEGACY_MIGRATED = "legacy_migrated"
|
||||
private const val PAIRING_CODE_LENGTH = 6
|
||||
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
|
||||
|
||||
@@ -96,6 +131,87 @@ class AuthManager(
|
||||
*/
|
||||
const val CONNECTION_ID_LEGACY: String = "legacy"
|
||||
|
||||
internal fun shouldPreservePairedSessionOnAuthFail(
|
||||
currentState: AuthState,
|
||||
rawReason: String,
|
||||
): Boolean {
|
||||
val lower = rawReason.lowercase()
|
||||
return currentState is AuthState.Paired &&
|
||||
"timeout" in lower &&
|
||||
("auth" in lower || "authentication" in lower)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort read of a connection's stored device id without making
|
||||
* that connection active. Used by the connection removal path so it
|
||||
* can delete the per-device route list before deleting the token
|
||||
* store backing file.
|
||||
*/
|
||||
suspend fun readStoredDeviceId(context: Context, tokenStoreKey: String): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val appContext = context.applicationContext
|
||||
val primary = KeystoreTokenStore.tryCreate(appContext, tokenStoreKey)
|
||||
?: LegacyEncryptedPrefsTokenStore(appContext, tokenStoreKey)
|
||||
primary.getString(KEY_DEVICE_ID)
|
||||
?: if (tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
|
||||
runCatching {
|
||||
LegacyEncryptedPrefsTokenStore(appContext).getString(KEY_DEVICE_ID)
|
||||
}.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exportStoredSecrets(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
): ConnectionAuthSecrets = withContext(Dispatchers.IO) {
|
||||
val store = tokenStoreForBackup(context, tokenStoreKey)
|
||||
ConnectionAuthSecrets(
|
||||
sessionToken = store.getString(KEY_SESSION_TOKEN),
|
||||
refreshToken = store.getString(KEY_REFRESH_TOKEN),
|
||||
deviceId = store.getString(KEY_DEVICE_ID),
|
||||
apiKey = store.getString(KEY_API_KEY),
|
||||
pairedSessionMetaJson = store.getString(KEY_PAIRED_META),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun importStoredSecrets(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
secrets: ConnectionAuthSecrets,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val store = tokenStoreForBackup(context, tokenStoreKey)
|
||||
writeOrRemove(store, KEY_SESSION_TOKEN, secrets.sessionToken)
|
||||
writeOrRemove(store, KEY_REFRESH_TOKEN, secrets.refreshToken)
|
||||
writeOrRemove(store, KEY_DEVICE_ID, secrets.deviceId)
|
||||
writeOrRemove(store, KEY_API_KEY, secrets.apiKey)
|
||||
writeOrRemove(store, KEY_PAIRED_META, secrets.pairedSessionMetaJson)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tokenStoreForBackup(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
): SessionTokenStore {
|
||||
val appContext = context.applicationContext
|
||||
return KeystoreTokenStore.tryCreate(appContext, tokenStoreKey)
|
||||
?: LegacyEncryptedPrefsTokenStore(appContext, tokenStoreKey)
|
||||
}
|
||||
|
||||
private fun writeOrRemove(
|
||||
store: SessionTokenStore,
|
||||
key: String,
|
||||
value: String?,
|
||||
) {
|
||||
if (value == null) {
|
||||
store.remove(key)
|
||||
} else {
|
||||
store.putString(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `profiles` array from an `auth.ok` payload into a list of
|
||||
* [Profile] entries. Extracted out of [handleAuthOk] so it's
|
||||
@@ -119,6 +235,9 @@ class AuthManager(
|
||||
* metadata) are optional on the wire. Missing / malformed values
|
||||
* fall back to `false` / `false` / `0` so older relays stay
|
||||
* compatible and bad server data can't crash the pairing handshake.
|
||||
* - `api_server_*` metadata is optional. When present, it lets the
|
||||
* client route chat through a profile's isolated Hermes API
|
||||
* server without exposing that profile server's key.
|
||||
*/
|
||||
fun parseAgentProfiles(array: JsonArray): List<Profile> {
|
||||
return array.mapNotNull { entry ->
|
||||
@@ -136,6 +255,16 @@ class AuthManager(
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
val skillCount = obj["skill_count"]
|
||||
?.jsonPrimitive?.intOrNull ?: 0
|
||||
val apiServerEnabled = obj["api_server_enabled"]
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
val apiServerUrl = obj["api_server_url"]
|
||||
?.jsonPrimitive?.contentOrNull
|
||||
val apiServerHost = obj["api_server_host"]
|
||||
?.jsonPrimitive?.contentOrNull
|
||||
val apiServerPort = obj["api_server_port"]
|
||||
?.jsonPrimitive?.intOrNull
|
||||
val apiServerKeyPresent = obj["api_server_key_present"]
|
||||
?.jsonPrimitive?.booleanOrNull ?: false
|
||||
Profile(
|
||||
name = name,
|
||||
model = model,
|
||||
@@ -144,6 +273,11 @@ class AuthManager(
|
||||
gatewayRunning = gatewayRunning,
|
||||
hasSoul = hasSoul,
|
||||
skillCount = skillCount,
|
||||
apiServerEnabled = apiServerEnabled,
|
||||
apiServerUrl = apiServerUrl,
|
||||
apiServerHost = apiServerHost,
|
||||
apiServerPort = apiServerPort,
|
||||
apiServerKeyPresent = apiServerKeyPresent,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -156,6 +290,48 @@ class AuthManager(
|
||||
private var _store: SessionTokenStore? = null
|
||||
private val storeMutex = Mutex()
|
||||
|
||||
/**
|
||||
* The encrypted-store filename for this connection — shared by [store]
|
||||
* and the plain hint file below so they always describe the same store.
|
||||
*/
|
||||
private val tokenPrefsName: String =
|
||||
tokenStoreKey ?: if (connectionId == CONNECTION_ID_LEGACY) {
|
||||
Connection.LEGACY_TOKEN_STORE_KEY
|
||||
} else {
|
||||
Connection.buildTokenStoreKey(connectionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain (non-encrypted) mirror of one boolean fact: "does this
|
||||
* connection have an API key stored?". Read at startup WITHOUT touching
|
||||
* the Keystore, so [ConnectionViewModel] can build the API client
|
||||
* immediately for key-less connections — the common local setup —
|
||||
* instead of queueing behind the encrypted store's first decrypt.
|
||||
*
|
||||
* Why this exists: on StrongBox devices every keystore operation runs
|
||||
* ~550ms and Tink serializes them process-globally; a measured S25
|
||||
* Ultra cold start spent 15 seconds in that marathon before
|
||||
* `getApiKey()` could return — only to answer "there is no key".
|
||||
*
|
||||
* The hint stores ONLY presence, never key material. It defaults to
|
||||
* `true` (unknown ⇒ assume a key exists ⇒ wait for the real decrypt),
|
||||
* so a missing or stale hint can never strip auth off a keyed
|
||||
* connection — the failure mode is "slow like before", never "401s".
|
||||
* It converges in [setApiKey]/[clearApiKey], in init's store
|
||||
* hydration, and after legacy migration.
|
||||
*/
|
||||
private val hintPrefs by lazy {
|
||||
context.getSharedPreferences("${tokenPrefsName}_plain_hints", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
/** True only when a previously-recorded hint says "no API key stored". */
|
||||
fun apiKeyKnownAbsent(): Boolean = !hintPrefs.getBoolean(HINT_API_KEY_PRESENT, true)
|
||||
|
||||
private fun recordApiKeyHint(present: Boolean) {
|
||||
_apiKeyPresent.value = present
|
||||
hintPrefs.edit().putBoolean(HINT_API_KEY_PRESENT, present).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily construct the best available token store. First tries
|
||||
* [KeystoreTokenStore] — if that fails on broken OEM keystores we fall
|
||||
@@ -170,24 +346,29 @@ class AuthManager(
|
||||
_store?.let { return it }
|
||||
return storeMutex.withLock {
|
||||
_store?.let { return it }
|
||||
withContext(Dispatchers.IO) {
|
||||
// Multi-connection: pick the EncryptedSharedPreferences
|
||||
// filename based on the bound connection. The legacy sentinel
|
||||
// keeps the pre-multi-connection install on its original file
|
||||
// so the existing paired device keeps working with no
|
||||
// migration.
|
||||
val prefsName = if (connectionId == CONNECTION_ID_LEGACY) {
|
||||
Connection.LEGACY_TOKEN_STORE_KEY
|
||||
} else {
|
||||
Connection.buildTokenStoreKey(connectionId)
|
||||
val picked = withContext(Dispatchers.IO) {
|
||||
// One keyset build per file, process-wide (see [SecureStoreCache]).
|
||||
// The legacy sentinel is deferred (eagerHydrate=false) and the
|
||||
// dashboard cookie store now shares this same file, so the active
|
||||
// connection's token keyset is the ONLY one built on the cold-
|
||||
// start critical path. [tokenPrefsName] picks the file.
|
||||
//
|
||||
// The build decrypts its Tink keyset eagerly, so a corrupt file
|
||||
// can throw AEADBadTagException — KeystoreTokenStore.tryCreate
|
||||
// degrades to null, the legacy store self-heals in its ctor, and
|
||||
// a fundamentally broken keystore falls back to InMemory (the app
|
||||
// stays up; the user re-pairs). See [buildRawTokenStore].
|
||||
val s = SecureStoreCache.getOrBuild(tokenPrefsName) {
|
||||
buildRawTokenStore(context, tokenPrefsName)
|
||||
}
|
||||
val picked: SessionTokenStore =
|
||||
KeystoreTokenStore.tryCreate(context, prefsName)
|
||||
?: LegacyEncryptedPrefsTokenStore(context, prefsName)
|
||||
migrateFromLegacyIfNeeded(picked)
|
||||
_store = picked
|
||||
picked
|
||||
// Migration runs AFTER the (shared) build so the cookie store can
|
||||
// trigger the build without needing token-migration logic; a
|
||||
// marker makes it read the legacy file at most once ever.
|
||||
migrateFromLegacyIfNeeded(s)
|
||||
s
|
||||
}
|
||||
_store = picked
|
||||
picked
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,18 +380,43 @@ class AuthManager(
|
||||
*/
|
||||
private fun migrateFromLegacyIfNeeded(picked: SessionTokenStore) {
|
||||
if (picked is LegacyEncryptedPrefsTokenStore) return
|
||||
// Multi-connection: only the legacy connection inherits from the pre-
|
||||
// multi-connection `hermes_companion_auth` file. A freshly-minted
|
||||
// per-connection store must NOT be seeded from the legacy file or
|
||||
// Gate on the FILE, not the connection id. Only the legacy connection-0
|
||||
// file (`hermes_companion_auth_hw`) inherits from the pre-multi-
|
||||
// connection `hermes_companion_auth` file; a freshly-minted per-
|
||||
// connection store (`hermes_auth_<id>`) must NOT be seeded from it or
|
||||
// we'd copy connection 0's token into every new connection.
|
||||
if (connectionId != CONNECTION_ID_LEGACY) return
|
||||
//
|
||||
// Why file-gated rather than `connectionId == CONNECTION_ID_LEGACY`:
|
||||
// the store build is now cached/deduped across the legacy sentinel and
|
||||
// the real connection-0 manager, so whichever one builds the file first
|
||||
// runs this migration. Both share `tokenPrefsName == LEGACY_TOKEN_STORE_KEY`
|
||||
// but only the sentinel had `connectionId == CONNECTION_ID_LEGACY`, so
|
||||
// the old id-based gate would skip migration whenever the real manager
|
||||
// won the race — dropping a pre-StrongBox user's token. The file name is
|
||||
// the same for both, so gating on it is race-proof.
|
||||
if (tokenPrefsName != Connection.LEGACY_TOKEN_STORE_KEY) return
|
||||
// Read the legacy file at most ONCE ever. The build is now cache-shared
|
||||
// (and the cookie store can trigger it without migrating), so without
|
||||
// this marker every freshly-rebuilt connection-0 AuthManager would
|
||||
// re-build the legacy `hermes_companion_auth` keyset just to find it
|
||||
// already drained — re-introducing the startup cost we just removed.
|
||||
if (picked.contains(KEY_LEGACY_MIGRATED)) return
|
||||
val legacy = try {
|
||||
LegacyEncryptedPrefsTokenStore(context)
|
||||
} catch (_: Exception) {
|
||||
// Legacy file unreadable/corrupt — nothing to inherit. Still mark
|
||||
// done so its keyset isn't rebuilt on every launch.
|
||||
picked.putString(KEY_LEGACY_MIGRATED, "1")
|
||||
return
|
||||
}
|
||||
|
||||
val keysToMigrate = listOf(KEY_SESSION_TOKEN, KEY_DEVICE_ID, KEY_API_KEY, KEY_PAIRED_META)
|
||||
val keysToMigrate = listOf(
|
||||
KEY_SESSION_TOKEN,
|
||||
KEY_REFRESH_TOKEN,
|
||||
KEY_DEVICE_ID,
|
||||
KEY_API_KEY,
|
||||
KEY_PAIRED_META,
|
||||
)
|
||||
var migrated = false
|
||||
for (k in keysToMigrate) {
|
||||
val existing = legacy.getString(k) ?: continue
|
||||
@@ -224,6 +430,7 @@ class AuthManager(
|
||||
// backup copies of the session token lying around.
|
||||
legacy.clearAll()
|
||||
}
|
||||
picked.putString(KEY_LEGACY_MIGRATED, "1")
|
||||
}
|
||||
|
||||
/** Cert pin store — shared across all relay connections. */
|
||||
@@ -352,22 +559,28 @@ class AuthManager(
|
||||
// one-line change in [onMessage].
|
||||
multiplexer.registerHandler("pairing", this)
|
||||
|
||||
// Check for existing session token off main thread
|
||||
scope.launch {
|
||||
val s = store()
|
||||
val existingToken = s.getString(KEY_SESSION_TOKEN)
|
||||
if (existingToken != null) {
|
||||
_authState.value = AuthState.Paired(existingToken)
|
||||
_currentPairedSession.value = loadStoredMetadata(existingToken)
|
||||
Log.i(
|
||||
TAG,
|
||||
"init: hydrated existing session_token=${existingToken.take(8)}… " +
|
||||
"→ authState=Paired (stale-at-startup unless this is a real continuous session)"
|
||||
)
|
||||
} else {
|
||||
Log.i(TAG, "init: no stored session_token → authState stays Unpaired")
|
||||
// Check for existing session token off main thread. Skipped for the
|
||||
// throwaway sentinel (eagerHydrate=false) so it never pays the keyset
|
||||
// decrypt for a store that's about to be replaced (see [eagerHydrate]).
|
||||
if (eagerHydrate) {
|
||||
scope.launch {
|
||||
val s = store()
|
||||
val existingToken = s.getString(KEY_SESSION_TOKEN)
|
||||
if (existingToken != null) {
|
||||
_authState.value = AuthState.Paired(existingToken)
|
||||
_currentPairedSession.value = loadStoredMetadata(existingToken)
|
||||
Log.i(
|
||||
TAG,
|
||||
"init: hydrated existing session_token=${existingToken.take(8)}… " +
|
||||
"→ authState=Paired (stale-at-startup unless this is a real continuous session)"
|
||||
)
|
||||
} else {
|
||||
Log.i(TAG, "init: no stored session_token → authState stays Unpaired")
|
||||
}
|
||||
// Converge the plain api-key-present hint with the decrypted
|
||||
// truth (also repairs a hint that predates legacy migration).
|
||||
recordApiKeyHint(!s.getString(KEY_API_KEY).isNullOrBlank())
|
||||
}
|
||||
_apiKeyPresent.value = !s.getString(KEY_API_KEY).isNullOrBlank()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,6 +665,9 @@ class AuthManager(
|
||||
*/
|
||||
suspend fun getOrCreateDeviceId(): String = getDeviceId()
|
||||
|
||||
/** Existing device ID without creating a new one. */
|
||||
suspend fun getExistingDeviceId(): String? = store().getString(KEY_DEVICE_ID)
|
||||
|
||||
/**
|
||||
* Set the TTL the user picked at [SessionTtlPickerDialog]. `0` → never,
|
||||
* `null` → defer to server default. Persisted across [authenticate]
|
||||
@@ -508,12 +724,17 @@ class AuthManager(
|
||||
val deviceId = getDeviceId()
|
||||
val payload = when (currentState) {
|
||||
is AuthState.Paired -> {
|
||||
val refreshToken = store().getString(KEY_REFRESH_TOKEN)
|
||||
Log.i(
|
||||
TAG,
|
||||
"authenticate: sending session_token (state=Paired, token=${currentState.token.take(8)}…)"
|
||||
"authenticate: sending session_token (state=Paired, token=${currentState.token.take(8)}…, " +
|
||||
"refresh=${!refreshToken.isNullOrBlank()})"
|
||||
)
|
||||
buildJsonObject {
|
||||
put("session_token", currentState.token)
|
||||
if (!refreshToken.isNullOrBlank()) {
|
||||
put("refresh_token", refreshToken)
|
||||
}
|
||||
put("device_id", deviceId)
|
||||
put("device_name", android.os.Build.MODEL)
|
||||
}
|
||||
@@ -597,6 +818,7 @@ class AuthManager(
|
||||
scope.launch {
|
||||
val s = store()
|
||||
s.remove(KEY_SESSION_TOKEN)
|
||||
s.remove(KEY_REFRESH_TOKEN)
|
||||
s.remove(KEY_PAIRED_META)
|
||||
if (relayUrl != null) {
|
||||
certPinStore.removePinFor(relayUrl)
|
||||
@@ -669,6 +891,7 @@ class AuthManager(
|
||||
scope.launch {
|
||||
val s = store()
|
||||
s.remove(KEY_SESSION_TOKEN)
|
||||
s.remove(KEY_REFRESH_TOKEN)
|
||||
s.remove(KEY_PAIRED_META)
|
||||
_authState.value = AuthState.Unpaired
|
||||
_currentPairedSession.value = null
|
||||
@@ -685,16 +908,16 @@ class AuthManager(
|
||||
val s = store()
|
||||
if (trimmed.isBlank()) {
|
||||
s.remove(KEY_API_KEY)
|
||||
_apiKeyPresent.value = false
|
||||
recordApiKeyHint(false)
|
||||
} else {
|
||||
s.putString(KEY_API_KEY, trimmed)
|
||||
_apiKeyPresent.value = true
|
||||
recordApiKeyHint(true)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearApiKey() {
|
||||
store().remove(KEY_API_KEY)
|
||||
_apiKeyPresent.value = false
|
||||
recordApiKeyHint(false)
|
||||
}
|
||||
|
||||
val isPaired: Boolean
|
||||
@@ -717,6 +940,14 @@ class AuthManager(
|
||||
if (token != null) {
|
||||
val s = store()
|
||||
s.putString(KEY_SESSION_TOKEN, token)
|
||||
val refreshToken = payload["refresh_token"]
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (refreshToken != null) {
|
||||
s.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
Log.i(TAG, "handleAuthOk: stored rotated refresh token")
|
||||
}
|
||||
_authState.value = AuthState.Paired(token)
|
||||
Log.i(TAG, "handleAuthOk: Paired(token=${token.take(8)}…)")
|
||||
// Server-issued code is one-shot — drop it once the
|
||||
@@ -818,13 +1049,36 @@ class AuthManager(
|
||||
?: "Unknown error"
|
||||
val humanized = humanizeAuthFailReason(rawReason)
|
||||
Log.w(TAG, "handleAuthFail: raw=$rawReason humanized=$humanized")
|
||||
if (shouldPreservePairedSessionOnAuthFail(_authState.value, rawReason)) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"handleAuthFail: preserving paired session after transient auth timeout"
|
||||
)
|
||||
return
|
||||
}
|
||||
clearPendingPairContextAfterAuthFailure(rawReason)
|
||||
_authState.value = AuthState.Failed(humanized)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "handleAuthFail: exception parsing payload", e)
|
||||
clearPendingPairContextAfterAuthFailure("parse failure")
|
||||
_authState.value = AuthState.Failed("Authentication failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearPendingPairContextAfterAuthFailure(reason: String) {
|
||||
if (serverIssuedCode == null) return
|
||||
serverIssuedCode = null
|
||||
pendingTtlSeconds = null
|
||||
pendingGrants = null
|
||||
pendingEndpoints = null
|
||||
_pairingCode.value = generatePairingCode()
|
||||
Log.i(
|
||||
TAG,
|
||||
"auth.fail consumed server-issued pairing code; " +
|
||||
"cleared pending pair context so reconnects stop (reason=$reason)"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map common relay `auth.fail` reasons to user-friendly short strings.
|
||||
* The wizard VerifyStep surfaces the returned text directly, so this is
|
||||
|
||||
@@ -18,10 +18,11 @@ import kotlinx.serialization.Serializable
|
||||
* @property expiresAt epoch seconds at which the server says the session
|
||||
* expires — or `null` when the user chose "never expire" at the
|
||||
* TTL picker. `null` is a first-class value, not a missing field.
|
||||
* @property grants per-channel expiry map. Keys: `"chat"`, `"terminal"`,
|
||||
* `"bridge"` (and any future channel the server adds). Values are
|
||||
* epoch seconds or `null` for "never". Missing channels = server
|
||||
* didn't grant that channel to this device.
|
||||
* @property grants per-channel expiry map. Known keys include `"chat"`,
|
||||
* `"terminal"`, `"bridge"`, `"tui"`, `"voice:config"`,
|
||||
* `"voice:stt"`, and `"voice:tts"`; future server-defined keys are
|
||||
* tolerated. Values are epoch seconds or `null` for "never".
|
||||
* Missing channels = server didn't grant that channel to this device.
|
||||
* @property transportHint the transport the server advises the phone to
|
||||
* use, for UX labeling only. `"wss"` / `"ws"` / `null` when the
|
||||
* server didn't provide a hint.
|
||||
|
||||
@@ -6,6 +6,44 @@ import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Process-global cache for encrypted stores, keyed by prefs-file name.
|
||||
*
|
||||
* `EncryptedSharedPreferences.create()` unwraps a Tink keyset via a KeyStore op
|
||||
* (~0.6–1 s on StrongBox), and Tink serializes those process-globally — so a
|
||||
* second build of the SAME file is pure waste (the measured cold-start
|
||||
* `Long monitor contention … AndroidKeysetManager.build()` with `waiters=1..4`).
|
||||
*
|
||||
* Caching by file name means each file's keyset builds ONCE process-wide. The
|
||||
* cache is **synchronous** ([ConcurrentHashMap.computeIfAbsent], which holds a
|
||||
* per-key lock so the build runs at most once per file) precisely so the SAME
|
||||
* instance serves both the suspend token path (callers wrap this in
|
||||
* [kotlinx.coroutines.Dispatchers.IO]) AND the synchronous OkHttp cookie-jar
|
||||
* path — which is how the dashboard cookies now ride the connection's
|
||||
* already-built token keyset instead of building a second one.
|
||||
*
|
||||
* The build is ~1 s on StrongBox: call only from IO / OkHttp threads, never the
|
||||
* main thread.
|
||||
*/
|
||||
internal object SecureStoreCache {
|
||||
private val instances = ConcurrentHashMap<String, SessionTokenStore>()
|
||||
|
||||
fun getOrBuild(prefsName: String, build: () -> SessionTokenStore): SessionTokenStore =
|
||||
instances.computeIfAbsent(prefsName) { build() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the raw encrypted store for [prefsName] — Keystore-backed when possible,
|
||||
* self-healing legacy fallback, in-memory last resort. No migration. Shared by
|
||||
* the token store and the dashboard cookie store so a given file always yields
|
||||
* the SAME backend, via [SecureStoreCache].
|
||||
*/
|
||||
internal fun buildRawTokenStore(context: Context, prefsName: String): SessionTokenStore =
|
||||
KeystoreTokenStore.tryCreate(context, prefsName)
|
||||
?: runCatching { LegacyEncryptedPrefsTokenStore(context, prefsName) }
|
||||
.getOrElse { InMemoryTokenStore() }
|
||||
|
||||
/**
|
||||
* Abstraction over the storage backend for the relay session token + API key
|
||||
@@ -72,9 +110,12 @@ class KeystoreTokenStore private constructor(
|
||||
) : SessionTokenStore {
|
||||
|
||||
// Mutable so [resetPrefs] can swap in a fresh instance after a corrupted
|
||||
// file is deleted. Built lazily via [buildPrefs] so the constructor can't
|
||||
// throw — [tryCreate] still controls the "is this device usable at all"
|
||||
// decision via its init probe below.
|
||||
// file is deleted. This field initializer runs [buildPrefs] eagerly, so it
|
||||
// CAN throw (e.g. AEADBadTagException on a corrupt keyset) — but the
|
||||
// constructor is private and only reachable via [tryCreate], which wraps
|
||||
// construction in try/catch and degrades to the legacy store. The
|
||||
// directly-constructed legacy path self-heals instead; see
|
||||
// [LegacyEncryptedPrefsTokenStore.buildPrefsResilient].
|
||||
private var prefs: SharedPreferences = buildPrefs()
|
||||
|
||||
private fun buildPrefs(): SharedPreferences {
|
||||
@@ -257,7 +298,38 @@ class LegacyEncryptedPrefsTokenStore(
|
||||
|
||||
// Mutable so [resetPrefs] can swap in a fresh instance after a corrupted
|
||||
// file is deleted. See [KeystoreTokenStore.resetPrefs] for the rationale.
|
||||
private var prefs: SharedPreferences = buildPrefs()
|
||||
//
|
||||
// Built via [buildPrefsResilient] so a corrupt keyset can't crash the
|
||||
// constructor. Unlike [KeystoreTokenStore], this class is `new`-ed
|
||||
// directly (it's the fallback when KeystoreTokenStore.tryCreate returns
|
||||
// null, and the migration source), so there's no tryCreate-style guard
|
||||
// upstream — the healing has to live here.
|
||||
private var prefs: SharedPreferences = buildPrefsResilient()
|
||||
|
||||
/**
|
||||
* Build the encrypted prefs, healing a corrupted keyset on the way.
|
||||
*
|
||||
* [EncryptedSharedPreferences.create] decrypts the Tink keyset eagerly, so
|
||||
* a stale/corrupt legacy file throws [javax.crypto.AEADBadTagException]
|
||||
* (AES-GCM tag mismatch) right here in the constructor. This is the classic
|
||||
* post-upgrade / post-restore failure: the encrypted blob persists but the
|
||||
* hardware master key it was sealed against is gone or rotated. Delete the
|
||||
* file and rebuild a fresh keyset against the current master key rather
|
||||
* than letting the exception escape and force-close the app — the token in
|
||||
* the unreadable file was lost anyway, so the user simply re-pairs.
|
||||
*/
|
||||
private fun buildPrefsResilient(): SharedPreferences =
|
||||
try {
|
||||
buildPrefs()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Initial legacy prefs build failed — wiping corrupted file and rebuilding: ${e.message}")
|
||||
try {
|
||||
appContext.deleteSharedPreferences(prefsName)
|
||||
} catch (e2: Exception) {
|
||||
Log.w(TAG, "deleteSharedPreferences($prefsName) failed: ${e2.message}")
|
||||
}
|
||||
buildPrefs()
|
||||
}
|
||||
|
||||
private fun buildPrefs(): SharedPreferences {
|
||||
val masterKey = MasterKey.Builder(appContext)
|
||||
@@ -342,3 +414,24 @@ class LegacyEncryptedPrefsTokenStore(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory last-resort implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Non-persistent [SessionTokenStore]. Used only when BOTH the Keystore and the
|
||||
* (self-healing) legacy encrypted store fail to construct — i.e. the device's
|
||||
* AndroidKeystore is so broken it can't even build a fresh key. Tokens live for
|
||||
* the process lifetime only, so the user re-pairs on the next cold start, but
|
||||
* the app stays up instead of force-closing. See [AuthManager.store].
|
||||
*/
|
||||
class InMemoryTokenStore : SessionTokenStore {
|
||||
private val map = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||
override val hasHardwareBackedStorage: Boolean = false
|
||||
override fun getString(key: String): String? = map[key]
|
||||
override fun putString(key: String, value: String) { map[key] = value }
|
||||
override fun remove(key: String) { map.remove(key) }
|
||||
override fun contains(key: String): Boolean = map.containsKey(key)
|
||||
override fun clearAll() { map.clear() }
|
||||
}
|
||||
|
||||
@@ -70,8 +70,10 @@ class AutoDisableWorker(private val context: Context) {
|
||||
// call is also wrapped in runCatching to swallow SecurityException as
|
||||
// a belt-and-braces. Suppress here rather than inlining the check —
|
||||
// the helper exists so the same gate can grow more conditions later
|
||||
// without each call site re-implementing it.
|
||||
@SuppressLint("MissingPermission")
|
||||
// without each call site re-implementing it. Both IDs are needed:
|
||||
// `NotificationPermission` is the notify()-specific check (POST_NOTIFICATIONS
|
||||
// on API 33+); `MissingPermission` is the generic fallback.
|
||||
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||
private fun postNotification() {
|
||||
ensureChannel()
|
||||
if (!hasPostNotificationsPermission()) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.bridge
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
@@ -300,6 +301,16 @@ class BridgeForegroundService : Service() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// ForegroundServiceType: lint requires the manifest `<service>` to declare
|
||||
// `foregroundServiceType` for targetSdk >= 34. The SIDELOAD manifest does
|
||||
// (specialUse|mediaProjection) + declares the matching FOREGROUND_SERVICE_*
|
||||
// permissions. The GOOGLEPLAY flavor deliberately omits this service AND
|
||||
// those permissions (no device-control capability for Play-Store
|
||||
// compliance), so this code is unreachable there — the service can't be
|
||||
// started without a manifest declaration. Lint analyzes the merged
|
||||
// googlePlay manifest and can't see the sideload guarantee, so suppress
|
||||
// here rather than weaken googlePlay by granting it specialUse.
|
||||
@SuppressLint("ForegroundServiceType")
|
||||
private fun startForegroundNotification() {
|
||||
ensureChannel()
|
||||
val notification = buildNotification()
|
||||
|
||||
@@ -25,7 +25,6 @@ import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.hermesandroid.relay.ui.components.BridgeStatusOverlayChip
|
||||
import com.hermesandroid.relay.ui.components.DestructiveVerbConfirmDialog
|
||||
import com.hermesandroid.relay.util.ComposeArrWorkaround
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
@@ -158,7 +157,6 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
|
||||
Log.w(TAG, "addView(chip) failed", it)
|
||||
return
|
||||
}
|
||||
compose.post { ComposeArrWorkaround.disableForViewTree(compose) }
|
||||
chipView = compose
|
||||
chipUnattended = unattended
|
||||
}
|
||||
@@ -227,7 +225,6 @@ class BridgeStatusOverlay(context: Context) : ConfirmationOverlayHost {
|
||||
onResult(false)
|
||||
return
|
||||
}
|
||||
compose.post { ComposeArrWorkaround.disableForViewTree(compose) }
|
||||
activeConfirmations[request.id] = compose
|
||||
}
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ object UnattendedAccessManager {
|
||||
* Acquire the screen-bright wake lock + opportunistically request
|
||||
* keyguard dismiss. Synchronous — does not suspend. The caller (
|
||||
* [com.hermesandroid.relay.accessibility.ActionExecutor] wrapper, or
|
||||
* [com.hermesandroid.relay.network.handlers.BridgeCommandHandler]
|
||||
* [com.hermesandroid.relay.network.relay.BridgeCommandHandler]
|
||||
* pre-dispatch hook) holds onto the result and decides whether to
|
||||
* proceed with the action.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* Shared profile/personality display and request identity helpers.
|
||||
*
|
||||
* A null profile name is the app's explicit "Server default" state. The
|
||||
* relay also advertises the root Hermes config as a synthetic profile named
|
||||
* "default"; for request/session identity that row is an alias of server
|
||||
* default so it does not split chat, voice, or session scope.
|
||||
*/
|
||||
object AgentDisplay {
|
||||
const val SERVER_DEFAULT_PROFILE_KEY: String = "__server_default__"
|
||||
private val GENERIC_MODEL_ALIASES = setOf(
|
||||
"hermes-agent",
|
||||
"hermes_agent",
|
||||
"hermes agent",
|
||||
)
|
||||
|
||||
// Only an EXPLICIT pick drives request/session identity. The advertised
|
||||
// "default" profile is an alias for server default, so falling back to it
|
||||
// here would split chat, voice, or session scope.
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun effectiveProfile(
|
||||
selectedProfile: Profile?,
|
||||
profiles: List<Profile>,
|
||||
): Profile? = selectedProfile
|
||||
|
||||
// Display can use the synthetic default profile's metadata without making
|
||||
// it a request/session override. Verbose SOUL summaries are filtered by
|
||||
// profileDisplayName below, so this is safe for headers/cards.
|
||||
fun effectiveDisplayProfile(
|
||||
selectedProfile: Profile?,
|
||||
profiles: List<Profile>,
|
||||
): Profile? = selectedProfile ?: profiles.firstOrNull { isServerDefaultAlias(it.name) }
|
||||
|
||||
// The NAME goes in the name slot. Non-default profiles use their profile
|
||||
// name first. The synthetic default profile uses its description only when
|
||||
// that description looks like a concise human agent name ("Victor"), not a
|
||||
// verbose SOUL summary.
|
||||
fun profileDisplayName(profile: Profile?): String? {
|
||||
if (profile == null) return null
|
||||
if (isServerDefaultAlias(profile.name)) {
|
||||
return defaultProfileDisplayName(profile)
|
||||
}
|
||||
return when {
|
||||
profile.name.isNotBlank() -> titleCase(profile.name.trim())
|
||||
profile.description.isNotBlank() -> profile.description.trim()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun defaultProfileDisplayName(profile: Profile?): String? =
|
||||
profile
|
||||
?.description
|
||||
?.trim()
|
||||
?.takeIf { it.looksLikeConciseAgentName() }
|
||||
?.let(::titleCase)
|
||||
|
||||
fun agentName(
|
||||
profile: Profile?,
|
||||
selectedPersonality: String,
|
||||
defaultPersonality: String,
|
||||
connectionLabel: String?,
|
||||
localDisplayAlias: String? = null,
|
||||
): String {
|
||||
localDisplayAlias(localDisplayAlias)?.let { return it }
|
||||
profileDisplayName(profile)?.let { return it }
|
||||
|
||||
// "none"/"neutral" are the upstream "cleared overlay" aliases — treat
|
||||
// them like "default" for identity: fall through to the server default
|
||||
// (or the base connection identity) rather than rendering the literal
|
||||
// word as an agent name.
|
||||
val personalityName = if (
|
||||
isClearedPersonality(selectedPersonality) &&
|
||||
defaultPersonality.isNotBlank()
|
||||
) {
|
||||
defaultPersonality
|
||||
} else if (isClearedPersonality(selectedPersonality)) {
|
||||
""
|
||||
} else {
|
||||
selectedPersonality
|
||||
}
|
||||
|
||||
return when {
|
||||
personalityName.isNotBlank() && personalityName != "default" ->
|
||||
titleCase(personalityName.trim())
|
||||
!connectionLabel.isNullOrBlank() -> connectionLabel.trim()
|
||||
else -> "Hermes"
|
||||
}
|
||||
}
|
||||
|
||||
/** True for the upstream "clear the overlay" aliases (default == none == neutral). */
|
||||
fun isClearedPersonality(value: String): Boolean =
|
||||
value.trim().lowercase() in setOf("default", "none", "neutral", "")
|
||||
|
||||
fun personalityLabel(
|
||||
selectedPersonality: String,
|
||||
defaultPersonality: String,
|
||||
): String = when {
|
||||
// Explicit "none" — show "None" (or the configured default name, if any)
|
||||
// so the cleared-overlay state is legible in the picker header.
|
||||
selectedPersonality.trim().lowercase() in setOf("none", "neutral") ->
|
||||
if (defaultPersonality.isNotBlank()) titleCase(defaultPersonality.trim()) else "None"
|
||||
selectedPersonality != "default" && selectedPersonality.isNotBlank() ->
|
||||
titleCase(selectedPersonality.trim())
|
||||
defaultPersonality.isNotBlank() -> titleCase(defaultPersonality.trim())
|
||||
else -> "Default"
|
||||
}
|
||||
|
||||
fun displayModelName(model: String?): String? =
|
||||
model
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.takeUnless { it.lowercase() in GENERIC_MODEL_ALIASES }
|
||||
|
||||
/**
|
||||
* A model string safe to SEND to the server as a model override or
|
||||
* `config.set model=…`. Returns null for the generic agent placeholders
|
||||
* ("hermes-agent", …) which are NOT real models — the server rejects them
|
||||
* (HTTP 400) and falls back. Null means "send no model; use the server's
|
||||
* configured default."
|
||||
*/
|
||||
fun requestModelName(model: String?): String? =
|
||||
model
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.takeUnless { it.lowercase() in GENERIC_MODEL_ALIASES }
|
||||
|
||||
fun isServerDefaultAlias(profileName: String?): Boolean =
|
||||
profileName?.trim()?.equals("default", ignoreCase = true) == true
|
||||
|
||||
fun normalizeSelection(profile: Profile?): Profile? =
|
||||
if (isServerDefaultAlias(profile?.name)) null else profile
|
||||
|
||||
fun profileRequestName(profileName: String?): String? =
|
||||
profileName
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() && !isServerDefaultAlias(it) }
|
||||
|
||||
fun profileSessionKey(profileName: String?): String =
|
||||
profileRequestName(profileName) ?: SERVER_DEFAULT_PROFILE_KEY
|
||||
|
||||
fun profileContextKey(connectionId: String?, profileName: String?): String =
|
||||
"${connectionId.orEmpty()}::${profileSessionKey(profileName)}"
|
||||
|
||||
fun localDisplayAlias(value: String?): String? =
|
||||
value
|
||||
?.trim()
|
||||
?.replace(Regex("\\s+"), " ")
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun String.looksLikeConciseAgentName(): Boolean {
|
||||
if (isBlank() || length > 40 || contains('\n') || contains('\r')) {
|
||||
return false
|
||||
}
|
||||
if (any { it == '.' || it == ':' || it == ';' }) {
|
||||
return false
|
||||
}
|
||||
return trim().split(Regex("\\s+")).size <= 4
|
||||
}
|
||||
|
||||
private fun titleCase(value: String): String =
|
||||
value.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
@@ -87,15 +88,17 @@ class BargeInPreferencesRepository(
|
||||
booleanPreferencesKey("barge_in_resume_after_interruption")
|
||||
}
|
||||
|
||||
val flow: Flow<BargeInPreferences> = dataStore.data.map { prefs ->
|
||||
BargeInPreferences(
|
||||
enabled = prefs[KEY_ENABLED] ?: DEFAULT_ENABLED,
|
||||
sensitivity = prefs[KEY_SENSITIVITY]?.let { decodeSensitivity(it) }
|
||||
?: DEFAULT_SENSITIVITY,
|
||||
resumeAfterInterruption = prefs[KEY_RESUME_AFTER_INTERRUPTION]
|
||||
?: DEFAULT_RESUME_AFTER_INTERRUPTION,
|
||||
)
|
||||
}
|
||||
val flow: Flow<BargeInPreferences> = dataStore.data
|
||||
.map { prefs ->
|
||||
BargeInPreferences(
|
||||
enabled = prefs[KEY_ENABLED] ?: DEFAULT_ENABLED,
|
||||
sensitivity = prefs[KEY_SENSITIVITY]?.let { decodeSensitivity(it) }
|
||||
?: DEFAULT_SENSITIVITY,
|
||||
resumeAfterInterruption = prefs[KEY_RESUME_AFTER_INTERRUPTION]
|
||||
?: DEFAULT_RESUME_AFTER_INTERRUPTION,
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun setEnabled(value: Boolean) {
|
||||
dataStore.edit { it[KEY_ENABLED] = value }
|
||||
|
||||
@@ -39,12 +39,14 @@ data class ChatMessage(
|
||||
val estimatedCost: Double? = null,
|
||||
// Agent/personality name for display on assistant messages
|
||||
val agentName: String? = null,
|
||||
// Small provenance badges rendered on assistant bubbles.
|
||||
val badges: List<String> = emptyList(),
|
||||
// File attachments (images, documents, etc.)
|
||||
val attachments: List<Attachment> = emptyList(),
|
||||
/**
|
||||
* Rich content cards emitted by the agent via `CARD:{json}` line
|
||||
* markers in the text stream. Parsed in
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler.scanForCardMarkers]
|
||||
* [com.hermesandroid.relay.network.upstream.ChatHandler.scanForCardMarkers]
|
||||
* and rendered inline by
|
||||
* [com.hermesandroid.relay.ui.components.HermesCardBubble]. Mirrors
|
||||
* [attachments]' lifecycle — the marker line is stripped from
|
||||
@@ -74,15 +76,46 @@ data class ChatMessage(
|
||||
* The sync builder treats messages with [voiceIntent] != null and
|
||||
* [VoiceIntentTrace.syncedToServer] == false as the inputs to its
|
||||
* synthesis pass; on a successful send we flip [VoiceIntentTrace.syncedToServer]
|
||||
* to true via [com.hermesandroid.relay.network.handlers.ChatHandler.markVoiceIntentsSynced]
|
||||
* to true via [com.hermesandroid.relay.network.upstream.ChatHandler.markVoiceIntentsSynced]
|
||||
* so they're not re-sent on the next turn.
|
||||
*/
|
||||
val voiceIntent: VoiceIntentTrace? = null
|
||||
val voiceIntent: VoiceIntentTrace? = null,
|
||||
/**
|
||||
* Provider-native Realtime Agent turns can answer without calling Hermes.
|
||||
* Those local-only assistant turns need to be spliced into the next Hermes
|
||||
* chat/run payload so switching back to normal chat preserves context.
|
||||
*
|
||||
* Hermes-backed realtime turns leave this null because Hermes already owns
|
||||
* the durable session turn; the provider's spoken summary is UI/runtime
|
||||
* provenance, not another canonical assistant message.
|
||||
*/
|
||||
val realtimeTurn: RealtimeTurnTrace? = null,
|
||||
/**
|
||||
* True for bubbles that exist ONLY on the client and have no server-side
|
||||
* row — slash-command notices, voice-intent traces, the steer echo, gateway
|
||||
* ask cards, an errored turn the server never persisted, and a provider-only
|
||||
* (non-Hermes-backed) realtime turn. The post-turn history reload
|
||||
* ([com.hermesandroid.relay.network.upstream.ChatHandler.loadMessageHistory])
|
||||
* preserves any client-only message whose id is absent from the reloaded
|
||||
* server transcript; without the flag those orphans would be silently
|
||||
* wiped by the reconcile.
|
||||
*
|
||||
* Replaces the old id-prefix whitelist (`voice-intent-`/`steer-`/`ask-`/
|
||||
* `system-notice-`) + "Error"-badge sniffing: each creator now declares its
|
||||
* own provenance instead of the reconcile having to know every id
|
||||
* convention. Defaults false so every server-backed message and existing
|
||||
* call site stays correct.
|
||||
*
|
||||
* NOTE: an "Error" badge alone does NOT make a message preservable — a turn
|
||||
* can error *after* persisting server-side, and that message must still
|
||||
* reconcile normally. Only [clientOnly] gates orphan preservation.
|
||||
*/
|
||||
val clientOnly: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Structured details about a phone-local voice intent that was dispatched
|
||||
* in-process via [com.hermesandroid.relay.network.handlers.BridgeCommandHandler.handleLocalCommand].
|
||||
* in-process via [com.hermesandroid.relay.network.relay.BridgeCommandHandler.handleLocalCommand].
|
||||
*
|
||||
* Captured on a [ChatMessage] (id prefix `voice-intent-`) so the next chat
|
||||
* payload can include synthetic OpenAI-format `assistant` + `tool` message
|
||||
@@ -111,12 +144,12 @@ data class ChatMessage(
|
||||
* includes an `error` field.
|
||||
* @property resultJson Compact JSON object describing the dispatch outcome.
|
||||
* On success, typically `{"ok":true,...}` with any tool-specific fields
|
||||
* from [com.hermesandroid.relay.network.handlers.LocalDispatchResult.resultJson].
|
||||
* from [com.hermesandroid.relay.network.shared.LocalDispatchResult.resultJson].
|
||||
* On failure, an error envelope including `ok:false`, `error`, optionally
|
||||
* `error_code`. Stored as a string and rendered verbatim into the
|
||||
* synthetic `tool`-role message's `content` field.
|
||||
* @property syncedToServer Idempotency guard. Flipped to true by
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler.markVoiceIntentsSynced]
|
||||
* [com.hermesandroid.relay.network.upstream.ChatHandler.markVoiceIntentsSynced]
|
||||
* the moment we hand the request payload to the API client. Once true,
|
||||
* the trace is excluded from future sync passes — the server-side
|
||||
* session has already absorbed it.
|
||||
@@ -129,6 +162,24 @@ data class VoiceIntentTrace(
|
||||
val syncedToServer: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Local provider-native realtime turn that has not necessarily been absorbed
|
||||
* into the Hermes session yet.
|
||||
*
|
||||
* Stored on the assistant message so the next normal chat send can emit a
|
||||
* compact OpenAI-format user/assistant pair before the live user message. This
|
||||
* keeps Realtime Agent and Hermes Chat + Voice Output as one conversation even
|
||||
* when the realtime provider answered directly.
|
||||
*/
|
||||
data class RealtimeTurnTrace(
|
||||
val userText: String,
|
||||
val assistantText: String,
|
||||
val provider: String? = null,
|
||||
val model: String? = null,
|
||||
val voice: String? = null,
|
||||
val syncedToServer: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* A file attachment sent with a message.
|
||||
*
|
||||
@@ -156,7 +207,23 @@ data class Attachment(
|
||||
/** Opaque token from `MEDIA:hermes-relay://<token>` — identifies the file on the relay. */
|
||||
val relayToken: String? = null,
|
||||
/** content:// URI from the FileProvider once bytes are cached to disk. */
|
||||
val cachedUri: String? = null
|
||||
val cachedUri: String? = null,
|
||||
/**
|
||||
* Whether this attachment was flagged sensitive (NSFW / spoiler) and should
|
||||
* render blurred until the user taps to reveal — honored per the user's
|
||||
* `MediaSettings.blurMode`.
|
||||
*
|
||||
* The flag is **model-emitted metadata, never an on-device or relay-side
|
||||
* classifier** (see `docs/plans/2026-06-18-attachment-experience.md` §C): the
|
||||
* agent annotates media it surfaces, the relay transports the bit
|
||||
* authoritatively via the `X-Media-Sensitive` response header, and the
|
||||
* client merely renders the blur. Populated for inbound attachments from
|
||||
* [com.hermesandroid.relay.network.relay.RelayHttpClient.FetchedMedia.sensitive]
|
||||
* when the bytes flip to [AttachmentState.LOADED]. Defaults false so every
|
||||
* existing outbound/inbound call site stays valid and unflagged media
|
||||
* renders exactly as before.
|
||||
*/
|
||||
val sensitive: Boolean = false
|
||||
) {
|
||||
val isImage: Boolean get() = contentType.startsWith("image/")
|
||||
|
||||
@@ -204,9 +271,31 @@ data class ToolCall(
|
||||
val success: Boolean?,
|
||||
val isComplete: Boolean = false,
|
||||
val error: String? = null,
|
||||
val runId: String? = null,
|
||||
val provenance: String? = null,
|
||||
// Duration tracking
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
val completedAt: Long? = null
|
||||
val completedAt: Long? = null,
|
||||
/**
|
||||
* Gateway `tool.generating` pre-start phase — the model is still
|
||||
* streaming this tool's arguments. Cleared (flipped false) when the
|
||||
* matching `tool.start` arrives and the call begins executing. Renders
|
||||
* as the quiet "preparing" state in ToolProgressCard / CompactToolCall
|
||||
* rather than the active running spinner.
|
||||
*/
|
||||
val isGenerating: Boolean = false,
|
||||
/**
|
||||
* Subagent lane index from gateway `subagent.*` events (`task_index`).
|
||||
* Null = top-level tool call, rendered exactly as before. Non-null
|
||||
* calls are grouped per index into a SubagentLane under the bubble.
|
||||
*/
|
||||
val taskIndex: Int? = null,
|
||||
/**
|
||||
* Human label for the owning subagent lane — the `subagent.start`
|
||||
* goal truncated to 60 chars. Carried on each child call so the lane
|
||||
* header can render without a separate lane registry.
|
||||
*/
|
||||
val taskLabel: String? = null
|
||||
)
|
||||
|
||||
enum class MessageRole {
|
||||
@@ -220,5 +309,16 @@ data class ChatSession(
|
||||
val title: String?,
|
||||
val model: String?,
|
||||
val messageCount: Int = 0,
|
||||
val updatedAt: Long = 0L
|
||||
)
|
||||
val updatedAt: Long = 0L,
|
||||
val startedAt: Long = 0L,
|
||||
val lastActivityAt: Long = 0L
|
||||
) {
|
||||
val activityTimestamp: Long
|
||||
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
|
||||
|
||||
val startTimestamp: Long
|
||||
get() = firstPositive(startedAt, updatedAt, lastActivityAt)
|
||||
|
||||
private fun firstPositive(vararg values: Long): Long =
|
||||
values.firstOrNull { it > 0L } ?: 0L
|
||||
}
|
||||
|
||||
@@ -3,6 +3,18 @@ package com.hermesandroid.relay.data
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.net.URI
|
||||
|
||||
@Serializable
|
||||
data class DashboardConnectionStatus(
|
||||
val checkedAtMillis: Long? = null,
|
||||
val reachable: Boolean = false,
|
||||
val authRequired: Boolean? = null,
|
||||
val authProviders: List<String> = emptyList(),
|
||||
val authenticated: Boolean? = null,
|
||||
val authProvider: String? = null,
|
||||
val gatewayTicketAvailable: Boolean? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A "connection" = a distinct Hermes server connection the app can switch between.
|
||||
*
|
||||
@@ -18,7 +30,7 @@ import java.net.URI
|
||||
* open the token store.
|
||||
*
|
||||
* Switching connection is a HEAVY context swap — caller is expected to tear down
|
||||
* the current [com.hermesandroid.relay.network.ConnectionManager],
|
||||
* the current [com.hermesandroid.relay.network.relay.ConnectionManager],
|
||||
* [com.hermesandroid.relay.auth.AuthManager], and API client, then construct
|
||||
* fresh ones pointed at the new connection's `tokenStoreKey`.
|
||||
*
|
||||
@@ -30,9 +42,9 @@ import java.net.URI
|
||||
*
|
||||
* **Terminology note (2026-04-18):** earlier drafts of this feature called the
|
||||
* concept "Profile". Renamed to [Connection] so that the term "Profile" is
|
||||
* free to mean what Hermes's server config means by it (agent profiles —
|
||||
* name + model + description defined under `agent.profiles` in config.yaml).
|
||||
* A follow-up pass will introduce the new `Profile` concept on top.
|
||||
* free to mean upstream Hermes profiles: separate host-side Hermes homes
|
||||
* under `~/.hermes/profiles/<name>/`, each with its own config, SOUL, memory,
|
||||
* sessions, skills, cron, and provider state.
|
||||
*/
|
||||
@Serializable
|
||||
data class Connection(
|
||||
@@ -41,6 +53,24 @@ data class Connection(
|
||||
val apiServerUrl: String,
|
||||
val relayUrl: String,
|
||||
val tokenStoreKey: String,
|
||||
/**
|
||||
* Hermes dashboard/admin URL. Dashboard management features use this
|
||||
* separately from the relay pairing channel; a blank/null value means
|
||||
* "derive from [apiServerUrl] using the conventional same-host :9119".
|
||||
*/
|
||||
val dashboardUrl: String? = null,
|
||||
val dashboardAuthRequired: Boolean? = null,
|
||||
val dashboardAuthProviders: List<String> = emptyList(),
|
||||
val dashboardLastStatus: DashboardConnectionStatus? = null,
|
||||
/**
|
||||
* Candidate host routes for this saved Hermes server. Standard setup
|
||||
* stores at least one candidate here so API, dashboard, voice, and Relay
|
||||
* helpers can follow LAN/Tailscale/public handoff before Relay pairing.
|
||||
* Older installs and legacy serialized records default to an empty list.
|
||||
*/
|
||||
val routeCandidates: List<EndpointCandidate> = emptyList(),
|
||||
/** Optional user preference such as "lan" or "tailscale"; null means Auto. */
|
||||
val preferredRouteRole: String? = null,
|
||||
/** Epoch milliseconds. Pass `System.currentTimeMillis()`; do not pass seconds. */
|
||||
val pairedAt: Long? = null,
|
||||
val lastActiveSessionId: String? = null,
|
||||
@@ -48,6 +78,12 @@ data class Connection(
|
||||
/** Epoch milliseconds. The auth.ok `expires_at` field is seconds — multiply by 1000 at the call site. */
|
||||
val expiresAt: Long? = null,
|
||||
) {
|
||||
val resolvedDashboardUrl: String
|
||||
get() = dashboardUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultDashboardUrl(apiServerUrl).orEmpty()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The pre-multi-connection EncryptedSharedPreferences filename. Matches
|
||||
@@ -57,6 +93,8 @@ data class Connection(
|
||||
*/
|
||||
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
|
||||
|
||||
const val DEFAULT_DASHBOARD_PORT: Int = 9119
|
||||
|
||||
/**
|
||||
* Derive a stable per-connection EncryptedSharedPreferences filename
|
||||
* from a connection UUID. Trimmed to the first 8 characters of the
|
||||
@@ -80,5 +118,215 @@ data class Connection(
|
||||
apiServerUrl
|
||||
}
|
||||
}
|
||||
|
||||
fun deriveDefaultDashboardUrl(
|
||||
apiServerUrl: String,
|
||||
dashboardPort: Int = DEFAULT_DASHBOARD_PORT,
|
||||
): String? {
|
||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val scheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "http"
|
||||
"https" -> "https"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$scheme://$hostPart:$dashboardPort"
|
||||
}
|
||||
|
||||
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
|
||||
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
|
||||
if (trimmed.isEmpty()) return true
|
||||
val derived = deriveDefaultDashboardUrl(apiServerUrl) ?: return false
|
||||
return trimmed.equals(derived, ignoreCase = true)
|
||||
}
|
||||
|
||||
fun deriveDefaultRelayUrl(
|
||||
apiServerUrl: String,
|
||||
relayPort: Int = 8767,
|
||||
): String? {
|
||||
val trimmed = apiServerUrl.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
|
||||
val scheme = when (uri.scheme?.lowercase()) {
|
||||
"http" -> "ws"
|
||||
"https" -> "wss"
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
|
||||
"[$host]"
|
||||
} else {
|
||||
host
|
||||
}
|
||||
return "$scheme://$hostPart:$relayPort"
|
||||
}
|
||||
|
||||
fun buildRouteCandidates(
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
extraApiUrls: List<Pair<String, String>> = emptyList(),
|
||||
): List<EndpointCandidate> {
|
||||
val routes = buildList {
|
||||
endpointCandidateFromApiUrl(
|
||||
role = inferRouteRole(apiServerUrl),
|
||||
priority = 0,
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl.takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultRelayUrl(apiServerUrl).orEmpty(),
|
||||
)?.let(::add)
|
||||
|
||||
extraApiUrls
|
||||
.map { it.first.trim() to it.second.trim() }
|
||||
.filter { (_, url) -> url.isNotBlank() }
|
||||
.forEachIndexed { index, (role, url) ->
|
||||
endpointCandidateFromApiUrl(
|
||||
role = role.ifBlank { inferRouteRole(url) },
|
||||
priority = index + 1,
|
||||
apiServerUrl = url,
|
||||
relayUrl = deriveDefaultRelayUrl(url).orEmpty(),
|
||||
)?.let(::add)
|
||||
}
|
||||
}
|
||||
|
||||
return routes
|
||||
.distinctBy {
|
||||
"${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}"
|
||||
}
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlay a freshly-rebuilt candidate list onto an existing stored
|
||||
* one, preserving the stored extras (priority > 0) that the rebuild
|
||||
* doesn't already cover. URL edits rebuild only the route(s) the
|
||||
* user actually touched — without this merge, saving an API or
|
||||
* Relay URL collapsed the stored list to a single candidate,
|
||||
* silently dropping the setup wizard's Tailscale route (or a
|
||||
* pairing payload's extra endpoints) and killing LAN/VPN roaming.
|
||||
*
|
||||
* Stored extras are preserved **verbatim** (role, priority, relay
|
||||
* URL) rather than re-derived, so payload-specified relay URLs
|
||||
* survive. Host:port collisions defer to the rebuilt entry.
|
||||
*/
|
||||
fun mergeRouteCandidates(
|
||||
rebuilt: List<EndpointCandidate>,
|
||||
existing: List<EndpointCandidate>,
|
||||
): List<EndpointCandidate> {
|
||||
val rebuiltHostPorts = rebuilt
|
||||
.map { "${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.toSet()
|
||||
val preserved = existing
|
||||
.filter { it.priority > 0 }
|
||||
.filterNot { "${it.api.host.lowercase()}:${it.api.port}" in rebuiltHostPorts }
|
||||
return (rebuilt + preserved)
|
||||
.distinctBy { "${it.role.lowercase()}|${it.api.host.lowercase()}:${it.api.port}" }
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize hand-typed API-URL input: trim, strip trailing slashes,
|
||||
* default a missing scheme to `http://`, and default a missing port
|
||||
* to [defaultPort] — most Hermes API servers speak plain HTTP on
|
||||
* 8642, and a bare `192.168.1.10` / Tailscale `100.x.y.z` is by far
|
||||
* the most common thing users type.
|
||||
*
|
||||
* URLs that already carry a scheme are preserved **verbatim**
|
||||
* (including a wrong one like `ws://`, so downstream validators can
|
||||
* complain precisely): an explicit `https://hermes.example.com` may
|
||||
* be a reverse proxy on 443, and force-appending :8642 would break
|
||||
* it. Port-defaulting applies only to scheme-less input, where the
|
||||
* user is visibly relying on our defaults.
|
||||
*/
|
||||
fun normalizeApiUrlInput(raw: String, defaultPort: Int = 8642): String {
|
||||
val trimmed = raw.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return trimmed
|
||||
if (SCHEME_REGEX.containsMatchIn(trimmed)) return trimmed
|
||||
val withScheme = "http://$trimmed"
|
||||
val uri = runCatching { URI(withScheme) }.getOrNull()
|
||||
val canAppendPort = uri != null &&
|
||||
!uri.host.isNullOrBlank() &&
|
||||
uri.port <= 0 &&
|
||||
uri.rawPath.isNullOrEmpty() &&
|
||||
uri.rawQuery == null
|
||||
return if (canAppendPort) "$withScheme:$defaultPort" else withScheme
|
||||
}
|
||||
|
||||
private val SCHEME_REGEX = Regex("^[A-Za-z][A-Za-z0-9+.-]*://")
|
||||
|
||||
fun endpointCandidateFromApiUrl(
|
||||
role: String,
|
||||
priority: Int,
|
||||
apiServerUrl: String,
|
||||
relayUrl: String,
|
||||
): EndpointCandidate? {
|
||||
val uri = runCatching { URI(apiServerUrl.trim().trimEnd('/')) }.getOrNull()
|
||||
?: return null
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
val tls = when (scheme) {
|
||||
"http" -> false
|
||||
"https" -> true
|
||||
else -> return null
|
||||
}
|
||||
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
|
||||
val port = if (uri.port > 0) uri.port else 8642
|
||||
val resolvedRelayUrl = relayUrl.trim().takeIf { it.isNotBlank() }
|
||||
?: deriveDefaultRelayUrl(apiServerUrl)
|
||||
?: return null
|
||||
val transportHint = when {
|
||||
resolvedRelayUrl.startsWith("wss://", ignoreCase = true) -> "wss"
|
||||
resolvedRelayUrl.startsWith("ws://", ignoreCase = true) -> "ws"
|
||||
else -> null
|
||||
}
|
||||
return EndpointCandidate(
|
||||
role = role.ifBlank { inferRouteRole(apiServerUrl) },
|
||||
priority = priority,
|
||||
api = ApiEndpoint(host = host, port = port, tls = tls),
|
||||
dashboard = deriveDefaultDashboardUrl(apiServerUrl)
|
||||
?.let { DashboardEndpoint(url = it) },
|
||||
relay = RelayEndpoint(url = resolvedRelayUrl, transportHint = transportHint),
|
||||
)
|
||||
}
|
||||
|
||||
fun inferRouteRole(apiServerUrl: String): String {
|
||||
val host = runCatching { URI(apiServerUrl.trim().trimEnd('/')).host }
|
||||
.getOrNull()
|
||||
?.lowercase()
|
||||
?: return "custom"
|
||||
return when {
|
||||
host.endsWith(".ts.net") || isTailscaleIpv4(host) -> "tailscale"
|
||||
host == "localhost" ||
|
||||
host == "127.0.0.1" ||
|
||||
host == "::1" ||
|
||||
isPrivateLanIpv4(host) -> "lan"
|
||||
else -> "public"
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTailscaleIpv4(host: String): Boolean {
|
||||
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
|
||||
if (parts.size != 4) return false
|
||||
return parts[0] == 100 && parts[1] in 64..127
|
||||
}
|
||||
|
||||
private fun isPrivateLanIpv4(host: String): Boolean {
|
||||
val parts = host.split('.').mapNotNull { it.toIntOrNull() }
|
||||
if (parts.size != 4) return false
|
||||
return when {
|
||||
parts[0] == 10 -> true
|
||||
parts[0] == 172 && parts[1] in 16..31 -> true
|
||||
parts[0] == 192 && parts[1] == 168 -> true
|
||||
parts[0] == 169 && parts[1] == 254 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,17 @@ class ConnectionStore private constructor(
|
||||
private val _activeConnectionId = MutableStateFlow<String?>(null)
|
||||
val activeConnectionId: StateFlow<String?> = _activeConnectionId.asStateFlow()
|
||||
|
||||
/**
|
||||
* Flips to `true` once the initial DataStore hydrate completes (success OR
|
||||
* failure). Until then [connections] / [activeConnection] hold their empty
|
||||
* seed values, which are indistinguishable from a genuinely empty store.
|
||||
* Consumers that must not mistake "still loading" for "nothing configured"
|
||||
* — e.g. the chat empty-state, which would otherwise flash a "Connect to
|
||||
* Hermes" CTA on every cold start — gate on this instead of on emptiness.
|
||||
*/
|
||||
private val _isHydrated = MutableStateFlow(false)
|
||||
val isHydrated: StateFlow<Boolean> = _isHydrated.asStateFlow()
|
||||
|
||||
/**
|
||||
* Derived: the active connection, or null when the active ID is missing
|
||||
* or points to a deleted connection. Recomputes every time either
|
||||
@@ -144,6 +155,11 @@ class ConnectionStore private constructor(
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Initial hydrate failed: ${e.message}")
|
||||
} finally {
|
||||
// Mark hydration done even on failure — a failed read still
|
||||
// means "we now know the store's state is empty", so the UI
|
||||
// should stop showing the neutral loading gate.
|
||||
_isHydrated.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,7 +174,8 @@ class ConnectionStore private constructor(
|
||||
// callers shouldn't rely on insertion order of a duplicate
|
||||
// add, and the alternative (throwing) makes migration code
|
||||
// more brittle than it needs to be.
|
||||
val next = current.filterNot { it.id == connection.id } + connection
|
||||
val normalized = connection.withDashboardDefaults()
|
||||
val next = current.filterNot { it.id == connection.id } + normalized
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
@@ -177,7 +194,8 @@ class ConnectionStore private constructor(
|
||||
Log.w(TAG, "updateConnection: no connection with id=${connection.id} — ignored")
|
||||
return@edit
|
||||
}
|
||||
val next = current.map { if (it.id == connection.id) connection else it }
|
||||
val normalized = connection.withDashboardDefaults()
|
||||
val next = current.map { if (it.id == connection.id) normalized else it }
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
@@ -212,16 +230,83 @@ class ConnectionStore private constructor(
|
||||
_activeConnectionId.value = null
|
||||
}
|
||||
}
|
||||
removed?.let { connection ->
|
||||
context?.let { ctx ->
|
||||
try {
|
||||
ctx.deleteSharedPreferences(connection.tokenStoreKey)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"deleteSharedPreferences(${connection.tokenStoreKey}) failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
removed?.let { deleteTokenStoresFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory-reset helper: clear the persisted connection list, active
|
||||
* pointer, legacy profile aliases, and every known per-connection auth
|
||||
* store. Unlike removing one connection, this intentionally does not pick
|
||||
* a successor; callers are resetting the app back to "no connection".
|
||||
*/
|
||||
suspend fun clearAllConnections() {
|
||||
writeMutex.withLock {
|
||||
var removed: List<Connection> = emptyList()
|
||||
dataStore.edit { prefs ->
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
prefs.remove(KEY_CONNECTIONS)
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
_connections.value = emptyList()
|
||||
_activeConnectionId.value = null
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun replaceConnections(
|
||||
connections: List<Connection>,
|
||||
activeConnectionId: String? = null,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
var removed: List<Connection> = emptyList()
|
||||
val normalizedConnections = connections.map { it.withDashboardDefaults() }
|
||||
val normalizedActiveId = activeConnectionId
|
||||
?.takeIf { id -> normalizedConnections.any { it.id == id } }
|
||||
?: normalizedConnections.firstOrNull()?.id
|
||||
|
||||
dataStore.edit { prefs ->
|
||||
removed = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
if (normalizedConnections.isEmpty()) {
|
||||
prefs.remove(KEY_CONNECTIONS)
|
||||
} else {
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(normalizedConnections)
|
||||
}
|
||||
if (normalizedActiveId == null) {
|
||||
prefs.remove(KEY_ACTIVE_CONNECTION_ID)
|
||||
} else {
|
||||
prefs[KEY_ACTIVE_CONNECTION_ID] = normalizedActiveId
|
||||
}
|
||||
prefs.remove(KEY_LEGACY_PROFILES)
|
||||
prefs.remove(KEY_LEGACY_ACTIVE_PROFILE_ID)
|
||||
_connections.value = normalizedConnections
|
||||
_activeConnectionId.value = normalizedActiveId
|
||||
}
|
||||
removed.forEach { deleteTokenStoresFor(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteTokenStoresFor(connection: Connection) {
|
||||
context?.let { ctx ->
|
||||
val storeKeys = buildSet {
|
||||
add(connection.tokenStoreKey)
|
||||
if (connection.tokenStoreKey == Connection.LEGACY_TOKEN_STORE_KEY) {
|
||||
// Pre-StrongBox fallback path used this file. If
|
||||
// connection 0 is removed, scrub it alongside the
|
||||
// hardware-backed legacy filename.
|
||||
add("hermes_companion_auth")
|
||||
}
|
||||
}
|
||||
for (storeKey in storeKeys) {
|
||||
try {
|
||||
ctx.deleteSharedPreferences(storeKey)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"deleteSharedPreferences($storeKey) failed: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,6 +368,8 @@ class ConnectionStore private constructor(
|
||||
pairedAt = pairedAtMillis,
|
||||
transportHint = transportHint,
|
||||
expiresAt = expiresAtMillis,
|
||||
dashboardUrl = target.dashboardUrl
|
||||
?: Connection.deriveDefaultDashboardUrl(target.apiServerUrl),
|
||||
)
|
||||
} else {
|
||||
it
|
||||
@@ -316,8 +403,12 @@ class ConnectionStore private constructor(
|
||||
// Already migrated or already has user-created connections.
|
||||
return@edit
|
||||
}
|
||||
val apiUrl = legacyApiServerUrl ?: DEFAULT_API_URL
|
||||
val relayUrl = legacyRelayUrl ?: DEFAULT_RELAY_URL
|
||||
if (legacyApiServerUrl.isNullOrBlank() && legacyRelayUrl.isNullOrBlank()) {
|
||||
Log.i(TAG, "migrateLegacyConnectionIfNeeded: no legacy URLs to seed")
|
||||
return@edit
|
||||
}
|
||||
val apiUrl = legacyApiServerUrl?.takeIf { it.isNotBlank() } ?: DEFAULT_API_URL
|
||||
val relayUrl = legacyRelayUrl?.takeIf { it.isNotBlank() } ?: DEFAULT_RELAY_URL
|
||||
val id = java.util.UUID.randomUUID().toString()
|
||||
val seed = Connection(
|
||||
id = id,
|
||||
@@ -325,6 +416,8 @@ class ConnectionStore private constructor(
|
||||
apiServerUrl = apiUrl,
|
||||
relayUrl = relayUrl,
|
||||
tokenStoreKey = Connection.LEGACY_TOKEN_STORE_KEY,
|
||||
dashboardUrl = Connection.deriveDefaultDashboardUrl(apiUrl),
|
||||
routeCandidates = Connection.buildRouteCandidates(apiUrl, relayUrl),
|
||||
pairedAt = null,
|
||||
lastActiveSessionId = legacyLastSessionId,
|
||||
transportHint = null,
|
||||
@@ -340,6 +433,33 @@ class ConnectionStore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDashboardStatus(
|
||||
connectionId: String,
|
||||
status: DashboardConnectionStatus,
|
||||
) {
|
||||
writeMutex.withLock {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeConnections(prefs[KEY_CONNECTIONS])
|
||||
val target = current.firstOrNull { it.id == connectionId } ?: return@edit
|
||||
val next = current.map {
|
||||
if (it.id == connectionId) {
|
||||
target.copy(
|
||||
dashboardUrl = target.dashboardUrl
|
||||
?: Connection.deriveDefaultDashboardUrl(target.apiServerUrl),
|
||||
dashboardAuthRequired = status.authRequired,
|
||||
dashboardAuthProviders = status.authProviders,
|
||||
dashboardLastStatus = status,
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
prefs[KEY_CONNECTIONS] = encodeConnections(next)
|
||||
_connections.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Encoding helpers ---------------------------------------------------
|
||||
|
||||
private fun encodeConnections(list: List<Connection>): String =
|
||||
@@ -349,12 +469,36 @@ class ConnectionStore private constructor(
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return try {
|
||||
json.decodeFromString(connectionListSerializer, raw)
|
||||
.map { it.withDashboardDefaults() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "decodeConnections failed, returning empty list: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Connection.withDashboardDefaults(): Connection {
|
||||
val derivedDashboardUrl = Connection.deriveDefaultDashboardUrl(apiServerUrl)
|
||||
val normalizedRoutes = routeCandidates.ifEmpty {
|
||||
Connection.buildRouteCandidates(apiServerUrl, relayUrl)
|
||||
}
|
||||
val normalizedPreferredRouteRole = preferredRouteRole?.takeIf { preferred ->
|
||||
normalizedRoutes.any { it.role.equals(preferred, ignoreCase = true) }
|
||||
}
|
||||
return if (
|
||||
(dashboardUrl.isNullOrBlank() && derivedDashboardUrl != null) ||
|
||||
normalizedRoutes != routeCandidates ||
|
||||
normalizedPreferredRouteRole != preferredRouteRole
|
||||
) {
|
||||
copy(
|
||||
dashboardUrl = dashboardUrl?.takeIf { it.isNotBlank() } ?: derivedDashboardUrl,
|
||||
routeCandidates = normalizedRoutes,
|
||||
preferredRouteRole = normalizedPreferredRouteRole,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConnectionStore"
|
||||
|
||||
@@ -370,6 +514,6 @@ class ConnectionStore private constructor(
|
||||
// from migrateLegacyConnectionIfNeeded() resolves to the same endpoints
|
||||
// a fresh install would.
|
||||
private const val DEFAULT_API_URL = "http://localhost:8642"
|
||||
private const val DEFAULT_RELAY_URL = "wss://localhost:8767"
|
||||
private const val DEFAULT_RELAY_URL = "ws://localhost:8767"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import android.util.Log
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.hermesandroid.relay.auth.AuthManager
|
||||
import com.hermesandroid.relay.auth.ConnectionAuthSecrets
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.StoredDashboardCookie
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -20,8 +24,8 @@ import java.io.File
|
||||
/**
|
||||
* Manages app data: backup, restore, and reset.
|
||||
*
|
||||
* Backup format is a JSON file containing settings and connection info.
|
||||
* Tokens are NOT included in backups for security.
|
||||
* Backup format is a JSON file containing full connection metadata and
|
||||
* credentials. Treat exported files as sensitive secrets.
|
||||
*/
|
||||
class DataManager(
|
||||
private val context: Context,
|
||||
@@ -51,8 +55,7 @@ class DataManager(
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup data model -- only non-sensitive settings.
|
||||
* Tokens and device IDs are never included.
|
||||
* Backup data model.
|
||||
*
|
||||
* **Schema history:**
|
||||
* - v1: `serverUrl` only (single endpoint, pre-API-split).
|
||||
@@ -66,22 +69,50 @@ class DataManager(
|
||||
* re-mapped to `connections` (see [importSettings]). v1/v2 imports
|
||||
* get `connections = emptyList()` since the old string list was not
|
||||
* structurally compatible.
|
||||
* - v5 (2026-06-08): full connection backups. Adds active connection id
|
||||
* and `connectionSecrets`, including API keys, relay tokens, device id,
|
||||
* paired metadata, and dashboard cookies.
|
||||
*/
|
||||
@Serializable
|
||||
data class AppBackup(
|
||||
val version: Int = 4,
|
||||
val version: Int = 5,
|
||||
val serverUrl: String? = null, // legacy (v1 compat)
|
||||
val apiServerUrl: String? = null,
|
||||
val relayUrl: String? = null,
|
||||
val theme: String = "auto",
|
||||
val onboardingCompleted: Boolean = false,
|
||||
val connections: List<Connection> = emptyList(),
|
||||
val exportedAt: Long = System.currentTimeMillis()
|
||||
val activeConnectionId: String? = null,
|
||||
val containsSensitiveData: Boolean = true,
|
||||
val connectionSecrets: List<ConnectionSecretBackup> = emptyList(),
|
||||
val exportedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectionSecretBackup(
|
||||
val connectionId: String,
|
||||
val tokenStoreKey: String,
|
||||
val auth: ConnectionAuthSecrets = ConnectionAuthSecrets(),
|
||||
val dashboardCookies: List<DashboardCookieBackup> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DashboardCookieBackup(
|
||||
val name: String,
|
||||
val value: String,
|
||||
val expiresAt: Long,
|
||||
val domain: String,
|
||||
val path: String,
|
||||
val secure: Boolean,
|
||||
val httpOnly: Boolean,
|
||||
val hostOnly: Boolean,
|
||||
val persistent: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Export app settings to a JSON string.
|
||||
* Does NOT include session tokens or device IDs (security).
|
||||
* Includes connection credentials. The export UI must warn the user that
|
||||
* the resulting JSON file is sensitive.
|
||||
*
|
||||
* The `sessionLabels` parameter is a legacy dead parameter — it was
|
||||
* previously sourced from `AuthManager.sessionLabels`, a field removed
|
||||
@@ -100,7 +131,7 @@ class DataManager(
|
||||
apiServerUrl: String? = null,
|
||||
relayUrl: String? = null
|
||||
): String {
|
||||
val connectionsSnapshot = connectionStore?.connections?.value
|
||||
val connectionsSnapshot = connectionStore?.connections?.value.orEmpty()
|
||||
if (connectionStore == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
@@ -108,19 +139,58 @@ class DataManager(
|
||||
"(caller constructed DataManager without the multi-connection ctor arg)",
|
||||
)
|
||||
}
|
||||
val connectionSecrets = connectionsSnapshot.map { connection ->
|
||||
ConnectionSecretBackup(
|
||||
connectionId = connection.id,
|
||||
tokenStoreKey = connection.tokenStoreKey,
|
||||
auth = AuthManager.exportStoredSecrets(context, connection.tokenStoreKey),
|
||||
dashboardCookies = EncryptedDashboardCookieStore(
|
||||
context = context,
|
||||
connectionId = connection.id,
|
||||
tokenStoreKey = connection.tokenStoreKey,
|
||||
).load().map { it.toBackup() },
|
||||
)
|
||||
}
|
||||
val backup = AppBackup(
|
||||
version = 4,
|
||||
version = 5,
|
||||
serverUrl = serverUrl, // legacy compat
|
||||
apiServerUrl = apiServerUrl,
|
||||
relayUrl = relayUrl,
|
||||
theme = theme,
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
connections = connectionsSnapshot ?: emptyList(),
|
||||
exportedAt = System.currentTimeMillis()
|
||||
connections = connectionsSnapshot,
|
||||
activeConnectionId = connectionStore?.activeConnectionId?.value,
|
||||
containsSensitiveData = true,
|
||||
connectionSecrets = connectionSecrets,
|
||||
exportedAt = System.currentTimeMillis(),
|
||||
)
|
||||
return json.encodeToString(backup)
|
||||
}
|
||||
|
||||
suspend fun restoreConnectionBackup(backup: AppBackup) {
|
||||
val store = connectionStore ?: return
|
||||
deleteSensitivePreferenceFiles()
|
||||
store.replaceConnections(
|
||||
connections = backup.connections,
|
||||
activeConnectionId = backup.activeConnectionId,
|
||||
)
|
||||
|
||||
val connectionsById = backup.connections.associateBy { it.id }
|
||||
backup.connectionSecrets.forEach { secret ->
|
||||
val connection = connectionsById[secret.connectionId] ?: return@forEach
|
||||
AuthManager.importStoredSecrets(
|
||||
context = context,
|
||||
tokenStoreKey = connection.tokenStoreKey,
|
||||
secrets = secret.auth,
|
||||
)
|
||||
EncryptedDashboardCookieStore(
|
||||
context = context,
|
||||
connectionId = connection.id,
|
||||
tokenStoreKey = connection.tokenStoreKey,
|
||||
).save(secret.dashboardCookies.map { it.toStoredCookie() })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import settings from a JSON string.
|
||||
* Returns the parsed backup, or null if invalid.
|
||||
@@ -221,6 +291,11 @@ class DataManager(
|
||||
// Preserve onboarding state before clearing
|
||||
val onboarding = isOnboardingCompleted()
|
||||
|
||||
// Multi-connection reset: clear the hot ConnectionStore state and
|
||||
// delete every per-connection token store before the global
|
||||
// DataStore is wiped.
|
||||
connectionStore?.clearAllConnections()
|
||||
|
||||
// Clear all DataStore preferences
|
||||
context.relayDataStore.edit { it.clear() }
|
||||
|
||||
@@ -231,15 +306,7 @@ class DataManager(
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the EncryptedSharedPreferences file for auth tokens
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefsDir = File(context.filesDir.parent, "shared_prefs")
|
||||
val authFile = File(prefsDir, "$AUTH_PREFS_NAME.xml")
|
||||
if (authFile.exists()) {
|
||||
authFile.delete()
|
||||
Log.d(TAG, "Deleted auth preferences file")
|
||||
}
|
||||
}
|
||||
deleteSensitivePreferenceFiles()
|
||||
|
||||
// Clear cache directory
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -254,6 +321,61 @@ class DataManager(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteSensitivePreferenceFiles() {
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefsDir = File(context.filesDir.parent, "shared_prefs")
|
||||
val stores = buildSet {
|
||||
add(AUTH_PREFS_NAME)
|
||||
add(Connection.LEGACY_TOKEN_STORE_KEY)
|
||||
prefsDir.listFiles()?.forEach { file ->
|
||||
if (file.extension == "xml") {
|
||||
val name = file.nameWithoutExtension
|
||||
if (
|
||||
name.startsWith("hermes_auth_") ||
|
||||
name.startsWith("hermes_dashboard_")
|
||||
) {
|
||||
add(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stores.forEach { storeName ->
|
||||
try {
|
||||
context.deleteSharedPreferences(storeName)
|
||||
Log.d(TAG, "Deleted auth preferences file: $storeName")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "deleteSharedPreferences($storeName) failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun StoredDashboardCookie.toBackup(): DashboardCookieBackup =
|
||||
DashboardCookieBackup(
|
||||
name = name,
|
||||
value = value,
|
||||
expiresAt = expiresAt,
|
||||
domain = domain,
|
||||
path = path,
|
||||
secure = secure,
|
||||
httpOnly = httpOnly,
|
||||
hostOnly = hostOnly,
|
||||
persistent = persistent,
|
||||
)
|
||||
|
||||
private fun DashboardCookieBackup.toStoredCookie(): StoredDashboardCookie =
|
||||
StoredDashboardCookie(
|
||||
name = name,
|
||||
value = value,
|
||||
expiresAt = expiresAt,
|
||||
domain = domain,
|
||||
path = path,
|
||||
secure = secure,
|
||||
httpOnly = httpOnly,
|
||||
hostOnly = hostOnly,
|
||||
persistent = persistent,
|
||||
)
|
||||
|
||||
/**
|
||||
* Reset only the onboarding completion flag.
|
||||
* Next app launch will show onboarding again.
|
||||
|
||||
@@ -40,6 +40,10 @@ data class EndpointCandidate(
|
||||
val priority: Int = 0,
|
||||
val api: ApiEndpoint,
|
||||
val relay: RelayEndpoint,
|
||||
val dashboard: DashboardEndpoint? = null,
|
||||
val proxy: ProxyEndpoint? = null,
|
||||
val security: String? = null,
|
||||
val recommended: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -61,6 +65,17 @@ data class ApiEndpoint(
|
||||
get() = "${if (tls) "https" else "http"}://$host:$port"
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard/admin surface for an [EndpointCandidate]. This is optional so
|
||||
* older v3 payloads that only carried API + Relay endpoints keep
|
||||
* deserializing; when absent, Android derives the conventional same-host
|
||||
* `:9119` dashboard URL from [ApiEndpoint].
|
||||
*/
|
||||
@Serializable
|
||||
data class DashboardEndpoint(
|
||||
val url: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* The relay-server half of an [EndpointCandidate] — the WSS URL the phone
|
||||
* opens for the bridge + terminal channels.
|
||||
@@ -78,6 +93,22 @@ data class RelayEndpoint(
|
||||
val transportHint: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Optional plugin-owned secure proxy route. Unlike [api], [dashboard], and
|
||||
* [relay], this is one app-facing base that can cover all Hermes-Relay
|
||||
* supported traffic after pairing. It is deliberately optional so plugin
|
||||
* proxy support can be advertised by newer payloads without changing the
|
||||
* standard upstream connection model.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProxyEndpoint(
|
||||
val url: String,
|
||||
@SerialName("transport_hint")
|
||||
val transportHint: String? = null,
|
||||
@SerialName("pin_sha256")
|
||||
val pinSha256: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns true when [EndpointCandidate.role] is one of the built-in, styled
|
||||
* roles: `lan`, `tailscale`, or `public`. Case-insensitive match — but the
|
||||
@@ -89,7 +120,7 @@ data class RelayEndpoint(
|
||||
*/
|
||||
fun EndpointCandidate.isKnownRole(): Boolean {
|
||||
return when (role.lowercase()) {
|
||||
"lan", "tailscale", "public" -> true
|
||||
"lan", "tailscale", "public", "plugin_proxy", "plugin-proxy", "https" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -106,7 +137,15 @@ fun EndpointCandidate.displayLabel(): String {
|
||||
return when (role.lowercase()) {
|
||||
"lan" -> "LAN"
|
||||
"tailscale" -> "Tailscale"
|
||||
"public" -> "Public"
|
||||
"public" -> if (api.tls) "HTTPS" else "Public"
|
||||
"https" -> "HTTPS"
|
||||
"plugin_proxy", "plugin-proxy" -> "Plugin proxy"
|
||||
else -> "Custom VPN ($role)"
|
||||
}
|
||||
}
|
||||
|
||||
fun EndpointCandidate.hasSecureProxy(): Boolean =
|
||||
proxy?.url?.startsWith("https://", ignoreCase = true) == true ||
|
||||
proxy?.url?.startsWith("wss://", ignoreCase = true) == true ||
|
||||
role.equals("plugin_proxy", ignoreCase = true) ||
|
||||
role.equals("plugin-proxy", ignoreCase = true)
|
||||
|
||||
@@ -75,21 +75,18 @@ object FeatureFlags {
|
||||
/**
|
||||
* Compile-time gating based on the active Gradle product flavor.
|
||||
*
|
||||
* Phase 3 ships Bridge on two tracks with very different AccessibilityService
|
||||
* scope: the `googlePlay` flavor carries a conservative event-type subset and
|
||||
* a "notifications + confirmations" description for Play Store policy review,
|
||||
* and the `sideload` flavor carries the full agent-control surface. The tier
|
||||
* flags below let UI code hide tier 3/4/6 surfaces on the Play build without
|
||||
* a runtime check — Kotlin's `val … get() = current == SIDELOAD` resolves at
|
||||
* each call site, but because `current` is a compile-time string, R8 is able
|
||||
* to fold the check away in release builds.
|
||||
* Phase 3 keeps "Hermes Bridge" as the umbrella, but only the `sideload`
|
||||
* flavor ships AccessibilityService-backed Device Control. The `googlePlay`
|
||||
* flavor is Bridge Core: relay pairing, chat, voice, terminal, notification
|
||||
* companion, media, and session-grant surfaces without screen reading, taps,
|
||||
* typing, screenshots, overlays, or unattended control.
|
||||
*
|
||||
* Tier definitions (see `Phase 3 — Bridge Channel.md` in the vault):
|
||||
* 1. baseline — both tracks (app open, tap, navigate within app)
|
||||
* 2. notifications — both tracks (read notifications, summarize, reply)
|
||||
* Device Control tier definitions (see `Phase 3 — Bridge Channel.md`):
|
||||
* 1. baseline — sideload only (app open, tap, navigate within app)
|
||||
* 2. screen context — sideload only (Accessibility tree / screen reads)
|
||||
* 3. voice-first — sideload only (always-on voice capture)
|
||||
* 4. vision-first — sideload only (always-on screen reading)
|
||||
* 5. safety rails — both tracks (confirmation dialogs, action log)
|
||||
* 5. safety rails — sideload only (confirmation dialogs, action log)
|
||||
* 6. ambitious future — sideload only (cross-app macros, scheduling)
|
||||
*/
|
||||
object BuildFlavor {
|
||||
@@ -112,11 +109,11 @@ object BuildFlavor {
|
||||
*/
|
||||
val isSideload: Boolean get() = current == SIDELOAD
|
||||
|
||||
val bridgeTier1: Boolean = true // baseline — both tracks
|
||||
val bridgeTier2: Boolean = true // notifications, calendar — both tracks
|
||||
val bridgeTier1: Boolean get() = current == SIDELOAD // baseline device control
|
||||
val bridgeTier2: Boolean get() = current == SIDELOAD // screen context
|
||||
val bridgeTier3: Boolean get() = current == SIDELOAD // voice-first
|
||||
val bridgeTier4: Boolean get() = current == SIDELOAD // vision-first
|
||||
val bridgeTier5: Boolean = true // safety rails — always on
|
||||
val bridgeTier5: Boolean get() = current == SIDELOAD // safety rails
|
||||
val bridgeTier6: Boolean get() = current == SIDELOAD // future ambitious
|
||||
|
||||
/** Human-readable badge label for the Settings → About version row. */
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
|
||||
/**
|
||||
* Single source of truth for the opt-in "keep the gateway chat connection
|
||||
* alive in the background" preference. Off by default.
|
||||
*
|
||||
* Shared by [com.hermesandroid.relay.viewmodel.ConnectionViewModel] (the
|
||||
* StateFlow + setter that drive the foreground service and the client's
|
||||
* no-background-close flag) and
|
||||
* [com.hermesandroid.relay.network.upstream.GatewayKeepAliveService]'s Stop notification
|
||||
* action, so both read/write the same key.
|
||||
*/
|
||||
val KEY_GATEWAY_KEEP_ALIVE = booleanPreferencesKey("gateway_keep_alive_background")
|
||||
|
||||
/** Persist the keep-alive preference. Used by the FGS Stop action. */
|
||||
suspend fun Context.setGatewayKeepAlive(enabled: Boolean) {
|
||||
relayDataStore.edit { it[KEY_GATEWAY_KEEP_ALIVE] = enabled }
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
|
||||
/**
|
||||
* A rich content card emitted inline in an assistant message via the
|
||||
* `CARD:{json}` line marker. Parsed by
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler] and rendered by
|
||||
* [com.hermesandroid.relay.network.upstream.ChatHandler] and rendered by
|
||||
* [com.hermesandroid.relay.ui.components.HermesCardBubble].
|
||||
*
|
||||
* The marker lives in the text stream alongside `MEDIA:...` for the same
|
||||
@@ -53,8 +53,22 @@ data class HermesCard(
|
||||
* which action (if any) has been dispatched, so the same card reloaded
|
||||
* from session history doesn't re-prompt. Falls back to the card's
|
||||
* position in the message when null.
|
||||
*
|
||||
* For the gateway ask types this is the ask's `request_id` (or
|
||||
* `approval-<sid>-<ts>` for approval, which has no request id) — the
|
||||
* dispatch tracker keys answer-once semantics off it.
|
||||
*/
|
||||
val id: String? = null,
|
||||
/**
|
||||
* Interactive input slot rendered between [fields] and [actions] —
|
||||
* the answer surface for the gateway ask cards (`ask.clarify` choice
|
||||
* chips + free text, `ask.secret` masked field, `ask.sudo`
|
||||
* hold-to-confirm). Null for every plain card. Submissions flow
|
||||
* through the renderer's `onInputSubmit(cardKey, value)` callback and
|
||||
* collapse the card via the same [HermesCardDispatch] list as button
|
||||
* actions.
|
||||
*/
|
||||
val input: HermesCardInput? = null,
|
||||
) {
|
||||
object BuiltInTypes {
|
||||
const val SKILL_RESULT = "skill_result"
|
||||
@@ -62,6 +76,14 @@ data class HermesCard(
|
||||
const val LINK_PREVIEW = "link_preview"
|
||||
const val CALENDAR_EVENT = "calendar_event"
|
||||
const val WEATHER = "weather"
|
||||
|
||||
// Gateway interactive asks (desktop-parity wave). Locally built
|
||||
// from clarify/approval/sudo/secret request events — never parsed
|
||||
// out of the text stream.
|
||||
const val ASK_APPROVAL = "ask.approval"
|
||||
const val ASK_CLARIFY = "ask.clarify"
|
||||
const val ASK_SUDO = "ask.sudo"
|
||||
const val ASK_SECRET = "ask.secret"
|
||||
}
|
||||
|
||||
object Accents {
|
||||
@@ -72,6 +94,67 @@ data class HermesCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive input slot on a [HermesCard]. The flags compose rather than
|
||||
* branch — a sudo ask can be `masked + holdToConfirm` (password field whose
|
||||
* submit is the 650ms press-fill button), while clarify is
|
||||
* `choices + allowFreeText` and secret is `masked` alone.
|
||||
*
|
||||
* Security contract: when [masked] is true the submitted value is a secret.
|
||||
* It must never be echoed into chat content, logged, or synced via
|
||||
* CardDispatchSyncBuilder — record [SECRET_PROVIDED_STAMP] as the dispatch's
|
||||
* actionValue instead of the real value. The renderer masks the collapse
|
||||
* stamp for masked inputs regardless, but the dispatch record itself is
|
||||
* persisted and synced, so the caller must not put the secret there.
|
||||
*/
|
||||
@Serializable
|
||||
data class HermesCardInput(
|
||||
/**
|
||||
* Input kind — one of [Kinds]. Drives which composite the renderer
|
||||
* builds; unknown kinds degrade to a plain free-text field so newer
|
||||
* asks still get an answer surface.
|
||||
*/
|
||||
val kind: String,
|
||||
/** Quick-answer chips (clarify). Empty = no chip row. */
|
||||
val choices: List<String> = emptyList(),
|
||||
/** Render the inline free-text mini field under the chips. */
|
||||
val allowFreeText: Boolean = false,
|
||||
/** Password-style field: masked glyphs + reveal toggle (secret/sudo). */
|
||||
val masked: Boolean = false,
|
||||
/** Submit is a 650ms hold-to-confirm press-fill instead of a tap (sudo). */
|
||||
val holdToConfirm: Boolean = false,
|
||||
/**
|
||||
* Wall-clock expiry for timed asks (sudo 120s, clarify/secret 300s).
|
||||
* The renderer shows a countdown footer (Amber under 30s) and
|
||||
* self-collapses to "Expired — not granted" past it. Null = no timeout
|
||||
* (approval is session-scoped).
|
||||
*/
|
||||
val expiresAtMillis: Long? = null,
|
||||
) {
|
||||
object Kinds {
|
||||
const val CHOICE = "choice"
|
||||
const val TEXT = "text"
|
||||
const val SECRET = "secret"
|
||||
const val CONFIRM = "confirm"
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Sentinel recorded as [HermesCardDispatch.actionValue] when a
|
||||
* [masked] input is submitted. The real secret value goes only to
|
||||
* the ask-respond RPC — never into the dispatch record, chat
|
||||
* content, or session sync.
|
||||
*/
|
||||
const val SECRET_PROVIDED_STAMP = "secret-provided"
|
||||
|
||||
/**
|
||||
* Value submitted by a bare hold-to-confirm (no text field) — the
|
||||
* sudo/approval "yes" that carries no payload of its own.
|
||||
*/
|
||||
const val CONFIRM_VALUE = "confirm"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A label/value row inside a card. [value] is rendered as markdown so the
|
||||
* agent can embed emphasis, inline code, or links.
|
||||
@@ -117,6 +200,16 @@ data class HermesCardAction(
|
||||
const val SEND_TEXT = "send_text"
|
||||
const val SLASH_COMMAND = "slash_command"
|
||||
const val OPEN_URL = "open_url"
|
||||
|
||||
/**
|
||||
* Ask-card answer: dispatch [value] straight to the gateway
|
||||
* ask-respond RPC (clarify/sudo/secret/approval.respond), never
|
||||
* as chat text. Dispatches in this mode are EXCLUDED from
|
||||
* [com.hermesandroid.relay.viewmodel.CardDispatchSyncBuilder] —
|
||||
* the server already absorbed the answer through the blocking
|
||||
* ask, and for secrets the value must not enter session memory.
|
||||
*/
|
||||
const val SUBMIT_ASK = "submit_ask"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +226,7 @@ data class HermesCardAction(
|
||||
* (with structured `tool_calls`) + `tool` message pairs under a synthetic
|
||||
* `hermes_card_action` tool name, splicing them into the session history
|
||||
* the LLM sees. After the API client takes ownership of the request,
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler.markCardDispatchesSynced]
|
||||
* [com.hermesandroid.relay.network.upstream.ChatHandler.markCardDispatchesSynced]
|
||||
* flips [syncedToServer] so subsequent turns don't re-send the same
|
||||
* trace.
|
||||
*/
|
||||
@@ -145,7 +238,7 @@ data class HermesCardDispatch(
|
||||
/**
|
||||
* Idempotency guard for the server-side session sync path.
|
||||
* Flipped to true by
|
||||
* [com.hermesandroid.relay.network.handlers.ChatHandler.markCardDispatchesSynced]
|
||||
* [com.hermesandroid.relay.network.upstream.ChatHandler.markCardDispatchesSynced]
|
||||
* once the API client has accepted the request that carried this
|
||||
* dispatch's synthetic message pair. Once true, the dispatch is
|
||||
* excluded from future
|
||||
|
||||
@@ -4,9 +4,26 @@ import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* How aggressively inbound media is blurred behind a "tap to reveal" gate.
|
||||
*
|
||||
* - [OFF] never blur — show everything immediately.
|
||||
* - [FLAGGED] blur only media the agent flagged sensitive (the model-emitted
|
||||
* `X-Media-Sensitive` bit; see
|
||||
* `docs/plans/2026-06-18-attachment-experience.md` §C). This is
|
||||
* the product default: zero blur when nothing is flagged.
|
||||
* - [ALL_IMAGES] blur every inbound image regardless of source. Works on the
|
||||
* pure standard path with no server support at all.
|
||||
*
|
||||
* Persisted by [Enum.name] so adding cases later is forward-safe; an unknown
|
||||
* stored value decodes back to the default rather than throwing.
|
||||
*/
|
||||
enum class BlurMode { OFF, FLAGGED, ALL_IMAGES }
|
||||
|
||||
/**
|
||||
* User-tunable limits for inbound media attachments fetched from the relay.
|
||||
*
|
||||
@@ -20,12 +37,17 @@ import kotlinx.coroutines.flow.map
|
||||
* - [autoFetchOnCellular] master switch: when false, the cellular-network
|
||||
* case always inserts a manual-download placeholder.
|
||||
* - [cachedMediaCapMb] LRU cap on the `hermes-media/` cache directory.
|
||||
* - [blurSensitive] whether (and which) inbound images render behind a
|
||||
* tap-to-reveal blur — see [BlurMode]. Unlike the four knobs above this one
|
||||
* also applies on the standard (no-Relay) path, since [BlurMode.ALL_IMAGES]
|
||||
* needs no server cooperation.
|
||||
*/
|
||||
data class MediaSettings(
|
||||
val maxInboundSizeMb: Int = 25,
|
||||
val autoFetchThresholdMb: Int = 2,
|
||||
val autoFetchOnCellular: Boolean = false,
|
||||
val cachedMediaCapMb: Int = 200
|
||||
val cachedMediaCapMb: Int = 200,
|
||||
val blurSensitive: BlurMode = BlurMode.FLAGGED
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -39,11 +61,18 @@ class MediaSettingsRepository(private val context: Context) {
|
||||
private val KEY_AUTO_FETCH_THRESHOLD_MB = intPreferencesKey("media_auto_fetch_threshold_mb")
|
||||
private val KEY_AUTO_FETCH_ON_CELLULAR = booleanPreferencesKey("media_auto_fetch_on_cellular")
|
||||
private val KEY_CACHED_MEDIA_CAP_MB = intPreferencesKey("media_cached_cap_mb")
|
||||
private val KEY_BLUR_SENSITIVE = stringPreferencesKey("media_blur_sensitive")
|
||||
|
||||
const val DEFAULT_MAX_INBOUND_MB = 25
|
||||
const val DEFAULT_AUTO_FETCH_THRESHOLD_MB = 2
|
||||
const val DEFAULT_AUTO_FETCH_ON_CELLULAR = false
|
||||
const val DEFAULT_CACHED_MEDIA_CAP_MB = 200
|
||||
val DEFAULT_BLUR_SENSITIVE = BlurMode.FLAGGED
|
||||
|
||||
/** Decode a persisted [BlurMode] name, falling back to the default. */
|
||||
private fun parseBlurMode(raw: String?): BlurMode =
|
||||
raw?.let { name -> BlurMode.entries.firstOrNull { it.name == name } }
|
||||
?: DEFAULT_BLUR_SENSITIVE
|
||||
}
|
||||
|
||||
val settings: Flow<MediaSettings> = context.relayDataStore.data.map { prefs ->
|
||||
@@ -51,10 +80,21 @@ class MediaSettingsRepository(private val context: Context) {
|
||||
maxInboundSizeMb = prefs[KEY_MAX_INBOUND_MB] ?: DEFAULT_MAX_INBOUND_MB,
|
||||
autoFetchThresholdMb = prefs[KEY_AUTO_FETCH_THRESHOLD_MB] ?: DEFAULT_AUTO_FETCH_THRESHOLD_MB,
|
||||
autoFetchOnCellular = prefs[KEY_AUTO_FETCH_ON_CELLULAR] ?: DEFAULT_AUTO_FETCH_ON_CELLULAR,
|
||||
cachedMediaCapMb = prefs[KEY_CACHED_MEDIA_CAP_MB] ?: DEFAULT_CACHED_MEDIA_CAP_MB
|
||||
cachedMediaCapMb = prefs[KEY_CACHED_MEDIA_CAP_MB] ?: DEFAULT_CACHED_MEDIA_CAP_MB,
|
||||
blurSensitive = parseBlurMode(prefs[KEY_BLUR_SENSITIVE])
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Just the blur knob — a standalone flow so per-bubble UI can observe it
|
||||
* without collecting (and recomposing on) the whole [MediaSettings].
|
||||
* Built here (outside composition) on purpose so callers can
|
||||
* `collectAsState()` it without tripping `FlowOperatorInvokedInComposition`.
|
||||
*/
|
||||
val blurMode: Flow<BlurMode> = context.relayDataStore.data.map { prefs ->
|
||||
parseBlurMode(prefs[KEY_BLUR_SENSITIVE])
|
||||
}
|
||||
|
||||
suspend fun setMaxInboundSize(mb: Int) {
|
||||
context.relayDataStore.edit { it[KEY_MAX_INBOUND_MB] = mb.coerceAtLeast(1) }
|
||||
}
|
||||
@@ -70,4 +110,8 @@ class MediaSettingsRepository(private val context: Context) {
|
||||
suspend fun setCachedMediaCap(mb: Int) {
|
||||
context.relayDataStore.edit { it[KEY_CACHED_MEDIA_CAP_MB] = mb.coerceAtLeast(10) }
|
||||
}
|
||||
|
||||
suspend fun setBlurSensitive(mode: BlurMode) {
|
||||
context.relayDataStore.edit { it[KEY_BLUR_SENSITIVE] = mode.name }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,19 @@ import kotlinx.serialization.Serializable
|
||||
* upstream layout (one directory per profile under `~/.hermes/profiles/`)
|
||||
* and added [systemMessage], sourced from each profile's `SOUL.md`.
|
||||
*
|
||||
* A Profile is a NAMED AGENT CONFIG within a Connection. Switching profile
|
||||
* changes:
|
||||
* - which model the phone asks the server to use on the next chat
|
||||
* request (via [model]);
|
||||
* - which system message the phone sends for that request (via
|
||||
* [systemMessage], when non-blank).
|
||||
* A Profile is an upstream Hermes profile context within a Connection.
|
||||
* Upstream stores named profiles as separate Hermes homes under
|
||||
* `~/.hermes/profiles/<name>/`. Switching profile changes the active agent
|
||||
* identity for the Android chat surface:
|
||||
* - which profile API server the phone routes chat/session calls to when
|
||||
* the relay advertises [apiServerUrl];
|
||||
* - which profile name the phone sends to the server for new sessions and
|
||||
* chat turns when isolated routing is not available;
|
||||
* - which model and system message the phone can send as compatibility
|
||||
* fallback (via [model] and [systemMessage]);
|
||||
* - which profile-scoped session id Android resumes for local chat context.
|
||||
*
|
||||
* It does not change the server, sessions, or memory.
|
||||
* It does not mutate the server's configured default profile.
|
||||
*
|
||||
* Wire shape uses snake_case (`system_message`), this class uses camelCase
|
||||
* (`systemMessage`) — translated via [SerialName].
|
||||
@@ -39,6 +44,12 @@ import kotlinx.serialization.Serializable
|
||||
* All three default to safe zero-values and are optional on the wire, so
|
||||
* older relays without the fields deserialize cleanly as
|
||||
* `gatewayRunning = false, hasSoul = false, skillCount = 0`.
|
||||
*
|
||||
* **Hermes profile API metadata.** A relay can advertise an isolated
|
||||
* profile API server without exposing its secret. When [apiServerUrl] is
|
||||
* present, Android routes chat/session traffic to that URL and reuses the
|
||||
* active connection's stored API key. Operators that use distinct API keys
|
||||
* per profile should pair those profile API servers as separate connections.
|
||||
*/
|
||||
@Serializable
|
||||
data class Profile(
|
||||
@@ -53,4 +64,17 @@ data class Profile(
|
||||
val hasSoul: Boolean = false,
|
||||
@SerialName("skill_count")
|
||||
val skillCount: Int = 0,
|
||||
)
|
||||
@SerialName("api_server_enabled")
|
||||
val apiServerEnabled: Boolean = false,
|
||||
@SerialName("api_server_url")
|
||||
val apiServerUrl: String? = null,
|
||||
@SerialName("api_server_host")
|
||||
val apiServerHost: String? = null,
|
||||
@SerialName("api_server_port")
|
||||
val apiServerPort: Int? = null,
|
||||
@SerialName("api_server_key_present")
|
||||
val apiServerKeyPresent: Boolean = false,
|
||||
) {
|
||||
val hasIsolatedApi: Boolean
|
||||
get() = !apiServerUrl.isNullOrBlank()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Local-only display aliases for agent profiles.
|
||||
*
|
||||
* These names are phone UI labels. They are never sent to Hermes and are keyed
|
||||
* by connection + profile context so the server-default agent can be called
|
||||
* something different on each configured Hermes host.
|
||||
*/
|
||||
class ProfileDisplayAliasStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.profileDisplayAliasesDataStore)
|
||||
|
||||
companion object {
|
||||
private const val PREFIX = "profile_alias__"
|
||||
|
||||
private fun keyName(connectionId: String, profileName: String?): String =
|
||||
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}"
|
||||
|
||||
private fun keyFor(connectionId: String, profileName: String?) =
|
||||
stringPreferencesKey(keyName(connectionId, profileName))
|
||||
|
||||
private fun connectionPrefix(connectionId: String): String =
|
||||
"$PREFIX${connectionId}__"
|
||||
}
|
||||
|
||||
suspend fun setAlias(connectionId: String, profileName: String?, alias: String?) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = keyFor(connectionId, profileName)
|
||||
if (alias.isNullOrBlank()) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
prefs[key] = alias
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun aliasFlow(connectionId: String, profileName: String?): Flow<String?> {
|
||||
val key = keyFor(connectionId, profileName)
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
suspend fun clearConnection(connectionId: String) {
|
||||
val prefix = connectionPrefix(connectionId)
|
||||
dataStore.edit { prefs ->
|
||||
prefs.asMap().keys
|
||||
.filter { it.name.startsWith(prefix) }
|
||||
.forEach { prefs.remove(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs -> prefs.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.profileDisplayAliasesDataStore: DataStore<Preferences>
|
||||
by preferencesDataStore(name = "profile_display_aliases")
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Local-only per-profile agent icons — the visual twin of [ProfileDisplayAliasStore].
|
||||
*
|
||||
* Stores a **file path** to an image that was copied into app storage (not a SAF
|
||||
* content URI, so it survives without a persistable-permission grant). Like the
|
||||
* name alias, these are phone-UI labels only: never sent to Hermes, and keyed by
|
||||
* connection + profile context so the same server-default agent can wear a
|
||||
* different face on each configured host.
|
||||
*/
|
||||
class ProfileIconStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.profileIconsDataStore)
|
||||
|
||||
companion object {
|
||||
private const val PREFIX = "profile_icon__"
|
||||
|
||||
private fun keyName(connectionId: String, profileName: String?): String =
|
||||
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}"
|
||||
|
||||
private fun keyFor(connectionId: String, profileName: String?) =
|
||||
stringPreferencesKey(keyName(connectionId, profileName))
|
||||
|
||||
private fun connectionPrefix(connectionId: String): String =
|
||||
"$PREFIX${connectionId}__"
|
||||
}
|
||||
|
||||
suspend fun setIcon(connectionId: String, profileName: String?, path: String?) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = keyFor(connectionId, profileName)
|
||||
if (path.isNullOrBlank()) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
prefs[key] = path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun iconFlow(connectionId: String, profileName: String?): Flow<String?> {
|
||||
val key = keyFor(connectionId, profileName)
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
suspend fun clearConnection(connectionId: String) {
|
||||
val prefix = connectionPrefix(connectionId)
|
||||
dataStore.edit { prefs ->
|
||||
prefs.asMap().keys
|
||||
.filter { it.name.startsWith(prefix) }
|
||||
.forEach { prefs.remove(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs -> prefs.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.profileIconsDataStore: DataStore<Preferences>
|
||||
by preferencesDataStore(name = "profile_icons")
|
||||
@@ -87,6 +87,12 @@ class ProfileSelectionStore(
|
||||
prefs.remove(keyFor(connectionId))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||