Skip to content

Commit e7277e0

Browse files
committed
fix(mobile): make the Expo web preview actually render (self-healing dev server)
The in-panel mobile web preview never rendered. Four compounding causes, each fixed deterministically at the dev-server layer so it works regardless of how the model builds the app: 1. Scaffold nesting: `cp -a template dir` nests when the run-start mkdir has already created `dir` (dir/cheatcode-expo-template/), so the project had no package.json at its root and fell back to a bare create-expo-app missing expo-router + the web deps. Copy template CONTENTS and verify package.json. 2. Broken start command: the model starts its own dev server via start_dev_server with a hallucinated `npx expo start --web --port 5173 --no-dev-client` (invalid flag -> expo exits; hardcoded 5173 -> collides). Normalize any `expo start` command to the one canonical invocation on the project's allocated port. 3. Corrupted dependency tree: the model rewrites package.json mid-build, pruning Metro's deps (react-dom, react-native-web, expo-asset) and DOWNGRADING others (expo-router to a pre-SDK major -> `Font.resetServerContext is not a function`). Self-heal: re-merge the template's deps with the template's versions winning for shared packages, keeping app-added extras, and reinstall only on change. 4. SSR-behind-proxy: without web.output:"single" the web dev server renders per-request and does `new URL(req.url)` behind the preview proxy -> `TypeError: Invalid URL`. Pin app.json to a metro SPA on every start. The heal is embedded in the canonical command persisted to the process record, so it re-applies on restart and wake-from-idle too. Also stop telling the app-builder agent to "Run Metro" (the preview is managed) to cut redundant starts. Verified live: fresh mobile builds -> previewRunning:true, no deps error, no Invalid URL, Metro serving; app HTML renders.
1 parent 8adc62e commit e7277e0

3 files changed

Lines changed: 106 additions & 32 deletions

File tree

apps/agent-worker/src/durable-objects/agent-run-app-builder.ts

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -666,27 +666,42 @@ async function stopProjectPreview(
666666
}
667667
}
668668

