* feat(shared): derive Claude model choices from the CLI catalog The choices are the rows of the claude CLI's own /model picker, in its order and with its display names. The stored value is what claude --model receives: an alias is resolved again each time the session's claude process starts, a full id stays on its model, so following and staying need no new field. Effort support is read per model so a model switch can drop an effort level the next model does not offer. * feat(cli): discover Claude models from the CLI's initialize response A machine RPC starts claude in stream-json mode, sends only an initialize control request and reads the models it reports, so no turn runs and no transcript is written. claude --print runs a directory's project hooks and .mcp.json servers without a trust prompt, so the probe starts from the home directory with user settings only, no MCP servers and hooks disabled. Successful catalogs are cached per claude executable for five minutes and concurrent requests share one probe; failures are not cached. * feat(hub): expose the Claude model catalog per machine GET /machines/:id/claude-models forwards to the runner's listClaudeModels RPC. A runner that predates the handler answers with the stable rpc_target_missing code so clients can keep their built-in list. * feat(web): offer the machine's Claude models in the model pickers The composer and the create-session form list the rows the machine's claude CLI shows in its own /model picker, as it shows them. Built-in presets the catalog does not list remain selectable after it. Until the catalog arrives, or on a runner without discovery, the built-in presets are shown as before. A saved Claude model is no longer reset to Default when the built-in list lacks it; the create-session form keeps it and marks it as no longer listed when the catalog dropped it. * feat(hub,web): keep Claude effort within what the selected model offers For Claude sessions the model endpoint accepts an optional effort, so a model switch and the effort it keeps reach the session as one config change. Other flavors are refused: Pi, for one, commits the model before its thinking level. When the machine's catalog says the next Claude model does not offer the current effort, the composer clears it in that same request. The create-session form drops such an effort however the model was set, including a restored preference or draft. Effort choices in both places narrow to the selected model's levels; without a catalog every level stays available as before. * fix(web): retry Claude model discovery after a failed probe The machine answers a failed probe with success:false over HTTP 200. The query cached that as an answer for its stale time, so a user who signed in and reopened a picker within a minute still saw only the built-in models. A failed probe is now a query error, which is not kept, and the next picker to mount asks the machine again. * fix(web): list only the Claude CLI's models once its catalog is known The built-in presets were kept after the catalog so that a partial list could not hide Opus. The catalog is the CLI's own /model list, though, not a partial one, and the presets added entries the CLI does not show (Sonnet 1M, Opus 1M, Fable 1M). With a catalog the list is now exactly its rows; a saved model it does not list stays as the current model, and New Session marks it as no longer listed. Without a catalog the built-in presets are shown as before. * fix(shared,web): list earlier Claude versions under Other models Claude Code's model sheet shows each family's newest model first and every earlier version under an "Other models" heading. The CLI's catalog lists models in that order but does not say where the split is, so the first row of each family is taken as its newest and later rows of the same family as earlier versions. The composer's model list and New Session's model select now show the same two groups, keeping the CLI order within each. Nothing is folded: every row stays one click away, as it does in Claude Code. * test(web): pin that Claude 1M variants come from the CLI catalog Claude Code 2.1.105, 2.1.200, 2.1.240 and 2.1.274 list a 1M choice as its own catalog row (opus[1m], claude-fable-5[1m]); 2.1.303 and 2.1.310 list none, and on 2.1.310 opus and sonnet run with a 1M context window. The catalog-only list therefore offers a 1M variant exactly when the CLI does. Pin that and record why the built-in [1m] presets are not appended.
hapi-web
React Mini App / PWA for monitoring and controlling hapi sessions.
What it does
- Session list with status, pending approvals, todos, and summaries.
- Chat view with streaming updates and message sending.
- Permission approval and denial workflows.
- Codex
request_user_inputchoices honorisOther: None of the above focuses optional notes, supports empty notes, and preserves the canonical wire value across languages. Recorded other answers and notes remain visible in live and historical cards; Pi/MCP forms without the flag are unchanged. - Permission mode and model selection.
- Machine list and remote session spawn.
- File browser and git status/diff views.
- PWA install prompt and offline banner.
- Optional unread-session count on the Windows taskbar when installed as an Edge/Chrome PWA (toggleable in Display settings; off by default).
Runtime behavior
- When opened inside Telegram, auth uses Telegram WebApp init data.
- When opened in a normal browser, you can log in with
CLI_API_TOKEN:<namespace>(orCLI_API_TOKENfor the default namespace). - The login screen includes a top-right hub picker; if unset, the app uses the same origin it was loaded from.
- Live updates come from the hub via SSE.
- Session
@suggestions require conversation content, including untitled conversations. Names and directory labels affect display/search, not eligibility; empty stubs stay excluded.
Routes
See src/router.tsx for route definitions.
/- Redirect to /sessions./sessions- Session list./sessions/$sessionId- Chat interface./sessions/new- Create new session./sessions/$sessionId/files- File browser with git status./sessions/$sessionId/file- File viewer with diff support./sessions/$sessionId/terminal- Terminal interface./browse- Workspace browser, enabled by the runner's configured workspace roots./share- Share-target landing (Web Share Target POST →?id=, or native/share#url=&text=&title=)./settings- Settings category hub (mobile) and responsive master-detail shell./settings/general- Language preferences./settings/display- Appearance, typography, colors, and session list preferences./settings/chat- Message input, tool card, and conversation color preferences./settings/voice- Everyday voice assistant preferences./settings/voice/voices- Full-page voice picker./settings/voice/advanced- Voice persona, tuning, and diagnostics./settings/machines- Machine management and runner status./settings/storage- SQLite storage sizes for the hub owner./settings/usage- Cache-aware token usage dashboard for the hub owner./settings/about- Application links and version information.
Features
Session list (src/components/SessionList.tsx)
- Active/inactive status indicator.
- Session title from name, summary, or path.
- Todo progress display.
- Pending permission request count.
- Agent name and model display.
Chat interface (src/components/SessionChat.tsx)
- Message thread with infinite scroll.
- Composer for sending messages.
- Permission mode and model selection for supported agents.
Cursor Auto uses CLI Auto for new/resumed sessions configured with Auto. Switching a live session to Auto when ACP does not advertise a literal Auto option relaunches the Cursor ACP process in place under
--model autowhile preserving the hub session id. - Session abort and handoff controls.
- Codex Continue planning hides the current proposal's action menu locally and focuses the composer without sending a message or replacing its draft. The plan document remains readable; recycled cards stay dismissed and new proposals get fresh action menus.
- Context size display.
- Per-session scratchlist (
src/components/AssistantChat/ScratchlistPanel.tsx)- Workbench panel for held notes/drafts; distinct from the queue.
- Add/delete/reorder entries; promote to composer (copy) or queue (send).
- Entries and attachments saved on the hub and synced across devices.
- Reordering affects only the current view and resets when entries refresh.
- Keyboard shortcut: Ctrl/Cmd+Shift+S to focus the add-input.
File browser (src/routes/sessions/files.tsx)
- Git status view (staged/unstaged files).
- File search with ripgrep.
- Navigate to file viewer.
File viewer (src/routes/sessions/file.tsx)
- File content display with syntax highlighting.
- Staged/unstaged diff view.
Terminal (src/routes/sessions/terminal.tsx)
- Remote terminal via xterm.js
- Real-time via Socket.IO
/terminal - Resize handling
Voice assistant
- ElevenLabs (@elevenlabs/react), Gemini Live, and Qwen Realtime backends
- Real-time voice control
- Standard and realtime composer dictation with provider capability selection
New session (src/components/NewSession/)
Modular session creation:
- Machine selector
- Directory input with recent paths
- Agent type selector
- Model selector
- Per-agent permission, effort, and collaboration controls when supported
First-User-Experience (FUE)
For a new, non-essential feature whose affordance would otherwise be hard to discover, consider the existing FUE primitive rather than a permanent UI block. Optional, not a requirement for every feature or a reason to expand a bug fix.
src/lib/use-fue.ts:useFue(featureId)returns{ status, engage, dismiss }; acknowledgement is isolated per feature inhapi.fue.v1.<featureId>localStorage keys.src/components/Fue.tsx:FueDotmarks the affordance;FueCalloutexplains it whilestatus === 'engaging'.- Dismissal requires an affirmative user action ("Got it"), never an auto-timeout.
- The FUE dot and feature-specific badges/counters are mutually exclusive; onboarding wins until acknowledged.
- Opt in per feature; skip the wrapper if an upstream component already supplies onboarding.
- Working example:
ScratchlistToggleButtoninsrc/components/AssistantChat/ComposerButtons.tsx. Use the source rather than maintaining a copied example here.
Authentication
See src/hooks/useAuth.ts and src/hooks/useAuthSource.ts.
- Telegram Mini App: Uses initData from WebApp SDK.
- Browser: Uses CLI_API_TOKEN from login prompt.
- JWT tokens with auto-refresh.
Data fetching
See src/hooks/queries/ for query hooks and src/hooks/mutations/ for mutations.
- Sessions, messages, machines via TanStack Query.
- Git status and file operations.
- Optimistic updates for message sending.
Real-time updates
See src/hooks/useSSE.ts.
- SSE connection to
/api/events. - Session/message/machine update events.
- Automatic cache invalidation on events.
Stack
React 19 + Vite + TanStack Router/Query + Tailwind + @assistant-ui/react + xterm.js + @elevenlabs/react + socket.io-client + workbox + shiki.
Source structure
src/router.tsx- Route definitions.src/components/- UI components.src/hooks/- Data fetching and state hooks.src/api/client.ts- API client.src/types/api.ts- Type definitions.
Development
From the repo root:
bun install
bun run dev:web
If testing in Telegram, set:
HAPI_PUBLIC_URLto the public HTTPS URL of the dev server.CORS_ORIGINSto include the dev server origin.
Tests
Unit tests run under vitest + jsdom:
bun run test:web
End-to-end browser tests for the scratchlist component (real Chromium, real
inert focus blocking, real localStorage round-trips) live at the repo root
under e2e/:
bun run test:e2e # headless
bun run test:e2e:ui # Playwright UI mode (debug)
The spec drives a Vite-served fixture page (web/e2e-fixtures/scratchlist-fixture.html)
that mounts the production ScratchlistPanel in isolation, so no hub /
auth / socket setup is required.
Build
bun run build:web
The built assets land in web/dist and are served by hapi-hub. The single executable can embed these assets.
Standalone hosting
You can host web/dist on a static host (GitHub Pages, Cloudflare Pages) and point it at any hapi hub:
- Build the web app. If your static host uses a subpath, set the Vite base:
bun run build:web -- --base /<repo>/
- Deploy
web/distto your static host. - Set hub CORS to allow the static origin (
HAPI_PUBLIC_URLorCORS_ORIGINS). - Open the static site, click the top-right Hub button on the login screen, and enter the hapi hub origin.
Clear the hub override in the same dialog to return to same-origin behavior.