* feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback (#893) Promotes scratchlist persistence from per-device localStorage to a hub- backed typed table so entries follow the operator across devices. v1 panel UI / FUE / shortcut / styling are deliberately unchanged - this is a backend + sync-layer feature. Hub side - New `session_scratchlist` typed table (sessionId, entryId, text, createdAt, updatedAt) with composite PK and FK ON DELETE CASCADE from sessions. Schema bumped V9 -> V10; idempotent migration added to the legacy + step ladders. - REST CRUD under `/api/sessions/:id/scratchlist[/:entryId]`, all routed through the existing `requireSessionFromParam` guard so namespace / ownership enforcement is identical to other session-scoped routes. - Per-session 200-entry cap enforced on POST. Duplicate entryId reported idempotently (200) so the migration retry path is safe. - `SessionPatchSchema` extended with `scratchlistUpdatedAt?: number`; every successful mutation emits a `session-updated` SSE patch with the token. (Following operator's piggyback decision; aligns with the parallel #884 patch-shape extension.) Web side - Hub becomes source of truth via TanStack Query (`queryKeys.scratchlist(sessionId)`); localStorage demoted to offline cache. Add / delete / update mutations are optimistic with rollback on error. - Silent first-load migration: existing localStorage entries are pushed to the hub preserving id + createdAt, and a one-time banner (mirroring `CursorMigrationBanner`) tells the operator their notes are now in the hub. Banner dismissal is per-session and persistent. - SSE handler queues a `scratchlist` invalidation when the patch carries `scratchlistUpdatedAt`, so cross-device + cross-tab updates land within an SSE round-trip. - Delete-session confirm copy now includes a count of scratchlist entries that will be cascade-deleted. Out of scope (separate tracking issue #894): "delete with summarize-and- migrate" UX flow. Tests - Hub: V9->V10 migration (fresh + multi-hop legacy + idempotent reopen + cascade-delete), `ScratchlistStore` CRUD + ordering, REST routes (happy path + 400/403/404/409), SyncEngine SSE emission. - Web: hook covers initial fetch, optimistic add/delete/update with rollback, localStorage migration + banner, cap enforcement, local-only reorder. Banner component renders only on `'completed'`. - Existing Playwright e2e (10 tests, panel UI regression) all pass unchanged. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): address HAPI Bot Major findings on PR #896 Two real data-correctness paths the bot caught on the initial review. 1. Migration partial-failure data loss The migration loop swallowed each failed POST and still wrote the `migrated` flag, while the offline-cache effect mirrored the (partial) hub state back into `hapi.scratchlist.v1.<sessionId>` - so a transient error or cap rejection could leave entries neither on the hub nor in localStorage. Fix: - Track failed entries during migration and persist them back to localStorage; do NOT advance the flag if any entry failed, so a future mount retries. - Gate the offline-cache effect on the migration flag. Pre- migration, localStorage holds the v1 entries the migration reads; mirroring an empty hub fetch over them was the wipe. - Drop the "skip migration when hub is non-empty" gate. Combined with the duplicate-idempotent POST short-circuit (below), a retry against a session that another device already populated is a safe union. 2. Duplicate POST returned 409 at cap The route checked `count >= SCRATCHLIST_MAX_ENTRIES` BEFORE asking the store whether the supplied `entryId` already existed, so an idempotent migration retry against a 200-row session returned 409 instead of 200. Fix: check duplicate first via a new `SyncEngine.getScratchlistEntry`, return the existing row with 200, and only run the cap check for genuinely new ids. Tests added: - hub/routes: at-cap + duplicate entryId returns 200 (not 409); at-cap + new entryId still 409. - web/hook: partial-failure persists the failed entries back to localStorage and leaves the flag unset; offline-cache effect does not wipe pre-migration localStorage. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web/scratchlist): per-entry age indicator (clock icon + tooltip) Surfaces the smart-relative time the entry was last saved on every scratchlist row, mirroring the bucketing used in the session list: just-now -> Nm -> Nh -> Nd -> absolute date. Implementation: - Extract the existing `formatRelativeTime` helper out of SessionList into `web/src/lib/relative-time.ts` so the panel can reuse the same buckets and i18n keys (no copy-paste drift between surfaces). Also add `formatAbsoluteDateTime` for the precise-stamp tooltip line. - Add `updatedAt?: number` to the local `ScratchlistEntry` shape. v1-only callers stay valid (the field is optional and `isEntry` now accepts rows that omit it). The hub hook forwards the hub's `updatedAt` so the indicator reflects edits, not just creation. - New `EntryAgeIndicator` component: clock SVG in the same style as the existing action icons, rendered inside both panel surfaces (the older `ScratchlistList` and the drawer variant). Falls back to `createdAt` when `updatedAt` is missing (legacy v1 rows during the migration window) and renders nothing if neither timestamp is usable. - Tooltip carries the relative bucket plus the absolute timestamp on a second line; aria-label carries the relative bucket only so screen readers stay terse. - Mirror `updatedAt` into the localStorage offline cache so an offline reload still has accurate ages. Tests: - `relative-time.test.ts`: bucket math, seconds-vs-ms detection, non-finite guard. - `ScratchlistPanel.test.tsx`: indicator renders with the right smart-relative bucket, falls back to `createdAt` when `updatedAt` is absent, and renders nothing when both timestamps are zero. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): bound client-supplied entryId length (HAPI Bot, PR #896) The POST /api/sessions/:id/scratchlist body validator left `entryId` unbounded (`z.string().min(1)`), but that string is persisted as part of the SQLite primary key. An authenticated/direct client could grow the table and its index well beyond the intended scratchlist limits by submitting oversized keys. Adds `SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128` (comfortably fits a UUID's 36 chars plus any prefix scheme we might layer on later) and applies `.max(...)` to the optional `entryId` in `ScratchlistEntryCreateRequestSchema`. Anything longer is rejected with 400 before the row hits SQLite. Test pins the new behavior: a 129-char id returns 400 and never reaches the engine. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): banner state machine - 'completed' is sticky until dismissed (HAPI Bot, PR #896) The previous state machine swallowed the migration banner if the operator reloaded the page before clicking dismiss: the migration flag was set on success, and on remount the init logic mapped a flag-set/dismiss-not-set session to 'pre-migrated', a state the banner explicitly refuses to render. Net effect: a migrated session never prompted for affirmative dismissal. Fixes: - Drop the 'pre-migrated' state. The dismissal flag is now the only signal that suppresses the banner; the migration flag alone means 'banner shows until dismissed' (now or after a reload). - Sessions that had nothing to migrate (no v1 entries in localStorage) pre-emptively write BOTH flags - migrated AND dismissed - so the bot's banner-stickiness fix doesn't surface a banner that has nothing to announce on freshly-created v2 sessions. Tests: - New `reload-before-dismiss leaves the banner visible` test pins the fix end-to-end: mount #1 migrates -> 'completed', unmount, mount #2 on the same session reads the localStorage flags and stays 'completed'. - New `opts fresh sessions out of the banner pre-emptively` test pins the no-v1-entries shortcut. - Existing `does not re-migrate on a mount where the migrated flag is already set` updated to assert 'completed' (not the dropped 'pre-migrated'). - Existing `skips migration when localStorage is empty` updated to assert the new 'dismissed' status + the banner-dismissed flag. - Banner test for the 'pre-migrated -> nothing' case removed (the state no longer exists). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): transfer rows during session merge so cascade-delete does not strand them (closes #920 for v2.0) `mergeSessionData` in `sessionCache.ts` ends every merge codepath with `deleteSession(oldSessionId)`, which fires `ON DELETE CASCADE` on every FK-tied table. `session_scratchlist.session_id` is FK'd with cascade, so without an explicit transfer step every dedup (#448 agent-id collision) and every resume-of-inactive (`syncEngine.resumeSession` -> mergeSessions) silently destroys the operator's per-session notes. This is the gap upstream-discovery agent flagged on #920 against PR #896. With the 2026-06-15 hub-restart cascade incident as evidence (23 sessions auto-archived in a single bounce, 4 confirmed HAPI-id rotations across 2 bounces), unmitigated this would violate v2.0's "survives reloads / second laptop / clear-site-data" promise the first time the operator hits a hub bounce. Fix: - New `transferScratchlistEntries(db, fromSessionId, toSessionId)` in `hub/src/store/scratchlist.ts`. Atomic via BEGIN/COMMIT. Uses `UPDATE OR IGNORE` so rows that would collide on PRIMARY KEY (session_id, entry_id) simply do not move - the dedup target's copy wins, matching the operator's mental model that the consolidated session is authoritative. Cleans up any collision-loser rows so the no-delete codepath (`mergeSessionHistory`) is symmetric with the delete path. - Wired into `mergeSessionData` BEFORE the `deleteSession()` call, alongside the existing message-merge step. Both `mergeSessions` (deleteOld=true) and `mergeSessionHistory` (deleteOld=false) get coverage because both can rotate the visible session id. - Emits `session-updated{scratchlistUpdatedAt}` on the new session so any web client looking at the consolidated id invalidates and refetches; for the keep-old codepath the emit also fires on the old id since it stays alive but is now empty of scratchlist. Tests (`sessionCache-merge-scratchlist.test.ts`, 7 cases): - mergeSessions (deleteOld=true): rows move, old is gone, no stranded rows. - mergeSessions PK collision: dedup target wins, unique-to-old rows still come across. - mergeSessions SSE: exactly one scratchlist patch on the new id. - mergeSessions no-op: zero rows -> zero emits. - mergeSessionHistory (deleteOld=false): rows move, old session stays alive but empty of scratchlist. - mergeSessionHistory SSE: emits on BOTH old and new ids. - Cascade-delete safety smoke: post-merge, an explicit operator delete of the new session DOES cascade-delete its scratchlist (i.e. the FK cascade we want is intact; the bug was triggering it on the wrong id). Web layer note: v1 localStorage is keyed by HAPI session id; on rotation the old key is orphaned but no longer represents data loss because the hub now holds the canonical state and the offline-cache mirror re-populates `hapi.scratchlist.v1.<newId>` on first read of the consolidated session. Documented as a known limitation; not a blocker for v2.0 because the hub is the source of truth. #894 (v2.1 migrate-on-delete) inherits a related concern about operator-Delete vs merge-Delete consent flow - flagged in the upstream-discovery handoff, separate scope. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): rebase onto upstream/main - scratchlist migration is V10→V11 Upstream landed V9→V10 as sessions.service_tier (#898/#904). Scratchlist v2 moves to V10→V11 so both migrations coexist without clobbering each other. - mergeSessionData conflict resolved: keep upstream migrateFromV9ToV10 (service_tier) and add migrateFromV10ToV11 (session_scratchlist) - SCHEMA_VERSION bumped 10 → 11 - Rename migration-v10.test.ts → migration-v11.test.ts with updated multi-hop coverage (V9→V10→V11) - Add serviceTier: null to scratchlist route test session fixture (required by upstream Session type after #898) Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): emit SSE on all-collision merge when old session stays alive (HAPI Bot, PR #896) When mergeSessionHistory deletes every old scratchlist row via PK collision (moved=0, collided>0) the still-alive old session kept showing stale cached entries until an unrelated refetch. Emit scratchlistUpdatedAt on the old id whenever collided>0 on the keep-old codepath, not only when moved>0. New-session emit stays gated on moved>0 since the target row is unchanged on full collision. Test pins the all-collision mergeSessionHistory case. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): stabilize migration queryKey to stop POST retry loop (HAPI Bot, PR #896) useMemo on queryKeys.scratchlist(sessionId) so the migration effect does not re-fire every render after a failed POST clears migrationAttemptedRef. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): dedupe optimistic add when SSE refetch wins race (HAPI Bot, PR #896) onSuccess now drops both the temporary optimistic id and any existing row with the canonical entryId so a fast SSE invalidation cannot leave twins. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): treat 404 on update/delete as stale cache, not rollback (HAPI Bot, PR #896) When another client already removed an entry, keep it gone locally and invalidate instead of restoring previousData from optimistic rollback. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(scratchlist): drop optimistic add ghost when previousData missing (HAPI Bot, PR #896) onError now filters by optimisticEntryId if the initial fetch never populated cache, so a rejected POST cannot leave an unsaved note. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
HAPI
Run official Claude Code / Codex / Cursor Agent / Grok Build / OpenCode sessions locally and control them remotely through a Web / PWA / Telegram Mini App.
Why HAPI? HAPI is a local-first alternative to Happy. See Why Not Happy? for the key differences.
Features
- Seamless Handoff - Work locally, switch to remote when needed, switch back anytime. No context loss, no session restart.
- Native First - HAPI wraps your AI agent instead of replacing it. Same terminal, same experience, same muscle memory.
- AFK Without Stopping - Step away from your desk? Approve AI requests from your phone with one tap.
- Your AI, Your Choice - Claude Code, Codex, Cursor Agent, Grok Build, OpenCode—different agents, one unified workflow.
- Terminal Anywhere - Run commands from your phone or browser, directly connected to the working machine.
- Voice Control - Talk to your AI agent hands-free using the built-in voice assistant.
- Workspace Browser - Opt-in via one or more
hapi runner start --workspace-root <path>flags: browse scoped file trees from the web and start sessions in allowed subdirectories.
Demo
https://github.com/user-attachments/assets/38230353-94c6-4dbe-9c29-b2a2cc457546
Getting Started
npx @twsxtd/hapi hub --relay # start hub with E2E encrypted relay
npx @twsxtd/hapi # run claude code
hapi server remains supported as an alias.
The terminal will display a URL and QR code. Scan the QR code with your phone or open the URL to access.
The relay uses WireGuard + TLS for end-to-end encryption. Your data is encrypted from your device to your machine.
For self-hosted options (Cloudflare Tunnel, Tailscale), see Installation
Docs
Build from source
bun install
bun run build:single-exe
Credits
HAPI means "哈皮" a Chinese transliteration of Happy. Great credit to the original project.