import { CreateEventInput, CreateSessionInput, UpdateSessionInput, UpdateEventStatusInput, Event, Session, EventSource, DeviceKey, GrantSessionKeyInput } from '../types'; /** * Download URL response */ export interface DownloadUrlResponse { downloadUrl: string; expiresAt: string; } /** * AppSync GraphQL client with WebSocket subscriptions */ export declare class AppSyncClient { private authenticated; private currentUserId; private currentEmail; private tokens; private activeSubscriptions; private pendingRefresh; private lastRefreshFailureAt; private static readonly REFRESH_BACKOFF_MS; private deviceKeyWatcher; private sessionUpdateWatchers; private statusWriteChains; private environment; constructor(); /** * Get the current authenticated user ID */ getCurrentUserId(): string; /** * Get the current authenticated user email */ getCurrentUserEmail(): string | null; /** * Authenticate using stored OAuth tokens from keychain */ authenticateWithStoredTokens(): Promise; /** * Refresh expired tokens */ private refreshTokens; /** * Do the work of refreshing tokens. Tries the caller-supplied tokens * first; on failure, re-reads from storage and retries once with a * potentially-fresher refresh token. This is the self-healing path * that lets `codevibe login` (which writes new tokens to the * keychain) recover running daemons without requiring a restart: * the in-memory copy the daemon cached at boot may be invalid, but * whatever the user just wrote from the login flow is still valid. * * Splitting the two attempts into a pure-network helper keeps the * orchestration readable without duplicating the fetch + body-shape * plumbing. */ private performRefresh; /** * POST to Cognito's /oauth2/token with a refresh_token grant. * Returns the parsed body on 200, or null on any failure (network * error, non-2xx response, JSON parse failure). Caller decides how * to proceed — this helper is side-effect-free beyond logging. */ private callCognitoRefresh; /** * Apply a successful refresh response: update in-memory cache first, * clear the backoff sentinel, then persist to storage. Success here * is defined as "the process has usable fresh tokens in memory" — * storage persistence is degraded-success, not a failure mode. * * Ordering matters. The API call to Cognito already succeeded, which * means we hold valid access/id tokens right now. If we delayed the * in-memory update until after persistence and the keychain write * threw (keychain locked, disk full, file-backend permission error), * we'd be stuck with stale-and-known-dead tokens in memory while * holding valid fresh tokens in local scope that vanish at the end * of this function. That would re-break both guarantees this hotfix * makes: no-restart recovery becomes "restart required to escape * the keychain-lock window," and backoff stays unarmed so the * caller hot-loops against a working Cognito endpoint — R1's MEDIUM * on round 1 of this review. Persistence-failure is loud-logged * so operators can see degraded durability without losing availability. */ private applyRefreshedTokens; /** * Check if authenticated */ isAuthenticated(): boolean; /** * Sign out */ signOut(): void; /** * Make a GraphQL request */ private graphqlRequest; /** * Create a session */ createSession(input: CreateSessionInput): Promise; /** * Update a session */ updateSession(input: UpdateSessionInput): Promise; private doUpdateSession; /** * Get a session */ getSession(sessionId: string): Promise; /** * Create an event */ createEvent(input: CreateEventInput): Promise; /** * Update event status */ updateEventStatus(input: UpdateEventStatusInput): Promise; /** * List events for a session */ listEvents(sessionId: string, source?: EventSource, limit?: number): Promise; /** * List the authenticated user's sessions. Paginates automatically * via nextToken so callers always get the complete set. */ listSessions(limit?: number): Promise>; /** * Mark stale ACTIVE sessions of a given agentType INACTIVE so they * stop appearing in the mobile app's session list. Called at daemon * startup to clean up after daemons that died without running their * graceful shutdown (crash, auth-loop death, force-kill, power loss). * * Staleness rule: lastHeartbeatAt is older than `staleThresholdMs` * (default 15 min — a conservative ~7.5× the 2-min heartbeat * interval, giving legitimately-active daemons on other machines * ample margin before we consider their session abandoned). * * Safety: * - Only sessions with status === 'ACTIVE' are candidates. * - Sessions explicitly listed in `excludeSessionIds` are skipped * (caller can pass the session the daemon is about to attach to * if the ID is known before the sweep). * - Absent `lastHeartbeatAt` (never-heartbeated sessions — should * only happen for rows created within the last few seconds) * treats the session as fresh and skips. * - updateSession failures are logged as warnings and don't abort * the sweep — best-effort cleanup. * * Returns the number of sessions actually marked INACTIVE. */ sweepOrphanSessions(opts: { agentType: string; staleThresholdMs?: number; excludeSessionIds?: string[]; }): Promise; /** * List user device keys */ listUserDeviceKeys(): Promise; /** * Register device key */ registerDeviceKey(deviceId: string, publicKey: string, platform: string, deviceName: string): Promise; /** * Grant a session key to an additional device by appending a new * per-device wrapped key blob to the session's encryptedKeys array. * * This is the client-side entry point for the session re-key flow: * when a new device (typically a mobile app) registers for a user who * already has active sessions, an already-enrolled device (typically * the desktop plugin) wraps the session key for the new device's * public key and calls this mutation to append it. The backend VTL * resolver enforces that the caller owns the session. */ grantSessionKey(input: GrantSessionKeyInput): Promise; /** * Get attachment download URL */ getAttachmentDownloadUrl(s3Key: string): Promise; /** * Subscribe to events for a session */ subscribeToEvents(sessionId: string, onEvent: (event: Event) => void, onError?: (error: Error) => void): () => void; /** * Build WebSocket URL * * AppSync exposes the realtime endpoint two different ways depending on * the GraphQL endpoint shape: * * 1. Direct AppSync URL (`.appsync-api..amazonaws.com`): * realtime is on a sibling subdomain — swap `appsync-api` → * `appsync-realtime-api` and keep the `/graphql` path. * → wss://.appsync-realtime-api..amazonaws.com/graphql * * 2. Custom domain (`api.codevibe.quantiya.ai` and friends): * realtime is path-based on the SAME hostname — append `/realtime` * to the GraphQL path. * → wss://api.codevibe.quantiya.ai/graphql/realtime * AWS docs: * https://docs.aws.amazon.com/appsync/latest/devguide/custom-domain-name.html * * The naive `.replace('appsync-api', 'appsync-realtime-api')` only works * for path 1; on a custom-domain URL it doesn't match anything and the * resulting `wss:///graphql` URL never completes the handshake. */ private buildRealtimeUrl; /** * Create WebSocket subscription */ private createSubscription; /** * Send subscription start message */ private sendSubscriptionStart; /** * Reset keep-alive timer */ private resetKeepAliveTimer; /** * Handle subscription error with two-phase reconnection: * Phase 1 (urgent): Exponential backoff for first N attempts * Phase 2 (persistent): Fixed interval retry indefinitely */ private handleSubscriptionError; /** * Cleanup subscription state */ private cleanupSubscriptionState; /** * Subscribe to device key registrations for the caller's own userId. * Returns a cleanup function that stops the subscription. */ subscribeToDeviceKeyRegistered(userId: string, onNewDevice: (device: DeviceKey) => void, onReconnect?: () => void, onError?: (error: Error) => void): () => void; /** * Public method to stop the device key watcher. Used at plugin shutdown. */ stopDeviceKeyWatcher(): void; private stopDeviceKeyWatcherInternal; private createDeviceKeyWatcherConnection; private sendDeviceKeyWatcherStart; private resetDeviceKeyWatcherKeepAlive; private handleDeviceKeyWatcherError; /** * Subscribe to status changes on our own session and invoke * onMobileEndRequested when status transitions to INACTIVE — typically * meaning the user tapped "End Session" in the mobile app. * * The callback is invoked from the WebSocket message handler. The * helper catches and logs WARN any error thrown by the callback so it * does not propagate to the subscription handler (would tear down the * WebSocket). * * Returns a stop() function the plugin MUST call BEFORE issuing its * own updateSession({ status: INACTIVE }) mutation in the per-session * cleanup handler — otherwise the desktop's own end-of-session * mutation echoes back through this subscription as a self-caused * callback. See §D.3.a in the design doc. Calling stop() more than * once is idempotent. * * Replace-on-duplicate-sessionId semantics: calling watchForMobileEnd * twice with the same sessionId stops the prior watcher and replaces * it with the new one, so at most one watcher is alive per sessionId. * Matches subscribeToEvents (above). Plugins should still call once * per session lifecycle; the replace path exists to make resume / * re-entry safe. * * Initial state assumed = ACTIVE: AppSync subscriptions are * mutation-triggered (NOT current-state snapshots), and plugins only * call this for sessions just created or resumed in ACTIVE state. The * first delivered status=INACTIVE payload IS the real signal and * fires the callback. * * Idempotent firing: callback fires AT MOST ONCE per helper instance. * Subsequent INACTIVE payloads (or any payload after fire) are * ignored. Plugin must rebuild the helper for a new session. */ watchForMobileEnd(sessionId: string, onMobileEndRequested: () => Promise | void): { stop: () => void; }; private createSessionUpdateWatcherConnection; /** * Process a delivered onSessionUpdated payload. Decides whether to * fire the callback per the locked rules in §A.2 of the design doc. */ private handleSessionUpdatePayload; private sendSessionUpdateWatcherStart; private resetSessionUpdateWatcherKeepAlive; private handleSessionUpdateWatcherError; private cleanupSessionUpdateWatcherState; private heartbeatTimers; /** * Start periodic heartbeat for a session. * Updates lastHeartbeatAt on the session every intervalMs (default 2 minutes). */ startHeartbeat(sessionId: string, intervalMs?: number): void; /** * Stop heartbeat for a session. */ stopHeartbeat(sessionId: string): void; /** * Send a single heartbeat update. */ private sendHeartbeat; /** * Tear down the mobile-event subscription for a SINGLE session. * * Use this when a session ends or is superseded by a /resume while the * daemon keeps running — cleanupSubscriptions() tears down ALL * subscriptions and is only appropriate on full daemon shutdown. * * Without per-session teardown, an ended session's WebSocket survives in * the still-running daemon. When AppSync idle-closes it (code 1000) the * reconnect path in the start_ack handler re-runs startHeartbeat() and * re-asserts status: ACTIVE — resurrecting the ended session as a * duplicate row that the mobile app shows as a second live session. * cleanupSubscriptionState() sets state.destroyed = true, which is exactly * the flag the start_ack and 'close' handlers check to bail out, so this * closes the resurrection path. * * (Confirmed in production logs 2026-06-04: a resumed-over Claude session * was marked INACTIVE at 00:23:34 and resurrected to ACTIVE at 03:02:05 * when its leaked subscription reconnected after an AppSync code-1000 * close.) * * Idempotent: a no-op if no subscription is tracked for sessionId. */ cleanupSubscription(sessionId: string): void; /** * Cleanup all subscriptions and heartbeats */ cleanupSubscriptions(): void; }