diff --git a/.config/mise/tasks/dev/desktop b/.config/mise/tasks/dev/desktop/_default similarity index 100% rename from .config/mise/tasks/dev/desktop rename to .config/mise/tasks/dev/desktop/_default diff --git a/.config/mise/tasks/dev/desktop/reset b/.config/mise/tasks/dev/desktop/reset new file mode 100755 index 000000000000..894c5856465e --- /dev/null +++ b/.config/mise/tasks/dev/desktop/reset @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +#MISE description="Stop this checkout's desktop dev runner and delete its Electron bundle so the next start rebuilds it" +#MISE depends=["dev:desktop:stop"] +set -euo pipefail +cd "$MISE_PROJECT_ROOT" +rm -rf apps/desktop/.electron-runtime +echo "[desktop-dev] removed apps/desktop/.electron-runtime" diff --git a/.config/mise/tasks/dev/desktop/stop b/.config/mise/tasks/dev/desktop/stop new file mode 100755 index 000000000000..a995c86097ff --- /dev/null +++ b/.config/mise/tasks/dev/desktop/stop @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +#MISE description="Stop this checkout's desktop dev runner" +set -euo pipefail +cd "$MISE_PROJECT_ROOT" +node apps/desktop/scripts/stop-dev-electron.mjs diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fded4eec9e41..10e383a7bacc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,6 +7,7 @@ "scripts": { "ensure:electron": "node scripts/ensure-electron-runtime.mjs", "start": "node scripts/start-electron.mjs", + "dev:stop": "node scripts/stop-dev-electron.mjs", "typecheck": "tsc-rs --noEmit", "test": "vp test run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 07578297f06d..8938763a3730 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -8,6 +8,8 @@ import { resolveDevProtocolClient, resolveElectronLaunchCommand, } from "./electron-launcher.mjs"; +import { acquireRunnerRecord, releaseRunnerRecord } from "./dev-runner-control.mjs"; +import { createCrashLoopBreaker } from "./dev-runner-guards.mjs"; import { waitForResources } from "./wait-for-resources.mjs"; const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); @@ -49,10 +51,31 @@ const watchedDirectories = [ const forcedShutdownTimeoutMs = 1_500; const restartDebounceMs = 120; const childTreeGracePeriodMs = 1_200; +const orphanCheckIntervalMs = 1_000; const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim(); // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. const hostPlatform = NodeOS.platform(); +const initialParentPid = process.ppid; + +// Two runners in one checkout rebuild the same launcher bundle concurrently and kill each +// other's Electron processes, so a new runner replaces the previous one. +const claim = await acquireRunnerRecord(desktopDir); +if (!claim.claimed) { + const reason = + claim.blocker.action === "survived" + ? "did not exit after SIGKILL" + : "may still own this checkout but its working directory could not be verified"; + console.error( + `[desktop-dev] runner ${claim.blocker.pid} ${reason}. ` + + "Stop it, or delete .electron-runtime/runner.json, then retry.", + ); + process.exit(1); +} +for (const stoppedPid of claim.stoppedPids) { + console.log(`[desktop-dev] stopped previous runner ${stoppedPid} for this checkout`); +} + NodeChildProcess.execFileSync( process.execPath, [NodePath.join(desktopDir, "scripts/build-browser-secret.mjs")], @@ -66,6 +89,11 @@ await waitForResources({ tcpPort: port, }); +if (process.ppid !== initialParentPid) { + releaseRunnerRecord(desktopDir); + process.exit(0); +} + const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; const devProtocolClient = resolveDevProtocolClient(); @@ -80,6 +108,8 @@ let currentApp = null; let restartQueue = Promise.resolve(); const expectedExits = new WeakSet(); const watchers = []; +const failedApps = new WeakSet(); +const crashLoopBreaker = createCrashLoopBreaker(); function killChildTreeByPid(pid, signal) { if (hostPlatform === "win32" || typeof pid !== "number") { @@ -129,9 +159,7 @@ function startApp() { currentApp = null; } - if (!shuttingDown) { - scheduleRestart(); - } + restartAfterFailure(app); }); app.once("exit", (code, signal) => { @@ -139,13 +167,30 @@ function startApp() { currentApp = null; } - const exitedAbnormally = signal !== null || code !== 0; - if (!shuttingDown && !expectedExits.has(app) && exitedAbnormally) { - scheduleRestart(); + if (signal !== null || code !== 0) { + restartAfterFailure(app); } }); } +// A spawn error can be followed by an exit event for the same child, so each child counts once. +function restartAfterFailure(app) { + if (shuttingDown || expectedExits.has(app) || failedApps.has(app)) { + return; + } + failedApps.add(app); + + if (crashLoopBreaker.recordAbnormalExit(Date.now()).tripped) { + console.error( + "[desktop-dev] Electron keeps crashing right after launch, so restarts are paused. " + + "Fix the problem and rebuild to try again, or run `mise run dev:desktop:reset` " + + "to rebuild the Electron bundle.", + ); + return; + } + scheduleRestart(); +} + async function stopApp() { const app = currentApp; if (!app) { @@ -217,6 +262,7 @@ function startWatchers() { return; } + crashLoopBreaker.rearm(); scheduleRestart(); }, ); @@ -248,16 +294,26 @@ async function shutdown(exitCode) { watcher.close(); } + clearInterval(orphanCheck); await stopApp(); killChildTree("TERM"); await new Promise((resolve) => { setTimeout(resolve, childTreeGracePeriodMs); }); killChildTree("KILL"); + // Released only after teardown, because the teardown kills every Electron tagged with this + // checkout and would take down a replacement runner's app. + releaseRunnerRecord(desktopDir); process.exit(exitCode); } +const orphanCheck = setInterval(() => { + if (process.ppid !== initialParentPid) { + void shutdown(0); + } +}, orphanCheckIntervalMs); + startWatchers(); cleanupStaleDevApps(); startApp(); diff --git a/apps/desktop/scripts/dev-runner-control.mjs b/apps/desktop/scripts/dev-runner-control.mjs new file mode 100644 index 000000000000..8474d0804fde --- /dev/null +++ b/apps/desktop/scripts/dev-runner-control.mjs @@ -0,0 +1,174 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { decideTakeover, parseRunnerRecord } from "./dev-runner-guards.mjs"; + +const stopGracePeriodMs = 4_000; +const stopPollIntervalMs = 100; +const maxClaimAttempts = 5; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime. +const hostPlatform = NodeOS.platform(); + +function runnerRecordPath(desktopDir) { + return NodePath.join(desktopDir, ".electron-runtime", "runner.json"); +} + +function readRunnerRecordText(desktopDir) { + try { + return NodeFS.readFileSync(runnerRecordPath(desktopDir), "utf8"); + } catch { + return null; + } +} + +function readRunnerRecord(desktopDir) { + const text = readRunnerRecordText(desktopDir); + return text === null ? null : parseRunnerRecord(text); +} + +function readProcessCwd(pid) { + if (hostPlatform === "linux") { + try { + return NodeFS.readlinkSync(`/proc/${pid}/cwd`); + } catch { + return null; + } + } + const result = NodeChildProcess.spawnSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { + encoding: "utf8", + }); + const line = result.stdout?.split("\n").find((entry) => entry.startsWith("n")); + return line ? line.slice(1) : null; +} + +function inspectProcess(pid) { + const result = NodeChildProcess.spawnSync("ps", ["-p", String(pid), "-o", "command="], { + encoding: "utf8", + }); + const command = result.stdout?.trim(); + if (result.status !== 0 || !command) { + return null; + } + return { command, cwd: readProcessCwd(pid) }; +} + +function isAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +async function waitForExit(pid, timeoutMs) { + for (let waited = 0; waited < timeoutMs; waited += stopPollIntervalMs) { + if (!isAlive(pid)) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, stopPollIntervalMs)); + } + return !isAlive(pid); +} + +function removeRunnerRecordIfUnchanged(desktopDir, text) { + if (readRunnerRecordText(desktopDir) === text) { + NodeFS.rmSync(runnerRecordPath(desktopDir), { force: true }); + } +} + +// The runner can exit on its own between inspection and each signal. +function signalIfAlive(pid, signal) { + try { + process.kill(pid, signal); + } catch (error) { + if (error?.code !== "ESRCH") { + throw error; + } + } +} + +async function terminate(pid) { + signalIfAlive(pid, "SIGTERM"); + if (!(await waitForExit(pid, stopGracePeriodMs))) { + signalIfAlive(pid, "SIGKILL"); + return waitForExit(pid, stopGracePeriodMs); + } + return true; +} + +async function clearRecordedRunner(desktopDir, selfPid) { + const text = readRunnerRecordText(desktopDir); + if (text === null) { + return { action: "none" }; + } + const record = parseRunnerRecord(text); + const decision = decideTakeover({ + record, + selfPid, + processInfo: record === null ? null : inspectProcess(record.pid), + desktopDir, + }); + if (decision.action === "unverifiable") { + return decision; + } + if (decision.action === "stop" && !(await terminate(decision.pid))) { + return { action: "survived", pid: decision.pid }; + } + removeRunnerRecordIfUnchanged(desktopDir, text); + return decision; +} + +export async function stopRecordedRunner(desktopDir) { + if (hostPlatform === "win32") { + return { action: "unsupported" }; + } + return clearRecordedRunner(desktopDir, 0); +} + +// The record is published with link() so it appears atomically with its full contents and two +// runners starting together cannot both believe they own the checkout. +export async function acquireRunnerRecord(desktopDir, pid = process.pid) { + const recordPath = runnerRecordPath(desktopDir); + const contents = `${JSON.stringify({ pid })}\n`; + NodeFS.mkdirSync(NodePath.dirname(recordPath), { recursive: true }); + if (hostPlatform === "win32") { + NodeFS.writeFileSync(recordPath, contents); + return { claimed: true, stoppedPids: [] }; + } + + const stagingPath = `${recordPath}.${pid}.tmp`; + NodeFS.writeFileSync(stagingPath, contents); + const stoppedPids = []; + try { + for (let attempt = 0; attempt < maxClaimAttempts; attempt += 1) { + try { + NodeFS.linkSync(stagingPath, recordPath); + return { claimed: true, stoppedPids }; + } catch (error) { + if (error?.code !== "EEXIST") { + throw error; + } + } + const decision = await clearRecordedRunner(desktopDir, pid); + if (decision.action === "unverifiable" || decision.action === "survived") { + return { claimed: false, blocker: decision }; + } + if (decision.action === "stop") { + stoppedPids.push(decision.pid); + } + } + throw new Error(`another runner keeps claiming ${recordPath}`); + } finally { + NodeFS.rmSync(stagingPath, { force: true }); + } +} + +export function releaseRunnerRecord(desktopDir, pid = process.pid) { + if (readRunnerRecord(desktopDir)?.pid === pid) { + NodeFS.rmSync(runnerRecordPath(desktopDir), { force: true }); + } +} diff --git a/apps/desktop/scripts/dev-runner-guards.mjs b/apps/desktop/scripts/dev-runner-guards.mjs new file mode 100644 index 000000000000..feec05dc04ec --- /dev/null +++ b/apps/desktop/scripts/dev-runner-guards.mjs @@ -0,0 +1,57 @@ +const RUNNER_SCRIPT_NAME = "dev-electron.mjs"; + +export function parseRunnerRecord(text) { + let parsed; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + const pid = parsed?.pid; + return Number.isInteger(pid) && pid > 1 ? { pid } : null; +} + +function isRunnerCommand(command) { + const [executable = "", ...args] = command.trim().split(/\s+/); + const script = args.find((arg) => !arg.startsWith("-")); + return ( + /^node(?:\.exe)?$/.test(executable.split(/[\\/]/).at(-1)) && + script !== undefined && + script.split(/[\\/]/).at(-1) === RUNNER_SCRIPT_NAME + ); +} + +export function isVerifiedRunner(processInfo, desktopDir) { + return ( + processInfo !== null && isRunnerCommand(processInfo.command) && processInfo.cwd === desktopDir + ); +} + +// `processInfo` is null when the pid is not alive. Only a verified runner is signaled: a pid +// running something else was reused, so its record is stale, while a runner whose working +// directory cannot be read may belong to this checkout and must be neither signaled nor replaced. +export function decideTakeover({ record, selfPid, processInfo, desktopDir }) { + if (record === null || record.pid === selfPid || processInfo === null) { + return { action: "none" }; + } + if (isVerifiedRunner(processInfo, desktopDir)) { + return { action: "stop", pid: record.pid }; + } + if (isRunnerCommand(processInfo.command) && processInfo.cwd === null) { + return { action: "unverifiable", pid: record.pid }; + } + return { action: "stale", pid: record.pid }; +} + +export function createCrashLoopBreaker({ maxAbnormalExits = 3, windowMs = 10_000 } = {}) { + let exitTimes = []; + return { + recordAbnormalExit(nowMs) { + exitTimes = [...exitTimes.filter((time) => nowMs - time < windowMs), nowMs]; + return { tripped: exitTimes.length >= maxAbnormalExits }; + }, + rearm() { + exitTimes = []; + }, + }; +} diff --git a/apps/desktop/scripts/dev-runner-guards.test.mjs b/apps/desktop/scripts/dev-runner-guards.test.mjs new file mode 100644 index 000000000000..3e75a05f4e93 --- /dev/null +++ b/apps/desktop/scripts/dev-runner-guards.test.mjs @@ -0,0 +1,94 @@ +import { assert, describe, it } from "vite-plus/test"; + +import { + createCrashLoopBreaker, + decideTakeover, + isVerifiedRunner, + parseRunnerRecord, +} from "./dev-runner-guards.mjs"; + +const desktopDir = "/work/a/apps/desktop"; +const runner = { command: "node scripts/dev-electron.mjs", cwd: desktopDir }; + +describe("runner record", () => { + it("accepts a pid and rejects malformed or unsafe records", () => { + assert.deepEqual(parseRunnerRecord('{"pid": 4242}'), { pid: 4242 }); + for (const text of ["", "not json", "{}", '{"pid":"7"}', '{"pid":1}', '{"pid":-3}']) { + assert.isNull(parseRunnerRecord(text)); + } + }); +}); + +describe("takeover decision", () => { + const decide = (overrides) => + decideTakeover({ + record: { pid: 100 }, + selfPid: 200, + processInfo: runner, + desktopDir, + ...overrides, + }); + + it("stops a live runner from the same checkout", () => { + assert.deepEqual(decide({}), { action: "stop", pid: 100 }); + }); + + it("does nothing without a record, for a dead pid, or for itself", () => { + assert.deepEqual(decide({ record: null }), { action: "none" }); + assert.deepEqual(decide({ processInfo: null }), { action: "none" }); + assert.deepEqual(decide({ selfPid: 100 }), { action: "none" }); + }); + + it("treats a pid reused by another process as a stale record", () => { + assert.deepEqual(decide({ processInfo: { command: "sleep 30", cwd: desktopDir } }), { + action: "stale", + pid: 100, + }); + assert.deepEqual(decide({ processInfo: { ...runner, cwd: "/work/b/apps/desktop" } }), { + action: "stale", + pid: 100, + }); + }); + + it("will not stop or replace a runner whose working directory is unknown", () => { + assert.deepEqual(decide({ processInfo: { ...runner, cwd: null } }), { + action: "unverifiable", + pid: 100, + }); + assert.isFalse(isVerifiedRunner({ ...runner, cwd: null }, desktopDir)); + }); + + it("matches the runner by its node entry script, not by a substring", () => { + const at = (command) => isVerifiedRunner({ command, cwd: desktopDir }, desktopDir); + assert.isTrue(at("/usr/local/bin/node --enable-source-maps scripts/dev-electron.mjs")); + assert.isFalse(at("tail -f scripts/dev-electron.mjs")); + assert.isFalse(at("node scripts/dev-electron.mjs.bak")); + }); +}); + +describe("crash loop breaker", () => { + it("trips on three abnormal exits inside the window", () => { + const breaker = createCrashLoopBreaker(); + assert.isFalse(breaker.recordAbnormalExit(0).tripped); + assert.isFalse(breaker.recordAbnormalExit(4_000).tripped); + assert.isTrue(breaker.recordAbnormalExit(9_000).tripped); + }); + + it("forgets exits that fall outside the window", () => { + const breaker = createCrashLoopBreaker(); + breaker.recordAbnormalExit(0); + breaker.recordAbnormalExit(5_000); + assert.isFalse(breaker.recordAbnormalExit(10_000).tripped); + assert.isFalse(breaker.recordAbnormalExit(30_000).tripped); + }); + + it("starts counting again after a rearm", () => { + const breaker = createCrashLoopBreaker(); + breaker.recordAbnormalExit(0); + breaker.recordAbnormalExit(1_000); + breaker.rearm(); + assert.isFalse(breaker.recordAbnormalExit(2_000).tripped); + assert.isFalse(breaker.recordAbnormalExit(3_000).tripped); + assert.isTrue(breaker.recordAbnormalExit(4_000).tripped); + }); +}); diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 0fb4377f07be..99bbcd26f892 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -13,12 +13,30 @@ const repoRoot = NodePath.resolve(desktopDir, "..", ".."); const devBundleIdSuffix = NodePath.basename(repoRoot) .toLowerCase() .replaceAll(/[^a-z0-9]+/g, ""); +export function resolveWorktreeLabel(folderName) { + return folderName.replace(/^t3code-(?:wt-)?/, "") || folderName; +} + +function isLinkedWorktree() { + try { + return NodeFS.statSync(NodePath.join(repoRoot, ".git")).isFile(); + } catch { + return false; + } +} + const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Beta)"; +// Only the Dock and app switcher label differs. The bundle path, executable names, bundle id and +// the app's own name and userData are derived from the fixed names above. +const APP_DOCK_NAME = + isDevelopment && isLinkedWorktree() + ? `T3 Code (Dev \u00b7 ${resolveWorktreeLabel(NodePath.basename(repoRoot))})` + : APP_DISPLAY_NAME; const APP_BUNDLE_ID = isDevelopment ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` : "com.t3tools.t3code"; const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; -const LAUNCHER_VERSION = 20; +const LAUNCHER_VERSION = 21; const developmentMacIconPngPath = NodePath.join( repoRoot, "assets", @@ -262,8 +280,8 @@ function ensureMacIconIcns(runtimeDir) { function resolveMacBundleInfoPlistStrings(executableName) { return { - CFBundleDisplayName: APP_DISPLAY_NAME, - CFBundleName: APP_DISPLAY_NAME, + CFBundleDisplayName: APP_DOCK_NAME, + CFBundleName: APP_DOCK_NAME, CFBundleIdentifier: APP_BUNDLE_ID, CFBundleExecutable: executableName, CFBundleIconFile: "icon.icns", @@ -361,6 +379,7 @@ function buildMacLauncher(electronBinaryPath) { sourceAppMtimeMs: NodeFS.statSync(sourceAppBundlePath).mtimeMs, iconMtimeMs: NodeFS.statSync(iconPath).mtimeMs, appBundleId: APP_BUNDLE_ID, + appDockName: APP_DOCK_NAME, appProtocolSchemes: APP_PROTOCOL_SCHEMES, }; diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index fa6e56b5800e..dd88bf2e47b5 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -12,6 +12,7 @@ import { makeDevelopmentLauncherScript, resolveElectronBinaryPath, resolveMacLauncherPaths, + resolveWorktreeLabel, writeDevelopmentLauncherScript, } from "./electron-launcher.mjs"; @@ -136,6 +137,14 @@ async function startupCleanupPattern(desktopDir) { NodeOS: { platform: () => "linux" }, NodePath, desktopDir, + acquireRunnerRecord: async () => ({ claimed: true, stoppedPids: [] }), + releaseRunnerRecord: () => undefined, + createCrashLoopBreaker: () => ({ + recordAbnormalExit: () => ({ tripped: false }), + rearm: () => undefined, + }), + setInterval: () => 0, + clearInterval: () => undefined, resolveDevProtocolClient: () => null, resolveElectronLaunchCommand: (args) => ({ electronPath: "electron", args }), waitForResources: async () => undefined, @@ -143,6 +152,7 @@ async function startupCleanupPattern(desktopDir) { process: { env: { VITE_DEV_SERVER_URL: "http://localhost:1234" }, execPath: "node", + ppid: 1, once: () => undefined, }, }, @@ -207,3 +217,12 @@ it ); } }); + +describe("worktree dock label", () => { + it("drops the checkout prefix and keeps the distinguishing name", () => { + assert.equal(resolveWorktreeLabel("t3code-wt-secret-management"), "secret-management"); + assert.equal(resolveWorktreeLabel("t3code-feature-x"), "feature-x"); + assert.equal(resolveWorktreeLabel("scratch"), "scratch"); + assert.equal(resolveWorktreeLabel("t3code-"), "t3code-"); + }); +}); diff --git a/apps/desktop/scripts/stop-dev-electron.mjs b/apps/desktop/scripts/stop-dev-electron.mjs new file mode 100644 index 000000000000..9f27552597c3 --- /dev/null +++ b/apps/desktop/scripts/stop-dev-electron.mjs @@ -0,0 +1,37 @@ +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { stopRecordedRunner } from "./dev-runner-control.mjs"; + +const desktopDir = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); + +const result = await stopRecordedRunner(desktopDir); +switch (result.action) { + case "stop": + console.log(`[desktop-dev] stopped runner ${result.pid} for this checkout`); + break; + case "stale": + console.log(`[desktop-dev] removed a stale runner.json for pid ${result.pid}`); + break; + case "none": + console.log("[desktop-dev] no runner is running for this checkout"); + break; + case "unverifiable": + console.error( + `[desktop-dev] runner ${result.pid} may own this checkout but its working directory could not be verified; stop it manually`, + ); + process.exitCode = 1; + break; + case "survived": + console.error( + `[desktop-dev] runner ${result.pid} did not exit after SIGKILL; stop it manually`, + ); + process.exitCode = 1; + break; + case "unsupported": + console.error( + "[desktop-dev] stopping the runner is not supported on Windows; stop it manually", + ); + process.exitCode = 1; + break; +} diff --git a/docs/operations/development.md b/docs/operations/development.md index bc4c0a58416b..58397a96cd6e 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -25,6 +25,12 @@ The server also loads the native resource monitor, which a fresh checkout has no `mise run dev:desktop` installs dependencies, builds `native/resource-monitor`, and then starts `vp run dev:desktop`. The tasks live in `.config/mise/tasks`. +The desktop runner keeps one instance per checkout: starting it stops a previous runner in the same +checkout, and a runner exits when its parent process dies. If Electron crashes repeatedly right +after launch, the runner pauses restarts until a watched file changes. `mise run dev:desktop:stop` +stops the runner for the current checkout, and `mise run dev:desktop:reset` also deletes +`apps/desktop/.electron-runtime` so the Electron bundle is rebuilt on the next start. + Flags go directly after the task name, for example `vp run dev --home-dir /tmp/t3code-dev`. Add `--browser` to open a browser automatically.