* fix(cli): surface real Cursor ACP session/load errors
Stop mislabeling every ACP session/load failure as a legacy
stream-json protocol problem. Prefer Cursor's Cannot use this model
stderr (including Available models when present), attach drained
stderr on process close, and keep structured formatAcpLoadError logs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): accumulate ACP stderr across split chunks
Address Codex Major on #1198: child_process stderr data events are not
message boundaries. Concatenate raw chunks in a rolling window, extract
Cannot use this model from the window on close, and prefer that over a
partial onStderrError hint when classifying resume failures.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): pin ACP model-rejection stderr when catalog overflows
Once Cannot use this model appears, keep the buffer from that match
head so a long Available models list cannot roll the rejection out of
the rolling window (Codex follow-up on #1198).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): wait for model id before ACP model-rejection emit
Only emit Cannot use this model via onStderrError once a non-space
token follows the colon, so a split before the id cannot suppress the
completed rolling-window message (Codex Minor on #1198).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): block ACP writes between process exit and close
Keep the post-exit stdin write guard while deferring markClosed until
stdio close so stderr can still enrich the failure (Codex Minor on #1198).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(hub,shared): scratchlist v2.2 hub attachment storage foundation (#921)
Hub stores scratchlist attachment bytes on filesystem; SQLite holds
AttachmentMetadata[] JSON via session_scratchlist.attachments (v11→v12).
Upstream ladder: v10→v11 text-only scratchlist table (#896), v11→v12
attachments column. Configurable limits via HAPI_SCRATCHLIST_* env vars.
Upload, serve, and limits REST routes; delete entry cleans hub files.
Web promote/rehydrate still TODO. Soup renumber branch follows.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): scratchlist v2.2 attachment UX (#921)
Route scratchlist-mode composer submits with attachments to hub storage,
show image thumbnails in the drawer, and rehydrate attachments on promote
to composer or queue (hub fetch → CLI upload for send).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): scratchlist attach submit, float thumbs, copy tooltip (#921)
Hub upload adapter now sets path on ready attachments so the composer send
button unlocks in scratchlist mode; routing label matches attachments too.
Entry thumbnails float left with text wrap; copy tooltip clarifies text-only.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): adapt scratchlist update tests to patch API (#921)
update() now takes { text?, attachments? }; v12 CRUD tests still passed a string.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,web): harden scratchlist attachment ownership and orphan cleanup
Resolve claimed hub paths against the current session before persist,
count on-disk session bytes for upload caps, delete blobs dropped on
entry update, and DELETE pending uploads when composer remove runs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: drop accidental .cursor files from attachment PR
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): exit scratchlist mode before rehydrate; delete raced uploads
Promote-to-composer flushes mode exit so attachments use the chat adapter.
Cancel-during-upload deletes the hub blob once upload returns.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,web): exact UUID delete match; stage hub paths on chat send
Reject partial attachment ids on disk delete, and restage scratchlist hub
attachments through uploadFile when sending after leaving scratchlist mode.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): skip text-only PUT resolve; cleanup session attachment dirs
Text-only edits keep existing attachment metadata after session-id transfer.
Require full UUID on resolve. Delete scratchlist attachment files when a
session is deleted.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,web): scratchlist attach route, PUT bytes, orphan deletes
Park only hub-resident attachments; subtract removed blobs from the PUT
session cap; delete attachment files only when no other entry still
references them.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): canonicalize scratchlist attachment filenames
Resolve stores the on-disk sanitized name (not claimed.filename) and
hardens Content-Disposition against CR/LF/quote injection.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(hub): cover toxic filename canonicalize on resolve
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,web): serialize scratchlist uploads; drop hub blobs after chat stage
Per-session upload lock keeps disk byte caps honest under concurrency.
After a successful toggle-off chat send, delete the staged hub copies so
they no longer count against the session attachment budget.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(shared,web): allow clearing scratchlist attachments; cleanup staged uploads
PUT may send attachments:[] without a text change. Staging to chat rolls
back partial normal-upload copies on failure.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): re-key scratchlist attachment files on session merge
Move hub blobs when scratchlist rows transfer between session ids so
quota and path ownership stay correct. Reject PUT that would leave an
empty textless entry after clearing attachments.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): reuse restored scratchlist hub attachments without re-upload
Composer draft remount was re-uploading blobs that already had a
hapi-hub:scratchlist path, orphaning the originals against session quota.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Radix Popover crashed with "Invalid hook call" because
@radix-ui/react-popover is not linked into web/node_modules and
resolves react from the repo root — a different instance than the
one app code imports. Two React copies make every hook-using third
party component throw on render and unmount the whole tree.
The VitePWA dev service worker also pulls its workbox imports only
after registration, so Vite re-optimizes deps and force-reloads the
page mid-run, which nondeterministically kills whichever e2e test is
in flight.
Machines are labelled by hostname with no way to give them a friendlier
name. `MachineMetadataSchema` has declared `displayName` all along and the
whole read path already honours it (`displayName → host → id`), but nothing
could ever write it: the CLI never sends the field, the hub exposed no route
that sets it, and the web UI had no editor.
Add the missing write path:
- `PATCH /api/machines/:id` with `{ displayName }`, guarded by the existing
`requireMachine`. An empty value removes the key so the label falls back to
the hostname; the empty string is never stored.
- `machineCache.renameMachine` merges that one key into the stored metadata
and lets `refreshMachine` publish `machine-updated`, which `useSSE` already
invalidates on — so every connected client relabels without new plumbing.
- A `/settings/machines` page listing online machines with inline rename,
placed between Voice and About so the existing preference pages keep their
order. Each row keeps the hostname visible, so a renamed machine is still
identifiable.
The merge reads the raw stored metadata rather than the cached `Machine`
view. That view is narrowed by `MachineMetadataSchema`, which strips unknown
keys and yields `null` for a row that fails validation — reachable, since the
CLI's `machine-update-metadata` handler accepts `z.unknown()`. Merging
against it would have written those fields out of existence.
The row's save is guarded by a ref rather than `isPending`: disabling the
focused input forces a blur, so Enter otherwise reaches `save` twice and
fires two PATCHes, the second of which can lose the version race and report
a failure for a rename that succeeded.
`mergeMachineMetadata` already preserves hub-side fields on CLI
re-registration, so a reconnect does not clobber the name.
Closes#1210
* feat(web): @ autocomplete to cite other sessions by title
Composer @ ranks fleet session titles (and id prefixes), inserting the
same Copy-reference citation grammar. Codex file @ mentions remain and
follow session hits. Bare /sessions/<id> paths autolink for in-app nav.
Closestiann/hapi#1213.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): show session @ picks as composer chips
Selecting a session from @ autocomplete adds an attachment-style chip
(flavor + title + active dot) instead of expanding prose in the textarea.
On send, chips serialize to markdown session links that render as chips
in the transcript.
Part of tiann/hapi#1213 dogfood.
Co-authored-by: Cursor <cursoragent@cursor.com>
* revert(web): v1 session @ mentions use plain-text expansion
Attachment-style composer chips cannot express positional
"this → session A / that → session B" intent. Keep @ autocomplete
and Copy-reference text insert for v1; rich segmented composer is v2.
Part of tiann/hapi#1213.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): do not autolink source paths under sessions/
Reject dotted session-id tails and stop treating `.ext` as a soft path
end so citations like `routes/sessions/chat.tsx` stay file-path links.
Part of tiann/hapi#1213.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): keep Vite BASE_URL on session citation anchors
SessionPathAnchor href now uses buildSessionReferencePath so copy /
open-in-new-tab stay in the PWA subpath scope.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): @ session mention search uses sessionMatchesQuery
Reuse the share/sidebar matcher (name + summary + path + id + machine)
while still labeling and inserting getSessionTitle (name before summary).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Claude Code injects its own user-role turns for skill bodies and compact
continuation summaries. The on-disk transcript flags them `isMeta`, which
claudeLocalLauncher drops before they ever reach the web UI. Over
stream-json the same event is flagged `isSynthetic` instead, and
sdkToLogConverter copied only `message`, dropping the flag entirely.
With no `isMeta` on the converted line, every downstream guard let it
through: OutgoingMessageQueue forwarded it, isExternalUserMessage
classified it as genuine human input (its XML-prefix allowlist does not
match a bare-markdown skill body), and the web UI rendered the full skill
document as a user bubble.
Normalize `isSynthetic` to `isMeta` in the converter so the SDK path
carries the same signal as the transcript path and the existing filters
fire. Fixes skill injections appearing as user messages in remote mode.
Loading an older page prepends hundreds of messages in one flush. tap's
scheduler aborts after 50 dirty resources and drops the overflow, so the
thread never applied the merged page: the scroll-restore gate never
passed and the top sentinel kept re-triggering (loads everything at
once). Raise MAX_FLUSH_LIMIT 50->2000 via bun patchedDependencies.
Adds a Playwright regression spec driving the real message-window store
and HappyThread against a fake paginated API: one page per top
approach, scroll restored, no idle reloads.
Remove the redundant "Load older" button; the existing top sentinel
already auto-loads older pages when approaching the top. Loading state
moves to a floating pill overlay so prepends no longer shift layout.
* fix(cli): restore Pi session resume
Use Pi's supported --session flag and keep the session initialized by the CLI instead of replacing it with a racing new_session RPC.
* test(cli): cover fresh Pi startup
* 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>
* fix(web): expose Codex Fast and Plan on Create Session
Wire serviceTier and collaborationMode through spawn so Create can set
the same Codex options chat Settings already supports (#1015).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): forward collaborationMode through machine spawn RPC
Create Session Plan was accepted by the hub but dropped in apiMachine
before buildCliArgs; also preserve collaborationMode on resume spawn.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): correct stopSession mock type in spawn RPC test
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): keep Fast mode across Create draft restore while models load
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): preserve pending Fast selection
* fix: apply Fast and Plan to imported Codex sessions
* test: narrow imported Codex session id
* fix: forward explicit Standard service tier
* fix: integrate create-session controls with current main
* test: close Codex RPC suite
* fix: preserve existing session spawn field
* fix(web): integrate Codex controls with current New Session form
* fix(web): reconcile draft types and submit state
* fix(hub): integrate spawn arguments with current resume flow
* test(cli): isolate spawn RPC suite
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(cli): add ping-peer CLI and MCP ping_peer for peer messaging
Promote resume-if-inactive + wait-active + POST message into a first-class
CLI command and session MCP tool so agents stop reinventing JWT+curl.
Fixes#1194
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): do not auto-approve MCP ping_peer
Cross-session messaging can resume a peer and inject a prompt, so keep
permission-mode gating (Codex PR review Major on #1195).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): require approval for ping_peer in read-only mode
Read-only auto-approve treated non-write names as safe; ping_peer can still
resume a peer and inject prompts, so gate it like a write tool.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): keep ping_peer out of Claude --allowedTools
toolNames still registers the MCP tool, but Claude auto-allow must not
pre-approve cross-session resume+inject without a permission prompt.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): re-check session active before ping-peer send
List/get can race; POST /messages still 409s if the target flips inactive
before send. Resume+wait again (and re-gate pi) immediately before POST.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
- Raise message page size 50 -> 200 (hub max) so cold loads and load-older
show more than a handful of rendered bubbles
- Flush pending messages when the thread forces scroll-to-bottom on mount,
and re-read atBottom after the latest fetch instead of using the stale
pre-fetch snapshot, so new messages no longer stay invisible after
leaving and re-entering a session
- Base the cold-load backfill floor on rendered conversation identities:
skip non-rendering rows (token-count/ready events, un-normalizable
content) and collapse tool call/result pairs into one card identity
* feat(hub): native companion (FCM) push channel + device registry
Adds opt-in FCM HTTP v1 notification delivery so a companion mobile/wearable
app can receive permission, ready, and task notifications end-to-end. The
channel is gated entirely on FCM_SERVICE_ACCOUNT_PATH + FCM_PROJECT_ID being
set; operators not running a companion see zero behavior change.
What lands:
- POST/DELETE /api/devices/register — JWT-authed FCM token registry,
upsert on (namespace, deviceId, platform), platforms `phone` | `wear`.
- Sqlite v9 → v10 migration adds `fcm_devices` (idx on namespace + token).
- FcmService — minimal HTTP v1 client, RS256 service-account JWT via
jose (dep already in tree), 5-minute access-token cache, 401 retry.
- FcmNotificationChannel — implements NotificationChannel, sends data-only
FCM (so companion can route to phone+watch surfaces). Body composition
parses an optional trailing `AGENT_NOTIFY_SUMMARY {json}` line for richer
ready summaries; truncates plain assistant text to 280 chars otherwise.
Tags each payload with `severity` (info/warning/success/error) so clients
can color/categorise the notification.
- PushNotificationChannel gains a NativeFallbackProbe — when a namespace
has at least one registered FCM device, web-push and SSE in-page toast
are skipped so the operator does not double-notify on phone+browser.
Probe is no-op when no FCM device is registered; PWA-only setups
unchanged. Branch trace gated on HAPI_NOTIFY_DEBUG=1.
- shared/src/messages.ts — `extractAssistantPlainText` (codex + Claude SDK
shapes) and `extractNotifySummary` (strict end-anchored line parser).
- hub/src/notifications/toolArgs.ts — tool-arg formatters lifted out of
telegram/sessionView (kept duplicated there in this PR; refactor of
Telegram is a follow-up).
- docs/api/native-companion-contract.md — payload + endpoints + env vars,
versioned at contract v1.
Test coverage:
- 260 hub tests pass (incl. 23 new across FCM channel, push dedup,
v10 migration, devices route).
- 60 shared tests pass (messages parsers).
Notes for reviewers:
- Reference companion implementation lives in a separate Android repo
(Kotlin, phone APK + Wear OS APK) — this PR is hub-side only.
- No new runtime deps (`jose` and `zod` already declared in hub).
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(contract): clarify scope - companion is remote-hub client, not hub-on-phone
Adds a Scope section to the native-companion contract so anyone
implementing it knows the audience: operators running the hub on a
server who want phone/watch as a notification surface, not users
expecting a Termux-bundled hub. Mirrors the framing now in
heavygee/hapi-companion README.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(contract): correct Scope section - hub topology is unchanged
Removes the prior framing that referenced a non-existent 'Termux
hub-on-phone' alternative. This contract describes a native client to
the same hub the PWA talks to; it does not change where the hub runs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): companion app pairing QR in Settings
Companion section in Settings renders a QR code encoding the deeplink
hapicompanion://bind?hub=<base>&code=<token>. Scanning it from the HAPI
companion app (Android phone or Wear OS) auto-fills the bind form and
authenticates against this hub - no manual URL/token paste.
QR is gated behind a Show button so the access token doesn't sit visible
on screen by default; a Copy link affordance and the textual deeplink
are also exposed for manual onboarding.
Adds qrcode + @types/qrcode to web/ (already a hub dep, no new resolved
package - just a workspace declaration).
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(hub): terminal QR for companion app pairing alongside PWA QR
After the existing PWA access QR is rendered on tunnel start, also print
the hapicompanion://bind?hub=...&code=... deeplink and a matching QR.
Same tunnel + token, different scheme: phones with the companion app
installed pick up the deeplink via the manifest intent filter; phones
without it ignore it and fall back to the PWA QR above.
QR rendering failure is non-fatal in both cases - the textual deeplink
above the QR is sufficient for manual paste.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(fcm): address HAPI Bot review on PR #803
Two bugs surfaced by the upstream review bot:
1) Web Push silently dropped when FCM is not actually configured.
The native-fallback probe only checked the device registry; it did
not check whether resolveFcmConfig() actually succeeded. So an
operator who previously enabled FCM, registered a phone, then later
started the hub WITHOUT FCM_SERVICE_ACCOUNT_PATH would see the probe
return true (devices still in DB) -> Web Push suppressed -> no FCM
channel registered -> notifications go to /dev/null.
Fix: extracted the probe construction into buildNativeFallbackProbe()
which short-circuits to () => false when fcmConfig is missing. Probe
never even consults the device store in the no-config branch, so
stale rows can never matter.
2) Transient FCM failures permanently unregistered devices.
sendToToken() returned a single boolean and sendToNamespace() removed
any device whose send returned false. A 429 (rate limit), 503
(server error), 401 (auth glitch), or even an ECONNREFUSED would
delete the device row, after which the user would need to re-pair to
get notifications again. The bot caught it; the fix is the obvious
one.
Fix: sendToToken() now returns 'sent' | 'invalid' | 'failed'.
- 'invalid' is reserved for the responses that genuinely indicate a
dead token: HTTP 404 with UNREGISTERED/NOT_FOUND, and HTTP 400
with INVALID_ARGUMENT explicitly referencing the token field.
- Everything else (429, 5xx, 401, 403, network errors) is 'failed'
and counts toward the failed tally without removing the device.
sendToNamespace() only calls removeDeviceByToken() on 'invalid'.
Tests: 11 new tests across two new files. fcmService.test.ts covers
all six branches (200, 404 unregistered, 429, 503, 401, network error)
plus a mixed-batch case that proves invalid tokens get removed in the
same call where transient-failure tokens survive. nativeFallbackProbe
.test.ts covers both no-config and configured branches plus the
explicit "no-config never touches the store" guarantee.
Hub test count: 273 -> 284 (all passing).
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(contract): correct FCM visibility rule and remove unsupported event type
HAPI Bot review on PR #803 caught two contract-doc accuracy gaps:
1) Visibility rule was wrong. Doc said "FCM fires when Web Push would
fire AND client not visible via SSE", but FcmNotificationChannel
ALWAYS fires regardless of PWA visibility (deliberately - native
companion is the canonical wrist-first surface, and there is a
passing test asserting this). Companion app implementers reading
the contract would have built foreground-suppression logic and
then dropped notifications when the PWA tab was open.
2) Documented `session-completed` event doesn't exist. NotificationHub
never calls into a 'session-completed' channel method on
FcmNotificationChannel; the type would never reach a native client.
Removed from the documented enum, leaving only the three actual
events: ready, permission-request, task-notification.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(contract): drop trailing whitespace, use blank line for paragraph break
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): persist CLI access token after Telegram bind so pairing QR works
The Settings -> Companion pairing QR reads the original CLI access token
from localStorage (hapi_access_token::<baseUrl>) so it can be encoded into
the hapicompanion://bind deeplink. For browser/CLI logins useAuthSource
already persists the token via setAccessToken, but the Telegram Mini App
bind path went through useAuth.bind() which exchanged the typed CLI token
for a JWT and never persisted it. Telegram users therefore always saw the
"signed in via Telegram..." fallback and got no usable QR.
After a successful client.bind() we now mirror useAuthSource's behavior
and write the same accessToken to the same localStorage key, restoring
parity between the two auth paths. No change for browser/CLI users.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(fcm): gate native-fallback probe on rolling FCM health
The native-fallback probe previously returned true whenever FCM was
configured AND devices were registered, which suppressed web-push for
the namespace. The HAPI Bot correctly pointed out the gap: if the FCM
pipeline silently breaks (expired service-account key, sustained 5xx,
OAuth token-fetch failure, network blackhole) the operator gets nothing
on either channel until they manually intervene.
Approach (deliberate, not the bot's exact suggested fix):
- FcmService now keeps a small rolling window (last 8 outcomes) of send
attempts and exposes `isHealthy()`. The threshold is 5+/8 failures =
unhealthy; the buffer starts empty so a freshly-booted hub is
optimistic ("innocent until proven guilty") and does not double-fire
on event #1.
- Token-fetch failure (`getFcmAccessToken` throws) now records exactly
one health-failure (not one per device), short-circuits the send
loop, and returns a result so `sendToNamespace` no longer leaks the
exception.
- `invalid` token responses are explicitly excluded from the health
buffer because they are per-device facts (rotated/uninstalled token),
not pipeline failures - FCM was reachable, it just rejected one
stale token.
- `buildNativeFallbackProbe` now optionally accepts the FcmService and
short-circuits to "let web-push fire" when health is bad, before it
even queries the device registry. The single-arg call shape is still
supported for back-compat.
Why not the bot's exact suggestion ("invert: call FCM first, fall back
on result.sent === 0"):
- Couples PushNotificationChannel to FcmService and FcmSendPayload,
reversing the clean parallel-channel architecture established earlier
in this PR.
- Treats every transient single-event failure as fallback-worthy, which
re-opens the duplicate-notification race that the suppression logic
was added to close (FCM HTTP timeout that delivers later + the web
push we sent in the meantime = two pings).
- A rolling health window only flips on sustained breakage, which is
the actual operational scenario the bot is worried about.
The wrist-first design intent ("FCM fires unconditionally, web-push is
suppressed for the same namespace") documented in
docs/api/native-companion-contract.md is preserved on the happy path.
The probe only re-enables web-push when there is concrete evidence the
native pipeline is not delivering.
Tests:
- New FcmService.isHealthy suite covers empty-buffer, threshold flip,
recovery as failures age out of the window, invalid-token exclusion,
and network-error path.
- nativeFallbackProbe gains coverage for the unhealthy-but-registered,
healthy-and-registered, and absent-fcmService (back-compat) cases.
- All 292 hub tests still pass; typecheck clean.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(telegram): drop duplicate tool-args formatter, use shared module
The Telegram session view had its own copy of formatToolArgumentsDetailed
identical to the one in hub/src/notifications/toolArgs.ts (already used by
the FCM channel). Replace the local copy with an import.
Removes ~70 lines of duplication, plus the now-unused MAX_TOOL_ARGS_LENGTH
constant and `truncate` import. The shared signature accepts an optional
opts arg whose default maxArgLength is 150 - matching the prior constant -
so the call site is unchanged.
Two benign upgrades come along for the ride from the shared module:
?? instead of || on field fallbacks (no real-world difference; permission
arguments never carry empty-string fields), and String(...) wrapping plus
a typeof object guard that makes non-string values render gracefully
instead of throwing into the catch block.
Hub tests: 311 pass / 0 fail. Telegram subset: 5 pass / 0 fail. typecheck
green.
Cold-reviewed by an out-of-context Claude Opus peer before push.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(fcm): require positive evidence in health window before suppressing web-push
Addresses HAPI Bot Major review on PR #803.
The previous health gate treated an empty outcome buffer as healthy
("innocent until proven guilty"). That created a silent-blackhole window
on cold start with broken FCM credentials: the push channel suppressed
SSE/Web Push for the first ~5 events while the FCM channel attempted
each delivery and recorded failures, until enough stacked to flip the
threshold. Every notification in that gap was silently lost.
New invariant: isHealthy() requires at least one successful FCM send in
the recent window (HEALTH_WINDOW=8) AND failures below threshold
(HEALTH_FAILURE_THRESHOLD=5). Both conditions are necessary; either
alone is insufficient evidence to safely suppress web-push fallback.
Trade-off: one duplicated notification per hub restart per namespace.
On the first event after restart, web-push fires alongside FCM (because
the gate has no positive evidence yet). Once FCM records that first
success, the gate engages and subsequent events are FCM-only. Worth it
for guaranteed delivery during cold-start outages.
Tests reworked to match new semantics:
- "starts UNHEALTHY with empty buffer" (was: healthy)
- "flips to healthy after first successful send" (new)
- "stays unhealthy across failures-only run" (new, exercises the exact
blackhole scenario the bot flagged)
- "flips back to unhealthy after threshold breach with prior successes"
(renamed, establishes successes first)
- "invalid tokens don't count against health" (reworked: send a mixed
batch first to establish health, then verify invalids don't flip it)
- "network errors count as failures" (reworked: establish health first)
Hub tests: 313 pass / 0 fail. typecheck green.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): bump FCM migration to V10→V11 after upstream service_tier V9→V10
Upstream/main landed sessions.service_tier at schema v10. The companion
FCM device registry now migrates at v11 so both changes compose cleanly
after the courtesy rebase onto current upstream/main.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): per-dispatch native gate instead of stale FCM probe
FCM runs before web-push; PushNotificationChannel skips web/SSE only
when the same notify() dispatch already delivered via FCM. Removes the
isHealthy()+device-row probe that could suppress web-push after warm
FCM outages.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,web): cap notifySummary for FCM limits; fix PWA test cast
Rebase follow-up: truncate AGENT_NOTIFY_SUMMARY summary/action before
FCM data payload (bot Major). Fix usePwaUpdate.test.ts setTimeout mock
cast so bun typecheck passes on current main.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): cap all FCM notifySummary fields and task bodies
Whitelist and truncate AGENT_NOTIFY_SUMMARY auxiliary fields before
JSON serialization; cap task-notification summaries to glance limit.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): FCM fetch timeouts and cap Grep/Glob permission args
10s AbortSignal.timeout on OAuth + FCM send so sequential web-push
fallback is not blocked on hung Google endpoints; truncate Grep/Glob
pattern in permission detail formatter.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): bind FCM token to one namespace on re-pair
Delete stale fcm_devices rows sharing the same token when a native
install registers under a different namespace.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): localize Companion settings and pairing copy
Add en/zh-CN keys for the Companion section title and CompanionPairing
strings; matches locale-driven Settings pattern (bot Minor on #803).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): tighten FCM token-invalid detection and truncation edge cases
Parse FCM error JSON: only UNREGISTERED or token-field INVALID_ARGUMENT
unregister devices; generic NOT_FOUND stays transient. Guard limit<=3
in truncateReadyText so tiny action budgets cannot blow the glance cap.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): parse FcmError details.errorCode for UNREGISTERED tokens
FCM v1 often returns HTTP 404 with root NOT_FOUND plus
details[].errorCode UNREGISTERED; prune those tokens while keeping
generic project/resource NOT_FOUND transient.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): mock AppContext for About Companion pairing in settings tests
Settings About now mounts CompanionPairing via useAppContext after the
#1027 hub redesign rebase; wrap the About route test with AppContext and
Companion mocks so the suite stays green.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(contract): point companion auth at POST /api/auth, not /api/bind
Pairing QR carries the CLI access token as `code`. /api/bind requires
Telegram initData; native companions must use /api/auth with accessToken.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): mount Companion pairing under Settings General
About is version/links only after the settings hub redesign; pairing is
setup, so keep Companion with language prefs and update the route tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
* refactor(web): add per-line shiki line-splitting helper
* feat(web): add global word-wrap toggle for code, markdown, and diff views
* feat(web): add a shaded gutter background behind line numbers
* fix(web): make DiffView compact rows follow the global wrap setting
* fix(web): trust live hub connectivity over navigator status
* fix(web): align offline and reconnecting banner precedence
* test(web): provide i18n context to banner tests
Hub: getOrCreateMachine now merges incoming machine-owned metadata over
the stored row (first-write-wins previously kept rows registered without
a host name nameless forever; hub-only fields like displayName survive).
Web: session-list machine labels are cached in localStorage so machines
whose row is gone or whose query has not loaded yet keep their last
known name instead of flickering to the 8-char id prefix.
useLongPress binds both touch and mouse handlers. After a tap, touch
browsers emit compatibility mouse events (~300ms later) that the page did
not preventDefault, so onClick fired twice: once from touchend, once from
the synthesized mouseup. On the wide tablet sidebar layout the list stays
under the finger, so the second onClick lands on whatever row slid into
that position and navigates to the wrong session.
preventDefault() on touchend for every handled tap, and additionally
swallow mouse events that arrive within 700ms of a touch so browsers that
still dispatch the compatibility sequence cannot re-trigger onClick.
Based on the fix by RiriAgent in the fork (commits 1bbfcc20, 5e3d135a).
Co-authored-by: RiriAgent <39219425+RiriAgent@users.noreply.github.com>
- prepare-npm-packages: exit(1) instead of warn-and-continue when a
platform binary is missing, so a broken build aborts the release
- release-all: after publishing platform packages, poll npm view for
every @twsxtd/hapi-<platform> package until it matches the release
version (10min timeout, 15s interval) before publishing the main
package
Fixes#1149
SessionChat fetched Codex models through the session-scoped endpoint,
so the CLI listed models in the session process cwd, where a missing
directory or project-level Codex config could skew or break the result.
Use the machine-scoped endpoint (already used by NewSession) and drop
the now-unused session route and RPC plumbing.
Fixes#1072
Searching the session list forced every directory group to expand all
sessions (expanded: isFiltering) and hid the Show more button, so the
user's per-group preview fold was ignored during filtering. Stop
overriding the preview state while filtering and keep the Show more /
Show less control available.
Fixes#1068
* fix(cursor): nest ACP extension outcome so plan approvals aren't cancelled
Cursor's ACP blocking extension methods (cursor/ask_question,
cursor/create_plan) expect the JSON-RPC result to nest the outcome under
an `outcome` key, e.g. `{ outcome: { outcome: "accepted" } }`. The
adapter returned it flat (`{ outcome: "accepted" }`), so Cursor read
`response.outcome.outcome` as undefined and fell back to a cancellation
— an approved plan was relayed to the agent as `User cancelled`, making
plan mode unusable over HAPI for Cursor sessions.
Wrap every ask_question / create_plan response in the nested envelope
and add regression tests asserting the exact wire shape for the
affirmative approval -> CreatePlan path (plus approved_for_session,
reject, and abort).
Fixes#79
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): continue task after CreatePlan Yes (plan→execute)
Nested ACP accept alone is not a complete fix: Yes unblocked create_plan
but the prompt turn still ended with the plan "done" and no execution.
Mirror Claude ExitPlanMode: on accept, leave plan/ask for an executable
mode and queue a continue prompt so the original user task keeps going.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): sync runCursor enqueue mode after CreatePlan accept
Codex Major on #1097: setPermissionMode alone left runCursor's
currentPermissionMode stale, so the next user message could re-enter
plan/ask after Yes. Notify onPermissionModeChanged from CursorSession
so the enqueue source of truth stays aligned with the session.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): exit scratchlist mode after successful promote-to-queue (#959)
After Send to queue accepts, call onExitScratchlistMode so the operator
can continue normal chat. Rejected sends keep mode on. Unit + Playwright
smoke coverage.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): add execStartedAt/execCompletedAt to ToolCard test mock
Upstream ChatToolCall gained exec timestamps; ToolCard.test.ts mock
was missing them and broke CI typecheck after rebase onto main.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Single-machine users no longer expand a redundant machine layer; with
multiple machines a chip filter bar (persisted, with hover health popup)
replaces the collapsible machine headers. Directory groups now render
top-level with machine-name suffixes when unfiltered. Also removes the
redundant session/project count header text.