669+
// Populate `dir` with the CONTENTS of a baked template (`src/.` copies dotfiles too), then verify a
670+
// package.json landed at the root. Returns false instead of throwing so callers fall back to a
671+
// generator only on a genuine failure. `dir` is a filesystem-safe /workspace/<slug> path (no shell
672+
// metacharacters), so the interpolation is safe; the whole script is shell-quoted by the exec layer.
673+
async function copyTemplateContents(
674+
sandbox: ProjectSandboxStub,
675+
templateDir: string,
676+
dir: string,
677+
): Promise<boolean> {
678+
const copied = await executeShellTerminal(
679+
{
680+
command: `mkdir -p ${dir} && cp -a ${templateDir}/. ${dir}/ && test -f ${dir}/package.json`,
681+
cwd: "/workspace",
682+
timeoutMs: 120_000,
683+
},
684+
{ sandbox },
685+
);
686+
return copied.success;
687+
}
688+
669689
async function scaffoldExpoApp(
670690
sandbox: ProjectSandboxStub,
671691
logger: AgentRunLogger,
672692
dir: string,
673693
): Promise<void> {
674-
try {
675-
await executeShellExec(
676-
{
677-
command: ["cp", "-a", "/home/node/cheatcode-expo-template", dir],
678-
cwd: "/workspace",
679-
timeoutMs: 120_000,
680-
},
681-
{ sandbox },
682-
);
694+
// Copy the template CONTENTS (`src/.` → `dst/`), never `cp -a src dst`: the latter nests as
695+
// `dst/cheatcode-expo-template/` when `dst` already exists (the run-start `mkdir -p` of the
696+
// workspace dir can win the race), silently yielding a project with no package.json at its root.
697+
// `test -f` verifies the layout so we only fall back to a bare create-expo-app — which lacks
698+
// expo-router and the web deps (react-dom/react-native-web) that `expo start --web` needs — on a
699+
// genuine copy failure, not on the nesting race.
700+
if (await copyTemplateContents(sandbox, "/home/node/cheatcode-expo-template", dir)) {
683701
logger.info("sandbox_expo_template_copied", { targetDir: dir });
684702
return;
685-
} catch (error) {
686-
logger.warn("sandbox_expo_template_copy_failed", {
687-
error: error instanceof Error ? error.message : "Unknown template copy error",
688-
});
689703
}
704+
logger.warn("sandbox_expo_template_copy_failed", { targetDir: dir });
690705

691706
await executeShellExec(
692707
{
@@ -712,22 +727,13 @@ async function scaffoldAppBuilder(
712727
logger: AgentRunLogger,
713728
dir: string,
714729
): Promise<void> {
715-
try {
716-
await executeShellExec(
717-
{
718-
command: ["cp", "-a", "/home/node/cheatcode-next-template", dir],
719-
cwd: "/workspace",
720-
timeoutMs: 120_000,
721-
},
722-
{ sandbox },
723-
);
730+
// Copy template CONTENTS into the project dir (see scaffoldExpoApp): `cp -a src dst` nests as
731+
// `dst/cheatcode-next-template/` when `dst` already exists, leaving no package.json at the root.
732+
if (await copyTemplateContents(sandbox, "/home/node/cheatcode-next-template", dir)) {
724733
logger.info("sandbox_next_template_copied", { targetDir: dir });
725734
return;
726-
} catch (error) {
727-
logger.warn("sandbox_next_template_copy_failed", {
728-
error: error instanceof Error ? error.message : "Unknown template copy error",
729-
});
730735
}
736+
logger.warn("sandbox_next_template_copy_failed", { targetDir: dir });
731737

732738
await executeShellExec(
733739
{

packages/agent-core/src/mastra/system-prompt.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,16 @@ Verify it in the browser: open the app's INTERNAL address in the sandbox's heade
218218

219219
const MOBILE_MODULE = `## Building the mobile app
220220
221-
Build the Expo Router screens for a polished, native-feeling app: real screens, real navigation, considered design — no lorem ipsum, no dead buttons. Run Metro and verify the app renders. The preview and Expo Go QR code are shown to the user automatically in the App panel — refer to them naturally, don't paste URLs.`;
221+
Build the Expo Router screens for a polished, native-feeling app: real screens, real navigation, considered design — no lorem ipsum, no dead buttons. Verify the app renders in the live preview. The preview and Expo Go QR code are shown to the user automatically in the App panel — refer to them naturally, don't paste URLs.`;
222+
223+
// Injected for app-builder / app-builder-mobile runs: their workspace is scaffolded and the dev
224+
// server is already running and managed BEFORE the agent's turn (see agent-run-app-builder), so any
225+
// server the model starts itself just fights the managed one for the project's port and breaks the
226+
// preview (e.g. a hallucinated `npx expo start --web --port 5173 --no-dev-client`). The general path
227+
// keeps WEB_MODULE's "start the dev server yourself" guidance; this note only applies here.
228+
const APP_BUILDER_PREVIEW_NOTE = `## Your preview is already running — do not start your own
229+
230+
This project is scaffolded and its dev server + live preview are ALREADY running and managed for you before your turn begins (for a mobile app that's Metro serving the app on web plus the Expo Go QR). Do NOT start, restart, or reconfigure the server yourself — no start_dev_server, \`expo start\`, \`npm run dev\`/\`web\`, or \`npx expo …\`: a second server fights the managed one for the project's port and breaks the preview. Just create and edit files in your workspace and the preview hot-reloads on save. Verify by opening the running app in the sandbox's headed Chromium at its INTERNAL localhost address; it's shown to the user automatically in the Computer/App panel — never paste the preview URL.`;
222231

223232
const DOCS_MODULE = `## Building documents & slides
224233
@@ -267,10 +276,10 @@ const DOMAIN_MODULES: Record<DomainKey, string> = {
267276
*/
268277
function selectDomainModules(projectMode?: string, taskMessage?: string): string[] {
269278
if (projectMode === "app-builder") {
270-
return [WEB_MODULE];
279+
return [WEB_MODULE, APP_BUILDER_PREVIEW_NOTE];
271280
}
272281
if (projectMode === "app-builder-mobile") {
273-
return [MOBILE_MODULE];
282+
return [MOBILE_MODULE, APP_BUILDER_PREVIEW_NOTE];
274283
}
275284
const domains = classifyDomains(taskMessage ?? "");
276285
return domains.length > 0 ? domains.map((domain) => DOMAIN_MODULES[domain]) : [GENERALIST_MODULE];

packages/tools-code/src/preview.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,21 @@ export async function executeStartDevServer(
5757
const cwd = runtimeContext.workspaceDir ?? parsedInput.cwd;
5858
const slug = deriveWorkspaceSlug(cwd);
5959
const name = `${APP_PREVIEW_SLOT_PREFIX}${slug}`;
60-
const port = await allocateDevServerPort(runtimeContext, slug, parsedInput.isMobile);
60+
// A mobile/Expo dev server has exactly one correct invocation: `expo start --web` on the project's
61+
// own allocated port. The model sometimes emits a broken variant via start_dev_server — a
62+
// hallucinated `--no-dev-client` that makes expo exit, or a hardcoded `--port 5173` that collides
63+
// with another project in the shared per-user sandbox — which lands in this project's preview slot
64+
// and leaves the panel blank. Normalize any `expo start` command (from the model OR the app-builder,
65+
// where it's a no-op) to the canonical form; web (Next/Vite) commands pass through untouched.
66+
const isExpo = isExpoStartCommand(parsedInput.command);
67+
const isMobile = isExpo || parsedInput.isMobile;
68+
const port = await allocateDevServerPort(runtimeContext, slug, isMobile);
69+
const command = isExpo ? expoWebCommand(port) : parsedInput.command;
6170
const process = await callSandboxMethod(runtimeContext.sandbox, "startProcess", {
62-
command: parsedInput.command,
71+
command,
6372
cwd,
6473
env: { ...parsedInput.env, PORT: String(port) },
65-
isMobile: parsedInput.isMobile,
74+
isMobile,
6675
keepAliveTimeoutMs: parsedInput.keepAliveTimeoutMs,
6776
maxRestarts: parsedInput.maxRestarts,
6877
processId: name,
@@ -89,6 +98,56 @@ export async function executeStartDevServer(
8998
});
9099
}
91100

101+
// An `expo start …` invocation, however the model spelled it (npx / pnpm exec / bare, any flags).
102+
function isExpoStartCommand(command: readonly string[]): boolean {
103+
return command.includes("expo") && command.includes("start");
104+
}
105+
106+
// Restore the curated Expo dependency tree onto the project's package.json. The model routinely
107+
// rewrites package.json mid-build — pruning packages Metro needs (react-dom, react-native-web,
108+
// expo-asset) AND downgrading others (e.g. expo-router to a pre-SDK major), which crashes
109+
// `expo start --web`. Re-merge the baked template's deps with the TEMPLATE's versions authoritative
110+
// for shared packages (fixes downgrades + restores removals) while keeping any extra packages the
111+
// app genuinely added. Exit 0 only when something changed, so the caller reinstalls only then —
112+
// keeping wake-from-idle (deps already correct) fast.
113+
const RESTORE_EXPO_DEPS_JS = [
114+
'const fs=require("fs");',
115+
'const t=require("/home/node/cheatcode-expo-template/package.json");',
116+
// Pin the web config to a client-rendered SPA on the Metro bundler. Without output:"single" the
117+
// Expo web dev server renders per-request and does `new URL(req.url)` behind the preview proxy,
118+
// which throws `TypeError: Invalid URL`; the model routinely drops this from app.json. Cheap file
119+
// write, applied every start (Metro reads it on boot), independent of the reinstall decision below.
120+
"try{",
121+
'const aj=process.cwd()+"/app.json";const a=require(aj);a.expo=a.expo||{};',
122+
'a.expo.web=Object.assign({},a.expo.web,{bundler:"metro",output:"single"});',
123+
'fs.writeFileSync(aj,JSON.stringify(a,null,2)+"\\n");',
124+
"}catch(e){}",
125+
'const j=process.cwd()+"/package.json";',
126+
"const p=require(j);",
127+
"const before=JSON.stringify([p.dependencies,p.devDependencies,p.main]);",
128+
"p.dependencies=Object.assign({},p.dependencies||{},t.dependencies);",
129+
"p.devDependencies=Object.assign({},p.devDependencies||{},t.devDependencies);",
130+
"p.main=t.main;",
131+
"const after=JSON.stringify([p.dependencies,p.devDependencies,p.main]);",
132+
"if(before===after){process.exit(1)}",
133+
'fs.writeFileSync(j,JSON.stringify(p,null,2)+"\\n");',
134+
].join("");
135+
136+
// The one canonical Expo dev-server command. It first self-heals the dependency tree (see above) and
137+
// reinstalls only if the merge changed anything — so a build that corrupted deps is repaired, while a
138+
// clean start / wake-from-idle skips straight to Metro. Because this is the command PERSISTED in the
139+
// process record, it re-heals on restart and wake too. Then Metro: `-c` clears its cache so a finished
140+
// app re-crawls cleanly; `--web` also answers exp:// manifests for the Expo Go QR; `--host lan` + the
141+
// project's allocated port keep it reachable and collision-free. `exec` hands the slot to Metro.
142+
function expoWebCommand(port: number): string[] {
143+
const restoreB64 = btoa(RESTORE_EXPO_DEPS_JS);
144+
const writeScript = `echo ${restoreB64} | base64 -d > /tmp/cc-restore-expo-deps.js`;
145+
const heal =
146+
"node /tmp/cc-restore-expo-deps.js && rm -f pnpm-lock.yaml package-lock.json && CI=1 EXPO_NO_TELEMETRY=1 pnpm install --prefer-offline";
147+
const startMetro = `exec pnpm exec expo start -c --web --host lan --port ${port}`;
148+
return ["sh", "-lc", `${writeScript}; (${heal}) ; ${startMetro}`];
149+
}
150+
92151
// The project's workspaceSlug = the last non-empty path segment of the cwd (/workspace/<slug>).
93152
// Every run has a project, so the forced cwd is always /workspace/<slug> and yields a slug here.
94153
function deriveWorkspaceSlug(cwd: string): string {

0 commit comments

Comments
 (0)