* fix(opencode): report why a prompt failed instead of pointing at logs The provider's own explanation already reaches this process: the ACP transport rejects session/prompt with the JSON-RPC error message verbatim. The launcher caught it, logged it, and handed the user a fixed "OpenCode prompt failed. Check logs for details." — a remote user is by definition not at the machine holding those logs, so a rate-limited session simply stopped with no stated reason. Only the message is used; that channel carries no response headers, cookies or body. It is unbounded though (a 20KB provider body produced a 20,210-character message), so it is capped at the same 200 characters the compaction bridge already applies to provider text, and the JSON-RPC "Internal error: " wrapper is stripped. A failure with nothing readable to say still renders the sentence it always did. * feat(opencode): surface upstream retries from the agent event stream A provider rate limit leaves OpenCode retrying indefinitely, and it announces that on exactly one channel: its own server event stream. Measured against a provider stubbed to answer 429 — 40 minutes, 85 retries, zero ACP notifications, zero stderr bytes, session/prompt never settling. HAPI showed a session that looked like it was thinking and said nothing else. Subscribes to that stream once the ACP session id is known and reports retries as the same api_error system message Claude sessions already use, so the web timeline folds a run of them into one block whose attempt count climbs. The reason rides along in the payload, and the presentation now appends it to "Retrying..." when an agent supplies one; sessions that supply nothing render exactly as before. Not every attempt is announced. The backoff tops out at 30 seconds and OpenCode does not give up, so a session held against a daily quota would otherwise persist two messages a minute for as long as it is left running. The first few attempts are reported, then only attempt numbers that are powers of two, which needs no clock to decide. The subscription must be scoped with ?directory=: without it the endpoint delivers heartbeats and no session events at all, with neither an error nor a 404 to notice. Its session.error event is deliberately not read — it carries the Authorization header, cookies and the full response body verbatim, and the same failure already reaches the user stripped to a message through the ACP prompt error. Delegated turns are not covered: OpenCode's subagent tool runs them in a child session with its own id, and this follows only the one it was opened for. No countdown is rendered, though the payload offers one: a timeline block outlives the turn it describes. Turn state is left alone; a retrying session really is busy. * fix(opencode): only relay prompt failures OpenCode itself reported AcpStdioTransport flattens a JSON-RPC error response to new Error(response.error.message), so a rejected session/prompt is indistinguishable by shape from an error the transport built locally. Its process-close error appends up to 4KB of raw subprocess stderr to the message, which the formatter would then have published to the hub and every connected client. Requiring the "Internal error: " wrapper OpenCode puts on every service failure turns this into an allowlist: an unrecognised rejection renders the sentence this path rendered before rather than whatever it happened to contain. Retry text is collapsed to one line before it is judged, since a whitespace-only message is truthy and embedded newlines break the one-line contract the helper states.
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.
- Permission mode and model selection.
- Machine list and remote session spawn.
- File browser and git status/diff views.
- PWA install prompt and offline banner.
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.
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./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/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 flavor label (claude/codex/gemini).
- Model mode display.
Chat interface (src/components/SessionChat.tsx)
- Message thread with infinite scroll.
- Composer for sending messages.
- Permission mode toggle (default/acceptEdits/auto/bypassPermissions/plan).
- Model selection (default/sonnet/sonnet[1m]/opus/opus[1m]).
- Session abort and mode switch controls.
- 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).
- Persists across reloads via
localStoragekeyed per session. - 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
- Resize handling
Voice assistant
- ElevenLabs integration (@elevenlabs/react)
- 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
- Permission mode toggle (YOLO mode)
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.