Files
hapi/hub
Junmo KimandGitHub 7d55bc1456 feat(web): float queued messages above composer until invocation (#542)
* refactor: add invoked_at column and propagate via messages-consumed

- Bump hub schema to V8: add `invoked_at INTEGER` to messages table
- Add `migrateFromV7ToV8` (idempotent ALTER TABLE ADD COLUMN)
- Add migration chain entries for V4/V5/V6/V7 → V8
- Expose `StoredMessage.invokedAt: number | null` and `markMessagesInvoked`
- Record server-side `Date.now()` in hub on `messages-consumed` socket event
- Propagate `invokedAt` through SSE (`messages-consumed` payload)
- Update `markMessagesConsumed` in web store to accept and store `invokedAt`
- Preserve optimistic `invokedAt` in `mergeMessages` (server echo path)
- Add migration unit tests (fresh V8, V7→V8 ALTER, markMessagesInvoked)

* feat(web): float queued messages above composer until invocation

Show queued (uninvoked) user messages in a dedicated floating bar above
the composer instead of inline in the thread timeline. Once the CLI acks
the batch via messages-consumed, the bar disappears and the messages
appear in the thread at their invocation position (invokedAt ordering).

- Add QueuedMessagesBar component: subscribes to message-window-store,
  filters user messages with invokedAt==null, shows clock icon + text
  preview; disappears when all messages are invoked
- Filter queued messages from thread (visibleMessages), sort by
  invokedAt ?? createdAt so invoked messages land at the right position
- Extend markMessagesConsumed to update server-loaded messages (status
  undefined) in addition to optimistic (status 'queued'), enabling
  multi-device and post-refresh scenarios
- Remove opacity-60 from UserMessage: queued messages no longer appear
  in the thread so the dimming branch is unreachable
- Include invokedAt in getMessagesPage/getMessagesAfter API responses
  so the web client can restore floating-bar state after page refresh
- Add invokedAt field to DecryptedMessageSchema for shared protocol type

* fix(hub,web): make sort use invokedAt and V8 backfill idempotent

- compareMessages: prioritize invokedAt/createdAt over seq so invoked
  messages land at their invocation position rather than their
  send-time seq position
- migrateFromV7ToV8: move backfill outside the ALTER guard so it
  re-runs if a previous attempt crashed between ALTER and UPDATE
  before the user_version bump (idempotent WHERE invoked_at IS NULL)

* fix(hub,web): cover localId-less messages and live-ack invokedAt

- addMessage: messages without a localId have no ack path
  (markMessagesInvoked matches by localId). Treat them as
  already-invoked at insert time so they land in the thread instead of
  sitting in the queued floating bar forever.
- markMessagesConsumed: apply the ack even when the message is already
  'sent' optimistically, so the live window receives invokedAt instead
  of waiting until a full refetch.

* fix(hub): propagate invokedAt in live message-received SSE payload

The SSE `message-received` event omitted `invokedAt` while REST
pagination included it, so localId-less CLI/local user messages arrived
on the live wire as queued (`invokedAt == null`) and stayed in the
floating bar until a full refetch replaced them with the stored row.

* fix(hub): propagate invokedAt in CLI socket message-received handler

The CLI socket 'message' handler fans out to web via a separate
`onWebappEvent` publisher; the previous fix only touched the
`MessageService` publisher. Aligns the live SSE payload shape with
the REST/page-load shape so localId-less CLI/local user messages with
`invokedAt = createdAt` (set in addMessage) reach web filters with the
field already populated, instead of being misclassified as queued
until a full refetch.

* fix(hub,web): add byPosition pagination to fix long-session queued message loss

Pagination used seq-based windows, so queued messages with low seq but late
invokedAt fell outside the visible window on refresh. Fix by adding a V8
byPosition mode that orders by COALESCE(invoked_at, created_at) DESC, seq DESC
with a composite cursor, while keeping the V7 seq path fully intact for
backward compatibility.

- hub/store/index: add idx_messages_session_position (createSchema + V7→V8 migration)
- hub/store/messages: add getMessagesByPosition with composite cursor SQL
- hub/store/messageStore: delegate getMessagesByPosition
- hub/sync/messageService: add getMessagesPageByPosition with nextBeforeAt response
- hub/sync/syncEngine: expose getMessagesPageByPosition
- hub/web/routes/messages: byPosition=1 query param dispatches to V8 path
- web/types/api: MessagesResponse.page gains optional nextBeforeAt
- web/api/client: getMessages gains byPosition + beforeAt options
- web/lib/message-window-store: fetchLatestMessages/fetchOlderMessages use V8
  composite cursor; fallback to seq cursor when hub returns no nextBeforeAt
- hub/store/migration-v8.test: 7 new tests covering position sort, composite
  cursor pagination, long-session scenario, V7 compat, and index existence

* fix(hub,web): re-sort on consume and use position cursor for next fetch

- markMessagesConsumed: re-merge with empty list to re-sort by position
  key after invokedAt is set. A queued user message becomes visible
  with the consume event; without re-sort it stays at its send-time
  array slot until the next fetch overwrites it.
- getMessagesPageByPosition: pick the cursor from stored[0] (oldest in
  position order) instead of scanning for minimum seq. With the page
  already in ascending position order, scanning for min seq could land
  on a low-seq, late-invoked row that is actually the newest in the
  page, causing the next older fetch to overlap.

* fix(web): trust invokedAt as the only invocation signal and pin cursor pair

- visibleMessages predicate (SessionChat + QueuedMessagesBar): drop the
  status === 'sent' check. status='sent' only means the REST write
  returned, not that the CLI consumed the message; an optimistic 'sent'
  with no invokedAt is still queued. invokedAt is the single source of
  truth for invocation.
- byPosition cursor: track oldestPositionSeq alongside oldestPositionAt
  so the server's cursor pair travels through the next older fetch
  unchanged. Recomputing beforeSeq from the local window's minimum seq
  could combine it with a server beforeAt that referred to a different
  row, causing the SQL cursor to skip or overlap.

* fix(hub): include uninvoked local messages in latest page

Long sessions can push a queued user message (invokedAt = null, sort key
= createdAt) outside the latest position-ordered page once the agent
emits more than `limit` later rows. A refresh or secondary client then
never receives the row, the floating bar stays empty, and the later
`messages-consumed` event only carries localIds — there is no way to
materialize the missing row at invocation time.

Pin uninvoked local user messages to every latest-page response
out-of-band. The pagination cursor still anchors to the position-ordered
page rows, so older-page fetches are unaffected.

* fix(web): preserve queued messages across trimVisible

The visible-window trim drops the oldest entries beyond
VISIBLE_WINDOW_SIZE, but a queued user message (invokedAt = null) sorts
by send time and is the oldest item. Once a long agent stream pushes
it past the window the row is gone from the client store, and the
`messages-consumed` SSE carries only localIds — there is no way to
restore or reposition the dropped row without a full refetch.

Pull queued rows out before slicing the regular budget, then merge
them back in. Queued rows are bounded by composer/CLI queue depth and
do not meaningfully grow the window.

* fix(web): use strict null for queued check and fall back invokedAt

- Optimistic message sets invokedAt: null explicitly so the strict-null
  queued check matches the local opt-in. Pre-V8 hub responses that
  omit the field (`undefined`) are treated as already-invoked and
  stay in the thread instead of being misclassified as queued.
- markMessagesConsumed: when the consume SyncEvent omits invokedAt
  (older hub) fall back to client time, otherwise a message that
  receives an ack with no server timestamp stays queued forever under
  the new strict-null filter. The persisted server value is still
  authoritative on next fetch.

* fix: comprehensive invokedAt propagation hardening (review feedback batch)

Bot review surfaced 11 propagation bugs incrementally; this batch fixes
9 additional adjacent issues found by hostile-review to break the
incremental discovery cycle:

- legacy DB (user_version=0 with HAPI tables): step ladder runs V1→V8
  before createSchema so pre-existing tables get all later columns/indexes
- step ladder includes V1/V2/V3 entries; previously V1-V3 DBs threw
- mergeSessionMessages collision branch forces invoked_at = created_at
  so unmergeable rows can't strand in the floating bar
- session-end auto-invokes still-queued user messages and broadcasts
  messages-consumed; the floating bar no longer pins ghost rows after
  the CLI is gone
- trimPending preserves queued rows symmetrically with trimVisible
- markMessagesInvoked is first-write-wins; duplicate acks are no-ops
  rather than re-stamping invoked_at and reordering the thread
- markMessagesConsumed migrates just-acked pending entries into the
  visible thread so non-at-bottom users see their own messages without
  scrolling
- mergeMessages dedup window compares by position key (invokedAt ?? createdAt)
  instead of createdAt only, so late-invoked optimistic copies don't
  duplicate the server echo
- isQueuedForInvocation centralized in lib/messages.ts (single
  predicate used by SessionChat, QueuedMessagesBar, and the store)

* fix(web): mirror hub's first-write-wins on markMessagesConsumed

The hub's markMessagesInvoked is first-write-wins, but the web store
was still overwriting any non-null invokedAt with the latest
messages-consumed timestamp. A duplicate ack (CLI re-emit) would leave
the SQLite row at the original timestamp while live clients moved
the message to the duplicate ack time, diverging until refetch.
Mirror the guard: only set invokedAt when it is null.

* fix: in-scope hostile-review polish

Web:
- fetchLatestMessages: persist the V8 composite cursor pair on the
  non-at-bottom branch too. Without this, a refresh while scrolled
  up dropped the cursor and the next loadMore fell back to V7 seq
  mode against a V8 hub — same asymmetric class of bug commit
  30df6b2 fixed for the at-bottom path.
- markMessagesConsumed: tighten the loose-null check on invokedAt
  to strict null, consistent with isQueuedForInvocation and the
  rest of the file. The idSet filter already shields V7-stamped
  rows from this path, but the strict-null contract should not
  vary by call site.
- messages: drop the upsertMessagesInCache export. It has no
  callers (verified with grep) and is the only user of the
  InfiniteData / MessagesResponse imports, so the imports go
  with it.

Hub tests:
- migration-v8.test.ts: add a session-end auto-invoke test
  (getUninvokedLocalMessages + markMessagesInvoked clears every
  queued row and stamps them all with the same invokedAt) and
  two byPosition union tests covering (1) a low-position queued
  row pushed out of the latest page is still surfaced via the
  uninvoked set, and (2) pageRows[0] is the oldest row in the
  page so the web client can safely anchor the next-older
  cursor on it.

* fix(hub,web): bot-13 polish — atomic SSE on DB success and attachment chip text

- sessionHandlers messages-consumed: emit messages-consumed only after
  markMessagesInvoked succeeds. Otherwise a transient SQLite failure
  would broadcast an invokedAt that was never persisted; live clients
  would hide the queued rows while a refresh / secondary client would
  see them as queued again, diverging the state.
- QueuedMessagesBar: fall back to attachment filenames when the
  message text is empty. The composer / POST /messages allow
  attachment-only sends; without the fallback those queued messages
  rendered as blank chips until invocation.
2026-04-29 17:23:01 +08:00
..
2026-01-27 19:51:21 +08:00

hapi-hub

Telegram bot + HTTP API + realtime updates for hapi hub.

What it does

  • Telegram bot for notifications and the Mini App entrypoint.
  • HTTP API for sessions, messages, permissions, machines, and files.
  • Server-Sent Events stream for live updates in the web app.
  • Socket.IO channel for CLI connections.
  • Serves the web app from web/dist or embedded assets in the single binary.
  • Persists state in SQLite.

Configuration

See src/configuration.ts for all options.

Required

  • CLI_API_TOKEN - Base shared secret used by CLI and web login. Clients append :<namespace> for isolation. Auto-generated on first run if not set.

Optional (Telegram)

  • TELEGRAM_BOT_TOKEN - Token from @BotFather.
  • HAPI_PUBLIC_URL - Public HTTPS URL for Telegram Mini App access. Also used to derive default CORS origins for the web app.

Optional (Voice)

  • ELEVENLABS_API_KEY - ElevenLabs API key for voice assistant.
  • ELEVENLABS_AGENT_ID - Custom ElevenLabs agent ID (auto-created if not set).

Optional

  • HAPI_LISTEN_HOST - HTTP bind address (default: 127.0.0.1).
  • HAPI_LISTEN_PORT - HTTP port (default: 3006).
  • CORS_ORIGINS - Comma-separated origins, or *.
  • HAPI_HOME - Data directory (default: ~/.hapi).
  • DB_PATH - SQLite database path (default: HAPI_HOME/hapi.db).
  • TELEGRAM_NOTIFICATION - Enable/disable Telegram notifications (default: true).
  • HAPI_RELAY_API - Relay API domain (default: relay.hapi.run).
  • HAPI_RELAY_AUTH - Relay auth key (default: hapi).
  • HAPI_RELAY_FORCE_TCP - Force TCP relay mode (true/1).
  • VAPID_SUBJECT - Contact email/URL for Web Push.

Running

Binary (single executable):

export TELEGRAM_BOT_TOKEN="..."
export CLI_API_TOKEN="shared-secret"
export HAPI_PUBLIC_URL="https://your-domain.example"

hapi hub

hapi server remains supported as an alias.

If you only need web + CLI, you can omit TELEGRAM_BOT_TOKEN. To enable Telegram, set TELEGRAM_BOT_TOKEN and HAPI_PUBLIC_URL, start the hub, open /app in the bot chat, and bind the Mini App with CLI_API_TOKEN:<namespace> when prompted.

From source:

bun install
bun run dev:hub

HTTP API

See src/web/routes/ for all endpoints.

Authentication (src/web/routes/auth.ts)

  • POST /api/auth - Get JWT token (Telegram initData or CLI_API_TOKEN[:namespace]).
  • POST /api/bind - Bind a Telegram account using initData + CLI_API_TOKEN:<namespace>.

Sessions (src/web/routes/sessions.ts)

  • GET /api/sessions - List all sessions.
  • GET /api/sessions/:id - Get session details.
  • POST /api/sessions/:id/abort - Abort session.
  • POST /api/sessions/:id/switch - Switch session to remote mode.
  • POST /api/sessions/:id/resume - Resume inactive session.
  • POST /api/sessions/:id/upload - Upload file (base64, max 50MB).
  • POST /api/sessions/:id/upload/delete - Delete uploaded file.
  • POST /api/sessions/:id/archive - Archive active session.
  • PATCH /api/sessions/:id - Rename session.
  • DELETE /api/sessions/:id - Delete inactive session.
  • GET /api/sessions/:id/slash-commands - List slash commands.
  • GET /api/sessions/:id/skills - List skills.
  • POST /api/sessions/:id/permission-mode - Set permission mode.
  • POST /api/sessions/:id/model - Set model preference.
  • POST /api/sessions/:id/effort - Set Claude effort preference.

Messages (src/web/routes/messages.ts)

  • GET /api/sessions/:id/messages - Get messages (paginated).
  • POST /api/sessions/:id/messages - Send message.

Permissions (src/web/routes/permissions.ts)

  • POST /api/sessions/:id/permissions/:requestId/approve - Approve permission.
  • POST /api/sessions/:id/permissions/:requestId/deny - Deny permission.

Machines (src/web/routes/machines.ts)

  • GET /api/machines - List online machines.
  • POST /api/machines/:id/spawn - Spawn new session on machine.
  • POST /api/machines/:id/paths/exists - Check if path exists.

Git/Files (src/web/routes/git.ts)

  • GET /api/sessions/:id/git-status - Git status.
  • GET /api/sessions/:id/git-diff-numstat - Diff summary.
  • GET /api/sessions/:id/git-diff-file - File-specific diff.
  • GET /api/sessions/:id/file - Read file content.
  • GET /api/sessions/:id/files - File search with ripgrep.

Events (src/web/routes/events.ts)

  • GET /api/events - SSE stream for live updates.
  • POST /api/visibility - Report client visibility state.

Voice (src/web/routes/voice.ts)

  • POST /api/voice/token - Get ElevenLabs conversation token.

Push Notifications (src/web/routes/push.ts)

  • GET /api/push/vapid-public-key - Get VAPID public key.
  • POST /api/push/subscribe - Subscribe to push notifications.
  • DELETE /api/push/subscribe - Unsubscribe.

CLI (src/web/routes/cli.ts)

  • POST /cli/sessions - Create/load session.
  • GET /cli/sessions/:id - Get session by ID.
  • POST /cli/machines - Create/load machine.
  • GET /cli/machines/:id - Get machine by ID.

Socket.IO

See src/socket/handlers/cli.ts for event handlers.

Namespace: /cli

Client events (CLI to hub)

  • message - Send message to session.
  • update-metadata - Update session metadata.
  • update-state - Update agent state.
  • session-alive - Keep session active.
  • session-end - Mark session ended.
  • machine-alive - Keep machine online.
  • rpc-register - Register RPC handler.
  • rpc-unregister - Unregister RPC handler.

Terminal events (web to hub)

  • terminal:create - Open terminal for session.
  • terminal:write - Send input.
  • terminal:resize - Resize dimensions.
  • terminal:close - Close terminal.

Hub events (hub to clients)

  • update - Broadcast session/message updates.
  • rpc-request - Incoming RPC call.

See src/socket/rpcRegistry.ts for RPC routing.

Telegram Bot

See src/telegram/bot.ts for bot implementation.

Commands

  • /start - Welcome message with Mini App link.
  • /app - Open Mini App.

Features

  • Permission request notifications with approve/deny buttons.
  • Session ready notifications.
  • Deep links to Mini App sessions.

See src/telegram/callbacks.ts for button handlers.

Core Logic

See src/sync/syncEngine.ts for the main session/message manager:

  • In-memory session cache with versioning.
  • Message pagination and retrieval.
  • Permission approval/denial.
  • RPC method routing via Socket.IO.
  • Event publishing to SSE and Telegram.
  • Git operations and file search.
  • Activity tracking and timeouts.

Storage

See src/store/index.ts for SQLite persistence:

  • Sessions with metadata and agent state.
  • Messages with pagination support.
  • Machines with runner state.
  • Todo extraction from messages.
  • Users table for Telegram bindings (includes namespace).

Source structure

  • src/web/ - HTTP service and routes.
  • src/socket/ - Socket.IO setup and handlers.
  • src/socket/handlers/cli/ - Modular CLI handlers.
  • src/telegram/ - Telegram bot.
  • src/sync/ - Core session/message logic.
  • src/store/ - SQLite persistence.
  • src/sse/ - Server-Sent Events.
  • src/config/ - Configuration loading and generation.
  • src/notifications/ - Push and Telegram notifications.
  • src/visibility/ - Client visibility tracking.

Security model

Access is controlled by:

  • Telegram initData verification plus bound Telegram users (bound via CLI_API_TOKEN:<namespace>).
  • CLI_API_TOKEN base secret for CLI and browser access (namespace is appended by clients).

Transport security depends on HTTPS in front of the hub.

Build for deployment

From the repo root:

bun run build:hub
bun run build:web

The hub build output is hub/dist/index.js, and the web assets are in web/dist.

Networking notes

  • Telegram Mini Apps require HTTPS and a public URL. If the hub has no public IP, use Cloudflare Tunnel or Tailscale and set HAPI_PUBLIC_URL to the HTTPS endpoint.
  • If the web app is hosted on a different origin, set CORS_ORIGINS (or HAPI_PUBLIC_URL) to include that static host origin.

Standalone web hosting

The web UI can be hosted separately from the hub (for example on GitHub Pages or Cloudflare Pages):

  1. Build and deploy web/dist from the repo root.
  2. Set CORS_ORIGINS (or HAPI_PUBLIC_URL) to the static host origin.
  3. Open the static site, click the Hub button on the login screen, and enter the hapi hub origin.

Leaving the hub override empty preserves the default same-origin behavior when the hub serves the web assets directly.