* feat(mobile): add Capacitor native shell * docs: add serve-sim workflow guidance * docs(mobile): add implementation handoff * chore(mobile): clean up generated defaults * feat(mobile): add connection onboarding * feat(mobile): manage saved instances * feat(mobile): refine connection management UI * chore(mobile): upgrade Capacitor 8 * fix(mobile): reliable saved-instance auth with secure token storage - store client tokens in the OS secure store (iOS Keychain / Android Keystore) per instance URL via direct native plugin calls; keep only token-less metadata in localStorage. Bound every secure call so a stalled bridge can't hang unlock. - bypass the secure-storage JS wrapper's lazy platform load (which stalled in the webview) by calling internalSetItem/internalGetItem/internalRemoveItem directly. - harden the shared connect/unlock controller (health + session + progressive password) and drop the heavy pre-connect hydration that stalled no-token hosts. - await token persistence before switching runtime endpoints (no fire-and-forget). - sync native iOS/Android projects + Keyboard/StatusBar config for Capacitor 8. * fix(mobile): keep UI stable across connection churn (no transport hardcoding) The "reload every ~10s" was a UX bug, not a transport one: - MobileSurfaceShell received a fresh inline onClose each parent render, so any re-render (e.g. an SSE/WS event) re-ran the focus effect and refocused the first element — stealing focus from the active input and collapsing the keyboard mid-edit. onClose now lives in a ref so the focus/keydown effect depends only on `open`. Fixes all sheets (Instances/Files/Changes/Settings). - Gate the mobile shell on connectionPhase, not the live isConnected flag, so a transient reconnect keeps MobileShell mounted instead of flashing the loader. - Instances form: populate fields imperatively on edit/cancel/save instead of via an effect keyed on the derived connection, so list churn can't wipe input. Transport stays on `auto` (WS-first with SSE fallback) — no hardcoded override, so WS-only Quick Tunnels and SSE-capable proxies both keep working. * feat(mobile): add native QR pairing-code scanner Wire the connection onboarding + Instances scan buttons to a real native scanner via @capacitor-mlkit/barcode-scanning, which registers as the BarcodeScanner plugin the existing mobileQrScan helper already resolves at runtime. Add NSCameraUsageDescription and bump the iOS deployment target to 15.5 (GoogleMLKit 8 requirement). * fix(cli): repair connect-url host resolution Define the missing isWildcardBindHost helper that connect-url called but was never declared, which crashed any link generation that reached host resolution. Also treat a full http(s) --host value as a public server URL so '--host https://example.com' produces a correct link instead of 'http://https://example.com:port'. * fix(mobile): make input follow the keyboard across all surfaces Switch the native Capacitor Keyboard plugin to resize: 'none' and drive the layout from an --oc-keyboard-inset CSS variable set on keyboardWillShow, which fires at the start of the iOS keyboard animation. A transition tuned to the native keyboard curve/duration (0.25s, cubic-bezier(0.38, 0.7, 0.125, 1)) makes the layout rise together with the keyboard instead of snapping into place after the built-in 'native' resize finished (~1.5s lag). The inset is consumed by every surface that can hold a focused input: - chat shell shrinks its height; - portal sheets/overlays raise their bottom edge; - the full-screen connect/login view caps its height so it actually scrolls (and is now generally scrollable for long saved-connection lists). * feat(mobile): rounder chat composer + native bottom safe area Round the mobile chat composer corners a touch more (1rem), and reserve a small app-level bottom safe area for the native shell via the --oc-app-bottom-safe token so controls clear the phone's rounded hardware corners. The reservation folds into the keyboard inset (no gap above the keyboard), and the composer's own bottom padding tightens while the keyboard is open. * fix(mobile): remove iOS 26 dark status-bar band; polish composer The dark band behind the status bar in system Dark Mode was iOS 26's automatic scroll edge effect (Liquid Glass) dimming the WebView's top edge beneath the status bar — appearance-coloured, so it tracked the system theme regardless of the in-app theme. Hide it via UIScrollView.topEdgeEffect/bottomEdgeEffect on the WebView's scroll view (iOS 26+), and make the WebView non-opaque so the themed web background shows under the overlaid status bar. Also: re-assert the status-bar overlay on resume, paint the document canvas with the theme background in the native shell, round the composer corners to 1.5rem, and enlarge the app-level bottom safe area so controls clear the rounded corners. * feat(mobile): logo splash until first paint is final (no FOUT / layout shift) Cold start flashed the fallback font and then reflowed once the real font and persisted appearance prefs landed, and text jumped a frame after mount because the mobile typography classes were applied from a hook effect. Fix it on three fronts: - apply device classes (device-mobile / mobile-pointer) synchronously in renderMobileApp before the first React paint, so mobile --text-* sizes are in effect from the start; - hold a logo splash (useFontsReady) until the UI web font has loaded; - gate that splash on appBootReady too, resolved once async appearance/typography preferences are applied, plus a double rAF so styles commit before reveal. All under a 2.5s safety timeout so a slow/offline CDN can't block startup. * feat(mobile): native local notifications; APNs implemented but frozen The native app now delivers agent ready/error/question/permission events as iOS (and Android) Local Notifications: a native notifications API backed by @capacitor/local-notifications replaces the Web Notifications API (which doesn't display in a WKWebView), driven by the notification SSE stream now subscribed in the mobile app. Tapping a notification opens its session. Also fix the settings toggle, which treated the Capacitor app as a browser and gated 'Enable Notifications' on the absent Web Notification permission, leaving it un-toggleable. Remote APNs push is implemented end-to-end (dependency-free HTTP/2 + ES256 JWT server runtime, token routes, client registration, iOS native config) but kept dormant: config-gated so it never fires, client registration not wired, and the aps-environment entitlement / background mode removed so the app builds with no Apple push setup. It will be reused once OpenChamber ships its own encrypted relay so users don't each configure APNs. See notifications/APNS.md. WKWebView can't use web push (unlike an installed PWA), so true background-when-suspended delivery on native requires APNs via that relay. * feat(mobile): APNs relay-mode background push Deliver native iOS background push through the central relay: the server posts device tokens + generic, model-based text to api.openchamber.dev/v1/push/send (default), which holds the single APNs key and signs+sends; dead tokens (410) are dropped from the per-session store. Direct APNs (HTTP/2 + ES256 JWT) stays as a fallback when OPENCHAMBER_PUSH_RELAY_DISABLED=true. The mobile push payload is generic only (model + scenario) so no session content crosses the relay. Re-enable the client token registration (useNativePushRegistration) and the aps-environment entitlement (alert pushes need no background mode). Wired into the same fanout as web push; focus-suppressed and only when tokens exist. * fix(mobile): APNs-only native notifications, generic templates, no foreground Make APNs the single notification channel for the native app and fix delivery: - Remove local notifications entirely (the @capacitor/local-notifications plugin and the SSE-driven path). A WKWebView can't tell foreground from background (document.hasFocus() is unreliable), so local notifications leaked while the app was open; the in-app dispatch is no-op'd on native. - Stop gating APNs on UI visibility — a backgrounded WebView can't report 'hidden' before iOS suspends it, which dropped background push. Instead always send and let iOS suppress the foreground banner (PushNotifications presentationOptions: []). - Fix a ReferenceError (out-of-scope 'variables') that crashed maybeSendPushForTrigger before any push was sent. - Mobile push text is generic: a scenario title ('Agent response is ready' / 'needs your input' / 'needs permission' / 'hit an error') + the session name, no model or message content. - Hide the focus toggle, templates, and test button in mobile notification settings. * feat(push): sign relay requests + bind tokens per server Each OpenChamber server now auto-generates an ECDSA P-256 keypair (persisted in settings, like the VAPID keys) and uses it to: - bind every newly-seen device token to the server on the relay (POST /v1/push/register-token, signed), and - sign every push send (publicKeyJwk + ts + signature over ts.sortedTokens.title). The relay derives serverId = SHA-256(publicKey), verifies the signature + timestamp, and only delivers to tokens bound to that server. Result: a leaked device token alone can no longer be used to push to a device — the sender also needs the server's private key. Stays zero-config (the keypair generates on first use). Drops the soft PUSH_RELAY_TOKEN bearer. * docs(push): describe relay data-confidentiality model Document that the push payload is not application-encrypted (TLS-in-transit only), what the relay and Apple can see (generic scenario title + session name, plus token/sessionId), that the signature is authentication rather than encryption, and what an end-to-end encrypted payload would require. * fix: invalid skill description * feat(push): app-icon badge for native notifications Send an absolute aps.badge with each native push = the count of distinct collapse-ids (tag) pushed since the app was last foregrounded, mirroring the lock-screen banner stack. Cleared server-side on user engagement (session view, message-sent, visibility beacon) and on-device via sceneDidBecomeActive. * feat(mobile): auto-connect last instance on launch + notification deep-links Cold launch silently reconnects to the most-recent saved instance (when reachable and a token is saved), holding the splash instead of flashing the connect screen; falls back to the connect screen when there's no saved instance, it's unreachable, or it needs a re-login. Notification-tap deep-links are now captured unconditionally (even before connect / on cold launch) and applied once the app is ready, so a tap opens the target session instead of being lost on the login screen. * fix(mobile): resolve theme background before first paint on cold launch The mobile shell entry (mobile.html) had no pre-paint theme step, so a cold launch flashed the WebView's default light canvas, then the baked design-system default (.dark { --background: #151313 }) via body.bg-background, before React's theme system injected the real theme vars. Add a blocking script that resolves dark/light from the persisted theme + system preference and sets --background (plus color-scheme and the element background) inline on the root, so the very first paint matches the resolved theme. Falls back to the default flexoki backgrounds when no theme has been persisted yet. * feat(mobile): openchamber:// deep-link foundation + arm64 simulator build Add a typed deep-link vocabulary (deepLinks.ts: parse/build + DeepLinkIntent) and a single native navigation layer (deepLinkNavigation.ts) that handles both the openchamber:// URL scheme (App.appUrlOpen — widgets, Live Activities, external links) and notification taps, normalising each into an intent. Session and new-session resolve against the store; shell surfaces (sessions/settings/ views/changes) register handlers. Cold-launch intents stash until the app is ready. Replaces the push-only useNativePushDeepLink and keeps backwards compatibility with bare sessionId payloads. Register the openchamber:// scheme in Info.plist. Dev tooling: with-mobile-env now honours xcode-select (-p) instead of hardcoding Xcode.app, so an Xcode beta is used. build:ios:simulator runs a new ios-sim-build script that temporarily drops the MLKit barcode-scanning pod (no arm64-simulator slice) so the app builds an arm64 binary installable on Apple Silicon simulators, then restores the Podfile + Pods for device builds. QR scanning already degrades cleanly when the native plugin is absent. * feat(mobile): iOS home/lock/Control Center widgets + push-driven refresh Add a Widget Extension (OpenChamberWidget) and a Notification Service Extension (OpenChamberNotificationService), wired into the Xcode project, sharing an App Group with the app. Widgets: - Overview (medium): recent sessions with read/unread dots + four quick actions (new, status, instances, settings). - Sessions (large): session list with per-session project label, attention count and a new-session button in the header. - Quick Actions (small): New chat pill + status/instances. - Lock Screen (accessoryCircular x2): brand logo to new session, attention counter. - Control Center control: brand logo (custom SF Symbol) to new session. Data: the app writes a session-overview snapshot (attention count + recent sessions with project labels) to the App Group on scene activate/resign; the NSE refreshes it from each push (aps.badge + sessionId) so widgets update even when the app is closed (needs aps mutable-content, added to the server + relay). Deep links: add openchamber://status (session status panel) and reuse view/instances; all widget taps route through the existing deep-link channel. * feat(mobile): large Sessions widget lists 6 sessions with project labels * feat(mobile): edge-swipe to switch sessions with directional slide+fade * fix(mobile): keep widgets in sync via reload-on-change + periodic refresh Widgets sharing the app's WidgetKit reload budget refreshed unevenly, leaving the large Sessions widget stale (no unread dot / attention count) while medium updated. Drop the per-call updatedAt from the snapshot, only write + reloadAllTimelines when the session overview actually changed (so we don't burn the budget on every scene activate/resign), and give each widget a periodic timeline refresh so a missed reload self-corrects. * feat(mobile): Android support — chrome fixes, SSE lock, icon, QR scan Cosmetics: - Status bar: on Android inset the WebView below the bar (overlay:false) and paint it with the resolved theme background + correct content Style, since Android doesn't feed env(safe-area-inset-top) to CSS. - Keyboard: skip the manual --oc-keyboard-inset on Android (the window resizes natively, so applying it double-counted and floated the composer); declare windowSoftInputMode=adjustResize and disable the shell height transition on Android so the header no longer bounces on keyboard open. Transport: lock Capacitor apps to SSE — native WebSocket streaming is unreliable on Android (events only arrive once a run finishes). Forced in sync-context and the other options are disabled in the Chat settings UI. Push: gate APNs registration to iOS only; on Android @capacitor/push-notifications register() needs Firebase/FCM (not configured) and crashes at launch. QR pairing: declare CAMERA permission + the ML Kit barcode_ui dependency, and install/await the Google barcode scanner module (with a post-install retry) before scanning so the first scan works without a manual retry. Icon: Android adaptive launcher icon generated from the cube logo (full-bleed white background, no edge artifact on One UI). Source assets under mobile/assets. Tooling: adb-based android-device.mjs + android:* scripts for device deploy. * feat(notifications): presence-aware push routing (don't spam the phone) Only push to a device when the notification would otherwise be missed there. A notification is suppressed on devices where the user is already present. - Tag every client's visibility beacon and web-push subscription with a platform ('ios' | 'android' | 'vscode' | 'desktop' | 'web') via getClientPlatform(). - Server tracks visibility per client (keyed by oc_ui_session) with the platform, and exposes isAnyInteractiveClientVisible() = any visible non-mobile client. - Native push (APNs) and mobile PWA web-push are now suppressed when an interactive (desktop/web/vscode) client is visible — it already shows the in-app notification. Gated on the desktop's visibility (reliable), never the phone's own (a backgrounded WKWebView can't report "hidden"). - Desktop/web web-push keeps the any-visible gate (a visible client absorbs it). - Skipping APNs also skips the badge increment so it doesn't drift. Fixes the case where every session on a shared instance pushed to the phone even while the user was actively working on desktop. * feat(mobile): Android FCM push notifications Enable native background push on Android via Firebase Cloud Messaging, in parallel with the existing iOS APNs path. - Add google-services.json + declare POST_NOTIFICATIONS (Android 13+). The Google Services Gradle plugin is applied when the file is present, so register() returns an FCM token instead of crashing. - Un-gate native push registration to iOS OR Android, and tag the registered token with its platform ('ios' | 'android') so the relay routes it to APNs vs FCM. - Server stores the platform per device token and binds it to the relay (platform included in the signed register message). - Notification small icon: monochrome cube silhouette with a mark on the top face, set as the FCM default_notification_icon so the status-bar icon reads as the logo. Relay-side FCM sending ships in openchamber-website. * docs(mobile): refresh HANDOFF with current state, dev/deploy process, and CI gap * chore(mobile): iOS store-review prerequisites (privacy manifest, encryption flag) - Add the app's PrivacyInfo.xcprivacy (no tracking; required-reason UserDefaults for the App Group snapshot shared with the widget + notification service extension) and wire it into the App target's resources — Apple requires an app-level privacy manifest. - Set ITSAppUsesNonExemptEncryption=false to skip the per-build export-compliance prompt. - HANDOFF: add a store-review-readiness checklist (in-repo vs release-time console/infra items). Verified: plist lint, xcodebuild parse, and an iOS simulator build with PrivacyInfo.xcprivacy bundled into App.app. * refactor(mobile): dedupe capacitor detection + make beacon guard explicit Addresses non-blocking PR review notes: - Consolidate the repeated Capacitor-native check (mobileConnections, deepLinkNavigation, usePushVisibilityBeacon each redefined it) onto the single isCapacitorApp() in lib/platform. - usePushVisibilityBeacon now guards on isWebRuntime() OR isCapacitorApp() instead of relying on isWebRuntime() being true for Capacitor, so the beacon can't silently stop if that changes.
654 lines
24 KiB
JavaScript
654 lines
24 KiB
JavaScript
export const createNotificationTriggerRuntime = (deps) => {
|
|
const {
|
|
readSettingsFromDisk,
|
|
prepareNotificationLastMessage,
|
|
buildTemplateVariables,
|
|
extractLastMessageText,
|
|
fetchLastAssistantMessageText,
|
|
resolveNotificationTemplate,
|
|
shouldApplyResolvedTemplateMessage,
|
|
emitDesktopNotification,
|
|
broadcastUiNotification,
|
|
sendPushToAllUiSessions,
|
|
sendApnsToAllUiSessions,
|
|
isAnyInteractiveClientVisible,
|
|
buildOpenCodeUrl,
|
|
getOpenCodeAuthHeaders,
|
|
} = deps;
|
|
|
|
// App-icon badge for native push: the set of DISTINCT collapse-ids (the push
|
|
// `tag`, e.g. `ready-<sessionId>` / `permission-<requestKey>`) we've sent since
|
|
// the app was last foregrounded. The badge is the absolute APNs `aps.badge`.
|
|
//
|
|
// We key by `tag`, not sessionId, because the tag IS the banner identity: iOS
|
|
// uses it as `apns-collapse-id`, so same-tag pushes REPLACE one banner while
|
|
// different tags are distinct banners. One session can raise several banners
|
|
// (ready + question + permission are different tags), so counting sessionIds
|
|
// both over- and under-counts the lock-screen stack; counting tags mirrors it.
|
|
//
|
|
// We deliberately do NOT derive this from the live attention snapshot
|
|
// (needsAttention/isViewed): that machinery is for in-app indicators on
|
|
// connected clients — a backgrounded client stays "viewing", and needsAttention
|
|
// is set by a separate session.status event that races the push trigger. The
|
|
// set is cleared when a UI client reports visible (`clearPendingPushBadge`),
|
|
// the same moment the device zeroes its icon badge on becomeActive.
|
|
const pendingPushTags = new Set();
|
|
const clearPendingPushBadge = () => {
|
|
pendingPushTags.clear();
|
|
};
|
|
const trackPushAndCountBadge = (tag) => {
|
|
if (typeof tag === 'string' && tag.length > 0) {
|
|
pendingPushTags.add(tag);
|
|
}
|
|
return pendingPushTags.size;
|
|
};
|
|
|
|
// Generic notification for native push (per the mobile design): a fixed, scenario-based
|
|
// title + the session name as the body. No model/project/message content crosses the relay.
|
|
const APNS_TITLE_BY_TYPE = {
|
|
ready: 'Agent response is ready',
|
|
error: 'Agent hit an error',
|
|
question: 'Agent needs your input',
|
|
permission: 'Agent needs permission',
|
|
};
|
|
|
|
const toApnsGenericPayload = (payload) => {
|
|
const data = payload?.data && typeof payload.data === 'object' ? payload.data : {};
|
|
const sessionName = typeof data.sessionName === 'string' && data.sessionName.trim().length > 0
|
|
? data.sessionName.trim()
|
|
: 'Session';
|
|
return {
|
|
title: APNS_TITLE_BY_TYPE[data.type] || 'Agent update',
|
|
body: sessionName,
|
|
badge: trackPushAndCountBadge(typeof payload?.tag === 'string' ? payload.tag : undefined),
|
|
tag: payload?.tag,
|
|
// sessionId is forwarded so a tapped push can deep-link; it is an opaque id, not content.
|
|
data: typeof data.sessionId === 'string' ? { sessionId: data.sessionId } : undefined,
|
|
};
|
|
};
|
|
|
|
// Fan a notification out to every delivery channel: browser web-push (full templated
|
|
// payload) and native iOS APNs (generic model-based text). Both share the dedup tag and
|
|
// `requireNoSse` focus gate; a failure in one channel must not block the other.
|
|
const fanoutPush = (payload, options) => {
|
|
// Presence-aware routing: if any interactive (non-mobile) client — desktop/web/vscode — is
|
|
// currently visible, it already shows the in-app notification, so skip the native push to the
|
|
// phone. Gated on the desktop's visibility (reliable), never the phone's own. When we skip we
|
|
// also skip toApnsGenericPayload, so the badge isn't incremented for an undelivered push.
|
|
const interactiveVisible = isAnyInteractiveClientVisible?.() === true;
|
|
return Promise.all([
|
|
Promise.resolve(sendPushToAllUiSessions?.(payload, options)).catch((error) => {
|
|
console.warn('[Push] web-push fanout failed:', error?.message ?? error);
|
|
}),
|
|
interactiveVisible
|
|
? Promise.resolve()
|
|
: Promise.resolve(sendApnsToAllUiSessions?.(toApnsGenericPayload(payload), options)).catch((error) => {
|
|
console.warn('[APNs] fanout failed:', error?.message ?? error);
|
|
}),
|
|
]);
|
|
};
|
|
|
|
let getIsWindowFocused = typeof deps.getIsWindowFocused === 'function'
|
|
? deps.getIsWindowFocused
|
|
: null;
|
|
|
|
const setGetIsWindowFocused = (cb) => {
|
|
getIsWindowFocused = typeof cb === 'function' ? cb : null;
|
|
};
|
|
|
|
const PUSH_READY_COOLDOWN_MS = 5000;
|
|
const PUSH_QUESTION_DEBOUNCE_MS = 500;
|
|
const PUSH_PERMISSION_DEBOUNCE_MS = 500;
|
|
const pushQuestionDebounceTimers = new Map();
|
|
const pushPermissionDebounceTimers = new Map();
|
|
const notifiedPermissionRequests = new Set();
|
|
const lastReadyNotificationAt = new Map();
|
|
|
|
const sessionParentIdCache = new Map();
|
|
const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000;
|
|
|
|
// Sessions where the client has enabled Permission Auto-Accept. Mirrored
|
|
// from the client-side permissionStore via POST /api/notifications/auto-accept
|
|
// so the server can suppress permission notifications BEFORE dispatch (the
|
|
// 500ms debounce race otherwise leaks notifications for auto-accepted
|
|
// permissions when the replied round-trip is slower than the debounce).
|
|
const autoAcceptingSessions = new Set();
|
|
const setAutoAcceptSession = (sessionId, enabled) => {
|
|
if (typeof sessionId !== 'string' || sessionId.length === 0) return;
|
|
if (enabled) {
|
|
autoAcceptingSessions.add(sessionId);
|
|
} else {
|
|
autoAcceptingSessions.delete(sessionId);
|
|
}
|
|
};
|
|
|
|
const buildSessionDeepLinkUrl = (sessionId) => {
|
|
if (!sessionId || typeof sessionId !== 'string') {
|
|
return '/';
|
|
}
|
|
return `/?session=${encodeURIComponent(sessionId)}`;
|
|
};
|
|
|
|
const getCachedSessionParentId = (sessionId) => {
|
|
const entry = sessionParentIdCache.get(sessionId);
|
|
if (!entry) return undefined;
|
|
if (Date.now() - entry.at > SESSION_PARENT_CACHE_TTL_MS) {
|
|
sessionParentIdCache.delete(sessionId);
|
|
return undefined;
|
|
}
|
|
return entry.parentID;
|
|
};
|
|
|
|
const setCachedSessionParentId = (sessionId, parentID) => {
|
|
if (!parentID) return;
|
|
sessionParentIdCache.set(sessionId, { parentID: parentID ?? null, at: Date.now() });
|
|
};
|
|
|
|
const getParentIdFromPayload = (payload) => {
|
|
if (!payload || typeof payload !== 'object') return null;
|
|
if (payload.type !== 'session.created' && payload.type !== 'session.updated') return null;
|
|
const parentID = payload.properties?.info?.parentID ?? null;
|
|
return typeof parentID === 'string' && parentID.length > 0 ? parentID : null;
|
|
};
|
|
|
|
const maybeCacheSessionParentFromPayload = (payload) => {
|
|
const sessionId = extractSessionIdFromPayload(payload);
|
|
if (typeof sessionId !== 'string' || sessionId.length === 0) return;
|
|
const parentID = getParentIdFromPayload(payload);
|
|
if (parentID) {
|
|
setCachedSessionParentId(sessionId, parentID);
|
|
}
|
|
};
|
|
|
|
const fetchSessionParentId = async (sessionId) => {
|
|
if (!sessionId) return undefined;
|
|
|
|
const cached = getCachedSessionParentId(sessionId);
|
|
if (cached !== undefined) return cached;
|
|
|
|
try {
|
|
const response = await fetch(buildOpenCodeUrl('/session', ''), {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
...getOpenCodeAuthHeaders(),
|
|
},
|
|
signal: AbortSignal.timeout(2000),
|
|
});
|
|
if (!response.ok) {
|
|
return undefined;
|
|
}
|
|
const data = await response.json().catch(() => null);
|
|
const sessions = Array.isArray(data)
|
|
? data
|
|
: Array.isArray(data?.items)
|
|
? data.items
|
|
: Array.isArray(data?.data)
|
|
? data.data
|
|
: null;
|
|
if (!sessions) {
|
|
return undefined;
|
|
}
|
|
|
|
const match = sessions.find((session) => session && typeof session === 'object' && session.id === sessionId);
|
|
const parentID = match?.parentID ?? null;
|
|
setCachedSessionParentId(sessionId, parentID);
|
|
return parentID;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
// Mirrors client-side autoRespondsPermission: a session auto-accepts if it
|
|
// OR any ancestor is flagged. Walks the parent chain via fetchSessionParentId.
|
|
const isSessionAutoAccepting = async (sessionId) => {
|
|
if (!sessionId || autoAcceptingSessions.size === 0) return false;
|
|
let current = sessionId;
|
|
const seen = new Set();
|
|
while (current && !seen.has(current)) {
|
|
if (autoAcceptingSessions.has(current)) return true;
|
|
seen.add(current);
|
|
const parent = await fetchSessionParentId(current);
|
|
if (!parent) return false;
|
|
current = parent;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const extractSessionIdFromPayload = (payload) => {
|
|
if (!payload || typeof payload !== 'object') return null;
|
|
const props = payload.properties;
|
|
const info = props?.info;
|
|
const sessionId =
|
|
info?.sessionID ??
|
|
info?.sessionId ??
|
|
props?.sessionID ??
|
|
props?.sessionId ??
|
|
props?.session ??
|
|
null;
|
|
return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
};
|
|
|
|
const extractDirectoryFromPayload = (payload) => {
|
|
if (!payload || typeof payload !== 'object') return undefined;
|
|
const props = payload.properties;
|
|
const directory = props?.directory ?? props?.info?.directory;
|
|
if (typeof directory !== 'string') return undefined;
|
|
const trimmed = directory.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
};
|
|
|
|
const formatMode = (raw) => {
|
|
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
const normalized = value.length > 0 ? value : 'agent';
|
|
return normalized
|
|
.split(/[-_\s]+/)
|
|
.filter(Boolean)
|
|
.map((token) => token.charAt(0).toUpperCase() + token.slice(1))
|
|
.join(' ');
|
|
};
|
|
|
|
const formatModelId = (raw) => {
|
|
const value = typeof raw === 'string' ? raw.trim() : '';
|
|
if (!value) {
|
|
return 'Assistant';
|
|
}
|
|
|
|
const tokens = value.split(/[-_]+/).filter(Boolean);
|
|
const result = [];
|
|
for (let i = 0; i < tokens.length; i += 1) {
|
|
const current = tokens[i];
|
|
const next = tokens[i + 1];
|
|
if (/^\d+$/.test(current) && next && /^\d+$/.test(next)) {
|
|
result.push(`${current}.${next}`);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
result.push(current);
|
|
}
|
|
|
|
return result
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(' ');
|
|
};
|
|
|
|
const maybeSendPushForTrigger = async (payload) => {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return;
|
|
}
|
|
|
|
maybeCacheSessionParentFromPayload(payload);
|
|
|
|
const sessionId = extractSessionIdFromPayload(payload);
|
|
const notificationDirectory = extractDirectoryFromPayload(payload);
|
|
if (payload.type === 'message.updated') {
|
|
const info = payload.properties?.info;
|
|
if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) {
|
|
const settings = await readSettingsFromDisk();
|
|
|
|
if (settings.notifyOnSubtasks === false) {
|
|
const parentIDFromPayload = getParentIdFromPayload(payload);
|
|
const parentID = parentIDFromPayload
|
|
? parentIDFromPayload
|
|
: await fetchSessionParentId(sessionId);
|
|
|
|
if (parentID) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (settings.notifyOnCompletion === false) {
|
|
return;
|
|
}
|
|
|
|
if (settings.notificationMode !== 'always' && getIsWindowFocused?.()) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const lastAt = lastReadyNotificationAt.get(sessionId) ?? 0;
|
|
if (now - lastAt < PUSH_READY_COOLDOWN_MS) {
|
|
return;
|
|
}
|
|
lastReadyNotificationAt.set(sessionId, now);
|
|
|
|
let title = `${formatMode(info?.mode)} agent is ready`;
|
|
let body = `${formatModelId(info?.modelID)} completed the task`;
|
|
let sessionName = '';
|
|
|
|
try {
|
|
const templates = settings.notificationTemplates || {};
|
|
const isSubtask = await fetchSessionParentId(sessionId);
|
|
const completionTemplate = isSubtask && settings.notifyOnSubtasks !== false
|
|
? (templates.subtask || templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' })
|
|
: (templates.completion || { title: '{agent_name} is ready', message: '{model_name} completed the task' });
|
|
|
|
const variables = await buildTemplateVariables(payload, sessionId);
|
|
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
|
|
|
|
const messageId = info?.id;
|
|
let lastMessage = extractLastMessageText(payload);
|
|
if (!lastMessage) {
|
|
lastMessage = await fetchLastAssistantMessageText(sessionId, messageId);
|
|
}
|
|
|
|
variables.last_message = await prepareNotificationLastMessage({
|
|
message: lastMessage,
|
|
settings,
|
|
});
|
|
|
|
const resolvedTitle = resolveNotificationTemplate(completionTemplate.title, variables);
|
|
const resolvedBody = resolveNotificationTemplate(completionTemplate.message, variables);
|
|
if (resolvedTitle) title = resolvedTitle;
|
|
if (shouldApplyResolvedTemplateMessage(completionTemplate.message, resolvedBody, variables)) body = resolvedBody;
|
|
} catch (error) {
|
|
console.warn('[Notification] Template resolution failed, using defaults:', error?.message || error);
|
|
}
|
|
|
|
if (settings.nativeNotificationsEnabled) {
|
|
const notificationPayload = {
|
|
title,
|
|
body,
|
|
tag: `ready-${sessionId}`,
|
|
kind: 'ready',
|
|
sessionId,
|
|
directory: notificationDirectory,
|
|
requireHidden: settings.notificationMode !== 'always',
|
|
};
|
|
const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
|
|
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
|
|
}
|
|
|
|
await fanoutPush(
|
|
{
|
|
title,
|
|
body,
|
|
tag: `ready-${sessionId}`,
|
|
data: {
|
|
url: buildSessionDeepLinkUrl(sessionId),
|
|
sessionId,
|
|
sessionName,
|
|
type: 'ready',
|
|
},
|
|
},
|
|
{ requireNoSse: true },
|
|
);
|
|
}
|
|
|
|
if (info?.role === 'assistant' && info?.finish === 'error' && sessionId) {
|
|
const settings = await readSettingsFromDisk();
|
|
if (settings.notifyOnError === false) return;
|
|
|
|
if (settings.notificationMode !== 'always' && getIsWindowFocused?.()) {
|
|
return;
|
|
}
|
|
|
|
let title = 'Tool error';
|
|
let body = 'An error occurred';
|
|
let sessionName = '';
|
|
|
|
try {
|
|
const variables = await buildTemplateVariables(payload, sessionId);
|
|
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
|
|
const errorMessageId = info?.id;
|
|
let lastMessage = extractLastMessageText(payload);
|
|
if (!lastMessage) {
|
|
lastMessage = await fetchLastAssistantMessageText(sessionId, errorMessageId);
|
|
}
|
|
|
|
variables.last_message = await prepareNotificationLastMessage({
|
|
message: lastMessage,
|
|
settings,
|
|
});
|
|
|
|
const errorTemplate = (settings.notificationTemplates || {}).error || { title: 'Tool error', message: '{last_message}' };
|
|
const resolvedTitle = resolveNotificationTemplate(errorTemplate.title, variables);
|
|
const resolvedBody = resolveNotificationTemplate(errorTemplate.message, variables);
|
|
if (resolvedTitle) title = resolvedTitle;
|
|
if (shouldApplyResolvedTemplateMessage(errorTemplate.message, resolvedBody, variables)) body = resolvedBody;
|
|
} catch (error) {
|
|
console.warn('[Notification] Error template resolution failed, using defaults:', error?.message || error);
|
|
}
|
|
|
|
if (settings.nativeNotificationsEnabled) {
|
|
const notificationPayload = {
|
|
title,
|
|
body,
|
|
tag: `error-${sessionId}`,
|
|
kind: 'error',
|
|
sessionId,
|
|
directory: notificationDirectory,
|
|
requireHidden: settings.notificationMode !== 'always',
|
|
};
|
|
const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
|
|
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
|
|
}
|
|
|
|
await fanoutPush(
|
|
{
|
|
title,
|
|
body,
|
|
tag: `error-${sessionId}`,
|
|
data: {
|
|
url: buildSessionDeepLinkUrl(sessionId),
|
|
sessionId,
|
|
sessionName,
|
|
type: 'error',
|
|
},
|
|
},
|
|
{ requireNoSse: true },
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (payload.type === 'question.asked' && sessionId) {
|
|
const existingTimer = pushQuestionDebounceTimers.get(sessionId);
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer);
|
|
}
|
|
|
|
const timer = setTimeout(async () => {
|
|
pushQuestionDebounceTimers.delete(sessionId);
|
|
|
|
const settings = await readSettingsFromDisk();
|
|
if (settings.notifyOnQuestion === false) {
|
|
return;
|
|
}
|
|
|
|
if (settings.notificationMode !== 'always' && getIsWindowFocused?.()) {
|
|
return;
|
|
}
|
|
|
|
const firstQuestion = payload.properties?.questions?.[0];
|
|
const header = typeof firstQuestion?.header === 'string' ? firstQuestion.header.trim() : '';
|
|
const questionText = typeof firstQuestion?.question === 'string' ? firstQuestion.question.trim() : '';
|
|
|
|
let title = /plan\s*mode/i.test(header)
|
|
? 'Switch to plan mode'
|
|
: /build\s*agent/i.test(header)
|
|
? 'Switch to build mode'
|
|
: header || 'Input needed';
|
|
let body = questionText || 'Agent is waiting for your response';
|
|
let sessionName = '';
|
|
|
|
try {
|
|
const variables = await buildTemplateVariables(payload, sessionId);
|
|
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
|
|
variables.last_message = questionText || header || '';
|
|
|
|
const templates = settings.notificationTemplates || {};
|
|
const questionTemplate = templates.question || { title: 'Input needed', message: '{last_message}' };
|
|
|
|
const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables);
|
|
const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables);
|
|
if (resolvedTitle) title = resolvedTitle;
|
|
if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody;
|
|
} catch (error) {
|
|
console.warn('[Notification] Question template resolution failed, using defaults:', error?.message || error);
|
|
}
|
|
|
|
if (settings.nativeNotificationsEnabled) {
|
|
const notificationPayload = {
|
|
kind: 'question',
|
|
title,
|
|
body,
|
|
tag: `question-${sessionId}`,
|
|
sessionId,
|
|
directory: notificationDirectory,
|
|
requireHidden: settings.notificationMode !== 'always',
|
|
};
|
|
const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
|
|
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
|
|
}
|
|
|
|
void fanoutPush(
|
|
{
|
|
title,
|
|
body,
|
|
tag: `question-${sessionId}`,
|
|
data: {
|
|
url: buildSessionDeepLinkUrl(sessionId),
|
|
sessionId,
|
|
sessionName,
|
|
type: 'question',
|
|
},
|
|
},
|
|
{ requireNoSse: true },
|
|
);
|
|
}, PUSH_QUESTION_DEBOUNCE_MS);
|
|
|
|
pushQuestionDebounceTimers.set(sessionId, timer);
|
|
return;
|
|
}
|
|
|
|
if (payload.type === 'permission.replied' && sessionId) {
|
|
const requestId = payload.properties?.requestID ?? payload.properties?.requestId ?? payload.properties?.id;
|
|
const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null;
|
|
const pendingNotification = pushPermissionDebounceTimers.get(sessionId);
|
|
if (!pendingNotification) {
|
|
return;
|
|
}
|
|
|
|
// Some runtimes may omit requestID on permission.replied.
|
|
// When request ID is missing, clear session debounce to avoid
|
|
// showing stale permission notifications for auto-approved prompts.
|
|
if (!requestKey || !pendingNotification.requestKey || pendingNotification.requestKey === requestKey) {
|
|
clearTimeout(pendingNotification.timer);
|
|
pushPermissionDebounceTimers.delete(sessionId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (payload.type === 'permission.asked' && sessionId) {
|
|
const requestId = payload.properties?.id ?? payload.properties?.requestID ?? payload.properties?.requestId;
|
|
const permission = payload.properties?.permission;
|
|
const requestKey = typeof requestId === 'string' ? `${sessionId}:${requestId}` : null;
|
|
if (requestKey && notifiedPermissionRequests.has(requestKey)) {
|
|
return;
|
|
}
|
|
|
|
// Client may be in Permission Auto-Accept for this session (or any
|
|
// ancestor). Skip the whole notification path — the client responds
|
|
// directly and the user has opted out of approval prompts.
|
|
if (await isSessionAutoAccepting(sessionId)) {
|
|
if (requestKey) notifiedPermissionRequests.add(requestKey);
|
|
return;
|
|
}
|
|
|
|
const existingTimer = pushPermissionDebounceTimers.get(sessionId);
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer.timer);
|
|
}
|
|
|
|
const timer = setTimeout(async () => {
|
|
pushPermissionDebounceTimers.delete(sessionId);
|
|
|
|
if (await isSessionAutoAccepting(sessionId)) {
|
|
if (requestKey) notifiedPermissionRequests.add(requestKey);
|
|
return;
|
|
}
|
|
|
|
const settings = await readSettingsFromDisk();
|
|
|
|
if (settings.notifyOnQuestion === false) {
|
|
return;
|
|
}
|
|
|
|
if (settings.notificationMode !== 'always' && getIsWindowFocused?.()) {
|
|
return;
|
|
}
|
|
|
|
const sessionTitle = payload.properties?.sessionTitle;
|
|
const permissionText = typeof permission === 'string' && permission.length > 0 ? permission : '';
|
|
const fallbackMessage = typeof sessionTitle === 'string' && sessionTitle.trim().length > 0
|
|
? sessionTitle.trim()
|
|
: permissionText || 'Agent is waiting for your approval';
|
|
|
|
let title = 'Permission required';
|
|
let body = fallbackMessage;
|
|
let sessionName = '';
|
|
|
|
try {
|
|
const variables = await buildTemplateVariables(payload, sessionId);
|
|
sessionName = typeof variables.session_name === 'string' ? variables.session_name : sessionName;
|
|
variables.last_message = fallbackMessage;
|
|
|
|
const templates = settings.notificationTemplates || {};
|
|
const questionTemplate = templates.question || { title: 'Permission required', message: '{last_message}' };
|
|
|
|
const resolvedTitle = resolveNotificationTemplate(questionTemplate.title, variables);
|
|
const resolvedBody = resolveNotificationTemplate(questionTemplate.message, variables);
|
|
if (resolvedTitle) title = resolvedTitle;
|
|
if (shouldApplyResolvedTemplateMessage(questionTemplate.message, resolvedBody, variables)) body = resolvedBody;
|
|
} catch (error) {
|
|
console.warn('[Notification] Permission template resolution failed, using defaults:', error?.message || error);
|
|
}
|
|
|
|
if (settings.nativeNotificationsEnabled) {
|
|
const notificationPayload = {
|
|
kind: 'permission',
|
|
title,
|
|
body,
|
|
tag: requestKey ? `permission-${requestKey}` : `permission-${sessionId}`,
|
|
sessionId,
|
|
directory: notificationDirectory,
|
|
requireHidden: settings.notificationMode !== 'always',
|
|
};
|
|
const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
|
|
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
|
|
}
|
|
|
|
if (requestKey) {
|
|
notifiedPermissionRequests.add(requestKey);
|
|
}
|
|
|
|
void fanoutPush(
|
|
{
|
|
title,
|
|
body,
|
|
tag: `permission-${sessionId}`,
|
|
data: {
|
|
url: buildSessionDeepLinkUrl(sessionId),
|
|
sessionId,
|
|
sessionName,
|
|
type: 'permission',
|
|
},
|
|
},
|
|
{ requireNoSse: true },
|
|
);
|
|
}, PUSH_PERMISSION_DEBOUNCE_MS);
|
|
|
|
pushPermissionDebounceTimers.set(sessionId, { timer, requestKey });
|
|
}
|
|
};
|
|
|
|
return {
|
|
maybeSendPushForTrigger,
|
|
setAutoAcceptSession,
|
|
setGetIsWindowFocused,
|
|
clearPendingPushBadge,
|
|
};
|
|
};
|