* fix(hub,web): deduplicate sessions by agent session ID When multiple CLI wrappers independently resume the same Codex thread, each generates a random tag, causing the hub to create duplicate session records for a single underlying thread. This leads to duplicate conversations in the web UI and messages routing to the wrong session. Add two-layer deduplication: - Hub: when a metadata update sets an agent session ID (codexSessionId, claudeSessionId, etc.) that already exists on another session in the same namespace, automatically merge the duplicate into the current session using the existing mergeSessions logic. - Web: deduplicate the session list display by agentSessionId as a safety net, keeping the active/most-recent session visible. Closes #446 * chore: add review-driven comments for dedup clarity - Explain single-threaded assumption in before/after metadata comparison - Document merge direction rationale (duplicate → active session) - Document deduplicateInProgress guard as known limitation - Add catch comment explaining web safety net fallback * fix: address review feedback from bot, Opus, and Codex - Skip active duplicates during hub-side dedup to avoid deleting sessions with live CLI sockets and pending agent state - Pass selectedSessionId into web dedup sort to prevent hiding the session the user is currently viewing - Add test for active-duplicate-not-merged case * fix: retry dedup on session-end and preserve agentState in merge - Trigger dedup when a session ends (handleSessionEnd), so active duplicates skipped during earlier dedup get merged once they disconnect - Preserve agentState from old session during mergeSessions when the new session has no agentState (mirrors existing model/effort/todos preservation pattern) - Extract triggerDedupIfNeeded helper for reuse across trigger points * fix(web): prefer active session over selected in dedup sort Active session always wins the dedup tie-break so the live connection is never hidden in favor of a selected inactive duplicate. Among inactive duplicates the selected one is still preferred. * fix: dedup on inactivity timeout and deep-merge agentState - expireInactive now returns expired session IDs so SyncEngine can trigger dedup for sessions that timed out (crash/network drop) instead of only on explicit session-end - mergeSessions now deep-merges agentState requests/completedRequests from both sessions instead of only copying when new is null * fix: exclude completed requests from merged pending set Filter out request IDs that already appear in completedRequests when merging agentState, preventing completed permission prompts from resurrecting as pending after session dedup. * fix: guard resume merge against prior auto-dedup The automatic dedup (triggered when the spawned CLI sets its agent session ID) can delete the old session before resumeSession reaches its own explicit mergeSessions call. Skip the merge if the old session no longer exists instead of failing the resume with a false error. * test: add coverage for dedup retry paths and web dedup sort Hub tests: - session-end triggers dedup retry for previously-active duplicates - inactivity timeout expiry triggers dedup retry - agentState deep merge filters completed requests from pending set Web tests: - basic dedup by agentSessionId - active session wins over inactive duplicate - selected session preferred among inactive duplicates - active always wins over selected inactive - sessions without agentSessionId pass through - independent dedup across different agentSessionIds * fix: read latest agentState before merge write to avoid overwriting live updates Re-read the target session's agentState right before writing the merged result, with a version-mismatch retry loop, so concurrent update-state events from the active CLI are not lost during dedup merge. * fix: sort expired sessions by recency before dedup When multiple duplicates for the same agent thread expire in a single sweep, process the most recent one first so it becomes the merge target and survives, rather than keeping the oldest by arbitrary iteration order. * fix: select most recent session as merge target in dedup deduplicateByAgentSessionId now collects all inactive candidates (including the caller) and picks the one with the highest activeAt (then updatedAt) as the merge target. This ensures the newest session survives regardless of which trigger point or ordering calls the dedup.
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- Application settings.
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/bypassPermissions/plan).
- Model selection (default/sonnet/sonnet[1m]/opus/opus[1m]).
- Session abort and mode switch controls.
- Context size display.
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
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.
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.