diff --git a/.gitignore b/.gitignore index 83ee0ef..1b4d9ad 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,7 @@ apps/mobile/ios/build/ apps/mobile/office-game/dist/ apps/mobile/office-game/node_modules/ +# Claude Code / oh-my-claudecode session state +.omc/ + apps/*/wrangler.local.toml diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx index 10ac2b7..63feb30 100644 --- a/apps/mobile/App.tsx +++ b/apps/mobile/App.tsx @@ -238,7 +238,13 @@ const OFFICE_DEV_PORT = 5174; const DEV_URL_OVERRIDE = process.env.EXPO_PUBLIC_OFFICE_DEV_URL; // eslint-disable-next-line @typescript-eslint/no-var-requires -const officeHtmlString: string = require('./office-game/dist/office-inline.js').html; +let officeHtmlString: string = ''; +try { + officeHtmlString = require('./office-game/dist/office-inline.js').html; +} catch (e) { + console.warn('Office game bundle not found, using fallback:', (e as Error).message); + officeHtmlString = ''; +} const OFFICE_HTML_BG_REGEX = /(html,\s*body\s*\{[^}]*background-color:\s*)([^;]+)(;)/; @@ -247,10 +253,13 @@ const GATEWAY_KEEPALIVE_INTERVAL_MS = 5_000; function resolveDevHost(): string { const scriptURL = (NativeModules as { SourceCode?: { scriptURL?: string } }).SourceCode?.scriptURL; - if (!scriptURL) return 'localhost'; + if (!scriptURL) { + return 'localhost'; + } try { - const { hostname } = new URL(scriptURL); - return hostname || 'localhost'; + const url = new URL(scriptURL); + const hostname = url.hostname || 'localhost'; + return hostname; } catch { return 'localhost'; } @@ -258,7 +267,9 @@ function resolveDevHost(): string { function resolveOfficeDevUrl(): string { const override = DEV_URL_OVERRIDE?.trim(); - if (override) return override; + if (override) { + return override; + } const host = resolveDevHost(); return `http://${host}:${OFFICE_DEV_PORT}`; } diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 3a423cc..d69d4ba 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Clawket", "slug": "clawket", - "version": "2.1.0", + "version": "3.0.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -14,7 +14,7 @@ }, "ios": { "supportsTablet": false, - "bundleIdentifier": "com.p697.clawket", + "bundleIdentifier": "com.binarybros.clawket", "infoPlist": { "NSCameraUsageDescription": "Allow Clawket to use your camera to scan QR codes and take photos to send to AI.", "NSPhotoLibraryAddUsageDescription": "Allow Clawket to save stats posters to your photo library.", @@ -32,7 +32,8 @@ "CFBundleDevelopmentRegion": "en", "UIPrefersShowingLanguageSettings": true }, - "appleTeamId": "C8TM82D73W" + "appleTeamId": "ZWT3N8XKE7", + "buildNumber": "260626001" }, "android": { "adaptiveIcon": { @@ -126,6 +127,6 @@ "projectId": "972e845f-da81-44db-a908-24be4ca80288" } }, - "owner": "p697" + "owner": "binarybros" } } diff --git a/apps/mobile/ios/SceneDelegate.swift b/apps/mobile/ios/SceneDelegate.swift new file mode 100644 index 0000000..0075dc7 --- /dev/null +++ b/apps/mobile/ios/SceneDelegate.swift @@ -0,0 +1,35 @@ + import UIKit + + class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + // Scene setup (e.g., initial view controller) + guard let windowScene = scene as? UIWindowScene else { return } + window = UIWindow(windowScene: windowScene) + // Assign your root view controller (e.g., from AppDelegate or React Native entry point) + window?.rootViewController = UIViewController() // Replace with your app's root (e.g., from Expo) + window?.makeKeyAndVisible() + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Cleanup when scene disconnects + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // App became active + } + + func sceneWillResignActive(_ scene: UIScene) { + // App will resign active + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // App entering foreground + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // App entered background + } + } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 0a79b3e..36eed77 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "clawket", - "version": "2.1.0", + "version": "3.0.0", "license": "AGPL-3.0-only", "main": "index.ts", "scripts": { diff --git a/apps/mobile/src/bootstrap/useAppBootstrap.ts b/apps/mobile/src/bootstrap/useAppBootstrap.ts index 6963762..0c793ed 100644 --- a/apps/mobile/src/bootstrap/useAppBootstrap.ts +++ b/apps/mobile/src/bootstrap/useAppBootstrap.ts @@ -25,7 +25,7 @@ type Props = { function buildAgentPreview( agentId: string, - backendKind: 'openclaw' | 'hermes' | 'youmind', + backendKind: 'openclaw' | 'hermes' | 'youmind' | 'agentzero', identity?: { agentName?: string; agentEmoji?: string; diff --git a/apps/mobile/src/components/chat/PairingPendingCard.tsx b/apps/mobile/src/components/chat/PairingPendingCard.tsx index e44cb96..85d868f 100644 --- a/apps/mobile/src/components/chat/PairingPendingCard.tsx +++ b/apps/mobile/src/components/chat/PairingPendingCard.tsx @@ -8,7 +8,7 @@ type Props = { approveCommand: string; copied: boolean; onCopy: () => void; - connectionMode?: 'relay' | 'local' | 'tailscale' | 'cloudflare' | 'custom' | 'hermes'; + connectionMode?: 'relay' | 'local' | 'tailscale' | 'cloudflare' | 'custom' | 'hermes' | 'agentzero'; onRetry?: () => void; }; diff --git a/apps/mobile/src/features/app-updates/releases.ts b/apps/mobile/src/features/app-updates/releases.ts index 0fe5622..cf7e45d 100644 --- a/apps/mobile/src/features/app-updates/releases.ts +++ b/apps/mobile/src/features/app-updates/releases.ts @@ -52,6 +52,23 @@ export const DEFAULT_APP_UPDATE_DEBUG_HINT = // Keep this array newest-first. The first entry is treated as the latest release. export const APP_UPDATE_RELEASES: AppUpdateRelease[] = [ + { + version: '3.0.0', + releasedAt: '2026-05-30', + entries: [ + { + id: 'hermes-tailscale-endpoint', + emoji: '🪽', + title: 'Hermes over Tailscale', + subtitle: 'Reach your Hermes bridge by MagicDNS — no more ephemeral tunnels expiring.', + action: { + type: 'navigate_config_add_connection', + tab: 'quick', + flow: 'local', + }, + }, + ], + }, { version: '2.1.0', releasedAt: '2026-04-12', diff --git a/apps/mobile/src/hooks/gatewayScanFlow.ts b/apps/mobile/src/hooks/gatewayScanFlow.ts index 3d59760..850bda3 100644 --- a/apps/mobile/src/hooks/gatewayScanFlow.ts +++ b/apps/mobile/src/hooks/gatewayScanFlow.ts @@ -59,6 +59,7 @@ export function toRuntimeConfig(item: SavedGatewayConfig, debugMode: boolean): G transportKind, mode: toLegacyGatewayMode({ backendKind, transportKind }), hermes: item.hermes, + agentzero: item.agentzero, relay: item.relay, debugMode, }; diff --git a/apps/mobile/src/hooks/useGatewayConfigForm.test.ts b/apps/mobile/src/hooks/useGatewayConfigForm.test.ts index 6beb79b..e506f43 100644 --- a/apps/mobile/src/hooks/useGatewayConfigForm.test.ts +++ b/apps/mobile/src/hooks/useGatewayConfigForm.test.ts @@ -620,4 +620,97 @@ describe('useGatewayConfigForm', () => { expect(gateway.disconnect).toHaveBeenCalledTimes(1); expect(onReset).toHaveBeenCalledTimes(1); }); + + it('saves an Agent Zero connection with backendKind=agentzero, normalized bridgeUrl, and token', async () => { + const onSaved = jest.fn(); + const gateway = { + disconnect: jest.fn(), + configure: jest.fn(), + connect: jest.fn(), + getDeviceIdentity: jest.fn().mockResolvedValue({ deviceId: 'device-az' }), + } as any; + + const { result } = renderHook(() => + useGatewayConfigForm({ + gateway, + initialConfig: null, + debugMode: false, + onSaved, + onReset: jest.fn(), + }), + ); + + await act(async () => { + result.current.openCreateEditor('manual'); + }); + + await act(async () => { + result.current.setEditorBackendKind('agentzero'); + result.current.setEditorUrl('https://agent-habitat.capybara-loggerhead.ts.net:8443/'); + result.current.setEditorAuthMethod('token'); + result.current.setEditorToken('6Gv7AhbIbZ8CEjUb'); + result.current.setEditorName('Habitat AZ'); + }); + + await act(async () => { + await result.current.saveEditor(); + }); + + const lastCall = (StorageService.setGatewayConfigsState as jest.Mock).mock.calls.at(-1)?.[0]; + expect(lastCall.configs[0]).toMatchObject({ + name: 'Habitat AZ', + backendKind: 'agentzero', + transportKind: 'custom', + mode: 'agentzero', + url: 'https://agent-habitat.capybara-loggerhead.ts.net:8443/', + token: '6Gv7AhbIbZ8CEjUb', + password: undefined, + hermes: undefined, + agentzero: { + // Trailing slash stripped, no path appended. + bridgeUrl: 'https://agent-habitat.capybara-loggerhead.ts.net:8443', + }, + }); + expect(onSaved).toHaveBeenCalled(); + }); + + it('rejects an Agent Zero connection without a token (X-API-KEY is required)', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); + const onSaved = jest.fn(); + const gateway = { + disconnect: jest.fn(), + configure: jest.fn(), + connect: jest.fn(), + getDeviceIdentity: jest.fn().mockResolvedValue({ deviceId: 'device-az' }), + } as any; + + const { result } = renderHook(() => + useGatewayConfigForm({ + gateway, + initialConfig: null, + debugMode: false, + onSaved, + onReset: jest.fn(), + }), + ); + + await act(async () => { + result.current.openCreateEditor('manual'); + }); + + await act(async () => { + result.current.setEditorBackendKind('agentzero'); + result.current.setEditorUrl('https://agent-habitat.capybara-loggerhead.ts.net:8443'); + // no token + }); + + await act(async () => { + await result.current.saveEditor(); + }); + + expect(alertSpy).toHaveBeenCalledWith('Missing Auth', expect.any(String)); + expect(StorageService.setGatewayConfigsState).not.toHaveBeenCalled(); + expect(onSaved).not.toHaveBeenCalled(); + alertSpy.mockRestore(); + }); }); diff --git a/apps/mobile/src/hooks/useGatewayConfigForm.ts b/apps/mobile/src/hooks/useGatewayConfigForm.ts index 6db0b80..93b418f 100644 --- a/apps/mobile/src/hooks/useGatewayConfigForm.ts +++ b/apps/mobile/src/hooks/useGatewayConfigForm.ts @@ -63,6 +63,10 @@ function normalizeEditorTransportKind(backendKind: GatewayBackendKind, transport if (backendKind === 'youmind') { return 'custom'; } + if (backendKind === 'agentzero') { + // AZ is plain HTTP to its web server; no relay/local/tailscale distinction. + return 'custom'; + } return transportKind; } @@ -88,6 +92,35 @@ function deriveHermesBridgeConfig( } } +/** + * Agent Zero wants a base HTTP URL (e.g. https://agent-habitat.capybara-loggerhead.ts.net:8443). + * We accept ws://… too as a courtesy — users frequently paste their Hermes-style URL + * by accident — and rewrite to http(s). + */ +function deriveAgentZeroConfig( + url: string, + existing?: SavedGatewayConfig['agentzero'], +): SavedGatewayConfig['agentzero'] | undefined { + const trimmed = url.trim(); + if (!trimmed) { + return existing; + } + try { + const parsed = new URL(trimmed.replace(/^ws:/, 'http:').replace(/^wss:/, 'https:')); + parsed.hash = ''; + parsed.search = ''; + // AZ has no canonical chat path; strip anything the user accidentally pasted. + parsed.pathname = '/'; + return { + bridgeUrl: parsed.toString().replace(/\/+$/, ''), + ...(existing?.displayName ? { displayName: existing.displayName } : {}), + ...(existing?.projectName ? { projectName: existing.projectName } : {}), + }; + } catch { + return existing; + } +} + function detectAuthMethod(token?: string, password?: string): GatewayAuthMethod { if ((token ?? '').trim()) return 'token'; if ((password ?? '').trim()) return 'password'; @@ -187,7 +220,12 @@ export function useGatewayConfigForm({ gateway, initialConfig, debugMode, onSave () => toLegacyGatewayMode({ backendKind: editorBackendKind, transportKind: manualEditorTransportKind }), [editorBackendKind, manualEditorTransportKind], ); - const editorRequiresDirectAuth = editorBackendKind === 'openclaw' && manualEditorTransportKind !== 'relay'; + // OpenClaw wants a chat-server token/password; Agent Zero wants its X-API-KEY + // surfaced via the same token field. Hermes embeds its token directly in the + // ws:// URL query string, so it doesn't need a separate auth input. + const editorRequiresDirectAuth = + (editorBackendKind === 'openclaw' && manualEditorTransportKind !== 'relay') + || editorBackendKind === 'agentzero'; const setEditorBackendKind = useCallback((nextBackendKind: GatewayBackendKind) => { setEditorBackendKindState(nextBackendKind); @@ -474,11 +512,16 @@ export function useGatewayConfigForm({ gateway, initialConfig, debugMode, onSave url: trimmedUrl, index: configs.length + 1, }); - const token = backendKind === 'openclaw' && editorAuthMethodState === 'token' ? (trimmedToken || undefined) : undefined; + // Agent Zero piggybacks on the OpenClaw `token` field for its X-API-KEY. + const tokenBackendUsesAuthField = backendKind === 'openclaw' || backendKind === 'agentzero'; + const token = tokenBackendUsesAuthField && editorAuthMethodState === 'token' ? (trimmedToken || undefined) : undefined; const password = backendKind === 'openclaw' && editorAuthMethodState === 'password' ? (trimmedPassword || undefined) : undefined; const hermes = backendKind === 'hermes' ? deriveHermesBridgeConfig(trimmedUrl, configs.find((item) => item.id === editingConfigId)?.hermes) : undefined; + const agentzero = backendKind === 'agentzero' + ? deriveAgentZeroConfig(trimmedUrl, configs.find((item) => item.id === editingConfigId)?.agentzero) + : undefined; const relay = transportKind === 'relay' ? { serverUrl: trimmedServerUrl, @@ -506,6 +549,7 @@ export function useGatewayConfigForm({ gateway, initialConfig, debugMode, onSave token, password, hermes, + agentzero, relay, updatedAt: now, }; @@ -540,6 +584,7 @@ export function useGatewayConfigForm({ gateway, initialConfig, debugMode, onSave token, password, hermes, + agentzero, relay, createdAt: now, updatedAt: now, diff --git a/apps/mobile/src/i18n/locales/de/chat.json b/apps/mobile/src/i18n/locales/de/chat.json index 2fd39f8..460fc66 100644 --- a/apps/mobile/src/i18n/locales/de/chat.json +++ b/apps/mobile/src/i18n/locales/de/chat.json @@ -322,9 +322,8 @@ "New message from {{agentName}}": "Neue Nachricht von {{agentName}}", "YouMind": "YouMind", "New chat": "New chat", - "Sign in to YouMind": "Sign in to YouMind", - "Open your YouMind boards, materials, and chats from here.": "Open your YouMind boards, materials, and chats from here.", "Sign in to YouMind": "Bei YouMind anmelden", + "Open your YouMind boards, materials, and chats from here.": "Open your YouMind boards, materials, and chats from here.", "Continue with Apple, Google, or email to open your YouMind account.": "Continue with Apple, Google, or email to open your YouMind account.", "Continue with Apple": "Continue with Apple", "Continue with Google": "Continue with Google", @@ -381,5 +380,14 @@ "You now can connect your YouMind account.": "Du kannst jetzt dein YouMind-Konto verbinden.", "Open Add Connection and choose Use YouMind to pick an existing account or sign in with another one.": "Öffne Add Connection und wähle Use YouMind, um ein vorhandenes Konto auszuwählen oder dich mit einem anderen Konto anzumelden.", "Manage YouMind accounts": "YouMind-Konten verwalten", - "Sign out": "Sign out" + "Sign out": "Sign out", + "Connection not configured": "Verbindung nicht konfiguriert", + "Set the Agent Zero base URL and Auth Token in Settings → Connections.": "Lege die Agent-Zero-Basis-URL und das Auth-Token in Einstellungen → Verbindungen fest.", + "Reset Chat": "Chat zurücksetzen", + "Project: {{name}}": "Projekt: {{name}}", + "Say hi to Agent Zero. Streaming begins after your first message.": "Begrüße Agent Zero. Das Streaming startet nach deiner ersten Nachricht.", + "Agent Zero is working…": "Agent Zero arbeitet…", + "Message Agent Zero": "Nachricht an Agent Zero", + "Send": "Senden", + "Agent Zero": "Agent Zero" } diff --git a/apps/mobile/src/i18n/locales/de/config.json b/apps/mobile/src/i18n/locales/de/config.json index 4d61062..4146cc1 100644 --- a/apps/mobile/src/i18n/locales/de/config.json +++ b/apps/mobile/src/i18n/locales/de/config.json @@ -79,6 +79,7 @@ "Backend": "Backend", "Transport": "Transport", "OpenClaw": "OpenClaw", + "Agent Zero": "Agent Zero", "Connection Mode": "Verbindungsmodus", "Relay": "Relay", "Local": "Lokal", diff --git a/apps/mobile/src/i18n/locales/en/chat.json b/apps/mobile/src/i18n/locales/en/chat.json index 8464eef..6f5d50c 100644 --- a/apps/mobile/src/i18n/locales/en/chat.json +++ b/apps/mobile/src/i18n/locales/en/chat.json @@ -324,7 +324,6 @@ "New chat": "New chat", "Sign in to YouMind": "Sign in to YouMind", "Open your YouMind boards, materials, and chats from here.": "Open your YouMind boards, materials, and chats from here.", - "Sign in to YouMind": "Sign in to YouMind", "Continue with Apple, Google, or email to open your YouMind account.": "Continue with Apple, Google, or email to open your YouMind account.", "Continue with Apple": "Continue with Apple", "Continue with Google": "Continue with Google", @@ -381,5 +380,14 @@ "You now can connect your YouMind account.": "You now can connect your YouMind account.", "Open Add Connection and choose Use YouMind to pick an existing account or sign in with another one.": "Open Add Connection and choose Use YouMind to pick an existing account or sign in with another one.", "Manage YouMind accounts": "Manage YouMind accounts", - "Sign out": "Sign out" + "Sign out": "Sign out", + "Connection not configured": "Connection not configured", + "Set the Agent Zero base URL and Auth Token in Settings → Connections.": "Set the Agent Zero base URL and Auth Token in Settings → Connections.", + "Reset Chat": "Reset Chat", + "Project: {{name}}": "Project: {{name}}", + "Say hi to Agent Zero. Streaming begins after your first message.": "Say hi to Agent Zero. Streaming begins after your first message.", + "Agent Zero is working…": "Agent Zero is working…", + "Message Agent Zero": "Message Agent Zero", + "Send": "Send", + "Agent Zero": "Agent Zero" } diff --git a/apps/mobile/src/i18n/locales/en/config.json b/apps/mobile/src/i18n/locales/en/config.json index e34edb9..df9f339 100644 --- a/apps/mobile/src/i18n/locales/en/config.json +++ b/apps/mobile/src/i18n/locales/en/config.json @@ -79,6 +79,7 @@ "Backend": "Backend", "Transport": "Transport", "OpenClaw": "OpenClaw", + "Agent Zero": "Agent Zero", "Connection Mode": "Connection Mode", "Relay": "Relay", "Local": "Local", diff --git a/apps/mobile/src/i18n/locales/es/chat.json b/apps/mobile/src/i18n/locales/es/chat.json index 524abc9..368e1bd 100644 --- a/apps/mobile/src/i18n/locales/es/chat.json +++ b/apps/mobile/src/i18n/locales/es/chat.json @@ -322,9 +322,8 @@ "New message from {{agentName}}": "Nuevo mensaje de {{agentName}}", "YouMind": "YouMind", "New chat": "New chat", - "Sign in to YouMind": "Sign in to YouMind", - "Open your YouMind boards, materials, and chats from here.": "Open your YouMind boards, materials, and chats from here.", "Sign in to YouMind": "Inicia sesión en YouMind", + "Open your YouMind boards, materials, and chats from here.": "Open your YouMind boards, materials, and chats from here.", "Continue with Apple, Google, or email to open your YouMind account.": "Continue with Apple, Google, or email to open your YouMind account.", "Continue with Apple": "Continue with Apple", "Continue with Google": "Continue with Google", @@ -381,5 +380,14 @@ "You now can connect your YouMind account.": "Ahora puedes conectar tu cuenta de YouMind.", "Open Add Connection and choose Use YouMind to pick an existing account or sign in with another one.": "Abre Add Connection y elige Use YouMind para seleccionar una cuenta existente o iniciar sesión con otra.", "Manage YouMind accounts": "Gestionar cuentas de YouMind", - "Sign out": "Sign out" + "Sign out": "Sign out", + "Connection not configured": "Conexión no configurada", + "Set the Agent Zero base URL and Auth Token in Settings → Connections.": "Configura la URL base de Agent Zero y el Auth Token en Ajustes → Conexiones.", + "Reset Chat": "Reiniciar chat", + "Project: {{name}}": "Proyecto: {{name}}", + "Say hi to Agent Zero. Streaming begins after your first message.": "Saluda a Agent Zero. La transmisión comienza tras tu primer mensaje.", + "Agent Zero is working…": "Agent Zero está trabajando…", + "Message Agent Zero": "Mensaje a Agent Zero", + "Send": "Enviar", + "Agent Zero": "Agent Zero" } diff --git a/apps/mobile/src/i18n/locales/es/config.json b/apps/mobile/src/i18n/locales/es/config.json index dca58e9..405c707 100644 --- a/apps/mobile/src/i18n/locales/es/config.json +++ b/apps/mobile/src/i18n/locales/es/config.json @@ -79,6 +79,7 @@ "Backend": "Backend", "Transport": "Transporte", "OpenClaw": "OpenClaw", + "Agent Zero": "Agent Zero", "Connection Mode": "Modo de conexión", "Relay": "Relay", "Local": "Local", diff --git a/apps/mobile/src/i18n/locales/ja/chat.json b/apps/mobile/src/i18n/locales/ja/chat.json index 268f938..06b752b 100644 --- a/apps/mobile/src/i18n/locales/ja/chat.json +++ b/apps/mobile/src/i18n/locales/ja/chat.json @@ -322,9 +322,8 @@ "New message from {{agentName}}": "{{agentName}} から新しいメッセージ", "YouMind": "YouMind", "New chat": "New chat", - "Sign in to YouMind": "Sign in to YouMind", - "Open your YouMind boards, materials, and chats from here.": "Open your YouMind boards, materials, and chats from here.", "Sign in to YouMind": "YouMind にサインイン", + "Open your YouMind boards, materials, and chats from here.": "Open your YouMind boards, materials, and chats from here.", "Continue with Apple, Google, or email to open your YouMind account.": "Continue with Apple, Google, or email to open your YouMind account.", "Continue with Apple": "Continue with Apple", "Continue with Google": "Continue with Google", @@ -381,5 +380,14 @@ "You now can connect your YouMind account.": "YouMind アカウントを接続できるようになりました。", "Open Add Connection and choose Use YouMind to pick an existing account or sign in with another one.": "Add Connection を開いて Use YouMind を選択すると、既存アカウントを選ぶか別のアカウントでサインインできます。", "Manage YouMind accounts": "YouMind アカウントを管理", - "Sign out": "Sign out" + "Sign out": "Sign out", + "Connection not configured": "接続が未設定です", + "Set the Agent Zero base URL and Auth Token in Settings → Connections.": "設定 → 接続 で Agent Zero のベース URL と Auth Token を設定してください。", + "Reset Chat": "チャットをリセット", + "Project: {{name}}": "プロジェクト: {{name}}", + "Say hi to Agent Zero. Streaming begins after your first message.": "Agent Zero に挨拶しましょう。最初のメッセージを送るとストリーミングが始まります。", + "Agent Zero is working…": "Agent Zero が処理中…", + "Message Agent Zero": "Agent Zero にメッセージ", + "Send": "送信", + "Agent Zero": "Agent Zero" } diff --git a/apps/mobile/src/i18n/locales/ja/config.json b/apps/mobile/src/i18n/locales/ja/config.json index 2b2fa47..dce6287 100644 --- a/apps/mobile/src/i18n/locales/ja/config.json +++ b/apps/mobile/src/i18n/locales/ja/config.json @@ -79,6 +79,7 @@ "Backend": "バックエンド", "Transport": "接続方式", "OpenClaw": "OpenClaw", + "Agent Zero": "Agent Zero", "Connection Mode": "接続モード", "Relay": "リレー", "Local": "ローカル", diff --git a/apps/mobile/src/i18n/locales/ko/chat.json b/apps/mobile/src/i18n/locales/ko/chat.json index d97edcd..fca677e 100644 --- a/apps/mobile/src/i18n/locales/ko/chat.json +++ b/apps/mobile/src/i18n/locales/ko/chat.json @@ -322,9 +322,8 @@ "New message from {{agentName}}": "{{agentName}} 님의 새 메시지", "YouMind": "YouMind", "New chat": "New chat", - "Sign in to YouMind": "Sign in to YouMind", - "Open your YouMind boards, materials, and chats from here.": "Open your YouMind boards, materials, and chats from here.", "Sign in to YouMind": "YouMind에 로그인", + "Open your YouMind boards, materials, and chats from here.": "Open your YouMind boards, materials, and chats from here.", "Continue with Apple, Google, or email to open your YouMind account.": "Continue with Apple, Google, or email to open your YouMind account.", "Continue with Apple": "Continue with Apple", "Continue with Google": "Continue with Google", @@ -381,5 +380,14 @@ "You now can connect your YouMind account.": "이제 YouMind 계정을 연결할 수 있어요.", "Open Add Connection and choose Use YouMind to pick an existing account or sign in with another one.": "Add Connection을 연 뒤 Use YouMind를 선택하면 기존 계정을 고르거나 다른 계정으로 로그인할 수 있습니다.", "Manage YouMind accounts": "YouMind 계정 관리", - "Sign out": "Sign out" + "Sign out": "Sign out", + "Connection not configured": "연결이 구성되지 않았습니다", + "Set the Agent Zero base URL and Auth Token in Settings → Connections.": "설정 → 연결에서 Agent Zero 기본 URL과 Auth Token을 설정하세요.", + "Reset Chat": "채팅 초기화", + "Project: {{name}}": "프로젝트: {{name}}", + "Say hi to Agent Zero. Streaming begins after your first message.": "Agent Zero에 인사를 건네 보세요. 첫 메시지를 보내면 스트리밍이 시작됩니다.", + "Agent Zero is working…": "Agent Zero가 작업 중…", + "Message Agent Zero": "Agent Zero에 메시지", + "Send": "전송", + "Agent Zero": "Agent Zero" } diff --git a/apps/mobile/src/i18n/locales/ko/config.json b/apps/mobile/src/i18n/locales/ko/config.json index 311750b..e090b1f 100644 --- a/apps/mobile/src/i18n/locales/ko/config.json +++ b/apps/mobile/src/i18n/locales/ko/config.json @@ -79,6 +79,7 @@ "Backend": "백엔드", "Transport": "전송 방식", "OpenClaw": "OpenClaw", + "Agent Zero": "Agent Zero", "Connection Mode": "연결 모드", "Relay": "릴레이", "Local": "로컬", diff --git a/apps/mobile/src/i18n/locales/zh-Hans/chat.json b/apps/mobile/src/i18n/locales/zh-Hans/chat.json index fd960a1..c27c536 100644 --- a/apps/mobile/src/i18n/locales/zh-Hans/chat.json +++ b/apps/mobile/src/i18n/locales/zh-Hans/chat.json @@ -324,7 +324,6 @@ "New chat": "新聊天", "Sign in to YouMind": "登录 YouMind", "Open your YouMind boards, materials, and chats from here.": "在这里打开你的 YouMind 画板、资料和聊天。", - "Sign in to YouMind": "登录 YouMind", "Continue with Apple, Google, or email to open your YouMind account.": "使用 Apple、Google 或邮箱继续,打开你的 YouMind 账户。", "Continue with Apple": "使用 Apple 继续", "Continue with Google": "使用 Google 继续", @@ -381,5 +380,14 @@ "You now can connect your YouMind account.": "你现在可以连接你的 YouMind 账号。", "Open Add Connection and choose Use YouMind to pick an existing account or sign in with another one.": "打开 Add Connection,然后选择 Use YouMind,以选择已有账号或登录另一个账号。", "Manage YouMind accounts": "管理 YouMind 账号", - "Sign out": "退出登录" + "Sign out": "退出登录", + "Connection not configured": "连接未配置", + "Set the Agent Zero base URL and Auth Token in Settings → Connections.": "在 设置 → 连接 中填写 Agent Zero 的地址和 Auth Token。", + "Reset Chat": "重置对话", + "Project: {{name}}": "项目:{{name}}", + "Say hi to Agent Zero. Streaming begins after your first message.": "向 Agent Zero 打个招呼。发送第一条消息后即开始流式响应。", + "Agent Zero is working…": "Agent Zero 正在处理…", + "Message Agent Zero": "发送给 Agent Zero", + "Send": "发送", + "Agent Zero": "Agent Zero" } diff --git a/apps/mobile/src/i18n/locales/zh-Hans/config.json b/apps/mobile/src/i18n/locales/zh-Hans/config.json index 9217c95..339eca7 100644 --- a/apps/mobile/src/i18n/locales/zh-Hans/config.json +++ b/apps/mobile/src/i18n/locales/zh-Hans/config.json @@ -79,6 +79,7 @@ "Backend": "后端", "Transport": "连接方式", "OpenClaw": "OpenClaw", + "Agent Zero": "Agent Zero", "Connection Mode": "连接模式", "Relay": "中继", "Local": "本地", diff --git a/apps/mobile/src/screens/ChatScreen/AgentZeroChatTab.tsx b/apps/mobile/src/screens/ChatScreen/AgentZeroChatTab.tsx new file mode 100644 index 0000000..ec991a1 --- /dev/null +++ b/apps/mobile/src/screens/ChatScreen/AgentZeroChatTab.tsx @@ -0,0 +1,446 @@ +// Agent Zero chat tab. +// +// Reached when the active gateway config has backendKind === 'agentzero'. +// Self-contained: doesn't share the OpenClaw/Hermes WS gateway, no drawer, +// no sidebar (yet). Streams progressive log items via AgentZeroClient and +// renders them as bubbles. The full a0 CLI flavor (project switching, slash +// commands, agent profile picker) is intentionally deferred — those layer on +// top of this baseline. +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + KeyboardAvoidingView, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + View, + type ListRenderItem, +} from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { RotateCcw, SendHorizontal } from 'lucide-react-native'; +import { useTabBarHeight } from '../../hooks/useTabBarHeight'; +import { useAppContext } from '../../contexts/AppContext'; +import { useAppTheme, type AppTheme } from '../../theme'; +import { FontSize, FontWeight, Radius, Space } from '../../theme/tokens'; +import { + createAgentZeroClient, + AgentZeroError, + type AgentZeroLogItem, +} from '../../services/agentzero-client'; + +type Colors = AppTheme['colors']; + +type BubbleAuthor = 'you' | 'agent' | 'tool' | 'system' | 'error'; + +interface ChatBubble { + /** Stable id — for user messages we mint a uuid; for AZ items we reuse + * the framework's log item id (or its `no` if id is null). */ + key: string; + author: BubbleAuthor; + /** Display text. */ + content: string; + /** Optional short label rendered above the bubble (tool name, etc.). */ + heading?: string; +} + +const AZ_TYPE_TO_AUTHOR: Record = { + user: 'you', + agent: 'agent', + response: 'agent', + tool: 'tool', + util: 'tool', + error: 'error', +}; + +function classifyLogItem(item: AgentZeroLogItem): BubbleAuthor { + return AZ_TYPE_TO_AUTHOR[item.type] ?? 'system'; +} + +function mintKey(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function AgentZeroChatTab(): React.JSX.Element { + const { config } = useAppContext(); + const { theme } = useAppTheme(); + const styles = useMemo(() => createStyles(theme.colors), [theme]); + const insets = useSafeAreaInsets(); + // Bottom tab bar overlays the bottom of the screen on iOS; subtract its + // height so the composer sits above it instead of behind it. Falls back to + // 0 in non-tab contexts (modal flows). + const tabBarHeight = useTabBarHeight(); + const { t } = useTranslation(['chat', 'common']); + + const bridgeUrl = config?.agentzero?.bridgeUrl ?? ''; + const token = config?.token ?? ''; + const projectName = config?.agentzero?.projectName; + + const client = useMemo( + () => (bridgeUrl && token + ? createAgentZeroClient({ bridgeUrl, projectName }, token) + : null), + [bridgeUrl, token, projectName], + ); + + const [bubbles, setBubbles] = useState([]); + const [contextId, setContextId] = useState(null); + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + const abortRef = useRef(null); + const listRef = useRef>(null); + + // Clean up any in-flight stream if the screen unmounts. + useEffect(() => () => abortRef.current?.abort(), []); + + const appendBubble = useCallback((b: ChatBubble) => { + setBubbles((prev) => [...prev, b]); + // Defer scroll-to-end so FlatList has the new item. + requestAnimationFrame(() => { + listRef.current?.scrollToEnd({ animated: true }); + }); + }, []); + + const resetChat = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + setBubbles([]); + setContextId(null); + setSending(false); + }, []); + + const handleSend = useCallback(async () => { + if (!client) return; + const text = draft.trim(); + if (!text || sending) return; + + appendBubble({ key: mintKey('user'), author: 'you', content: text }); + setDraft(''); + setSending(true); + + const controller = new AbortController(); + abortRef.current = controller; + // Dedupe per-stream: AZ's poll-window returns historical items too; the + // client already filters by `no` cursor, but we additionally skip user + // echoes (we just rendered the user bubble ourselves). + const seen = new Set(); + try { + const final = await client.streamMessage( + { message: text, contextId: contextId ?? undefined }, + (item) => { + if (item.type === 'user') return; + const key = item.id ?? `no-${item.no}`; + if (seen.has(key)) return; + seen.add(key); + appendBubble({ + key, + author: classifyLogItem(item), + content: item.content, + heading: item.heading || undefined, + }); + }, + { pollIntervalMs: 400 }, + controller.signal, + ); + setContextId(final.contextId); + } catch (err) { + const message = err instanceof AgentZeroError + ? `${err.status}: ${err.message}` + : err instanceof Error + ? err.message + : String(err); + appendBubble({ key: mintKey('err'), author: 'error', content: message }); + } finally { + setSending(false); + abortRef.current = null; + } + }, [appendBubble, client, contextId, draft, sending]); + + const renderItem: ListRenderItem = useCallback(({ item }) => ( + + + {item.heading ? {item.heading} : null} + + {item.content || ' '} + + + + ), [styles]); + + // Inline header (the shared ChatHeader is shaped for OpenClaw connection + // state which doesn't apply to AZ's HTTP polling model). + const headerTitle = t('Agent Zero'); + const headerSubtitle = projectName + ? t('Project: {{name}}', { name: projectName }) + : undefined; + + if (!client) { + return ( + + + + {headerTitle} + {t('Connection not configured')} + + + + + {t('Set the Agent Zero base URL and Auth Token in Settings → Connections.')} + + + + ); + } + + // Composer bottom padding has to clear: + // - the tab bar (iOS native bottom tabs draw OVER the screen content) + // - the home indicator (insets.bottom on devices without a tab bar) + // - never less than Space.md so the input doesn't kiss the edge + const composerBottomInset = Math.max(Space.md, tabBarHeight, insets.bottom); + + return ( + + + + {headerTitle} + {headerSubtitle ? {headerSubtitle} : null} + + [styles.headerAction, pressed && styles.headerActionPressed]} + > + + + + + {bubbles.length === 0 && !sending ? ( + + + {t('Say hi to Agent Zero. Streaming begins after your first message.')} + + + ) : ( + b.key} + renderItem={renderItem} + contentContainerStyle={styles.bubbleList} + onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })} + /> + )} + + {sending ? ( + + + {t('Agent Zero is working…')} + + ) : null} + + + + [ + styles.sendButton, + (sending || !draft.trim()) && styles.sendButtonDisabled, + pressed && styles.sendButtonPressed, + ]} + > + + + + + ); +} + +function bubbleStyleByAuthor(s: ReturnType, author: BubbleAuthor) { + switch (author) { + case 'you': return s.bubbleYou; + case 'agent': return s.bubbleAgent; + case 'tool': return s.bubbleTool; + case 'error': return s.bubbleError; + default: return s.bubbleSystem; + } +} + +function bubbleTextStyleByAuthor(s: ReturnType, author: BubbleAuthor) { + switch (author) { + case 'you': return s.bubbleTextYou; + case 'error': return s.bubbleTextError; + default: return s.bubbleTextAgent; + } +} + +function createStyles(colors: Colors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + emptyHint: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: Space.xl, + }, + emptyHintText: { + color: colors.textMuted, + fontSize: FontSize.base, + textAlign: 'center', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: Space.lg, + paddingBottom: Space.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border, + backgroundColor: colors.surface, + }, + headerTextWrap: { + flex: 1, + }, + headerTitle: { + color: colors.text, + fontSize: FontSize.lg, + fontWeight: FontWeight.semibold, + }, + headerSubtitle: { + color: colors.textMuted, + fontSize: FontSize.sm, + marginTop: 2, + }, + headerAction: { + padding: Space.sm, + }, + headerActionPressed: { + opacity: 0.6, + }, + bubbleList: { + paddingHorizontal: Space.lg, + paddingTop: Space.md, + paddingBottom: Space.md, + }, + bubbleRow: { + flexDirection: 'row', + marginBottom: Space.sm, + }, + bubbleRowRight: { + justifyContent: 'flex-end', + }, + bubble: { + maxWidth: '85%', + paddingHorizontal: Space.md, + paddingVertical: Space.sm, + borderRadius: Radius.md, + }, + bubbleYou: { + backgroundColor: colors.primary, + }, + bubbleAgent: { + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + }, + bubbleTool: { + backgroundColor: colors.surfaceMuted, + }, + bubbleSystem: { + backgroundColor: colors.surfaceMuted, + }, + bubbleError: { + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.error, + }, + bubbleHeading: { + color: colors.textMuted, + fontSize: FontSize.xs, + fontWeight: FontWeight.semibold, + marginBottom: Space.xs, + textTransform: 'uppercase', + }, + bubbleText: { + fontSize: FontSize.base, + lineHeight: 20, + }, + bubbleTextYou: { + color: colors.primaryText, + }, + bubbleTextAgent: { + color: colors.text, + }, + bubbleTextError: { + color: colors.error, + }, + sendingRow: { + flexDirection: 'row', + alignItems: 'center', + gap: Space.sm, + paddingHorizontal: Space.lg, + paddingVertical: Space.xs, + }, + sendingText: { + color: colors.textMuted, + fontSize: FontSize.sm, + }, + composerWrap: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: Space.sm, + paddingHorizontal: Space.md, + paddingTop: Space.sm, + backgroundColor: colors.surface, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + composerInput: { + flex: 1, + maxHeight: 120, + paddingHorizontal: Space.md, + paddingVertical: Space.sm, + borderRadius: Radius.lg, + backgroundColor: colors.surfaceMuted, + color: colors.text, + fontSize: FontSize.base, + }, + sendButton: { + width: 44, + height: 44, + borderRadius: Radius.full, + backgroundColor: colors.primary, + alignItems: 'center', + justifyContent: 'center', + }, + sendButtonDisabled: { + opacity: 0.4, + }, + sendButtonPressed: { + opacity: 0.7, + }, + }); +} diff --git a/apps/mobile/src/screens/ChatScreen/ChatTab.tsx b/apps/mobile/src/screens/ChatScreen/ChatTab.tsx index de387ec..f07768d 100644 --- a/apps/mobile/src/screens/ChatScreen/ChatTab.tsx +++ b/apps/mobile/src/screens/ChatScreen/ChatTab.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { InteractionManager, Platform, StyleSheet, View } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import { CommonActions } from '@react-navigation/native'; import { DrawerContentComponentProps, createDrawerNavigator, useDrawerProgress } from '@react-navigation/drawer'; import Animated, { useAnimatedStyle, interpolate } from 'react-native-reanimated'; @@ -15,6 +15,7 @@ import { ChatControllerProvider, useChatControllerContext } from './ChatControll import { useChatController } from './hooks/useChatController'; import { ChatScreen } from './index'; import { YouMindChatTab } from './YouMindChatTab'; +import { AgentZeroChatTab } from './AgentZeroChatTab'; export type ChatDrawerParamList = { ChatMain: undefined; @@ -104,7 +105,7 @@ const ChatDrawerContent = React.memo(function ChatDrawerContent({ prevProgressRef.current = cur; // Drawer just finished opening (progress crossed 1.0) if (cur === 1 && prev < 1) { - InteractionManager.runAfterInteractions(() => { + requestIdleCallback(() => { void refreshSessions(); }); } @@ -241,6 +242,9 @@ export function ChatTab(): React.JSX.Element { if (config?.backendKind === 'youmind') { return ; } + if (config?.backendKind === 'agentzero') { + return ; + } const [sidebarPreset, setSidebarPreset] = React.useState<{ requestedAt: number; tab: 'sessions' | 'subagents' | 'cron'; diff --git a/apps/mobile/src/screens/ChatScreen/YouMindChatTab.tsx b/apps/mobile/src/screens/ChatScreen/YouMindChatTab.tsx index 9ea7d45..8942c98 100644 --- a/apps/mobile/src/screens/ChatScreen/YouMindChatTab.tsx +++ b/apps/mobile/src/screens/ChatScreen/YouMindChatTab.tsx @@ -4,7 +4,6 @@ import { Alert, Animated as RNAnimated, Image, - InteractionManager, Platform, Pressable, StyleProp, @@ -404,7 +403,7 @@ const YouMindDrawerContent = React.memo(function YouMindDrawerContent({ const previous = prevProgressRef.current; prevProgressRef.current = current; if (current === 1 && previous < 1) { - InteractionManager.runAfterInteractions(() => { + requestIdleCallback(() => { void onRefreshSessions(); }); } @@ -1062,7 +1061,7 @@ export function YouMindChatTab(): React.JSX.Element { if (!selectedSkill) return; const trackedSkill = selectedSkill; setSelectedSkill(null); - InteractionManager.runAfterInteractions(() => { + requestIdleCallback(() => { analyticsEvents.chatSkillSelected({ source: 'youmind_chat', action: 'clear', @@ -1075,7 +1074,7 @@ export function YouMindChatTab(): React.JSX.Element { const handleSelectRecommendedSkill = React.useCallback((skill: YouMindSkillSummary) => { setSelectedSkill(skill); - InteractionManager.runAfterInteractions(() => { + requestIdleCallback(() => { analyticsEvents.chatSkillSelected({ source: 'youmind_chat_recommended', action: 'select', @@ -1101,7 +1100,7 @@ export function YouMindChatTab(): React.JSX.Element { const handleSelectSkill = React.useCallback((skill: YouMindSkillSummary) => { setSelectedSkill(skill); skillPickerRef.current?.dismiss(); - InteractionManager.runAfterInteractions(() => { + requestIdleCallback(() => { analyticsEvents.chatSkillSelected({ source: 'youmind_chat_picker', action: 'select', diff --git a/apps/mobile/src/screens/ChatScreen/hooks/useChatController.ts b/apps/mobile/src/screens/ChatScreen/hooks/useChatController.ts index c38299b..4faf831 100644 --- a/apps/mobile/src/screens/ChatScreen/hooks/useChatController.ts +++ b/apps/mobile/src/screens/ChatScreen/hooks/useChatController.ts @@ -1075,10 +1075,14 @@ export function useChatController({ const hasRunningChat = !!currentRunIdRef.current; // Refresh visible history after transport freshness has been re-established. scheduleForegroundRefresh(awayMs, hasRunningChat); + // Always re-verify the socket on foreground: iOS freezes the WS in the + // background and it often returns half-open (state still "ready" but + // dead), so probeConnection forces a reconnect. Previously this was + // gated on hasRunningChat, leaving an idle open chat with a dead socket. + if (awayMs >= 12_000 || gateway.getConnectionState() !== "ready") { + void gateway.probeConnection(); + } if (hasRunningChat) { - if (awayMs >= 12_000 || gateway.getConnectionState() !== "ready") { - void gateway.probeConnection(); - } if (history.sessionKey) { void requestRunRecovery(history.sessionKey, "app-active"); } diff --git a/apps/mobile/src/screens/ConfigScreen/ConfigScreenLayout.tsx b/apps/mobile/src/screens/ConfigScreen/ConfigScreenLayout.tsx index 8e2ef27..3de14d2 100644 --- a/apps/mobile/src/screens/ConfigScreen/ConfigScreenLayout.tsx +++ b/apps/mobile/src/screens/ConfigScreen/ConfigScreenLayout.tsx @@ -101,6 +101,7 @@ function getBackendLabels(t: (key: string) => string): Record ((isEditing && controller.editorBackendKind === 'youmind') ? (['youmind'] as const) - : (['openclaw', 'hermes'] as const)), + : (['openclaw', 'hermes', 'agentzero'] as const)), [controller.editorBackendKind, isEditing], ); const authInputLabel = controller.editorAuthMethod === 'token' ? t('Auth Token') : t('Password'); diff --git a/apps/mobile/src/screens/ConfigScreen/index.tsx b/apps/mobile/src/screens/ConfigScreen/index.tsx index 683a107..bf21408 100644 --- a/apps/mobile/src/screens/ConfigScreen/index.tsx +++ b/apps/mobile/src/screens/ConfigScreen/index.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useRef } from 'react'; -import { InteractionManager, Platform } from 'react-native'; +import { Platform } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { RouteProp, useIsFocused, useNavigation, useRoute } from '@react-navigation/native'; import { useTabBarHeight } from '../../hooks/useTabBarHeight'; @@ -103,7 +103,7 @@ export function ConfigScreen(): React.JSX.Element { const pendingRequest = consumePendingConfigAddConnectionRequest(); if (!pendingRequest) return; let cancelled = false; - const task = InteractionManager.runAfterInteractions(() => { + const task = requestIdleCallback(() => { requestAnimationFrame(() => { if (cancelled) return; openRequestedAddConnection(pendingRequest); @@ -111,7 +111,7 @@ export function ConfigScreen(): React.JSX.Element { }); return () => { cancelled = true; - task.cancel(); + cancelIdleCallback(task); }; }, [isFocused, openRequestedAddConnection]); diff --git a/apps/mobile/src/screens/ConfigScreen/qrPayload.ts b/apps/mobile/src/screens/ConfigScreen/qrPayload.ts index 3ec5de7..e769003 100644 --- a/apps/mobile/src/screens/ConfigScreen/qrPayload.ts +++ b/apps/mobile/src/screens/ConfigScreen/qrPayload.ts @@ -195,7 +195,7 @@ export function parseQRPayload(raw: string): QRScanResult | null { return { url: String(obj.url), ...(hermes ? { backendKind: 'hermes' as const } : {}), - ...(mode && mode !== 'hermes' ? { transportKind: mode } : {}), + ...(mode && mode !== 'hermes' && mode !== 'agentzero' ? { transportKind: mode } : {}), ...(typeof obj.token === 'string' ? { token: obj.token } : {}), ...(typeof obj.password === 'string' ? { password: obj.password } : {}), mode, @@ -223,7 +223,7 @@ export function parseQRPayload(raw: string): QRScanResult | null { return { url: `${scheme}://${obj.host}:${port}`, ...(hermes ? { backendKind: 'hermes' as const } : {}), - ...(mode && mode !== 'hermes' ? { transportKind: mode } : {}), + ...(mode && mode !== 'hermes' && mode !== 'agentzero' ? { transportKind: mode } : {}), ...(typeof obj.token === 'string' ? { token: obj.token } : {}), ...(typeof obj.password === 'string' ? { password: obj.password } : {}), mode, diff --git a/apps/mobile/src/services/agentzero-client.test.ts b/apps/mobile/src/services/agentzero-client.test.ts new file mode 100644 index 0000000..3ac9ee8 --- /dev/null +++ b/apps/mobile/src/services/agentzero-client.test.ts @@ -0,0 +1,374 @@ +import { AgentZeroClient, AgentZeroError, createAgentZeroClient } from './agentzero-client'; + +type MockFetchCall = { url: string; init: RequestInit }; + +function buildMockFetch( + responder: (req: MockFetchCall) => { status: number; bodyJson?: unknown; bodyText?: string }, +): { + fetchImpl: typeof fetch; + calls: MockFetchCall[]; +} { + const calls: MockFetchCall[] = []; + const fetchImpl = (async (url: RequestInfo | URL, init: RequestInit = {}) => { + const call: MockFetchCall = { url: String(url), init }; + calls.push(call); + const { status, bodyJson, bodyText } = responder(call); + const body = bodyText ?? (bodyJson !== undefined ? JSON.stringify(bodyJson) : ''); + return { + ok: status >= 200 && status < 300, + status, + text: async () => body, + // The client uses text() not json(), but provide json() for completeness. + json: async () => (bodyJson !== undefined ? bodyJson : JSON.parse(body)), + } as unknown as Response; + }) as unknown as typeof fetch; + return { fetchImpl, calls }; +} + +describe('AgentZeroClient', () => { + describe('sendMessage', () => { + it('POSTs JSON to /api/api_message with X-API-KEY header and parses the response', async () => { + const { fetchImpl, calls } = buildMockFetch(() => ({ + status: 200, + bodyJson: { context_id: 'ctx-1', response: 'pong' }, + })); + const client = new AgentZeroClient({ + baseUrl: 'http://az.example.com:5000', + token: 'tok-123', + fetchImpl, + }); + + const out = await client.sendMessage({ message: 'ping' }); + + expect(out).toEqual({ contextId: 'ctx-1', response: 'pong' }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe('http://az.example.com:5000/api/api_message'); + expect(calls[0].init.method).toBe('POST'); + const headers = calls[0].init.headers as Record; + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['X-API-KEY']).toBe('tok-123'); + expect(JSON.parse(String(calls[0].init.body))).toEqual({ message: 'ping' }); + }); + + it('strips trailing slashes from the baseUrl so /api/api_message is well-formed', async () => { + const { fetchImpl, calls } = buildMockFetch(() => ({ + status: 200, + bodyJson: { context_id: 'c', response: 'r' }, + })); + const client = new AgentZeroClient({ + baseUrl: 'http://az.example.com:5000///', + token: 't', + fetchImpl, + }); + + await client.sendMessage({ message: 'hi' }); + expect(calls[0].url).toBe('http://az.example.com:5000/api/api_message'); + }); + + it('threads contextId, projectName, agentProfile, lifetimeHours through the body', async () => { + const { fetchImpl, calls } = buildMockFetch(() => ({ + status: 200, + bodyJson: { context_id: 'ctx-2', response: 'ok' }, + })); + const client = new AgentZeroClient({ + baseUrl: 'http://az', + token: 't', + fetchImpl, + }); + + await client.sendMessage({ + message: 'hi', + contextId: 'ctx-2', + projectName: 'demo', + agentProfile: 'catgirl', + lifetimeHours: 1, + attachments: [{ filename: 'a.txt', base64: 'aGk=' }], + }); + + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + message: 'hi', + context_id: 'ctx-2', + project_name: 'demo', + agent_profile: 'catgirl', + lifetime_hours: 1, + attachments: [{ filename: 'a.txt', base64: 'aGk=' }], + }); + }); + + it('omits optional fields entirely when not provided (no nulls)', async () => { + const { fetchImpl, calls } = buildMockFetch(() => ({ + status: 200, + bodyJson: { context_id: 'c', response: 'r' }, + })); + const client = new AgentZeroClient({ + baseUrl: 'http://az', + token: 't', + fetchImpl, + }); + + await client.sendMessage({ message: 'hi' }); + const body = JSON.parse(String(calls[0].init.body)); + expect(Object.keys(body).sort()).toEqual(['message']); + }); + + it('throws AgentZeroError with the server-provided error message and HTTP status on 4xx', async () => { + const { fetchImpl } = buildMockFetch(() => ({ + status: 404, + bodyJson: { error: 'Context not found' }, + })); + const client = new AgentZeroClient({ + baseUrl: 'http://az', + token: 't', + fetchImpl, + }); + + await expect(client.sendMessage({ message: 'hi', contextId: 'ctx-x' })).rejects.toMatchObject({ + name: 'AgentZeroError', + message: 'Context not found', + status: 404, + }); + }); + + it('throws AgentZeroError with a synthetic message when the server returns no body', async () => { + const { fetchImpl } = buildMockFetch(() => ({ status: 500, bodyText: '' })); + const client = new AgentZeroClient({ + baseUrl: 'http://az', + token: 't', + fetchImpl, + }); + + await expect(client.sendMessage({ message: 'hi' })).rejects.toMatchObject({ + name: 'AgentZeroError', + message: 'HTTP 500', + status: 500, + }); + }); + + it('rejects when the success body is missing context_id or response', async () => { + const { fetchImpl } = buildMockFetch(() => ({ status: 200, bodyJson: { context_id: 'x' } })); + const client = new AgentZeroClient({ + baseUrl: 'http://az', + token: 't', + fetchImpl, + }); + + await expect(client.sendMessage({ message: 'hi' })).rejects.toThrow( + /Invalid Agent Zero response shape/, + ); + }); + + it('rejects empty messages without making a request', async () => { + const { fetchImpl, calls } = buildMockFetch(() => ({ status: 200, bodyJson: {} })); + const client = new AgentZeroClient({ baseUrl: 'http://az', token: 't', fetchImpl }); + await expect(client.sendMessage({ message: ' ' })).rejects.toThrow('message is required'); + expect(calls).toHaveLength(0); + }); + }); + + describe('health', () => { + it.each([200, 302])('returns true on HTTP %i', async (status) => { + const { fetchImpl } = buildMockFetch(() => ({ status })); + const client = new AgentZeroClient({ baseUrl: 'http://az', token: 't', fetchImpl }); + await expect(client.health()).resolves.toBe(true); + }); + + it('returns false on a non-2xx/302 status', async () => { + const { fetchImpl } = buildMockFetch(() => ({ status: 500 })); + const client = new AgentZeroClient({ baseUrl: 'http://az', token: 't', fetchImpl }); + await expect(client.health()).resolves.toBe(false); + }); + + it('returns false when fetch throws (network down, abort)', async () => { + const fetchImpl = (async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; + const client = new AgentZeroClient({ baseUrl: 'http://az', token: 't', fetchImpl }); + await expect(client.health()).resolves.toBe(false); + }); + }); + + describe('createAgentZeroClient factory', () => { + it('creates a client bound to the gateway config bridgeUrl', async () => { + const { fetchImpl, calls } = buildMockFetch(() => ({ + status: 200, + bodyJson: { context_id: 'c', response: 'r' }, + })); + const client = createAgentZeroClient( + { bridgeUrl: 'http://az.example.com:5000', displayName: 'Habitat AZ' }, + 'tok-via-factory', + fetchImpl, + ); + await client.sendMessage({ message: 'hi' }); + expect(calls[0].url).toBe('http://az.example.com:5000/api/api_message'); + expect((calls[0].init.headers as Record)['X-API-KEY']).toBe('tok-via-factory'); + }); + }); + + it('exports a named AgentZeroError class for instanceof checks at call sites', () => { + expect(new AgentZeroError('x', 503)).toBeInstanceOf(Error); + expect(new AgentZeroError('x', 503).status).toBe(503); + expect(new AgentZeroError('x', 503).name).toBe('AgentZeroError'); + }); + + describe('streamMessage', () => { + function logItem(no: number, type: string, content: string): Record { + return { no, id: `id-${no}`, type, heading: '', content, kvps: null, timestamp: 0, agentno: 0 }; + } + + it('kicks off via /api/api_message_async, polls /api/api_log_get, emits items, resolves with final response', async () => { + // Sequential responder: kickoff first, then 2 polls (still active, then done with response item). + let callIdx = 0; + const { fetchImpl, calls } = buildMockFetch(() => { + callIdx += 1; + if (callIdx === 1) { + return { status: 200, bodyJson: { message: 'received', context_id: 'ctx-async' } }; + } + if (callIdx === 2) { + return { + status: 200, + bodyJson: { + context_id: 'ctx-async', + log: { + items: [logItem(0, 'user', 'hi'), logItem(1, 'agent', 'thinking...')], + progress_active: true, + }, + }, + }; + } + return { + status: 200, + bodyJson: { + context_id: 'ctx-async', + log: { + items: [ + logItem(0, 'user', 'hi'), + logItem(1, 'agent', 'thinking...'), + logItem(2, 'response', 'done!'), + ], + progress_active: false, + }, + }, + }; + }); + const client = new AgentZeroClient({ baseUrl: 'http://az', token: 't', fetchImpl }); + const seen: string[] = []; + const out = await client.streamMessage( + { message: 'go' }, + (item) => { seen.push(`${item.type}:${item.content}`); }, + { pollIntervalMs: 1 }, // jest doesn't need real sleeps + ); + + expect(out).toEqual({ contextId: 'ctx-async', response: 'done!' }); + // Each item should be emitted exactly once, in order. + expect(seen).toEqual(['user:hi', 'agent:thinking...', 'response:done!']); + // First call was the async kickoff. + expect(calls[0].url).toBe('http://az/api/api_message_async'); + // Subsequent calls are log polls. + expect(calls.slice(1).every((c) => c.url === 'http://az/api/api_log_get')).toBe(true); + }); + + it('threads contextId through the kickoff body when continuing an existing chat', async () => { + let callIdx = 0; + const { fetchImpl, calls } = buildMockFetch(() => { + callIdx += 1; + if (callIdx === 1) { + return { status: 200, bodyJson: { message: 'received', context_id: 'ctx-existing' } }; + } + return { + status: 200, + bodyJson: { + context_id: 'ctx-existing', + log: { items: [logItem(0, 'response', 'ok')], progress_active: false }, + }, + }; + }); + const client = new AgentZeroClient({ baseUrl: 'http://az', token: 't', fetchImpl }); + + await client.streamMessage( + { message: 'follow-up', contextId: 'ctx-existing' }, + () => {}, + { pollIntervalMs: 1 }, + ); + + const kickoffBody = JSON.parse(String(calls[0].init.body)); + expect(kickoffBody).toEqual({ message: 'follow-up', context_id: 'ctx-existing' }); + }); + + it('throws AgentZeroError synchronously when the async kickoff fails', async () => { + const { fetchImpl } = buildMockFetch(() => ({ + status: 401, + bodyJson: { error: 'Invalid API key' }, + })); + const client = new AgentZeroClient({ baseUrl: 'http://az', token: 'bad', fetchImpl }); + + await expect( + client.streamMessage({ message: 'hi' }, () => {}, { pollIntervalMs: 1 }), + ).rejects.toMatchObject({ name: 'AgentZeroError', status: 401, message: 'Invalid API key' }); + }); + + it('does not emit the same log item twice across poll ticks', async () => { + let callIdx = 0; + const item0 = logItem(0, 'agent', 'still working'); + const item1 = logItem(1, 'response', 'final answer'); + const { fetchImpl } = buildMockFetch(() => { + callIdx += 1; + if (callIdx === 1) { + return { status: 200, bodyJson: { message: 'received', context_id: 'c' } }; + } + if (callIdx === 2) { + return { + status: 200, + bodyJson: { context_id: 'c', log: { items: [item0], progress_active: true } }, + }; + } + // Tick 3 also returns item0 (no progress) then tick 4 adds item1 + closes. + if (callIdx === 3) { + return { + status: 200, + bodyJson: { context_id: 'c', log: { items: [item0], progress_active: true } }, + }; + } + return { + status: 200, + bodyJson: { context_id: 'c', log: { items: [item0, item1], progress_active: false } }, + }; + }); + const client = new AgentZeroClient({ baseUrl: 'http://az', token: 't', fetchImpl }); + + const seen: number[] = []; + const out = await client.streamMessage( + { message: 'go' }, + (it) => { seen.push(it.no); }, + { pollIntervalMs: 1 }, + ); + expect(seen).toEqual([0, 1]); + expect(out.response).toBe('final answer'); + }); + + it('rejects with AgentZeroError(408) when no response arrives within maxStreamMs', async () => { + let callIdx = 0; + const { fetchImpl } = buildMockFetch(() => { + callIdx += 1; + if (callIdx === 1) { + return { status: 200, bodyJson: { message: 'received', context_id: 'c' } }; + } + // Endless work, never a response, never goes idle. + return { + status: 200, + bodyJson: { context_id: 'c', log: { items: [logItem(0, 'agent', 'hmm')], progress_active: true } }, + }; + }); + const client = new AgentZeroClient({ baseUrl: 'http://az', token: 't', fetchImpl }); + await expect( + client.streamMessage({ message: 'go' }, () => {}, { pollIntervalMs: 1, maxStreamMs: 5 }), + ).rejects.toMatchObject({ name: 'AgentZeroError', status: 408 }); + }); + + it('rejects empty messages without making a request', async () => { + const { fetchImpl, calls } = buildMockFetch(() => ({ status: 200, bodyJson: {} })); + const client = new AgentZeroClient({ baseUrl: 'http://az', token: 't', fetchImpl }); + await expect(client.streamMessage({ message: '' }, () => {})).rejects.toThrow('message is required'); + expect(calls).toHaveLength(0); + }); + }); +}); diff --git a/apps/mobile/src/services/agentzero-client.ts b/apps/mobile/src/services/agentzero-client.ts new file mode 100644 index 0000000..b0ed5e4 --- /dev/null +++ b/apps/mobile/src/services/agentzero-client.ts @@ -0,0 +1,382 @@ +// Agent Zero HTTP client. +// +// Three surfaces against AZ's REST API: +// +// 1. sendMessage(req) — POST /api/api_message, blocks until the agent finishes. +// Returns { contextId, response }. Same endpoint a0 CLI + the habitat +// health-check cron use. +// +// 2. streamMessage(req, onLogItem) — kicks off the agent and streams its log +// items as they happen. Two-phase: +// a. POST /api/api_message_async — returns the context_id immediately +// (the agent task runs in the background). +// b. Poll /api/api_log_get on that context_id every ~400ms, emit each +// new log item via onLogItem, stop when progress_active=false. +// Returns the final { contextId, response } where `response` is the +// last log item of type 'response'. +// +// NOTE: api_message_async is a small AZ patch (deployed on habitat as +// api/api_message_async.py) — a key-authed clone of the framework's own +// web-authed message_async. Without it the framework only exposes the +// blocking api_message under X-API-KEY. +// +// 3. health() — light liveness check against GET /. +// +// Endpoint contracts (from agent-zero/api/api_message{,_async}.py + api_log_get.py): +// POST {baseUrl}/api/api_message +// headers: X-API-KEY: +// Content-Type: application/json +// body: { message, context_id?, attachments?, project_name?, +// agent_profile?, lifetime_hours? } +// ok: { context_id, response } +// +// POST {baseUrl}/api/api_message_async +// same body as api_message +// ok: { message: "Message received.", context_id } (fire-and-forget) +// +// POST {baseUrl}/api/api_log_get +// body: { context_id, length? } +// ok: { context_id, log: { items: LogItem[], progress_active, ... } } +// +// Error shape (all three): { error: string } with non-2xx status. + +import type { AgentZeroGatewayConfig } from '../types'; + +export interface AgentZeroMessageRequest { + /** User-facing message body. Required. */ + message: string; + /** AZ chat context to continue. Omit for a new chat. */ + contextId?: string; + /** Inline attachments (base64-encoded). */ + attachments?: Array<{ filename: string; base64: string }>; + /** Switch active AZ project for this turn (analogous to `/project foo` in a0 CLI). */ + projectName?: string; + /** Override the agent profile (e.g. `'helpful'`, `'catgirl'`). */ + agentProfile?: string; + /** Context lifetime in hours (default 24 in AZ). */ + lifetimeHours?: number; +} + +export interface AgentZeroMessageResponse { + /** AZ chat context id — pass back on the next request to continue the conversation. */ + contextId: string; + /** Final agent reply text. */ + response: string; +} + +export class AgentZeroError extends Error { + readonly status: number; + constructor(message: string, status: number) { + super(message); + this.name = 'AgentZeroError'; + this.status = status; + } +} + +/** Mirrors AZ's LogItem dict shape (helpers/log.py:LogItem.output()). */ +export interface AgentZeroLogItem { + /** Monotonic position in the context's log — use as your poll cursor. */ + no: number; + /** AZ log entry id (UUID-ish). May be null for tombstone entries. */ + id: string | null; + /** AZ log type: 'user', 'agent', 'tool', 'error', 'response', etc. */ + type: string; + heading: string; + content: string; + /** AZ key/value pairs attached to the log (attachments, tool args, etc.). */ + kvps: Record | null; + /** Unix seconds (float). */ + timestamp: number; + /** Multi-agent index (0 = root). */ + agentno: number; +} + +export interface AgentZeroStreamOptions { + /** + * How often to poll /api/api_log_get for new items, ms. Default 400. + * The AZ web UI polls every 500ms; 400 keeps mobile feeling snappy without + * hammering loopback. + */ + pollIntervalMs?: number; + /** + * Max number of items to ask AZ for per poll (window into the tail of the + * log). Default 200 — enough for typical turns, small enough to stay cheap. + */ + pollWindow?: number; + /** + * Hard cap on how long streamMessage will wait for a final response item, + * ms. Default 5 minutes. Hitting this rejects with AgentZeroError(408). + */ + maxStreamMs?: number; +} + +export interface AgentZeroClientOptions { + /** Base HTTP URL of the AZ instance, no trailing slash. */ + baseUrl: string; + /** AZ auth token (the `X-API-KEY` header value — same string `create_auth_token()` produces). */ + token: string; + /** Optional fetch impl override for tests. */ + fetchImpl?: typeof fetch; +} + +export class AgentZeroClient { + private readonly baseUrl: string; + private readonly token: string; + private readonly doFetch: typeof fetch; + + constructor(options: AgentZeroClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ''); + this.token = options.token; + this.doFetch = options.fetchImpl ?? fetch; + } + + /** + * Liveness check: GET / returns 200 (or 302 to login). + * Anything else (or a network failure) → false. + */ + async health(signal?: AbortSignal): Promise { + try { + const res = await this.doFetch(`${this.baseUrl}/`, { method: 'GET', signal }); + return res.status === 200 || res.status === 302; + } catch { + return false; + } + } + + /** + * Send one user turn. On success the caller MUST store `contextId` and pass + * it back on the next call to continue the same AZ chat. + * + * Throws `AgentZeroError` for non-2xx responses (status is preserved) and + * stdlib `Error` for shape problems. + */ + async sendMessage(req: AgentZeroMessageRequest, signal?: AbortSignal): Promise { + if (!req.message || !req.message.trim()) { + throw new Error('message is required'); + } + + const body: Record = { message: req.message }; + if (req.contextId !== undefined) body.context_id = req.contextId; + if (req.attachments !== undefined) body.attachments = req.attachments; + if (req.projectName !== undefined) body.project_name = req.projectName; + if (req.agentProfile !== undefined) body.agent_profile = req.agentProfile; + if (req.lifetimeHours !== undefined) body.lifetime_hours = req.lifetimeHours; + + const res = await this.doFetch(`${this.baseUrl}/api/api_message`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-KEY': this.token, + }, + body: JSON.stringify(body), + signal, + }); + + const text = await res.text(); + let data: unknown = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + // body wasn't JSON — fall through to status-based error + } + + if (!res.ok) { + const errMsg = (data as { error?: unknown } | null)?.error; + throw new AgentZeroError( + typeof errMsg === 'string' && errMsg ? errMsg : `HTTP ${res.status}`, + res.status, + ); + } + + if (!data || typeof data !== 'object' || !('context_id' in data) || !('response' in data)) { + throw new Error('Invalid Agent Zero response shape — missing context_id or response'); + } + + return { + contextId: String((data as { context_id: unknown }).context_id), + response: String((data as { response: unknown }).response), + }; + } + + /** + * Start an AZ turn and stream the agent's log items as they happen. + * + * Flow: + * 1. POST /api/api_message_async to kick the agent off and capture its + * `context_id` immediately (no waiting for the final answer). + * 2. Poll /api/api_log_get every `pollIntervalMs` ms, emit each new log + * item via `onLogItem`. Stop when `progress_active=false` AND we + * received a `type === 'response'` item. + * + * Resolves with `{ contextId, response }`. The `response` is the content of + * the last `type === 'response'` log item (matches what sendMessage would + * have returned synchronously). + * + * Errors: + * - kickoff failure (4xx/5xx on api_message_async) → rejects synchronously + * - transient polling errors → swallowed, next tick retries + * - timeout watchdog (no response item within `maxStreamMs`, default 5min) + * → rejects with AgentZeroError(status 408) + * - signal abort → rejects with the abort reason + */ + async streamMessage( + req: AgentZeroMessageRequest, + onLogItem: (item: AgentZeroLogItem) => void, + options: AgentZeroStreamOptions = {}, + signal?: AbortSignal, + ): Promise { + if (!req.message || !req.message.trim()) { + throw new Error('message is required'); + } + const pollIntervalMs = options.pollIntervalMs ?? 400; + const pollWindow = options.pollWindow ?? 200; + const maxStreamMs = options.maxStreamMs ?? 5 * 60_000; + + // Phase 1: kickoff + const kickoffBody: Record = { message: req.message }; + if (req.contextId !== undefined) kickoffBody.context_id = req.contextId; + if (req.attachments !== undefined) kickoffBody.attachments = req.attachments; + if (req.projectName !== undefined) kickoffBody.project_name = req.projectName; + if (req.agentProfile !== undefined) kickoffBody.agent_profile = req.agentProfile; + if (req.lifetimeHours !== undefined) kickoffBody.lifetime_hours = req.lifetimeHours; + + const kickoffRes = await this.doFetch(`${this.baseUrl}/api/api_message_async`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-KEY': this.token, + }, + body: JSON.stringify(kickoffBody), + signal, + }); + const kickoffText = await kickoffRes.text(); + let kickoffData: unknown = null; + try { + kickoffData = kickoffText ? JSON.parse(kickoffText) : null; + } catch { + // fall through + } + if (!kickoffRes.ok) { + const errMsg = (kickoffData as { error?: unknown } | null)?.error; + throw new AgentZeroError( + typeof errMsg === 'string' && errMsg ? errMsg : `HTTP ${kickoffRes.status}`, + kickoffRes.status, + ); + } + const ctxId = (kickoffData as { context_id?: unknown } | null)?.context_id; + if (typeof ctxId !== 'string' || !ctxId) { + throw new Error('Invalid Agent Zero kickoff response — missing context_id'); + } + + // Phase 2: poll until the agent emits a 'response' item AND goes idle. + const startedAt = Date.now(); + let highWater = -1; + let finalResponse: string | null = null; + + // eslint-disable-next-line no-constant-condition + while (true) { + if (signal?.aborted) { + throw signal.reason ?? new Error('aborted'); + } + if (Date.now() - startedAt > maxStreamMs) { + throw new AgentZeroError( + `Streaming timeout: no response within ${maxStreamMs}ms`, + 408, + ); + } + + let snapshot: { items: AgentZeroLogItem[]; progressActive: boolean }; + try { + snapshot = await this.fetchLog(ctxId, pollWindow, signal); + } catch { + // transient — back off and retry + await sleep(pollIntervalMs, signal); + continue; + } + + for (const item of snapshot.items) { + if (item.no > highWater) { + highWater = item.no; + onLogItem(item); + if (item.type === 'response') { + finalResponse = item.content; + } + } + } + + if (!snapshot.progressActive && finalResponse !== null) { + return { contextId: ctxId, response: finalResponse }; + } + + await sleep(pollIntervalMs, signal); + } + } + + private async fetchLog( + contextId: string, + length: number, + signal?: AbortSignal, + ): Promise<{ items: AgentZeroLogItem[]; progressActive: boolean }> { + const res = await this.doFetch(`${this.baseUrl}/api/api_log_get`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-KEY': this.token, + }, + body: JSON.stringify({ context_id: contextId, length }), + signal, + }); + if (!res.ok) { + throw new AgentZeroError(`HTTP ${res.status}`, res.status); + } + const data = await res.json().catch(() => null) as unknown; + const log = (data as { log?: unknown } | null)?.log as + | { items?: unknown; progress_active?: unknown } + | undefined; + if (!log || !Array.isArray(log.items)) { + throw new Error('Invalid Agent Zero log response shape'); + } + return { + items: (log.items as unknown[]).filter(isLogItem), + progressActive: Boolean(log.progress_active), + }; + } +} + +function isLogItem(value: unknown): value is AgentZeroLogItem { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + return typeof v.no === 'number' + && typeof v.type === 'string' + && typeof v.heading === 'string' + && typeof v.content === 'string'; +} + +/** Promise-based sleep that respects an AbortSignal. */ +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = (): void => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + resolve(); + }; + signal?.addEventListener('abort', onAbort); + }); +} + +/** Convenience factory that pulls baseUrl from an AgentZeroGatewayConfig. */ +export function createAgentZeroClient( + config: AgentZeroGatewayConfig, + token: string, + fetchImpl?: typeof fetch, +): AgentZeroClient { + return new AgentZeroClient({ baseUrl: config.bridgeUrl, token, fetchImpl }); +} diff --git a/apps/mobile/src/services/app-update-announcement.test.ts b/apps/mobile/src/services/app-update-announcement.test.ts index f365b93..7b8958d 100644 --- a/apps/mobile/src/services/app-update-announcement.test.ts +++ b/apps/mobile/src/services/app-update-announcement.test.ts @@ -57,7 +57,7 @@ describe('app update announcement service', () => { }); it('returns null when the app version is not in the unified release history', () => { - expect(getCurrentAppUpdateAnnouncement('3.0.0')).toBeNull(); + expect(getCurrentAppUpdateAnnouncement('99.99.99')).toBeNull(); }); it('returns null when the app version is empty', () => { @@ -109,7 +109,7 @@ describe('app update announcement service', () => { await markCurrentAppUpdateAnnouncementShown(); expect(AsyncStorage.setItem).toHaveBeenCalledWith( - 'clawket.appUpdateAnnouncementSeen.v1:2.1.0', + `clawket.appUpdateAnnouncementSeen.v1:${APP_PACKAGE_VERSION}`, '1', ); }); diff --git a/apps/mobile/src/services/auto-app-review.test.ts b/apps/mobile/src/services/auto-app-review.test.ts index b9025ca..6debac1 100644 --- a/apps/mobile/src/services/auto-app-review.test.ts +++ b/apps/mobile/src/services/auto-app-review.test.ts @@ -1,12 +1,14 @@ jest.mock('react-native', () => ({ - InteractionManager: { - runAfterInteractions: (callback: () => void) => callback(), - }, Platform: { OS: 'ios', }, })); +// requestIdleCallback is not available in Node.js test env +if (typeof requestIdleCallback === 'undefined') { + (globalThis as any).requestIdleCallback = (cb: () => void) => cb(); +} + jest.mock('expo-store-review', () => ({ isAvailableAsync: jest.fn(), requestReview: jest.fn(), diff --git a/apps/mobile/src/services/auto-app-review.ts b/apps/mobile/src/services/auto-app-review.ts index 87f304f..b93c50c 100644 --- a/apps/mobile/src/services/auto-app-review.ts +++ b/apps/mobile/src/services/auto-app-review.ts @@ -1,4 +1,4 @@ -import { InteractionManager, Platform } from 'react-native'; +import { Platform } from 'react-native'; import * as StoreReview from 'expo-store-review'; import { APP_PACKAGE_VERSION } from '../constants/app-version'; import { AutoAppReviewState, StorageService } from './storage'; @@ -32,7 +32,7 @@ export function shouldAttemptAutomaticReview(params: { function runAfterInteractions(): Promise { return new Promise((resolve) => { - InteractionManager.runAfterInteractions(() => resolve()); + requestIdleCallback(() => resolve()); }); } diff --git a/apps/mobile/src/services/gateway-backends.test.ts b/apps/mobile/src/services/gateway-backends.test.ts index 07dd9cd..443d856 100644 --- a/apps/mobile/src/services/gateway-backends.test.ts +++ b/apps/mobile/src/services/gateway-backends.test.ts @@ -37,6 +37,20 @@ describe('gateway-backends', () => { expect(resolveGatewayBackendKind({ mode: 'local' } as any)).toBe('openclaw'); expect(resolveGatewayBackendKind({ mode: 'custom' } as any)).toBe('openclaw'); }); + + it('honors explicit backendKind === agentzero', () => { + expect(resolveGatewayBackendKind({ backendKind: 'agentzero' } as any)).toBe('agentzero'); + }); + + it('falls back to legacy mode === agentzero', () => { + expect(resolveGatewayBackendKind({ mode: 'agentzero' } as any)).toBe('agentzero'); + }); + + it('falls back to presence of agentzero config block', () => { + expect( + resolveGatewayBackendKind({ agentzero: { bridgeUrl: 'http://host:5000' } } as any), + ).toBe('agentzero'); + }); }); describe('resolveGatewayTransportKind', () => { @@ -96,6 +110,11 @@ describe('gateway-backends', () => { expect(resolveGlobalMainSessionKey('hermes')).toBe('main'); expect(resolveGlobalMainSessionKey({ backendKind: 'hermes' } as any)).toBe('main'); }); + + it('returns "main" for agentzero (session-shaped backend)', () => { + expect(resolveGlobalMainSessionKey('agentzero')).toBe('main'); + expect(resolveGlobalMainSessionKey({ backendKind: 'agentzero' } as any)).toBe('main'); + }); }); describe('getGatewayModeLabel', () => { @@ -111,6 +130,11 @@ describe('gateway-backends', () => { expect(getGatewayModeLabel({ backendKind: 'hermes', transportKind: 'relay' } as any)).toBe('Hermes'); expect(getGatewayModeLabel({ backendKind: 'hermes', transportKind: 'local' } as any)).toBe('Hermes'); }); + + it('labels Agent Zero independently of transport', () => { + expect(getGatewayModeLabel({ backendKind: 'agentzero', transportKind: 'tailscale' } as any)).toBe('Agent Zero'); + expect(getGatewayModeLabel({ backendKind: 'agentzero', transportKind: 'custom' } as any)).toBe('Agent Zero'); + }); }); describe('getGatewayBackendCapabilities', () => { @@ -167,9 +191,11 @@ describe('gateway-backends', () => { }); describe('type guards', () => { - it('isGatewayBackendKind accepts only the two known backends', () => { + it('isGatewayBackendKind accepts every registered backend', () => { expect(isGatewayBackendKind('openclaw')).toBe(true); expect(isGatewayBackendKind('hermes')).toBe(true); + expect(isGatewayBackendKind('youmind')).toBe(true); + expect(isGatewayBackendKind('agentzero')).toBe(true); expect(isGatewayBackendKind('other')).toBe(false); expect(isGatewayBackendKind(undefined)).toBe(false); }); @@ -199,5 +225,25 @@ describe('gateway-backends', () => { it('defaults to "custom" when no transport is provided for OpenClaw', () => { expect(toLegacyGatewayMode({ backendKind: 'openclaw' })).toBe('custom'); }); + + it('maps Agent Zero to the legacy "agentzero" mode irrespective of transport', () => { + expect(toLegacyGatewayMode({ backendKind: 'agentzero', transportKind: 'tailscale' })).toBe('agentzero'); + expect(toLegacyGatewayMode({ backendKind: 'agentzero', transportKind: 'custom' })).toBe('agentzero'); + }); + }); + + describe('Agent Zero capabilities (phase-1 sketch)', () => { + it('has the chat capability enabled but advanced console screens off until adapters land', () => { + const caps = getGatewayBackendCapabilities('agentzero'); + expect(caps.consoleRoot).toBe(true); + expect(caps.gatewayConnection).toBe(true); + expect(caps.chatAbort).toBe(true); + expect(caps.chatAttachments).toBe(true); + // Phase-1 intentionally off: + expect(caps.consoleCron).toBe(false); + expect(caps.consoleClawHub).toBe(false); + expect(caps.modelSelection).toBe(false); + expect(caps.openClawConfigScreens).toBe(false); + }); }); }); diff --git a/apps/mobile/src/services/gateway-backends.ts b/apps/mobile/src/services/gateway-backends.ts index 5abeadc..6835655 100644 --- a/apps/mobile/src/services/gateway-backends.ts +++ b/apps/mobile/src/services/gateway-backends.ts @@ -8,7 +8,7 @@ import type { import { THINKING_LEVELS } from '../utils/gateway-settings'; import type { ThinkingLevel } from '../utils/gateway-settings'; -type GatewayLike = Pick; +type GatewayLike = Pick; export type GatewayBackendCapabilities = { consoleRoot: boolean; @@ -102,6 +102,42 @@ const HERMES_CAPABILITIES: GatewayBackendCapabilities = { openClawConfigScreens: false, }; +// Agent Zero capabilities — phase 1 sketch. +// +// AZ is a session-shaped backend (one project at a time, like Hermes' "main" +// session model). Phase 1 ships only the chat path; console screens that need +// AZ-specific HTTP wiring stay off until their adapters land. When toggling +// any flag on, search for `selectByBackend` call sites in the corresponding +// screen and decide whether the existing `openclaw` fallthrough is acceptable +// or a dedicated `agentzero` branch is needed. +const AGENTZERO_CAPABILITIES: GatewayBackendCapabilities = { + consoleRoot: true, + gatewayConnection: true, + chatAbort: true, + chatAttachments: true, + consoleDiscover: false, + consoleClawHub: false, + modelCatalog: false, + modelSelection: false, + configRead: false, + configWrite: false, + consoleChannels: false, + consoleCron: false, + consoleCronCreate: false, + consoleSkills: false, + consoleUsage: false, + consoleCost: false, + consoleTools: false, + consoleNodes: false, + consoleFiles: false, + consoleLogs: false, + consoleAgentList: false, + consoleAgentDetail: false, + consoleAgentSessionsBoard: false, + consoleHeartbeat: false, + openClawConfigScreens: false, +}; + const YOUMIND_CAPABILITIES: GatewayBackendCapabilities = { consoleRoot: true, gatewayConnection: false, @@ -141,6 +177,11 @@ const BACKENDS: Record = { label: 'Hermes', capabilities: HERMES_CAPABILITIES, }, + agentzero: { + kind: 'agentzero', + label: 'Agent Zero', + capabilities: AGENTZERO_CAPABILITIES, + }, youmind: { kind: 'youmind', label: 'YouMind', @@ -150,6 +191,12 @@ const BACKENDS: Record = { const HERMES_THINKING_LEVELS: ThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh']; +// Agent Zero does not yet expose a thinking-level selector (the framework +// owns reasoning depth via the model config). Surface a single "off" entry +// so the UI selector renders consistently without claiming a feature we +// don't support over the wire. +const AGENTZERO_THINKING_LEVELS: ThinkingLevel[] = ['off']; + export function isGatewayTransportKind(value: unknown): value is GatewayTransportKind { return value === 'local' || value === 'tailscale' @@ -159,12 +206,13 @@ export function isGatewayTransportKind(value: unknown): value is GatewayTranspor } export function isGatewayBackendKind(value: unknown): value is GatewayBackendKind { - return value === 'openclaw' || value === 'hermes' || value === 'youmind'; + return value === 'openclaw' || value === 'hermes' || value === 'youmind' || value === 'agentzero'; } export function resolveGatewayBackendKind(value: GatewayLike | null | undefined): GatewayBackendKind { if (isGatewayBackendKind(value?.backendKind)) return value.backendKind; if (value?.mode === 'hermes' || value?.hermes) return 'hermes'; + if (value?.mode === 'agentzero' || value?.agentzero) return 'agentzero'; return 'openclaw'; } @@ -180,6 +228,7 @@ export function toLegacyGatewayMode(value: { transportKind?: GatewayTransportKind; }): GatewayMode { if (value.backendKind === 'hermes') return 'hermes'; + if (value.backendKind === 'agentzero') return 'agentzero'; return value.transportKind ?? 'custom'; } @@ -198,6 +247,7 @@ export function getGatewayThinkingLevels( return selectByBackend(input, { openclaw: [...THINKING_LEVELS], hermes: [...HERMES_THINKING_LEVELS], + agentzero: [...AGENTZERO_THINKING_LEVELS], }); } @@ -216,13 +266,17 @@ export function getGatewayThinkingLevels( */ export function selectByBackend( input: GatewayLike | GatewayBackendKind | null | undefined, - options: { openclaw: T; hermes: T; youmind?: T }, + options: { openclaw: T; hermes: T; youmind?: T; agentzero?: T }, ): T { const kind = typeof input === 'string' && isGatewayBackendKind(input) ? input : resolveGatewayBackendKind(input as GatewayLike | null | undefined); if (kind === 'hermes') return options.hermes; if (kind === 'youmind') return options.youmind ?? options.openclaw; + // Agent Zero falls back to openclaw branches at call sites that haven't + // opted in yet — same pattern as youmind. Wire a dedicated `agentzero` + // option as each screen learns its AZ behavior. + if (kind === 'agentzero') return options.agentzero ?? options.openclaw; return options.openclaw; } @@ -244,6 +298,7 @@ export function resolveGlobalMainSessionKey( openclaw: null, hermes: 'main', youmind: 'main', + agentzero: 'main', }); } @@ -251,6 +306,7 @@ export function getGatewayModeLabel(input: GatewayLike): string { const backendKind = resolveGatewayBackendKind(input); const transportKind = resolveGatewayTransportKind(input); if (backendKind === 'hermes') return 'Hermes'; + if (backendKind === 'agentzero') return 'Agent Zero'; if (backendKind === 'youmind') return 'YouMind'; switch (transportKind) { case 'relay': @@ -277,11 +333,13 @@ export function buildGatewayDefaultName(input: { const host = parseHost(input.url); const baseLabel = backendKind === 'hermes' ? 'Hermes' - : backendKind === 'youmind' - ? 'YouMind' - : transportKind === 'relay' - ? 'Relay' - : 'Custom'; + : backendKind === 'agentzero' + ? 'Agent Zero' + : backendKind === 'youmind' + ? 'YouMind' + : transportKind === 'relay' + ? 'Relay' + : 'Custom'; if (host) return `${baseLabel} (${host})`; return `${baseLabel} Gateway ${input.index}`; } diff --git a/apps/mobile/src/services/storage.ts b/apps/mobile/src/services/storage.ts index 6099e6b..5710877 100644 --- a/apps/mobile/src/services/storage.ts +++ b/apps/mobile/src/services/storage.ts @@ -697,7 +697,22 @@ export const StorageService = { await this.setGatewayConfigsState(migrated); return migrated; } - return { activeId: null, configs: [] }; + const _now = Date.now(); + const _defaultHermes: GatewayConfigsState = { + activeId: 'hermes-binarybros-default', + configs: [{ + id: 'hermes-binarybros-default', + name: 'Hermes (Binary Bros)', + backendKind: 'hermes', + transportKind: 'local', + mode: 'hermes', + url: 'ws://studio.capybara-loggerhead.ts.net:4319/v1/hermes/ws?token=binarybros-hermes-clawket-2026', + hermes: { bridgeUrl: 'http://studio.capybara-loggerhead.ts.net:4319', displayName: 'Binary Bros Hermes' }, + createdAt: _now, + updatedAt: _now, + }], + }; + return _defaultHermes; }, async setIdentity(identity: DeviceIdentity): Promise { diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts index c33be64..5190657 100644 --- a/apps/mobile/src/types/index.ts +++ b/apps/mobile/src/types/index.ts @@ -30,9 +30,29 @@ export interface HermesGatewayConfig { displayName?: string; } -export type GatewayBackendKind = 'openclaw' | 'hermes' | 'youmind'; +/** + * Agent Zero connection block. + * + * Agent Zero exposes a Socket.IO transport on the same HTTP host that serves + * its web UI (port 5000 by default). Unlike OpenClaw the chat path is not a + * bare WebSocket — clients connect via Socket.IO and authenticate with the + * `X-API-KEY` header (the value comes from helpers/settings.create_auth_token + * on the AZ side, currently surfaced as `token` on this config). + * + * `projectName` switches the active AZ project (analogous to `/project foo` + * in the `a0` CLI). When unset, AZ uses the default project. + */ +export interface AgentZeroGatewayConfig { + /** Base HTTP URL of the AZ instance (no trailing slash). e.g. https://agent-habitat.capybara-loggerhead.ts.net:8443 */ + bridgeUrl: string; + displayName?: string; + /** Optional AZ project to scope the conversation to. */ + projectName?: string; +} + +export type GatewayBackendKind = 'openclaw' | 'hermes' | 'youmind' | 'agentzero'; export type GatewayTransportKind = 'local' | 'tailscale' | 'cloudflare' | 'custom' | 'relay'; -export type GatewayMode = GatewayTransportKind | 'hermes'; +export type GatewayMode = GatewayTransportKind | 'hermes' | 'agentzero'; export interface GatewayConfig { url: string; @@ -44,6 +64,7 @@ export interface GatewayConfig { mode?: GatewayMode; relay?: RelayGatewayConfig; hermes?: HermesGatewayConfig; + agentzero?: AgentZeroGatewayConfig; debugMode?: boolean; } @@ -74,6 +95,7 @@ export interface SavedGatewayConfig { password?: string; relay?: RelayGatewayConfig; hermes?: HermesGatewayConfig; + agentzero?: AgentZeroGatewayConfig; createdAt: number; updatedAt: number; } diff --git a/apps/mobile/src/utils/usage-format.ts b/apps/mobile/src/utils/usage-format.ts index 4476a34..8d9e7f9 100644 --- a/apps/mobile/src/utils/usage-format.ts +++ b/apps/mobile/src/utils/usage-format.ts @@ -52,7 +52,9 @@ export function formatDayLabel(dateStr: string): string { const [, y, m, d] = match; const date = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d))); if (Number.isNaN(date.valueOf())) return dateStr; - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + // Format in UTC to match how the date was constructed. Without timeZone: 'UTC' + // a viewer west of UTC sees "Jan 14" for a "2024-01-15" input. + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' }); } export function pct(part: number, total: number): number { diff --git a/package-lock.json b/package-lock.json index 0ae8c5b..87375ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ }, "apps/bridge-cli": { "name": "@p697/clawket", - "version": "0.6.3", + "version": "0.6.4", "license": "AGPL-3.0-only", "dependencies": { "qrcode": "^1.5.4", @@ -1544,7 +1544,8 @@ }, "apps/mobile": { "name": "clawket", - "version": "2.0.0", + "version": "2.1.0", + "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { "@bottom-tabs/react-navigation": "^1.1.0", @@ -1697,20 +1698,6 @@ "node": ">=12.20" } }, - "apps/mobile/node_modules/@expo/ui": { - "version": "55.0.1", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-55.0.1.tgz", - "integrity": "sha512-j9UFdW2OaM4+dmk5UYzgwtwioreWJlGQUhBxPUH2/5U4ROS2JPyJo0jtwgM6bfawyYQfobKvp0utVoRQJ3Ul1A==", - "license": "MIT", - "dependencies": { - "sf-symbols-typescript": "^2.1.0" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, "apps/mobile/node_modules/@gorhom/bottom-sheet": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.8.tgz", @@ -2302,130 +2289,6 @@ "devOptional": true, "license": "MIT" }, - "apps/mobile/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "apps/mobile/node_modules/brace-expansion/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "apps/mobile/node_modules/expo-application": { - "version": "55.0.8", - "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-55.0.8.tgz", - "integrity": "sha512-PeZk4Zj8LlzRcRtK3J4ouSPBoi9lroYsRbbz/0HEvx+uB6HIaM1qfzgpcctvjkdJJfnidBQNyieW5BVO/qUQ6w==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, - "apps/mobile/node_modules/expo-battery": { - "version": "55.0.8", - "resolved": "https://registry.npmjs.org/expo-battery/-/expo-battery-55.0.8.tgz", - "integrity": "sha512-kPNpYWAvsjQxEcmdHOU1nJGnLua/Ov8EBd2zRxpxs/saNIoOeZXpupVz74/pTrG/9JgTrPm+v1RoLqVRM7fgkg==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*" - } - }, - "apps/mobile/node_modules/expo-blur": { - "version": "55.0.8", - "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-55.0.8.tgz", - "integrity": "sha512-hmYhQiYFMTpHNzFaCF63jTABgnsozB5+wSEV8rCNBfNEHTdRVNFPkF+TZpbIqJpkhHfGLsKptRmR/wyZmAqREA==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "apps/mobile/node_modules/expo-camera": { - "version": "55.0.9", - "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-55.0.9.tgz", - "integrity": "sha512-ywjmZChGPqyIginN9K1lEJCSTB6M4the37m56tAcvYs3cu0DAUAlTl4n2MY6S7kGdm3iEi0cGmlDpHFyh7rmwg==", - "license": "MIT", - "dependencies": { - "barcode-detector": "^3.0.0" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*", - "react-native-web": "*" - }, - "peerDependenciesMeta": { - "react-native-web": { - "optional": true - } - } - }, - "apps/mobile/node_modules/expo-clipboard": { - "version": "55.0.8", - "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-55.0.8.tgz", - "integrity": "sha512-s0Hkop+dc6m09LwzUAWweNI0gzLAaX5CgEGR8TMdOdSPKTPc2rCl8h8Ji/cUNM1wYoJQ4Wysa15E8If/Vlu7WA==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "apps/mobile/node_modules/expo-device": { - "version": "55.0.9", - "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-55.0.9.tgz", - "integrity": "sha512-BzeuL7lwg2jh/tU+HTJ5dxygB1tpfgThaguPPH86K0ujcj/4RBkC27i/i7nhSoWvL1pQIgUqL0L7WTtjcS9t/w==", - "license": "MIT", - "dependencies": { - "ua-parser-js": "^0.7.33" - }, - "peerDependencies": { - "expo": "*" - } - }, - "apps/mobile/node_modules/expo-document-picker": { - "version": "55.0.8", - "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-55.0.8.tgz", - "integrity": "sha512-p6rYEQ1/h3UqGl3+hzTjv51fsNxoOVfMGSYjHX2/e3cvcy02MWWE+bpj4QEGo9MBwU4RyyIbuv/SCxGtAtG+eA==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, - "apps/mobile/node_modules/expo-file-system": { - "version": "55.0.10", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.10.tgz", - "integrity": "sha512-ysFdVdUgtfj2ApY0Cn+pBg+yK4xp+SNwcaH8j2B91JJQ4OXJmnyCSmrNZYz7J4mdYVuv2GzxIP+N/IGlHQG3Yw==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, - "apps/mobile/node_modules/expo-haptics": { - "version": "55.0.8", - "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-55.0.8.tgz", - "integrity": "sha512-yVR6EsQwl1WuhFITc0PpfI/7dsBdjK/F2YA8xB80UUW9iTa+Tqz21FpH4n/vtbargpzFxkhl5WNYMa419+QWFQ==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, "apps/mobile/node_modules/expo-image-manipulator": { "version": "55.0.11", "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-55.0.11.tgz", @@ -2438,120 +2301,6 @@ "expo": "*" } }, - "apps/mobile/node_modules/expo-image-picker": { - "version": "55.0.9", - "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-55.0.9.tgz", - "integrity": "sha512-e82tLpbg5U2qFdtDQLIcISMNhIr+a4Nt/ZfM+nC6X7qaGjzeYAuzvcb3xUe80Ix1H3D64b/tiA6a7KjQ48cfFA==", - "license": "MIT", - "dependencies": { - "expo-image-loader": "~55.0.0" - }, - "peerDependencies": { - "expo": "*" - } - }, - "apps/mobile/node_modules/expo-linking": { - "version": "55.0.7", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-55.0.7.tgz", - "integrity": "sha512-MiGCedere1vzQTEi2aGrkzd7eh/rPSz4w6F3GMBuAJzYl+/0VhIuyhozpEGrueyDIXWfzaUVOcn3SfxVi+kwQQ==", - "license": "MIT", - "dependencies": { - "expo-constants": "~55.0.7", - "invariant": "^2.2.4" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, - "apps/mobile/node_modules/expo-localization": { - "version": "55.0.8", - "resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-55.0.8.tgz", - "integrity": "sha512-uFmpTsoDT7JE5Nwgt0EQ5gBvFVo7/u458SlY6V9Ep9wY/WPucL0o00VpXoFULaMtKHquKBgVUdHwk6E+JFz4dg==", - "license": "MIT", - "dependencies": { - "rtl-detect": "^1.0.2" - }, - "peerDependencies": { - "expo": "*", - "react": "*" - } - }, - "apps/mobile/node_modules/expo-location": { - "version": "55.1.2", - "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-55.1.2.tgz", - "integrity": "sha512-QT/9Hh0mjBnsJD1A6VYVKskFK9PODTpHVMA3DmGDTbvKS55yhY/fj6f28dn3zAYTaLAWIZa5j3/C+LCJSq7kwA==", - "license": "MIT", - "dependencies": { - "@expo/image-utils": "^0.8.12" - }, - "peerDependencies": { - "expo": "*" - } - }, - "apps/mobile/node_modules/expo-media-library": { - "version": "55.0.9", - "resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-55.0.9.tgz", - "integrity": "sha512-E12e4gjQEZNdAa7MHDLOAiOMQmhmOGHFMMU5DpiK6I01hPXyRcaxgAQQLpibIXNP4O5LjGi2psa3NBacjyjxkw==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, - "apps/mobile/node_modules/expo-network": { - "version": "55.0.8", - "resolved": "https://registry.npmjs.org/expo-network/-/expo-network-55.0.8.tgz", - "integrity": "sha512-IXxwFq5B2OImc6BvHHGN9QOCYfMk3wPnbBL+NiyBFqy+g2LsdnGzWLA2HXD8+1Ngdvi7PwckQO+q6WKHLRx4Vw==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*" - } - }, - "apps/mobile/node_modules/expo-notifications": { - "version": "55.0.10", - "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-55.0.10.tgz", - "integrity": "sha512-F+ozrVFthKCwfqz2cXmcqrqwzBMTAwoNBqTZERuFtgc+6I++mweVzLLTtbAy8kBDZ33MA13GKyeq7mw13/rDgw==", - "license": "MIT", - "dependencies": { - "@expo/image-utils": "^0.8.12", - "abort-controller": "^3.0.0", - "badgin": "^1.1.5", - "expo-application": "~55.0.8", - "expo-constants": "~55.0.7" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "apps/mobile/node_modules/expo-secure-store": { - "version": "55.0.8", - "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-55.0.8.tgz", - "integrity": "sha512-8w9tQe8U6oRo5YIzqCqVhRrOnfoODNDoitBtLXEx+zS6WLUnkRq5kH7ViJuOgiM7PzLr9pvAliRiDOKyvFbTuQ==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, - "apps/mobile/node_modules/expo-sharing": { - "version": "55.0.11", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-55.0.11.tgz", - "integrity": "sha512-YlVez832W0sYR2KJY4Dr8ON9aC+Wp8a/r40eQyhoHT9Tetkr2KBM7tWLT0CGKRuTTnrqJL1C51UacLkHJ9zmNA==", - "license": "MIT", - "dependencies": { - "@expo/config-plugins": "^55.0.6", - "@expo/config-types": "^55.0.5", - "@expo/plist": "^0.5.2" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, "apps/mobile/node_modules/expo-status-bar": { "version": "55.0.4", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-55.0.4.tgz", @@ -2575,16 +2324,6 @@ "react-native": "*" } }, - "apps/mobile/node_modules/expo-store-review": { - "version": "55.0.8", - "resolved": "https://registry.npmjs.org/expo-store-review/-/expo-store-review-55.0.8.tgz", - "integrity": "sha512-DxDD5F8jEAFV2daotIQtLRO5KTtKE/cEgaTC5fHvMDLn0BWfXRrPKsYoMma4vvcLdx4zqf905OTK2tT4w7L/zw==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "apps/mobile/node_modules/i18next": { "version": "25.10.3", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.3.tgz", @@ -2643,309 +2382,49 @@ } } }, - "apps/mobile/node_modules/jest-expo": { - "version": "55.0.9", - "resolved": "https://registry.npmjs.org/jest-expo/-/jest-expo-55.0.9.tgz", - "integrity": "sha512-6wz7JJUeW2e0+APRQP7eOcXKPdI7bdmAIoBiPJbtzSBRlghho8LzPcv4jkoVFoYi8SKb9k3BTKx4GcUlyVMedw==", + "apps/mobile/node_modules/jest/node_modules/@jest/core": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", + "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", "dev": true, "license": "MIT", "dependencies": { - "@expo/config": "~55.0.8", - "@expo/json-file": "^10.0.12", - "@jest/create-cache-key-function": "^29.2.1", - "@jest/globals": "^29.2.1", - "babel-jest": "^29.2.1", - "jest-environment-jsdom": "^29.2.1", - "jest-snapshot": "^29.2.1", - "jest-watch-select-projects": "^2.0.0", - "jest-watch-typeahead": "2.2.1", - "json5": "^2.2.3", - "lodash": "^4.17.19", - "react-test-renderer": "19.2.0", - "server-only": "^0.0.1", - "stacktrace-js": "^2.0.2" + "@jest/console": "30.3.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.3.0", + "jest-config": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-resolve-dependencies": "30.3.0", + "jest-runner": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "jest-watcher": "30.3.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0" }, - "bin": { - "jest": "bin/jest.js" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "expo": "*", - "react-native": "*", - "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "peerDependenciesMeta": { - "react-server-dom-webpack": { - "optional": true - } - } - }, - "apps/mobile/node_modules/jest-expo/node_modules/@expo/config": { - "version": "55.0.10", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-55.0.10.tgz", - "integrity": "sha512-qCHxo9H1ZoeW+y0QeMtVZ3JfGmumpGrgUFX60wLWMarraoQZSe47ZUm9kJSn3iyoPjUtUNanO3eXQg+K8k4rag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@expo/config-plugins": "~55.0.7", - "@expo/config-types": "^55.0.5", - "@expo/json-file": "^10.0.12", - "@expo/require-utils": "^55.0.3", - "deepmerge": "^4.3.1", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "resolve-from": "^5.0.0", - "resolve-workspace-root": "^2.0.0", - "semver": "^7.6.0", - "slugify": "^1.3.4" - } - }, - "apps/mobile/node_modules/jest-expo/node_modules/@expo/config/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "apps/mobile/node_modules/jest-expo/node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "apps/mobile/node_modules/jest-expo/node_modules/@jest/globals/node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "apps/mobile/node_modules/jest-expo/node_modules/@jest/globals/node_modules/@jest/expect/node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "apps/mobile/node_modules/jest-expo/node_modules/@jest/globals/node_modules/@jest/expect/node_modules/expect/node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "apps/mobile/node_modules/jest-expo/node_modules/@jest/globals/node_modules/@jest/expect/node_modules/expect/node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "apps/mobile/node_modules/jest-expo/node_modules/@jest/globals/node_modules/@jest/expect/node_modules/expect/node_modules/jest-matcher-utils/node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "apps/mobile/node_modules/jest-expo/node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "apps/mobile/node_modules/jest-expo/node_modules/jest-snapshot/node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "apps/mobile/node_modules/jest-expo/node_modules/jest-snapshot/node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "apps/mobile/node_modules/jest-expo/node_modules/jest-snapshot/node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "apps/mobile/node_modules/jest-expo/node_modules/jest-snapshot/node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "apps/mobile/node_modules/jest/node_modules/@jest/core": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", - "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.3.0", - "@jest/pattern": "30.0.1", - "@jest/reporters": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", - "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-changed-files": "30.3.0", - "jest-config": "30.3.0", - "jest-haste-map": "30.3.0", - "jest-message-util": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.3.0", - "jest-resolve-dependencies": "30.3.0", - "jest-runner": "30.3.0", - "jest-runtime": "30.3.0", - "jest-snapshot": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", - "jest-watcher": "30.3.0", - "pretty-format": "30.3.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { + "node-notifier": { "optional": true } } @@ -8266,22 +7745,6 @@ "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" } }, - "apps/mobile/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "apps/mobile/node_modules/posthog-react-native": { "version": "4.37.5", "resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.37.5.tgz", @@ -9478,13 +8941,6 @@ "@cloudflare/workers-types": "^4.20260220.0" } }, - "apps/relay-registry/node_modules/@cloudflare/workers-types": { - "version": "4.20260317.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260317.1.tgz", - "integrity": "sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ==", - "dev": true, - "license": "MIT OR Apache-2.0" - }, "apps/relay-worker": { "name": "@clawket/relay-worker", "version": "0.1.0", @@ -9496,13 +8952,6 @@ "@cloudflare/workers-types": "^4.20260220.0" } }, - "apps/relay-worker/node_modules/@cloudflare/workers-types": { - "version": "4.20260317.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260317.1.tgz", - "integrity": "sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ==", - "dev": true, - "license": "MIT OR Apache-2.0" - }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -11883,6 +11332,20 @@ "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", "license": "MIT" }, + "node_modules/@expo/ui": { + "version": "55.0.13", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-55.0.13.tgz", + "integrity": "sha512-B4qNSksgHEOx/q+zwz6NNomos/w7ZptjewjfMpGVc09CUBYWBGHLYP8i5Tc4HgqLN3p4BJalReRv+X39OefJaw==", + "license": "MIT", + "dependencies": { + "sf-symbols-typescript": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/@expo/vector-icons": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", @@ -15027,27 +14490,64 @@ "react-native": "*" } }, - "node_modules/expo-auth-session/node_modules/expo-constants": { + "node_modules/expo-battery": { + "version": "55.0.13", + "resolved": "https://registry.npmjs.org/expo-battery/-/expo-battery-55.0.13.tgz", + "integrity": "sha512-yt2BZCs76aeM9UPbIf8pAGvZJ6AQ7UQ8qjiQcciU3o3q512ZTgsIWObfOqZlTy/abzU2njHW6OikWbtQbeyeZg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo-blur": { "version": "55.0.14", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.14.tgz", - "integrity": "sha512-l23QVQCYBPKT5zbxxZdJeuhiunadvWdjcQ9+GC8h+02jCoLmWRk20064nCINnQTP3Hf+uLPteUiwYrJd0e446w==", + "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-55.0.14.tgz", + "integrity": "sha512-NKyCKFWTNpX4CZSsiE1sgkqk/yvR1K0UTukuIbxVKoobB+yALLg1CFav0NqfdQqjhtoj5oEzP0Brlq92Z08Zfg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-camera": { + "version": "55.0.16", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-55.0.16.tgz", + "integrity": "sha512-9c6FGrLVwMLyQ08wqwkH7DXAC8Oj1VD0LXM4hFu62Nuq2f2zIAZwsXxG7J5ex+HHHAGyligGGi6VJWuiib9qNg==", "license": "MIT", "dependencies": { - "@expo/config": "~55.0.15", - "@expo/env": "~2.1.1" + "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-clipboard": { + "version": "55.0.13", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-55.0.13.tgz", + "integrity": "sha512-PrOmmuVsGW4bAkNQmGKtxMXj3invsfN+jfIKmQxHwE/dn7ODqwFWviUTa+PMUjP3XZmYCDLyu/i0GLeu7HF9Ew==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", "react-native": "*" } }, "node_modules/expo-constants": { - "version": "55.0.9", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.9.tgz", - "integrity": "sha512-iBiXjZeuU5S/8docQeNzsVvtDy4w0zlmXBpFEi1ypwugceEpdQQab65TVRbusXAcwpNVxCPMpNlDssYp0Pli2g==", + "version": "55.0.15", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.15.tgz", + "integrity": "sha512-w394fcZLJjeKN+9ZnJzL/HiarE1nwZFDa+3S9frevh6Ur+MAAs9QDrcXhDrV8T3xqRzzYaqsP6Z8TFZ4efWN1A==", "license": "MIT", "dependencies": { - "@expo/config": "~55.0.10", "@expo/env": "~2.1.1" }, "peerDependencies": { @@ -15064,6 +14564,27 @@ "expo": "*" } }, + "node_modules/expo-device": { + "version": "55.0.15", + "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-55.0.15.tgz", + "integrity": "sha512-vXy4U/IeYI+zHGG45Ap6J7EuyQmkstyo8I+/5YGr5q2zmqLBo6SWE62wii8i9hLHheHn6AtF9UPrSWAREJrE8A==", + "license": "MIT", + "dependencies": { + "ua-parser-js": "^0.7.33" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-document-picker": { + "version": "55.0.13", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-55.0.13.tgz", + "integrity": "sha512-IhswJElhdzs3fKDEKW8KXYRoFkWGEsXRMYAZT46Yo56zqqy8yQXrczo33RSwD2hFzNQBdLT97SJL9N311UyS3g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "55.0.12", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.12.tgz", @@ -15088,6 +14609,15 @@ "react-native": "*" } }, + "node_modules/expo-haptics": { + "version": "55.0.14", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-55.0.14.tgz", + "integrity": "sha512-KjDItBsA9mi1f5nRwf8g1wOdfEcLHwvEdt5Jl1sMCDETR/homcGOl+F3QIiPOl/PRlbGVieQsjTtF4DGtHOj6g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-image-loader": { "version": "55.0.0", "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-55.0.0.tgz", @@ -15097,6 +14627,18 @@ "expo": "*" } }, + "node_modules/expo-image-picker": { + "version": "55.0.19", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-55.0.19.tgz", + "integrity": "sha512-PqOOfRz7+hbB9IFN0LfNxpJJwuPlUG0Abr0qM3Wc61OJ7FFyuKJ50QJ/fFItzSuoXifET1YIFBiXx5nA8Gkinw==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~55.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "55.0.4", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-55.0.4.tgz", @@ -15121,15 +14663,36 @@ "react-native": "*" } }, - "node_modules/expo-linking/node_modules/expo-constants": { - "version": "55.0.14", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.14.tgz", - "integrity": "sha512-l23QVQCYBPKT5zbxxZdJeuhiunadvWdjcQ9+GC8h+02jCoLmWRk20064nCINnQTP3Hf+uLPteUiwYrJd0e446w==", + "node_modules/expo-localization": { + "version": "55.0.13", + "resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-55.0.13.tgz", + "integrity": "sha512-fXiEUUihIrXmAEzoneaTOFcQ7TKmr25RR/ymrB/MvYTVnmevFA1zY2KI0VSiXY+NKKjZ8mG65YSn1wh4gEYKxA==", "license": "MIT", "dependencies": { - "@expo/config": "~55.0.15", - "@expo/env": "~2.1.1" + "rtl-detect": "^1.0.2" }, + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo-location": { + "version": "55.1.8", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-55.1.8.tgz", + "integrity": "sha512-mEExFf84nmWLwi14GFfUsFLrCm10gbcqFn9EPXpuruQ28YMtJWgCD+jJtESYPQkYF44N21fVok3T28fLuCqydA==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.13" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-media-library": { + "version": "55.0.15", + "resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-55.0.15.tgz", + "integrity": "sha512-VD2Lppq0JR6TeI4ivVGsbz4UJ+hKD0DSB/P+I0eoOpoOcLc3/5WboDKgy2uW8YYZqee55V+JU7TMdRDi+lTnCg==", + "license": "MIT", "peerDependencies": { "expo": "*", "react-native": "*" @@ -15172,6 +14735,43 @@ "react-native": "*" } }, + "node_modules/expo-network": { + "version": "55.0.13", + "resolved": "https://registry.npmjs.org/expo-network/-/expo-network-55.0.13.tgz", + "integrity": "sha512-7u+npCmCPRpVrjkUlQtUetPnTN1gRyj7z13bBM5w9w1AHMb4PfoxtIys5EB9ukzNYBg/gaZ/y5dtxomGpc6BKw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo-notifications": { + "version": "55.0.22", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-55.0.22.tgz", + "integrity": "sha512-Rwvsp/lAEXfDYBxkQZpaLF9ZB25cJ/yfHhD/ESclbPesN0nbQBZ/5rGb1xS/saANtkStbEGfDlA80uHh2zEpsA==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.13", + "abort-controller": "^3.0.0", + "badgin": "^1.1.5", + "expo-application": "~55.0.14", + "expo-constants": "~55.0.15" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-secure-store": { + "version": "55.0.13", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-55.0.13.tgz", + "integrity": "sha512-I6r0JNO1Fd4o0Gu7Ixiic7s89lqgdUHq17uBH9y1f/AntoyKn71TdtYJH82RgfsBbu5qNVzrwImmvlANyOlITQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "55.0.6", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.6.tgz", @@ -15181,6 +14781,32 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sharing": { + "version": "55.0.18", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-55.0.18.tgz", + "integrity": "sha512-Tqy4LXRLw/UEg5mT7BKhx8y4ReNz8fVldvhHJV5cesH3kRgEerHkYxVwid2vd7v34KnNp0RH1OqUyDlzZTQ9AQ==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "^55.0.8", + "@expo/config-types": "^55.0.5", + "@expo/plist": "^0.5.2" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-store-review": { + "version": "55.0.13", + "resolved": "https://registry.npmjs.org/expo-store-review/-/expo-store-review-55.0.13.tgz", + "integrity": "sha512-3cIfDUOBArAeuDQEiYToTZdB1UGSHSe4NhLunEf7hYG86ICgSYIXcosisYnsMLTE6GxL/0XJ34sQOfrP7HfASA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-web-browser": { "version": "55.0.14", "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-55.0.14.tgz", @@ -16748,6 +16374,166 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-expo": { + "version": "55.0.16", + "resolved": "https://registry.npmjs.org/jest-expo/-/jest-expo-55.0.16.tgz", + "integrity": "sha512-bOvrTNyDaiaoTz9GhvnXib9v9rjX9PTJFvvoqRMRKEg4MoHghG82E7YF+pH71EWSXTaibQ07F46GS+fcUxTWEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/config": "~55.0.15", + "@expo/json-file": "^10.0.13", + "@jest/create-cache-key-function": "^29.2.1", + "@jest/globals": "^29.2.1", + "babel-jest": "^29.2.1", + "jest-environment-jsdom": "^29.2.1", + "jest-snapshot": "^29.2.1", + "jest-watch-select-projects": "^2.0.0", + "jest-watch-typeahead": "2.2.1", + "json5": "^2.2.3", + "lodash": "^4.17.19", + "react-test-renderer": "19.2.0", + "server-only": "^0.0.1", + "stacktrace-js": "^2.0.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*", + "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" + }, + "peerDependenciesMeta": { + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/jest-expo/node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-expo/node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-expo/node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-expo/node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-expo/node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-expo/node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-expo/node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",