* fix(cursor): migrator path-priority + ambiguity surface (closes #844 regression) The legacy-to-ACP migrator's `findLegacyChatStore()` walks `~/.cursor/chats/<workspace-hash>/<cursorSessionId>/store.db` via `readdirSync()` and returns the FIRST match. When the same cursor session id exists in more than one workspace-hash drawer (operator opened the session from a worktree, an old workspace clone, etc.) the readdir order picks an arbitrary candidate. The migrator then transplants alien content into the ACP target, deletes the source drawer, and reports success - because the verify probe only checks "loads cleanly", not "loaded the right content". Operator session resurrects with no recall of its real history. Four-part fix (all four must land together): 1. Path-priority discovery in `findLegacyChatStore(id, home, cwd?)`: - Optional 3rd arg = canonical workspace path (caller passes `session.metadata.path`). - Compute md5(cwd) and check that drawer FIRST. - Fall back to readdir scan only if the canonical drawer is empty. - If 2+ candidates remain after fallback, throw `AmbiguousLegacyStoreError` listing all of them (workspaceHash, sizeBytes, mtimeMs). 2. Ambiguity surface in `maybeAutoMigrateLegacyCursorSession`: - Catch `ambiguous_legacy_store` / `size_mismatch` refusals and promote `cursorMigrationState` from 'in_progress' to a new 'ambiguous' state instead of silently clearing the banner. Operator sees an actionable web-banner. 3. Size sanity check before transplant: - Compare HAPI's known message count (new `MessageStore.countMessages` + `CursorLegacyMigratorDeps.getHapiMessageCount` dep) against the candidate `store.db`'s blob count. If message count > 100 AND blob count < messageCount/4, refuse with `size_mismatch`. - Skipped when message count is 0 (brand-new session) or the dep is unwired (unit tests, CLI direct callers). 4. Diagnostic logging on every successful transplant: - `[migrator] transplanted` info log capturing cursorSessionId, picked workspaceHash, candidate count discovered, sourceBytes, sourceBlobCount, targetAcpPath, sourceRemoved, canonical-path md5. Future regressions of this bug shape are diagnosable from `journalctl -u hapi-hub` without blob-overlap forensics. Tests added in `hub/src/cursor/cursorLegacyMigrator.test.ts`: - regression guard for single-drawer discovery - canonical-path wins over readdir order - ambiguity throws with all candidates listed (3-drawer + 2-drawer no-canonical-arg variants) - canonical-path resolves ambiguity cleanly - listLegacyChatStoreCandidates enumeration - workspaceHashFromPath shape - migrateOne happy path with canonical workspace + 3 sibling decoys - migrateOne refuses with ambiguous_legacy_store (3 drawers, no canonical match) and leaves all sources untouched - migrateOne proceeds when canonical path resolves - size_mismatch refuses tiny candidate when messageCount=6000 - size_mismatch passes when candidate blob count meets the floor - size sanity skipped on messageCount=0, missing dep, throwing dep, boundary (messageCount=100) - countLegacyStoreBlobs returns counts / null on bad path And in `hub/src/sync/syncEngineAutoMigrate.test.ts`: - cursorMigrationState promoted to 'ambiguous' on ambiguous_legacy_store / size_mismatch refusals. Schema: - `shared/src/schemas.ts`: cursorMigrationState enum gains 'ambiguous'. - `shared/src/apiTypes.ts`: CursorMigrateRefusalReason gains 'ambiguous_legacy_store' + 'size_mismatch'. Real-world repro (operator's tooling session, 2026-06-09): three legacy drawers contained one cursor session id - one with the real 21k-blob history, two with stale 19/568-blob diagnostic snapshots. Migrator silently transplanted the 568-blob alien content; resurrected session had no memory of prior history. Manual rescue completed; this fix prevents recurrence and surfaces the ambiguity to the operator instead. * fix(cursor): address cold review on migrator path-priority fix Self-review against the cold-PR rubric surfaces four polish items on the previous commit; all four addressed in-loop before push. - Major: `migrator:transplanted` candidate count was captured AFTER the source rm, so for the dominant single-candidate happy path the log reported `candidateCount=0, sourceRemoved=true`. Useless for diagnosing a future regression of the bug shape this PR is fixing. Snapshot candidates + source-side size + source-side blob count BEFORE any destructive step and use those for the log. - Minor: `sourceBytes` and `sourceBlobCount` were read from the destination path (acpSessionDir/store.db). The cp guarantees they match, but the field names imply source-side measurement. Now they measure the source directly. - Minor: `setCursorMigrationStateAmbiguous` silently returned false on cache miss / repeated version mismatch / write failure, letting the finally{} block clear the banner without any log. Now emits a warn-level log so the gap is diagnosable from journalctl. - Minor: `findLegacyChatStore` is exported public API and used as a free function in unit tests. An out-of-band caller bypassing preflightSession could pass `..` or `/etc/passwd` and have the inner `join(chatsRoot, wsh, id, 'store.db')` resolve to an arbitrary on- disk path. The probe is read-only `statSync` so blast radius is small, but enforce the same CURSOR_SESSION_ID_RE at the function boundary as a defence-in-depth. New unit test locks the behaviour. Hub test suite: 414 pass, 0 fail. Typecheck clean across cli/web/hub. * fix(cursor): cold-review polish on migrator path-priority (tiann/hapi#873) - Web `CursorMigrationBanner` now renders a "Manual review needed" state for `cursorMigrationState === 'ambiguous'` (Major #1: caller was promoting the metadata flag but no UI surfaced it). - Pin the md5-fixture contract for `workspaceHashFromPath`: raw, no-normalization, trailing-slash-distinct hashes computed via `printf '%s' <path> | md5sum` (Major #2: prevents algorithm drift that would silently revert path-priority discovery to fallback). - Snapshot full candidate set BEFORE the canonical fast-path resolves a single drawer so the `migrator:transplanted` log reports the decision-time count, not a post-rm undercount (Minor #1). - Warn log when canonical-path drawer is missing but readdir hands back exactly one candidate - regression-equivalent behaviour, but the size mismatch warrants a journalctl trail (path-normalization corner case the maintainer can grep for). - Boundary test: `messageCount = 101` (first value above the skip threshold) engages the size sanity check, pinning the cutoff contract (Nit). - Schema docstring on `cursorMigrationState` enum spelling out the banner contract per value (Nit). - syncEngine `getHapiMessageCount` warn-logs `countMessages` throws instead of silently downgrading to 0 (would chronically disable the floor). Drafted with claude-4.6-sonnet-thinking via Cursor; reviewed and tested by the operator. tiann/hapi#873. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): correct log-search strings in ambiguous banner copy The en/zh-CN locale strings told users to grep for 'migrator:ambiguous_legacy_store' and 'migrator:size_mismatch' but the hub emits '[migrator] ambiguous legacy store; refusing transplant' and '[migrator] size sanity check refused transplant'. Fix both locale files to quote the actual log prefix so the journalctl grep the operator is directed to actually hits. Addresses tiann/hapi#877 bot finding (Minor). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): address #877 bot Minor findings (trim + boundary guard) - Remove .trim() from canonical path before hashing: Cursor hashes raw workspace-path bytes; trimming a POSIX path with leading/ trailing spaces would hash to the wrong drawer, causing a false canonical miss and potential ambiguity refusal. - Add CURSOR_SESSION_ID_RE guard to listLegacyChatStoreCandidates: the function was exported without the same traversal-ID boundary check present in findLegacyChatStore. A future direct caller bypassing findLegacyChatStore could stat paths outside the intended <wsh>/<cursorSessionId>/store.db shape. - Move CURSOR_SESSION_ID_RE declaration above both functions that reference it so there is no temporal-dead-zone hazard. Addresses tiann/hapi#877 bot review Minor findings. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
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/distor 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 orCLI_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_TOKENbase 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_URLto the HTTPS endpoint. - If the web app is hosted on a different origin, set
CORS_ORIGINS(orHAPI_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):
- Build and deploy
web/distfrom the repo root. - Set
CORS_ORIGINS(orHAPI_PUBLIC_URL) to the static host origin. - 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.