@@ -48,6 +48,10 @@ interface RunAppBuilderOptions {
4848 input : AgentRunAppBuilderInput ;
4949 logger : AgentRunLogger ;
5050 sandbox : ProjectSandboxStub ;
51+ // Harness workspace setup runs before the model streams; its progress is status
52+ // chrome (run stage / Computer panel), never visible answer prose. Anything the
53+ // model needs to know (e.g. the live preview URL) is handed to it as agentContextNote.
54+ setRunStage : ( stage : string ) => void ;
5155}
5256
5357export async function runAppBuilder (
@@ -67,38 +71,45 @@ export async function runAppBuilder(
6771 if ( shouldBootstrap && input . importRepoUrl ) {
6872 return importRepoWorkspace ( { ...options , repoUrl : input . importRepoUrl } ) ;
6973 }
70- await runTemplateAppBuilder ( { ...options , mobile, shouldBootstrap } ) ;
71- return { } ;
74+ return runTemplateAppBuilder ( { ...options , mobile, shouldBootstrap } ) ;
7275}
7376
7477async function runTemplateAppBuilder (
7578 options : RunAppBuilderOptions & { mobile : boolean ; shouldBootstrap : boolean } ,
76- ) : Promise < void > {
79+ ) : Promise < { agentContextNote : string } > {
7780 const { env, input, logger, mobile, sandbox } = options ;
7881 await prepareTemplateWorkspace ( options ) ;
7982 await clearBuildCache ( sandbox , mobile ) ;
8083 await snapshotAppBuilderWorkspace ( { env, input, logger, sandbox } ) ;
81- await startTemplatePreview ( options ) ;
84+ const preview = await startTemplatePreview ( options ) ;
85+ return { agentContextNote : templateContextNote ( mobile , preview ) } ;
86+ }
87+
88+ function templateContextNote (
89+ mobile : boolean ,
90+ preview : { previewUrl : string ; expoUrl : string | null } ,
91+ ) : string {
92+ const stack = mobile ? "Expo Router" : "Next.js" ;
93+ const web = mobile
94+ ? " The preview is the app rendered on web (react-native-web) inside a phone frame, so verify it there in the browser."
95+ : "" ;
96+ const expo = preview . expoUrl
97+ ? ` The user can also scan the Expo Go QR code shown beside the preview (${ preview . expoUrl } ) to run it on a real device.`
98+ : "" ;
99+ return `[context] A ${ stack } workspace is scaffolded in ${ APP_BUILDER_DIR } and its live preview is already running at ${ preview . previewUrl } . Build the user's app by editing files under ${ APP_BUILDER_DIR } ; the preview hot-reloads on save.${ web } ${ expo } ` ;
82100}
83101
84102async function prepareTemplateWorkspace (
85103 options : RunAppBuilderOptions & { mobile : boolean ; shouldBootstrap : boolean } ,
86104) : Promise < void > {
87- const { append, input, logger, mobile, sandbox, shouldBootstrap } = options ;
88- await append ( {
89- type : "text-delta" ,
90- id : "answer" ,
91- delta : mobile
92- ? "Preparing the Expo workspace and live preview...\n"
93- : "Preparing the Next.js workspace and live preview...\n" ,
94- } ) ;
105+ const { input, logger, mobile, sandbox, setRunStage, shouldBootstrap } = options ;
106+ setRunStage ( mobile ? "Preparing the Expo workspace." : "Preparing the Next.js workspace." ) ;
95107 if ( ! shouldBootstrap ) {
96- await append ( {
97- type : "text-delta" ,
98- id : "answer" ,
99- delta : "Using the restored app workspace for this follow-up...\n" ,
100- } ) ;
108+ setRunStage ( "Restoring the app workspace." ) ;
101109 await installAppBuilderDependencies ( sandbox , logger , mobile ) ;
110+ if ( mobile ) {
111+ await ensureExpoWebSupport ( sandbox , logger ) ;
112+ }
102113 return ;
103114 }
104115 await resetAppBuilderDirectory ( sandbox ) ;
@@ -108,41 +119,29 @@ async function prepareTemplateWorkspace(
108119 await scaffoldAppBuilder ( sandbox , logger ) ;
109120 }
110121 await installAppBuilderDependencies ( sandbox , logger , mobile ) ;
111- if ( ! mobile ) {
112- await append ( {
113- type : "text-delta" ,
114- id : "answer" ,
115- delta : "Seeding the app files before the agent customizes them...\n" ,
116- } ) ;
122+ if ( mobile ) {
123+ await ensureExpoWebSupport ( sandbox , logger ) ;
124+ } else {
125+ setRunStage ( "Seeding the starter files." ) ;
117126 await writeAppBuilderFiles ( input , sandbox ) ;
118127 }
119128}
120129
121130async function startTemplatePreview (
122131 options : RunAppBuilderOptions & { mobile : boolean } ,
123- ) : Promise < void > {
124- const { append, env, mobile, sandbox } = options ;
125- await append ( {
126- type : "text-delta" ,
127- id : "answer" ,
128- delta : "Starting the dev server and exposing the preview URL...\n" ,
129- } ) ;
132+ ) : Promise < { previewUrl : string ; expoUrl : string | null } > {
133+ const { append, env, mobile, sandbox, setRunStage } = options ;
134+ setRunStage ( "Starting the dev server." ) ;
130135 const preview = mobile
131136 ? await startExpoDevServer ( env , sandbox )
132137 : await startAppBuilderDevServer ( env , sandbox ) ;
133- const previewUrl = clientPreviewUrl ( preview . previewUrl , resolvePreviewHostname ( env ) ) ;
138+ const previewUrl = preview . previewUrl ;
134139 const expoUrl = mobile ? expoUrlFromPreview ( preview . previewUrl ) : null ;
135140 await append ( {
136141 type : "data-sandbox-status" ,
137142 data : { v : 1 , status : "ready" , previewUrl, ...( expoUrl ? { expoUrl } : { } ) } ,
138143 } ) ;
139- await append ( {
140- type : "text-delta" ,
141- id : "answer" ,
142- delta : expoUrl
143- ? `Preview is running:\n\n${ previewUrl } \n\nScan the QR code in the App panel with Expo Go to test on a real device (${ expoUrl } ).\n\nContinuing with the agent build in ${ APP_BUILDER_DIR } ...\n`
144- : `Preview is running:\n\n${ previewUrl } \n\nContinuing with the agent build in ${ APP_BUILDER_DIR } ...\n` ,
145- } ) ;
144+ return { previewUrl, expoUrl } ;
146145}
147146
148147// First import run only: clone the public GitHub repo over the empty workspace,
@@ -152,7 +151,7 @@ async function startTemplatePreview(
152151async function importRepoWorkspace (
153152 options : RunAppBuilderOptions & { repoUrl : string } ,
154153) : Promise < { agentContextNote : string } > {
155- const { append, env, input, logger, repoUrl, sandbox } = options ;
154+ const { append, env, input, logger, repoUrl, sandbox, setRunStage } = options ;
156155 const startedAt = Date . now ( ) ;
157156 const repoRef = parseGitHubRepo ( repoUrl ) ;
158157 if ( ! repoRef ) {
@@ -161,18 +160,14 @@ async function importRepoWorkspace(
161160 throw repoImportError ( "The import URL must be a public https github.com repository." ) ;
162161 }
163162 logger . info ( "repo_import_started" , { repoHost : repoRef . host , repoPath : repoRef . path } ) ;
164- await append ( {
165- type : "text-delta" ,
166- id : "answer" ,
167- delta : `Cloning ${ repoRef . path } into ${ APP_BUILDER_DIR } ...\n` ,
168- } ) ;
163+ setRunStage ( `Cloning ${ repoRef . path } .` ) ;
169164 await resetAppBuilderDirectory ( sandbox ) ;
170165 await cloneRepoOrThrow ( { env, input, logger, repoRef, repoUrl, sandbox } ) ;
171166 await markImportedWorkspace ( sandbox ) ;
172167 const installRan = await installImportedDependencies ( sandbox , logger ) ;
173168 await clearBuildCache ( sandbox , isMobileBuild ( input ) ) ;
174169 await snapshotAppBuilderWorkspace ( { env, input, logger, sandbox } ) ;
175- await emitImportReady ( append , repoUrl ) ;
170+ await emitImportReady ( append ) ;
176171 logger . info ( "repo_import_succeeded" , {
177172 durationMs : Date . now ( ) - startedAt ,
178173 installRan,
@@ -189,12 +184,8 @@ async function importRepoWorkspace(
189184async function restoreImportedWorkspace (
190185 options : RunAppBuilderOptions ,
191186) : Promise < { agentContextNote : string } > {
192- const { append, env, input, logger, sandbox } = options ;
193- await append ( {
194- type : "text-delta" ,
195- id : "answer" ,
196- delta : "Using the imported app workspace for this follow-up...\n" ,
197- } ) ;
187+ const { append, env, input, logger, sandbox, setRunStage } = options ;
188+ setRunStage ( "Restoring the imported workspace." ) ;
198189 await installImportedDependencies ( sandbox , logger ) ;
199190 await clearBuildCache ( sandbox , isMobileBuild ( input ) ) ;
200191 await snapshotAppBuilderWorkspace ( { env, input, logger, sandbox } ) ;
@@ -306,13 +297,10 @@ async function pathExists(sandbox: ProjectSandboxStub, path: string): Promise<bo
306297 return result . success ;
307298}
308299
309- async function emitImportReady ( append : AppendChunk , repoUrl : string ) : Promise < void > {
300+ async function emitImportReady ( append : AppendChunk ) : Promise < void > {
301+ // The import outcome is handed to the model via agentContextNote (importedContextNote),
302+ // so it narrates the next steps itself — only the sandbox-ready chrome is emitted here.
310303 await append ( { type : "data-sandbox-status" , data : { v : 1 , status : "ready" } } ) ;
311- await append ( {
312- type : "text-delta" ,
313- id : "answer" ,
314- delta : `Imported ${ repoUrl } . The agent will inspect the project and start the dev server.\n` ,
315- } ) ;
316304}
317305
318306function emitImportEvent (
@@ -424,11 +412,16 @@ async function startExpoDevServer(
424412) : Promise < { previewUrl : string } > {
425413 return executeStartDevServer (
426414 {
415+ // `--web` makes the single Metro dev server also serve the react-native-web
416+ // build as a real web page at `/` (iframe-renderable in the Computer panel),
417+ // while the SAME server keeps answering exp:// manifests for Expo Go — so we
418+ // get both the in-panel preview and the QR from one process on port 8081.
427419 command : [
428420 "pnpm" ,
429421 "exec" ,
430422 "expo" ,
431423 "start" ,
424+ "--web" ,
432425 "--host" ,
433426 "lan" ,
434427 "--port" ,
@@ -636,21 +629,116 @@ async function installAppBuilderDependencies(
636629 ) ;
637630}
638631
639- function resolvePreviewHostname ( env : AgentRunAppBuilderEnv ) : string {
640- return PreviewHostnameSchema . parse ( env . PREVIEW_HOSTNAME ) ;
632+ // Expo web (react-native-web) is what makes `expo start --web` render a real page in
633+ // the Computer panel iframe. The default template ships react-dom + react-native-web
634+ // but NOT @expo /metro-runtime, and the Metro web bundler must be selected — so ensure
635+ // all three deps are present (SDK-matched via `expo install`) and pin web.bundler=metro.
636+ // Idempotent: the dep check short-circuits restores where they're already installed.
637+ async function ensureExpoWebSupport (
638+ sandbox : ProjectSandboxStub ,
639+ logger : AgentRunLogger ,
640+ ) : Promise < void > {
641+ const alreadyInstalled = await executeShellTerminal (
642+ {
643+ command :
644+ "test -d node_modules/react-native-web && test -d node_modules/react-dom && test -d node_modules/@expo/metro-runtime" ,
645+ cwd : APP_BUILDER_DIR ,
646+ timeoutMs : 10_000 ,
647+ } ,
648+ { sandbox } ,
649+ ) ;
650+ if ( ! alreadyInstalled . success ) {
651+ try {
652+ await executeShellExec (
653+ {
654+ command : [
655+ "pnpm" ,
656+ "exec" ,
657+ "expo" ,
658+ "install" ,
659+ "react-dom" ,
660+ "react-native-web" ,
661+ "@expo/metro-runtime" ,
662+ ] ,
663+ cwd : APP_BUILDER_DIR ,
664+ env : { CI : "1" , EXPO_NO_TELEMETRY : "1" } ,
665+ timeoutMs : 240_000 ,
666+ } ,
667+ { sandbox } ,
668+ ) ;
669+ logger . info ( "sandbox_expo_web_deps_installed" , { } ) ;
670+ } catch ( error ) {
671+ logger . warn ( "sandbox_expo_web_deps_failed" , {
672+ error : error instanceof Error ? error . message : "Unknown expo web dependency error" ,
673+ } ) ;
674+ }
675+ }
676+ // Force the Metro web bundler + single-page output for Expo Router web. `output:"single"`
677+ // serves a client-rendered SPA (one index.html) instead of per-request server rendering,
678+ // which does `new URL(req.url)` behind the proxy and throws. Best-effort: a no-op when the
679+ // project uses app.config.* instead of app.json. (The client-side base path is handled by
680+ // serving mobile previews under a clean subdomain URL — see buildPreviewUrl — because the
681+ // Expo dev server ignores experiments.baseUrl / EXPO_BASE_URL.)
682+ await executeShellExec (
683+ {
684+ command : [
685+ "node" ,
686+ "-e" ,
687+ 'const fs=require("fs");try{const j=JSON.parse(fs.readFileSync("app.json","utf8"));j.expo=j.expo||{};j.expo.web={...(j.expo.web||{}),bundler:"metro",output:"single"};fs.writeFileSync("app.json",JSON.stringify(j,null,2));}catch(e){}' ,
688+ ] ,
689+ cwd : APP_BUILDER_DIR ,
690+ timeoutMs : 15_000 ,
691+ } ,
692+ { sandbox } ,
693+ ) ;
694+ await ensureMetroForwardedHostFix ( sandbox ) ;
695+ }
696+
697+ // The preview proxy chain (gateway → Daytona's multi-hop edge) delivers `X-Forwarded-Host` to
698+ // the sandbox as a COMMA-SEPARATED LIST (e.g. "gateway.trycheatcode.com, 8081-<id>.daytonaproxy01.net").
699+ // Metro's Server._processRequest does `new URL(req.url, "http://" + xForwardedHost)`, and a
700+ // comma-list host is an invalid URL — so every `.bundle` request 500s ("TypeError: Invalid URL")
701+ // and the web preview renders blank. This can't be fixed upstream (the list is assembled inside
702+ // Daytona), so we normalise the header in Metro's own config via `enhanceMiddleware`, which runs
703+ // before `_processRequest`. Wraps any existing metro.config.js; idempotent via the marker grep.
704+ async function ensureMetroForwardedHostFix ( sandbox : ProjectSandboxStub ) : Promise < void > {
705+ const script = [
706+ 'if [ -f metro.config.js ] && grep -q "x-forwarded-host" metro.config.js; then exit 0; fi' ,
707+ "if [ -f metro.config.js ]; then mv metro.config.js metro.config.base.js; fi" ,
708+ "cat > metro.config.js <<'METROEOF'" ,
709+ METRO_FORWARDED_HOST_CONFIG ,
710+ "METROEOF" ,
711+ ] . join ( "\n" ) ;
712+ await executeShellExec (
713+ { command : [ "bash" , "-lc" , script ] , cwd : APP_BUILDER_DIR , timeoutMs : 15_000 } ,
714+ { sandbox } ,
715+ ) ;
641716}
642717
643- export function clientPreviewUrl ( previewUrl : string , previewHostname : string ) : string {
644- if ( previewHostname !== "localhost:8787" ) {
645- return previewUrl ;
646- }
647- const parsed = new URL ( previewUrl ) ;
648- if ( ! parsed . hostname . endsWith ( ".localhost" ) ) {
649- return previewUrl ;
650- }
651- const encodedHost = btoa ( parsed . host )
652- . replaceAll ( "+" , "-" )
653- . replaceAll ( "/" , "_" )
654- . replace ( / = + $ / , "" ) ;
655- return `http://localhost:8787/__sandbox/${ encodedHost } ${ parsed . pathname } ${ parsed . search } ${ parsed . hash } ` ;
718+ const METRO_FORWARDED_HOST_CONFIG = `// Cheatcode: normalise the comma-separated X-Forwarded-Host the preview proxy chain injects so
719+ // Metro's Server can parse the request URL. Wraps the project's base config (or Expo's default).
720+ let config;
721+ try {
722+ config = require("./metro.config.base.js");
723+ } catch (e) {
724+ config = require("expo/metro-config").getDefaultConfig(__dirname);
725+ }
726+ const baseEnhance = config.server && config.server.enhanceMiddleware;
727+ config.server = Object.assign({}, config.server, {
728+ enhanceMiddleware: (middleware, server) => {
729+ const inner = baseEnhance ? baseEnhance(middleware, server) : middleware;
730+ return (req, res, next) => {
731+ const xfh = req.headers["x-forwarded-host"];
732+ if (typeof xfh === "string" && xfh.indexOf(",") !== -1) {
733+ req.headers["x-forwarded-host"] = xfh.split(",")[0].trim();
734+ }
735+ return inner(req, res, next);
736+ };
737+ },
738+ });
739+ module.exports = config;
740+ ` ;
741+
742+ function resolvePreviewHostname ( env : AgentRunAppBuilderEnv ) : string {
743+ return PreviewHostnameSchema . parse ( env . PREVIEW_HOSTNAME ) ;
656744}
0 commit comments