Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 16 additions & 5 deletions apps/mobile/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<!DOCTYPE html><html><head><meta charset="utf-8"></head><body></body></html>';
}

const OFFICE_HTML_BG_REGEX = /(html,\s*body\s*\{[^}]*background-color:\s*)([^;]+)(;)/;

Expand All @@ -247,18 +253,23 @@ 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';
}
}

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}`;
}
Expand Down
9 changes: 5 additions & 4 deletions apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.",
Expand All @@ -32,7 +32,8 @@
"CFBundleDevelopmentRegion": "en",
"UIPrefersShowingLanguageSettings": true
},
"appleTeamId": "C8TM82D73W"
"appleTeamId": "ZWT3N8XKE7",
"buildNumber": "260626001"
},
"android": {
"adaptiveIcon": {
Expand Down Expand Up @@ -126,6 +127,6 @@
"projectId": "972e845f-da81-44db-a908-24be4ca80288"
}
},
"owner": "p697"
"owner": "binarybros"
}
}
35 changes: 35 additions & 0 deletions apps/mobile/ios/SceneDelegate.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "clawket",
"version": "2.1.0",
"version": "3.0.0",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/bootstrap/useAppBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type Props = {

function buildAgentPreview(
agentId: string,
backendKind: 'openclaw' | 'hermes' | 'youmind',
backendKind: 'openclaw' | 'hermes' | 'youmind' | 'agentzero',
identity?: {
agentName?: string;
agentEmoji?: string;
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/components/chat/PairingPendingCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/features/app-updates/releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/hooks/gatewayScanFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
93 changes: 93 additions & 0 deletions apps/mobile/src/hooks/useGatewayConfigForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,4 +620,97 @@ describe('useGatewayConfigForm', () => {
expect(gateway.disconnect).toHaveBeenCalledTimes(1);
expect(onReset).toHaveBeenCalledTimes(1);
});

it('saves an Agent Zero connection with backendKind=agentzero, normalized bridgeUrl, and token', async () => {
const onSaved = jest.fn();
const gateway = {
disconnect: jest.fn(),
configure: jest.fn(),
connect: jest.fn(),
getDeviceIdentity: jest.fn().mockResolvedValue({ deviceId: 'device-az' }),
} as any;

const { result } = renderHook(() =>
useGatewayConfigForm({
gateway,
initialConfig: null,
debugMode: false,
onSaved,
onReset: jest.fn(),
}),
);

await act(async () => {
result.current.openCreateEditor('manual');
});

await act(async () => {
result.current.setEditorBackendKind('agentzero');
result.current.setEditorUrl('https://agent-habitat.capybara-loggerhead.ts.net:8443/');
result.current.setEditorAuthMethod('token');
result.current.setEditorToken('6Gv7AhbIbZ8CEjUb');
result.current.setEditorName('Habitat AZ');
});

await act(async () => {
await result.current.saveEditor();
});

const lastCall = (StorageService.setGatewayConfigsState as jest.Mock).mock.calls.at(-1)?.[0];
expect(lastCall.configs[0]).toMatchObject({
name: 'Habitat AZ',
backendKind: 'agentzero',
transportKind: 'custom',
mode: 'agentzero',
url: 'https://agent-habitat.capybara-loggerhead.ts.net:8443/',
token: '6Gv7AhbIbZ8CEjUb',
password: undefined,
hermes: undefined,
agentzero: {
// Trailing slash stripped, no path appended.
bridgeUrl: 'https://agent-habitat.capybara-loggerhead.ts.net:8443',
},
});
expect(onSaved).toHaveBeenCalled();
});

it('rejects an Agent Zero connection without a token (X-API-KEY is required)', async () => {
const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
const onSaved = jest.fn();
const gateway = {
disconnect: jest.fn(),
configure: jest.fn(),
connect: jest.fn(),
getDeviceIdentity: jest.fn().mockResolvedValue({ deviceId: 'device-az' }),
} as any;

const { result } = renderHook(() =>
useGatewayConfigForm({
gateway,
initialConfig: null,
debugMode: false,
onSaved,
onReset: jest.fn(),
}),
);

await act(async () => {
result.current.openCreateEditor('manual');
});

await act(async () => {
result.current.setEditorBackendKind('agentzero');
result.current.setEditorUrl('https://agent-habitat.capybara-loggerhead.ts.net:8443');
// no token
});

await act(async () => {
await result.current.saveEditor();
});

expect(alertSpy).toHaveBeenCalledWith('Missing Auth', expect.any(String));
expect(StorageService.setGatewayConfigsState).not.toHaveBeenCalled();
expect(onSaved).not.toHaveBeenCalled();
alertSpy.mockRestore();
});
});
49 changes: 47 additions & 2 deletions apps/mobile/src/hooks/useGatewayConfigForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -88,6 +92,35 @@ function deriveHermesBridgeConfig(
}
}

/**
* Agent Zero wants a base HTTP URL (e.g. https://agent-habitat.capybara-loggerhead.ts.net:8443).
* We accept ws://… too as a courtesy — users frequently paste their Hermes-style URL
* by accident — and rewrite to http(s).
*/
function deriveAgentZeroConfig(
url: string,
existing?: SavedGatewayConfig['agentzero'],
): SavedGatewayConfig['agentzero'] | undefined {
const trimmed = url.trim();
if (!trimmed) {
return existing;
}
try {
const parsed = new URL(trimmed.replace(/^ws:/, 'http:').replace(/^wss:/, 'https:'));
parsed.hash = '';
parsed.search = '';
// AZ has no canonical chat path; strip anything the user accidentally pasted.
parsed.pathname = '/';
return {
bridgeUrl: parsed.toString().replace(/\/+$/, ''),
...(existing?.displayName ? { displayName: existing.displayName } : {}),
...(existing?.projectName ? { projectName: existing.projectName } : {}),
};
} catch {
return existing;
}
}

function detectAuthMethod(token?: string, password?: string): GatewayAuthMethod {
if ((token ?? '').trim()) return 'token';
if ((password ?? '').trim()) return 'password';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -506,6 +549,7 @@ export function useGatewayConfigForm({ gateway, initialConfig, debugMode, onSave
token,
password,
hermes,
agentzero,
relay,
updatedAt: now,
};
Expand Down Expand Up @@ -540,6 +584,7 @@ export function useGatewayConfigForm({ gateway, initialConfig, debugMode, onSave
token,
password,
hermes,
agentzero,
relay,
createdAt: now,
updatedAt: now,
Expand Down
Loading