diff --git a/.gitignore b/.gitignore
index 1b85a63..8298635 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,46 @@ dist/
.wrangler/
*.log
.DS_Store
+
+# upstream protocol clone (vendor source; never committed)
+.upstream/
+
+# local Claude Code permission overrides (not committed)
+.claude/settings.local.json
+
+# --- secrets & credentials (repo-wide; never commit) ---
+.env
+.env.*
+!.env.example
+.dev.vars
+.dev.vars.*
+!.dev.vars.example
+*.pem
+*.key
+*.p8
+*.p12
+*.pfx
+*.cer
+*.certSigningRequest
+*.mobileprovision
+*.keystore
+*.jks
+credentials.json
+secrets.json
+*.secret
+google-services.json
+GoogleService-Info.plist
+
+# --- terraform (state + tfvars hold secrets; never commit) ---
+*.tfvars
+*.tfvars.json
+!*.tfvars.example
+*.tfstate
+*.tfstate.*
+.terraform/
+crash.log
+crash.*.log
+override.tf
+override.tf.json
+*_override.tf
+*_override.tf.json
diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore
index f8c6c2e..26d8cd3 100644
--- a/apps/mobile/.gitignore
+++ b/apps/mobile/.gitignore
@@ -9,7 +9,8 @@ dist/
web-build/
expo-env.d.ts
-# Native
+# Native / EAS credentials (EAS-managed creds live on Expo servers, not here;
+# these patterns are belt-and-suspenders for local-credential or exported keys)
.kotlin/
*.orig.*
*.jks
@@ -17,6 +18,7 @@ expo-env.d.ts
*.p12
*.key
*.mobileprovision
+credentials.json
# Metro
.metro-health-check*
diff --git a/apps/mobile/app.json b/apps/mobile/app.json
index 765ecc8..051ef13 100644
--- a/apps/mobile/app.json
+++ b/apps/mobile/app.json
@@ -1,14 +1,21 @@
{
"expo": {
- "name": "mobile",
+ "name": "Constructor",
"slug": "mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mobile",
"userInterfaceStyle": "automatic",
+ "runtimeVersion": {
+ "policy": "appVersion"
+ },
"ios": {
- "icon": "./assets/expo.icon"
+ "icon": "./assets/expo.icon",
+ "bundleIdentifier": "dev.nejc.constructor",
+ "infoPlist": {
+ "ITSAppUsesNonExemptEncryption": false
+ }
},
"android": {
"adaptiveIcon": {
@@ -36,7 +43,8 @@
}
],
"expo-secure-store",
- "expo-web-browser"
+ "expo-web-browser",
+ "expo-sqlite"
],
"experiments": {
"typedRoutes": true,
@@ -48,6 +56,9 @@
"projectId": "00d6ac0f-2366-4d7e-843c-20cf79f4ea7d"
}
},
- "owner": "refrakts"
+ "owner": "refrakts",
+ "updates": {
+ "url": "https://u.expo.dev/00d6ac0f-2366-4d7e-843c-20cf79f4ea7d"
+ }
}
}
diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json
new file mode 100644
index 0000000..e1d9ce8
--- /dev/null
+++ b/apps/mobile/eas.json
@@ -0,0 +1,26 @@
+{
+ "cli": {
+ "version": ">= 5.0.0",
+ "appVersionSource": "remote"
+ },
+ "build": {
+ "development": {
+ "developmentClient": true,
+ "distribution": "internal",
+ "channel": "development",
+ "ios": { "simulator": false }
+ },
+ "preview": {
+ "distribution": "internal",
+ "channel": "preview"
+ },
+ "production": {
+ "distribution": "store",
+ "channel": "production",
+ "autoIncrement": true
+ }
+ },
+ "submit": {
+ "production": {}
+ }
+}
diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js
index 73cd076..ce15145 100644
--- a/apps/mobile/metro.config.js
+++ b/apps/mobile/metro.config.js
@@ -9,4 +9,10 @@ config.resolver.nodeModulesPaths = [
path.resolve(workspaceRoot, "node_modules"),
];
config.resolver.disableHierarchicalLookup = true;
+// markdown-it (via react-native-markdown-display) requires Node's "punycode";
+// alias it to the userland package so Metro can resolve it in RN/Hermes.
+config.resolver.extraNodeModules = {
+ ...config.resolver.extraNodeModules,
+ punycode: require.resolve("punycode/"),
+};
module.exports = config;
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index a41ac7b..7e6f93f 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -11,6 +11,7 @@
"lint": "expo lint"
},
"dependencies": {
+ "@constructor/protocol": "workspace:*",
"@expo/ui": "~55.0.16",
"@react-navigation/bottom-tabs": "^7.15.5",
"@react-navigation/elements": "^2.9.10",
@@ -25,15 +26,19 @@
"expo-font": "~55.0.7",
"expo-glass-effect": "~55.0.11",
"expo-image": "~55.0.10",
+ "expo-insights": "~55.0.17",
"expo-linking": "~55.0.15",
"expo-notifications": "~55.0.23",
"expo-router": "~55.0.14",
"expo-secure-store": "~55.0.14",
"expo-splash-screen": "~55.0.21",
+ "expo-sqlite": "~55.0.16",
"expo-status-bar": "~55.0.6",
"expo-symbols": "~55.0.8",
"expo-system-ui": "~55.0.18",
+ "expo-updates": "~55.0.22",
"expo-web-browser": "~55.0.16",
+ "punycode": "^2.3.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.6",
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index b04d0a8..8f316c7 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -1,16 +1,56 @@
-import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
-import React from 'react';
-import { useColorScheme } from 'react-native';
+import { Stack } from 'expo-router';
-import { AnimatedSplashOverlay } from '@/components/animated-icon';
-import AppTabs from '@/components/app-tabs';
+import { useAuth } from '@/data/auth';
+import { AppProviders } from '@/data/provider';
+import { useThemeColors } from '@/ui';
-export default function TabLayout() {
- const colorScheme = useColorScheme();
+/** Fallback route Expo Router resolves to when a guard redirects. */
+export const unstable_settings = { anchor: 'index' };
+
+/** Native iOS bottom-sheet presentation (Expo Router v55 / react-native-screens). */
+const SHEET = {
+ presentation: 'formSheet' as const,
+ sheetGrabberVisible: true,
+ sheetCornerRadius: 20,
+ sheetAllowedDetents: [0.6, 1] as number[],
+ sheetInitialDetentIndex: 0,
+};
+
+function StackNav() {
+ const c = useThemeColors();
+ const { signedIn } = useAuth();
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function RootLayout() {
return (
-
-
-
-
+
+
+
);
}
diff --git a/apps/mobile/src/app/explore.tsx b/apps/mobile/src/app/explore.tsx
deleted file mode 100644
index f08c5d3..0000000
--- a/apps/mobile/src/app/explore.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import { Image } from 'expo-image';
-import { SymbolView } from 'expo-symbols';
-import React from 'react';
-import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-
-import { ExternalLink } from '@/components/external-link';
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-import { Collapsible } from '@/components/ui/collapsible';
-import { WebBadge } from '@/components/web-badge';
-import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
-import { useTheme } from '@/hooks/use-theme';
-
-export default function TabTwoScreen() {
- const safeAreaInsets = useSafeAreaInsets();
- const insets = {
- ...safeAreaInsets,
- bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
- };
- const theme = useTheme();
-
- const contentPlatformStyle = Platform.select({
- android: {
- paddingTop: insets.top,
- paddingLeft: insets.left,
- paddingRight: insets.right,
- paddingBottom: insets.bottom,
- },
- web: {
- paddingTop: Spacing.six,
- paddingBottom: Spacing.four,
- },
- });
-
- return (
-
-
-
- Explore
-
- This starter app includes example{'\n'}code to help you get started.
-
-
-
- pressed && styles.pressed}>
-
- Expo documentation
-
-
-
-
-
-
-
-
-
- This app has two screens: src/app/index.tsx and{' '}
- src/app/explore.tsx
-
-
- The layout file in src/app/_layout.tsx sets up
- the tab navigator.
-
-
- Learn more
-
-
-
-
-
-
- You can open this project on Android, iOS, and the web. To open the web version,
- press w in the terminal running this
- project.
-
-
-
-
-
-
-
- For static images, you can use the @2x and{' '}
- @3x suffixes to provide files for different
- screen densities.
-
-
-
- Learn more
-
-
-
-
-
- This template has light and dark mode support. The{' '}
- useColorScheme() hook lets you inspect what the
- user's current color scheme is, and so you can adjust UI colors accordingly.
-
-
- Learn more
-
-
-
-
-
- This template includes an example of an animated component. The{' '}
- src/components/ui/collapsible.tsx component uses
- the powerful react-native-reanimated library to
- animate opening this hint.
-
-
-
- {Platform.OS === 'web' && }
-
-
- );
-}
-
-const styles = StyleSheet.create({
- scrollView: {
- flex: 1,
- },
- contentContainer: {
- flexDirection: 'row',
- justifyContent: 'center',
- },
- container: {
- maxWidth: MaxContentWidth,
- flexGrow: 1,
- },
- titleContainer: {
- gap: Spacing.three,
- alignItems: 'center',
- paddingHorizontal: Spacing.four,
- paddingVertical: Spacing.six,
- },
- centerText: {
- textAlign: 'center',
- },
- pressed: {
- opacity: 0.7,
- },
- linkButton: {
- flexDirection: 'row',
- paddingHorizontal: Spacing.four,
- paddingVertical: Spacing.two,
- borderRadius: Spacing.five,
- justifyContent: 'center',
- gap: Spacing.one,
- alignItems: 'center',
- },
- sectionsWrapper: {
- gap: Spacing.five,
- paddingHorizontal: Spacing.four,
- paddingTop: Spacing.three,
- },
- collapsibleContent: {
- alignItems: 'center',
- },
- imageTutorial: {
- width: '100%',
- aspectRatio: 296 / 171,
- borderRadius: Spacing.three,
- marginTop: Spacing.two,
- },
- imageReact: {
- width: 100,
- height: 100,
- alignSelf: 'center',
- },
-});
diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx
index 8ec3e6f..7631b4e 100644
--- a/apps/mobile/src/app/index.tsx
+++ b/apps/mobile/src/app/index.tsx
@@ -1,98 +1 @@
-import * as Device from 'expo-device';
-import { Platform, StyleSheet } from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
-
-import { AnimatedIcon } from '@/components/animated-icon';
-import { HintRow } from '@/components/hint-row';
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-import { WebBadge } from '@/components/web-badge';
-import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
-
-function getDevMenuHint() {
- if (Platform.OS === 'web') {
- return use browser devtools;
- }
- if (Device.isDevice) {
- return (
-
- shake device or press m in terminal
-
- );
- }
- const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
- return (
-
- press {shortcut}
-
- );
-}
-
-export default function HomeScreen() {
- return (
-
-
-
-
-
- Welcome to Expo
-
-
-
-
- get started
-
-
-
- src/app/index.tsx}
- />
-
- npm run reset-project}
- />
-
-
- {Platform.OS === 'web' && }
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- justifyContent: 'center',
- flexDirection: 'row',
- },
- safeArea: {
- flex: 1,
- paddingHorizontal: Spacing.four,
- alignItems: 'center',
- gap: Spacing.three,
- paddingBottom: BottomTabInset + Spacing.three,
- maxWidth: MaxContentWidth,
- },
- heroSection: {
- alignItems: 'center',
- justifyContent: 'center',
- flex: 1,
- paddingHorizontal: Spacing.four,
- gap: Spacing.four,
- },
- title: {
- textAlign: 'center',
- },
- code: {
- textTransform: 'uppercase',
- },
- stepContainer: {
- gap: Spacing.three,
- alignSelf: 'stretch',
- paddingHorizontal: Spacing.three,
- paddingVertical: Spacing.four,
- borderRadius: Spacing.four,
- },
-});
+export { SessionListScreen as default } from '@/features/sessions/list/screen';
diff --git a/apps/mobile/src/app/new.tsx b/apps/mobile/src/app/new.tsx
new file mode 100644
index 0000000..373848a
--- /dev/null
+++ b/apps/mobile/src/app/new.tsx
@@ -0,0 +1 @@
+export { CreateSessionScreen as default } from '@/features/sessions/create/screen';
diff --git a/apps/mobile/src/app/s/[id].tsx b/apps/mobile/src/app/s/[id].tsx
new file mode 100644
index 0000000..9a196af
--- /dev/null
+++ b/apps/mobile/src/app/s/[id].tsx
@@ -0,0 +1 @@
+export { SessionDetailScreen as default } from '@/features/sessions/detail/screen';
diff --git a/apps/mobile/src/app/settings.tsx b/apps/mobile/src/app/settings.tsx
new file mode 100644
index 0000000..9f4d93a
--- /dev/null
+++ b/apps/mobile/src/app/settings.tsx
@@ -0,0 +1 @@
+export { SettingsScreen as default } from '@/features/profiles/screen';
diff --git a/apps/mobile/src/app/sign-in.tsx b/apps/mobile/src/app/sign-in.tsx
new file mode 100644
index 0000000..82f6e86
--- /dev/null
+++ b/apps/mobile/src/app/sign-in.tsx
@@ -0,0 +1 @@
+export { SignInScreen as default } from '@/features/auth/screen';
diff --git a/apps/mobile/src/components/animated-icon.module.css b/apps/mobile/src/components/animated-icon.module.css
deleted file mode 100644
index f8156fe..0000000
--- a/apps/mobile/src/components/animated-icon.module.css
+++ /dev/null
@@ -1,6 +0,0 @@
-.expoLogoBackground {
- background-image: linear-gradient(180deg, #3c9ffe, #0274df);
- border-radius: 40px;
- width: 128px;
- height: 128px;
-}
diff --git a/apps/mobile/src/components/animated-icon.tsx b/apps/mobile/src/components/animated-icon.tsx
deleted file mode 100644
index 91a480f..0000000
--- a/apps/mobile/src/components/animated-icon.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-import { Image } from 'expo-image';
-import { useState } from 'react';
-import { Dimensions, StyleSheet, View } from 'react-native';
-import Animated, { Easing, Keyframe } from 'react-native-reanimated';
-import { scheduleOnRN } from 'react-native-worklets';
-
-const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90;
-const DURATION = 600;
-
-export function AnimatedSplashOverlay() {
- const [visible, setVisible] = useState(true);
-
- if (!visible) return null;
-
- const splashKeyframe = new Keyframe({
- 0: {
- transform: [{ scale: INITIAL_SCALE_FACTOR }],
- opacity: 1,
- },
- 20: {
- opacity: 1,
- },
- 70: {
- opacity: 0,
- easing: Easing.elastic(0.7),
- },
- 100: {
- opacity: 0,
- transform: [{ scale: 1 }],
- easing: Easing.elastic(0.7),
- },
- });
-
- return (
- {
- 'worklet';
- if (finished) {
- scheduleOnRN(setVisible, false);
- }
- })}
- style={styles.backgroundSolidColor}
- />
- );
-}
-
-const keyframe = new Keyframe({
- 0: {
- transform: [{ scale: INITIAL_SCALE_FACTOR }],
- },
- 100: {
- transform: [{ scale: 1 }],
- easing: Easing.elastic(0.7),
- },
-});
-
-const logoKeyframe = new Keyframe({
- 0: {
- transform: [{ scale: 1.3 }],
- opacity: 0,
- },
- 40: {
- transform: [{ scale: 1.3 }],
- opacity: 0,
- easing: Easing.elastic(0.7),
- },
- 100: {
- opacity: 1,
- transform: [{ scale: 1 }],
- easing: Easing.elastic(0.7),
- },
-});
-
-const glowKeyframe = new Keyframe({
- 0: {
- transform: [{ rotateZ: '0deg' }],
- },
- 100: {
- transform: [{ rotateZ: '7200deg' }],
- },
-});
-
-export function AnimatedIcon() {
- return (
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- imageContainer: {
- justifyContent: 'center',
- alignItems: 'center',
- },
- glow: {
- width: 201,
- height: 201,
- position: 'absolute',
- },
- iconContainer: {
- justifyContent: 'center',
- alignItems: 'center',
- width: 128,
- height: 128,
- zIndex: 100,
- },
- image: {
- position: 'absolute',
- width: 76,
- height: 71,
- },
- background: {
- borderRadius: 40,
- experimental_backgroundImage: `linear-gradient(180deg, #3C9FFE, #0274DF)`,
- width: 128,
- height: 128,
- position: 'absolute',
- },
- backgroundSolidColor: {
- ...StyleSheet.absoluteFillObject,
- backgroundColor: '#208AEF',
- zIndex: 1000,
- },
-});
diff --git a/apps/mobile/src/components/animated-icon.web.tsx b/apps/mobile/src/components/animated-icon.web.tsx
deleted file mode 100644
index dfbb1fd..0000000
--- a/apps/mobile/src/components/animated-icon.web.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import { Image } from 'expo-image';
-import { StyleSheet, View } from 'react-native';
-import Animated, { Keyframe, Easing } from 'react-native-reanimated';
-
-import classes from './animated-icon.module.css';
-const DURATION = 300;
-
-export function AnimatedSplashOverlay() {
- return null;
-}
-
-const keyframe = new Keyframe({
- 0: {
- transform: [{ scale: 0 }],
- },
- 60: {
- transform: [{ scale: 1.2 }],
- easing: Easing.elastic(1.2),
- },
- 100: {
- transform: [{ scale: 1 }],
- easing: Easing.elastic(1.2),
- },
-});
-
-const logoKeyframe = new Keyframe({
- 0: {
- opacity: 0,
- },
- 60: {
- transform: [{ scale: 1.2 }],
- opacity: 0,
- easing: Easing.elastic(1.2),
- },
- 100: {
- transform: [{ scale: 1 }],
- opacity: 1,
- easing: Easing.elastic(1.2),
- },
-});
-
-const glowKeyframe = new Keyframe({
- 0: {
- transform: [{ rotateZ: '-180deg' }, { scale: 0.8 }],
- opacity: 0,
- },
- [DURATION / 1000]: {
- transform: [{ rotateZ: '0deg' }, { scale: 1 }],
- opacity: 1,
- easing: Easing.elastic(0.7),
- },
- 100: {
- transform: [{ rotateZ: '7200deg' }],
- },
-});
-
-export function AnimatedIcon() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- alignItems: 'center',
- width: '100%',
- zIndex: 1000,
- position: 'absolute',
- top: 128 / 2 + 138,
- },
- imageContainer: {
- justifyContent: 'center',
- alignItems: 'center',
- },
- glow: {
- width: 201,
- height: 201,
- position: 'absolute',
- },
- iconContainer: {
- justifyContent: 'center',
- alignItems: 'center',
- width: 128,
- height: 128,
- },
- image: {
- position: 'absolute',
- width: 76,
- height: 71,
- },
- background: {
- width: 128,
- height: 128,
- position: 'absolute',
- },
-});
diff --git a/apps/mobile/src/components/app-tabs.tsx b/apps/mobile/src/components/app-tabs.tsx
deleted file mode 100644
index 0e1bc23..0000000
--- a/apps/mobile/src/components/app-tabs.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { NativeTabs } from 'expo-router/unstable-native-tabs';
-import React from 'react';
-import { useColorScheme } from 'react-native';
-
-import { Colors } from '@/constants/theme';
-
-export default function AppTabs() {
- const scheme = useColorScheme();
- const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
-
- return (
-
-
- Home
-
-
-
-
- Explore
-
-
-
- );
-}
diff --git a/apps/mobile/src/components/app-tabs.web.tsx b/apps/mobile/src/components/app-tabs.web.tsx
deleted file mode 100644
index 6542e46..0000000
--- a/apps/mobile/src/components/app-tabs.web.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import {
- Tabs,
- TabList,
- TabTrigger,
- TabSlot,
- TabTriggerSlotProps,
- TabListProps,
-} from 'expo-router/ui';
-import { SymbolView } from 'expo-symbols';
-import React from 'react';
-import { Pressable, useColorScheme, View, StyleSheet } from 'react-native';
-
-import { ExternalLink } from './external-link';
-import { ThemedText } from './themed-text';
-import { ThemedView } from './themed-view';
-
-import { Colors, MaxContentWidth, Spacing } from '@/constants/theme';
-
-export default function AppTabs() {
- return (
-
-
-
-
-
- Home
-
-
- Explore
-
-
-
-
- );
-}
-
-export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) {
- return (
- pressed && styles.pressed}>
-
-
- {children}
-
-
-
- );
-}
-
-export function CustomTabList(props: TabListProps) {
- const scheme = useColorScheme();
- const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
-
- return (
-
-
-
- Expo Starter
-
-
- {props.children}
-
-
-
- Docs
-
-
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- tabListContainer: {
- position: 'absolute',
- width: '100%',
- padding: Spacing.three,
- justifyContent: 'center',
- alignItems: 'center',
- flexDirection: 'row',
- },
- innerContainer: {
- paddingVertical: Spacing.two,
- paddingHorizontal: Spacing.five,
- borderRadius: Spacing.five,
- flexDirection: 'row',
- alignItems: 'center',
- flexGrow: 1,
- gap: Spacing.two,
- maxWidth: MaxContentWidth,
- },
- brandText: {
- marginRight: 'auto',
- },
- pressed: {
- opacity: 0.7,
- },
- tabButtonView: {
- paddingVertical: Spacing.one,
- paddingHorizontal: Spacing.three,
- borderRadius: Spacing.three,
- },
- externalPressable: {
- flexDirection: 'row',
- justifyContent: 'center',
- alignItems: 'center',
- gap: Spacing.one,
- marginLeft: Spacing.three,
- },
-});
diff --git a/apps/mobile/src/components/external-link.tsx b/apps/mobile/src/components/external-link.tsx
deleted file mode 100644
index 883e515..0000000
--- a/apps/mobile/src/components/external-link.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Href, Link } from 'expo-router';
-import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
-import { type ComponentProps } from 'react';
-
-type Props = Omit, 'href'> & { href: Href & string };
-
-export function ExternalLink({ href, ...rest }: Props) {
- return (
- {
- if (process.env.EXPO_OS !== 'web') {
- // Prevent the default behavior of linking to the default browser on native.
- event.preventDefault();
- // Open the link in an in-app browser.
- await openBrowserAsync(href, {
- presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
- });
- }
- }}
- />
- );
-}
diff --git a/apps/mobile/src/components/hint-row.tsx b/apps/mobile/src/components/hint-row.tsx
deleted file mode 100644
index a66062b..0000000
--- a/apps/mobile/src/components/hint-row.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import React, { type ReactNode } from 'react';
-import { View, StyleSheet } from 'react-native';
-
-import { ThemedText } from './themed-text';
-import { ThemedView } from './themed-view';
-
-import { Spacing } from '@/constants/theme';
-
-type HintRowProps = {
- title?: string;
- hint?: ReactNode;
-};
-
-export function HintRow({ title = 'Try editing', hint = 'app/index.tsx' }: HintRowProps) {
- return (
-
- {title}
-
- {hint}
-
-
- );
-}
-
-const styles = StyleSheet.create({
- stepRow: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- },
- codeSnippet: {
- borderRadius: Spacing.two,
- paddingVertical: Spacing.half,
- paddingHorizontal: Spacing.two,
- },
-});
diff --git a/apps/mobile/src/components/themed-text.tsx b/apps/mobile/src/components/themed-text.tsx
deleted file mode 100644
index 799c8b1..0000000
--- a/apps/mobile/src/components/themed-text.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { Platform, StyleSheet, Text, type TextProps } from 'react-native';
-
-import { Fonts, ThemeColor } from '@/constants/theme';
-import { useTheme } from '@/hooks/use-theme';
-
-export type ThemedTextProps = TextProps & {
- type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code';
- themeColor?: ThemeColor;
-};
-
-export function ThemedText({ style, type = 'default', themeColor, ...rest }: ThemedTextProps) {
- const theme = useTheme();
-
- return (
-
- );
-}
-
-const styles = StyleSheet.create({
- small: {
- fontSize: 14,
- lineHeight: 20,
- fontWeight: 500,
- },
- smallBold: {
- fontSize: 14,
- lineHeight: 20,
- fontWeight: 700,
- },
- default: {
- fontSize: 16,
- lineHeight: 24,
- fontWeight: 500,
- },
- title: {
- fontSize: 48,
- fontWeight: 600,
- lineHeight: 52,
- },
- subtitle: {
- fontSize: 32,
- lineHeight: 44,
- fontWeight: 600,
- },
- link: {
- lineHeight: 30,
- fontSize: 14,
- },
- linkPrimary: {
- lineHeight: 30,
- fontSize: 14,
- color: '#3c87f7',
- },
- code: {
- fontFamily: Fonts.mono,
- fontWeight: Platform.select({ android: 700 }) ?? 500,
- fontSize: 12,
- },
-});
diff --git a/apps/mobile/src/components/themed-view.tsx b/apps/mobile/src/components/themed-view.tsx
deleted file mode 100644
index c710df9..0000000
--- a/apps/mobile/src/components/themed-view.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { View, type ViewProps } from 'react-native';
-
-import { ThemeColor } from '@/constants/theme';
-import { useTheme } from '@/hooks/use-theme';
-
-export type ThemedViewProps = ViewProps & {
- lightColor?: string;
- darkColor?: string;
- type?: ThemeColor;
-};
-
-export function ThemedView({ style, lightColor, darkColor, type, ...otherProps }: ThemedViewProps) {
- const theme = useTheme();
-
- return ;
-}
diff --git a/apps/mobile/src/components/ui/collapsible.tsx b/apps/mobile/src/components/ui/collapsible.tsx
deleted file mode 100644
index d0d745b..0000000
--- a/apps/mobile/src/components/ui/collapsible.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { SymbolView } from 'expo-symbols';
-import { PropsWithChildren, useState } from 'react';
-import { Pressable, StyleSheet } from 'react-native';
-import Animated, { FadeIn } from 'react-native-reanimated';
-
-import { ThemedText } from '@/components/themed-text';
-import { ThemedView } from '@/components/themed-view';
-import { Spacing } from '@/constants/theme';
-import { useTheme } from '@/hooks/use-theme';
-
-export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
- const [isOpen, setIsOpen] = useState(false);
- const theme = useTheme();
-
- return (
-
- [styles.heading, pressed && styles.pressedHeading]}
- onPress={() => setIsOpen((value) => !value)}>
-
-
-
-
- {title}
-
- {isOpen && (
-
-
- {children}
-
-
- )}
-
- );
-}
-
-const styles = StyleSheet.create({
- heading: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: Spacing.two,
- },
- pressedHeading: {
- opacity: 0.7,
- },
- button: {
- width: Spacing.four,
- height: Spacing.four,
- borderRadius: 12,
- justifyContent: 'center',
- alignItems: 'center',
- },
- content: {
- marginTop: Spacing.three,
- borderRadius: Spacing.three,
- marginLeft: Spacing.four,
- padding: Spacing.four,
- },
-});
diff --git a/apps/mobile/src/components/web-badge.tsx b/apps/mobile/src/components/web-badge.tsx
deleted file mode 100644
index 23933d2..0000000
--- a/apps/mobile/src/components/web-badge.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { version } from 'expo/package.json';
-import { Image } from 'expo-image';
-import React from 'react';
-import { useColorScheme, StyleSheet } from 'react-native';
-
-import { ThemedText } from './themed-text';
-import { ThemedView } from './themed-view';
-
-import { Spacing } from '@/constants/theme';
-
-export function WebBadge() {
- const scheme = useColorScheme();
-
- return (
-
-
- v{version}
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- padding: Spacing.five,
- alignItems: 'center',
- gap: Spacing.two,
- },
- versionText: {
- textAlign: 'center',
- },
- badgeImage: {
- width: 123,
- aspectRatio: 123 / 24,
- },
-});
diff --git a/apps/mobile/src/data/auth.tsx b/apps/mobile/src/data/auth.tsx
new file mode 100644
index 0000000..6a67c4a
--- /dev/null
+++ b/apps/mobile/src/data/auth.tsx
@@ -0,0 +1,38 @@
+/**
+ * Mock auth state for the navigation gate. Real GitHub OAuth (via the gateway)
+ * is M1 — this only flips a boolean so `Stack.Protected` in the router can gate
+ * the app behind sign-in. State is in-memory (resets on reload) by design: it
+ * is a UI gate, not a credential store.
+ */
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+} from 'react';
+
+type Auth = {
+ signedIn: boolean;
+ signIn: () => void;
+ signOut: () => void;
+};
+
+const AuthContext = createContext(null);
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [signedIn, setSignedIn] = useState(false);
+ const signIn = useCallback(() => setSignedIn(true), []);
+ const signOut = useCallback(() => setSignedIn(false), []);
+ const value = useMemo(
+ () => ({ signedIn, signIn, signOut }),
+ [signedIn, signIn, signOut],
+ );
+ return {children};
+}
+
+export function useAuth(): Auth {
+ const ctx = useContext(AuthContext);
+ if (!ctx) throw new Error('useAuth must be used within ');
+ return ctx;
+}
diff --git a/apps/mobile/src/data/gateway.ts b/apps/mobile/src/data/gateway.ts
new file mode 100644
index 0000000..bab4df4
--- /dev/null
+++ b/apps/mobile/src/data/gateway.ts
@@ -0,0 +1,45 @@
+/**
+ * The single data seam (PLAN-02 / spec approach A). Screens depend ONLY on this
+ * interface (via the queries hooks), never on a concrete impl. MockSessionGateway
+ * implements it now; the real HTTP/WS gateway implements the same interface later
+ * with zero screen changes. Typed entirely against the vendored protocol.
+ */
+import type {
+ CreateSessionRequest,
+ SandboxEvent,
+ Session,
+ SessionArtifact,
+ SessionState,
+} from '@constructor/protocol';
+
+export type { CreateSessionRequest, SandboxEvent, Session, SessionArtifact, SessionState };
+
+export interface SubscribeSnapshot {
+ state: SessionState;
+ artifacts: SessionArtifact[];
+ replay: {
+ events: SandboxEvent[];
+ hasMore: boolean;
+ cursor: { timestamp: number; id: string } | null;
+ };
+}
+
+export interface StreamListeners {
+ snapshot(s: SubscribeSnapshot): void;
+ event(e: SandboxEvent): void;
+ closed?(reason?: string): void;
+}
+
+export interface StreamHandle {
+ unsubscribe(): void;
+}
+
+export interface SessionGateway {
+ listSessions(): Promise;
+ getSession(id: string): Promise;
+ createSession(req: CreateSessionRequest): Promise<{ sessionId: string }>;
+ /** Mirrors the real DO: a `snapshot` (state + replay) then a live event stream. */
+ subscribe(id: string, on: StreamListeners): StreamHandle;
+ sendFollowUp(id: string, content: string): Promise;
+ stop(id: string): Promise;
+}
diff --git a/apps/mobile/src/data/mock/emitter.ts b/apps/mobile/src/data/mock/emitter.ts
new file mode 100644
index 0000000..f7840ca
--- /dev/null
+++ b/apps/mobile/src/data/mock/emitter.ts
@@ -0,0 +1,41 @@
+/** Replays a scripted SandboxEvent[] through stream listeners with realistic
+ * inter-event timing. Returns a cancel fn (clears pending timers). */
+import type { SandboxEvent } from '@constructor/protocol';
+
+import type { StreamListeners, SubscribeSnapshot } from '../gateway';
+
+export function startScriptedStream(
+ snapshot: SubscribeSnapshot,
+ script: SandboxEvent[],
+ on: StreamListeners,
+): () => void {
+ let cancelled = false;
+ const timers: ReturnType[] = [];
+
+ // snapshot first (mirrors `subscribed` frame), then stream after a beat.
+ const snapTimer = setTimeout(() => {
+ if (cancelled) return;
+ on.snapshot(snapshot);
+ let delay = 350;
+ script.forEach((evt) => {
+ delay += evt.type === 'token' ? 90 : 260;
+ timers.push(
+ setTimeout(() => {
+ if (cancelled) return;
+ on.event(evt);
+ }, delay),
+ );
+ });
+ timers.push(
+ setTimeout(() => {
+ if (!cancelled) on.closed?.('completed');
+ }, delay + 200),
+ );
+ }, 200);
+ timers.push(snapTimer);
+
+ return () => {
+ cancelled = true;
+ timers.forEach(clearTimeout);
+ };
+}
diff --git a/apps/mobile/src/data/mock/fixtures.ts b/apps/mobile/src/data/mock/fixtures.ts
new file mode 100644
index 0000000..74cf583
--- /dev/null
+++ b/apps/mobile/src/data/mock/fixtures.ts
@@ -0,0 +1,103 @@
+/**
+ * Mock fixtures shaped to the real vendored protocol. The scripted scenario
+ * mirrors the real DO contract: a `snapshot` (state + replay) then a live
+ * `SandboxEvent` stream, so screens behave as they will post-M0 (spec §5).
+ * Enum literals are `satisfies`-checked against @constructor/protocol.
+ */
+import type { SandboxEvent, Session, SessionState } from '@constructor/protocol';
+
+const now = Date.now();
+const SBX = 'sbx_mock_1';
+
+export const mockSessions: Session[] = [
+ {
+ id: 's_active',
+ title: 'Add dark-mode toggle to settings',
+ repoOwner: 'refrakts',
+ repoName: 'constructor-mobile',
+ baseBranch: 'main',
+ branchName: 'open-inspect/s_active',
+ baseSha: 'abc1234',
+ currentSha: 'def5678',
+ opencodeSessionId: null,
+ status: 'active',
+ parentSessionId: null,
+ spawnSource: 'user',
+ spawnDepth: 0,
+ createdAt: now - 1000 * 60 * 12,
+ updatedAt: now - 1000 * 30,
+ },
+ {
+ id: 's_done',
+ title: 'Fix flaky auth test',
+ repoOwner: 'refrakts',
+ repoName: 'constructor-mobile',
+ baseBranch: 'main',
+ branchName: 'open-inspect/s_done',
+ baseSha: 'aaa0001',
+ currentSha: 'bbb0002',
+ opencodeSessionId: null,
+ status: 'completed',
+ parentSessionId: null,
+ spawnSource: 'user',
+ spawnDepth: 0,
+ createdAt: now - 1000 * 60 * 60 * 5,
+ updatedAt: now - 1000 * 60 * 60 * 4,
+ },
+] satisfies Session[];
+
+export function mockSessionState(id: string): SessionState {
+ const src = mockSessions.find((x) => x.id === id) ?? mockSessions[0];
+ return {
+ id: src.id,
+ title: src.title,
+ repoOwner: src.repoOwner,
+ repoName: src.repoName,
+ baseBranch: src.baseBranch,
+ branchName: src.branchName,
+ status: src.status,
+ sandboxStatus: 'running',
+ messageCount: 1,
+ createdAt: src.createdAt,
+ model: 'anthropic/claude-sonnet-4-6',
+ isProcessing: src.status === 'active',
+ totalCost: 0,
+ } satisfies SessionState;
+}
+
+/** Scenario A — happy path: prompt → tool → cumulative token stream → done. */
+export function scenarioHappy(): SandboxEvent[] {
+ const t = () => Date.now() / 1000;
+ const mid = 'm_1';
+ const partials = [
+ 'Looking at the settings screen…',
+ 'Looking at the settings screen… adding a `Toggle`',
+ 'Looking at the settings screen… adding a `Toggle` bound to the theme store.',
+ 'Looking at the settings screen… adding a `Toggle` bound to the theme store.\n\n```tsx\n\n```\n\nDone.',
+ ];
+ const evts: SandboxEvent[] = [
+ { type: 'user_message', content: 'Add a dark-mode toggle to the settings screen', messageId: mid, timestamp: t() },
+ { type: 'step_start', messageId: mid, sandboxId: SBX, timestamp: t() },
+ { type: 'tool_call', tool: 'read_file', args: { path: 'src/app/settings.tsx' }, callId: 'c1', messageId: mid, sandboxId: SBX, timestamp: t() },
+ { type: 'tool_result', callId: 'c1', result: 'export default function Settings() { … }', messageId: mid, sandboxId: SBX, timestamp: t() },
+ ];
+ for (const p of partials) {
+ evts.push({ type: 'token', content: p, messageId: mid, sandboxId: SBX, timestamp: t() });
+ }
+ evts.push({ type: 'step_finish', cost: 0.0123, tokens: 1840, messageId: mid, sandboxId: SBX, timestamp: t() });
+ evts.push({ type: 'execution_complete', messageId: mid, success: true, sandboxId: SBX, timestamp: t() });
+ return evts;
+}
+
+/** Scenario B — error path. */
+export function scenarioError(): SandboxEvent[] {
+ const t = () => Date.now() / 1000;
+ const mid = 'm_err';
+ return [
+ { type: 'user_message', content: 'Refactor the auth module', messageId: mid, timestamp: t() },
+ { type: 'step_start', messageId: mid, sandboxId: SBX, timestamp: t() },
+ { type: 'tool_call', tool: 'run_tests', args: {}, callId: 'c9', messageId: mid, sandboxId: SBX, timestamp: t() },
+ { type: 'error', error: 'Test suite failed: 3 failing in auth.test.ts', messageId: mid, sandboxId: SBX, timestamp: t() },
+ { type: 'execution_complete', messageId: mid, success: false, error: 'aborted', sandboxId: SBX, timestamp: t() },
+ ];
+}
diff --git a/apps/mobile/src/data/mock/mock-gateway.ts b/apps/mobile/src/data/mock/mock-gateway.ts
new file mode 100644
index 0000000..982d60a
--- /dev/null
+++ b/apps/mobile/src/data/mock/mock-gateway.ts
@@ -0,0 +1,70 @@
+/** In-memory SessionGateway. Same interface the real HTTP/WS gateway will
+ * implement later — screens never know which one is wired. */
+import type { CreateSessionRequest, Session, SessionState } from '@constructor/protocol';
+
+import type { SessionGateway, StreamHandle, StreamListeners, SubscribeSnapshot } from '../gateway';
+import { startScriptedStream } from './emitter';
+import { mockSessionState, mockSessions, scenarioError, scenarioHappy } from './fixtures';
+
+export class MockSessionGateway implements SessionGateway {
+ private sessions: Session[] = [...mockSessions];
+
+ async listSessions(): Promise {
+ await tick();
+ return [...this.sessions];
+ }
+
+ async getSession(id: string): Promise {
+ await tick();
+ return mockSessionState(id);
+ }
+
+ async createSession(req: CreateSessionRequest): Promise<{ sessionId: string }> {
+ await tick();
+ const id = `s_${Math.random().toString(36).slice(2, 8)}`;
+ const ts = Date.now();
+ this.sessions = [
+ {
+ id,
+ title: req.title ?? `${req.repoOwner}/${req.repoName}`,
+ repoOwner: req.repoOwner,
+ repoName: req.repoName,
+ baseBranch: req.branch ?? 'main',
+ branchName: `open-inspect/${id}`,
+ baseSha: null,
+ currentSha: null,
+ opencodeSessionId: null,
+ status: 'active',
+ parentSessionId: null,
+ spawnSource: 'user',
+ spawnDepth: 0,
+ createdAt: ts,
+ updatedAt: ts,
+ },
+ ...this.sessions,
+ ];
+ return { sessionId: id };
+ }
+
+ subscribe(id: string, on: StreamListeners): StreamHandle {
+ const state = mockSessionState(id);
+ const snapshot: SubscribeSnapshot = {
+ state,
+ artifacts: [],
+ replay: { events: [], hasMore: false, cursor: null },
+ };
+ const script = state.status === 'failed' ? scenarioError() : scenarioHappy();
+ const cancel = startScriptedStream(snapshot, script, on);
+ return { unsubscribe: cancel };
+ }
+
+ async sendFollowUp(_id: string, _content: string): Promise {
+ await tick();
+ }
+
+ async stop(_id: string): Promise {
+ await tick();
+ }
+}
+
+const tick = () => new Promise((r) => setTimeout(r, 220));
diff --git a/apps/mobile/src/data/provider.tsx b/apps/mobile/src/data/provider.tsx
new file mode 100644
index 0000000..fcf9060
--- /dev/null
+++ b/apps/mobile/src/data/provider.tsx
@@ -0,0 +1,34 @@
+/** Wires the gateway seam + TanStack Query. Swap `defaultGateway` for the real
+ * HTTP/WS impl later — nothing else changes. */
+import React, { createContext, useContext, useMemo } from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+import type { SessionGateway } from './gateway';
+import { MockSessionGateway } from './mock/mock-gateway';
+import { AuthProvider } from './auth';
+
+const GatewayContext = createContext(null);
+
+export function useGateway(): SessionGateway {
+ const g = useContext(GatewayContext);
+ if (!g) throw new Error('useGateway must be used within ');
+ return g;
+}
+
+export function AppProviders({
+ children,
+ gateway,
+}: {
+ children: React.ReactNode;
+ gateway?: SessionGateway;
+}) {
+ const client = useMemo(() => new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 5_000 } } }), []);
+ const gw = useMemo(() => gateway ?? new MockSessionGateway(), [gateway]);
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/mobile/src/data/queries.ts b/apps/mobile/src/data/queries.ts
new file mode 100644
index 0000000..480ee45
--- /dev/null
+++ b/apps/mobile/src/data/queries.ts
@@ -0,0 +1,71 @@
+/** The only data entry points screens use. Lists go through TanStack Query;
+ * the live stream uses the ported pure transforms over the gateway. */
+import { useEffect, useRef, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import type { CreateSessionRequest, SandboxEvent, SessionState } from '@constructor/protocol';
+import { costDelta, foldEvent, type PendingRef } from '@/features/sessions/stream/transforms';
+
+import { useGateway } from './provider';
+
+export { useGateway } from './provider';
+
+export function useSessions() {
+ const gw = useGateway();
+ return useQuery({ queryKey: ['sessions'], queryFn: () => gw.listSessions() });
+}
+
+export function useSession(id: string) {
+ const gw = useGateway();
+ return useQuery({ queryKey: ['session', id], queryFn: () => gw.getSession(id), enabled: !!id });
+}
+
+export function useCreateSession() {
+ const gw = useGateway();
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (req: CreateSessionRequest) => gw.createSession(req),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['sessions'] }),
+ });
+}
+
+export type StreamStatus = 'connecting' | 'live' | 'closed';
+
+export interface SessionStream {
+ status: StreamStatus;
+ state: SessionState | null;
+ events: SandboxEvent[];
+ cost: number;
+}
+
+export function useSessionStream(id: string): SessionStream {
+ const gw = useGateway();
+ const [status, setStatus] = useState('connecting');
+ const [state, setState] = useState(null);
+ const [events, setEvents] = useState([]);
+ const [cost, setCost] = useState(0);
+ const pending = useRef(null) as PendingRef;
+
+ useEffect(() => {
+ if (!id) return;
+ pending.current = null;
+ setStatus('connecting');
+ setEvents([]);
+ const handle = gw.subscribe(id, {
+ snapshot: (snap) => {
+ setState(snap.state);
+ setEvents(snap.replay.events);
+ setStatus('live');
+ },
+ event: (e) => {
+ setEvents((prev) => foldEvent(prev, e, pending));
+ const d = costDelta(e);
+ if (d) setCost((c) => c + d);
+ },
+ closed: () => setStatus('closed'),
+ });
+ return () => handle.unsubscribe();
+ }, [gw, id]);
+
+ return { status, state, events, cost };
+}
diff --git a/apps/mobile/src/features/auth/brand-backdrop.tsx b/apps/mobile/src/features/auth/brand-backdrop.tsx
new file mode 100644
index 0000000..aab794d
--- /dev/null
+++ b/apps/mobile/src/features/auth/brand-backdrop.tsx
@@ -0,0 +1,76 @@
+/** Phase-1 slice owner: auth. A soft brand wash built without expo-linear-gradient
+ * (not in the manifest, no new deps allowed): absolutely-positioned, heavily
+ * blurred-by-softness circular Views at low alpha. Tuned so the accent stays
+ * visible against both light (#ffffff) and dark (#000000) backgrounds. */
+import React from 'react';
+import { StyleSheet, View, useColorScheme } from 'react-native';
+
+// The primary action color from `@/ui` Button (#208AEF) — kept in sync visually.
+const ACCENT = '#208AEF';
+
+function withAlpha(hex: string, alpha: number) {
+ const a = Math.round(Math.min(Math.max(alpha, 0), 1) * 255)
+ .toString(16)
+ .padStart(2, '0');
+ return `${hex}${a}`;
+}
+
+export function BrandBackdrop() {
+ const dark = useColorScheme() === 'dark';
+ // Dark needs slightly stronger blobs to read against pure black; light stays soft.
+ const topAlpha = dark ? 0.22 : 0.14;
+ const midAlpha = dark ? 0.16 : 0.1;
+ const lowAlpha = dark ? 0.12 : 0.07;
+
+ return (
+
+ {/* Large top-right wash */}
+
+ {/* Mid-left glow */}
+
+ {/* Soft bottom anchor behind the action */}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ clip: { overflow: 'hidden' },
+ blob: { position: 'absolute' },
+});
diff --git a/apps/mobile/src/features/auth/github-mark.tsx b/apps/mobile/src/features/auth/github-mark.tsx
new file mode 100644
index 0000000..a36d89b
--- /dev/null
+++ b/apps/mobile/src/features/auth/github-mark.tsx
@@ -0,0 +1,94 @@
+/** Phase-1 slice owner: auth. GitHub glyph composed from pure RN Views — no SVG,
+ * no new deps (react-native-svg / expo-linear-gradient are not in the manifest).
+ * A stylized octocat silhouette: rounded head, two ear notches, a body, and a
+ * short tentacle. Reads as an intentional minimal mark, not a broken icon. */
+import React from 'react';
+import { StyleSheet, View } from 'react-native';
+
+export function GitHubMark({ size = 30, color = '#ffffff' }: { size?: number; color?: string }) {
+ // All sub-shapes are expressed as fractions of `size` so the mark scales cleanly.
+ const u = (n: number) => Math.round(size * n);
+ const tint = { backgroundColor: color };
+
+ return (
+
+ {/* Ears */}
+
+
+ {/* Head */}
+
+ {/* Body / chin merges into the head, squared at the bottom for the silhouette */}
+
+ {/* Tentacle */}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ shape: { position: 'absolute' },
+});
diff --git a/apps/mobile/src/features/auth/screen.tsx b/apps/mobile/src/features/auth/screen.tsx
new file mode 100644
index 0000000..bd34af0
--- /dev/null
+++ b/apps/mobile/src/features/auth/screen.tsx
@@ -0,0 +1,187 @@
+/** Phase-1 slice owner: auth. Visual shell only — real OAuth is M1 (gated on
+ * deployment + a mobile GitHub OAuth App). The primary action is a MOCK that
+ * routes to '/'; no expo-auth-session, no real GitHub flow here. The in-progress
+ * state below is cosmetic so the screen feels production-real on Expo Go.
+ *
+ * Visual richness is built from `@/ui` primitives + RN core only: no
+ * expo-linear-gradient / react-native-svg (not in the manifest, no new deps). */
+import React from 'react';
+import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
+import { Stack } from 'expo-router';
+
+import { useAuth } from '@/data/auth';
+import { Button, Screen, useThemeColors } from '@/ui';
+import { Fonts, Spacing } from '@/constants/theme';
+
+import { BrandBackdrop } from './brand-backdrop';
+import { GitHubMark } from './github-mark';
+
+const ACCENT = '#208AEF';
+
+export function SignInScreen() {
+ const { signIn } = useAuth();
+ const c = useThemeColors();
+ const [signingIn, setSigningIn] = React.useState(false);
+
+ const timer = React.useRef | null>(null);
+ React.useEffect(() => () => {
+ if (timer.current) clearTimeout(timer.current);
+ }, []);
+
+ // Mock-only: brief cosmetic "connecting" beat, then route home. The real
+ // GitHub OAuth handshake replaces this body wholesale in M1.
+ const onContinue = React.useCallback(() => {
+ if (signingIn) return;
+ setSigningIn(true);
+ // Flip mock auth; the router's Stack.Protected gate reveals the app and
+ // resolves to the `index` anchor.
+ timer.current = setTimeout(signIn, 550);
+ }, [signIn, signingIn]);
+
+ return (
+
+ {/* Per-screen override only — does not touch the frozen src/app layout;
+ keeps the modal presentation, drops the stock "Sign in" header so the
+ brand lockup reads as the hero. */}
+
+
+
+ {/* --- Brand lockup -------------------------------------------------- */}
+
+
+ C
+
+ Constructor
+
+ Control your background coding agents
+
+
+
+ {/* --- Spacer ------------------------------------------------------- */}
+
+
+ {/* --- Auth affordance ---------------------------------------------- */}
+
+
+
+
+
+
+ GitHub
+
+ Sign in to manage your agent sessions
+
+
+ {signingIn ? : null}
+
+
+
+
+
+ Mock sign-in · real GitHub OAuth lands in M1
+
+
+
+ {/* --- Legal footnote ----------------------------------------------- */}
+
+ By continuing you agree to the Terms of Service and acknowledge the Privacy Policy.
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ body: {
+ flex: 1,
+ paddingHorizontal: Spacing.three,
+ paddingTop: Spacing.six,
+ paddingBottom: Spacing.four,
+ },
+ // Brand lockup
+ lockup: { alignItems: 'center', gap: Spacing.three },
+ appMark: {
+ width: 76,
+ height: 76,
+ borderRadius: 20,
+ backgroundColor: ACCENT,
+ borderWidth: StyleSheet.hairlineWidth,
+ alignItems: 'center',
+ justifyContent: 'center',
+ shadowColor: ACCENT,
+ shadowOpacity: 0.45,
+ shadowRadius: 18,
+ shadowOffset: { width: 0, height: 8 },
+ elevation: 8,
+ },
+ appMarkGlyph: {
+ color: '#ffffff',
+ fontSize: 40,
+ fontWeight: '800',
+ fontFamily: Fonts.rounded,
+ marginTop: -2,
+ },
+ wordmark: {
+ fontSize: 34,
+ fontWeight: '800',
+ fontFamily: Fonts.rounded,
+ letterSpacing: 0.2,
+ marginTop: Spacing.one,
+ },
+ tagline: {
+ fontSize: 16,
+ fontFamily: Fonts.sans,
+ textAlign: 'center',
+ lineHeight: 22,
+ maxWidth: 280,
+ },
+ flexGap: { flex: 1, minHeight: Spacing.five },
+ // Auth block — the @/ui Button supplies its own marginTop (Spacing.four),
+ // so no extra gap here keeps the provider row → button rhythm tight.
+ authBlock: {},
+ providerRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: Spacing.three,
+ paddingHorizontal: Spacing.three,
+ paddingVertical: Spacing.three,
+ borderRadius: 14,
+ borderWidth: StyleSheet.hairlineWidth,
+ marginHorizontal: Spacing.three,
+ },
+ providerMark: {
+ width: 40,
+ height: 40,
+ borderRadius: 10,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ providerCopy: { flex: 1 },
+ providerTitle: { fontSize: 16, fontWeight: '600', fontFamily: Fonts.sans },
+ providerSub: { fontSize: 13, marginTop: 2, fontFamily: Fonts.sans },
+ statusCaption: {
+ fontSize: 12,
+ textAlign: 'center',
+ fontFamily: Fonts.sans,
+ marginTop: Spacing.two,
+ },
+ // Legal
+ legal: {
+ fontSize: 11,
+ lineHeight: 16,
+ textAlign: 'center',
+ fontFamily: Fonts.sans,
+ marginTop: Spacing.four,
+ paddingHorizontal: Spacing.four,
+ opacity: 0.85,
+ },
+});
diff --git a/apps/mobile/src/features/profiles/ProfileForm.tsx b/apps/mobile/src/features/profiles/ProfileForm.tsx
new file mode 100644
index 0000000..90c6934
--- /dev/null
+++ b/apps/mobile/src/features/profiles/ProfileForm.tsx
@@ -0,0 +1,99 @@
+/**
+ * Reusable connection-profile form (add + edit).
+ *
+ * Collects ONLY `name` + `gatewayUrl` (PLAN-02: `wsUrl` is discovered from the
+ * gateway, never typed). Inline, on-submit validation with native-iOS-styled
+ * error rows. Uses only `@/ui` primitives + core RN.
+ */
+import React, { useMemo, useState } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+import { Spacing } from '@/constants/theme';
+import { Button, Section, useThemeColors } from '@/ui';
+
+import {
+ type DraftErrors,
+ type ProfileDraft,
+ hasErrors,
+ validateDraft,
+} from './profile-store';
+import { ValidatedField } from './ValidatedField';
+
+export function ProfileForm({
+ mode,
+ initial,
+ submitLabel,
+ onSubmit,
+ onCancel,
+}: {
+ mode: 'add' | 'edit';
+ initial?: ProfileDraft;
+ submitLabel: string;
+ onSubmit: (draft: ProfileDraft) => void;
+ onCancel?: () => void;
+}) {
+ const c = useThemeColors();
+ const [name, setName] = useState(initial?.name ?? '');
+ const [gatewayUrl, setGatewayUrl] = useState(initial?.gatewayUrl ?? '');
+ const [touched, setTouched] = useState(false);
+
+ const draft: ProfileDraft = { name, gatewayUrl };
+ const errors: DraftErrors = useMemo(() => validateDraft(draft), [name, gatewayUrl]);
+ const showErrors = touched;
+
+ const handleSubmit = () => {
+ setTouched(true);
+ if (hasErrors(errors)) return;
+ onSubmit({ name: name.trim(), gatewayUrl: gatewayUrl.trim() });
+ };
+
+ return (
+
+
+
+
+ The websocket URL is discovered automatically from the gateway after you
+ connect — you don't need to enter it.
+
+
+
+ {onCancel ? (
+
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ helpText: {
+ fontSize: 13,
+ lineHeight: 18,
+ paddingHorizontal: Spacing.four,
+ paddingTop: Spacing.three,
+ },
+});
diff --git a/apps/mobile/src/features/profiles/ValidatedField.tsx b/apps/mobile/src/features/profiles/ValidatedField.tsx
new file mode 100644
index 0000000..c864138
--- /dev/null
+++ b/apps/mobile/src/features/profiles/ValidatedField.tsx
@@ -0,0 +1,64 @@
+/**
+ * `@/ui` `TextField` + an inline validation message and a focus/error accent.
+ * The shared `TextField` primitive (frozen `src/ui`) has no error slot, so the
+ * slice adds one here without touching the wrapper.
+ */
+import React, { useState } from 'react';
+import { StyleSheet, Text, View, type TextInputProps } from 'react-native';
+
+import { Spacing } from '@/constants/theme';
+import { TextField, useThemeColors } from '@/ui';
+
+const ACCENT = '#208AEF';
+const DANGER = '#E5484D';
+
+export function ValidatedField({
+ label,
+ error,
+ last,
+ onFocus,
+ onBlur,
+ style,
+ ...rest
+}: TextInputProps & { label: string; error?: string; last?: boolean }) {
+ const c = useThemeColors();
+ const [focused, setFocused] = useState(false);
+
+ const borderColor = error ? DANGER : focused ? ACCENT : 'transparent';
+
+ return (
+
+ {
+ setFocused(true);
+ onFocus?.(e);
+ }}
+ onBlur={(e) => {
+ setFocused(false);
+ onBlur?.(e);
+ }}
+ style={[styles.input, { borderColor }, style]}
+ {...rest}
+ />
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ wrap: { paddingBottom: Spacing.three },
+ divided: { borderBottomWidth: StyleSheet.hairlineWidth },
+ input: { borderWidth: 1 },
+ error: {
+ fontSize: 13,
+ fontWeight: '500',
+ marginTop: Spacing.two,
+ marginLeft: Spacing.four,
+ paddingHorizontal: Spacing.three,
+ },
+});
diff --git a/apps/mobile/src/features/profiles/profile-store.tsx b/apps/mobile/src/features/profiles/profile-store.tsx
new file mode 100644
index 0000000..1a67b9b
--- /dev/null
+++ b/apps/mobile/src/features/profiles/profile-store.tsx
@@ -0,0 +1,241 @@
+/**
+ * Connection-profile store (profiles slice), persisted via expo-sqlite/kv-store.
+ *
+ * PLAN-02 model: a profile is `{ id; name; gatewayUrl; wsUrl? }`. The user
+ * enters ONLY `name` + `gatewayUrl`; `wsUrl` is discovered later from the
+ * gateway's `GET /config` (NOT asked for here). Exactly one profile is active.
+ *
+ * Persistence: state is hydrated synchronously from `expo-sqlite/kv-store`
+ * (`Storage.getItemSync`) so there is no first-paint flash, and written back on
+ * every change. The public API (add/update/remove/setActive + selectors) is
+ * unchanged, so screens are unaffected by the storage backend.
+ */
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useReducer,
+} from 'react';
+import { Storage } from 'expo-sqlite/kv-store';
+
+const KV_KEY = 'constructor.profiles.v1';
+
+export type Profile = {
+ id: string;
+ name: string;
+ gatewayUrl: string;
+ /** Discovered later from the gateway (GET /config) — never user-entered. */
+ wsUrl?: string;
+};
+
+/** Fields the user actually edits in the UI. */
+export type ProfileDraft = { name: string; gatewayUrl: string };
+
+type State = {
+ profiles: Profile[];
+ activeProfileId: string | null;
+};
+
+type Action =
+ | { type: 'add'; profile: Profile }
+ | { type: 'update'; id: string; draft: ProfileDraft }
+ | { type: 'remove'; id: string }
+ | { type: 'setActive'; id: string };
+
+let _seq = 0;
+function makeId(): string {
+ _seq += 1;
+ return `p_${Date.now().toString(36)}_${_seq.toString(36)}`;
+}
+
+const SEED: State = (() => {
+ const seed: Profile = {
+ id: makeId(),
+ name: 'Local mock',
+ gatewayUrl: 'mock://local',
+ };
+ return { profiles: [seed], activeProfileId: seed.id };
+})();
+
+/** Synchronous hydrate; falls back to SEED on missing/corrupt/unavailable. */
+function loadInitialState(): State {
+ try {
+ const raw = Storage.getItemSync(KV_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw) as Partial;
+ if (parsed && Array.isArray(parsed.profiles) && parsed.profiles.length > 0) {
+ const profiles = parsed.profiles as Profile[];
+ const activeProfileId =
+ parsed.activeProfileId && profiles.some((p) => p.id === parsed.activeProfileId)
+ ? parsed.activeProfileId
+ : profiles[0].id;
+ return { profiles, activeProfileId };
+ }
+ }
+ } catch {
+ // missing / corrupt / storage unavailable → seed
+ }
+ return SEED;
+}
+
+function reducer(state: State, action: Action): State {
+ switch (action.type) {
+ case 'add': {
+ const profiles = [...state.profiles, action.profile];
+ const activeProfileId = state.activeProfileId ?? action.profile.id;
+ return { profiles, activeProfileId };
+ }
+ case 'update': {
+ const profiles = state.profiles.map((p) =>
+ p.id === action.id
+ ? {
+ ...p,
+ name: action.draft.name.trim(),
+ gatewayUrl: action.draft.gatewayUrl.trim(),
+ wsUrl:
+ p.gatewayUrl.trim() === action.draft.gatewayUrl.trim()
+ ? p.wsUrl
+ : undefined,
+ }
+ : p,
+ );
+ return { ...state, profiles };
+ }
+ case 'remove': {
+ const profiles = state.profiles.filter((p) => p.id !== action.id);
+ let activeProfileId = state.activeProfileId;
+ if (activeProfileId === action.id) {
+ activeProfileId = profiles[0]?.id ?? null;
+ }
+ return { profiles, activeProfileId };
+ }
+ case 'setActive': {
+ if (!state.profiles.some((p) => p.id === action.id)) return state;
+ return { ...state, activeProfileId: action.id };
+ }
+ default:
+ return state;
+ }
+}
+
+type ProfileStore = {
+ profiles: Profile[];
+ activeProfileId: string | null;
+ activeProfile: Profile | null;
+ addProfile: (draft: ProfileDraft) => Profile;
+ updateProfile: (id: string, draft: ProfileDraft) => void;
+ removeProfile: (id: string) => void;
+ setActiveProfile: (id: string) => void;
+};
+
+const ProfileStoreContext = createContext(null);
+
+export function ProfileStoreProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [state, dispatch] = useReducer(reducer, undefined, loadInitialState);
+
+ // Persist on every change. Synchronous + best-effort: a storage failure must
+ // never crash the UI (the in-memory state remains the source of truth).
+ useEffect(() => {
+ try {
+ Storage.setItemSync(KV_KEY, JSON.stringify(state));
+ } catch {
+ // ignore — non-fatal
+ }
+ }, [state]);
+
+ const addProfile = useCallback((draft: ProfileDraft): Profile => {
+ const profile: Profile = {
+ id: makeId(),
+ name: draft.name.trim(),
+ gatewayUrl: draft.gatewayUrl.trim(),
+ };
+ dispatch({ type: 'add', profile });
+ return profile;
+ }, []);
+
+ const updateProfile = useCallback((id: string, draft: ProfileDraft) => {
+ dispatch({ type: 'update', id, draft });
+ }, []);
+
+ const removeProfile = useCallback((id: string) => {
+ dispatch({ type: 'remove', id });
+ }, []);
+
+ const setActiveProfile = useCallback((id: string) => {
+ dispatch({ type: 'setActive', id });
+ }, []);
+
+ const value = useMemo(() => {
+ const activeProfile =
+ state.profiles.find((p) => p.id === state.activeProfileId) ?? null;
+ return {
+ profiles: state.profiles,
+ activeProfileId: state.activeProfileId,
+ activeProfile,
+ addProfile,
+ updateProfile,
+ removeProfile,
+ setActiveProfile,
+ };
+ }, [state, addProfile, updateProfile, removeProfile, setActiveProfile]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useProfileStore(): ProfileStore {
+ const ctx = useContext(ProfileStoreContext);
+ if (!ctx) {
+ throw new Error('useProfileStore must be used within ');
+ }
+ return ctx;
+}
+
+// --- validation -------------------------------------------------------------
+
+export type DraftErrors = { name?: string; gatewayUrl?: string };
+
+export function validateDraft(draft: ProfileDraft): DraftErrors {
+ const errors: DraftErrors = {};
+
+ const name = draft.name.trim();
+ if (!name) {
+ errors.name = 'Name is required.';
+ } else if (name.length > 60) {
+ errors.name = 'Keep the name under 60 characters.';
+ }
+
+ const raw = draft.gatewayUrl.trim();
+ if (!raw) {
+ errors.gatewayUrl = 'Gateway URL is required.';
+ } else {
+ let parsed: URL | null = null;
+ try {
+ parsed = new URL(raw);
+ } catch {
+ parsed = null;
+ }
+ if (!parsed) {
+ errors.gatewayUrl = 'Enter a valid URL, e.g. https://gateway.example.dev';
+ } else if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ errors.gatewayUrl = 'URL must start with http:// or https://';
+ } else if (!parsed.hostname) {
+ errors.gatewayUrl = 'URL must include a host.';
+ }
+ }
+
+ return errors;
+}
+
+export function hasErrors(errors: DraftErrors): boolean {
+ return Boolean(errors.name || errors.gatewayUrl);
+}
diff --git a/apps/mobile/src/features/profiles/screen.tsx b/apps/mobile/src/features/profiles/screen.tsx
new file mode 100644
index 0000000..ad57a87
--- /dev/null
+++ b/apps/mobile/src/features/profiles/screen.tsx
@@ -0,0 +1,320 @@
+/**
+ * Phase-1 slice: profiles / connection settings.
+ *
+ * PLAN-02 model: `Profile = { id; name; gatewayUrl; wsUrl? }`. The user enters
+ * ONLY `name` + `gatewayUrl`; `wsUrl` is discovered later from the gateway.
+ * Multiple profiles, exactly one active; switching active is a core action.
+ *
+ * Constraints: only `@/ui` primitives + `@/constants/theme` + core RN. State is
+ * in-memory (no AsyncStorage / secure-store — persistence deferred per slice
+ * brief). The provider is mounted here so the slice stays self-contained and
+ * `src/app` / `src/data` stay frozen. Light + dark via `useThemeColors`.
+ */
+import React, { useState } from 'react';
+import { Alert, StyleSheet, Text, View } from 'react-native';
+import { useRouter } from 'expo-router';
+
+import { Spacing } from '@/constants/theme';
+import {
+ AppBar,
+ Badge,
+ Button,
+ EmptyState,
+ ListItem,
+ Screen,
+ Section,
+ useThemeColors,
+} from '@/ui';
+
+import { ProfileForm } from './ProfileForm';
+import {
+ type Profile,
+ type ProfileDraft,
+ ProfileStoreProvider,
+ useProfileStore,
+} from './profile-store';
+
+const ACCENT = '#208AEF';
+
+type Pane =
+ | { kind: 'list' }
+ | { kind: 'add' }
+ | { kind: 'edit'; id: string };
+
+// --- list ------------------------------------------------------------------
+
+function ProfileRow({
+ profile,
+ active,
+ last,
+ onPress,
+}: {
+ profile: Profile;
+ active: boolean;
+ last: boolean;
+ onPress: () => void;
+}) {
+ const c = useThemeColors();
+ return (
+
+ {active ? (
+
+ ) : (
+ Set active
+ )}
+ {'›'}
+
+ }
+ />
+ );
+}
+
+function ConnectionsList({
+ onAdd,
+ onOpen,
+ onSignIn,
+}: {
+ onAdd: () => void;
+ onOpen: (id: string) => void;
+ onSignIn: () => void;
+}) {
+ const c = useThemeColors();
+ const { profiles, activeProfileId, activeProfile, setActiveProfile } =
+ useProfileStore();
+
+ return (
+
+
+
+ {profiles.length === 0 ? (
+
+
+
+
+ ) : (
+ <>
+
+ {profiles.map((p, i) => (
+
+ p.id === activeProfileId
+ ? onOpen(p.id)
+ : setActiveProfile(p.id)
+ }
+ />
+ ))}
+
+
+
+ Tap a connection to make it active. Tap again, or the chevron, to
+ edit or remove it. Each connection keeps its own sign-in.
+
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+// --- add -------------------------------------------------------------------
+
+function AddConnection({ onDone }: { onDone: () => void }) {
+ const { addProfile } = useProfileStore();
+
+ const handleSubmit = (draft: ProfileDraft) => {
+ addProfile(draft);
+ onDone();
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+// --- edit / delete ---------------------------------------------------------
+
+function EditConnection({
+ id,
+ onDone,
+}: {
+ id: string;
+ onDone: () => void;
+}) {
+ const c = useThemeColors();
+ const {
+ profiles,
+ activeProfileId,
+ updateProfile,
+ removeProfile,
+ setActiveProfile,
+ } = useProfileStore();
+
+ const profile = profiles.find((p) => p.id === id);
+
+ // Profile vanished (e.g. removed elsewhere) — bail back to the list.
+ if (!profile) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ const isActive = profile.id === activeProfileId;
+
+ const confirmDelete = () => {
+ Alert.alert(
+ 'Remove connection',
+ `Remove “${profile.name}”? This can't be undone.`,
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Remove',
+ style: 'destructive',
+ onPress: () => {
+ removeProfile(profile.id);
+ onDone();
+ },
+ },
+ ],
+ );
+ };
+
+ return (
+
+
+
+
+
+ ) : (
+ Tap to activate
+ )
+ }
+ onPress={isActive ? undefined : () => setActiveProfile(profile.id)}
+ />
+ {!isActive ? (
+ setActiveProfile(profile.id)}
+ trailing={{'›'}}
+ />
+ ) : null}
+
+
+ {
+ updateProfile(profile.id, draft);
+ onDone();
+ }}
+ onCancel={onDone}
+ />
+
+
+
+
+
+ );
+}
+
+// --- screen root -----------------------------------------------------------
+
+function SettingsScreenInner() {
+ const router = useRouter();
+ const [pane, setPane] = useState({ kind: 'list' });
+
+ const goSignIn = () => router.push('/sign-in');
+ const goList = () => setPane({ kind: 'list' });
+
+ switch (pane.kind) {
+ case 'add':
+ return ;
+ case 'edit':
+ return ;
+ case 'list':
+ default:
+ return (
+ setPane({ kind: 'add' })}
+ onOpen={(id) => setPane({ kind: 'edit', id })}
+ onSignIn={goSignIn}
+ />
+ );
+ }
+}
+
+export function SettingsScreen() {
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ trailing: { flexDirection: 'row', alignItems: 'center', gap: Spacing.two },
+ chevron: { fontSize: 20, fontWeight: '400' },
+ setActiveHint: { fontSize: 14, fontWeight: '600' },
+ footnote: {
+ fontSize: 13,
+ lineHeight: 18,
+ paddingHorizontal: Spacing.four,
+ paddingTop: Spacing.three,
+ },
+ emptyWrap: { flexGrow: 1, justifyContent: 'center' },
+ dangerZone: { marginTop: Spacing.five },
+});
diff --git a/apps/mobile/src/features/sessions/create/model-selector.tsx b/apps/mobile/src/features/sessions/create/model-selector.tsx
new file mode 100644
index 0000000..27bc440
--- /dev/null
+++ b/apps/mobile/src/features/sessions/create/model-selector.tsx
@@ -0,0 +1,102 @@
+/**
+ * Native model selector (sessions/create slice). A disclosure Row that expands
+ * an inline, grouped, tappable list with a checkmark on the active model — no
+ * picker library. Options come from the frozen `@constructor/protocol`.
+ */
+import React, { useState } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+import { MODEL_OPTIONS, type ValidModel } from '@constructor/protocol';
+import { Spacing } from '@/constants/theme';
+import { Row, useThemeColors } from '@/ui';
+
+function modelName(id: ValidModel): string {
+ for (const group of MODEL_OPTIONS) {
+ const found = group.models.find((m) => m.id === id);
+ if (found) return found.name;
+ }
+ return id;
+}
+
+export function ModelSelector({
+ value,
+ onChange,
+}: {
+ value: ValidModel;
+ onChange: (model: ValidModel) => void;
+}) {
+ const c = useThemeColors();
+ const [open, setOpen] = useState(false);
+
+ return (
+
+ setOpen((o) => !o)} last={!open}>
+
+ Model
+
+
+ {modelName(value)}
+
+ {open ? '⌄' : '›'}
+
+
+ {open ? (
+
+ {MODEL_OPTIONS.map((group, gi) => (
+
+
+
+ {group.category.toUpperCase()}
+
+
+ {group.models.map((m, mi) => {
+ const selected = m.id === value;
+ const isLastRow =
+ gi === MODEL_OPTIONS.length - 1 && mi === group.models.length - 1;
+ return (
+ {
+ onChange(m.id);
+ setOpen(false);
+ }}
+ >
+
+
+ {m.name}
+
+
+ {m.description}
+
+
+ {selected ? (
+ {'✓'}
+ ) : null}
+
+ );
+ })}
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+const st = StyleSheet.create({
+ flex: { flex: 1 },
+ title: { fontSize: 16, fontWeight: '500' },
+ value: { fontSize: 16, maxWidth: 180, textAlign: 'right' },
+ chevron: { fontSize: 18, marginLeft: Spacing.two },
+ groupHeader: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.two },
+ groupTitle: { fontSize: 12, fontWeight: '600' },
+ optTitle: { fontSize: 16, fontWeight: '500' },
+ optSub: { fontSize: 13, marginTop: 2 },
+ check: { fontSize: 17, fontWeight: '700', color: '#208AEF', marginLeft: Spacing.two },
+});
diff --git a/apps/mobile/src/features/sessions/create/reasoning-selector.tsx b/apps/mobile/src/features/sessions/create/reasoning-selector.tsx
new file mode 100644
index 0000000..195f537
--- /dev/null
+++ b/apps/mobile/src/features/sessions/create/reasoning-selector.tsx
@@ -0,0 +1,84 @@
+/**
+ * Reasoning-effort selector (sessions/create slice). Rendered only when the
+ * chosen model supports reasoning. Lists the model's valid efforts as tappable
+ * Rows with a checkmark; an "Auto" row clears the selection (no field sent) for
+ * models whose reasoning default is undefined (e.g. gpt-5.x base).
+ */
+import React from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+import {
+ getReasoningConfig,
+ type ReasoningEffort,
+ type ValidModel,
+} from '@constructor/protocol';
+import { Spacing } from '@/constants/theme';
+import { Row, useThemeColors } from '@/ui';
+
+const EFFORT_LABEL: Record = {
+ none: 'None',
+ low: 'Low',
+ medium: 'Medium',
+ high: 'High',
+ xhigh: 'Extra High',
+ max: 'Max',
+};
+
+export function effortLabel(effort: ReasoningEffort | undefined): string {
+ return effort ? EFFORT_LABEL[effort] : 'Auto';
+}
+
+export function ReasoningSelector({
+ model,
+ value,
+ onChange,
+}: {
+ model: ValidModel;
+ value: ReasoningEffort | undefined;
+ onChange: (effort: ReasoningEffort | undefined) => void;
+}) {
+ const c = useThemeColors();
+ const config = getReasoningConfig(model);
+ if (!config) return null;
+
+ // "Auto" (undefined) is offered only when the model has no enforced default,
+ // matching MODEL_REASONING_CONFIG where `default` is undefined.
+ const showAuto = config.default === undefined;
+
+ const renderRow = (
+ effort: ReasoningEffort | undefined,
+ label: string,
+ last: boolean,
+ ) => {
+ const selected = value === effort;
+ return (
+ onChange(effort)}>
+
+
+ {label}
+
+
+ {selected ? {'✓'} : null}
+
+ );
+ };
+
+ return (
+
+ {showAuto ? renderRow(undefined, 'Auto', false) : null}
+ {config.efforts.map((effort, i) =>
+ renderRow(
+ effort,
+ EFFORT_LABEL[effort],
+ i === config.efforts.length - 1,
+ ),
+ )}
+
+ );
+}
+
+const st = StyleSheet.create({
+ flex: { flex: 1 },
+ optTitle: { fontSize: 16, fontWeight: '500' },
+ check: { fontSize: 17, fontWeight: '700', color: '#208AEF', marginLeft: Spacing.two },
+});
diff --git a/apps/mobile/src/features/sessions/create/screen.tsx b/apps/mobile/src/features/sessions/create/screen.tsx
new file mode 100644
index 0000000..03c4a49
--- /dev/null
+++ b/apps/mobile/src/features/sessions/create/screen.tsx
@@ -0,0 +1,178 @@
+/** Phase-1 slice owner: sessions/create. Native-iOS create-session form. */
+import React, { useState } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+import { useRouter } from 'expo-router';
+
+import {
+ DEFAULT_MODEL,
+ getDefaultReasoningEffort,
+ supportsReasoning,
+ type ReasoningEffort,
+ type ValidModel,
+} from '@constructor/protocol';
+import { useCreateSession } from '@/data/queries';
+import { Spacing } from '@/constants/theme';
+import {
+ AppBar,
+ Button,
+ Screen,
+ Section,
+ TextField,
+ useThemeColors,
+} from '@/ui';
+
+import { ModelSelector } from './model-selector';
+import { ReasoningSelector, effortLabel } from './reasoning-selector';
+
+export function CreateSessionScreen() {
+ const router = useRouter();
+ const create = useCreateSession();
+ const c = useThemeColors();
+
+ const [repoOwner, setRepoOwner] = useState('refrakts');
+ const [repoName, setRepoName] = useState('constructor-mobile');
+ const [title, setTitle] = useState('');
+ const [branch, setBranch] = useState('main');
+ const [model, setModel] = useState(DEFAULT_MODEL);
+ const [effort, setEffort] = useState(
+ getDefaultReasoningEffort(DEFAULT_MODEL),
+ );
+ const [error, setError] = useState(null);
+
+ // Effort validity is per-model; re-seed it from the new model's default
+ // whenever the model changes so we never submit an invalid combination.
+ const onModelChange = (next: ValidModel) => {
+ setModel(next);
+ setEffort(getDefaultReasoningEffort(next));
+ };
+
+ const canSubmit =
+ repoOwner.trim().length > 0 &&
+ repoName.trim().length > 0 &&
+ !create.isPending;
+
+ const submit = async () => {
+ if (!canSubmit) return;
+ setError(null);
+ const branchTrimmed = branch.trim();
+ const titleTrimmed = title.trim();
+ try {
+ const res = await create.mutateAsync({
+ repoOwner: repoOwner.trim(),
+ repoName: repoName.trim(),
+ title: titleTrimmed || undefined,
+ branch: branchTrimmed || undefined,
+ model,
+ reasoningEffort: effort || undefined,
+ });
+ router.replace({ pathname: '/s/[id]', params: { id: res.sessionId } });
+ } catch (e) {
+ setError(
+ e instanceof Error ? e.message : 'Could not create the session. Try again.',
+ );
+ }
+ };
+
+ const reasoning = supportsReasoning(model);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {reasoning ? (
+
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+ );
+}
+
+const st = StyleSheet.create({
+ errorBox: {
+ marginHorizontal: Spacing.three,
+ marginTop: Spacing.four,
+ paddingHorizontal: Spacing.three,
+ paddingVertical: Spacing.three,
+ borderRadius: 10,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: '#E5484D',
+ backgroundColor: '#E5484D22',
+ },
+ errorText: { fontSize: 14, fontWeight: '500' },
+ footnote: {
+ paddingHorizontal: Spacing.four,
+ paddingTop: Spacing.four,
+ alignItems: 'center',
+ },
+ footnoteText: { fontSize: 13, textAlign: 'center' },
+});
diff --git a/apps/mobile/src/features/sessions/detail/Composer.tsx b/apps/mobile/src/features/sessions/detail/Composer.tsx
new file mode 100644
index 0000000..f03960a
--- /dev/null
+++ b/apps/mobile/src/features/sessions/detail/Composer.tsx
@@ -0,0 +1,154 @@
+/** Follow-up composer + Stop, shown while the stream is live. Multiline input
+ * with an inline send affordance; Stop sits beside it and calls gateway.stop.
+ * Both actions guard against double-fire with a local pending flag. */
+import React, { useState } from 'react';
+import {
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Platform,
+ Pressable,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { Fonts, Spacing } from '@/constants/theme';
+import { useThemeColors } from '@/ui';
+
+export function Composer({
+ onSend,
+ onStop,
+}: {
+ onSend: (text: string) => Promise;
+ onStop: () => Promise;
+}) {
+ const c = useThemeColors();
+ const insets = useSafeAreaInsets();
+ const [draft, setDraft] = useState('');
+ const [sending, setSending] = useState(false);
+ const [stopping, setStopping] = useState(false);
+
+ const canSend = draft.trim().length > 0 && !sending;
+
+ const send = async () => {
+ const text = draft.trim();
+ if (!text || sending) return;
+ setSending(true);
+ try {
+ await onSend(text);
+ setDraft('');
+ } finally {
+ setSending(false);
+ }
+ };
+
+ const stop = async () => {
+ if (stopping) return;
+ setStopping(true);
+ try {
+ await onStop();
+ } finally {
+ setStopping(false);
+ }
+ };
+
+ return (
+
+
+
+ {stopping ? (
+
+ ) : (
+ Stop
+ )}
+
+
+
+
+
+
+
+ {sending ? (
+
+ ) : (
+ ↑
+ )}
+
+
+
+ );
+}
+
+const s = StyleSheet.create({
+ bar: {
+ flexDirection: 'row',
+ alignItems: 'flex-end',
+ gap: Spacing.two,
+ paddingHorizontal: Spacing.three,
+ paddingTop: Spacing.three,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ },
+ inputWrap: {
+ flex: 1,
+ borderRadius: 20,
+ paddingHorizontal: Spacing.three,
+ paddingVertical: Platform.OS === 'ios' ? Spacing.two + 2 : 2,
+ minHeight: 40,
+ maxHeight: 132,
+ justifyContent: 'center',
+ },
+ input: { fontSize: 15, fontFamily: Fonts?.sans, lineHeight: 20, maxHeight: 120 },
+ sendBtn: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ sendText: { fontSize: 20, fontWeight: '700' },
+ stopBtn: {
+ height: 40,
+ paddingHorizontal: Spacing.three,
+ borderRadius: 20,
+ borderWidth: StyleSheet.hairlineWidth,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ stopText: { color: '#E5484D', fontSize: 14, fontWeight: '700' },
+});
diff --git a/apps/mobile/src/features/sessions/detail/EventRow.tsx b/apps/mobile/src/features/sessions/detail/EventRow.tsx
new file mode 100644
index 0000000..db25eb7
--- /dev/null
+++ b/apps/mobile/src/features/sessions/detail/EventRow.tsx
@@ -0,0 +1,180 @@
+/** Maps one folded `SandboxEvent` to its native presentation.
+ *
+ * - user_message → right-aligned sent bubble
+ * - token → assistant markdown card (cumulative text, folded upstream)
+ * - tool_call → collapsible ToolCallRow (paired with its tool_result)
+ * - tool_result → rendered ONLY if orphaned (no matching tool_call rendered)
+ * - step_start/finish → subtle separator line (+ cost/tokens on finish)
+ * - error → prominent red callout
+ * - execution_complete → terminal success/cost row
+ * - heartbeat / others → null (ignored)
+ */
+import React from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+import type { SandboxEvent } from '@constructor/protocol';
+import { Fonts, Spacing } from '@/constants/theme';
+import { useThemeColors } from '@/ui';
+
+import { StreamMarkdown } from './markdown';
+import { ToolCallRow } from './ToolCallRow';
+
+type ToolResult = Extract;
+
+export function EventRow({
+ event,
+ resultsByCallId,
+ pairedCallIds,
+ cost,
+}: {
+ event: SandboxEvent;
+ /** callId → its tool_result, so a tool_call can show its outcome inline. */
+ resultsByCallId: Map;
+ /** callIds whose result is already shown inside a ToolCallRow. */
+ pairedCallIds: Set;
+ /** Running accumulated cost (rendered on the terminal row). */
+ cost: number;
+}) {
+ const c = useThemeColors();
+
+ switch (event.type) {
+ case 'user_message':
+ return (
+
+
+ {event.content}
+
+
+ );
+
+ case 'token':
+ return (
+
+
+
+ );
+
+ case 'tool_call':
+ return ;
+
+ case 'tool_result':
+ // Shown inside its ToolCallRow when paired; render standalone only if orphaned.
+ if (pairedCallIds.has(event.callId)) return null;
+ return (
+
+
+ {event.error ? 'TOOL ERROR' : 'TOOL RESULT'}
+
+
+ {event.error || event.result}
+
+
+ );
+
+ case 'step_start':
+ return (
+
+
+
+ {event.isSubtask ? 'subtask' : 'thinking'}
+
+
+
+ );
+
+ case 'step_finish': {
+ const bits: string[] = [];
+ if (typeof event.cost === 'number' && event.cost > 0) bits.push(`$${event.cost.toFixed(4)}`);
+ if (typeof event.tokens === 'number' && event.tokens > 0)
+ bits.push(`${event.tokens.toLocaleString()} tok`);
+ return (
+
+
+
+ {bits.length ? `step · ${bits.join(' · ')}` : 'step complete'}
+
+
+
+ );
+ }
+
+ case 'error':
+ return (
+
+ Error
+
+ {event.error}
+
+
+ );
+
+ case 'execution_complete':
+ return (
+
+
+ {event.success ? '✓ Completed' : '✗ Failed'}
+
+
+ {event.error ? event.error : `$${cost.toFixed(4)}`}
+
+
+ );
+
+ // git_sync, artifact, push_complete, push_error, heartbeat → not in this view
+ default:
+ return null;
+ }
+}
+
+const s = StyleSheet.create({
+ userWrap: { alignItems: 'flex-end' },
+ userBubble: {
+ maxWidth: '85%',
+ borderRadius: 16,
+ borderBottomRightRadius: 4,
+ paddingHorizontal: Spacing.three,
+ paddingVertical: Spacing.two + 2,
+ },
+ userText: { color: '#ffffff', fontSize: 15, fontFamily: Fonts?.sans, lineHeight: 21 },
+ assistant: {
+ borderRadius: 12,
+ paddingHorizontal: Spacing.three,
+ paddingVertical: Spacing.three - 2,
+ },
+ metaLabel: { fontSize: 11, fontWeight: '700', letterSpacing: 0.5, marginBottom: Spacing.one },
+ mono: { fontSize: 12, fontFamily: Fonts?.mono, lineHeight: 17 },
+ stepRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.two, paddingVertical: 2 },
+ hair: { flex: 1, height: StyleSheet.hairlineWidth },
+ stepText: { fontSize: 11, fontWeight: '600', letterSpacing: 0.4 },
+ errorCard: {
+ borderRadius: 12,
+ borderWidth: StyleSheet.hairlineWidth,
+ backgroundColor: '#E5484D14',
+ paddingHorizontal: Spacing.three,
+ paddingVertical: Spacing.three - 2,
+ },
+ errorTitle: { color: '#E5484D', fontSize: 14, fontWeight: '700', marginBottom: Spacing.one },
+ terminal: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ borderRadius: 12,
+ borderWidth: StyleSheet.hairlineWidth,
+ paddingHorizontal: Spacing.three,
+ paddingVertical: Spacing.three,
+ },
+ terminalText: { fontSize: 15, fontWeight: '700', fontFamily: Fonts?.sans },
+ terminalCost: { fontSize: 13, fontFamily: Fonts?.mono },
+});
diff --git a/apps/mobile/src/features/sessions/detail/ToolCallRow.tsx b/apps/mobile/src/features/sessions/detail/ToolCallRow.tsx
new file mode 100644
index 0000000..b11f3c8
--- /dev/null
+++ b/apps/mobile/src/features/sessions/detail/ToolCallRow.tsx
@@ -0,0 +1,130 @@
+/** Native collapsible tool-call card. A `tool_call` event is paired (by
+ * `callId`, done in EventRow) with its matching `tool_result`. Collapsed it
+ * shows tool name + a one-line summary + status dot; tapped it expands the
+ * full args and result/output in a monospace block. */
+import React, { useState } from 'react';
+import { LayoutAnimation, Platform, Pressable, StyleSheet, Text, UIManager, View } from 'react-native';
+
+import type { SandboxEvent } from '@constructor/protocol';
+import { Fonts, Spacing } from '@/constants/theme';
+import { useThemeColors } from '@/ui';
+
+if (
+ Platform.OS === 'android' &&
+ UIManager.setLayoutAnimationEnabledExperimental
+) {
+ UIManager.setLayoutAnimationEnabledExperimental(true);
+}
+
+type ToolCall = Extract;
+type ToolResult = Extract;
+
+function pretty(value: unknown): string {
+ if (value == null) return '';
+ if (typeof value === 'string') return value;
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+}
+
+/** Compact one-liner from common arg shapes (path/file/command/query…). */
+function argSummary(args: Record): string {
+ const keys = ['path', 'file', 'filePath', 'command', 'cmd', 'query', 'pattern', 'url', 'name'];
+ for (const k of keys) {
+ const v = args?.[k];
+ if (typeof v === 'string' && v) return v;
+ }
+ const entries = Object.entries(args ?? {});
+ if (entries.length === 0) return '';
+ const [k, v] = entries[0];
+ return `${k}: ${typeof v === 'string' ? v : pretty(v)}`.slice(0, 80);
+}
+
+export function ToolCallRow({ call, result }: { call: ToolCall; result?: ToolResult }) {
+ const c = useThemeColors();
+ const [open, setOpen] = useState(false);
+
+ const errored = !!result?.error || call.status === 'error' || call.status === 'failed';
+ const settled = !!result || call.status === 'success' || call.status === 'completed';
+ const dot = errored ? '#E5484D' : settled ? '#30A46C' : '#F5A623';
+
+ const summary = argSummary(call.args);
+ const resultText = result ? (result.error ? result.error : result.result) : call.output;
+
+ const toggle = () => {
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
+ setOpen((o) => !o);
+ };
+
+ return (
+
+
+
+
+
+ {'⚙ '}
+ {call.tool}
+
+ {summary ? (
+
+ {summary}
+
+ ) : null}
+
+ {open ? '⌄' : '›'}
+
+
+ {open ? (
+
+ ARGS
+
+ {pretty(call.args) || '{}'}
+
+ {resultText ? (
+ <>
+
+ {errored ? 'ERROR' : 'RESULT'}
+
+
+ {resultText}
+
+ >
+ ) : settled ? null : (
+ Running…
+ )}
+
+ ) : null}
+
+ );
+}
+
+const s = StyleSheet.create({
+ flex: { flex: 1 },
+ card: { borderRadius: 12, overflow: 'hidden' },
+ head: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: Spacing.three,
+ paddingVertical: Spacing.three,
+ gap: Spacing.three,
+ },
+ dot: { width: 8, height: 8, borderRadius: 4 },
+ tool: { fontSize: 15, fontWeight: '600', fontFamily: Fonts?.sans },
+ glyph: { fontSize: 13 },
+ sub: { fontSize: 13, marginTop: 2, fontFamily: Fonts?.sans },
+ chev: { fontSize: 18, fontWeight: '600', width: 16, textAlign: 'center' },
+ body: {
+ paddingHorizontal: Spacing.three,
+ paddingBottom: Spacing.three,
+ paddingTop: Spacing.two,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ },
+ label: { fontSize: 11, fontWeight: '700', letterSpacing: 0.5, marginBottom: Spacing.one },
+ mono: { fontSize: 12, fontFamily: Fonts?.mono, lineHeight: 17 },
+});
diff --git a/apps/mobile/src/features/sessions/detail/markdown.tsx b/apps/mobile/src/features/sessions/detail/markdown.tsx
new file mode 100644
index 0000000..4eadcc7
--- /dev/null
+++ b/apps/mobile/src/features/sessions/detail/markdown.tsx
@@ -0,0 +1,86 @@
+/** Hardened Markdown renderer for streamed assistant text.
+ * `html:false` blocks raw-HTML injection from model output; the MarkdownIt
+ * instance is created once (module scope) so every token re-render reuses it.
+ * Themed via the `style` map so it tracks light/dark from `useThemeColors`. */
+import React, { useMemo } from 'react';
+import { StyleSheet } from 'react-native';
+import Markdown, { MarkdownIt } from 'react-native-markdown-display';
+
+import { Fonts } from '@/constants/theme';
+import { useThemeColors } from '@/ui';
+
+/** Single shared, hardened parser — no per-render allocation, no raw HTML. */
+const md = MarkdownIt({ html: false, linkify: true, typographer: false });
+
+export function StreamMarkdown({ content }: { content: string }) {
+ const c = useThemeColors();
+
+ // Re-themed only when palette changes (light/dark flip), not per token.
+ const styles = useMemo(
+ () =>
+ ({
+ body: { color: c.text, fontSize: 15, fontFamily: Fonts?.sans, lineHeight: 22 },
+ paragraph: { marginTop: 0, marginBottom: 8 },
+ heading1: { color: c.text, fontSize: 20, fontWeight: '700', marginBottom: 6 },
+ heading2: { color: c.text, fontSize: 18, fontWeight: '700', marginBottom: 6 },
+ heading3: { color: c.text, fontSize: 16, fontWeight: '600', marginBottom: 4 },
+ strong: { fontWeight: '700', color: c.text },
+ em: { fontStyle: 'italic' },
+ link: { color: '#208AEF' },
+ bullet_list: { marginBottom: 4 },
+ ordered_list: { marginBottom: 4 },
+ list_item: { color: c.text },
+ blockquote: {
+ backgroundColor: c.backgroundElement,
+ borderLeftColor: c.backgroundSelected,
+ borderLeftWidth: 3,
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ marginBottom: 8,
+ },
+ hr: { backgroundColor: c.backgroundSelected, height: StyleSheet.hairlineWidth },
+ code_inline: {
+ color: c.text,
+ backgroundColor: c.backgroundElement,
+ borderRadius: 4,
+ paddingHorizontal: 4,
+ paddingVertical: 1,
+ fontFamily: Fonts?.mono,
+ fontSize: 13,
+ },
+ code_block: {
+ color: c.text,
+ backgroundColor: c.backgroundElement,
+ borderRadius: 8,
+ padding: 10,
+ fontFamily: Fonts?.mono,
+ fontSize: 13,
+ marginBottom: 8,
+ },
+ fence: {
+ color: c.text,
+ backgroundColor: c.backgroundElement,
+ borderRadius: 8,
+ padding: 10,
+ fontFamily: Fonts?.mono,
+ fontSize: 13,
+ marginBottom: 8,
+ },
+ table: {
+ borderColor: c.backgroundSelected,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderRadius: 6,
+ marginBottom: 8,
+ },
+ th: { padding: 6, color: c.text },
+ td: { padding: 6, color: c.text },
+ }) as StyleSheet.NamedStyles,
+ [c],
+ );
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/apps/mobile/src/features/sessions/detail/screen.tsx b/apps/mobile/src/features/sessions/detail/screen.tsx
new file mode 100644
index 0000000..374be66
--- /dev/null
+++ b/apps/mobile/src/features/sessions/detail/screen.tsx
@@ -0,0 +1,219 @@
+/** Phase-1 slice owner: sessions/detail — the live session event stream.
+ *
+ * Append-only FlashList (v2, new arch) over the folded `SandboxEvent[]` from
+ * `useSessionStream`. `maintainVisibleContentPosition` (on by default in v2)
+ * keeps it pinned to the newest event while the user is at the bottom; a
+ * floating "Jump to latest" appears once they scroll up. Header carries the
+ * title, a live/closed status badge and the running cost. While live, a
+ * follow-up composer + Stop are shown. Connecting / empty / closed states are
+ * all handled. Data flows ONLY through `@/data/queries`.
+ */
+import React, { useCallback, useMemo, useRef, useState } from 'react';
+import {
+ NativeScrollEvent,
+ NativeSyntheticEvent,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native';
+import { FlashList, type FlashListRef } from '@shopify/flash-list';
+import { useLocalSearchParams } from 'expo-router';
+
+import type { SandboxEvent } from '@constructor/protocol';
+import { useGateway, useSessionStream } from '@/data/queries';
+import { Fonts, Spacing } from '@/constants/theme';
+import { AppBar, Badge, EmptyState, Loading, Screen, useThemeColors } from '@/ui';
+
+import { Composer } from './Composer';
+import { EventRow } from './EventRow';
+
+type ToolResult = Extract;
+
+/** Stable per-event key (events are append-only; folding only mutates the
+ * trailing token in place, so type+timestamp+index is collision-free here). */
+function eventKey(e: SandboxEvent, i: number): string {
+ const id =
+ 'callId' in e && e.callId
+ ? e.callId
+ : 'messageId' in e && e.messageId
+ ? e.messageId
+ : '';
+ return `${i}:${e.type}:${id}:${e.timestamp}`;
+}
+
+export function SessionDetailScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const c = useThemeColors();
+ const gw = useGateway();
+ const { status, state, events, cost } = useSessionStream(id);
+
+ const listRef = useRef>(null);
+ const [atBottom, setAtBottom] = useState(true);
+
+ const title = state?.title ?? 'Session';
+ const live = status === 'live';
+
+ // Pair tool_result→tool_call by callId so each call shows its outcome inline,
+ // and so a paired result isn't also rendered as its own row.
+ const { resultsByCallId, pairedCallIds } = useMemo(() => {
+ const byId = new Map();
+ const calls = new Set();
+ for (const e of events) {
+ if (e.type === 'tool_result') byId.set(e.callId, e);
+ else if (e.type === 'tool_call') calls.add(e.callId);
+ }
+ const paired = new Set();
+ for (const cid of calls) if (byId.has(cid)) paired.add(cid);
+ return { resultsByCallId: byId, pairedCallIds: paired };
+ }, [events]);
+
+ const renderItem = useCallback(
+ ({ item }: { item: SandboxEvent }) => (
+
+ ),
+ [resultsByCallId, pairedCallIds, cost],
+ );
+
+ const getItemType = useCallback((item: SandboxEvent) => item.type, []);
+
+ const onScroll = useCallback((e: NativeSyntheticEvent) => {
+ const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
+ const distanceFromBottom =
+ contentSize.height - (contentOffset.y + layoutMeasurement.height);
+ setAtBottom(distanceFromBottom < 80);
+ }, []);
+
+ const jumpToLatest = useCallback(() => {
+ listRef.current?.scrollToEnd({ animated: true });
+ setAtBottom(true);
+ }, []);
+
+ const sendFollowUp = useCallback(
+ async (text: string) => {
+ await gw.sendFollowUp(id, text);
+ },
+ [gw, id],
+ );
+
+ const stop = useCallback(async () => {
+ await gw.stop(id);
+ }, [gw, id]);
+
+ // Connecting: snapshot not yet delivered and nothing to show.
+ if (status === 'connecting' && events.length === 0) {
+ return (
+
+ } />
+
+
+ );
+ }
+
+ return (
+
+
+ {cost > 0 ? (
+ ${cost.toFixed(4)}
+ ) : null}
+
+
+ }
+ />
+
+
+ {events.length === 0 ? (
+
+ ) : (
+
+ )}
+
+ {!atBottom && events.length > 0 ? (
+
+ ↓ Jump to latest
+
+ ) : null}
+
+
+ {live ? (
+
+ ) : (
+
+
+ {status === 'closed' ? 'Session ended' : 'Not connected'}
+
+
+ )}
+
+ );
+}
+
+function Gap() {
+ return ;
+}
+
+const s = StyleSheet.create({
+ flex: { flex: 1 },
+ headerRight: { flexDirection: 'row', alignItems: 'center', gap: Spacing.two },
+ cost: { fontSize: 13, fontFamily: Fonts?.mono },
+ listContent: { padding: Spacing.three },
+ gap: { height: Spacing.three },
+ jump: {
+ position: 'absolute',
+ alignSelf: 'center',
+ paddingHorizontal: Spacing.three,
+ paddingVertical: Spacing.two,
+ borderRadius: 20,
+ shadowColor: '#000',
+ shadowOpacity: 0.2,
+ shadowRadius: 6,
+ shadowOffset: { width: 0, height: 2 },
+ elevation: 4,
+ },
+ jumpText: { color: '#ffffff', fontSize: 13, fontWeight: '700' },
+ closedBar: {
+ paddingVertical: Spacing.three,
+ alignItems: 'center',
+ borderTopWidth: StyleSheet.hairlineWidth,
+ },
+ closedText: { fontSize: 13, fontWeight: '500' },
+});
diff --git a/apps/mobile/src/features/sessions/list/relative-time.ts b/apps/mobile/src/features/sessions/list/relative-time.ts
new file mode 100644
index 0000000..a65e845
--- /dev/null
+++ b/apps/mobile/src/features/sessions/list/relative-time.ts
@@ -0,0 +1,46 @@
+/**
+ * sessions/list slice helper. Pure, dependency-free relative-time formatter for
+ * `Session.updatedAt` / `createdAt` (epoch **milliseconds** per the vendored
+ * protocol). Native-iOS phrasing: "Just now", "5m ago", "3h ago", "Yesterday",
+ * weekday within the last week, then a short calendar date.
+ */
+
+const MINUTE = 60_000;
+const HOUR = 60 * MINUTE;
+const DAY = 24 * HOUR;
+
+const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
+const MONTHS = [
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
+] as const;
+
+/**
+ * Compact relative label suitable for a list trailing slot.
+ * @param epochMs timestamp in milliseconds
+ * @param now reference time (injectable for tests; defaults to Date.now())
+ */
+export function formatRelative(epochMs: number, now: number = Date.now()): string {
+ if (!Number.isFinite(epochMs)) return '';
+ const diff = now - epochMs;
+
+ // Clock skew / future timestamps: treat as just now rather than "-3m".
+ if (diff < 0) return 'Just now';
+ if (diff < MINUTE) return 'Just now';
+ if (diff < HOUR) return `${Math.floor(diff / MINUTE)}m ago`;
+ if (diff < DAY) return `${Math.floor(diff / HOUR)}h ago`;
+
+ const d = new Date(epochMs);
+ if (diff < 2 * DAY) return 'Yesterday';
+ if (diff < 7 * DAY) return WEEKDAYS[d.getDay()];
+
+ const sameYear = d.getFullYear() === new Date(now).getFullYear();
+ const base = `${MONTHS[d.getMonth()]} ${d.getDate()}`;
+ return sameYear ? base : `${base}, ${d.getFullYear()}`;
+}
+
+/** Longer accessibility phrasing ("Updated 5m ago"). */
+export function describeUpdated(epochMs: number, now: number = Date.now()): string {
+ const rel = formatRelative(epochMs, now);
+ return rel ? `Updated ${rel.toLowerCase()}` : 'Update time unknown';
+}
diff --git a/apps/mobile/src/features/sessions/list/screen.tsx b/apps/mobile/src/features/sessions/list/screen.tsx
new file mode 100644
index 0000000..e45b938
--- /dev/null
+++ b/apps/mobile/src/features/sessions/list/screen.tsx
@@ -0,0 +1,271 @@
+/**
+ * Phase-1 slice owner: sessions/list (spec §6).
+ *
+ * Native-iOS session list: status-grouped sections rendered with the `@/ui`
+ * `Section` card + `ListItem` chrome, a status Badge, relative "updated"
+ * timestamps, a prominent + → /new, a Settings gear → /settings, and
+ * pull-to-refresh wired to `refetch`. Loading / empty / error all live inside
+ * the same pullable surface so the RefreshControl (and its retry affordance)
+ * applies uniformly.
+ *
+ * Why a local scroll surface: `@/ui` `Screen` only renders a *non*-refreshable
+ * ScrollView under `scroll`. The slice spec explicitly requires a
+ * `RefreshControl`, so this slice owns its scroll surface — a core RN
+ * `ScrollView` + `RefreshControl` (both core RN; `RefreshControl` is named by
+ * the slice spec). Every piece of visible chrome still uses `@/ui` primitives.
+ */
+import React, { useCallback, useMemo, useState } from 'react';
+import {
+ Pressable,
+ RefreshControl,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native';
+import { useRouter } from 'expo-router';
+
+import type { Session, SessionStatus } from '@constructor/protocol';
+import { useSessions } from '@/data/queries';
+import {
+ AppBar,
+ Badge,
+ Button,
+ EmptyState,
+ ListItem,
+ Loading,
+ Screen,
+ Section,
+ useThemeColors,
+} from '@/ui';
+import { Spacing } from '@/constants/theme';
+
+import { describeUpdated, formatRelative } from './relative-time';
+
+const ACCENT = '#208AEF';
+
+/** Spec §6 verbatim: active→good, completed→neutral, failed→bad, else→warn. */
+type Tone = 'neutral' | 'good' | 'warn' | 'bad';
+function statusTone(status: SessionStatus | string): Tone {
+ if (status === 'active') return 'good';
+ if (status === 'failed') return 'bad';
+ if (status === 'completed') return 'neutral';
+ return 'warn'; // created · archived · cancelled · unknown
+}
+
+const STATUS_LABEL: Record = {
+ created: 'Queued',
+ active: 'Active',
+ completed: 'Completed',
+ failed: 'Failed',
+ archived: 'Archived',
+ cancelled: 'Cancelled',
+};
+const statusLabel = (status: SessionStatus | string) =>
+ STATUS_LABEL[status] ?? String(status);
+
+type SessionGroup = { key: string; title: string; items: Session[] };
+
+const byUpdatedDesc = (a: Session, b: Session) => b.updatedAt - a.updatedAt;
+
+/** iOS-Settings-style grouping. Only non-empty groups are emitted so the
+ * screen never shows a dangling header at small data sizes. */
+function groupSessions(sessions: Session[]): SessionGroup[] {
+ const active: Session[] = [];
+ const recent: Session[] = [];
+ for (const sess of sessions) {
+ (sess.status === 'active' ? active : recent).push(sess);
+ }
+ const groups: SessionGroup[] = [];
+ if (active.length) {
+ groups.push({ key: 'active', title: 'Active', items: active.sort(byUpdatedDesc) });
+ }
+ if (recent.length) {
+ groups.push({ key: 'recent', title: 'Recent', items: recent.sort(byUpdatedDesc) });
+ }
+ return groups;
+}
+
+function HeaderActions({
+ onSettings,
+ onNew,
+}: {
+ onSettings: () => void;
+ onNew: () => void;
+}) {
+ const c = useThemeColors();
+ return (
+
+
+ {'⚙︎'}
+
+
+ +
+
+
+ );
+}
+
+function SessionTrailing({ session }: { session: Session }) {
+ const c = useThemeColors();
+ return (
+
+
+ {formatRelative(session.updatedAt)}
+
+
+
+ );
+}
+
+/** A status group rendered as a `@/ui` Section card with `count` in the header. */
+function SessionGroupCard({
+ group,
+ onOpen,
+}: {
+ group: SessionGroup;
+ onOpen: (id: string) => void;
+}) {
+ const heading = `${group.title} · ${group.items.length}`;
+ return (
+
+ {group.items.map((session, i) => (
+ }
+ onPress={() => onOpen(session.id)}
+ last={i === group.items.length - 1}
+ />
+ ))}
+
+ );
+}
+
+export function SessionListScreen() {
+ const router = useRouter();
+ const { data, isLoading, isError, refetch } = useSessions();
+ const [refreshing, setRefreshing] = useState(false);
+
+ const groups = useMemo(() => groupSessions(data ?? []), [data]);
+
+ const onRefresh = useCallback(async () => {
+ setRefreshing(true);
+ try {
+ await refetch();
+ } finally {
+ setRefreshing(false);
+ }
+ }, [refetch]);
+
+ const openSession = useCallback(
+ (id: string) => router.push({ pathname: '/s/[id]', params: { id } }),
+ [router],
+ );
+
+ // Only the very first load (nothing cached) gets the centered spinner.
+ const showInitialLoading = isLoading && !data;
+ const isEmpty = !isError && !showInitialLoading && groups.length === 0;
+ const fillCenter = isError || isEmpty;
+
+ return (
+
+ router.push('/settings')}
+ onNew={() => router.push('/new')}
+ />
+ }
+ />
+
+ {showInitialLoading ? (
+
+ ) : (
+
+ }
+ >
+ {isError ? (
+
+
+
+
+
+
+ ) : isEmpty ? (
+
+
+
+
+
+ ) : (
+ groups.map((group) => (
+
+ ))
+ )}
+
+ )}
+
+ );
+}
+
+const s = StyleSheet.create({
+ flex: { flex: 1 },
+
+ actions: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: Spacing.three,
+ },
+ gear: { fontSize: 22, fontWeight: '400' },
+ plus: { fontSize: 30, fontWeight: '300', lineHeight: 32 },
+
+ content: { paddingBottom: Spacing.six },
+ contentFill: { flexGrow: 1, justifyContent: 'center' },
+
+ trailing: { alignItems: 'flex-end', gap: Spacing.one, maxWidth: 132 },
+ time: { fontSize: 12, fontWeight: '500' },
+
+ stateBlock: { alignItems: 'center', gap: Spacing.three, paddingHorizontal: Spacing.three },
+ stateAction: { alignSelf: 'stretch' },
+});
diff --git a/apps/mobile/src/features/sessions/stream/transforms.ts b/apps/mobile/src/features/sessions/stream/transforms.ts
new file mode 100644
index 0000000..c3e05a2
--- /dev/null
+++ b/apps/mobile/src/features/sessions/stream/transforms.ts
@@ -0,0 +1,90 @@
+/**
+ * Ported from background-agents/packages/web/src/hooks/use-session-socket.ts
+ * lines 64-100 (collapseTokenEvents) and 250-297 (processSandboxEvent rule)
+ * @ a7b968f3dfc7ff4d3d92fc158d57834c100e453c — PURE logic only, no React/DOM.
+ *
+ * Semantics (PLAN-02 / 03-data-plane): `token` events are cumulative (each
+ * carries the full accumulated text); only the last token before
+ * `execution_complete` is kept, emitted with the token's original timestamp.
+ */
+import type { SandboxEvent } from '@constructor/protocol';
+
+export interface PendingToken {
+ content: string;
+ messageId: string;
+ sandboxId: string;
+ timestamp: number;
+}
+/** Mutable holder (the upstream `pendingTextRef` — a plain box, kept pure). */
+export type PendingRef = { current: PendingToken | null };
+
+export function collapseTokenEvents(events: SandboxEvent[], pending: PendingRef): SandboxEvent[] {
+ const result: SandboxEvent[] = [];
+ for (const evt of events) {
+ if (evt.type === 'token' && evt.content && evt.messageId) {
+ pending.current = {
+ content: evt.content,
+ messageId: evt.messageId,
+ sandboxId: evt.sandboxId,
+ timestamp: evt.timestamp,
+ };
+ } else if (evt.type === 'execution_complete') {
+ if (pending.current) {
+ const p = pending.current;
+ pending.current = null;
+ result.push({
+ type: 'token',
+ content: p.content,
+ messageId: p.messageId,
+ sandboxId: p.sandboxId,
+ timestamp: p.timestamp,
+ });
+ }
+ result.push(evt);
+ } else {
+ result.push(evt);
+ }
+ }
+ return result;
+}
+
+/**
+ * Pure equivalent of upstream `processSandboxEvent` (which used setState):
+ * fold one live event into the prior event list using the same rule.
+ */
+export function foldEvent(prev: SandboxEvent[], event: SandboxEvent, pending: PendingRef): SandboxEvent[] {
+ if (event.type === 'token' && event.content && event.messageId) {
+ pending.current = {
+ content: event.content,
+ messageId: event.messageId,
+ sandboxId: event.sandboxId,
+ timestamp: event.timestamp,
+ };
+ return prev;
+ }
+ if (event.type === 'execution_complete') {
+ const out = [...prev];
+ if (pending.current) {
+ const p = pending.current;
+ pending.current = null;
+ out.push({
+ type: 'token',
+ content: p.content,
+ messageId: p.messageId,
+ sandboxId: p.sandboxId,
+ timestamp: p.timestamp,
+ });
+ }
+ out.push(event);
+ return out;
+ }
+ return [...prev, event];
+}
+
+/** Pure cost accumulation (upstream folded this into sessionState on step_finish). */
+export function costDelta(event: SandboxEvent): number {
+ if (event.type === 'step_finish' && typeof event.cost === 'number' && Number.isFinite(event.cost) && event.cost > 0) {
+ return event.cost;
+ }
+ return 0;
+}
diff --git a/apps/mobile/src/hooks/use-color-scheme.ts b/apps/mobile/src/hooks/use-color-scheme.ts
deleted file mode 100644
index 17e3c63..0000000
--- a/apps/mobile/src/hooks/use-color-scheme.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { useColorScheme } from 'react-native';
diff --git a/apps/mobile/src/hooks/use-color-scheme.web.ts b/apps/mobile/src/hooks/use-color-scheme.web.ts
deleted file mode 100644
index 7eb1c1b..0000000
--- a/apps/mobile/src/hooks/use-color-scheme.web.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useColorScheme as useRNColorScheme } from 'react-native';
-
-/**
- * To support static rendering, this value needs to be re-calculated on the client side for web
- */
-export function useColorScheme() {
- const [hasHydrated, setHasHydrated] = useState(false);
-
- useEffect(() => {
- setHasHydrated(true);
- }, []);
-
- const colorScheme = useRNColorScheme();
-
- if (hasHydrated) {
- return colorScheme;
- }
-
- return 'light';
-}
diff --git a/apps/mobile/src/hooks/use-theme.ts b/apps/mobile/src/hooks/use-theme.ts
deleted file mode 100644
index 677e015..0000000
--- a/apps/mobile/src/hooks/use-theme.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * Learn more about light and dark modes:
- * https://docs.expo.dev/guides/color-schemes/
- */
-
-import { Colors } from '@/constants/theme';
-import { useColorScheme } from '@/hooks/use-color-scheme';
-
-export function useTheme() {
- const scheme = useColorScheme();
- const theme = scheme === 'unspecified' ? 'light' : scheme;
-
- return Colors[theme];
-}
diff --git a/apps/mobile/src/ui/index.tsx b/apps/mobile/src/ui/index.tsx
new file mode 100644
index 0000000..a37cc74
--- /dev/null
+++ b/apps/mobile/src/ui/index.tsx
@@ -0,0 +1,255 @@
+/**
+ * UI wrapper (PLAN-02). Primary surface = these plain-RN, native-iOS-styled
+ * primitives — they render in Expo Go. `@expo/ui` (SwiftUI) ships native code
+ * and CRASHES Expo Go if statically imported, so it is lazy-required behind a
+ * guard and exposed only as an opt-in enhancement for dev/standalone builds.
+ * Phase-1 screens MUST use these primitives, never import `@expo/ui` directly.
+ */
+import React from 'react';
+import {
+ ActivityIndicator,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Switch,
+ Text,
+ TextInput,
+ View,
+ useColorScheme,
+ type TextInputProps,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { Stack } from 'expo-router';
+import Constants from 'expo-constants';
+
+import { Colors, Fonts, Spacing } from '@/constants/theme';
+
+export type Palette = Record;
+
+export function useThemeColors(): Palette {
+ return (useColorScheme() === 'dark' ? Colors.dark : Colors.light) as Palette;
+}
+
+// --- @expo/ui capability shim ---------------------------------------------
+export const isExpoGo = Constants.appOwnership === 'expo';
+let _swiftUI: typeof import('@expo/ui/swift-ui') | null = null;
+if (!isExpoGo) {
+ try {
+ _swiftUI = require('@expo/ui/swift-ui');
+ } catch {
+ _swiftUI = null;
+ }
+}
+/** Opt-in SwiftUI surface. `available` is false in Expo Go — always branch on it. */
+export const nativeUI = { available: !!_swiftUI, swiftUI: _swiftUI } as const;
+
+// --- primitives ------------------------------------------------------------
+export function Screen({
+ children,
+ scroll,
+}: {
+ children: React.ReactNode;
+ scroll?: boolean;
+}) {
+ const c = useThemeColors();
+ const Body: React.ElementType = scroll ? ScrollView : View;
+ // The native Stack header (or formSheet chrome) owns the top inset now, so
+ // Screen only guards the bottom (home indicator). `automatic` content inset
+ // lets iOS large titles collapse correctly on scroll.
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * Bridges into the native UIKit Stack header instead of drawing a second bar.
+ * Screens keep calling ``; it now sets the real
+ * native header title + headerRight (large titles, blur, back-swipe come free).
+ */
+export function AppBar({ title, right }: { title: string; right?: React.ReactNode }) {
+ return (
+ <>{right}> : undefined,
+ }}
+ />
+ );
+}
+
+export function Section({
+ title,
+ children,
+}: {
+ title?: string;
+ children: React.ReactNode;
+}) {
+ const c = useThemeColors();
+ return (
+
+ {title ? (
+ {title.toUpperCase()}
+ ) : null}
+ {children}
+
+ );
+}
+
+export function Row({
+ children,
+ onPress,
+ last,
+}: {
+ children: React.ReactNode;
+ onPress?: () => void;
+ last?: boolean;
+}) {
+ const c = useThemeColors();
+ const inner = (
+
+ {children}
+
+ );
+ return onPress ? (
+
+ {inner}
+
+ ) : (
+ inner
+ );
+}
+
+export function ListItem({
+ title,
+ subtitle,
+ trailing,
+ onPress,
+ last,
+}: {
+ title: string;
+ subtitle?: string;
+ trailing?: React.ReactNode;
+ onPress?: () => void;
+ last?: boolean;
+}) {
+ const c = useThemeColors();
+ return (
+
+
+
+ {title}
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+ {trailing}
+
+ );
+}
+
+export function TextField(props: TextInputProps & { label?: string }) {
+ const c = useThemeColors();
+ const { label, style, ...rest } = props;
+ return (
+
+ {label ? {label} : null}
+
+
+ );
+}
+
+export function Toggle({ value, onValueChange }: { value: boolean; onValueChange: (v: boolean) => void }) {
+ return ;
+}
+
+export function Button({
+ title,
+ onPress,
+ variant = 'primary',
+ disabled,
+}: {
+ title: string;
+ onPress: () => void;
+ variant?: 'primary' | 'ghost' | 'destructive';
+ disabled?: boolean;
+}) {
+ const c = useThemeColors();
+ const bg =
+ variant === 'primary' ? '#208AEF' : variant === 'destructive' ? '#E5484D' : 'transparent';
+ const fg = variant === 'ghost' ? '#208AEF' : '#ffffff';
+ return (
+
+ {title}
+
+ );
+}
+
+export function Separator() {
+ const c = useThemeColors();
+ return ;
+}
+
+export function Badge({ text, tone = 'neutral' }: { text: string; tone?: 'neutral' | 'good' | 'warn' | 'bad' }) {
+ const map = { neutral: '#8E8E93', good: '#30A46C', warn: '#F5A623', bad: '#E5484D' };
+ return (
+
+ {text}
+
+ );
+}
+
+export function EmptyState({ title, hint }: { title: string; hint?: string }) {
+ const c = useThemeColors();
+ return (
+
+ {title}
+ {hint ? {hint} : null}
+
+ );
+}
+
+export function Loading({ label }: { label?: string }) {
+ const c = useThemeColors();
+ return (
+
+
+ {label ? {label} : null}
+
+ );
+}
+
+const s = StyleSheet.create({
+ flex: { flex: 1 },
+ scrollPad: { paddingBottom: Spacing.six },
+ section: { paddingHorizontal: Spacing.three, paddingTop: Spacing.four },
+ sectionTitle: { fontSize: 12, fontWeight: '600', marginBottom: Spacing.two, marginLeft: Spacing.two },
+ card: { borderRadius: 12, overflow: 'hidden' },
+ row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: Spacing.three, paddingVertical: Spacing.three, gap: Spacing.three },
+ itemTitle: { fontSize: 16, fontWeight: '500', fontFamily: Fonts.sans },
+ itemSub: { fontSize: 13, marginTop: 2, fontFamily: Fonts.sans },
+ field: { paddingHorizontal: Spacing.three, paddingTop: Spacing.three },
+ label: { fontSize: 13, marginBottom: Spacing.two, marginLeft: Spacing.two },
+ input: { borderRadius: 10, paddingHorizontal: Spacing.three, paddingVertical: Spacing.three, fontSize: 16, fontFamily: Fonts.sans },
+ btn: { borderRadius: 12, paddingVertical: Spacing.three, alignItems: 'center', marginHorizontal: Spacing.three, marginTop: Spacing.four },
+ btnGhost: { borderWidth: StyleSheet.hairlineWidth, borderColor: '#208AEF' },
+ btnText: { fontSize: 16, fontWeight: '600', fontFamily: Fonts.sans },
+ badge: { borderRadius: 6, borderWidth: StyleSheet.hairlineWidth, paddingHorizontal: 8, paddingVertical: 2, alignSelf: 'flex-start' },
+ badgeText: { fontSize: 12, fontWeight: '600' },
+ empty: { alignItems: 'center', justifyContent: 'center', padding: Spacing.six, gap: Spacing.two, flexGrow: 1 },
+ emptyTitle: { fontSize: 17, fontWeight: '600', fontFamily: Fonts.sans },
+});
diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json
index 2e9a669..97ea785 100644
--- a/apps/mobile/tsconfig.json
+++ b/apps/mobile/tsconfig.json
@@ -8,6 +8,9 @@
],
"@/assets/*": [
"./assets/*"
+ ],
+ "@constructor/protocol": [
+ "../../packages/protocol/src/index.ts"
]
}
},
diff --git a/docs/runbooks/dev-and-eas.md b/docs/runbooks/dev-and-eas.md
new file mode 100644
index 0000000..92c8c28
--- /dev/null
+++ b/docs/runbooks/dev-and-eas.md
@@ -0,0 +1,127 @@
+# Runbook — Running, TestFlight, OTA, Metadata
+
+Branch `build/mobile-ui`. App: `apps/mobile` (Expo SDK 55, pnpm monorepo).
+Bundle ID: `dev.nejc.constructor`. EAS owner: **your personal Expo account**.
+Runtime version policy: **`appVersion`** (tied to `expo.version`; see §4 for why).
+
+---
+
+## 1. Quick look — Expo Go (mock preview only)
+
+```bash
+cd apps/mobile && pnpm start # --tunnel if phone/Mac not on same Wi-Fi
+```
+Throwaway preview, never the deliverable: Expo Go is a precompiled sandbox — ignores
+most of `app.json`, can't run custom native code (`@expo/ui` SwiftUI doesn't render),
+doesn't reflect production. The real artifact is the TestFlight build (§3). Mock data;
+profiles persist via expo-sqlite/kv-store.
+
+## 2. First-time EAS setup (personal account)
+
+`app.json` doesn't pin `owner`/`projectId`/`updates.url` (they were the `refrakts`
+project's). Create the project under your account:
+
+```bash
+cd apps/mobile
+npx eas-cli@latest login # your personal Expo account
+npx eas-cli@latest init # new project under your account; writes extra.eas.projectId (+ owner)
+npx eas-cli@latest update:configure # adds updates.url for the new project
+ # (leaves runtimeVersion: appVersion as-is)
+```
+Then **commit the regenerated `app.json`** (`projectId`/`updates.url` are not secrets).
+
+## 3. TestFlight (release build, not public App Store)
+
+A TestFlight build is a **store/release build**: it runs the *embedded* JS bundle
+(no Metro / no `pnpm start`) and is updated via OTA (§4). Apple account active;
+`infoPlist.ITSAppUsesNonExemptEncryption=false` auto-answers export compliance.
+
+```bash
+cd apps/mobile
+npx eas-cli@latest build --profile production --platform ios # store-signed; build # auto-increments
+npx eas-cli@latest submit --profile production --platform ios # → App Store Connect → TestFlight
+```
+- Choose **"Let EAS manage credentials."** `eas submit` offers to **create the App
+ Store Connect app record** if missing — expected; it does **not** publish to the
+ public App Store (TestFlight only).
+- Apple processes ~5–15 min → **App Store Connect → TestFlight** → add **Internal
+ Testers** (≤100, immediate, no review) / External (first build ~1-day review).
+- Monorepo handled: run from `apps/mobile`; EAS uploads the git repo from root
+ (`packages/protocol` included, `.upstream/` gitignored/not uploaded), pinned pnpm
+ via root `packageManager`.
+
+### Optional: dev client for fast local iteration
+```bash
+npx eas-cli@latest device:create # register iPhone (one-time)
+npx eas-cli@latest build --profile development --platform ios # dev client
+# install via QR, then: cd apps/mobile && pnpm start # JS over Metro, full SwiftUI
+```
+
+## 4. OTA updates — `appVersion` policy
+
+`runtimeVersion.policy = "appVersion"` → **runtimeVersion = `expo.version`** (today
+`1.0.0`). It is embedded in every build (TestFlight, dev, internal). Push JS/asset
+changes with **no new build**:
+
+```bash
+cd apps/mobile
+npx eas-cli@latest update --branch production --environment production -m "what changed"
+# first time, if not auto-linked: eas channel:edit production --branch production
+```
+- Testers get it on the **next cold launch** (downloads in background, applies next
+ launch — Apple permits this for TestFlight/App Store).
+- **`--environment` is required on SDK 55** or it errors.
+- **Native-change rule (replaces the old fingerprint auto-detect):** an OTA update
+ only reaches builds with the **same `expo.version`**. When you make a *native*
+ change (new/upgraded native module, native config), bump `expo.version`
+ (`1.0.0` → `1.0.1`) **and** ship a new TestFlight build. JS/asset-only changes ship
+ via `eas update` to the current version with no rebuild. Discipline is manual but
+ explicit; you rebuild for native changes anyway.
+- Rollout/rollback (M5): `--rollout-percentage`, `update:edit`,
+ `update:revert-update-rollout`. One active rollout per branch/channel.
+
+> **Why not the `fingerprint` policy?** It was the original choice (auto-detects
+> native changes) but it computes the runtime version *differently on macOS-local
+> vs the EAS Linux builder* — EAS prebuild adds an `ios/` `bareNativeDir` and native
+> autolinking dir hashes (reanimated/worklets/screens/safe-area-context) differ
+> across environments — which **failed every build** with a "Runtime version
+> mismatch" in pnpm-monorepo + CNG. `appVersion` is deterministic, keeps managed/CNG,
+> and still fully supports OTA. (Revisit fingerprint only if Expo improves
+> cross-environment determinism for pnpm monorepos.)
+
+## 5. EAS Metadata (optional — App Store listing as code)
+
+`eas metadata` manages **App Store Connect listing metadata** via `store.config.json`
+(iOS only). **Not needed for internal TestFlight**; matters for *external* TestFlight
+(beta review) and a future App Store. After the first `eas submit` creates the ASC
+app: `eas metadata:pull` (scaffolds a schema-correct `store.config.json` — listing
+text only, **no secrets, safe to commit**) → edit → `eas metadata:push`. Don't
+hand-author it. Re-`pull` after dashboard edits to avoid overwrites.
+
+## 6. Credentials & gitignore
+
+**Nothing leaks by default.** EAS-managed (remote) credentials — the default we use —
+keep the APNs push key, distribution cert, and provisioning profile **on Expo's
+servers, not in this repo**; `eas build` writes no secrets to the tree.
+`apps/mobile/.gitignore` already ignores `*.p8 *.p12 *.mobileprovision *.key *.pem
+*.jks` and `/ios /android`; `credentials.json` is added defensively (only present if
+you opt into *local* credentials). No action needed.
+
+## 7. EAS Insights (usage analytics)
+
+`expo-insights` is installed. After a build it auto-reports app **cold-start
+usage** (over time, by platform, by app-store version) to the Expo dashboard —
+no code, no `app.json`/plugin change (autolinked on SDK 55), no secrets. View at
+expo.dev → project `mobile` → **Insights**. Free preview.
+
+**Requires a native build — NOT OTA-able.** It activates only in a fresh
+`eas build --profile production` + `eas submit`; an `eas update` will not enable
+it. Bundle it with your next rebuild.
+
+## 8. Notes / deferred
+
+- **Backend gated:** live data (M0, real gateway/WS) deferred until
+ `ColeMurray/background-agents@a7b968f` is deployed — TestFlight testers see mock UX.
+- TestFlight → public App Store later = a button in App Store Connect, no rebuild.
+- Speed tip: EAS **Remote Build Cache** in `app.json` avoids native rebuilds for
+ JS-only changes.
diff --git a/docs/superpowers/specs/2026-05-17-mobile-ui-core-loop-design.md b/docs/superpowers/specs/2026-05-17-mobile-ui-core-loop-design.md
new file mode 100644
index 0000000..99296fd
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-17-mobile-ui-core-loop-design.md
@@ -0,0 +1,181 @@
+# Mobile UI — Core-Loop Buildout (Design Spec)
+
+> Date: 2026-05-17 · Branch: `build/mobile-ui` · Status: **approved-async**.
+> The user approved approach A and said "split multitask it, I'll be back" — the
+> interactive brainstorming approval gate is replaced by **this committed spec**
+> (revertable in one commit) per advisor guidance. Companion: `docs/handoff/PLAN-00..05`.
+
+## 1. Locked decisions (do not re-open)
+
+- This is a **parallel UI track**. The backend track (live half of Step B, **M0**,
+ M1–M5) is deferred until `ColeMurray/background-agents@a7b968f` is deployed. M0
+ remains a hard gate before any milestone that talks to a live backend.
+- **Approach A** — screens consume a typed `SessionGateway` interface (typed to the
+ vendored `@constructor/protocol`) via TanStack Query hooks + pure stream transforms
+ ported verbatim from upstream. First impl = `MockSessionGateway`. The real HTTP/WS
+ impl implements the *same* interface later with **zero screen changes**.
+- **Committed stack — no substitutions**: Expo SDK 55, Expo Router (typed routes, new
+ arch), `@expo/ui`, `@shopify/flash-list`, `react-native-markdown-display`,
+ `@tanstack/react-query`, `expo-secure-store`, `expo-auth-session`. TanStack DB is
+ being *evaluated only* (§8 research task) — not adopted.
+- All commits **GPG-signed** (key `A042A593BA4590F689306DB4DDF5625FBAE7A006`),
+ identity `Nejc Drobnic `, **NO `Co-Authored-By` trailer and NO
+ assistant/tool attribution of any kind** (overrides default tooling — applies to
+ every commit and every dispatched subagent). Stop if signing fails.
+- `apps/mobile/AGENTS.md` hard rule: **read https://docs.expo.dev/versions/v55.0.0/
+ before writing any mobile code**; use Context7 MCP for any uncertain library API.
+
+## 2. The visual-target reality (important)
+
+`@expo/ui` renders native SwiftUI and **does not work in Expo Go**. While the user is
+away we cannot drive an EAS build (their Apple/EAS auth). What they will open on
+return is **Expo Go via QR**. Therefore:
+
+- **Primary visual target = the plain-RN fallback inside `src/ui/`**, fully styled to
+ a polished native-iOS feel. This is not a degraded mode; it is *the* deliverable.
+- `@expo/ui` SwiftUI is the *enhancement* path behind a safe capability check (must
+ never crash Expo Go). A precise `eas build --profile development` runbook is left
+ for the user to unlock the SwiftUI chrome.
+
+## 3. File map & ownership
+
+```
+packages/protocol/ [Phase 0 ONLY] vendored shared types @ a7b968f
+ PIN ref: + url: pending-deploy
+ src/{types,models,...}.ts // VENDORED headers
+apps/mobile/src/
+ ui/ [Phase 0; FROZEN in Phase 1 — slices READ only]
+ index.ts, primitives, theme glue, @expo/ui capability shim
+ data/ [Phase 0; FROZEN in Phase 1 — slices READ only]
+ gateway.ts SessionGateway interface (typed to @constructor/protocol)
+ provider.tsx GatewayProvider + QueryClient
+ queries.ts TanStack Query hooks (keyed by activeProfileId)
+ mock/{fixtures,emitter,mock-gateway}.ts
+ features/sessions/stream/transforms.ts [Phase 0] PORTED pure transforms (marker)
+ features//** [Phase 1 — exactly one slice agent owns each]
+ app/** [Phase 0 pre-creates thin route wrappers]
+ constants/theme.ts [Phase 0; FROZEN in Phase 1]
+```
+
+**Ownership rules (parallelization safety):**
+
+1. **Phase 0 owns ALL `package.json` / `pnpm-lock.yaml` edits.** A slice agent that
+ thinks it needs a new dependency **HALTS and reports** — never `pnpm add` in a slice.
+2. Route files under `src/app/` are pre-created in Phase 0 as thin wrappers that
+ `import` from `src/features//screen.tsx`. Slice agents edit **only**
+ `src/features//**`.
+3. **Frozen during Phase 1** (slice agents READ, never EDIT): `src/ui/**`,
+ `src/constants/theme.ts`, `src/data/**`, `features/sessions/stream/transforms.ts`,
+ `packages/protocol/**`, `src/app/**`.
+4. No cross-slice imports. If the shared contract feels too thin, the slice agent
+ **HALTS and reports** rather than widening it locally.
+
+## 4. `SessionGateway` contract (Phase 0 owns; reconcile to vendored names)
+
+Conceptual shape (exact protocol type names confirmed when vendored in Phase 0; the
+interface is Phase-0-owned and reconciled there before fan-out):
+
+```ts
+import type {
+ Session, SessionState, SandboxEvent, CreateSessionRequest,
+} from "@constructor/protocol";
+
+export type StreamHandle = { unsubscribe(): void };
+
+export interface SessionGateway {
+ listSessions(): Promise;
+ getSession(id: string): Promise;
+ createSession(req: CreateSessionRequest): Promise<{ sessionId: string }>;
+ /** Mirrors real DO: emits `subscribed` (state + replay{events,hasMore,cursor})
+ * then a stream of SandboxEvent, then terminal status. */
+ subscribe(id: string, on: (e: SandboxEvent) => void): StreamHandle;
+ sendFollowUp(id: string, text: string): Promise;
+ stop(id: string): Promise;
+}
+```
+
+Screens never touch the gateway directly — only via `data/queries.ts` hooks
+(`useSessions`, `useSession`, `useCreateSession`, `useSessionStream`, …), all keyed by
+`activeProfileId` so profiles never bleed (PLAN-02).
+
+## 5. Mock contract & scripted scenario
+
+The mock obeys the **real subscribe shape** so screens behave as the real Durable
+Object will (no post-M0 rework): `subscribe` → `{ type:"subscribed", state,
+replay:{events,hasMore,cursor} }` → ordered `SandboxEvent` stream → terminal.
+
+**Scenario A (happy path)** — bind discriminant names to the vendored union in Phase 0:
+
+1. `subscribed` with `state.status = "running"`, `replay.events = []`, `hasMore:false`.
+2. user prompt echoed → assistant "thinking" indicator.
+3. `tool_call` started: `write_file src/App.tsx` → `tool_output` (success).
+4. token stream for one `messageId`: ~20 token events, **cumulative live / coalesced
+ on replay** (transforms.ts handles folding) rendered as growing markdown.
+5. `message` finalized for that `messageId`.
+6. terminal `status = "completed"`.
+
+**Scenario B (short)**: prompt → tool error → `status = "error"` (drives the error UI).
+
+Emitter replays Scenario A by default with realistic 40–120 ms inter-token timing so
+FlashList streaming/`maintainVisibleContentPosition` is exercised.
+
+## 6. Slices (Phase 1 — one agent each, disjoint dirs)
+
+| Slice | Route | Feature dir | Gateway/hooks | Required states |
+|---|---|---|---|---|
+| Profiles/Settings | `/(settings)` | `features/profiles` | profile store (AsyncStorage) + secure-store stub | empty/first-run, list, add/edit/delete, set-active |
+| Sign-in shell | `/sign-in` | `features/auth` | mock auth toggle (no real OAuth) | signed-out, in-progress, signed-in |
+| Session list | `/(app)/index` | `features/sessions/list` | `useSessions` | loading, empty, populated, error, pull-to-refresh |
+| Create session | `/(app)/new` | `features/sessions/create` | `useCreateSession`, `VALID_MODELS` | form, validation, submitting, success→navigate |
+| Session detail / stream | `/(app)/s/[id]` | `features/sessions/detail` | `useSession` + `useSessionStream` | connecting, replay, live stream, follow-up composer, stop, completed/error |
+
+All slices: native-iOS feel via `src/ui` wrapper (never import `@expo/ui` directly),
+light/dark via `constants/theme.ts`, typecheck-clean for their files, polished empty/
+loading/error states (no raw spinners-on-white).
+
+## 7. Phases
+
+- **Phase 0 (sequential, parent):** branch ✓ → vendor `@constructor/protocol`
+ @ `a7b968f` (clone, resolve full 40-char SHA, copy pure subset, `// VENDORED`
+ headers, structured `PIN`: `ref: background-agents@` + `url:
+ pending-deploy`) → `src/ui` wrapper (RN-primary + `@expo/ui` shim) → data layer
+ (gateway, mock, emitter, provider, queries) → port pure transforms (marker:
+ `// Ported from background-agents/packages/web/src/hooks/use-session-socket.ts:63-99,227-274 @ `)
+ → Expo Router shell + theme + route stubs. **EXIT GATE: `pnpm -w typecheck` clean
+ AND `expo export`/Metro bundle succeeds.** Signed commit. *Do not dispatch Phase 1
+ until the exit gate is green.*
+- **Phase 1 (parallel):** 5 slice agents (disjoint dirs, rules §3) + 1 read-only
+ TanStack DB research agent. Each gets AGENTS.md verbatim + this spec.
+- **Phase 2:** integrate, `pnpm -w typecheck` + `expo lint`, confirm Expo-Go
+ loadable, write EAS dev-build runbook, signed commits, verification-before-
+ completion, written status report for the user's return.
+
+## 8. TanStack DB research (read-only, no edits)
+
+Evaluate https://tanstack.com/db/latest/docs/overview#react-native — output: (a) RN
+support reality, (b) fit with socket-stream + protocol-typed + mock-seam architecture,
+(c) cost-to-adopt vs current TanStack Query, (d) what Query already gives us that DB
+doesn't. **Do not adopt** (committed stack) — surface a recommendation for user decision.
+
+## 9. Success criteria
+
+- App loads in **Expo Go via QR**; core loop navigable end-to-end on mock data;
+ polished native-feel RN UI; light + dark.
+- `@expo/ui` SwiftUI path present behind dev build; runbook provided.
+- `pnpm -w typecheck` and `expo lint` clean.
+- `packages/protocol` vendored @ `a7b968f`, structured `PIN` (`url: pending-deploy`).
+- Every commit signed + verified (`git log --show-signature`), no attribution trailer.
+
+## 10. Deferred / NOT in scope
+
+M0 + live contract assertions, `PIN` url, real OAuth/auth, real network/WS transport,
+history/resume, repo secrets, automations, screenshots, push. (Later slices / gated on
+the `a7b968f` deployment.)
+
+## 11. Open risks
+
+- `@expo/ui` alpha coverage → mitigated: RN-primary wrapper, one-file swaps.
+- Vendored protocol type names may differ from handoff sketches → Phase 0 reconciles
+ the `SessionGateway`/transforms against the real union *before* fan-out.
+- Mock scenario must match the real DO contract shape so screens need no post-M0
+ rework → emitter mirrors `subscribed`/`replay` exactly.
diff --git a/package.json b/package.json
index 054a674..903da4c 100644
--- a/package.json
+++ b/package.json
@@ -1 +1,5 @@
-{"name":"constructor-mobile","private":true}
+{
+ "name": "constructor-mobile",
+ "private": true,
+ "packageManager": "pnpm@10.28.2"
+}
diff --git a/packages/protocol/PIN b/packages/protocol/PIN
new file mode 100644
index 0000000..6e1674c
--- /dev/null
+++ b/packages/protocol/PIN
@@ -0,0 +1,2 @@
+ref: background-agents@a7b968f3dfc7ff4d3d92fc158d57834c100e453c
+url: pending-deploy
diff --git a/packages/protocol/src/git.ts b/packages/protocol/src/git.ts
new file mode 100644
index 0000000..5f9c23f
--- /dev/null
+++ b/packages/protocol/src/git.ts
@@ -0,0 +1,51 @@
+// VENDORED from background-agents@a7b968f3dfc7ff4d3d92fc158d57834c100e453c :: packages/shared/src/git.ts
+// Do not edit. Re-vendor via scripts/vendor-protocol.sh. Pinned: packages/protocol/PIN
+/**
+ * Git utilities for branch management.
+ */
+
+/**
+ * Branch naming convention for Open-Inspect sessions.
+ */
+export const BRANCH_PREFIX = "open-inspect";
+
+/**
+ * Normalize a git branch name for consistent Open-Inspect branch handling.
+ */
+export function normalizeBranchName(branchName: string): string {
+ return branchName.trim().toLowerCase();
+}
+
+/**
+ * Generate a branch name for a session.
+ *
+ * @param sessionId - Session ID
+ * @param title - Optional title for the branch
+ * @returns Branch name in format: open-inspect/{session-id}
+ */
+export function generateBranchName(sessionId: string, _title?: string): string {
+ // Use just session ID to keep it short and unique
+ return normalizeBranchName(`${BRANCH_PREFIX}/${sessionId}`);
+}
+
+/**
+ * Extract session ID from a branch name.
+ *
+ * @param branchName - Branch name
+ * @returns Session ID or null if not an Open-Inspect branch
+ */
+export function extractSessionIdFromBranch(branchName: string): string | null {
+ const prefix = `${BRANCH_PREFIX}/`;
+ const normalizedBranchName = normalizeBranchName(branchName);
+ if (!normalizedBranchName.startsWith(prefix)) {
+ return null;
+ }
+ return normalizedBranchName.slice(prefix.length);
+}
+
+/**
+ * Check if a branch name is an Open-Inspect branch.
+ */
+export function isInspectBranch(branchName: string): boolean {
+ return normalizeBranchName(branchName).startsWith(`${BRANCH_PREFIX}/`);
+}
diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts
new file mode 100644
index 0000000..b349a9d
--- /dev/null
+++ b/packages/protocol/src/index.ts
@@ -0,0 +1,4 @@
+// GENERATED by scripts/vendor-protocol.sh — do not edit. Pinned: packages/protocol/PIN
+export * from "./types";
+export * from "./models";
+export * from "./git";
diff --git a/packages/protocol/src/integrations.ts b/packages/protocol/src/integrations.ts
new file mode 100644
index 0000000..e5770a2
--- /dev/null
+++ b/packages/protocol/src/integrations.ts
@@ -0,0 +1,139 @@
+// VENDORED from background-agents@a7b968f3dfc7ff4d3d92fc158d57834c100e453c :: packages/shared/src/types/integrations.ts
+// Do not edit. Re-vendor via scripts/vendor-protocol.sh. Pinned: packages/protocol/PIN
+// Integration settings types
+
+export type IntegrationId = "github" | "linear" | "code-server" | "sandbox" | "slack";
+
+/** Enforces the common shape for all integration configurations. */
+export interface IntegrationEntry<
+ TRepo extends object = Record,
+ TGlobalDefaults extends object = TRepo,
+> {
+ global: {
+ enabledRepos?: string[];
+ defaults?: TGlobalDefaults;
+ };
+ repo: TRepo;
+}
+
+/** Overridable behavior settings for the GitHub bot. Used at both global (defaults) and per-repo (overrides) levels. */
+export interface GitHubBotSettings {
+ autoReviewOnOpen?: boolean;
+ model?: string;
+ reasoningEffort?: string;
+ allowedTriggerUsers?: string[];
+ codeReviewInstructions?: string;
+ commentActionInstructions?: string;
+}
+
+/** Overridable behavior settings for the Linear bot. Used at both global (defaults) and per-repo (overrides) levels. */
+export interface LinearBotSettings {
+ model?: string;
+ reasoningEffort?: string;
+ allowUserPreferenceOverride?: boolean;
+ allowLabelModelOverride?: boolean;
+ emitToolProgressActivities?: boolean;
+ issueSessionInstructions?: string;
+}
+
+/** Overridable behavior settings for the code-server integration. */
+export interface CodeServerSettings {
+ enabled?: boolean;
+}
+
+/** Maximum number of tunnel ports a user can configure per sandbox. */
+export const MAX_TUNNEL_PORTS = 10;
+
+/** Sandbox environment settings. Provider-agnostic: describes what the user wants, not how it's done. */
+export interface SandboxSettings {
+ /** Extra ports to expose via tunnels (e.g., dev server ports 3000, 5173). */
+ tunnelPorts?: number[];
+ /** Enable a browser-based terminal (ttyd) in sandbox sessions. */
+ terminalEnabled?: boolean;
+}
+
+export type SlackMentionsPolicy = "allow" | "escape" | "strip";
+
+/** Per-repo Slack overrides. Mentions policy is workspace-wide and cannot be overridden per repo. */
+export interface SlackRepoSettings {
+ agentNotificationsEnabled?: boolean;
+}
+
+/** Global Slack defaults: per-repo fields plus workspace-wide policy controls. */
+export interface SlackGlobalSettings extends SlackRepoSettings {
+ mentionsPolicy?: SlackMentionsPolicy;
+}
+
+/** Maps each integration ID to its global and per-repo settings types. */
+export interface IntegrationSettingsMap {
+ github: IntegrationEntry;
+ linear: IntegrationEntry;
+ "code-server": IntegrationEntry;
+ sandbox: IntegrationEntry;
+ slack: IntegrationEntry;
+}
+
+/** Derived type for the GitHub bot global config. */
+export type GitHubGlobalConfig = IntegrationSettingsMap["github"]["global"];
+export type LinearGlobalConfig = IntegrationSettingsMap["linear"]["global"];
+export type CodeServerGlobalConfig = IntegrationSettingsMap["code-server"]["global"];
+export type SandboxGlobalConfig = IntegrationSettingsMap["sandbox"]["global"];
+export type SlackGlobalConfig = IntegrationSettingsMap["slack"]["global"];
+
+/** Full MCP server config with decrypted credentials. Internal use only. */
+export interface McpServerConfig {
+ id: string;
+ name: string;
+ type: "local" | "remote";
+ command?: string[];
+ url?: string;
+ env?: Record;
+ headers?: Record;
+ repoScopes?: string[] | null;
+ enabled: boolean;
+}
+
+/** MCP server metadata for API responses — no decrypted credentials. */
+export interface McpServerMetadata {
+ id: string;
+ name: string;
+ type: "local" | "remote";
+ command?: string[];
+ url?: string;
+ hasEnv: boolean;
+ hasHeaders: boolean;
+ repoScopes?: string[] | null;
+ enabled: boolean;
+}
+
+export const INTEGRATION_DEFINITIONS: {
+ id: IntegrationId;
+ name: string;
+ description: string;
+}[] = [
+ {
+ id: "github",
+ name: "GitHub Bot",
+ description: "Automated PR reviews and comment-triggered actions",
+ },
+ {
+ id: "linear",
+ name: "Linear Agent",
+ description: "Issue-driven coding sessions from Linear agent mentions",
+ },
+ {
+ id: "code-server",
+ name: "Code Server",
+ description: "Browser-based VS Code editor attached to sandbox sessions",
+ },
+ {
+ id: "sandbox",
+ name: "Sandbox",
+ description: "Sandbox environment settings (tunnel ports, timeouts, etc.)",
+ },
+ {
+ id: "slack",
+ name: "Slack",
+ description: "Agent-driven Slack notifications and mention policy",
+ },
+];
diff --git a/packages/protocol/src/models.ts b/packages/protocol/src/models.ts
new file mode 100644
index 0000000..924a8ad
--- /dev/null
+++ b/packages/protocol/src/models.ts
@@ -0,0 +1,252 @@
+// VENDORED from background-agents@a7b968f3dfc7ff4d3d92fc158d57834c100e453c :: packages/shared/src/models.ts
+// Do not edit. Re-vendor via scripts/vendor-protocol.sh. Pinned: packages/protocol/PIN
+/**
+ * Centralized model definitions and reasoning configuration.
+ *
+ * All packages import model-related types and validation from here
+ * to ensure consistent behavior across control plane, web UI, and Slack bot.
+ */
+
+/**
+ * Valid model names supported by the system.
+ * All models use "provider/model" format.
+ */
+export const VALID_MODELS = [
+ "anthropic/claude-haiku-4-5",
+ "anthropic/claude-sonnet-4-5",
+ "anthropic/claude-sonnet-4-6",
+ "anthropic/claude-opus-4-5",
+ "anthropic/claude-opus-4-6",
+ "anthropic/claude-opus-4-7",
+ "openai/gpt-5.2",
+ "openai/gpt-5.4",
+ "openai/gpt-5.5",
+ "openai/gpt-5.2-codex",
+ "openai/gpt-5.3-codex",
+ "openai/gpt-5.3-codex-spark",
+ "opencode/kimi-k2.5",
+ "opencode/minimax-m2.5",
+ "opencode/glm-5",
+] as const;
+
+export type ValidModel = (typeof VALID_MODELS)[number];
+
+/**
+ * Default model to use when none specified or invalid.
+ */
+export const DEFAULT_MODEL: ValidModel = "anthropic/claude-sonnet-4-6";
+
+/**
+ * Reasoning effort levels supported across providers.
+ *
+ * - "none": No reasoning (OpenAI only)
+ * - "low"/"medium"/"high"/"xhigh": Progressive reasoning depth
+ * - "max": Maximum reasoning budget (Anthropic extended thinking)
+ */
+export type ReasoningEffort = "none" | "low" | "medium" | "high" | "xhigh" | "max";
+
+export interface ModelReasoningConfig {
+ efforts: ReasoningEffort[];
+ default: ReasoningEffort | undefined;
+}
+
+/**
+ * Per-model reasoning configuration.
+ * Models not listed here do not support reasoning controls.
+ */
+export const MODEL_REASONING_CONFIG: Partial> = {
+ "anthropic/claude-haiku-4-5": { efforts: ["high", "max"], default: "max" },
+ "anthropic/claude-sonnet-4-5": { efforts: ["high", "max"], default: "max" },
+ "anthropic/claude-sonnet-4-6": { efforts: ["low", "medium", "high", "max"], default: "high" },
+ "anthropic/claude-opus-4-5": { efforts: ["high", "max"], default: "max" },
+ "anthropic/claude-opus-4-6": { efforts: ["low", "medium", "high", "max"], default: "high" },
+ "anthropic/claude-opus-4-7": { efforts: ["low", "medium", "high", "max"], default: "high" },
+ "openai/gpt-5.2": { efforts: ["none", "low", "medium", "high", "xhigh"], default: undefined },
+ "openai/gpt-5.4": { efforts: ["none", "low", "medium", "high", "xhigh"], default: undefined },
+ "openai/gpt-5.5": { efforts: ["none", "low", "medium", "high", "xhigh"], default: undefined },
+ "openai/gpt-5.2-codex": { efforts: ["low", "medium", "high", "xhigh"], default: "high" },
+ "openai/gpt-5.3-codex": { efforts: ["low", "medium", "high", "xhigh"], default: "high" },
+ "openai/gpt-5.3-codex-spark": { efforts: ["low", "medium", "high", "xhigh"], default: "high" },
+};
+
+export interface ModelDisplayInfo {
+ id: ValidModel;
+ name: string;
+ description: string;
+}
+
+export interface ModelCategory {
+ category: string;
+ models: ModelDisplayInfo[];
+}
+
+/**
+ * Model options grouped by provider, for use in UI dropdowns.
+ */
+export const MODEL_OPTIONS: ModelCategory[] = [
+ {
+ category: "Anthropic",
+ models: [
+ {
+ id: "anthropic/claude-haiku-4-5",
+ name: "Claude Haiku 4.5",
+ description: "Fast and efficient",
+ },
+ {
+ id: "anthropic/claude-sonnet-4-5",
+ name: "Claude Sonnet 4.5",
+ description: "Balanced performance",
+ },
+ {
+ id: "anthropic/claude-sonnet-4-6",
+ name: "Claude Sonnet 4.6",
+ description: "Latest balanced, fast coding",
+ },
+ {
+ id: "anthropic/claude-opus-4-5",
+ name: "Claude Opus 4.5",
+ description: "Most capable",
+ },
+ {
+ id: "anthropic/claude-opus-4-6",
+ name: "Claude Opus 4.6",
+ description: "Most capable, adaptive thinking",
+ },
+ {
+ id: "anthropic/claude-opus-4-7",
+ name: "Claude Opus 4.7",
+ description: "Latest, most capable",
+ },
+ ],
+ },
+ {
+ category: "OpenAI",
+ models: [
+ { id: "openai/gpt-5.2", name: "GPT 5.2", description: "400K context, fast" },
+ { id: "openai/gpt-5.4", name: "GPT 5.4", description: "Flagship model" },
+ { id: "openai/gpt-5.5", name: "GPT 5.5", description: "Latest flagship model" },
+ { id: "openai/gpt-5.2-codex", name: "GPT 5.2 Codex", description: "Optimized for code" },
+ { id: "openai/gpt-5.3-codex", name: "GPT 5.3 Codex", description: "Latest codex" },
+ {
+ id: "openai/gpt-5.3-codex-spark",
+ name: "GPT 5.3 Codex Spark",
+ description: "Low-latency codex variant",
+ },
+ ],
+ },
+ {
+ category: "OpenCode Zen",
+ models: [
+ { id: "opencode/kimi-k2.5", name: "Kimi K2.5", description: "Moonshot AI" },
+ { id: "opencode/minimax-m2.5", name: "MiniMax M2.5", description: "MiniMax" },
+ { id: "opencode/glm-5", name: "GLM 5", description: "Z.ai 744B MoE" },
+ ],
+ },
+];
+
+/**
+ * Models enabled by default when no preferences are stored.
+ * Excludes zen models which must be opted into via settings.
+ */
+export const DEFAULT_ENABLED_MODELS: ValidModel[] = [
+ "anthropic/claude-haiku-4-5",
+ "anthropic/claude-sonnet-4-5",
+ "anthropic/claude-sonnet-4-6",
+ "anthropic/claude-opus-4-5",
+ "anthropic/claude-opus-4-6",
+ "anthropic/claude-opus-4-7",
+ "openai/gpt-5.2",
+ "openai/gpt-5.4",
+ "openai/gpt-5.5",
+ "openai/gpt-5.2-codex",
+ "openai/gpt-5.3-codex",
+ "openai/gpt-5.3-codex-spark",
+];
+
+// === Normalization ===
+
+/**
+ * Normalize a model ID to canonical "provider/model" format.
+ * Adds "anthropic/" prefix to bare Claude model names and "openai/" prefix
+ * to bare GPT model names for backward compat with existing data in D1,
+ * SQLite, and Slack KV.
+ */
+export function normalizeModelId(modelId: string): string {
+ if (modelId.includes("/")) return modelId;
+ if (modelId.startsWith("claude-")) return `anthropic/${modelId}`;
+ if (modelId.startsWith("gpt-")) return `openai/${modelId}`;
+ return modelId;
+}
+
+// === Validation helpers ===
+
+/**
+ * Check if a model name is valid.
+ * Accepts both prefixed ("anthropic/claude-haiku-4-5") and bare ("claude-haiku-4-5") formats.
+ */
+export function isValidModel(model: string): model is ValidModel {
+ return VALID_MODELS.includes(normalizeModelId(model) as ValidModel);
+}
+
+/**
+ * Check if a model supports reasoning controls.
+ */
+export function supportsReasoning(model: string): boolean {
+ return getReasoningConfig(model) !== undefined;
+}
+
+/**
+ * Get reasoning configuration for a model, or undefined if not supported.
+ */
+export function getReasoningConfig(model: string): ModelReasoningConfig | undefined {
+ const normalized = normalizeModelId(model);
+ if (!isValidModel(normalized)) return undefined;
+ return MODEL_REASONING_CONFIG[normalized as ValidModel];
+}
+
+/**
+ * Get the default reasoning effort for a model, or undefined if not supported.
+ */
+export function getDefaultReasoningEffort(model: string): ReasoningEffort | undefined {
+ return getReasoningConfig(model)?.default;
+}
+
+/**
+ * Check if a reasoning effort is valid for a given model.
+ */
+export function isValidReasoningEffort(model: string, effort: string): boolean {
+ const config = getReasoningConfig(model);
+ if (!config) return false;
+ return config.efforts.includes(effort as ReasoningEffort);
+}
+
+/**
+ * Extract provider and model from a model ID.
+ *
+ * Normalizes bare Claude model names first, then splits on "/".
+ *
+ * @example
+ * extractProviderAndModel("anthropic/claude-haiku-4-5") // { provider: "anthropic", model: "claude-haiku-4-5" }
+ * extractProviderAndModel("claude-haiku-4-5") // { provider: "anthropic", model: "claude-haiku-4-5" }
+ * extractProviderAndModel("openai/gpt-5.2-codex") // { provider: "openai", model: "gpt-5.2-codex" }
+ */
+export function extractProviderAndModel(modelId: string): { provider: string; model: string } {
+ const normalized = normalizeModelId(modelId);
+ if (normalized.includes("/")) {
+ const [provider, ...modelParts] = normalized.split("/");
+ return { provider, model: modelParts.join("/") };
+ }
+ // Fallback for truly unknown models
+ return { provider: "anthropic", model: normalized };
+}
+
+/**
+ * Get a valid model or fall back to default.
+ * Accepts both prefixed and bare formats; always returns canonical prefixed format.
+ */
+export function getValidModelOrDefault(model: string | undefined | null): ValidModel {
+ if (model && isValidModel(model)) {
+ return normalizeModelId(model) as ValidModel;
+ }
+ return DEFAULT_MODEL;
+}
diff --git a/packages/protocol/src/triggers-conditions.ts b/packages/protocol/src/triggers-conditions.ts
new file mode 100644
index 0000000..b37487b
--- /dev/null
+++ b/packages/protocol/src/triggers-conditions.ts
@@ -0,0 +1,98 @@
+// VENDORED from background-agents@a7b968f3dfc7ff4d3d92fc158d57834c100e453c :: packages/shared/src/triggers/conditions.ts
+// Do not edit. Re-vendor via scripts/vendor-protocol.sh. Pinned: packages/protocol/PIN
+/**
+ * Condition system for trigger-based automations.
+ *
+ * Each condition type's shape is defined once in ConditionConfigMap.
+ * TypeScript derives the discriminated union and typed handler interfaces from it.
+ */
+
+import type { AutomationEvent, AutomationEventSource } from "./triggers-types";
+
+// ─── 1. ConditionConfigMap: single source of truth ───────────────────────────
+
+export interface ConditionConfigMap {
+ branch: { operator: "glob_match" | "exact"; value: string[] };
+ label: { operator: "any_of" | "none_of"; value: string[] };
+ path_glob: { operator: "any_match"; value: string[] };
+ actor: { operator: "include" | "exclude"; value: string[] };
+ check_conclusion: { operator: "eq"; value: string };
+ linear_status: { operator: "any_of"; value: string[] };
+ sentry_project: { operator: "any_of"; value: string[] };
+ sentry_level: { operator: "any_of"; value: string[] };
+ jsonpath: { operator: "all_match"; value: JsonPathFilter[] };
+}
+
+export interface JsonPathFilter {
+ path: string;
+ comparison: "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "contains" | "exists";
+ value?: string | number | boolean;
+}
+
+// ─── 2. Derived discriminated union ──────────────────────────────────────────
+
+export type TriggerCondition = {
+ [K in keyof ConditionConfigMap]: { type: K } & ConditionConfigMap[K];
+}[keyof ConditionConfigMap];
+
+// ─── 3. Typed handler interface ──────────────────────────────────────────────
+
+export type ConditionType = keyof ConditionConfigMap;
+
+type ConditionOf = Extract;
+
+export interface ConditionHandler {
+ /** Validate at automation creation time. Returns null if valid, error string otherwise. */
+ validate(condition: ConditionOf): string | null;
+
+ /** Evaluate at event matching time. Returns true if the condition passes. */
+ evaluate(condition: ConditionOf, event: AutomationEvent): boolean;
+
+ /** Which event sources this condition can be used with. */
+ appliesTo: AutomationEventSource[];
+}
+
+// ─── 4. Typed registry ───────────────────────────────────────────────────────
+
+export type ConditionRegistry = {
+ [K in ConditionType]: ConditionHandler;
+};
+
+// ─── 5. Dispatch ─────────────────────────────────────────────────────────────
+
+export function matchesConditions(
+ conditions: TriggerCondition[],
+ event: AutomationEvent,
+ registry: ConditionRegistry
+): boolean {
+ return conditions.every((condition) => {
+ const handler = registry[condition.type] as ConditionHandler;
+ return handler.evaluate(condition, event);
+ });
+}
+
+// ─── 6. Validation (called at automation creation time) ──────────────────────
+
+export function validateConditions(
+ conditions: TriggerCondition[],
+ triggerSource: AutomationEventSource,
+ registry: ConditionRegistry
+): string[] {
+ const errors: string[] = [];
+ for (const condition of conditions) {
+ const handler = registry[condition.type] as ConditionHandler;
+ if (!handler.appliesTo.includes(triggerSource)) {
+ errors.push(`Condition "${condition.type}" does not apply to ${triggerSource} triggers`);
+ continue;
+ }
+ const err = handler.validate(condition);
+ if (err) errors.push(err);
+ }
+ return errors;
+}
+
+// ─── 7. TriggerConfig (stored as JSON in D1) ────────────────────────────────
+
+export interface TriggerConfig {
+ conditions: TriggerCondition[];
+}
diff --git a/packages/protocol/src/triggers-types.ts b/packages/protocol/src/triggers-types.ts
new file mode 100644
index 0000000..eac37b8
--- /dev/null
+++ b/packages/protocol/src/triggers-types.ts
@@ -0,0 +1,119 @@
+// VENDORED from background-agents@a7b968f3dfc7ff4d3d92fc158d57834c100e453c :: packages/shared/src/triggers/types.ts
+// Do not edit. Re-vendor via scripts/vendor-protocol.sh. Pinned: packages/protocol/PIN
+/**
+ * Core types for the trigger-based automation event system.
+ */
+
+import type { AutomationTriggerType } from "./types";
+import type { ConditionType } from "./triggers-conditions";
+
+// ─── Event Sources ────────────────────────────────────────────────────────────
+
+export type AutomationEventSource = "github" | "linear" | "sentry" | "webhook";
+
+/**
+ * Maps AutomationTriggerType → AutomationEventSource.
+ * Used by control-plane validation and web UI condition builders.
+ */
+export const TRIGGER_TYPE_TO_SOURCE: Partial> =
+ {
+ github_event: "github",
+ linear_event: "linear",
+ sentry: "sentry",
+ webhook: "webhook",
+ };
+
+// ─── Base Event ───────────────────────────────────────────────────────────────
+
+interface BaseAutomationEvent {
+ /** Dot-delimited event type (e.g., "pull_request.opened", "issue.created"). */
+ eventType: string;
+
+ /** Trigger key for dedup and concurrency (e.g., "pr:42", "sentry_issue:12345"). */
+ triggerKey: string;
+
+ /** Concurrency key — the stable prefix of triggerKey for concurrency scoping. */
+ concurrencyKey: string;
+
+ /** Human-readable context block prepended to automation instructions. */
+ contextBlock: string;
+
+ /** Raw event metadata for logging/debugging. Not used for matching. */
+ meta: Record;
+}
+
+// ─── Source-Specific Variants ─────────────────────────────────────────────────
+
+export interface GitHubAutomationEvent extends BaseAutomationEvent {
+ source: "github";
+ repoOwner: string;
+ repoName: string;
+ branch?: string;
+ labels?: string[];
+ actor?: string;
+ changedFiles?: string[];
+ checkConclusion?: string;
+}
+
+export interface LinearAutomationEvent extends BaseAutomationEvent {
+ source: "linear";
+ repoOwner: string;
+ repoName: string;
+ actor?: string;
+ labels?: string[];
+ linearStatus?: string;
+}
+
+export interface SentryAutomationEvent extends BaseAutomationEvent {
+ source: "sentry";
+ automationId: string;
+ sentryProject: string;
+ sentryLevel: string;
+ culpritFile?: string;
+}
+
+export interface WebhookAutomationEvent extends BaseAutomationEvent {
+ source: "webhook";
+ automationId: string;
+ body: unknown;
+}
+
+// ─── Discriminated Union ──────────────────────────────────────────────────────
+
+export type AutomationEvent =
+ | GitHubAutomationEvent
+ | LinearAutomationEvent
+ | SentryAutomationEvent
+ | WebhookAutomationEvent;
+
+// ─── Trigger Source Definition ────────────────────────────────────────────────
+
+export interface TriggerSourceDefinition {
+ /** Source identifier — must match a member of AutomationEventSource. */
+ source: AutomationEventSource;
+
+ /** The trigger_type value stored in D1. */
+ triggerType: AutomationTriggerType;
+
+ /** Human-readable name for the UI. */
+ displayName: string;
+
+ /** Short description shown in the trigger type selector. */
+ description: string;
+
+ /** Supported event types with UI metadata. */
+ eventTypes: Array<{
+ eventType: string;
+ displayName: string;
+ description: string;
+ }>;
+
+ /** Whether the UI should expose an event type selector for this trigger source. */
+ supportsEventTypes?: boolean;
+
+ /** Optional UI placeholder for the event type selector for this trigger source. */
+ eventTypePlaceholder?: string;
+
+ /** Condition types this source supports (keys into ConditionConfigMap). */
+ supportedConditions: ConditionType[];
+}
diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts
new file mode 100644
index 0000000..72ff846
--- /dev/null
+++ b/packages/protocol/src/types.ts
@@ -0,0 +1,779 @@
+// VENDORED from background-agents@a7b968f3dfc7ff4d3d92fc158d57834c100e453c :: packages/shared/src/types/index.ts
+// Do not edit. Re-vendor via scripts/vendor-protocol.sh. Pinned: packages/protocol/PIN
+/**
+ * Shared type definitions used across Open-Inspect packages.
+ */
+
+// Session states
+export type SessionStatus =
+ | "created"
+ | "active"
+ | "completed"
+ | "failed"
+ | "archived"
+ | "cancelled";
+export type SandboxStatus =
+ | "pending"
+ | "spawning"
+ | "connecting"
+ | "warming"
+ | "syncing"
+ | "ready"
+ | "running"
+ | "stale"
+ | "snapshotting"
+ | "stopped"
+ | "failed";
+export type GitSyncStatus = "pending" | "in_progress" | "completed" | "failed";
+export type MessageStatus = "pending" | "processing" | "completed" | "failed";
+export type MessageSource = "web" | "slack" | "linear" | "extension" | "github" | "automation";
+export type ArtifactType = "pr" | "screenshot" | "video" | "preview" | "branch";
+export type EventType =
+ | "heartbeat"
+ | "token"
+ | "tool_call"
+ | "step_start"
+ | "step_finish"
+ | "tool_result"
+ | "git_sync"
+ | "error"
+ | "execution_complete"
+ | "artifact"
+ | "push_complete"
+ | "push_error"
+ | "user_message";
+export type ParticipantRole = "owner" | "member";
+export type SpawnSource =
+ | "user"
+ | "agent"
+ | "automation"
+ | "github-bot"
+ | "linear-bot"
+ | "slack-bot";
+export type ConfidenceLevel = "high" | "medium" | "low";
+
+// Participant in a session
+export interface SessionParticipant {
+ id: string;
+ userId: string;
+ scmLogin: string | null;
+ scmName: string | null;
+ scmEmail: string | null;
+ role: ParticipantRole;
+}
+
+// Session state
+export interface Session {
+ id: string;
+ title: string | null;
+ repoOwner: string;
+ repoName: string;
+ baseBranch: string;
+ branchName: string | null;
+ baseSha: string | null;
+ currentSha: string | null;
+ opencodeSessionId: string | null;
+ status: SessionStatus;
+ parentSessionId: string | null;
+ spawnSource: SpawnSource;
+ spawnDepth: number;
+ createdAt: number;
+ updatedAt: number;
+}
+
+// Message in a session
+export interface SessionMessage {
+ id: string;
+ authorId: string;
+ content: string;
+ source: MessageSource;
+ attachments: Attachment[] | null;
+ status: MessageStatus;
+ createdAt: number;
+ startedAt: number | null;
+ completedAt: number | null;
+}
+
+// Attachment to a message
+export interface Attachment {
+ type: "file" | "image" | "url";
+ name: string;
+ url?: string;
+ content?: string;
+ mimeType?: string;
+}
+
+// Agent event
+export interface AgentEvent {
+ id: string;
+ type: EventType;
+ data: Record;
+ messageId: string | null;
+ createdAt: number;
+}
+
+// Artifact created by session
+export interface SessionArtifact {
+ id: string;
+ type: ArtifactType;
+ url: string | null;
+ metadata: Record | null;
+ createdAt: number;
+}
+
+/**
+ * Metadata stored on branch artifacts when PR creation falls back to manual flow.
+ */
+export interface ManualPullRequestArtifactMetadata {
+ mode: "manual_pr";
+ head: string;
+ base: string;
+ createPrUrl: string;
+ provider?: string;
+}
+
+/** Metadata stored on screenshot artifacts. */
+export interface ScreenshotArtifactMetadata {
+ /** R2 object key */
+ objectKey: string;
+ /** MIME type: image/png, image/jpeg, image/webp */
+ mimeType: "image/png" | "image/jpeg" | "image/webp";
+ /** File size in bytes */
+ sizeBytes: number;
+ /** Viewport dimensions at capture time */
+ viewport?: { width: number; height: number };
+ /** URL that was screenshotted */
+ sourceUrl?: string;
+ /** Whether this is a full-page screenshot */
+ fullPage?: boolean;
+ /** Whether element annotations are overlaid */
+ annotated?: boolean;
+ /** Caption or description provided by the agent */
+ caption?: string;
+}
+
+/** Metadata stored on video recording artifacts. */
+export interface VideoArtifactMetadata {
+ /** R2 object key */
+ objectKey: string;
+ /** MIME type for saved recordings. */
+ mimeType: "video/mp4";
+ /** File size in bytes */
+ sizeBytes: number;
+ /** Agent-provided title or description of the validation recording */
+ caption: string;
+ /** Recording duration in milliseconds */
+ durationMs: number;
+ /** Artifact creation time as epoch milliseconds */
+ createdAt: number;
+ /** Recording start time as epoch milliseconds */
+ recordingStartedAt: number;
+ /** Recording end time as epoch milliseconds */
+ recordingEndedAt: number;
+ /** Captured viewport dimensions */
+ dimensions: { width: number; height: number };
+ /** Whether recording stopped at the maximum duration */
+ truncated: boolean;
+ /** Recordings must not include audio */
+ hasAudio?: false;
+ /** Captured surface for v1 */
+ captureSurface?: "browser";
+ /** Artifact source */
+ source?: "agent";
+ /** URL at recording start */
+ sourceUrl?: string;
+ /** URL when recording stopped */
+ endUrl?: string;
+}
+
+// Pull request info
+export interface PullRequest {
+ number: number;
+ title: string;
+ body: string;
+ url: string;
+ state: "open" | "closed" | "merged" | "draft";
+ headRef: string;
+ baseRef: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+// Sandbox events (from Modal / control-plane synthesized)
+export type SandboxEvent =
+ | { type: "heartbeat"; sandboxId: string; status: string; timestamp: number }
+ | {
+ type: "token";
+ content: string;
+ messageId: string;
+ sandboxId: string;
+ timestamp: number;
+ }
+ | {
+ type: "tool_call";
+ tool: string;
+ args: Record;
+ callId: string;
+ status?: string;
+ output?: string;
+ messageId: string;
+ sandboxId: string;
+ timestamp: number;
+ }
+ | {
+ type: "step_start";
+ messageId: string;
+ sandboxId: string;
+ timestamp: number;
+ isSubtask?: boolean;
+ }
+ | {
+ type: "step_finish";
+ cost?: number;
+ tokens?: number;
+ reason?: string;
+ messageId: string;
+ sandboxId: string;
+ timestamp: number;
+ isSubtask?: boolean;
+ }
+ | {
+ type: "tool_result";
+ callId: string;
+ result: string;
+ error?: string;
+ messageId: string;
+ sandboxId: string;
+ timestamp: number;
+ }
+ | {
+ type: "git_sync";
+ status: GitSyncStatus;
+ sha?: string;
+ sandboxId: string;
+ timestamp: number;
+ }
+ | {
+ type: "error";
+ error: string;
+ messageId: string;
+ sandboxId: string;
+ timestamp: number;
+ }
+ | {
+ type: "execution_complete";
+ messageId: string;
+ success: boolean;
+ error?: string;
+ sandboxId: string;
+ timestamp: number;
+ }
+ | {
+ type: "artifact";
+ artifactType: string;
+ artifactId?: string;
+ url: string;
+ metadata?: Record;
+ messageId?: string;
+ sandboxId: string;
+ timestamp: number;
+ }
+ | {
+ type: "push_complete";
+ branchName: string;
+ sandboxId?: string;
+ timestamp: number;
+ }
+ | {
+ type: "push_error";
+ branchName: string;
+ error: string;
+ sandboxId?: string;
+ timestamp: number;
+ }
+ | {
+ type: "user_message";
+ content: string;
+ messageId: string;
+ timestamp: number;
+ author?: {
+ participantId: string;
+ name: string;
+ avatar?: string;
+ };
+ };
+
+// WebSocket message types
+export type ClientMessage =
+ | { type: "ping" }
+ | { type: "subscribe"; token: string; clientId: string }
+ | {
+ type: "prompt";
+ content: string;
+ model?: string;
+ reasoningEffort?: string;
+ attachments?: Attachment[];
+ }
+ | { type: "stop" }
+ | { type: "typing" }
+ | { type: "presence"; status: "active" | "idle"; cursor?: { line: number; file: string } }
+ | { type: "fetch_history"; cursor: { timestamp: number; id: string }; limit?: number };
+
+export type ServerMessage =
+ | { type: "pong"; timestamp: number }
+ | {
+ type: "subscribed";
+ sessionId: string;
+ state: SessionState;
+ artifacts: SessionArtifact[];
+ participantId: string;
+ participant?: { participantId: string; name: string; avatar?: string };
+ replay?: {
+ events: SandboxEvent[];
+ hasMore: boolean;
+ cursor: { timestamp: number; id: string } | null;
+ };
+ spawnError?: string | null;
+ }
+ | { type: "prompt_queued"; messageId: string; position: number }
+ | { type: "sandbox_event"; event: SandboxEvent }
+ | { type: "presence_sync"; participants: ParticipantPresence[] }
+ | { type: "presence_update"; participants: ParticipantPresence[] }
+ | { type: "presence_leave"; userId: string }
+ | { type: "sandbox_warming" }
+ | { type: "sandbox_spawning" }
+ | { type: "sandbox_status"; status: SandboxStatus }
+ | { type: "sandbox_ready" }
+ | { type: "sandbox_error"; error: string }
+ | { type: "artifact_created"; artifact: SessionArtifact }
+ | { type: "session_branch"; branchName: string }
+ | { type: "snapshot_saved"; imageId: string; reason: string }
+ | { type: "sandbox_restored"; message: string }
+ | { type: "sandbox_warning"; message: string }
+ | { type: "processing_status"; isProcessing: boolean }
+ | {
+ type: "history_page";
+ items: SandboxEvent[];
+ hasMore: boolean;
+ cursor: { timestamp: number; id: string } | null;
+ }
+ | { type: "session_status"; status: SessionStatus }
+ | { type: "session_title"; title: string }
+ | {
+ type: "child_session_update";
+ childSessionId: string;
+ status: SessionStatus;
+ title: string | null;
+ }
+ | { type: "code_server_info"; url: string; password: string }
+ | { type: "ttyd_info"; url: string; token: string }
+ | { type: "tunnel_urls"; urls: Record }
+ | { type: "error"; code: string; message: string };
+
+// Session state sent to clients
+export interface SessionState {
+ id: string;
+ title: string | null;
+ repoOwner: string;
+ repoName: string;
+ baseBranch: string;
+ branchName: string | null;
+ status: SessionStatus;
+ sandboxStatus: SandboxStatus;
+ messageCount: number;
+ createdAt: number;
+ model?: string;
+ reasoningEffort?: string;
+ isProcessing?: boolean;
+ parentSessionId?: string | null;
+ totalCost?: number;
+ codeServerUrl?: string | null;
+ codeServerPassword?: string | null;
+ tunnelUrls?: Record | null;
+ ttydUrl?: string | null;
+ ttydToken?: string | null;
+}
+
+// Participant presence info
+export interface ParticipantPresence {
+ participantId: string;
+ userId: string;
+ name: string;
+ avatar?: string;
+ status: "active" | "idle" | "away";
+ lastSeen: number;
+}
+
+// Repository types for GitHub App installation
+export interface InstallationRepository {
+ id: number;
+ owner: string;
+ name: string;
+ fullName: string;
+ description: string | null;
+ private: boolean;
+ defaultBranch: string;
+ language?: string | null;
+ topics?: string[];
+}
+
+export interface RepoMetadata {
+ description?: string;
+ aliases?: string[];
+ channelAssociations?: string[];
+ keywords?: string[];
+}
+
+export interface EnrichedRepository extends InstallationRepository {
+ metadata?: RepoMetadata;
+}
+
+// Bot package shared types
+export interface RepoConfig {
+ id: string;
+ owner: string;
+ name: string;
+ fullName: string;
+ displayName: string;
+ description: string;
+ defaultBranch: string;
+ private: boolean;
+ language?: string | null;
+ topics?: string[];
+ aliases?: string[];
+ keywords?: string[];
+ channelAssociations?: string[];
+}
+
+export type ControlPlaneRepo = EnrichedRepository;
+
+export interface ControlPlaneReposResponse {
+ repos: ControlPlaneRepo[];
+ cached: boolean;
+ cachedAt: string;
+}
+
+export interface ClassificationResult {
+ repo: RepoConfig | null;
+ confidence: ConfidenceLevel;
+ reasoning: string;
+ alternatives?: RepoConfig[];
+ needsClarification: boolean;
+}
+
+export interface EventResponse {
+ id: string;
+ type: EventType;
+ data: Record;
+ messageId: string | null;
+ createdAt: number;
+}
+
+export interface ListEventsResponse {
+ events: EventResponse[];
+ cursor?: string;
+ hasMore: boolean;
+}
+
+export interface ArtifactResponse {
+ id: string;
+ type: ArtifactType;
+ url: string | null;
+ metadata: Record | null;
+ createdAt: number;
+}
+
+export interface ListArtifactsResponse {
+ artifacts: ArtifactResponse[];
+}
+
+export interface ToolCallSummary {
+ tool: string;
+ summary: string;
+}
+
+export interface ArtifactInfo {
+ type: ArtifactType;
+ url: string;
+ label: string;
+ metadata?: Record | null;
+}
+
+export interface AgentResponse {
+ textContent: string;
+ toolCalls: ToolCallSummary[];
+ artifacts: ArtifactInfo[];
+ success: boolean;
+ error?: string;
+}
+
+export interface UserPreferences {
+ userId: string;
+ model: string;
+ reasoningEffort?: string;
+ branch?: string;
+ updatedAt: number;
+}
+
+export interface Logger {
+ debug(msg: string, data?: Record): void;
+ info(msg: string, data?: Record): void;
+ warn(msg: string, data?: Record): void;
+ error(msg: string, data?: Record): void;
+ child(context: Record): Logger;
+}
+
+// ─── Callback Context (discriminated union) ──────────────────────────────────
+
+export interface SlackCallbackContext {
+ source: "slack";
+ channel: string;
+ threadTs: string;
+ repoFullName: string;
+ model: string;
+ reasoningEffort?: string;
+ reactionMessageTs?: string;
+}
+
+export interface LinearCallbackContext {
+ source: "linear";
+ issueId: string;
+ issueIdentifier: string;
+ issueUrl: string;
+ repoFullName: string;
+ model: string;
+ agentSessionId?: string;
+ organizationId?: string;
+ emitToolProgressActivities?: boolean;
+}
+
+export interface AutomationCallbackContext {
+ source: "automation";
+ automationId: string;
+ runId: string;
+ automationName: string;
+}
+
+export type CallbackContext =
+ | SlackCallbackContext
+ | LinearCallbackContext
+ | AutomationCallbackContext;
+
+// API response types
+export interface CreateSessionRequest {
+ repoOwner: string;
+ repoName: string;
+ title?: string;
+ model?: string;
+ reasoningEffort?: string;
+ branch?: string;
+}
+
+export interface CreateSessionResponse {
+ sessionId: string;
+ status: SessionStatus;
+}
+
+export interface ListSessionsResponse {
+ sessions: Session[];
+ cursor?: string;
+ hasMore: boolean;
+}
+
+// --- Agent-spawned sub-sessions ---
+
+/** Request body for POST /sessions/:parentId/children */
+export interface SpawnChildSessionRequest {
+ title: string;
+ prompt: string;
+ repoOwner?: string;
+ repoName?: string;
+ model?: string;
+ reasoningEffort?: string;
+}
+
+/** Returned by parent DO's GET /internal/spawn-context */
+export interface SpawnContext {
+ repoOwner: string;
+ repoName: string;
+ repoId: number | null;
+ model: string;
+ reasoningEffort: string | null;
+ baseBranch: string | null;
+ owner: {
+ userId: string;
+ scmUserId: string | null;
+ scmLogin: string | null;
+ scmName: string | null;
+ scmEmail: string | null;
+ scmAccessTokenEncrypted: string | null;
+ scmRefreshTokenEncrypted: string | null;
+ scmTokenExpiresAt: number | null;
+ };
+}
+
+/** Returned by child DO's GET /internal/child-summary */
+export interface ChildSessionDetail {
+ session: {
+ id: string;
+ title: string;
+ status: SessionStatus;
+ repoOwner: string;
+ repoName: string;
+ branchName: string | null;
+ model: string;
+ createdAt: number;
+ updatedAt: number;
+ };
+ sandbox: { status: SandboxStatus } | null;
+ artifacts: Array<{ type: string; url: string; metadata: unknown }>;
+ recentEvents: Array<{ type: string; data: unknown; createdAt: number }>;
+}
+
+// ─── Analytics ───────────────────────────────────────────────────────────────
+
+export const ANALYTICS_DAYS = [7, 14, 30, 90] as const;
+export type AnalyticsDays = (typeof ANALYTICS_DAYS)[number];
+
+export const ANALYTICS_BREAKDOWN_BY = ["user", "repo"] as const;
+export type AnalyticsBreakdownBy = (typeof ANALYTICS_BREAKDOWN_BY)[number];
+
+export interface AnalyticsStatusBreakdown {
+ created: number;
+ active: number;
+ completed: number;
+ failed: number;
+ archived: number;
+ cancelled: number;
+}
+
+export interface AnalyticsSummaryResponse {
+ totalSessions: number;
+ activeUsers: number;
+ totalCost: number;
+ avgCost: number;
+ totalPrs: number;
+ statusBreakdown: AnalyticsStatusBreakdown;
+}
+
+export interface AnalyticsTimeseriesPoint {
+ date: string;
+ groups: Record;
+}
+
+export interface AnalyticsTimeseriesResponse {
+ series: AnalyticsTimeseriesPoint[];
+}
+
+export interface AnalyticsBreakdownEntry {
+ key: string;
+ displayName?: string;
+ sessions: number;
+ completed: number;
+ failed: number;
+ cancelled: number;
+ cost: number;
+ prs: number;
+ messageCount: number;
+ avgDuration: number;
+ lastActive: number;
+}
+
+export interface AnalyticsBreakdownResponse {
+ entries: AnalyticsBreakdownEntry[];
+}
+
+// ─── Automation Engine ────────────────────────────────────────────────────────
+
+export type AutomationTriggerType =
+ | "schedule"
+ | "github_event"
+ | "linear_event"
+ | "sentry"
+ | "webhook";
+
+export type AutomationRunStatus = "starting" | "running" | "completed" | "failed" | "skipped";
+
+// Re-export TriggerConfig for use in automation interfaces below
+import type { TriggerConfig } from "./triggers-conditions";
+
+export interface Automation {
+ id: string;
+ name: string;
+ repoOwner: string;
+ repoName: string;
+ baseBranch: string;
+ repoId: number | null;
+ instructions: string;
+ triggerType: AutomationTriggerType;
+ scheduleCron: string | null;
+ scheduleTz: string;
+ model: string;
+ reasoningEffort: string | null;
+ enabled: boolean;
+ nextRunAt: number | null;
+ consecutiveFailures: number;
+ createdBy: string;
+ createdAt: number;
+ updatedAt: number;
+ deletedAt: number | null;
+ eventType: string | null;
+ triggerConfig: TriggerConfig | null;
+}
+
+export interface CreateAutomationRequest {
+ name: string;
+ repoOwner: string;
+ repoName: string;
+ baseBranch?: string;
+ instructions: string;
+ triggerType?: AutomationTriggerType;
+ scheduleCron?: string;
+ scheduleTz?: string;
+ model?: string;
+ reasoningEffort?: string | null;
+ eventType?: string;
+ triggerConfig?: TriggerConfig;
+ sentryClientSecret?: string;
+}
+
+export interface UpdateAutomationRequest {
+ name?: string;
+ instructions?: string;
+ scheduleCron?: string;
+ scheduleTz?: string;
+ model?: string;
+ reasoningEffort?: string | null;
+ baseBranch?: string;
+ eventType?: string;
+ triggerConfig?: TriggerConfig;
+}
+
+export interface AutomationRun {
+ id: string;
+ automationId: string;
+ sessionId: string | null;
+ status: AutomationRunStatus;
+ skipReason: string | null;
+ failureReason: string | null;
+ scheduledAt: number;
+ startedAt: number | null;
+ completedAt: number | null;
+ createdAt: number;
+ sessionTitle: string | null;
+ artifactSummary: string | null;
+ triggerKey: string | null;
+ concurrencyKey: string | null;
+}
+
+export interface ListAutomationsResponse {
+ automations: Automation[];
+ total: number;
+}
+
+export interface ListAutomationRunsResponse {
+ runs: AutomationRun[];
+ total: number;
+}
+
+export * from "./integrations";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 19294c6..72497b4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,6 +32,9 @@ importers:
apps/mobile:
dependencies:
+ '@constructor/protocol':
+ specifier: workspace:*
+ version: link:../../packages/protocol
'@expo/ui':
specifier: ~55.0.16
version: 55.0.16(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
@@ -74,6 +77,9 @@ importers:
expo-image:
specifier: ~55.0.10
version: 55.0.10(expo@55.0.24)(react-native-web@0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ expo-insights:
+ specifier: ~55.0.17
+ version: 55.0.17(expo@55.0.24)
expo-linking:
specifier: ~55.0.15
version: 55.0.15(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
@@ -89,6 +95,9 @@ importers:
expo-splash-screen:
specifier: ~55.0.21
version: 55.0.21(expo@55.0.24)(typescript@5.9.3)
+ expo-sqlite:
+ specifier: ~55.0.16
+ version: 55.0.16(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
expo-status-bar:
specifier: ~55.0.6
version: 55.0.6(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
@@ -98,9 +107,15 @@ importers:
expo-system-ui:
specifier: ~55.0.18
version: 55.0.18(expo@55.0.24)(react-native-web@0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))
+ expo-updates:
+ specifier: ~55.0.22
+ version: 55.0.22(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
expo-web-browser:
specifier: ~55.0.16
version: 55.0.16(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))
+ punycode:
+ specifier: ^2.3.1
+ version: 2.3.1
react:
specifier: 19.2.0
version: 19.2.0
@@ -2136,6 +2151,9 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
+ arg@4.1.3:
+ resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
+
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
@@ -2156,6 +2174,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
+ await-lock@2.2.2:
+ resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==}
+
babel-jest@29.7.0:
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -2617,6 +2638,9 @@ packages:
peerDependencies:
expo: '*'
+ expo-eas-client@55.0.5:
+ resolution: {integrity: sha512-wRagCeSbSnSGVXgP7V+qiGfXzZ9hTVKWvKIOP7lwrX3MIEenNmNlO4D3RVC3aNU2GhmO3ZCZIIEre80KZoUUHA==}
+
expo-file-system@55.0.20:
resolution: {integrity: sha512-sBCHhNlCT3EiqCcE6xSbyvOLUAlKx7+p0qjo+c+UPyC/gMrXUdva99g25uptM+fEMwy2co25MUQQ0U0guQLOQA==}
peerDependencies:
@@ -2648,6 +2672,14 @@ packages:
react-native-web:
optional: true
+ expo-insights@55.0.17:
+ resolution: {integrity: sha512-X1uELdl4lP7+qs5ewtAPaFWrWa7Lp0Ltkq93skDc8fBVgl3aUqLrAdnz4UMyy5waM2JnVr0WtUqOIQW9+6e+jg==}
+ peerDependencies:
+ expo: '*'
+
+ expo-json-utils@55.0.2:
+ resolution: {integrity: sha512-QJMOZOPOG7CTnKcrdVaiummn2va1MCO56z++eyWkDv3GBRODldM6MFMDf/jTREWthFc2Nxo6TuyWRrEV9S6n/Q==}
+
expo-keep-awake@55.0.8:
resolution: {integrity: sha512-PfIpMfM+STOBwkR5XOE+yVtER86c44MD+W8QD8JxuO0sT9pF7Y1SJYakWlpvX8xsGA+bjKLxftm9403s9kQhKA==}
peerDependencies:
@@ -2660,6 +2692,11 @@ packages:
react: '*'
react-native: '*'
+ expo-manifests@55.0.17:
+ resolution: {integrity: sha512-vKZvFivX3usVJKfBODKQcFHso0g38zlGbRGqGAppz+il0zKvG6umpJ47OZbzLod7iJpjd+ZDD2AGuOxacixonA==}
+ peerDependencies:
+ expo: '*'
+
expo-modules-autolinking@55.0.22:
resolution: {integrity: sha512-13x32V0HMHJDjND4K/gU2lQIZNxYn5S5rFzujqHmnXvOO6WGrVVELpk/0p5FmBfeuQ7GGFsATbhazQk+FeukUw==}
hasBin: true
@@ -2730,12 +2767,22 @@ packages:
peerDependencies:
expo: '*'
+ expo-sqlite@55.0.16:
+ resolution: {integrity: sha512-v6EIL4ygqWt/+ZfI76jIIv+IIaU8PnWPNjkmIN95vEQgh0FrWqzwssqe5ffQmm79kIfqIPTtAgTdl8MuZv88gg==}
+ peerDependencies:
+ expo: '*'
+ react: '*'
+ react-native: '*'
+
expo-status-bar@55.0.6:
resolution: {integrity: sha512-ijOUptfdiqYt7rObZ6jrPQ8sE5YN/8MxKCIJx0b7TY4nGkSJxhPIxeoW4GXcXCA8mTQ9PiOHH/ThLZgRVZvUlQ==}
peerDependencies:
react: '*'
react-native: '*'
+ expo-structured-headers@55.0.2:
+ resolution: {integrity: sha512-KITovrWigTOtsII5hRQ9/3ydaNcxCux5g6O+eTPLyjnye9dpkDKl5GmCLVPVKIL/d7253OtbGtWMD4m0gha5pw==}
+
expo-symbols@55.0.8:
resolution: {integrity: sha512-Dg6BTu+fCWukdlh+3XYIr6NbqJWmK4aAQ6i6BInKnWU0ALuzVUJcMDq8Lk9bHok2hOh3OhzJqlCqEoBXPInIVQ==}
peerDependencies:
@@ -2754,6 +2801,19 @@ packages:
react-native-web:
optional: true
+ expo-updates-interface@55.1.6:
+ resolution: {integrity: sha512-evxNpagCkjT3lE6bGV570TFzRtKuIuLY8I37RYHoriXCJ+ZKCN1hbmklK29uAixya+BxGpeTI2K4FqYeJLvfrw==}
+ peerDependencies:
+ expo: '*'
+
+ expo-updates@55.0.22:
+ resolution: {integrity: sha512-xLprYCwHYLrH+rtI5yMHWWScv6vMRRRpc+JHGjkLTeaFKHt1Lo1Kk7RUSOgSd61uiWX3yvI9mLRypdJbRvD5Mw==}
+ hasBin: true
+ peerDependencies:
+ expo: '*'
+ react: '*'
+ react-native: '*'
+
expo-web-browser@55.0.16:
resolution: {integrity: sha512-eeGs3439ewO/Q56Pzg3qbAVZSE0oH/R7XW9VCXI59k0m78ZIYbBtPT4PMFL/+sBgRkXm546Lq/DFcJQPTOfXJg==}
peerDependencies:
@@ -3529,6 +3589,10 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
query-string@7.1.3:
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
engines: {node: '>=6'}
@@ -6399,6 +6463,8 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.2
+ arg@4.1.3: {}
+
arg@5.0.2: {}
argparse@1.0.10:
@@ -6415,6 +6481,8 @@ snapshots:
assertion-error@2.0.1: {}
+ await-lock@2.2.2: {}
+
babel-jest@29.7.0(@babel/core@7.29.0):
dependencies:
'@babel/core': 7.29.0
@@ -6950,6 +7018,8 @@ snapshots:
expo: 55.0.24(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
ua-parser-js: 0.7.41
+ expo-eas-client@55.0.5: {}
+
expo-file-system@55.0.20(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)):
dependencies:
expo: 55.0.24(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
@@ -6977,6 +7047,13 @@ snapshots:
optionalDependencies:
react-native-web: 0.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ expo-insights@55.0.17(expo@55.0.24):
+ dependencies:
+ expo: 55.0.24(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+ expo-eas-client: 55.0.5
+
+ expo-json-utils@55.0.2: {}
+
expo-keep-awake@55.0.8(expo@55.0.24)(react@19.2.0):
dependencies:
expo: 55.0.24(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
@@ -6992,6 +7069,11 @@ snapshots:
- expo
- supports-color
+ expo-manifests@55.0.17(expo@55.0.24):
+ dependencies:
+ expo: 55.0.24(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+ expo-json-utils: 55.0.2
+
expo-modules-autolinking@55.0.22(typescript@5.9.3):
dependencies:
'@expo/require-utils': 55.0.5(typescript@5.9.3)
@@ -7086,12 +7168,21 @@ snapshots:
- supports-color
- typescript
+ expo-sqlite@55.0.16(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
+ dependencies:
+ await-lock: 2.2.2
+ expo: 55.0.24(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+ react: 19.2.0
+ react-native: 0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)
+
expo-status-bar@55.0.6(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0
react-native: 0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)
react-native-is-edge-to-edge: 1.3.1(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
+ expo-structured-headers@55.0.2: {}
+
expo-symbols@55.0.8(expo-font@55.0.7)(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
dependencies:
'@expo-google-fonts/material-symbols': 0.4.37
@@ -7112,6 +7203,32 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ expo-updates-interface@55.1.6(expo@55.0.24):
+ dependencies:
+ expo: 55.0.24(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+
+ expo-updates@55.0.22(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
+ dependencies:
+ '@expo/code-signing-certificates': 0.0.6
+ '@expo/plist': 0.5.3
+ '@expo/spawn-async': 1.7.2
+ arg: 4.1.3
+ chalk: 4.1.2
+ debug: 4.4.3
+ expo: 55.0.24(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
+ expo-eas-client: 55.0.5
+ expo-manifests: 55.0.17(expo@55.0.24)
+ expo-structured-headers: 55.0.2
+ expo-updates-interface: 55.1.6(expo@55.0.24)
+ getenv: 2.0.0
+ glob: 13.0.6
+ ignore: 5.3.2
+ react: 19.2.0
+ react-native: 0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)
+ resolve-from: 5.0.0
+ transitivePeerDependencies:
+ - supports-color
+
expo-web-browser@55.0.16(expo@55.0.24)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)):
dependencies:
expo: 55.0.24(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.14)(react-dom@19.2.0(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
@@ -7979,6 +8096,8 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
+ punycode@2.3.1: {}
+
query-string@7.1.3:
dependencies:
decode-uri-component: 0.2.2
diff --git a/scripts/vendor-protocol.sh b/scripts/vendor-protocol.sh
new file mode 100644
index 0000000..4ae543d
--- /dev/null
+++ b/scripts/vendor-protocol.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Reproducible vendor of the pinned Open-Inspect protocol subset (PLAN-04).
+# Copies ONLY the pure, Hermes-safe type/model closure consumed by the apps.
+# Usage: scripts/vendor-protocol.sh [upstream-dir]
+set -euo pipefail
+
+SHA="${1:?usage: scripts/vendor-protocol.sh [upstream-dir]}"
+UP="${2:-.upstream/background-agents}"
+REPO="https://github.com/ColeMurray/background-agents"
+DST="packages/protocol/src"
+PINF="packages/protocol/PIN"
+
+# --- ensure upstream clone is present and at the requested commit ---
+if [ ! -d "$UP/.git" ]; then
+ git clone "$REPO" "$UP"
+fi
+if ! git -C "$UP" cat-file -e "${SHA}^{commit}" 2>/dev/null; then
+ git -C "$UP" fetch --unshallow origin 2>/dev/null || git -C "$UP" fetch origin
+fi
+git -C "$UP" checkout -q "$SHA"
+FULL="$(git -C "$UP" rev-parse HEAD)"
+S="$UP/packages/shared/src"
+
+mkdir -p "$DST"
+rm -f "$DST"/*.ts
+
+# --- copy with a VENDORED provenance header (exact upstream bytes preserved) ---
+hdr() { # $1 = dest file, $2 = upstream relpath under packages/shared/src
+ {
+ printf '// VENDORED from background-agents@%s :: packages/shared/src/%s\n' "$FULL" "$2"
+ printf '// Do not edit. Re-vendor via scripts/vendor-protocol.sh. Pinned: packages/protocol/PIN\n'
+ cat "$S/$2"
+ } > "$1"
+}
+hdr "$DST/types.ts" "types/index.ts"
+hdr "$DST/integrations.ts" "types/integrations.ts"
+hdr "$DST/triggers-conditions.ts" "triggers/conditions.ts"
+hdr "$DST/triggers-types.ts" "triggers/types.ts"
+hdr "$DST/models.ts" "models.ts"
+hdr "$DST/git.ts" "git.ts"
+
+# --- flatten the types/ and triggers/ subdirs into a flat src/ (rewrite imports) ---
+sed -i.bak 's#from "\.\./triggers/conditions"#from "./triggers-conditions"#' "$DST/types.ts"
+sed -i.bak 's#from "\./types"#from "./triggers-types"#' "$DST/triggers-conditions.ts"
+sed -i.bak -e 's#from "\.\./types"#from "./types"#' \
+ -e 's#from "\./conditions"#from "./triggers-conditions"#' "$DST/triggers-types.ts"
+rm -f "$DST"/*.bak
+
+# --- generated barrel (composition, not vendored verbatim) ---
+{
+ printf '// GENERATED by scripts/vendor-protocol.sh — do not edit. Pinned: packages/protocol/PIN\n'
+ printf 'export * from "./types";\n' # also re-exports ./integrations via its own export *
+ printf 'export * from "./models";\n'
+ printf 'export * from "./git";\n'
+} > "$DST/index.ts"
+
+# --- structured PIN: ref + deployment URL (URL preserved once a real one is set) ---
+URL="pending-deploy"
+if [ -f "$PINF" ]; then
+ EX="$(sed -n 's/^url: //p' "$PINF" 2>/dev/null || true)"
+ if [ -n "${EX:-}" ] && [ "$EX" != "pending-deploy" ]; then URL="$EX"; fi
+fi
+printf 'ref: background-agents@%s\nurl: %s\n' "$FULL" "$URL" > "$PINF"
+
+echo "vendored background-agents@$FULL"
+ls -1 "$DST"