feat: add phase 1 runnable skeleton

This commit is contained in:
Hermes Agent
2026-07-09 04:19:23 +00:00
parent deec0c7fa3
commit bd0cf34aa3
35 changed files with 675 additions and 5 deletions
+2 -3
View File
@@ -7,7 +7,7 @@ hermes-mobile/
LICENSE
.gitignore
.editorconfig
package.json # pnpm workspace root, later
package.json # pnpm workspace root
pnpm-workspace.yaml
tsconfig.base.json
install.sh # one-liner installer entrypoint, later
@@ -31,8 +31,7 @@ hermes-mobile/
src/
app/
App.tsx
router.tsx
providers.tsx
navigation.ts
screens/
AskScreen/
ActivityScreen/
+1 -1
View File
@@ -8,7 +8,7 @@ Repo: https://git.molberg.cloud/bonzi/hermes-mobile
## Status
Planning scaffold only. No production code yet.
Phase 1 skeleton is underway. The repo now contains a pnpm monorepo with a shared TypeScript package, a Fastify companion server exposing `/api/health`, and a Vite React mobile shell with bottom navigation. Hermes prompting, pairing/auth, persistence, realtime streams, Capacitor Android, and installer productionization are still upcoming roadmap items.
## Product Goals
+1
View File
@@ -0,0 +1 @@
{"name":"@hermes-mobile/companion","version":"0.1.0","private":true,"type":"module","main":"dist/index.js","scripts":{"dev":"tsx watch src/index.ts","build":"tsc -p tsconfig.json","start":"node dist/index.js","typecheck":"tsc -p tsconfig.json --noEmit","lint":"eslint src"},"dependencies":{"@fastify/cors":"^10.0.1","@hermes-mobile/shared":"workspace:*","fastify":"^5.2.1"},"devDependencies":{"tsx":"^4.19.2"}}
+20
View File
@@ -0,0 +1,20 @@
import cors from '@fastify/cors';
import Fastify from 'fastify';
import { registerHealthRoutes } from './routes/health.js';
export async function buildApp() {
const app = Fastify({ logger: true });
await app.register(cors, {
origin: true,
});
app.get('/', async () => ({
service: 'hermes-mobile-companion',
health: '/api/health',
}));
await registerHealthRoutes(app);
return app;
}
+13
View File
@@ -0,0 +1,13 @@
import { buildApp } from './app.js';
const port = Number.parseInt(process.env.PORT ?? '8787', 10);
const host = process.env.HOST ?? '0.0.0.0';
const app = await buildApp();
try {
await app.listen({ port, host });
} catch (error) {
app.log.error(error);
process.exit(1);
}
+21
View File
@@ -0,0 +1,21 @@
import type { FastifyInstance } from 'fastify';
import { healthResponseSchema } from '@hermes-mobile/shared';
import { getHermesHealth } from '../system/hermesHealth.js';
export async function registerHealthRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/health', async () => {
const response = {
ok: true,
service: 'hermes-mobile-companion' as const,
version: process.env.npm_package_version ?? '0.1.0',
mode: process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test' ? process.env.NODE_ENV : 'development',
uptimeSeconds: process.uptime(),
timestamp: new Date().toISOString(),
checks: {
hermes: await getHermesHealth(),
},
};
return healthResponseSchema.parse(response);
});
}
+26
View File
@@ -0,0 +1,26 @@
import { access } from 'node:fs/promises';
import { delimiter } from 'node:path';
import { env } from 'node:process';
import type { HermesHealth } from '@hermes-mobile/shared';
async function isExecutable(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
export async function getHermesHealth(): Promise<HermesHealth> {
const pathEntries = env.PATH?.split(delimiter).filter(Boolean) ?? [];
for (const pathEntry of pathEntries) {
const cliPath = `${pathEntry}/hermes`;
if (await isExecutable(cliPath)) {
return { cliAvailable: true, cliPath };
}
}
return { cliAvailable: false };
}
+1
View File
@@ -0,0 +1 @@
{"extends":"../../tsconfig.base.json","compilerOptions":{"outDir":"dist","rootDir":"src","noEmit":false},"references":[{"path":"../../packages/shared"}],"include":["src"]}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#11131f" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Hermes Mobile</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
{"name":"@hermes-mobile/mobile","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"vite --host 0.0.0.0","build":"tsc -p tsconfig.json && vite build","preview":"vite preview --host 0.0.0.0","typecheck":"tsc -p tsconfig.json --noEmit","lint":"eslint src"},"dependencies":{"@hermes-mobile/shared":"workspace:*","lucide-react":"^0.468.0","react":"^19.0.0","react-dom":"^19.0.0"},"devDependencies":{"@types/react":"^19.0.2","@types/react-dom":"^19.0.2","@vitejs/plugin-react":"^4.3.4","vite":"^6.0.7"}}
+1
View File
@@ -0,0 +1 @@
{"name":"Hermes Mobile","short_name":"Hermes","description":"Mobile control surface for Hermes Agent","start_url":"/","display":"standalone","background_color":"#11131f","theme_color":"#11131f","icons":[]}
+26
View File
@@ -0,0 +1,26 @@
import { type ReactNode, useState } from 'react';
import { AppShell } from '../components/app-shell/AppShell.js';
import { ActivityScreen } from '../screens/ActivityScreen/ActivityScreen.js';
import { AskScreen } from '../screens/AskScreen/AskScreen.js';
import { CronScreen } from '../screens/CronScreen/CronScreen.js';
import { FilesScreen } from '../screens/FilesScreen/FilesScreen.js';
import { SettingsScreen } from '../screens/SettingsScreen/SettingsScreen.js';
import type { ScreenId } from './navigation.js';
const screens: Record<ScreenId, ReactNode> = {
ask: <AskScreen />,
activity: <ActivityScreen />,
cron: <CronScreen />,
files: <FilesScreen />,
settings: <SettingsScreen />,
};
export function App() {
const [activeScreen, setActiveScreen] = useState<ScreenId>('ask');
return (
<AppShell activeScreen={activeScreen} onNavigate={setActiveScreen}>
{screens[activeScreen]}
</AppShell>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { Activity, CalendarClock, Files, MessageCircle, Settings } from 'lucide-react';
export const navItems = [
{ id: 'ask', label: 'Ask', icon: MessageCircle },
{ id: 'activity', label: 'Activity', icon: Activity },
{ id: 'cron', label: 'Cron', icon: CalendarClock },
{ id: 'files', label: 'Files', icon: Files },
{ id: 'settings', label: 'Settings', icon: Settings },
] as const;
export type ScreenId = (typeof navItems)[number]['id'];
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
import type { ScreenId } from '../../app/navigation.js';
import { BottomNav } from '../bottom-nav/BottomNav.js';
type AppShellProps = {
activeScreen: ScreenId;
children: ReactNode;
onNavigate: (screen: ScreenId) => void;
};
export function AppShell({ activeScreen, children, onNavigate }: AppShellProps) {
return (
<div className="app-shell">
<header className="app-header">
<div>
<p className="eyebrow">Hermes Mobile</p>
<h1>Agent command center</h1>
</div>
<div className="status-pill">
<span className="status-pill__dot" />
Companion pending
</div>
</header>
<main className="app-main">{children}</main>
<BottomNav activeScreen={activeScreen} onNavigate={onNavigate} />
</div>
);
}
@@ -0,0 +1,31 @@
import type { ScreenId } from '../../app/navigation.js';
import { navItems } from '../../app/navigation.js';
type BottomNavProps = {
activeScreen: ScreenId;
onNavigate: (screen: ScreenId) => void;
};
export function BottomNav({ activeScreen, onNavigate }: BottomNavProps) {
return (
<nav className="bottom-nav" aria-label="Primary navigation">
{navItems.map((item) => {
const Icon = item.icon;
const active = item.id === activeScreen;
return (
<button
aria-current={active ? 'page' : undefined}
className="bottom-nav__item"
key={item.id}
onClick={() => onNavigate(item.id)}
type="button"
>
<Icon aria-hidden="true" size={20} strokeWidth={2.4} />
<span>{item.label}</span>
</button>
);
})}
</nav>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app/App.js';
import './styles/globals.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
@@ -0,0 +1,21 @@
const events = [
['task.created', 'Task lifecycle events will appear here.'],
['tool_call.started', 'Tool cards will expand with raw output.'],
['assistant.final', 'Final answers stay attached to sessions.'],
] as const;
export function ActivityScreen() {
return (
<section className="screen stack-screen">
<h2>Activity timeline</h2>
<div className="timeline-list">
{events.map(([type, description]) => (
<article className="timeline-card" key={type}>
<span>{type}</span>
<p>{description}</p>
</article>
))}
</div>
</section>
);
}
@@ -0,0 +1,28 @@
import { Mic, Paperclip, SendHorizontal } from 'lucide-react';
export function AskScreen() {
return (
<section className="screen ask-screen">
<div className="hero-card">
<p className="eyebrow">Quick ask</p>
<h2>Send Hermes a task from your phone.</h2>
<p>Text prompting lands first; voice, uploads, streaming activity, and approvals build on this shell.</p>
</div>
<form className="composer" onSubmit={(event) => event.preventDefault()}>
<textarea aria-label="Prompt" placeholder="Ask Hermes to check a service, edit code, or summarize logs…" rows={5} />
<div className="composer__actions">
<button className="icon-button" type="button" aria-label="Attach file">
<Paperclip size={20} />
</button>
<button className="icon-button" type="button" aria-label="Record voice note">
<Mic size={20} />
</button>
<button className="send-button" type="submit">
Send <SendHorizontal size={18} />
</button>
</div>
</form>
</section>
);
}
@@ -0,0 +1,10 @@
export function CronScreen() {
return (
<section className="screen stack-screen">
<h2>Cron</h2>
<article className="empty-card">
<p>Hermes cron jobs will be listed here with run, pause, resume, and output controls.</p>
</article>
</section>
);
}
@@ -0,0 +1,10 @@
export function FilesScreen() {
return (
<section className="screen stack-screen">
<h2>Files</h2>
<article className="empty-card">
<p>Uploads and generated artifacts will live here for reuse, download, and sharing.</p>
</article>
</section>
);
}
@@ -0,0 +1,12 @@
export function SettingsScreen() {
return (
<section className="screen stack-screen">
<h2>Settings</h2>
<article className="settings-card">
<label htmlFor="server-url">Companion server URL</label>
<input id="server-url" placeholder="https://hermes.local:8787" type="url" />
<p>Pairing, health checks, notification setup, and theme controls will expand from here.</p>
</article>
</section>
);
}
+256
View File
@@ -0,0 +1,256 @@
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #11131f;
color: #fff8ec;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at 20% 10%, rgba(255, 184, 77, 0.24), transparent 34rem),
radial-gradient(circle at 80% 0%, rgba(102, 92, 255, 0.24), transparent 28rem),
#11131f;
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
.app-shell {
display: grid;
grid-template-rows: auto 1fr auto;
width: min(100%, 32rem);
min-height: 100vh;
margin: 0 auto;
padding: max(1rem, env(safe-area-inset-top)) 1rem max(0.75rem, env(safe-area-inset-bottom));
}
.app-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0 1rem;
}
.app-header h1,
.screen h2,
.hero-card h2 {
margin: 0;
line-height: 1.05;
}
.app-header h1 {
max-width: 12rem;
font-size: clamp(1.5rem, 7vw, 2.35rem);
}
.eyebrow {
margin: 0 0 0.4rem;
color: #ffca6a;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
padding: 0.45rem 0.65rem;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 248, 236, 0.8);
font-size: 0.72rem;
font-weight: 700;
}
.status-pill__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: #ffca6a;
box-shadow: 0 0 1rem rgba(255, 202, 106, 0.9);
}
.app-main {
overflow: auto;
padding: 0.5rem 0 1rem;
}
.screen {
display: grid;
gap: 1rem;
}
.hero-card,
.composer,
.timeline-card,
.empty-card,
.settings-card {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1.5rem;
background: rgba(21, 24, 39, 0.78);
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.24);
backdrop-filter: blur(18px);
}
.hero-card {
padding: 1.25rem;
}
.hero-card h2 {
font-size: clamp(2rem, 11vw, 3.65rem);
letter-spacing: -0.06em;
}
.hero-card p:last-child,
.timeline-card p,
.empty-card p,
.settings-card p {
margin: 0.8rem 0 0;
color: rgba(255, 248, 236, 0.72);
line-height: 1.5;
}
.composer {
display: grid;
gap: 0.85rem;
padding: 0.75rem;
}
.composer textarea,
.settings-card input {
width: 100%;
border: 0;
border-radius: 1rem;
outline: 0;
background: rgba(255, 255, 255, 0.08);
color: #fff8ec;
}
.composer textarea {
min-height: 9rem;
resize: vertical;
padding: 1rem;
}
.composer textarea::placeholder,
.settings-card input::placeholder {
color: rgba(255, 248, 236, 0.42);
}
.composer__actions {
display: flex;
align-items: center;
gap: 0.65rem;
}
.icon-button,
.send-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 3rem;
border: 0;
border-radius: 999px;
}
.icon-button {
width: 3rem;
background: rgba(255, 255, 255, 0.1);
color: #fff8ec;
}
.send-button {
gap: 0.45rem;
margin-left: auto;
padding: 0 1.05rem;
background: #ffca6a;
color: #17110a;
font-weight: 900;
}
.stack-screen h2 {
font-size: 2rem;
}
.timeline-list {
display: grid;
gap: 0.75rem;
}
.timeline-card,
.empty-card,
.settings-card {
padding: 1rem;
}
.timeline-card span {
color: #89e5ff;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.78rem;
font-weight: 800;
}
.settings-card {
display: grid;
gap: 0.75rem;
}
.settings-card label {
font-weight: 800;
}
.settings-card input {
padding: 0.9rem 1rem;
}
.bottom-nav {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.25rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1.35rem;
padding: 0.35rem;
background: rgba(16, 18, 30, 0.9);
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.35);
backdrop-filter: blur(18px);
}
.bottom-nav__item {
display: grid;
place-items: center;
gap: 0.2rem;
min-height: 3.4rem;
border: 0;
border-radius: 1rem;
background: transparent;
color: rgba(255, 248, 236, 0.58);
font-size: 0.67rem;
font-weight: 800;
}
.bottom-nav__item[aria-current='page'] {
background: rgba(255, 202, 106, 0.16);
color: #ffca6a;
}
+1
View File
@@ -0,0 +1 @@
{"extends":"../../tsconfig.base.json","compilerOptions":{"jsx":"react-jsx","lib":["ES2022","DOM","DOM.Iterable"],"types":["vite/client"],"noEmit":true},"references":[{"path":"../../packages/shared"}],"include":["src","vite.config.ts"]}
+9
View File
@@ -0,0 +1,9 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
});
+3 -1
View File
@@ -2,7 +2,7 @@
## Phase 0 — Planning Scaffold
Status: current.
Status: completed.
Deliverables:
- Repo created
@@ -16,6 +16,8 @@ Deliverables:
Goal: empty but runnable monorepo.
Status: in progress.
Deliverables:
- pnpm workspace
- `apps/mobile` Vite React app with Capacitor Android target and PWA fallback
+31
View File
@@ -0,0 +1,31 @@
import js from '@eslint/js';
import tseslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
export default [
{
ignores: ['**/dist/**', '**/node_modules/**', '**/coverage/**', '.dev/**'],
},
js.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
globals: {
console: 'readonly',
process: 'readonly',
JSX: 'readonly',
},
},
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
...tseslint.configs.recommended.rules,
},
},
];
+1
View File
@@ -0,0 +1 @@
{"name":"hermes-mobile","version":"0.1.0","private":true,"description":"Android-first mobile control surface and companion server for Hermes Agent.","license":"MIT","type":"module","packageManager":"pnpm@9.15.0","scripts":{"dev":"pnpm --parallel --filter @hermes-mobile/shared --filter @hermes-mobile/companion --filter @hermes-mobile/mobile dev","build":"pnpm -r build","typecheck":"pnpm -r typecheck","lint":"pnpm -r lint"},"engines":{"node":">=20.11"},"devDependencies":{"@typescript-eslint/eslint-plugin":"^8.19.1","@typescript-eslint/parser":"^8.19.1","eslint":"^9.17.0","typescript":"^5.7.2"}}
+1
View File
@@ -0,0 +1 @@
{"name":"@hermes-mobile/shared","version":"0.1.0","private":true,"type":"module","main":"dist/index.js","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"}},"scripts":{"build":"tsc -p tsconfig.json","dev":"tsc -p tsconfig.json --watch --preserveWatchOutput","typecheck":"tsc -p tsconfig.json --noEmit","lint":"eslint src"},"dependencies":{"zod":"^3.24.1"},"devDependencies":{}}
+21
View File
@@ -0,0 +1,21 @@
import { z } from 'zod';
export const hermesHealthSchema = z.object({
cliAvailable: z.boolean(),
cliPath: z.string().optional(),
});
export const healthResponseSchema = z.object({
ok: z.boolean(),
service: z.literal('hermes-mobile-companion'),
version: z.string(),
mode: z.enum(['development', 'test', 'production']),
uptimeSeconds: z.number().nonnegative(),
timestamp: z.string().datetime(),
checks: z.object({
hermes: hermesHealthSchema,
}),
});
export type HermesHealth = z.infer<typeof hermesHealthSchema>;
export type HealthResponse = z.infer<typeof healthResponseSchema>;
+42
View File
@@ -0,0 +1,42 @@
import { z } from 'zod';
export const companionEventLevelSchema = z.enum(['debug', 'info', 'warn', 'error']);
export const companionEventTypeSchema = z.enum([
'task.created',
'task.started',
'task.status',
'task.finished',
'task.failed',
'task.cancelled',
'assistant.delta',
'assistant.message',
'assistant.final',
'tool_call.started',
'tool_call.output',
'tool_call.finished',
'tool_call.failed',
'file.uploaded',
'file.generated',
'approval.required',
'approval.resolved',
'cron.job.listed',
'cron.job.updated',
'cron.job.finished',
'notification.sent',
'notification.failed',
]);
export const companionEventSchema = z.object({
id: z.string().min(1),
type: companionEventTypeSchema,
taskId: z.string().min(1).optional(),
sessionId: z.string().min(1).optional(),
ts: z.string().datetime(),
level: companionEventLevelSchema.optional(),
payload: z.unknown(),
});
export type CompanionEventLevel = z.infer<typeof companionEventLevelSchema>;
export type CompanionEventType = z.infer<typeof companionEventTypeSchema>;
export type CompanionEvent = z.infer<typeof companionEventSchema>;
+3
View File
@@ -0,0 +1,3 @@
export * from './api.js';
export * from './events.js';
export * from './tasks.js';
+14
View File
@@ -0,0 +1,14 @@
import { z } from 'zod';
export const taskStatusSchema = z.enum(['queued', 'running', 'completed', 'failed', 'cancelled']);
export const taskSummarySchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
status: taskStatusSchema,
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export type TaskStatus = z.infer<typeof taskStatusSchema>;
export type TaskSummary = z.infer<typeof taskSummarySchema>;
+1
View File
@@ -0,0 +1 @@
{"extends":"../../tsconfig.base.json","compilerOptions":{"declaration":true,"declarationMap":true,"outDir":"dist","rootDir":"src","composite":true},"include":["src"]}
+3
View File
@@ -0,0 +1,3 @@
packages:
- apps/*
- packages/*
+1
View File
@@ -0,0 +1 @@
{"compilerOptions":{"target":"ES2022","useDefineForClassFields":true,"module":"ESNext","moduleResolution":"Bundler","resolveJsonModule":true,"isolatedModules":true,"strict":true,"noUncheckedIndexedAccess":true,"exactOptionalPropertyTypes":true,"skipLibCheck":true,"forceConsistentCasingInFileNames":true,"baseUrl":".","paths":{"@hermes-mobile/shared":["packages/shared/src/index.ts"]}}}