* 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>
* feat(web): mermaid diagram lightbox on click
Click rendered mermaid blocks in chat to open a zoomable full-screen viewer.
Re-renders from source in the modal with the current theme. Closes#737.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): fit mermaid lightbox to viewport on open
Auto-scale diagrams to fill the viewer instead of opening at intrinsic
mermaid size. Reset returns to fit; zoom label is relative to fit (100%).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): fit mermaid lightbox to device screen not inner panel
Use visualViewport for fit scale, full-screen pan layer, and a floating
toolbar so the diagram can use the whole display.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): show mermaid lightbox by reusing inline SVG
Second mermaid.render on open often left a 0×0 SVG while fit scale was
computed from the loading placeholder. Reuse the inline SVG in the modal
and measure viewBox with retried fit-to-screen.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): uniquify mermaid SVG ids in lightbox clone
Inlining the same mermaid markup twice duplicates element ids and breaks
url(#ref) resolution in the modal copy. Prefix ids and hrefs for lightbox only.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): give mermaid lightbox SVG explicit dimensions
Mermaid emits width="100%" with max-width in px; that collapses to 0×0
inside the centered lightbox layer. Derive width/height from viewBox for
the uniquified lightbox clone.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): render mermaid lightbox via isolated SVG data URL
String id rewrites broke mermaid's embedded CSS so only labels appeared
zoomed. Rasterize the inline SVG to a data-URL img instead of duplicating
markup in the DOM.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): lightbox re-renders SVG for sequence diagrams
Data-URL images drop or blank some mermaid diagram types (sequence).
Re-render with a modal-specific id into inline SVG on a code-bg panel,
and add sequence theme variables for dark/light.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): mermaid lightbox uses inline SVG in shadow DOM
Reuse the inline render in an isolated shadow root so sequence CSS stays
intact, and fit the viewport from viewBox dimensions instead of the loading
placeholder or width="100%" layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(web): Playwright lightbox coverage per mermaid diagram type
Add e2e harness and a script that opens the lightbox for each diagram
kind (flowchart through kanban). Fit uses inline getBBox() so compact
charts like gitGraph fill the viewport.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(web): bounded Playwright via webServer, fix gantt fit sizing
Playwright owns Vite lifecycle (no agent-spawned dev server). Fit uses
viewBox unless viewBox padding is excessive (gitGraph); wide charts use
width-based coverage in e2e.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(web): gitignore Playwright test-results
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): address PR 741 bot feedback (typecheck, fit floor, gitignore)
Guard lightbox open when svg is null; allow fit scale down to 0.01 while
keeping 0.25 minimum for manual zoom; ignore Playwright test-results/ correctly.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(web): Playwright asserts click expands diagram vs inline
Measure inline vs lightbox bounding box after click; require visible
growth (area ratio or max dimension) plus dialog + shadow SVG content.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(web): Playwright against live HAPI session for mermaid lightbox
Add seed script for a dedicated chat session, live hub Playwright suite
(HAPI_LIVE=1), and dogfood doc. Live tests fail until driver serves shadow-DOM
lightbox (catches gray-box regression on stale bundles).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): undo wrapper transform in lightbox fit; carry fit floor in zoom
Resolves PR #741 review threads (HAPI Bot Major):
1. measureSvgIntrinsicSize / measureContentSize prefer intrinsic dimensions
(viewBox -> width/height attrs -> img.naturalSize) before getBoundingClientRect.
When the rect is the only signal, divide by scaleRef.current so the 50/200ms
refit retries stop compounding with the wrapper's scale(...) transform.
Large diagrams no longer jump tiny or oversize after async render completes.
2. Interactive zoom (wheel/keys/buttons/pinch) now clamps with
Math.min(MIN_SCALE, baseScaleRef.current). A diagram fitted below the
normal 25% floor stays reachable instead of snapping back to 25% and
clipping. Zoom-out button disabled threshold uses the same min.
3. Add Vitest coverage for both helpers (intrinsic precedence, scale-aware
rect fallback, divide-by-zero guard) so regressions surface without
needing the full Playwright stack.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(scripts): mermaid seed refuses to wipe non-fixture sessions
HAPI Bot Major (PR #741): SESSION_ID is documented as overridable,
and the script unconditionally deletes every message for the target
session before seeding fixtures. If pointed at a real session id,
that's silent data loss.
Refuse to proceed when an existing session id has a tag other than
'mermaid-lightbox-e2e'. New ids and the canonical fixture session
still seed normally; real sessions throw before any DELETE runs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): normalize mermaid svg for lightbox shadow root
Mermaid emits width="100%" on every diagram. Inside a shadow root whose
host has no explicit size, that collapses to zero in Chromium for most
diagram types - only ones that ship pixel attrs (e.g. journey) happen to
render. Operator confirmed on the live driver: every diagram except
journey opened to a grey rounded square.
MermaidLightboxSvg now runs normalizeMermaidSvgForStandaloneDisplay before
injecting (strips width/height="100%", bakes viewBox dims as pixels) and
sets :host{display:inline-block} so the host sizes to the SVG. Inline svg
in chat is unchanged - only the lightbox copy is normalized.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): keep mermaid lightbox content below the toolbar
Operator screenshot showed the diagram top (e.g. pie 'Pets' title)
clipped behind the toolbar bar. Two causes:
1. getScreenFitSize used the full viewport height, so the fit scale
sized the diagram to fill an area the toolbar overlapped.
2. The viewport (drag/zoom area) was inset-0; content centered on the
full viewport center, not the visible region's center, pushing the
top behind the toolbar.
Measure the toolbar with a ResizeObserver, subtract its height from
the fit calculation (clamped at zero), and start the viewport region
below the toolbar (top: toolbarHeight). Fit scale recomputes whenever
toolbar height changes.
Adds Vitest coverage for getScreenFitSize reserved-top math.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): guard ResizeObserver before constructing it
HAPI Bot Major (PR #741): Vitest jsdom does not polyfill ResizeObserver,
so the toolbar measure effect throws ReferenceError when the existing
mermaid-diagram React tests open the lightbox. Same code path is also
brittle in any browser/webview without the API.
Fall back to plain window 'resize' listener when ResizeObserver is
absent. Toolbar height won't auto-update on element resize without it,
but the lightbox still renders and the resize listener catches the
common viewport-rotation case.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(scripts): live mermaid playwright wrapper runs from repo root
HAPI Bot Minor (PR #741): the wrapper sets cwd to scripts/, but the
test:mermaid-lightbox:live npm script lives in the repo-root
package.json, so spawning npm there exited before Playwright started.
Switch cwd to the repo root and drop the unused WEB_DIR constant.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): accept signed viewBox values in mermaid lightbox normalize
HAPI Bot Minor (PR #741): the viewBox regex only matched digits, dots,
and spaces, so a valid viewBox with negative origin (e.g. '-8 -8 640 480')
returned null. normalizeMermaidSvgForStandaloneDisplay then became a
no-op and left width='100%', re-introducing the zero-sized lightbox
render this PR is meant to fix for the affected diagrams.
Switch to the bot's suggested regex (signed numbers, single or double
quotes, comma or space separators) and reject NaN parts. Adds Vitest
coverage for signed origins, single quotes, comma separators, the
malformed/no-viewBox null paths, and an end-to-end normalize test that
fails against the old regex.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): align @playwright/test on 1.60.0 across workspaces
HAPI Bot Major (PR #741): web/package.json pinned @playwright/test at
1.49.1 while the root workspace and bun.lock were on 1.60.0. The
mismatch surfaced after rebasing onto upstream/main, where the root had
already moved to 1.60.0 while my web devDependency lagged from an older
commit. A frozen install would reject the lockfile and the new web e2e
script could resolve a different Playwright than root scripts.
Bump the web devDependency to 1.60.0 and regenerate bun.lock so all
workspaces share one Playwright version.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): move mermaid playwright fixtures out of public
HAPI Bot Minor (PR #741): the e2e and smoke fixtures lived under
web/public, so Vite copied them verbatim into web/dist and the hub
asset generator embedded them in production bundles. Both pages
import Vite dev-only paths (/@react-refresh and /src/dev/...), so
the production /mermaid-lightbox-{e2e,smoke}.html routes would 404
on those imports.
Move both fixtures to web/e2e-fixtures/ to match the existing
scratchlist-fixture pattern (relative ../src/dev import, served by
Vite at /e2e-fixtures/...) and update the Playwright spec to hit the
new path. Build now ships 112 PWA precache entries instead of 114
(both fixtures excluded from dist).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web/markdown): disable single-dollar inline math in remark-math
Default remark-math configuration treats $...$ as inline LaTeX, which
turns any prose containing two currency amounts (e.g. "save $400 vs the
$200 plan") into a KaTeX block — paragraphs collapse, whitespace is
stripped, the running text is re-rendered as math symbols.
Pass `singleDollarTextMath: false` to remarkMath so single $ is plain
text. Block math `$$...$$` (on its own line) still renders, matching
GitHub-flavored markdown semantics.
Single source of truth: MARKDOWN_PLUGINS is shared by MarkdownText,
Reasoning, and MarkdownRenderer — fix lands in all three surfaces.
Adds 3 regression tests that drive the unified pipeline end-to-end:
prose with multiple "$N" amounts produces no `class="katex"` and no
`<math>` element; `$$...$$` block math still does.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(web): declare unified/remark-parse/remark-rehype/hast-util-to-html
The new markdown-text regression test imports these directly to drive the
unified pipeline end-to-end. They were resolving via transitive deps from
remark-math and rehype-katex, which is fragile — a future dep upgrade can
remove the transitives and break the test.
Declare them explicitly under devDependencies. No code change; lockfile
records the same versions that were already installed transitively
(unified@11.0.5, remark-parse@11.0.0, remark-rehype@11.1.2,
hast-util-to-html@9.0.5).
Addresses the HAPI Bot review finding on PR #805.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(web): upgrade @tanstack/react-router to ^1.170.8
Fixes QuotaExceededError in scroll restoration: upstream @tanstack/react-router
>=1.145.6 wraps sessionStorage.setItem with try-catch, preventing the crash when
scroll restoration cache exceeds quota.
Refs: #683, #716, #721
* fix(web): adapt scrollStorageGuard to @tanstack/router-core >=1.145.6 API
`scrollRestorationCache` was removed from the public exports; replace with
`storageKey` import and simplify `hardResetScrollRestorationPersistedState`
to a plain `removeItem`. Remove the now-stale in-memory cache sync path and
its associated tests. Upstream try-catch (>=1.145.6) covers crash prevention;
this guard continues to proactively prune sessionStorage.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
* feat(web): polish chat rendering
Refresh the web chat presentation across user messages, tool cards, code blocks, diffs, reasoning, and Mermaid diagrams.\n\nAdd focused regression coverage for bubble/status behavior, code and diff rendering, clipboard output, Mermaid theming, and message-window updates.
* fix(web): stabilize chat tool rendering
Preserve manual scroll anchors while older messages and tool dialogs update, and align code, diff, and tool result rendering with chat typography.
Add chat font-weight settings, ignore local Playwright CLI artifacts, document the Angular commit-message convention, and cover the scroll, result, code, diff, and settings behavior with focused tests.
Constraint: User requested committing all current workspace diffs with Angular-style commit messaging
Tested: bun run typecheck:web && bun run test:web && git diff --check
Co-authored-by: OmX <omx@oh-my-codex.dev>
* style(tool-card): polish question and permission card styles
Align AskUserQuestion option surfaces and permission action hierarchy with the existing tool card visual language while preserving interaction logic. Extract shared option presentation helpers and theme-driven hover/muted colors to reduce duplication.
Constraint: Frontend style-only polish; preserve existing permission and answer submission behavior
Rejected: Keep screenshot artifacts in the repo | they are local visual review output, not source
Confidence: high
Scope-risk: narrow
Tested: git diff --check; bun run typecheck:web; bun run test:web; bun run build:web
Not-tested: manual cross-browser visual QA beyond local Playwright inspection
Co-authored-by: OmX <omx@oh-my-codex.dev>
* fix(cli): keep Claude remote plan prompts actionable
Handle Claude remote /plan locally so HAPI switches plan permission mode before forwarding any prompt text. This avoids Claude Code treating /plan as an unknown skill and ending with only a ready event.
Constraint: Claude SDK result messages are not conversation log entries, so command handling must happen before the prompt reaches Claude.\nRejected: surfacing SDK result summaries in web chat | would expose transport-level summaries broadly instead of fixing the slash-command path.\nConfidence: high\nScope-risk: narrow\nDirective: Keep Claude remote slash commands that alter runtime mode in the CLI special-command parser.\nTested: bun test cli/src/parsers/specialCommands.test.ts; bun typecheck; git diff --check\nNot-tested: Manual GitHub-hosted runner deployment.
* fix(web): polish tool result rendering
* fix(web): preserve collapsed session order
* fix(chat): settle initial thread scroll
* fix(settings): remove chat font weight option
* fix(web): remove font weight bootstrap code
* chore: remove unrelated branch artifacts
* test(web): update consumed message invocation test
* fix(chat): cancel initial scroll settling on manual scroll
---------
Co-authored-by: huhaoyu.hahahu <huhaoyu.hahahu@bytedance.com>
Co-authored-by: OmX <omx@oh-my-codex.dev>
* feat(web): add LaTeX math formula rendering with KaTeX
Add remark-math + rehype-katex to the markdown rendering pipeline
so inline ($...$) and display ($$...$$) math formulas are rendered
as proper KaTeX output in chat messages and tool results.
Closes#237
* fix(web): disable single-dollar math parsing and add KaTeX to reasoning
- Set singleDollarTextMath: false to prevent $HOME, $PATH etc from
being misinterpreted as math formulas. Only $$...$$ (display) is
parsed; inline math requires explicit \(...\) or $$...$$.
- Add rehypePlugins to the reasoning renderer so math formulas
render consistently across chat, tool results, and reasoning blocks.
* refactor(web): use satisfies for type-safe plugin exports
Replace any[] with satisfies NonNullable<MarkdownTextPrimitiveProps[...]>
to preserve type safety on the shared plugin lists without needing
eslint suppressions.
* fix(web): enable single-dollar inline math syntax
Re-enable $...$ parsing (remark-math default) so inline formulas
like $E=mc^2$ render correctly. Shell variables like $HOME typically
appear inside code spans/blocks which remark-math does not parse,
so false positives are minimal in practice.
* feat(web): add About section to settings page
- Add website link to hapi.run
- Display app version from CLI package
- Display protocol version from shared module
- Add Vitest testing setup with settings page tests
🤖 Generated with Claude Code
* test(web): add tests for website link and i18n key usage
Address residual risks mentioned in PR review:
- Test website link URL and security attributes (target, rel)
- Verify correct i18n keys are used for About section via spy
Simplify test setup by using real I18nProvider and en locale.
🤖 Generated with Claude Code
Implement push notification system with VAPID keys, client service worker integration, and push subscription management. Includes server-side PushService for sending notifications and PushNotifier for reactive event handling, plus client-side usePushNotifications hook and service worker support.
- Add LICENSE file to root and cli/ directories with LGPL-3.0-or-later text
- Create cli/NOTICE file with MIT attribution for happy-cli derived code
- Update license field in cli/, server/, and web/ package.json to "LGPL-3.0-or-later"
- Add NOTICE to cli/package.json files array for npm publishing
Migrate to Tailwind CSS v4 with new @tailwindcss/postcss plugin, upgrade xterm.js to v6, vite to v7, and other core dependencies for improved performance and compatibility.
Updates ModelContextProtocol SDK and multiple dependent libraries to latest versions. Refactors TypeScript schemas to avoid instantiation depth issues by widening Zod types and using explicit type parameters.
Removes dev dependencies no longer needed after migrating from tsx to bun as TypeScript runtime and removing linting toolchain. Moves workbox-window to web package dependencies where it's actually used.
- Add CLI-side terminal management via Bun.Terminal with TerminalManager
- Implement server-side Socket.IO proxy for terminal I/O between web and CLI
- Create web terminal UI component with xterm.js and support for resize/reconnect
- Add terminal route and navigation button in session chat
- Include comprehensive terminal implementation plan and architecture docs
Replace state-based screen navigation with proper URL-based routing. This includes:
- New router configuration with routes for sessions, machines, and spawn pages
- App context provider to share API and token across the app
- useAppGoBack hook for handling browser and Telegram back navigation
- Refactored App component to render outlet and use router hooks
- Memory history for Telegram app, browser history for web
Replace manual state management with TanStack Query (React Query) for more robust server state handling. This refactoring introduces:
- New hooks for queries: useSessions, useSession, useMessages, useMachines
- New hooks for mutations: useSendMessage, useSessionActions, useSpawnSession
- Centralized query client with optimized configuration (5s staleTime, disabled window focus refetch)
- Query key factory for consistent cache invalidation
- Improved message synchronization via socket events with cache updates
- Optimistic updates for message sending with retry capability
- Simplified App.tsx by removing manual state management logic
- Integrated React Query devtools in development mode
This enables automatic cache management, better error handling, and a foundation for more sophisticated data fetching patterns.
Replaces react-shiki with a custom minimal Shiki highlighter to significantly reduce bundle size. Only loads 29 common languages and 2 themes (github-light/dark), using the JavaScript RegExp engine instead of WASM.
- Reduced web bundle from 5.8MB to 2.5MB (57% reduction)
- Saves 622KB by avoiding WASM runtime
- Graceful fallback for unsupported languages
- Removed react-shiki dependency
- Added @shikijs/langs, @shikijs/themes, shiki, hast-util-to-jsx-runtime
- Added light mode CSS rules for syntax highlighting
Consolidate markdown rendering logic into dedicated assistant-ui components and update syntax highlighting to use Shiki with GitHub themes. Removes dependency on react-markdown and react-syntax-highlighter in favor of @assistant-ui/react-markdown with Shiki-based highlighting.
Extract word detection and suggestion logic into reusable utilities and hooks.
Add FloatingOverlay and Autocomplete components for intelligent input assistance.
Integrate settings panel and abort button directly into ChatInput component.
Simplify SessionHeader by removing settings dialog (now in ChatInput).