From 3cddcfacf1f1011d113b0c7d7a8fd1158a6a87c4 Mon Sep 17 00:00:00 2001 From: Ripnrip Date: Sat, 30 May 2026 00:15:57 -0400 Subject: [PATCH 01/14] chore: ignore Claude Code session state directory --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 From 6d86b5654de53beb2eec908c1da2c02ee01c4ecb Mon Sep 17 00:00:00 2001 From: Ripnrip Date: Sat, 30 May 2026 00:16:42 -0400 Subject: [PATCH 02/14] chore(mobile): bump to 3.0.0; swap InteractionManager for requestIdleCallback - package.json: 2.1.0 -> 3.0.0 (matches app.json marketing version) - App.tsx: tolerate a missing office-game/dist/office-inline.js bundle so dev builds without office assets don't hard-crash; fall back to an empty doc - ChatTab/YouMindChatTab/ConfigScreen/auto-app-review: replace InteractionManager.runAfterInteractions with requestIdleCallback. RN's InteractionManager can stall on busy animations; requestIdleCallback yields cleanly. - SceneDelegate.swift: scene lifecycle stub for iOS deep-linking + lifecycle events generated by Expo prebuild. --- apps/mobile/App.tsx | 21 ++++++++--- apps/mobile/ios/SceneDelegate.swift | 35 +++++++++++++++++++ apps/mobile/package.json | 2 +- .../mobile/src/screens/ChatScreen/ChatTab.tsx | 4 +-- .../src/screens/ChatScreen/YouMindChatTab.tsx | 9 +++-- .../mobile/src/screens/ConfigScreen/index.tsx | 6 ++-- .../src/services/auto-app-review.test.ts | 8 +++-- apps/mobile/src/services/auto-app-review.ts | 4 +-- 8 files changed, 68 insertions(+), 21 deletions(-) create mode 100644 apps/mobile/ios/SceneDelegate.swift 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/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/screens/ChatScreen/ChatTab.tsx b/apps/mobile/src/screens/ChatScreen/ChatTab.tsx index de387ec..665153f 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'; @@ -104,7 +104,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(); }); } 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/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/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()); }); } From ae265f7f9bb1a9a0451fad4ff4aecfbe1617265d Mon Sep 17 00:00:00 2001 From: Ripnrip Date: Sat, 30 May 2026 00:16:57 -0400 Subject: [PATCH 03/14] fix(mobile): UTC-format day labels; dehardcode the app-update test version - usage-format.formatDayLabel: pass timeZone: 'UTC' to toLocaleDateString. The input "2024-01-15" was being rendered as "Jan 14" by viewers west of UTC because Date.UTC + local toLocaleDateString shifted the day backward. - app-update-announcement.test: replace the hardcoded "2.1.0" assertion with `APP_PACKAGE_VERSION` so the storage-key test stops breaking every version bump; replace the "3.0.0 not in history" probe with "99.99.99" so the test doesn't lie when 3.0.0 actually gets a release entry. Both tests were red on main; they're green now. --- apps/mobile/src/services/app-update-announcement.test.ts | 4 ++-- apps/mobile/src/utils/usage-format.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) 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/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 { From eca4a96cf32e177a3d34abe290790d9d72818876 Mon Sep 17 00:00:00 2001 From: Ripnrip Date: Sat, 30 May 2026 00:17:13 -0400 Subject: [PATCH 04/14] feat(mobile): switch Hermes default endpoint to Tailscale MagicDNS The seed Hermes (Binary Bros) connection was pointing at the Mac Studio's raw Tailscale IP (100.89.167.39). That worked, but: - the bridge actually runs on agent-habitat now, not the studio - tryCloudflare tunnel URLs that previously hosted the bridge expire after a few hours and stranded the iOS app on dead endpoints Repointing the seed at the habitat VM via MagicDNS so it survives IP shuffles and tunnel turnover: ws://agent-habitat.tail48d4cc.ts.net:4319/v1/hermes/ws?token=... Add a 3.0.0 release announcement entry covering the same change so users upgrading from 2.x see the new connection flow on first launch. --- apps/mobile/app.json | 11 ++++++----- .../mobile/src/features/app-updates/releases.ts | 17 +++++++++++++++++ apps/mobile/src/services/storage.ts | 17 ++++++++++++++++- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 3a423cc..4b6c4b8 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": "260530" }, "android": { "adaptiveIcon": { @@ -126,6 +127,6 @@ "projectId": "972e845f-da81-44db-a908-24be4ca80288" } }, - "owner": "p697" + "owner": "binarybros" } -} +} \ No newline at end of file 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/services/storage.ts b/apps/mobile/src/services/storage.ts index 6099e6b..062d1df 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://agent-habitat.tail48d4cc.ts.net:4319/v1/hermes/ws?token=binarybros-hermes-clawket-2026', + hermes: { bridgeUrl: 'http://agent-habitat.tail48d4cc.ts.net:4319', displayName: 'Binary Bros Hermes' }, + createdAt: _now, + updatedAt: _now, + }], + }; + return _defaultHermes; }, async setIdentity(identity: DeviceIdentity): Promise { From 4f47d8fdd6347c23e412d150c23e97d2f75e1bb6 Mon Sep 17 00:00:00 2001 From: Ripnrip Date: Sat, 30 May 2026 00:17:40 -0400 Subject: [PATCH 05/14] feat(mobile): agentzero backend kind + AgentZeroClient with real streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Agent Zero as a third product backend alongside OpenClaw and Hermes. Scope of this pass: Backend metadata: - types.GatewayBackendKind gains 'agentzero'; new AgentZeroGatewayConfig (bridgeUrl + optional projectName) on GatewayConfig/SavedGatewayConfig. - gateway-backends: AGENTZERO_CAPABILITIES (chat-only — console screens stay off until per-screen adapters land); registry entry; widened isGatewayBackendKind, resolveGatewayBackendKind, selectByBackend (with fall-through to openclaw at call sites that haven't opted in yet), resolveGlobalMainSessionKey ('main'), getGatewayModeLabel, buildGatewayDefaultName, toLegacyGatewayMode. 6 new unit tests. - Call sites widened to accept the new union: useAppBootstrap.buildAgentPreview, ConfigScreenLayout.getBackendLabels, qrPayload (excludes 'agentzero' from the transport-kind filter), PairingPendingCard.connectionMode. Transport (services/agentzero-client.ts, 14 + 6 tests): - sendMessage(): one-shot POST /api/api_message, returns { contextId, response }. Matches what the a0 CLI and the habitat health-check cron use. - streamMessage(req, onLogItem): real progressive streaming. 1. POST /api/api_message_async to kick off the agent and capture context_id immediately. 2. Poll /api/api_log_get every ~400ms, emit each new log item via onLogItem. 3. Resolve when progress_active=false AND a 'response'-type item arrived. Hard maxStreamMs watchdog returns AgentZeroError(408). AbortSignal threads through both phases. - AgentZeroError class so call sites can `instanceof` and inspect status. - createAgentZeroClient(config, token) factory bound to AgentZeroGatewayConfig. NOTE: api_message_async is a tiny key-authed clone of AZ's web-auth-only message_async, deployed on the AZ instance as api/api_message_async.py (persisted under habitat:~/clawket-credentials/agentzero-patches/, auto- restored on AZ launch — see habitat:~/clawket-credentials/README.md). gateway.ts is NOT touched in this pass; agent-zero-native screens import createAgentZeroClient directly. The transport API stays stable when the poll loop is later swapped for socket.io-client. --- apps/mobile/src/bootstrap/useAppBootstrap.ts | 2 +- .../components/chat/PairingPendingCard.tsx | 2 +- .../ConfigScreen/ConfigScreenLayout.tsx | 1 + .../src/screens/ConfigScreen/qrPayload.ts | 4 +- .../src/services/agentzero-client.test.ts | 374 +++++++++++++++++ apps/mobile/src/services/agentzero-client.ts | 382 ++++++++++++++++++ .../src/services/gateway-backends.test.ts | 48 ++- apps/mobile/src/services/gateway-backends.ts | 74 +++- apps/mobile/src/types/index.ts | 26 +- 9 files changed, 898 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/src/services/agentzero-client.test.ts create mode 100644 apps/mobile/src/services/agentzero-client.ts 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/screens/ConfigScreen/ConfigScreenLayout.tsx b/apps/mobile/src/screens/ConfigScreen/ConfigScreenLayout.tsx index 8e2ef27..518cc2d 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 { 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/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/types/index.ts b/apps/mobile/src/types/index.ts index c33be64..1724b62 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. http://agent-habitat.tail48d4cc.ts.net:5000 */ + 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; } From da4fec9207a241888b897bdd65226f4b2e0d0b3a Mon Sep 17 00:00:00 2001 From: Ripnrip Date: Sat, 30 May 2026 00:18:22 -0400 Subject: [PATCH 06/14] chore: sync package-lock.json Lockfile catches up to changes that landed in the working tree before this session (apps/bridge-cli 0.6.3 -> 0.6.4, @expo/ui removal). The 3.0.0 version bumps in apps/mobile and root will surface on the next `npm install`; the build verified in this session does not depend on them. --- package-lock.json | 994 ++++++++++++++++++---------------------------- 1 file changed, 390 insertions(+), 604 deletions(-) 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", From 08e7c671cd429cda1a6ecc126aed6e27d62ca439 Mon Sep 17 00:00:00 2001 From: Ripnrip Date: Sat, 30 May 2026 00:20:17 -0400 Subject: [PATCH 07/14] chore(mobile): bump iOS buildNumber to 260530001 for next TestFlight --- apps/mobile/app.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 4b6c4b8..1f384f9 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -33,7 +33,7 @@ "UIPrefersShowingLanguageSettings": true }, "appleTeamId": "ZWT3N8XKE7", - "buildNumber": "260530" + "buildNumber": "260530001" }, "android": { "adaptiveIcon": { @@ -129,4 +129,4 @@ }, "owner": "binarybros" } -} \ No newline at end of file +} From 5f6a1236b97321833f449443e384d019109ace63 Mon Sep 17 00:00:00 2001 From: Ripnrip Date: Sat, 30 May 2026 01:01:10 -0400 Subject: [PATCH 08/14] feat(mobile): wire Agent Zero into AddConnection + new chat tab with streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on top of the agentzero backendKind + AgentZeroClient scaffolding so users can actually create + chat with an Agent Zero connection from the app. AddConnection editor: - 'Agent Zero' becomes a third option in the backend pill alongside OpenClaw and Hermes. - New deriveAgentZeroConfig() normalizes whatever the user pastes (http://, https://, even ws://… by accident) down to a clean base URL with no path. - editorRequiresDirectAuth widened to require token entry for agentzero — AZ's X-API-KEY rides on the existing `token` field on SavedGatewayConfig. - saveEditor threads `agentzero` into both create + edit paths. - normalizeEditorTransportKind pins agentzero to 'custom' transport (no relay/local/tailscale variants — just HTTP to the AZ web server). - toRuntimeConfig passes agentzero through to the runtime config. - 2 new tests on useGatewayConfigForm: a happy-path save and the missing-token rejection. Chat: - New AgentZeroChatTab — self-contained, doesn't share the OpenClaw/Hermes WS gateway. Uses createAgentZeroClient + streamMessage to render each log item as a bubble keyed by the framework's `no` cursor. Persists the AZ contextId in component state so subsequent turns continue the same chat. Bubble color/border varies by AZ log type (user/agent/tool/error). - Inline minimal header (the shared ChatHeader is shaped around OpenClaw connection-state events that don't apply to AZ's HTTP polling model). - 'Reset Chat' button discards the contextId so the next message opens a fresh AZ context. - Streaming UX: 'Agent Zero is working…' indicator while progress_active is true; bubble list auto-scrolls to bottom on each new item. - Component unmount aborts any in-flight stream via AbortController. - Dispatched from ChatTab via a sibling `if (backendKind === 'agentzero')` branch next to the existing youmind branch. i18n: - 1 new key in `config` namespace ("Agent Zero") + 9 new keys in `chat` namespace across all 6 locales (en, zh-Hans, ja, ko, de, es). Product name stays untranslated; surrounding copy is localized. Tests: 1363/1363 pass (was 1361 before this branch). Typecheck clean. Out of scope (still parked for later): - Project switcher / a0 CLI's /project foo flow - Slash commands (/new, /code) as first-class UI - Persistent contextId across app restarts (currently in-memory only) - AZ-specific drawer with past contexts - AZ chat history pre-load on open --- apps/mobile/src/hooks/gatewayScanFlow.ts | 1 + .../src/hooks/useGatewayConfigForm.test.ts | 93 ++++ apps/mobile/src/hooks/useGatewayConfigForm.ts | 49 +- apps/mobile/src/i18n/locales/de/chat.json | 14 +- apps/mobile/src/i18n/locales/de/config.json | 1 + apps/mobile/src/i18n/locales/en/chat.json | 12 +- apps/mobile/src/i18n/locales/en/config.json | 1 + apps/mobile/src/i18n/locales/es/chat.json | 14 +- apps/mobile/src/i18n/locales/es/config.json | 1 + apps/mobile/src/i18n/locales/ja/chat.json | 14 +- apps/mobile/src/i18n/locales/ja/config.json | 1 + apps/mobile/src/i18n/locales/ko/chat.json | 14 +- apps/mobile/src/i18n/locales/ko/config.json | 1 + .../mobile/src/i18n/locales/zh-Hans/chat.json | 12 +- .../src/i18n/locales/zh-Hans/config.json | 1 + .../screens/ChatScreen/AgentZeroChatTab.tsx | 435 ++++++++++++++++++ .../mobile/src/screens/ChatScreen/ChatTab.tsx | 4 + .../ConfigScreen/ConfigScreenLayout.tsx | 7 +- 18 files changed, 656 insertions(+), 19 deletions(-) create mode 100644 apps/mobile/src/screens/ChatScreen/AgentZeroChatTab.tsx 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..a22a6b5 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('http://agent-habitat.tail48d4cc.ts.net:5000/'); + 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: 'http://agent-habitat.tail48d4cc.ts.net:5000/', + token: '6Gv7AhbIbZ8CEjUb', + password: undefined, + hermes: undefined, + agentzero: { + // Trailing slash stripped, no path appended. + bridgeUrl: 'http://agent-habitat.tail48d4cc.ts.net:5000', + }, + }); + 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('http://agent-habitat.tail48d4cc.ts.net:5000'); + // 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..615e0fb 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. http://agent-habitat.tail48d4cc.ts.net:5000). + * 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..bff2273 --- /dev/null +++ b/apps/mobile/src/screens/ChatScreen/AgentZeroChatTab.tsx @@ -0,0 +1,435 @@ +// 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 { 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(); + 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.')} + + + + ); + } + + 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 665153f..f07768d 100644 --- a/apps/mobile/src/screens/ChatScreen/ChatTab.tsx +++ b/apps/mobile/src/screens/ChatScreen/ChatTab.tsx @@ -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; @@ -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/ConfigScreen/ConfigScreenLayout.tsx b/apps/mobile/src/screens/ConfigScreen/ConfigScreenLayout.tsx index 518cc2d..f21a7c7 100644 --- a/apps/mobile/src/screens/ConfigScreen/ConfigScreenLayout.tsx +++ b/apps/mobile/src/screens/ConfigScreen/ConfigScreenLayout.tsx @@ -112,6 +112,11 @@ function getUrlPlaceholder(input: { if (input.backendKind === 'youmind') { return 'https://youmind.com'; } + if (input.backendKind === 'agentzero') { + // AZ wants the base HTTP URL of the web server (port 5000 by default). + // The /api/api_message{,_async} + /api/api_log_get paths are added by the client. + return 'http://agent-habitat.tail48d4cc.ts.net:5000'; + } if (input.backendKind === 'hermes') { switch (input.transportKind) { case 'local': @@ -1346,7 +1351,7 @@ function EditorModal({ controller, theme, styles }: EditorModalProps): React.JSX const manualBackendOptions = useMemo( () => ((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'); From d417d21257e9c70973b8f7e5e52a2f168beb6ba3 Mon Sep 17 00:00:00 2001 From: Ripnrip Date: Sat, 30 May 2026 01:01:22 -0400 Subject: [PATCH 09/14] chore(mobile): bump iOS buildNumber to 260530002 for AZ-UI TestFlight --- apps/mobile/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 1f384f9..f5dc9ea 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -33,7 +33,7 @@ "UIPrefersShowingLanguageSettings": true }, "appleTeamId": "ZWT3N8XKE7", - "buildNumber": "260530001" + "buildNumber": "260530002" }, "android": { "adaptiveIcon": { From 2005cddf0e0d850ad4319d29cac6a31b459349bc Mon Sep 17 00:00:00 2001 From: Ripnrip Date: Sat, 30 May 2026 01:27:02 -0400 Subject: [PATCH 10/14] fix(mobile): AgentZeroChatTab composer was hidden behind the bottom tab bar useTabBarHeight() must be added to the composer's bottom padding (and to the KeyboardAvoidingView offset) so the input row clears the iOS native tabs overlay. Without it, the composer rendered below the tab bar and was unreachable on first launch. Mirrors how YouMindChatTab handles the same. --- .../src/screens/ChatScreen/AgentZeroChatTab.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/screens/ChatScreen/AgentZeroChatTab.tsx b/apps/mobile/src/screens/ChatScreen/AgentZeroChatTab.tsx index bff2273..ec991a1 100644 --- a/apps/mobile/src/screens/ChatScreen/AgentZeroChatTab.tsx +++ b/apps/mobile/src/screens/ChatScreen/AgentZeroChatTab.tsx @@ -22,6 +22,7 @@ import { 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'; @@ -68,6 +69,10 @@ export function AgentZeroChatTab(): React.JSX.Element { 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 ?? ''; @@ -190,11 +195,17 @@ export function AgentZeroChatTab(): React.JSX.Element { ); } + // 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 ( @@ -236,7 +247,7 @@ export function AgentZeroChatTab(): React.JSX.Element { ) : null} - + Date: Sat, 30 May 2026 01:27:02 -0400 Subject: [PATCH 11/14] chore(mobile): bump iOS buildNumber to 260530003 for tab-bar-fix TestFlight --- apps/mobile/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index f5dc9ea..aa414e1 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -33,7 +33,7 @@ "UIPrefersShowingLanguageSettings": true }, "appleTeamId": "ZWT3N8XKE7", - "buildNumber": "260530002" + "buildNumber": "260530003" }, "android": { "adaptiveIcon": { From b9a63fef30262c83d773fc32e6e1410ed8ca8729 Mon Sep 17 00:00:00 2001 From: Agent-Habitat Date: Tue, 2 Jun 2026 08:00:59 +0000 Subject: [PATCH 12/14] chore(mobile): bump iOS buildNumber to 260602001 for TestFlight --- apps/mobile/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index aa414e1..1e4dd29 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -33,7 +33,7 @@ "UIPrefersShowingLanguageSettings": true }, "appleTeamId": "ZWT3N8XKE7", - "buildNumber": "260530003" + "buildNumber": "260602001" }, "android": { "adaptiveIcon": { From 45e6b6c6e0256c3c5e264ef714f5d67a06dae542 Mon Sep 17 00:00:00 2001 From: Agent-Habitat Date: Fri, 26 Jun 2026 19:08:06 +0000 Subject: [PATCH 13/14] fix(mobile): connect to current Hermes/AZ tailnet endpoints + foreground WS re-probe Migrate baked-in connection endpoints to the current tailnet: - Hermes default bridge now targets studio (where the bridge actually runs and is reachable on the tailnet) instead of agent-habitat:4319, which is loopback-only there and not WS-reachable via tailscale serve. - Agent Zero base URL migrated from the retired tail48d4cc domain :5000 to capybara-loggerhead :8443 (served to AZ on :8080); updated the config-form placeholder, JSDoc, types, and tests to match. Also fix a foreground reconnect gap in useChatController: iOS freezes the WS in the background and it often returns half-open (state still "ready" but dead). The re-probe was gated on hasRunningChat, so an idle open chat kept a dead socket. Always re-probe on foreground when stale. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mobile/src/hooks/useGatewayConfigForm.test.ts | 8 ++++---- apps/mobile/src/hooks/useGatewayConfigForm.ts | 2 +- .../src/screens/ChatScreen/hooks/useChatController.ts | 10 +++++++--- .../src/screens/ConfigScreen/ConfigScreenLayout.tsx | 2 +- apps/mobile/src/services/storage.ts | 4 ++-- apps/mobile/src/types/index.ts | 2 +- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/hooks/useGatewayConfigForm.test.ts b/apps/mobile/src/hooks/useGatewayConfigForm.test.ts index a22a6b5..e506f43 100644 --- a/apps/mobile/src/hooks/useGatewayConfigForm.test.ts +++ b/apps/mobile/src/hooks/useGatewayConfigForm.test.ts @@ -646,7 +646,7 @@ describe('useGatewayConfigForm', () => { await act(async () => { result.current.setEditorBackendKind('agentzero'); - result.current.setEditorUrl('http://agent-habitat.tail48d4cc.ts.net:5000/'); + result.current.setEditorUrl('https://agent-habitat.capybara-loggerhead.ts.net:8443/'); result.current.setEditorAuthMethod('token'); result.current.setEditorToken('6Gv7AhbIbZ8CEjUb'); result.current.setEditorName('Habitat AZ'); @@ -662,13 +662,13 @@ describe('useGatewayConfigForm', () => { backendKind: 'agentzero', transportKind: 'custom', mode: 'agentzero', - url: 'http://agent-habitat.tail48d4cc.ts.net:5000/', + url: 'https://agent-habitat.capybara-loggerhead.ts.net:8443/', token: '6Gv7AhbIbZ8CEjUb', password: undefined, hermes: undefined, agentzero: { // Trailing slash stripped, no path appended. - bridgeUrl: 'http://agent-habitat.tail48d4cc.ts.net:5000', + bridgeUrl: 'https://agent-habitat.capybara-loggerhead.ts.net:8443', }, }); expect(onSaved).toHaveBeenCalled(); @@ -700,7 +700,7 @@ describe('useGatewayConfigForm', () => { await act(async () => { result.current.setEditorBackendKind('agentzero'); - result.current.setEditorUrl('http://agent-habitat.tail48d4cc.ts.net:5000'); + result.current.setEditorUrl('https://agent-habitat.capybara-loggerhead.ts.net:8443'); // no token }); diff --git a/apps/mobile/src/hooks/useGatewayConfigForm.ts b/apps/mobile/src/hooks/useGatewayConfigForm.ts index 615e0fb..93b418f 100644 --- a/apps/mobile/src/hooks/useGatewayConfigForm.ts +++ b/apps/mobile/src/hooks/useGatewayConfigForm.ts @@ -93,7 +93,7 @@ function deriveHermesBridgeConfig( } /** - * Agent Zero wants a base HTTP URL (e.g. http://agent-habitat.tail48d4cc.ts.net:5000). + * 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). */ 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 f21a7c7..3de14d2 100644 --- a/apps/mobile/src/screens/ConfigScreen/ConfigScreenLayout.tsx +++ b/apps/mobile/src/screens/ConfigScreen/ConfigScreenLayout.tsx @@ -115,7 +115,7 @@ function getUrlPlaceholder(input: { if (input.backendKind === 'agentzero') { // AZ wants the base HTTP URL of the web server (port 5000 by default). // The /api/api_message{,_async} + /api/api_log_get paths are added by the client. - return 'http://agent-habitat.tail48d4cc.ts.net:5000'; + return 'https://agent-habitat.capybara-loggerhead.ts.net:8443'; } if (input.backendKind === 'hermes') { switch (input.transportKind) { diff --git a/apps/mobile/src/services/storage.ts b/apps/mobile/src/services/storage.ts index 062d1df..5710877 100644 --- a/apps/mobile/src/services/storage.ts +++ b/apps/mobile/src/services/storage.ts @@ -706,8 +706,8 @@ export const StorageService = { backendKind: 'hermes', transportKind: 'local', mode: 'hermes', - url: 'ws://agent-habitat.tail48d4cc.ts.net:4319/v1/hermes/ws?token=binarybros-hermes-clawket-2026', - hermes: { bridgeUrl: 'http://agent-habitat.tail48d4cc.ts.net:4319', displayName: 'Binary Bros 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, }], diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts index 1724b62..5190657 100644 --- a/apps/mobile/src/types/index.ts +++ b/apps/mobile/src/types/index.ts @@ -43,7 +43,7 @@ export interface HermesGatewayConfig { * 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. http://agent-habitat.tail48d4cc.ts.net:5000 */ + /** 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. */ From 6f6f98953e94aa7bdf2c866491c7cf3a5101e9dc Mon Sep 17 00:00:00 2001 From: Agent-Habitat Date: Fri, 26 Jun 2026 19:08:12 +0000 Subject: [PATCH 14/14] chore(mobile): bump iOS buildNumber to 260626001 for TestFlight Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/mobile/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 1e4dd29..d69d4ba 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -33,7 +33,7 @@ "UIPrefersShowingLanguageSettings": true }, "appleTeamId": "ZWT3N8XKE7", - "buildNumber": "260602001" + "buildNumber": "260626001" }, "android": { "adaptiveIcon": {