Ignore Cursor's Using worktree stdout banner without masking other
non-JSON ACP frames (markClosed + kill). Skip --cursor-worktree when
spawn directory is already a linked git worktree so ACP can initialize.
Fixes#1085
Co-authored-by: Cursor <cursoragent@cursor.com>
The public relay used to accept a shared auth key compiled into every
hub, so its bandwidth was open to anyone. The relay now issues a
per-hub credential it can meter and revoke, and hubs obtain one on
their own.
- --relay resolves an auth key at startup: HAPI_RELAY_AUTH env, then a
key persisted in settings.json, then a fresh key from the relay's
/issue endpoint. There is no shared-key fallback; if no key can be
obtained the tunnel does not start and the hub says why.
- A persisted key rejected by the relay (HTTP 403 after revocation or a
secret rotation) is discarded and replaced once, then the tunnel is
restarted, so a revoked hub recovers without manual edits. Keys given
explicitly through the environment are never overwritten.
- Issuance is rate-limited per public IP; HTTP 429 is reported with the
retry hint instead of being retried blindly, which matters for users
sharing a CGNAT or corporate egress address.
- The tunnel URL now comes from upstream tunwg's slog JSON on stderr
(msg="listener started"), replacing the fork's custom --json event,
and --log_level=0 keeps per-request logs out of the hub console.
Requires a relay running tunwg with TUNWG_AUTH_SECRET configured.
* 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>
* test: reproduce issue #786
* fix: load extra headers from settings (closes#786)
* test: cover extra header precedence and redaction
* fix: redact persisted extra headers in diagnostics
* test: cover runner extra header identity
* fix: restart runner when extra headers change
* 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>
* feat(web): in-app PWA update prompt when new service worker is available (closes#938)
User-controlled reload with a persistent banner, visibility-triggered SW
checks, and an expandable rationale. Switches registerType to prompt.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): align vite.config with soup layers for clean driver merge
Keeps registerType prompt while matching garden IWER stubs and PWA
share_target shape expected by feat/pwa-share-target in the manifest.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert "fix(web): align vite.config with soup layers for clean driver merge"
This reverts commit 6f0915b0884d029a2413d8819a4dfe81d7c4e595.
* fix(web): make PWA reload apply waiting service worker updates
Handle SKIP_WAITING in injectManifest sw.ts and reload via controllerchange
with a timed fallback when vite-plugin-pwa prompt mode does not navigate.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): satisfy setTimeout mock typing in PWA reload tests
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): register PWA service worker before auth gates
Mount PwaUpdateProvider at app root and show the update banner on login
and error screens so registerSW runs for logged-out users too.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): offset PWA update banner below top status banners
Reserve top-12 when syncing or reconnecting so the reload prompt stays
visible above SyncingBanner and ReconnectingBanner.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): offset PWA update banner below voice error banner
Use PwaUpdateBannerWithStatusOffset inside VoiceProvider so voice errors
share the same top-12 reservation as sync and reconnect banners.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
The runner spawns child agent sessions with `detached: true`
(`cli/src/runner/run.ts:454`) so they survive runner restart, and
runner cleanup (`run.ts:1049`) does not iterate or kill tracked
children on shutdown. The runner is already designed as a long-lived
process whose exit leaves agent sessions intact.
But Node's `detached: true` calls `setsid()` (new process session),
which does NOT escape the parent's systemd cgroup. Without an
explicit `KillMode`, systemd defaults to `control-group`, which
SIGTERMs every PID in the runner's cgroup whenever the unit stops -
forcibly archiving every running session and discarding the detach
contract.
Adds `KillMode=process` to the reference runner unit and a note
explaining the contract. With this change, `systemctl restart
hapi-runner.service` (and any cascade-stop from `Requires=`) only
signals the main runner PID; the cleanup runs without killing
descendants; agent sessions stay alive; the new runner reconnects via
the existing socket.io reconnect path
(`cli/src/api/apiMachine.ts:385`) and re-establishes control via the
existing RPC layer.
This is the smallest fix for #915. The complementary safety net -
runner re-attaching to orphaned children on cold start when no
running runner exists - will be tracked in a separate issue and PR.
AI-disclosure (per CONTRIBUTING.md): drafted with claude-opus-4.7 as
peer agent during a fork-side post-mortem of a 7-hour outage that
this fix would have prevented.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): drop timing heuristic from #784 intercept; scan raw payload (#801 follow-up)
PR #801 shipped a two-strategy intercept for the synthetic AskQuestion
skip response in legacy stream-json mode. Real-traffic data from a
post-merge run shows the marker-match strategy never fires (the
converter's `extractToolResult` discards the marker for tool shapes it
does not recognize, returning `{}`) and the timing-signature
defense-in-depth strategy fires only on false positives - notably the
Anthropic Vertex Claude tool calls cursor-agent surfaces in legacy
sessions, which all land as `name=unknown` with the `{}` extracted
result and frequently complete under the 500 ms threshold.
Measured on a single legacy-resumed session (`7b769423`): 1,136
`name=unknown` tool calls, 16 rewritten as `no_input_surface`, zero
actual marker strings stored anywhere in the session. The 16 rewrites
were legitimate fast tool calls (Anthropic Vertex `toolu_vrtx_*` IDs)
mischaracterized as fabricated skip responses.
Changes:
- Remove the timing-signature heuristic and its supporting state
(started-at map, elapsed-ms calculation, latency threshold, test-only
state reset).
- Move the marker scan from the post-`extractToolResult` output to the
raw `tool_call` payload, so it can see the marker on stream-json
shapes the converter does not specifically recognize. Function-shaped
tools exclude `function.arguments` from the scan to avoid matching
agent-controlled input. Other shapes scan the full payload (no
agent-input field exists at the top level).
- Refresh tests: drop timing-based positive cases, add a marker-in-raw-
payload positive case for `name=unknown` shapes, and add a regression
that legitimate fast `name=unknown` tool calls without the marker
pass through with `status: completed`.
- Document scope: this intercept now lives only on the legacy stream-
json path, which only resumed pre-ACP sessions hit. New cursor remote
sessions go through `cursorAcpBackend` and the `cursor/ask_question`
ACP extension method (#799) - immune to this bug. The intercept
drains with the legacy session population.
Tracking: #784. Builds on #801, complements #799.
* fix(cursor): exclude agent input from marker scan; surface top-level Anthropic tool names (Codex P2)
Codex flagged a false-positive case on the fork-stage review of this
branch (heavygee/hapi#35, P2): an Anthropic tool_use shape with a
top-level `name` (e.g. `{id, name: 'TodoWrite', input: { ... }}`) gets
labelled `name=unknown` by the converter and passes the AskQuestion
gate. If the agent's `input` quotes the synthetic-skip marker - which
happens whenever an agent edits or documents this very bug - the
intercept would rewrite a perfectly fine TodoWrite as a fabricated
skip.
Two-part fix:
1. `extractToolName` now reads the top-level `name` field as a final
fallback. A real `TodoWrite` / `Bash` / `str_replace_based_edit_tool`
surfaces with its actual name and is rejected by the AskQuestion
gate before the marker scan runs. The original AskQuestion
fabrication case still surfaces as `unknown` (per #784 issue body
the name is stripped in the fabricated payload) and remains
detectable.
2. Defense in depth: introduce `AGENT_INPUT_KEYS = {input, args,
arguments}` and exclude these from the non-function shape's marker
scan. Even if a tool reaches this code path with `name=unknown` and
the marker buried in its `input`, the intercept won't fire on agent-
controlled text.
Two new regression tests:
- Anthropic tool_use shape `{id, name: 'TodoWrite', input: {todos: [
marker]}}` → passes through with `status: 'completed'`.
- `name=unknown` shape with marker only inside `input` → passes through
with `status: 'completed'`.
All 20/20 tests pass; typecheck clean (cli + web + hub).
* feat(cli,web,hub): migrate Cursor remote sessions to ACP with model/effort pickers
Move stream-json remote launcher to legacy path and add ACP launcher with
set_config_option model/mode sync, optimistic keepalive on config changes, and
shared catalog caching. Web gets dual base/effort Cursor pickers for session and
new-session flows; hide composer status bar when Cursor sends no usage_update.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli,web,shared): Cursor model picker — ACP wires + CLI sku variants
Enrich the web/mobile picker with agent --list-models SKUs grouped under
ACP wire bases, fix session-open base highlight, and keep catalog discovery
safe while the ACP transport holds the CLI lock.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor-acp): apply ACP default model when web resets to Default
Web sends model: null for Default; push session/set_config_option with the
ACP default[] wire so Cursor backend matches hub state. Regression tests
for setModel(null) and applyModelConfig(null).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(acp): clear stale agent-acp lock when owning process is gone
Check lock pid with signal 0; remove orphaned lock dirs after SIGKILL or
crash so listCursorModels can run cold probes again. Regression tests for
guard and catalog discovery.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(cursor): use live pid for ACP lock handler tests
Stale-lock cleanup clears dead pids; handler tests must simulate an
active lock with the current process pid to avoid cold probes/timeouts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(acp): scope agent CLI lock guard to Cursor agent command only
Gemini/OpenCode/Kimi ACP sessions must not register agent-acp-active;
that blocked listCursorModels while unrelated backends were running.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,web): reject Cursor model changes for local sessions
Hub returns 409 when controlledByUser is set, matching Codex. Web hides
model and variant pickers for local Cursor sessions so users do not hit
a dead RPC path. Document pre-push-review in AGENTS.md.
Verified: bun typecheck; bun run test (919 cli + 243 hub + 768 web + 46 shared).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): send stable ids for Cursor ask_question replies
Parse and submit question.id and option.id so ACP receives keys like
{ approach: ['a'] } instead of index/label. Verified: bun typecheck && bun run test.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): intercept fabricated 'Questions skipped' AskQuestion result in headless mode (#784)
When cursor-agent runs under `--print --output-format stream-json` (HAPI's
current Cursor remote launcher), the CLI returns a synthetic
`Questions skipped by the user, continue with the information you already have`
response for the `AskQuestion` tool in ~zero seconds with no error flag,
because there is no IDE surface to render the question. The underlying
model can interpret this as legitimate user consent and act on it.
This patch intercepts the synthetic result in
`cli/src/cursor/utils/cursorEventConverter.ts` and rewrites the
`tool_call`/completed event to a structured `no_input_surface` failure
(`status: 'failed'`, which downstream becomes `is_error: true`).
Detection has two strategies:
1. String match - any `tool_call`/completed payload whose serialized form
contains the synthetic-skip marker is rewritten. This is robust to
wherever cursor-agent stuffs the marker inside the `tool_call` object.
2. Timing + name heuristic (defense in depth) - any completion that arrives
within 500 ms of its 'started' event with a trivial result, for a tool
call named `AskQuestion`, `askQuestion`, `ask_question`, or the
converter's `unknown` fallback, is also rewritten. This catches the case
where cursor-agent changes the synthetic-string text in a future release.
The converter tracks per-call timestamps in a bounded `Map` (`<= 1024`
entries, oldest evicted on overflow) and clears entries when the
corresponding 'completed' event arrives. A small test-only reset hook
isolates state between Vitest cases.
This is a transitional safety patch. It auto-deletes when #781's ACP
launcher replaces the stream-json launcher and `cursor/ask_question`
becomes a proper bidirectional ACP method where fabrication is
structurally impossible.
Scope is intentionally tiny: only `cli/src/cursor/utils/cursorEventConverter.ts`,
its colocated Vitest file, and a section in `docs/guide/cursor.md`. No
changes to `cursorRemoteLauncher.ts`, ACP code, web normalizer, or
permission UI.
Refs: tiann/hapi#781 (long-term resolution via ACP migration)
Closes: tiann/hapi#784
* fix(cursor): gate AskQuestion intercept on tool name (#784 PR #801 review)
Address regression flagged by the HAPI auto-review bot on #801:
`containsSyntheticSkipMarker` previously stringified the entire `tool_call`
payload and matched the literal marker substring. Because this PR also adds
that exact marker to `docs/guide/cursor.md` (to document the intercept), a
Cursor `read_file` of that documentation page would surface the marker
inside `readToolCall.result.content` and be rewritten as a
`no_input_surface` failure, corrupting an unrelated, legitimate result.
The intercept is now gated on the tool name resolving to an
AskQuestion-shaped call (`AskQuestion`, `askQuestion`, `ask_question`, or
the converter's `unknown` fallback for unnamed function-shaped tools).
`read_file` / `write_file` tool calls - which have explicit `read_file`
and `write_file` names from `extractToolName` - no longer fall under the
intercept, regardless of what their payload contains.
The marker check itself now walks values recursively (string / array /
object), guarded by a `WeakSet` against cycles, instead of relying on
`JSON.stringify`. Slightly tidier; behaviour is otherwise unchanged for
the AskQuestion path.
Regression tests added:
- `read_file` result whose `content` contains the marker -> passes
through with `status: 'completed'` and no `no_input_surface`.
- `write_file` whose serialized `args` contain the marker -> same.
- A non-AskQuestion function tool (`MyCustomTool`) whose result quotes
the marker -> same.
All 846 cli tests pass (17 in this file). `bun run typecheck` exits 0.
* fix(cursor): scope synthetic-skip check to extracted result (#784 PR #801 review-2)
Address second Major finding from the HAPI auto-review bot on #801:
After the previous fix gated the intercept on the tool name, the marker
check still recursed into the entire `tool_call` object - which includes
`function.arguments`, the agent's own prompt text. A legitimate
AskQuestion whose prompt quotes the synthetic-skip marker (e.g. an agent
debugging this exact bug, or any prompt that pastes the marker verbatim)
would have been rewritten as `no_input_surface` even when the operator
actually answered.
Changes:
1. `extractToolResult` now extracts the cursor-side response from
function-shaped tool calls. Previously it returned `{}` for anything
that wasn't `readToolCall` or `writeToolCall`. It now returns
`function.result` when present, otherwise every field of `function`
except `name` and `arguments`. This excludes the agent's input from
what downstream sees as the tool result, and as a side effect surfaces
the actual cursor response for function-shaped tools (which was
previously lost - see the #784 incident note about HAPI storing
`output: {}` for AskQuestion in the message DB).
2. `shouldRewriteAsNoInputSurface` now searches only the extracted
`result`, not the whole `tool_call`. The bot's exact recommendation.
3. Test added: an AskQuestion whose `arguments` quote the marker but
whose `result` is a real user answer, with elapsed time past the
500 ms threshold so the timing heuristic does not apply. Asserts the
tool_result passes through with `status: 'completed'` and the
operator's actual answer.
All 847 cli tests pass (18 in `cursorEventConverter.test.ts`).
`bun run typecheck` exits 0.
The widened `extractToolResult` scope is necessary for the marker check
to actually find the synthetic string (it lives inside `function.result`
or a sibling field), and is the bot's explicit recommendation. It also
removes the long-standing data-loss bug where AskQuestion responses were
surfaced to the message DB as opaque `{}` - regardless of fabrication.
* feat(web): group consecutive tool-use cards
Add a web-only visible projection that groups consecutive root-level execution tools into expandable cards.
Keep approval and question tools standalone, reuse older-history loading on expand, and add regression coverage for grouping and UI behavior.
* fix(web): hydrate oldest visible tool group
Mark needsOlderHistory on the first visible grouped tool run even when earlier visible blocks are non-tool content, and add regression coverage for the boundary.
* fix(web): continue grouped history hydration
Decouple ToolGroupCard older-history chaining from the shared loading flag, invalidate stale hydration runs safely, and add regression coverage for multi-page hydration.
* fix(web): harden grouped tool hydration
- retry incomplete group hydration after transient pagination contention\n- keep approved and denied permissioned tool cards eligible for grouping\n- cover both regressions with targeted web tests
* fix(web): keep Codex permission cards standalone
- treat CodexPermission as a semantic grouping boundary even after approval\n- keep permissioned execution tools groupable while preserving permission milestones\n- add regression coverage for Codex permission eligibility and boundary behavior
* fix(web): narrow incomplete tool-group hydration
- only mark groups at the oldest visible boundary as needing older history\n- avoid auto-paginating complete groups behind text, standalone tools, or permission milestones\n- add regression coverage for the adjacent boundary cases
* feat(cli): support extra headers for hub requests
* fix(types): normalize missing session fields to null
* refactor(cli): simplify socket extra headers config
* feat(cursor): add support for Cursor Agent CLI integration
- Introduced new command `hapi cursor` to start Cursor Agent sessions.
- Added functionality for resuming sessions and managing permission modes.
- Updated documentation to include Cursor Agent usage and installation instructions.
- Enhanced existing codebase to accommodate Cursor as a recognized agent flavor.
- Implemented local and remote session handling for Cursor Agent.
This update expands HAPI's capabilities by integrating support for the Cursor Agent, allowing users to leverage its features alongside existing agents.
* Remove TODO.md file as it is no longer needed following the integration of Cursor Agent CLI support. This cleanup helps streamline project documentation and reflects the completion of the associated tasks.
* feat(cursor): implement remote mode and fix --hapi-starting-mode
- Consume --hapi-starting-mode in cursor command (do not forward to agent)
- Implement cursorRemoteLauncher: spawn agent -p with stream-json, --trust
- Add cursorEventConverter for NDJSON parsing (system/assistant/tool_call/result)
- Multi-turn via --resume session_id
- Update docs: cursor supports both local and remote modes
Made-with: Cursor
* fix: type error
* fix(cursor): address PR review - model UI, sessionId metadata, duplicate flags
- HappyComposer: use isClaudeFlavor for model mode (cursor has no model modes)
- cursorLocalLauncher: call onSessionFound for resume so cursorSessionId in metadata
- cursorCommand: do not forward parsed flags to cursorArgs (avoid duplicates)
Made-with: Cursor
Update installation guides to specify the official npm registry
and add a recommendation to use it for global installs, as some
mirrors may not sync platform packages in time.
- Remove Quick Tunnel (TryCloudflare) documentation as it doesn't support SSE which HAPI uses for real-time updates
- Add warning note explaining the limitation with link to Cloudflare docs
- Keep only Named Tunnel as the recommended approach
- Add tip about HAPI_RELAY_FORCE_TCP environment variable for users experiencing connectivity issues
- Add settings.json column to environment variables table with key name mappings
- Document missing ENV variables: TELEGRAM_BOT_TOKEN, TELEGRAM_NOTIFICATION,
HAPI_RELAY_FORCE_TCP, VAPID_SUBJECT
- Add settings.json example with configuration priority explanation
- Create JSON Schema file for settings.json validation and editor autocompletion
with all fields, descriptions, and ENV variable references
clsoe #113
Corrects outdated information about HAPI's decentralized architecture compared
to Happy's centralized approach. Updates user model, encryption strategy,
and deployment details to reflect current design. Clarifies that HAPI supports
both self-hosted and relay modes with proper security implications.
- Replace outdated WEBAPP_URL with HAPI_PUBLIC_URL in server and web READMEs
- Add CLI version verification steps in prerequisites section
- Enhance Cloudflare Tunnel documentation with quick and named tunnel examples
- Add --protocol http2 recommendation for tunnel stability
- Include pm2 alternative for runner process management
- Add Telegram Mini App troubleshooting notes and verification steps
Update documentation across README, installation, and quick-start guides
to highlight the new relay-based access method with WireGuard + TLS
end-to-end encryption. Changes include:
- Recommend `hapi server --relay` as the default startup command
- Explain URL and QR code generation in terminal for instant access
- Note end-to-end encryption for security assurance
- Reorganize self-hosted tunnel options (Cloudflare, Tailscale, IP)
- Update website installation steps and add E2EE badge
Use relative paths with .md extension for docs/guide/*.md files and
relative paths in README.md to support both GitHub rendering and
VitePress website (which has base: '/docs/'). Fixes#36
Introduce comprehensive documentation of HAPI's Seamless Handoff feature:
- README: Add Seamless Handoff to Features section
- how-it-works: Add complete Seamless Handoff guide with local/remote modes and switching workflow
- quick-start: Add Seamless Handoff link to Next steps
- AppShowcase: Add third card featuring "Switch Freely" mode switching capability
- Locales: Update English and Chinese translations for showcase content