feat: add phase 1 runnable skeleton
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"extends":"../../tsconfig.base.json","compilerOptions":{"outDir":"dist","rootDir":"src","noEmit":false},"references":[{"path":"../../packages/shared"}],"include":["src"]}
|
||||
@@ -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>
|
||||
@@ -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"}}
|
||||
@@ -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":[]}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]}
|
||||
@@ -0,0 +1,9 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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"}}
|
||||
@@ -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":{}}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './api.js';
|
||||
export * from './events.js';
|
||||
export * from './tasks.js';
|
||||
@@ -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>;
|
||||
@@ -0,0 +1 @@
|
||||
{"extends":"../../tsconfig.base.json","compilerOptions":{"declaration":true,"declarationMap":true,"outDir":"dist","rootDir":"src","composite":true},"include":["src"]}
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- apps/*
|
||||
- packages/*
|
||||
@@ -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"]}}}
|
||||
Reference in New Issue
Block a user