diff --git a/CLAUDE.md b/CLAUDE.md
index 8f8d19648..d0d0b8bbc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2014,7 +2014,26 @@ tmux's `history-limit` keeps the raw value.
`fs.watch` of `
/lib` that deliberately OUTLIVES the node's view; (5) the terminal is hidden
by default (`runConfig.showTerminal`, toggled by ⋯ with the extra-args field): collapse's
`display: none` path via `.term-node:has(.run-bar--compact)` and a node height fitted to the
- rows; a run that ends without our Stop says where to look. No "bring simulator forward":
+ rows; a run that ends without our Stop says where to look; (6) **switching folders keeps the app
+ when it can** (`canHotSwitch`: a running Flutter run, same device, same flavor, Flutter on both
+ sides): the toolbar offers **Switch (keep app)** beside **Rebuild** — type `d` (flutter's own
+ detach: the tool exits, the app keeps running), `flutter attach` from the new folder (only the
+ flags attach accepts survive, `attachArgs`, MEASURED against `flutter attach --help` — attach has
+ no `--flavor`), wait until it prints its key help AFTER its own `▶ flutter attach` line
+ (`attachConnected`), then a hot RESTART — never a reload: a freshly attached tool only pushes
+ files changed after it connected (MEASURED live, Flutter 3.47 / iOS 27.1 simulator: a reload
+ after attaching from the other checkout "Reloaded 0 libraries" and the old code kept running; a
+ SIGUSR2 restart swapped it in 2.6 s, same app pid, no rebuild). **On an iOS simulator attach
+ needs `--debug-url`**: it finds an already-running app by mDNS, which the simulator barely
+ supports ("The Dart VM Service was not discovered after 30 seconds"), and the URL `flutter run`
+ printed is its DDS proxy, gone with the detached tool. `simulatorVmServiceUrl` reads the app's
+ own "The Dart VM service is listening on …" line from `simctl spawn log show` (~3 s) and
+ takes the newest one whose process is still alive (`parseVmServiceLog`); not found ⇒ the switch
+ is refused with Rebuild offered. Some Macs ship no registered `Simulator.app` (`open -a
+ Simulator` fails); booting falls back to the active Xcode's copy and otherwise runs headless. Flutter REFUSES a hand-passed `FLUTTER_APP_FLAVOR`, so after an
+ attach `appFlavor` is the pubspec default; an app whose `lib/` reads `appFlavor` is refused the
+ switch with that reason. Dart `"request": "attach"` configurations run as `flutter attach` too;
+ every other attach is still refused. No "bring simulator forward":
Simulator.app can only be activated as a whole (same-named simulators are told apart by id in
the dropdown). Add menus: **New view ▸ New run configuration**. Local projects only (disabled
with `RUN_SSH_HINT` in SSH projects; relay stub answers "managed on the host"); POSIX only
diff --git a/src/core/run-service.test.ts b/src/core/run-service.test.ts
index 7765ea0f6..fab9377e6 100644
--- a/src/core/run-service.test.ts
+++ b/src/core/run-service.test.ts
@@ -131,6 +131,33 @@ describe('run-service', () => {
expect(await startRun('../x', { projectDir: w, reloadOnSave: true })).toMatchObject({ ok: false })
})
+ it('switch mode writes a flutter attach launcher; refuses non-Flutter runs and apps that read appFlavor', async () => {
+ write('app/pubspec.yaml', PUBSPEC)
+ write('app/lib/main_dev.dart', 'void main() {}')
+ write('app/.vscode/launch.json', `{ "configurations": [
+ { "name": "Dev", "type": "dart", "request": "launch", "program": "lib/main_dev.dart", "args": ["--flavor", "dev", "--web-port=8080"] },
+ { "name": "Tool", "type": "node", "request": "launch", "program": "a.js" }
+ ] }`)
+ const app = path.join(root, 'app')
+ const r = await startRun('n8', { projectDir: app, launchConfig: 'Dev', deviceId: 'SIM-1', reloadOnSave: true }, { attach: true })
+ expect(r).toMatchObject({ ok: true, kind: 'process', hotReload: true })
+ const script = readFileSync(launcherPath('n8'), 'utf8')
+ expect(script).toContain(`'flutter' 'attach' '-t' 'lib/main_dev.dart' '-d' 'SIM-1' '--pid-file' '${flutterPidFile('n8')}'`)
+ expect(script).not.toContain('--flavor')
+ expect(script).not.toContain('--web-port')
+
+ expect(await startRun('n8', { projectDir: app, launchConfig: 'Tool', reloadOnSave: true }, { attach: true })).toMatchObject({
+ ok: false,
+ error: expect.stringMatching(/not a Flutter run/)
+ })
+
+ write('app/lib/flavor.dart', "import 'package:flutter/services.dart'; final f = appFlavor;")
+ expect(await startRun('n8', { projectDir: app, launchConfig: 'Dev', deviceId: 'SIM-1', reloadOnSave: true }, { attach: true })).toEqual({
+ ok: false,
+ error: 'This app reads appFlavor, which flutter attach cannot set — rebuild to switch.'
+ })
+ })
+
describe('on a Windows core', () => {
const real = Object.getOwnPropertyDescriptor(process, 'platform') as PropertyDescriptor
beforeEach(() => Object.defineProperty(process, 'platform', { value: 'win32' }))
diff --git a/src/core/run-service.ts b/src/core/run-service.ts
index 20f5246c3..d9372559d 100644
--- a/src/core/run-service.ts
+++ b/src/core/run-service.ts
@@ -19,6 +19,7 @@ import {
parseEnvFile,
parseLaunchFile,
parseTasksFile,
+ parseVmServiceLog,
planLaunch,
resolveEntry,
RUN_WINDOWS_REASON,
@@ -218,12 +219,63 @@ export async function bootSimulator(udid: unknown): Promise {
if (!/current state: Booted/i.test(String((e as { stderr?: string }).stderr ?? (e as Error).message))) return false
}
devicesCache = null
+ await openSimulatorApp()
+ return true
+}
+
+/**
+ * Show Simulator.app, best effort. `open -a Simulator` needs LaunchServices to know the app, and on
+ * a machine where it does not (MEASURED: a Mac whose Xcode ships no registered Simulator.app —
+ * "Unable to find application named 'Simulator'") the bundle inside the active Xcode is tried. A
+ * machine with neither runs its simulators headless, which is fine: the app runs either way.
+ */
+async function openSimulatorApp(): Promise {
try {
await run('/usr/bin/open', ['-a', 'Simulator'], { timeout: 15_000 })
+ return
} catch {
- /* booted either way; the window is a convenience */
+ /* try the active Xcode's own copy */
}
- return true
+ try {
+ const { stdout } = await run('/usr/bin/xcode-select', ['-p'], { timeout: 5_000 })
+ await run('/usr/bin/open', [path.join(stdout.trim(), 'Applications', 'Simulator.app')], { timeout: 15_000 })
+ } catch {
+ /* headless simulators — nothing to show */
+ }
+}
+
+/**
+ * The running Flutter app's own VM service URL on an iOS simulator — read from the simulator's
+ * log (see `parseVmServiceLog` for why attach needs it). The newest announcement whose process is
+ * still alive wins; an app that started long ago is found by widening the window once.
+ */
+export async function simulatorVmServiceUrl(udid: string): Promise {
+ if (process.platform !== 'darwin' || !SIMULATOR_UDID.test(udid)) return null
+ let alive: Set | null = null
+ try {
+ const { stdout } = await run('/usr/bin/xcrun', ['simctl', 'spawn', udid, 'launchctl', 'list'], { timeout: 15_000, maxBuffer: 8 * 1024 * 1024 })
+ alive = new Set(stdout.split('\n').map((l) => l.split('\t')[0]).filter((p) => /^\d+$/.test(p)))
+ } catch {
+ return null
+ }
+ for (const window of ['1h', '24h']) {
+ let text = ''
+ try {
+ text = (
+ await run(
+ '/usr/bin/xcrun',
+ ['simctl', 'spawn', udid, 'log', 'show', '--style', 'compact', '--last', window, '--predicate', 'eventMessage CONTAINS "Dart VM service is listening on"'],
+ { timeout: 90_000, maxBuffer: 16 * 1024 * 1024 }
+ )
+ ).stdout
+ } catch {
+ continue
+ }
+ const hits = parseVmServiceLog(text).reverse()
+ const live = hits.find((h) => alive?.has(h.pid))
+ if (live) return live.url
+ }
+ return null
}
// ─── Project discovery ───────────────────────────────────────────────────────────────────────
@@ -305,7 +357,43 @@ function referencedEnv(...values: unknown[]): string[] {
return names
}
-export async function startRun(nodeId: unknown, rawConfig: unknown): Promise {
+/**
+ * Does the app's own Dart code read `appFlavor`? `flutter attach` has no `--flavor`, and Flutter
+ * REFUSES a hand-passed `FLUTTER_APP_FLAVOR` define (flutter_command.dart,
+ * `_ensureReservedDartDefineIsUnset`), so after an attach + hot restart `appFlavor` falls back to
+ * the pubspec's default flavor — the wrong answer for an app that branches on it. Bounded scan of
+ * `lib/` (the app's code; packages are not the app's choice). A read failure answers "no".
+ */
+async function readsAppFlavor(dir: string): Promise {
+ const stack = [path.join(dir, 'lib')]
+ let files = 0
+ while (stack.length && files < 4000) {
+ const d = stack.pop() as string
+ let entries: import('node:fs').Dirent[]
+ try {
+ entries = await readdir(d, { withFileTypes: true })
+ } catch {
+ continue
+ }
+ for (const e of entries) {
+ const p = path.join(d, e.name)
+ if (e.isDirectory()) stack.push(p)
+ else if (e.name.endsWith('.dart')) {
+ files++
+ try {
+ if ((await readFile(p, 'utf8')).includes('appFlavor')) return true
+ } catch {
+ /* unreadable — not evidence */
+ }
+ }
+ }
+ }
+ return false
+}
+
+export async function startRun(nodeId: unknown, rawConfig: unknown, rawOpts?: unknown): Promise {
+ // `attach`: switch a running Flutter app to this folder without rebuilding it (see canHotSwitch).
+ const attach = !!rawOpts && typeof rawOpts === 'object' && (rawOpts as { attach?: unknown }).attach === true
if (!validNode(nodeId)) return { ok: false, error: 'Invalid node.' }
const config = normalizeRunConfig(rawConfig)
if (!config) return { ok: false, error: 'Choose a folder first.' }
@@ -331,7 +419,32 @@ export async function startRun(nodeId: unknown, rawConfig: unknown): Promise c.name === entry.name) as LaunchConfig
const tasks = await readTasks(dir)
@@ -349,7 +462,7 @@ export async function startRun(nodeId: unknown, rawConfig: unknown): Promise listDevices(refresh === true))
platform().handle(IPC.runBootDevice, (udid: unknown) => bootSimulator(udid))
platform().handle(IPC.runDiscover, (dir: unknown) => discoverProjects(dir))
- platform().handle(IPC.runStart, (nodeId: unknown, config: unknown) => startRun(nodeId, config))
+ platform().handle(IPC.runStart, (nodeId: unknown, config: unknown, opts: unknown) => startRun(nodeId, config, opts))
platform().handle(IPC.runStatus, (nodeId: unknown) => runStatus(nodeId))
platform().handle(IPC.runStop, (nodeId: unknown, force: unknown) => stopRun(nodeId, force === true))
platform().handle(IPC.runSignal, (nodeId: unknown, kind: unknown) => signalRun(nodeId, kind))
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 9cc7047cc..df707d48c 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -585,7 +585,7 @@ const api: NodeTerminalApi = {
devices: (refresh) => ipcRenderer.invoke(IPC.runDevices, refresh),
bootDevice: (udid) => ipcRenderer.invoke(IPC.runBootDevice, udid),
discoverProjects: (dir) => ipcRenderer.invoke(IPC.runDiscover, dir),
- start: (nodeId, config) => ipcRenderer.invoke(IPC.runStart, nodeId, config),
+ start: (nodeId, config, opts) => ipcRenderer.invoke(IPC.runStart, nodeId, config, opts),
status: (nodeId) => ipcRenderer.invoke(IPC.runStatus, nodeId),
stop: (nodeId, force) => ipcRenderer.invoke(IPC.runStop, nodeId, force),
signal: (nodeId, kind) => ipcRenderer.invoke(IPC.runSignal, nodeId, kind),
diff --git a/src/renderer/bridge/ws-bridge.ts b/src/renderer/bridge/ws-bridge.ts
index 61d027b31..58981073f 100644
--- a/src/renderer/bridge/ws-bridge.ts
+++ b/src/renderer/bridge/ws-bridge.ts
@@ -1122,7 +1122,7 @@ export function buildRunConfigApi(client: RpcClient): Pick client.request(IPC.runDevices, refresh) as Promise,
bootDevice: (udid) => client.request(IPC.runBootDevice, udid) as Promise,
discoverProjects: (dir) => client.request(IPC.runDiscover, dir) as Promise,
- start: (nodeId, config) => client.request(IPC.runStart, nodeId, config) as Promise,
+ start: (nodeId, config, opts) => client.request(IPC.runStart, nodeId, config, opts) as Promise,
status: (nodeId) => client.request(IPC.runStatus, nodeId) as Promise,
stop: (nodeId, force) => client.request(IPC.runStop, nodeId, force) as Promise,
signal: (nodeId, kind) => client.request(IPC.runSignal, nodeId, kind) as Promise,
diff --git a/src/renderer/nodes/RunBar.tsx b/src/renderer/nodes/RunBar.tsx
index 98cc2b2ab..dee18b6b6 100644
--- a/src/renderer/nodes/RunBar.tsx
+++ b/src/renderer/nodes/RunBar.tsx
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useReactFlow, useStore } from '@xyflow/react'
import {
+ attachConnected,
+ canHotSwitch,
deviceClaims,
resolveEntry,
runDirLabel,
@@ -9,6 +11,7 @@ import {
type RunDevice,
type RunEntriesResult,
type RunNodeConfig,
+ type RunSnapshot,
type RunStatus
} from '@shared/run-config'
import { isShellCommand } from '@shared/agents/pane'
@@ -38,7 +41,7 @@ const STATUS_POLL_MS = 2_000
const STOP_GRACE_MS = 5_000
const NOTE_MS = 6_000
-type Busy = 'booting' | 'starting' | 'stopping' | 'reloading' | 'restarting' | null
+type Busy = 'booting' | 'starting' | 'stopping' | 'reloading' | 'restarting' | 'switching' | null
const CHOOSE_FOLDER = '\u0000choose'
const NO_ENTRY = '\u0000none'
@@ -100,6 +103,8 @@ export function RunBar({ nodeId, config, autoStart }: Props) {
const cfgRef = useRef(config)
cfgRef.current = config
+ /** The entry the node resolves to now (kept current below), for callbacks and the status poll. */
+ const entryRef = useRef(undefined)
const running = !!status?.running
const say = useCallback((kind: 'error' | 'info', text: string) => setNote({ kind, text }), [])
@@ -155,6 +160,7 @@ export function RunBar({ nodeId, config, autoStart }: Props) {
[entries, config.launchConfig]
)
const wantsDevices = entries.some((e) => e.usesDevice)
+ entryRef.current = entry
const loadDevices = useCallback(
(refresh: boolean) => {
@@ -173,9 +179,18 @@ export function RunBar({ nodeId, config, autoStart }: Props) {
// Set while WE are stopping the run, so its end is not reported as unexpected.
const stoppingRef = useRef(false)
const lastRunningRef = useRef(null)
+ /** What the live run was started from — what a folder switch must match to keep the app.
+ * Set at Run; recovered from the node's config when the run was already going at mount (the
+ * config cannot have changed before this view existed to change it). Cleared when it ends. */
+ const runningRef = useRef(null)
const pollStatus = useCallback(async () => {
const s = await api.runConfig.status(nodeId)
if (!mounted.current) return s.running
+ if (!s.running && !stoppingRef.current) runningRef.current = null
+ if (s.running && !runningRef.current && entryRef.current) {
+ const c = cfgRef.current
+ runningRef.current = { projectDir: c.projectDir, deviceId: c.deviceId, flavor: entryRef.current.flavor, hotReload: entryRef.current.hotReload }
+ }
if (lastRunningRef.current === true && !s.running && !stoppingRef.current && !cfgRef.current.showTerminal) {
const how = s.exitCode === null || s.exitCode === 0 ? 'The run ended' : `The run exited with code ${s.exitCode}`
setNote({ kind: s.exitCode ? 'error' : 'info', text: `${how} — open ⋯ to see the terminal output.` })
@@ -284,6 +299,7 @@ export function RunBar({ nodeId, config, autoStart }: Props) {
await start({ launchConfig: first })
return
}
+ runningRef.current = { projectDir: cfg.projectDir, deviceId: cfg.deviceId, flavor: target?.flavor, hotReload: r.hotReload }
const sent = await api.pty.sendText(nodeId, r.command)
setNeedsRerun(false)
if (sent === 'pasted-not-submitted') say('info', 'The command is in the terminal — press Enter to start.')
@@ -334,6 +350,73 @@ export function RunBar({ nodeId, config, autoStart }: Props) {
await start()
}, [running, stop, start])
+ /**
+ * Switch the running Flutter app to the node's (new) folder WITHOUT rebuilding it:
+ * 1. `d` — flutter run/attach's own "detach": the tool exits, the app keeps running;
+ * 2. `flutter attach` from the new folder, to the same device;
+ * 3. once it has connected, a hot RESTART (SIGUSR2). Not a reload: a freshly attached tool only
+ * pushes files that change after it connected, so the new checkout's code would mostly not
+ * reach the app. A restart pushes the whole program — the app's state resets, nothing native
+ * is rebuilt.
+ * Every failure leaves the app running and says so; Rebuild stays one click away.
+ */
+ const switchKeepApp = useCallback(async () => {
+ const cfg = cfgRef.current
+ setBusy('switching')
+ stoppingRef.current = true
+ await api.pty.sendText(nodeId, 'd', { enter: false })
+ const detached = await waitStopped(10_000)
+ stoppingRef.current = false
+ if (!detached) {
+ setBusy(null)
+ say('error', 'The running tool did not detach — open ⋯ to check the terminal.')
+ return
+ }
+ const r = await api.runConfig.start(nodeId, cfg, { attach: true })
+ if (!r.ok || r.kind !== 'process') {
+ setBusy(null)
+ say('error', `${r.ok ? 'Could not attach.' : r.error} The app is still running on the device; Rebuild restarts it from this folder.`)
+ return
+ }
+ const sent = await api.pty.sendText(nodeId, r.command)
+ if (sent !== true) {
+ setBusy(null)
+ say('error', sent === 'pasted-not-submitted' ? 'The attach command is in the terminal — press Enter.' : 'Could not type into this terminal.')
+ return
+ }
+ // Wait for the attach to connect (it prints its key help once it has). Discovering the app
+ // can take a while on a busy simulator; an attach that exits early has failed.
+ let connected = false
+ let sawRunning = false
+ const deadline = Date.now() + 120_000
+ while (mounted.current && Date.now() < deadline) {
+ if (attachConnected(await api.pty.capture(nodeId))) {
+ connected = true
+ break
+ }
+ const alive = await pollStatus()
+ if (alive) sawRunning = true
+ else if (sawRunning) break
+ await sleep(700)
+ }
+ if (!mounted.current) return
+ if (!connected) {
+ setBusy(null)
+ say('error', 'flutter attach did not connect — open ⋯ to see why. Rebuild starts the app from this folder.')
+ return
+ }
+ const restarted = await api.runConfig.signal(nodeId, 'restart')
+ runningRef.current = { projectDir: cfg.projectDir, deviceId: cfg.deviceId, flavor: entryRef.current?.flavor, hotReload: true }
+ setNeedsRerun(false)
+ setBusy(null)
+ say(
+ restarted ? 'info' : 'error',
+ restarted
+ ? `Switched to ${runDirLabel(cfg.projectDir)} without rebuilding — hot restarted (the app's state was reset).`
+ : 'Attached, but the hot restart failed — press R in the terminal.'
+ )
+ }, [api, nodeId, waitStopped, pollStatus, say])
+
const signal = useCallback(
async (kind: 'reload' | 'restart') => {
setBusy(kind === 'reload' ? 'reloading' : 'restarting')
@@ -460,6 +543,9 @@ export function RunBar({ nodeId, config, autoStart }: Props) {
)
const browser = !!entry?.browser
+ // Folder/configuration changed under a running Flutter app that the new choice would build the
+ // same way: offer to switch without rebuilding (canHotSwitch states the rule).
+ const hotSwitch = canHotSwitch(runningRef.current, { config, entry })
const canRun = !!entry?.supported && !busy
const statusText =
busy === 'booting'
@@ -472,6 +558,8 @@ export function RunBar({ nodeId, config, autoStart }: Props) {
? 'Hot reload'
: busy === 'restarting'
? 'Hot restart'
+ : busy === 'switching'
+ ? 'Switching…'
: running
? 'Running'
: status?.exitCode
@@ -612,7 +700,29 @@ export function RunBar({ nodeId, config, autoStart }: Props) {
)
)}
- {needsRerun && running && (
+ {needsRerun && running && hotSwitch && (
+ <>
+
+
+ >
+ )}
+ {needsRerun && running && !hotSwitch && (